1use std::fmt;
20use std::net::IpAddr;
21
22use ipnet::IpNet;
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26#[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 fn ip_in_includes(&self, ip: IpAddr) -> bool {
70 self.includes.iter().any(|target| target.matches_ip(ip))
71 }
72
73 fn hostname_in_includes(&self, hostname: &str) -> bool {
75 self.includes
76 .iter()
77 .any(|target| target.matches_hostname(hostname))
78 }
79
80 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 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 pub fn allows_ip(&self, ip: IpAddr) -> bool {
124 self.allows(Some(ip), None)
125 }
126
127 pub fn allows_hostname(&self, hostname: &str) -> bool {
129 self.allows(None, Some(hostname))
130 }
131}
132
133#[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#[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#[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#[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#[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#[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#[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#[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 #[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 assert!(scope.allows(Some(ip), None));
383 assert!(!scope.allows(Some(ip), Some("dc01.corp.local")));
385 }
386
387 #[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 assert!(scope.allows(None, Some("dc01.corp.local")));
404 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}