Skip to main content

adhammer_core/
scope.rs

1//! Engagement scope and no-cred assessment control-plane types.
2//!
3//! WS-FOUNDATION-INTEGRATE (1.4.10 foundation, capability in 1.5.0). These types describe what the operator
4//! is allowed to touch, what a check reports, and which capability a later
5//! phase may consume. Protocol-agnostic — the network implementation lives
6//! elsewhere.
7//!
8//! ## Excludes-win-across-identity (BF-3 fix)
9//!
10//! Prior draft only checked one identity form at a time: `contains_ip`
11//! walked IP-shaped excludes, `contains_hostname` walked hostname-shaped
12//! excludes. A target excluded by name could still be reached by IP (and
13//! vice versa). The [`EngagementScope::allows`] entry point now takes an
14//! optional IP + optional hostname pair and refuses if EITHER form matches
15//! ANY exclude — regardless of which axis the exclude was declared on.
16//! Runners that resolve DNS before scope-check pass both forms so an
17//! exclude on `dc01.corp.local` blocks the query for `10.0.0.10` too.
18
19use std::fmt;
20use std::net::IpAddr;
21
22use ipnet::IpNet;
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26/// A machine-readable assessment scope.
27///
28/// The operator provides an include list and an optional exclude list.
29/// Includes must be non-empty. Excludes always win over includes, across
30/// every identity form the caller can provide.
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32pub struct EngagementScope {
33    pub includes: Vec<ScopeTarget>,
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub excludes: Vec<ScopeTarget>,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub domain_hints: Vec<String>,
38}
39
40impl EngagementScope {
41    pub fn new(includes: Vec<ScopeTarget>) -> Result<Self, ScopeError> {
42        let scope = Self {
43            includes,
44            excludes: Vec::new(),
45            domain_hints: Vec::new(),
46        };
47        scope.validate()?;
48        Ok(scope)
49    }
50
51    pub fn validate(&self) -> Result<(), ScopeError> {
52        if self.includes.is_empty() {
53            return Err(ScopeError::EmptyIncludes);
54        }
55        for target in self.includes.iter().chain(self.excludes.iter()) {
56            target.validate()?;
57        }
58        for hint in &self.domain_hints {
59            if normalize_name(hint).is_none() {
60                return Err(ScopeError::InvalidDomainHint(hint.clone()));
61            }
62        }
63        Ok(())
64    }
65
66    /// Single-axis include check for an IP. See [`Self::allows`] for the
67    /// full excludes-win-across-identity contract; call `allows` unless you
68    /// deliberately want to bypass the cross-form exclude check (rare).
69    fn ip_in_includes(&self, ip: IpAddr) -> bool {
70        self.includes.iter().any(|target| target.matches_ip(ip))
71    }
72
73    /// Single-axis include check for a hostname.
74    fn hostname_in_includes(&self, hostname: &str) -> bool {
75        self.includes
76            .iter()
77            .any(|target| target.matches_hostname(hostname))
78    }
79
80    /// BF-3: exclude match on ANY provided identity form. If the caller
81    /// supplies both `ip` and `hostname`, either alone matching an exclude
82    /// vetoes the target.
83    fn any_exclude_matches(&self, ip: Option<IpAddr>, hostname: Option<&str>) -> bool {
84        if let Some(ip) = ip {
85            if self.excludes.iter().any(|target| target.matches_ip(ip)) {
86                return true;
87            }
88        }
89        if let Some(hostname) = hostname {
90            if self
91                .excludes
92                .iter()
93                .any(|target| target.matches_hostname(hostname))
94            {
95                return true;
96            }
97        }
98        false
99    }
100
101    /// BF-3 fix. Returns `true` iff:
102    ///   1. at least one of `ip` / `hostname` appears in the include list; AND
103    ///   2. NEITHER `ip` nor `hostname` appears in ANY exclude.
104    ///
105    /// Callers that only know one identity form supply `None` for the other;
106    /// the exclude check runs against only the known form. Runners that
107    /// resolve DNS before scope-check should call this with both forms so
108    /// an exclude on `dc01.corp.local` also blocks `10.0.0.10`.
109    pub fn allows(&self, ip: Option<IpAddr>, hostname: Option<&str>) -> bool {
110        let in_includes = match (ip, hostname) {
111            (None, None) => return false,
112            (Some(ip), None) => self.ip_in_includes(ip),
113            (None, Some(h)) => self.hostname_in_includes(h),
114            (Some(ip), Some(h)) => self.ip_in_includes(ip) || self.hostname_in_includes(h),
115        };
116        if !in_includes {
117            return false;
118        }
119        !self.any_exclude_matches(ip, hostname)
120    }
121
122    /// Convenience: allows-check for an IP-only target.
123    pub fn allows_ip(&self, ip: IpAddr) -> bool {
124        self.allows(Some(ip), None)
125    }
126
127    /// Convenience: allows-check for a hostname-only target.
128    pub fn allows_hostname(&self, hostname: &str) -> bool {
129        self.allows(None, Some(hostname))
130    }
131}
132
133/// One target shape allowed in an engagement scope.
134#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(tag = "kind", rename_all = "kebab-case")]
136pub enum ScopeTarget {
137    Host { addr: IpAddr },
138    Cidr { net: IpNet },
139    Hostname { name: String },
140}
141
142impl ScopeTarget {
143    pub fn validate(&self) -> Result<(), ScopeError> {
144        match self {
145            ScopeTarget::Host { .. } | ScopeTarget::Cidr { .. } => Ok(()),
146            ScopeTarget::Hostname { name } => {
147                if normalize_name(name).is_some() {
148                    Ok(())
149                } else {
150                    Err(ScopeError::InvalidHostname(name.clone()))
151                }
152            }
153        }
154    }
155
156    pub fn matches_ip(&self, ip: IpAddr) -> bool {
157        match self {
158            ScopeTarget::Host { addr } => *addr == ip,
159            ScopeTarget::Cidr { net } => net.contains(&ip),
160            ScopeTarget::Hostname { .. } => false,
161        }
162    }
163
164    pub fn matches_hostname(&self, hostname: &str) -> bool {
165        match self {
166            ScopeTarget::Hostname { name } => {
167                let lhs = normalize_name(name);
168                let rhs = normalize_name(hostname);
169                lhs.is_some() && lhs == rhs
170            }
171            ScopeTarget::Host { .. } | ScopeTarget::Cidr { .. } => false,
172        }
173    }
174}
175
176/// Stable check identifier for the black-box runner and report output.
177#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
178#[serde(transparent)]
179pub struct CheckId(String);
180
181impl CheckId {
182    pub fn new(value: impl Into<String>) -> Result<Self, ScopeError> {
183        let value = value.into();
184        if value.is_empty()
185            || !value
186                .bytes()
187                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
188        {
189            return Err(ScopeError::InvalidCheckId(value));
190        }
191        Ok(Self(value))
192    }
193
194    pub fn as_str(&self) -> &str {
195        &self.0
196    }
197}
198
199impl fmt::Display for CheckId {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        f.write_str(&self.0)
202    }
203}
204
205/// High-level class of a check for operator policy and reporting.
206#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "kebab-case")]
208pub enum CheckClass {
209    Discovery,
210    Impact,
211    PostCred,
212}
213
214/// Coarse result vocabulary for a single check execution.
215#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "kebab-case")]
217pub enum FindingStatus {
218    Found,
219    NotFound,
220    Blocked,
221    NotApplicable,
222    Error,
223}
224
225/// Opaque handle used to reference a secret without rendering the secret itself.
226#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
227#[serde(transparent)]
228pub struct SecretHandle(String);
229
230impl SecretHandle {
231    pub fn new(value: impl Into<String>) -> Result<Self, ScopeError> {
232        let value = value.into();
233        if value.is_empty() {
234            return Err(ScopeError::InvalidSecretHandle(value));
235        }
236        Ok(Self(value))
237    }
238
239    pub fn as_str(&self) -> &str {
240        &self.0
241    }
242}
243
244impl fmt::Display for SecretHandle {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        f.write_str(&self.0)
247    }
248}
249
250/// A capability recovered during a run that can unlock later checks.
251#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
252pub struct Capability {
253    pub kind: CapabilityKind,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub principal: Option<String>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub source: Option<CheckId>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub secret: Option<SecretHandle>,
260}
261
262/// Capability category, intentionally coarse for the first implementation pass.
263#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "kebab-case")]
265pub enum CapabilityKind {
266    AnonymousLdap,
267    SmbNullSession,
268    Password,
269    MachineAccount,
270    KerberosTicket,
271    Certificate,
272}
273
274/// A recommended next action emitted by a check or report.
275#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
276pub struct NextAction {
277    pub check: CheckId,
278    pub class: CheckClass,
279    pub summary: String,
280    #[serde(default)]
281    pub requires_consent: bool,
282}
283
284#[derive(Debug, Error, PartialEq, Eq)]
285pub enum ScopeError {
286    #[error("engagement scope must include at least one target")]
287    EmptyIncludes,
288    #[error("invalid hostname in scope: {0}")]
289    InvalidHostname(String),
290    #[error("invalid domain hint in scope: {0}")]
291    InvalidDomainHint(String),
292    #[error("invalid check id: {0}")]
293    InvalidCheckId(String),
294    #[error("invalid secret handle: {0}")]
295    InvalidSecretHandle(String),
296}
297
298fn normalize_name(name: &str) -> Option<String> {
299    let trimmed = name.trim().trim_end_matches('.');
300    if trimmed.is_empty() {
301        return None;
302    }
303    if !trimmed
304        .bytes()
305        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-' || byte == b'_')
306    {
307        return None;
308    }
309    if trimmed.starts_with('.') || trimmed.ends_with('.') || trimmed.contains("..") {
310        return None;
311    }
312    Some(trimmed.to_ascii_lowercase())
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use std::str::FromStr;
319
320    #[test]
321    fn empty_includes_rejected() {
322        let err = EngagementScope::new(Vec::new()).unwrap_err();
323        assert_eq!(err, ScopeError::EmptyIncludes);
324    }
325
326    #[test]
327    fn cidr_contains_ip() {
328        let scope = EngagementScope::new(vec![ScopeTarget::Cidr {
329            net: IpNet::from_str("10.0.0.0/24").unwrap(),
330        }])
331        .unwrap();
332        assert!(scope.allows_ip(IpAddr::from_str("10.0.0.42").unwrap()));
333        assert!(!scope.allows_ip(IpAddr::from_str("10.0.1.42").unwrap()));
334    }
335
336    #[test]
337    fn exclude_overrides_include() {
338        let scope = EngagementScope {
339            includes: vec![ScopeTarget::Cidr {
340                net: IpNet::from_str("10.0.0.0/24").unwrap(),
341            }],
342            excludes: vec![ScopeTarget::Host {
343                addr: IpAddr::from_str("10.0.0.42").unwrap(),
344            }],
345            domain_hints: Vec::new(),
346        };
347        scope.validate().unwrap();
348        assert!(!scope.allows_ip(IpAddr::from_str("10.0.0.42").unwrap()));
349        assert!(scope.allows_ip(IpAddr::from_str("10.0.0.43").unwrap()));
350    }
351
352    #[test]
353    fn hostname_matching_is_case_insensitive() {
354        let scope = EngagementScope::new(vec![ScopeTarget::Hostname {
355            name: "DC01.Corp.Local".into(),
356        }])
357        .unwrap();
358        assert!(scope.allows_hostname("dc01.corp.local"));
359        assert!(scope.allows_hostname("DC01.CORP.LOCAL."));
360        assert!(!scope.allows_hostname("dc02.corp.local"));
361    }
362
363    /// BF-3 regression. Prior draft: excluding a hostname did NOT block
364    /// the corresponding IP. `allows(Some(ip), Some(hostname))` must
365    /// refuse if EITHER the ip or the hostname is excluded.
366    #[test]
367    fn hostname_exclude_blocks_ip_lookup_via_allows() {
368        let scope = EngagementScope {
369            includes: vec![ScopeTarget::Cidr {
370                net: IpNet::from_str("10.0.0.0/24").unwrap(),
371            }],
372            excludes: vec![ScopeTarget::Hostname {
373                name: "dc01.corp.local".into(),
374            }],
375            domain_hints: Vec::new(),
376        };
377        scope.validate().unwrap();
378        let ip = IpAddr::from_str("10.0.0.10").unwrap();
379        // Ip-only lookup — no hostname context, so the hostname exclude
380        // cannot fire. Caller loses the cross-form protection when they
381        // pass only one axis; documented on `allows`.
382        assert!(scope.allows(Some(ip), None));
383        // Once the caller supplies BOTH forms, the hostname exclude wins.
384        assert!(!scope.allows(Some(ip), Some("dc01.corp.local")));
385    }
386
387    /// BF-3 regression: reverse of the above — an IP exclude blocks the
388    /// query even when the include shape is a hostname.
389    #[test]
390    fn ip_exclude_blocks_hostname_lookup_via_allows() {
391        let scope = EngagementScope {
392            includes: vec![ScopeTarget::Hostname {
393                name: "dc01.corp.local".into(),
394            }],
395            excludes: vec![ScopeTarget::Host {
396                addr: IpAddr::from_str("10.0.0.10").unwrap(),
397            }],
398            domain_hints: Vec::new(),
399        };
400        scope.validate().unwrap();
401        let ip = IpAddr::from_str("10.0.0.10").unwrap();
402        // Hostname-only lookup: passes (no ip context to hit the exclude).
403        assert!(scope.allows(None, Some("dc01.corp.local")));
404        // With ip context: exclude wins.
405        assert!(!scope.allows(Some(ip), Some("dc01.corp.local")));
406    }
407
408    #[test]
409    fn allows_with_no_identity_forms_returns_false() {
410        let scope = EngagementScope::new(vec![ScopeTarget::Host {
411            addr: IpAddr::from_str("10.0.0.1").unwrap(),
412        }])
413        .unwrap();
414        assert!(!scope.allows(None, None));
415    }
416
417    #[test]
418    fn scope_round_trips_through_json() {
419        let scope = EngagementScope {
420            includes: vec![
421                ScopeTarget::Cidr {
422                    net: IpNet::from_str("192.168.10.0/24").unwrap(),
423                },
424                ScopeTarget::Hostname {
425                    name: "dc01.lab.local".into(),
426                },
427            ],
428            excludes: vec![ScopeTarget::Host {
429                addr: IpAddr::from_str("192.168.10.1").unwrap(),
430            }],
431            domain_hints: vec!["lab.local".into()],
432        };
433        let json = serde_json::to_string(&scope).unwrap();
434        let decoded: EngagementScope = serde_json::from_str(&json).unwrap();
435        assert_eq!(decoded, scope);
436        assert!(decoded.allows_hostname("DC01.LAB.LOCAL"));
437    }
438
439    #[test]
440    fn invalid_names_rejected() {
441        let err = EngagementScope::new(vec![ScopeTarget::Hostname {
442            name: "bad host".into(),
443        }])
444        .unwrap_err();
445        assert_eq!(err, ScopeError::InvalidHostname("bad host".into()));
446
447        let scope = EngagementScope {
448            includes: vec![ScopeTarget::Host {
449                addr: IpAddr::from_str("127.0.0.1").unwrap(),
450            }],
451            excludes: Vec::new(),
452            domain_hints: vec!["corp local".into()],
453        };
454        let err = scope.validate().unwrap_err();
455        assert_eq!(err, ScopeError::InvalidDomainHint("corp local".into()));
456    }
457
458    #[test]
459    fn check_id_requires_lowercase_kebab_case() {
460        assert_eq!(CheckId::new("dns-enum").unwrap().as_str(), "dns-enum");
461        let err = CheckId::new("DnsEnum").unwrap_err();
462        assert_eq!(err, ScopeError::InvalidCheckId("DnsEnum".into()));
463    }
464
465    #[test]
466    fn secret_handle_must_not_be_empty() {
467        let err = SecretHandle::new("").unwrap_err();
468        assert_eq!(err, ScopeError::InvalidSecretHandle(String::new()));
469    }
470}