1use 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
38pub struct ConsentPolicy {
39 pub allow_impact: bool,
40 pub allow_spoof: bool,
43 pub interactive: bool,
44}
45
46#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum RunnerRefusal {
86 NotInSelection,
87 ImpactRequiresConsent,
88 PostCredRequiresCapability,
90 HostBudgetExhausted {
92 limit: usize,
93 },
94 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
125pub 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 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 pub fn capabilities(&self) -> Vec<Capability> {
165 self.landed_capabilities
166 .lock()
167 .expect("landed_capabilities mutex poisoned")
168 .clone()
169 }
170
171 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 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 pub fn hosts_touched(&self) -> usize {
202 self.touched_hosts
203 .lock()
204 .expect("touched_hosts mutex poisoned")
205 .len()
206 }
207
208 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 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 pub fn should_run(&self, check: &CheckId, class: CheckClass) -> bool {
253 self.may_run(check, class).is_ok()
254 }
255
256 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 #[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 #[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 r.start_host(IpAddr::from_str("10.0.0.1").unwrap()).unwrap();
363 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 #[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 #[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 #[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 assert_eq!(snap.len(), 1);
446 assert_eq!(r.capabilities().len(), 2);
447 }
448
449 #[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 let result = runner
463 .discover_dns(&["127.0.0.1".parse().unwrap()])
464 .await
465 .unwrap();
466 assert!(result.is_empty());
467 }
468}