diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 11b4b225..37b9bb1e 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -20,12 +20,15 @@ pub use discovery_polling::discovery_poller; use std::sync::Arc; use anyhow::Result; +use redis::aio::ConnectionLike; use serde_json::Value; use tracing::{debug, info, warn}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::output_extraction; use crate::orchestrator::results::CompletedTask; +use crate::orchestrator::state::SharedState; +use crate::orchestrator::task_queue::TaskQueueCore; use crate::orchestrator::throttling::Throttler; use self::admin_checks::{ @@ -771,6 +774,42 @@ fn gmsa_exploit_token(username: &str) -> String { format!("gmsa_{}", username.trim_end_matches('$').to_lowercase()) } +/// 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 and the scoreboard understates progress. +/// +/// No-op for non-gMSA usernames. Errors from `mark_exploited` are logged +/// but not propagated — credit emission is best-effort and shouldn't +/// fail the surrounding hash-publish flow. +async fn emit_gmsa_exploit_token_if_gmsa( + state: &SharedState, + queue: &TaskQueueCore, + username: &str, +) where + C: ConnectionLike + Clone + Send + Sync + 'static, +{ + if !is_gmsa_principal(username) { + return; + } + let vuln_id = gmsa_exploit_token(username); + if let Err(e) = state.mark_exploited(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" + ); + } +} + 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]; @@ -1408,32 +1447,8 @@ async fn extract_discoveries( ) .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 = gmsa_exploit_token(&username); - 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" - ); - } - } + emit_gmsa_exploit_token_if_gmsa(&dispatcher.state, &dispatcher.queue, &username) + .await; } 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 1d51f419..aebe6ef1 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -1331,6 +1331,54 @@ fn gmsa_exploit_token_converges_with_enumeration_format() { assert_eq!(gmsa_exploit_token("gmsaDragon$"), "gmsa_gmsadragon"); } +mod emit_gmsa_exploit_token { + use super::super::emit_gmsa_exploit_token_if_gmsa; + use crate::orchestrator::state::SharedState; + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::state::mock_redis::MockRedisConnection; + + fn mock_queue() -> TaskQueueCore { + TaskQueueCore::from_connection(MockRedisConnection::new()) + } + + #[tokio::test] + async fn marks_exploited_for_gmsa_principal() { + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + emit_gmsa_exploit_token_if_gmsa(&state, &q, "gmsaDragon$").await; + let s = state.read().await; + assert!(s.exploited_vulnerabilities.contains("gmsa_gmsadragon")); + } + + #[tokio::test] + async fn no_op_for_plain_machine_account() { + // DC01$ ends with `$` but is not a gMSA — no token should be emitted. + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + emit_gmsa_exploit_token_if_gmsa(&state, &q, "DC01$").await; + let s = state.read().await; + assert!(s.exploited_vulnerabilities.is_empty()); + } + + #[tokio::test] + async fn no_op_for_regular_user() { + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + emit_gmsa_exploit_token_if_gmsa(&state, &q, "alice").await; + let s = state.read().await; + assert!(s.exploited_vulnerabilities.is_empty()); + } + + #[tokio::test] + async fn token_normalized_lowercase_for_mixed_case_input() { + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + emit_gmsa_exploit_token_if_gmsa(&state, &q, "GMSA_WEB$").await; + let s = state.read().await; + assert!(s.exploited_vulnerabilities.contains("gmsa_gmsa_web")); + } +} + #[test] fn seimpersonate_signal_detects_enabled_in_whoami_priv_output() { use super::result_has_seimpersonate_signal;