Skip to main content

adhammer_sdk/
blackbox.rs

1//! Black-box runner control-plane.
2//!
3//! WS-FOUNDATION-INTEGRATE (1.4.10 foundation, capability in 1.5.0). Small, protocol-agnostic surface that
4//! lets a runner:
5//!   - filter checks by an `only`/`skip` selection,
6//!   - refuse a check whose class violates the current consent posture
7//!     (BF-5 for PostCred, `allow_impact` for Impact),
8//!   - stop before touching a new host if `max_hosts` would be exceeded
9//!     (BF-4),
10//!   - report whether the wall-clock budget has been consumed
11//!     (BF-4 for `max_duration_secs`),
12//!   - record landed capabilities so a later PostCred check can proceed.
13//!
14//! DNS discovery uses the hand-rolled resolver (WS-FOUNDATION-DNS-HANDROLL,
15//! 1.5.0) — `dns_wire` codec + tokio UDP/TCP in `adhammer_collector::
16//! discovery`, no `hickory-resolver` dependency (D2 lock, docs/PLAN_1.5.0.md).
17//! `BlackBoxRunner::discover_dns` wraps it as a `Discovery`-class check
18//! that respects the selection + duration budget.
19
20use std::collections::HashSet;
21use std::net::IpAddr;
22use std::sync::Mutex;
23use std::time::Instant;
24
25use adhammer_core::{Capability, CheckClass, CheckId, EngagementScope, FindingStatus};
26
27/// Operator policy for a black-box assessment run.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct RunPolicy {
30    pub scope: EngagementScope,
31    pub consent: ConsentPolicy,
32    pub max_hosts: Option<usize>,
33    pub max_duration_secs: Option<u64>,
34}
35
36/// Consent flags that govern whether a runner may execute higher-risk checks.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
38pub struct ConsentPolicy {
39    pub allow_impact: bool,
40    /// Runners that broadcast-spoof (LLMNR/NBT-NS/DHCPv6 poisoners) must
41    /// query this flag directly before sending a poisoned response.
42    pub allow_spoof: bool,
43    pub interactive: bool,
44}
45
46/// Inclusive and exclusive check selectors.
47#[derive(Clone, Debug, PartialEq, Eq, Default)]
48pub struct CheckSelection {
49    pub only: Vec<CheckId>,
50    pub skip: Vec<CheckId>,
51}
52
53impl CheckSelection {
54    pub fn includes(&self, check: &CheckId) -> bool {
55        (self.only.is_empty() || self.only.iter().any(|candidate| candidate == check))
56            && !self.skip.iter().any(|candidate| candidate == check)
57    }
58}
59
60/// Compact rollup for a run.
61#[derive(Clone, Debug, PartialEq, Eq, Default)]
62pub struct RunSummary {
63    pub planned: usize,
64    pub completed: usize,
65    pub findings: usize,
66    pub blocked: usize,
67    pub errors: usize,
68}
69
70impl RunSummary {
71    pub fn record(&mut self, status: FindingStatus) {
72        self.completed += 1;
73        match status {
74            FindingStatus::Found => self.findings += 1,
75            FindingStatus::Blocked => self.blocked += 1,
76            FindingStatus::Error => self.errors += 1,
77            FindingStatus::NotFound | FindingStatus::NotApplicable => {}
78        }
79    }
80}
81
82/// Reason a runner refused a check. Distinct variants let a report render
83/// "why", not just "no".
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum RunnerRefusal {
86    NotInSelection,
87    ImpactRequiresConsent,
88    /// BF-5 (1.4.10): PostCred fired without a landed capability.
89    PostCredRequiresCapability,
90    /// BF-4 (1.4.10): host budget exhausted.
91    HostBudgetExhausted {
92        limit: usize,
93    },
94    /// BF-4 (1.4.10): wall-clock budget exhausted.
95    DurationBudgetExhausted {
96        limit_secs: u64,
97    },
98}
99
100impl std::fmt::Display for RunnerRefusal {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::NotInSelection => f.write_str("check not in operator selection"),
104            Self::ImpactRequiresConsent => {
105                f.write_str("impact-class check requires ConsentPolicy.allow_impact=true")
106            }
107            Self::PostCredRequiresCapability => {
108                f.write_str("post-cred check requires at least one landed capability (see BF-5)")
109            }
110            Self::HostBudgetExhausted { limit } => {
111                write!(f, "host budget exhausted (max_hosts={limit})")
112            }
113            Self::DurationBudgetExhausted { limit_secs } => {
114                write!(
115                    f,
116                    "duration budget exhausted (max_duration_secs={limit_secs})"
117                )
118            }
119        }
120    }
121}
122
123impl std::error::Error for RunnerRefusal {}
124
125/// Selection + policy + runtime accounting for a black-box run.
126///
127/// Not `Clone` — the interior `Mutex` around the touched-host set and the
128/// landed capabilities is per-run state. Wrap in `Arc` for concurrent use.
129pub struct BlackBoxRunner {
130    policy: RunPolicy,
131    selection: CheckSelection,
132    started_at: Instant,
133    touched_hosts: Mutex<HashSet<IpAddr>>,
134    landed_capabilities: Mutex<Vec<Capability>>,
135}
136
137impl BlackBoxRunner {
138    pub fn new(policy: RunPolicy, selection: CheckSelection) -> Self {
139        Self {
140            policy,
141            selection,
142            started_at: Instant::now(),
143            touched_hosts: Mutex::new(HashSet::new()),
144            landed_capabilities: Mutex::new(Vec::new()),
145        }
146    }
147
148    pub fn policy(&self) -> &RunPolicy {
149        &self.policy
150    }
151
152    /// Record that a check landed a capability (anonymous LDAP bind
153    /// succeeded, SMB null session opened, credential recovered, …). Later
154    /// PostCred-class checks may now proceed.
155    pub fn record_capability(&self, cap: Capability) {
156        self.landed_capabilities
157            .lock()
158            .expect("landed_capabilities mutex poisoned")
159            .push(cap);
160    }
161
162    /// Snapshot of landed capabilities (defensive clone — callers should
163    /// not hold the internal lock).
164    pub fn capabilities(&self) -> Vec<Capability> {
165        self.landed_capabilities
166            .lock()
167            .expect("landed_capabilities mutex poisoned")
168            .clone()
169    }
170
171    /// Elapsed wall-clock time within the budget cap? BF-4.
172    pub fn duration_within_budget(&self) -> bool {
173        match self.policy.max_duration_secs {
174            None => true,
175            Some(cap) => self.started_at.elapsed().as_secs() < cap,
176        }
177    }
178
179    /// BF-4 gate: register a first-touch of `ip`. Errors if the host would
180    /// push us over `max_hosts`. Repeat calls with the same `ip` are free
181    /// (already counted). Callers place this immediately before dialing.
182    pub fn start_host(&self, ip: IpAddr) -> Result<(), RunnerRefusal> {
183        let mut touched = self
184            .touched_hosts
185            .lock()
186            .expect("touched_hosts mutex poisoned");
187        if touched.contains(&ip) {
188            return Ok(());
189        }
190        if let Some(cap) = self.policy.max_hosts {
191            if touched.len() >= cap {
192                return Err(RunnerRefusal::HostBudgetExhausted { limit: cap });
193            }
194        }
195        touched.insert(ip);
196        Ok(())
197    }
198
199    /// Distinct hosts touched so far. Diagnostic only — the budget check
200    /// happens in `start_host`.
201    pub fn hosts_touched(&self) -> usize {
202        self.touched_hosts
203            .lock()
204            .expect("touched_hosts mutex poisoned")
205            .len()
206    }
207
208    /// BF-4 + BF-5 gate: return `Ok(())` if `check` at `class` may run
209    /// under the current policy AND runtime state; `Err(RunnerRefusal)`
210    /// otherwise. Does NOT register a host touch — call `start_host`
211    /// before the actual dial.
212    pub fn may_run(&self, check: &CheckId, class: CheckClass) -> Result<(), RunnerRefusal> {
213        if !self.selection.includes(check) {
214            return Err(RunnerRefusal::NotInSelection);
215        }
216        if !self.duration_within_budget() {
217            return Err(RunnerRefusal::DurationBudgetExhausted {
218                limit_secs: self.policy.max_duration_secs.unwrap_or(0),
219            });
220        }
221        match class {
222            CheckClass::Discovery => Ok(()),
223            CheckClass::Impact => {
224                if self.policy.consent.allow_impact {
225                    Ok(())
226                } else {
227                    Err(RunnerRefusal::ImpactRequiresConsent)
228                }
229            }
230            CheckClass::PostCred => {
231                // BF-5: a PostCred check requires SOMETHING already
232                // landed. Any capability suffices — the specific
233                // subclass check (does this capability actually reach
234                // this target?) is the caller's responsibility.
235                if self
236                    .landed_capabilities
237                    .lock()
238                    .expect("landed_capabilities mutex poisoned")
239                    .is_empty()
240                {
241                    Err(RunnerRefusal::PostCredRequiresCapability)
242                } else {
243                    Ok(())
244                }
245            }
246        }
247    }
248
249    /// Boolean sibling of `may_run` for hot-path filters where the reason
250    /// does not need surfacing. Prefer `may_run` when the caller wants to
251    /// log or report a refusal.
252    pub fn should_run(&self, check: &CheckId, class: CheckClass) -> bool {
253        self.may_run(check, class).is_ok()
254    }
255
256    /// WS-FOUNDATION-DNS-HANDROLL (1.5.0). Phase-0 discovery: resolve AD
257    /// SRV families for the scope's domain hints over the hand-rolled DNS
258    /// client. Gated as a `Discovery`-class `dns-enum` check — respects
259    /// the selection + duration budget. `nameservers` are the DNS servers
260    /// to query (port 53); pass `adhammer_collector::system_nameservers()`
261    /// or an operator-supplied list. Returns an empty vec when the check
262    /// is not selected or no nameservers are available.
263    pub async fn discover_dns(
264        &self,
265        nameservers: &[std::net::IpAddr],
266    ) -> anyhow::Result<Vec<adhammer_collector::DnsDiscovery>> {
267        let check = CheckId::new("dns-enum").expect("hard-coded dns-enum check id is valid");
268        if self.may_run(&check, CheckClass::Discovery).is_err() {
269            return Ok(Vec::new());
270        }
271        adhammer_collector::discover_dns(&self.policy.scope, nameservers).await
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use adhammer_core::{
278        Capability, CapabilityKind, CheckClass, CheckId, EngagementScope, FindingStatus,
279        ScopeTarget,
280    };
281    use std::net::IpAddr;
282    use std::str::FromStr;
283
284    use super::{
285        BlackBoxRunner, CheckSelection, ConsentPolicy, RunPolicy, RunSummary, RunnerRefusal,
286    };
287
288    fn scope() -> EngagementScope {
289        EngagementScope::new(vec![ScopeTarget::Host {
290            addr: IpAddr::from_str("127.0.0.1").unwrap(),
291        }])
292        .unwrap()
293    }
294
295    fn cap(kind: CapabilityKind) -> Capability {
296        Capability {
297            kind,
298            principal: None,
299            source: None,
300            secret: None,
301        }
302    }
303
304    fn runner_with(policy: RunPolicy, selection: CheckSelection) -> BlackBoxRunner {
305        BlackBoxRunner::new(policy, selection)
306    }
307
308    fn default_policy(max_hosts: Option<usize>, max_secs: Option<u64>) -> RunPolicy {
309        RunPolicy {
310            scope: scope(),
311            consent: ConsentPolicy::default(),
312            max_hosts,
313            max_duration_secs: max_secs,
314        }
315    }
316
317    #[test]
318    fn only_and_skip_filter_checks() {
319        let dns = CheckId::new("dns-enum").unwrap();
320        let ldap = CheckId::new("ldap-rootdse").unwrap();
321        let selection = CheckSelection {
322            only: vec![dns.clone()],
323            skip: vec![ldap.clone()],
324        };
325        assert!(selection.includes(&dns));
326        assert!(!selection.includes(&ldap));
327    }
328
329    #[test]
330    fn impact_checks_require_consent() {
331        let r = runner_with(default_policy(None, None), CheckSelection::default());
332        assert!(r.should_run(&CheckId::new("dns-enum").unwrap(), CheckClass::Discovery));
333        let err = r
334            .may_run(&CheckId::new("spray-kerberos").unwrap(), CheckClass::Impact)
335            .unwrap_err();
336        assert_eq!(err, RunnerRefusal::ImpactRequiresConsent);
337    }
338
339    /// BF-5 regression. A PostCred check without any landed capability
340    /// must refuse. Once a capability is recorded, the same check
341    /// proceeds.
342    #[test]
343    fn postcred_requires_landed_capability() {
344        let r = runner_with(default_policy(None, None), CheckSelection::default());
345        let check = CheckId::new("laps-read").unwrap();
346        let err = r.may_run(&check, CheckClass::PostCred).unwrap_err();
347        assert_eq!(err, RunnerRefusal::PostCredRequiresCapability);
348
349        r.record_capability(cap(CapabilityKind::AnonymousLdap));
350        assert!(r.should_run(&check, CheckClass::PostCred));
351        assert_eq!(r.capabilities().len(), 1);
352    }
353
354    /// BF-4 regression. `start_host` enforces `max_hosts` and refuses
355    /// the first host beyond the cap; already-touched hosts are free.
356    #[test]
357    fn max_hosts_budget_refuses_extra_first_touches() {
358        let r = runner_with(default_policy(Some(2), None), CheckSelection::default());
359        r.start_host(IpAddr::from_str("10.0.0.1").unwrap()).unwrap();
360        r.start_host(IpAddr::from_str("10.0.0.2").unwrap()).unwrap();
361        // Re-touch the same host: free.
362        r.start_host(IpAddr::from_str("10.0.0.1").unwrap()).unwrap();
363        // Third distinct host: refused.
364        let err = r
365            .start_host(IpAddr::from_str("10.0.0.3").unwrap())
366            .unwrap_err();
367        assert_eq!(err, RunnerRefusal::HostBudgetExhausted { limit: 2 });
368        assert_eq!(r.hosts_touched(), 2);
369    }
370
371    /// BF-4 regression. `max_duration_secs = Some(0)` forces every
372    /// may_run past `started_at` to refuse. `None` never refuses on time.
373    #[test]
374    fn duration_budget_refuses_after_cap() {
375        let r = runner_with(default_policy(None, Some(0)), CheckSelection::default());
376        let check = CheckId::new("dns-enum").unwrap();
377        let err = r.may_run(&check, CheckClass::Discovery).unwrap_err();
378        assert!(matches!(
379            err,
380            RunnerRefusal::DurationBudgetExhausted { limit_secs: 0 }
381        ));
382
383        let r = runner_with(default_policy(None, None), CheckSelection::default());
384        assert!(r.duration_within_budget());
385        assert!(r.should_run(&check, CheckClass::Discovery));
386    }
387
388    /// Selection refusal is distinct from consent / budget refusal.
389    #[test]
390    fn refusal_reasons_are_distinct() {
391        let dns = CheckId::new("dns-enum").unwrap();
392        let ldap = CheckId::new("ldap-rootdse").unwrap();
393        let r = runner_with(
394            default_policy(None, None),
395            CheckSelection {
396                only: vec![dns.clone()],
397                skip: Vec::new(),
398            },
399        );
400        assert_eq!(
401            r.may_run(&ldap, CheckClass::Discovery).unwrap_err(),
402            RunnerRefusal::NotInSelection
403        );
404    }
405
406    #[test]
407    fn summary_counts_findings_blocks_and_errors() {
408        let mut summary = RunSummary {
409            planned: 3,
410            ..RunSummary::default()
411        };
412        summary.record(FindingStatus::Found);
413        summary.record(FindingStatus::Blocked);
414        summary.record(FindingStatus::Error);
415        assert_eq!(summary.planned, 3);
416        assert_eq!(summary.completed, 3);
417        assert_eq!(summary.findings, 1);
418        assert_eq!(summary.blocked, 1);
419        assert_eq!(summary.errors, 1);
420    }
421
422    #[test]
423    fn refusal_display_is_operator_readable() {
424        assert!(format!("{}", RunnerRefusal::NotInSelection).contains("selection"));
425        assert!(format!("{}", RunnerRefusal::ImpactRequiresConsent).contains("allow_impact"));
426        assert!(format!("{}", RunnerRefusal::PostCredRequiresCapability).contains("capability"));
427        assert!(format!("{}", RunnerRefusal::HostBudgetExhausted { limit: 42 }).contains("42"));
428        assert!(format!(
429            "{}",
430            RunnerRefusal::DurationBudgetExhausted { limit_secs: 900 }
431        )
432        .contains("900"));
433    }
434
435    /// The runner does not clone or leak the internal capability lock
436    /// (defensive-clone contract).
437    #[test]
438    fn capabilities_snapshot_is_defensive_clone() {
439        let r = runner_with(default_policy(None, None), CheckSelection::default());
440        r.record_capability(cap(CapabilityKind::AnonymousLdap));
441        let snap = r.capabilities();
442        assert_eq!(snap.len(), 1);
443        r.record_capability(cap(CapabilityKind::SmbNullSession));
444        // The snapshot returned above is not affected by later records.
445        assert_eq!(snap.len(), 1);
446        assert_eq!(r.capabilities().len(), 2);
447    }
448
449    /// WS-FOUNDATION-DNS-HANDROLL (1.5.0). A skipped `dns-enum` check
450    /// short-circuits `discover_dns` without any socket work — returns
451    /// empty rather than attempting a lookup.
452    #[tokio::test]
453    async fn skipped_dns_check_short_circuits_without_lookup() {
454        let runner = runner_with(
455            default_policy(None, None),
456            CheckSelection {
457                only: Vec::new(),
458                skip: vec![CheckId::new("dns-enum").unwrap()],
459            },
460        );
461        // Even with a nameserver supplied, the skip selection returns [].
462        let result = runner
463            .discover_dns(&["127.0.0.1".parse().unwrap()])
464            .await
465            .unwrap();
466        assert!(result.is_empty());
467    }
468}