diff --git a/.taskfiles/remote/Taskfile.yaml b/.taskfiles/remote/Taskfile.yaml index f0aa5289..a3b2801e 100644 --- a/.taskfiles/remote/Taskfile.yaml +++ b/.taskfiles/remote/Taskfile.yaml @@ -900,6 +900,7 @@ tasks: # zigbuild's Zig ar wrapper on Darwin), prefer zigbuild on Linux, # fall back to raw cargo if [ "$(uname)" = "Darwin" ] && command -v cross >/dev/null 2>&1; then + export AWS_LC_SYS_CMAKE_BUILDER=1 cross build --release --target {{.RUST_TARGET}} elif command -v cargo-zigbuild >/dev/null 2>&1; then cargo zigbuild --release --target {{.RUST_TARGET}} diff --git a/ares-cli/src/dedup/domains.rs b/ares-cli/src/dedup/domains.rs index 82818add..8469ba7c 100644 --- a/ares-cli/src/dedup/domains.rs +++ b/ares-cli/src/dedup/domains.rs @@ -206,9 +206,43 @@ pub(crate) fn normalize_state_domains( } } + // Also keep child domains whose suffix parent is already valid. + // e.g. north.sevenkingdoms.local survives when sevenkingdoms.local is valid, + // even before any north users/hosts have been enumerated. + let child_domains: Vec = domains + .iter() + .filter_map(|d| { + let lower = d.trim().to_lowercase(); + let parts: Vec<&str> = lower.split('.').collect(); + if parts.len() > 2 { + let parent = parts[1..].join("."); + if valid_domains.contains(&parent) { + return Some(lower); + } + } + None + }) + .collect(); + valid_domains.extend(child_domains); + + // A string that appears as the suffix (parts[1..]) of any host FQDN is a real + // domain, even if it also happens to appear as a host's own hostname field + // (e.g. a DC recorded as hostname="north.sevenkingdoms.local" while + // castelblack.north.sevenkingdoms.local is another host in the same op). + let confirmed_domains: HashSet = hosts + .iter() + .filter(|h| !h.hostname.is_empty() && h.hostname.contains('.')) + .map(|h| { + let lower = h.hostname.to_lowercase(); + let parts: Vec<&str> = lower.split('.').collect(); + parts[1..].join(".") + }) + .collect(); + domains.retain(|d| { let lower = d.to_lowercase(); - valid_domains.contains(&lower) && !host_fqdns.contains(&lower) + valid_domains.contains(&lower) + && (!host_fqdns.contains(&lower) || confirmed_domains.contains(&lower)) }); } } diff --git a/ares-cli/src/dedup/tests.rs b/ares-cli/src/dedup/tests.rs index b5972cae..d6b238be 100644 --- a/ares-cli/src/dedup/tests.rs +++ b/ares-cli/src/dedup/tests.rs @@ -664,6 +664,32 @@ fn normalize_state_domains_drops_host_fqdn_masquerading_as_domain() { assert_eq!(domains, vec!["c26h.local".to_string()]); } +#[test] +fn normalize_state_domains_domain_kept_when_hostname_matches_but_subdomain_host_exists() { + // A DC whose hostname field equals the domain name (e.g. the DC for + // child.contoso.local is stored as hostname="child.contoso.local") must NOT be + // excluded when another host confirms it is a real domain + // (dc01.child.contoso.local → suffix = child.contoso.local). + let users = vec![make_user("contoso.local", "admin")]; + let mut creds = vec![]; + let mut hashes = vec![]; + let mut domains = vec![ + "contoso.local".to_string(), + "child.contoso.local".to_string(), + ]; + let hosts = vec![ + make_host("192.168.58.150", "child.contoso.local"), + make_host("192.168.58.51", "dc01.child.contoso.local"), + ]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert!( + domains.contains(&"child.contoso.local".to_string()), + "child domain should survive: dc01.child.* confirms it is a real domain" + ); +} + #[test] fn normalize_state_domains_domain_kept_from_target_domain() { // target_domain should cause that domain to be retained even without hosts/users. @@ -686,6 +712,39 @@ fn normalize_state_domains_domain_kept_from_target_domain() { assert_eq!(domains[0], "fabrikam.local"); } +#[test] +fn normalize_state_domains_child_domain_kept_when_parent_valid() { + // A child domain (3+ labels) should survive the filter when its + // suffix parent is already in valid_domains, even if no users/hosts + // have been enumerated in the child domain yet. + let users = vec![make_user("contoso.local", "admin")]; + let mut creds = vec![]; + let mut hashes = vec![]; + let mut domains = vec![ + "contoso.local".to_string(), + "child.contoso.local".to_string(), + "orphan.other".to_string(), + ]; + let hosts: Vec = vec![]; + + normalize_state_domains( + &users, + &mut creds, + &mut hashes, + &mut domains, + &hosts, + Some("contoso.local"), + ); + + assert!(domains.contains(&"contoso.local".to_string())); + assert!( + domains.contains(&"child.contoso.local".to_string()), + "child domain should survive when parent is valid" + ); + // orphan.other has no parent in valid_domains — dropped + assert!(!domains.contains(&"orphan.other".to_string())); +} + #[test] fn normalize_state_domains_hash_not_corrected_when_domain_is_known() { // When hash domain IS in known_domains, it should NOT be corrected even if user diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index 2a0302d8..df3e7ed0 100644 --- a/ares-cli/src/ops/loot/format/display.rs +++ b/ares-cli/src/ops/loot/format/display.rs @@ -14,16 +14,8 @@ pub(super) fn print_loot_human( ) { println!("Operation: {}", state.operation_id); - let started = state.started_at.format("%Y-%m-%d %H:%M:%S UTC"); - if let Some(completed) = state.completed_at { - let ended = completed.format("%Y-%m-%d %H:%M:%S UTC"); - let elapsed = format_duration(completed - state.started_at); - println!("Started: {started}"); - println!("Completed: {ended} ({elapsed})"); - } else { - let elapsed = format_duration(chrono::Utc::now() - state.started_at); - println!("Started: {started}"); - println!("Running: {elapsed}"); + for line in operation_timing_lines(state, chrono::Utc::now()) { + println!("{line}"); } let topology = compute_forest_topology(domains_input); @@ -284,6 +276,34 @@ pub(super) fn print_loot_human( print_mitre_techniques(&state.all_techniques, &state.all_timeline_events); } +fn operation_timing_lines( + state: &SharedRedTeamState, + now: chrono::DateTime, +) -> Vec { + let started = state.started_at.format("%Y-%m-%d %H:%M:%S UTC"); + let mut lines = vec![format!("Started: {started}")]; + + if let Some(completed) = state.completed_at { + let ended = completed.format("%Y-%m-%d %H:%M:%S UTC"); + let elapsed = format_duration(completed - state.started_at); + lines.push(format!("Completed: {ended} ({elapsed})")); + } else if let Some(red_completed) = state.red_completed_at { + let ended = red_completed.format("%Y-%m-%d %H:%M:%S UTC"); + let elapsed = format_duration(red_completed - state.started_at); + lines.push(format!("Completed: {ended} ({elapsed})")); + if state.red_blocked_on_blue { + lines.push("Finalizing: waiting on blue investigations".to_string()); + } else if let Some(reason) = &state.red_completion_reason { + lines.push(format!("Finalizing: {reason}")); + } + } else { + let elapsed = format_duration(now - state.started_at); + lines.push(format!("Running: {elapsed}")); + } + + lines +} + /// Compact summary used by `ops runtime`: DA/GT banner with per-domain /// breakdown plus a one-line host/DC count. Shares formatting with /// `print_loot_human` so the live-watch view stays consistent with `ops loot`. @@ -1202,6 +1222,30 @@ mod tests { } } + #[test] + fn operation_timing_red_complete_waiting_on_blue_prints_completed() { + let mut state = empty_state(); + state.started_at = chrono::DateTime::parse_from_rfc3339("2026-05-15T20:53:56Z") + .unwrap() + .with_timezone(&chrono::Utc); + state.red_completed_at = Some( + chrono::DateTime::parse_from_rfc3339("2026-05-15T22:04:43Z") + .unwrap() + .with_timezone(&chrono::Utc), + ); + state.red_blocked_on_blue = true; + + let now = chrono::DateTime::parse_from_rfc3339("2026-05-15T22:10:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + let lines = operation_timing_lines(&state, now); + + assert_eq!(lines[0], "Started: 2026-05-15 20:53:56 UTC"); + assert_eq!(lines[1], "Completed: 2026-05-15 22:04:43 UTC (1h 10m 47s)"); + assert_eq!(lines[2], "Finalizing: waiting on blue investigations"); + assert!(!lines.iter().any(|line| line.starts_with("Running:"))); + } + // capitalize #[test] diff --git a/ares-cli/src/ops/loot/format/json.rs b/ares-cli/src/ops/loot/format/json.rs index 7694d3c0..20cfc48d 100644 --- a/ares-cli/src/ops/loot/format/json.rs +++ b/ares-cli/src/ops/loot/format/json.rs @@ -132,6 +132,9 @@ pub(super) fn print_loot_json( "operation_id": state.operation_id, "started_at": state.started_at.to_rfc3339(), "completed_at": state.completed_at.map(|dt| dt.to_rfc3339()), + "red_completed_at": state.red_completed_at.map(|dt| dt.to_rfc3339()), + "red_completion_reason": state.red_completion_reason, + "red_blocked_on_blue": state.red_blocked_on_blue, "has_domain_admin": state.has_domain_admin, "domain_admin_path": state.domain_admin_path, "has_golden_ticket": state.has_golden_ticket, diff --git a/ares-cli/src/orchestrator/automation/crack.rs b/ares-cli/src/orchestrator/automation/crack.rs index 0fa303c3..c3ef5fee 100644 --- a/ares-cli/src/orchestrator/automation/crack.rs +++ b/ares-cli/src/orchestrator/automation/crack.rs @@ -244,7 +244,7 @@ mod tests { // eligible for retry — this was the bug. Confirm that the dedup // marker is NOT written before the cap. let mut state = StateInner::new("op-test".into()); - let key = "north.contoso.local:svc_sql:abcdef0123456789abcdef0123456789"; + let key = "child.contoso.local:svc_sql:abcdef0123456789abcdef0123456789"; for _ in 0..(MAX_CRACK_ATTEMPTS - 1) { simulate_attempt(&mut state, key); } diff --git a/ares-cli/src/orchestrator/automation/gpp_sysvol.rs b/ares-cli/src/orchestrator/automation/gpp_sysvol.rs index a2d6d049..4608a1fc 100644 --- a/ares-cli/src/orchestrator/automation/gpp_sysvol.rs +++ b/ares-cli/src/orchestrator/automation/gpp_sysvol.rs @@ -18,6 +18,36 @@ use tracing::{debug, info, warn}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; +fn same_forest_domain(a: &str, b: &str) -> bool { + let a = a.to_lowercase(); + let b = b.to_lowercase(); + !a.is_empty() + && !b.is_empty() + && (a == b || a.ends_with(&format!(".{b}")) || b.ends_with(&format!(".{a}"))) +} + +fn credential_for_domain( + state: &StateInner, + domain: &str, +) -> Option { + state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + && c.domain.eq_ignore_ascii_case(domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + && same_forest_domain(&c.domain, domain) + }) + }) + .cloned() +} + /// Collect GPP/SYSVOL work items from state (pure logic, no async). fn collect_gpp_sysvol_work(state: &StateInner) -> Vec { if state.credentials.is_empty() { @@ -32,12 +62,7 @@ fn collect_gpp_sysvol_work(state: &StateInner) -> Vec { continue; } - let cred = match state - .credentials - .iter() - .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) - .or_else(|| state.credentials.first()) - { + let cred = match credential_for_domain(state, domain) { Some(c) => c.clone(), None => continue, }; @@ -274,7 +299,7 @@ mod tests { } #[test] - fn collect_falls_back_to_first_credential() { + fn collect_skips_unrelated_cross_forest_credential() { let mut state = StateInner::new("test".into()); state .domain_controllers @@ -283,8 +308,21 @@ mod tests { .credentials .push(make_cred("fabuser", "fabrikam.local")); let work = collect_gpp_sysvol_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_allows_child_domain_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_cred("childuser", "child.contoso.local")); + let work = collect_gpp_sysvol_work(&state); assert_eq!(work.len(), 1); - assert_eq!(work[0].credential.username, "fabuser"); + assert_eq!(work[0].credential.username, "childuser"); } #[test] diff --git a/ares-cli/src/orchestrator/automation/group_enumeration.rs b/ares-cli/src/orchestrator/automation/group_enumeration.rs index 3ed346ef..3bad80a0 100644 --- a/ares-cli/src/orchestrator/automation/group_enumeration.rs +++ b/ares-cli/src/orchestrator/automation/group_enumeration.rs @@ -588,7 +588,7 @@ mod tests { // Child-domain cred should work for parent-domain via trust state .credentials - .push(make_credential("admin", "P@ssw0rd!", "north.contoso.local")); // pragma: allowlist secret + .push(make_credential("admin", "P@ssw0rd!", "child.contoso.local")); // pragma: allowlist secret let work = collect_group_enum_work(&state); assert_eq!( work.len(), @@ -596,7 +596,7 @@ mod tests { "child-domain cred should fall back for parent" ); assert_eq!(work[0].dedup_key, "group_enum:contoso.local:trust"); - assert_eq!(work[0].credential.domain, "north.contoso.local"); + assert_eq!(work[0].credential.domain, "child.contoso.local"); } #[tokio::test] diff --git a/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs b/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs index b53358e1..4212f668 100644 --- a/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs +++ b/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs @@ -101,6 +101,7 @@ struct PivotWork { linked_server: String, cred_username: String, cred_domain: String, + impersonate_user: Option, } /// Has any `mssql_impersonation` vuln on the same `target` been marked @@ -134,11 +135,13 @@ async fn collect_pivot_work(dispatcher: &Dispatcher) -> Vec { // `auto_mssql_impersonation` just landed EXECUTE AS LOGIN, which // proves source-side access AND grants the rights typically needed // for openquery hops — see plan-loot-gaps.md §1E). - .filter(|v| { - state.exploited_vulnerabilities.contains(&v.vuln_id) - || same_target_impersonation_exploited(&state, &v.target) - }) .filter_map(|vuln| { + let has_link_access = state.exploited_vulnerabilities.contains(&vuln.vuln_id); + let has_impersonation = same_target_impersonation_exploited(&state, &vuln.target); + if !has_link_access && !has_impersonation { + return None; + } + let linked_server = vuln .details .get("linked_server") @@ -185,6 +188,7 @@ async fn collect_pivot_work(dispatcher: &Dispatcher) -> Vec { linked_server, cred_username: cred.username, cred_domain: cred.domain, + impersonate_user: has_impersonation.then(|| "sa".to_string()), }) }) .collect() @@ -601,6 +605,10 @@ fn build_probe_args(item: &PivotWork) -> Value { }); if !item.cred_domain.is_empty() { tool_args["domain"] = json!(item.cred_domain); + tool_args["windows_auth"] = json!(true); + } + if let Some(ref impersonate_user) = item.impersonate_user { + tool_args["impersonate_user"] = json!(impersonate_user); } tool_args } @@ -617,6 +625,7 @@ mod tests { linked_server: "SQL".into(), cred_username: "svc_sql".into(), cred_domain: "contoso.local".into(), + impersonate_user: None, } } @@ -626,6 +635,7 @@ mod tests { assert_eq!(args["target"], "192.168.58.51"); assert_eq!(args["username"], "svc_sql"); assert_eq!(args["domain"], "contoso.local"); + assert_eq!(args["windows_auth"], true); assert_eq!(args["linked_server"], "SQL"); assert_eq!(args["query"].as_str().unwrap(), PROBE_QUERY); // Plaintext secrets MUST NOT be in the probe args — the local @@ -640,6 +650,15 @@ mod tests { item.cred_domain = String::new(); let args = build_probe_args(&item); assert!(args.get("domain").is_none()); + assert!(args.get("windows_auth").is_none()); + } + + #[test] + fn probe_args_wrap_link_hop_when_impersonation_confirmed() { + let mut item = sample_work(); + item.impersonate_user = Some("sa".into()); + let args = build_probe_args(&item); + assert_eq!(args["impersonate_user"], "sa"); } #[test] diff --git a/ares-cli/src/orchestrator/automation/ntlmv1_downgrade.rs b/ares-cli/src/orchestrator/automation/ntlmv1_downgrade.rs index a89c9a77..345a4e05 100644 --- a/ares-cli/src/orchestrator/automation/ntlmv1_downgrade.rs +++ b/ares-cli/src/orchestrator/automation/ntlmv1_downgrade.rs @@ -14,6 +14,36 @@ use tracing::{debug, info, warn}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; +fn same_forest_domain(a: &str, b: &str) -> bool { + let a = a.to_lowercase(); + let b = b.to_lowercase(); + !a.is_empty() + && !b.is_empty() + && (a == b || a.ends_with(&format!(".{b}")) || b.ends_with(&format!(".{a}"))) +} + +fn credential_for_domain( + state: &StateInner, + domain: &str, +) -> Option { + state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + && c.domain.eq_ignore_ascii_case(domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + && same_forest_domain(&c.domain, domain) + }) + }) + .cloned() +} + /// Collect NTLMv1 downgrade work items from state (pure logic, no async). fn collect_ntlmv1_work(state: &StateInner) -> Vec { if state.credentials.is_empty() { @@ -28,12 +58,7 @@ fn collect_ntlmv1_work(state: &StateInner) -> Vec { continue; } - let cred = match state - .credentials - .iter() - .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) - .or_else(|| state.credentials.first()) - { + let cred = match credential_for_domain(state, domain) { Some(c) => c.clone(), None => continue, }; @@ -312,7 +337,7 @@ mod tests { } #[test] - fn collect_falls_back_to_first_credential() { + fn collect_skips_unrelated_cross_forest_credential() { let mut state = StateInner::new("test".into()); state .domain_controllers @@ -321,8 +346,21 @@ mod tests { .credentials .push(make_cred("fabuser", "fabrikam.local")); let work = collect_ntlmv1_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_allows_child_domain_credential() { + let mut state = StateInner::new("test".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state + .credentials + .push(make_cred("childuser", "child.contoso.local")); + let work = collect_ntlmv1_work(&state); assert_eq!(work.len(), 1); - assert_eq!(work[0].credential.username, "fabuser"); + assert_eq!(work[0].credential.username, "childuser"); } #[test] diff --git a/ares-cli/src/orchestrator/automation/password_policy.rs b/ares-cli/src/orchestrator/automation/password_policy.rs index 9ae27ca8..269a40ad 100644 --- a/ares-cli/src/orchestrator/automation/password_policy.rs +++ b/ares-cli/src/orchestrator/automation/password_policy.rs @@ -16,6 +16,36 @@ use tracing::{debug, info, warn}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; +fn same_forest_domain(a: &str, b: &str) -> bool { + let a = a.to_lowercase(); + let b = b.to_lowercase(); + !a.is_empty() + && !b.is_empty() + && (a == b || a.ends_with(&format!(".{b}")) || b.ends_with(&format!(".{a}"))) +} + +fn credential_for_domain( + state: &StateInner, + domain: &str, +) -> Option { + state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + && c.domain.eq_ignore_ascii_case(domain) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_principal_quarantined(&c.username, &c.domain) + && same_forest_domain(&c.domain, domain) + }) + }) + .cloned() +} + fn collect_password_policy_work(state: &StateInner) -> Vec { if state.credentials.is_empty() { return Vec::new(); @@ -29,12 +59,7 @@ fn collect_password_policy_work(state: &StateInner) -> Vec { continue; } - let cred = match state - .credentials - .iter() - .find(|c| c.domain.to_lowercase() == domain.to_lowercase()) - .or_else(|| state.credentials.first()) - { + let cred = match credential_for_domain(state, domain) { Some(c) => c.clone(), None => continue, }; @@ -349,7 +374,7 @@ mod tests { } #[test] - fn collect_falls_back_to_first_credential() { + fn collect_skips_unrelated_cross_forest_credential() { let mut state = StateInner::new("test-op".into()); state .domain_controllers @@ -359,9 +384,24 @@ mod tests { .credentials .push(make_credential("fabuser", "Fab!Pass1", "fabrikam.local")); // pragma: allowlist secret let work = collect_password_policy_work(&state); + assert!(work.is_empty()); + } + + #[test] + fn collect_allows_child_domain_credential() { + let mut state = StateInner::new("test-op".into()); + state + .domain_controllers + .insert("contoso.local".into(), "192.168.58.10".into()); + state.credentials.push(make_credential( + "childuser", + "Child!Pass1", + "child.contoso.local", + )); // pragma: allowlist secret + let work = collect_password_policy_work(&state); assert_eq!(work.len(), 1); - assert_eq!(work[0].credential.username, "fabuser"); - assert_eq!(work[0].credential.domain, "fabrikam.local"); + assert_eq!(work[0].credential.username, "childuser"); + assert_eq!(work[0].credential.domain, "child.contoso.local"); } #[test] diff --git a/ares-cli/src/orchestrator/automation/s4u.rs b/ares-cli/src/orchestrator/automation/s4u.rs index 8c198d58..16b6fc94 100644 --- a/ares-cli/src/orchestrator/automation/s4u.rs +++ b/ares-cli/src/orchestrator/automation/s4u.rs @@ -336,22 +336,29 @@ pub(crate) fn build_s4u_payload(item: &S4uWork) -> Value { /// Check whether a task result matches any of the given error patterns. fn result_matches_patterns(result: &ares_core::models::TaskResult, patterns: &[&str]) -> bool { + let from_rust_llm_runner = result.worker_pod.as_deref() == Some("rust-llm-runner"); let payload = match &result.result { Some(v) => v, None => return false, }; - // Check error field - if let Some(err) = &result.error { - if patterns.iter().any(|p| err.contains(p)) { - return true; + // Legacy/non-LLM workers report tool failures via the top-level error + // field. rust-llm-runner uses this for loop-control/status strings. + if !from_rust_llm_runner { + if let Some(err) = &result.error { + if patterns.iter().any(|p| err.contains(p)) { + return true; + } } } - // Check raw tool outputs (array of strings embedded in the result payload) + // Check raw tool outputs (array of strings or {output: ...} objects). if let Some(outputs) = payload.get("tool_outputs").and_then(|v| v.as_array()) { for output in outputs { - if let Some(text) = output.as_str() { + if let Some(text) = output + .as_str() + .or_else(|| output.get("output").and_then(|v| v.as_str())) + { if patterns.iter().any(|p| text.contains(p)) { return true; } @@ -359,11 +366,15 @@ fn result_matches_patterns(result: &ares_core::models::TaskResult, patterns: &[& } } - // Check summary/result text - for key in &["summary", "output", "tool_output"] { - if let Some(text) = payload.get(*key).and_then(|v| v.as_str()) { - if patterns.iter().any(|p| text.contains(p)) { - return true; + // Legacy workers transport real tool stdout via scalar payload fields. + // rust-llm-runner scalars are model-authored narrative and must not drive + // retry control. + if !from_rust_llm_runner { + for key in &["output", "tool_output"] { + if let Some(text) = payload.get(*key).and_then(|v| v.as_str()) { + if patterns.iter().any(|p| text.contains(p)) { + return true; + } } } } @@ -393,16 +404,25 @@ mod tests { use chrono::Utc; use serde_json::json; - fn make_result(result: Option, error: Option) -> TaskResult { + fn make_result_with_worker_pod( + result: Option, + error: Option, + worker_pod: Option<&str>, + ) -> TaskResult { TaskResult { task_id: "t-test".to_string(), success: false, result, error, + worker_pod: worker_pod.map(str::to_string), completed_at: Utc::now(), } } + fn make_result(result: Option, error: Option) -> TaskResult { + make_result_with_worker_pod(result, error, None) + } + #[test] fn s4u_failure_cooldown_is_five_minutes() { assert_eq!(S4U_FAILURE_COOLDOWN, Duration::from_secs(300)); @@ -457,16 +477,32 @@ mod tests { } #[test] - fn result_matches_patterns_summary_match() { + fn result_matches_patterns_tool_outputs_object_match() { let tr = make_result( Some(json!({ - "summary": "S4U attack failed: STATUS_ACCOUNT_LOCKED_OUT for svc_sql$@contoso.local" + "tool_outputs": [ + {"output": "S4U attack failed: STATUS_ACCOUNT_LOCKED_OUT for svc_sql$@contoso.local"} + ] })), None, ); assert!(result_matches_patterns(&tr, &["STATUS_ACCOUNT_LOCKED_OUT"])); } + #[test] + fn result_matches_patterns_ignores_summary_text() { + let tr = make_result( + Some(json!({ + "summary": "S4U attack failed: STATUS_ACCOUNT_LOCKED_OUT for svc_sql$@contoso.local" + })), + None, + ); + assert!(!result_matches_patterns( + &tr, + &["STATUS_ACCOUNT_LOCKED_OUT"] + )); + } + #[test] fn result_matches_patterns_output_key_match() { let tr = make_result( @@ -517,11 +553,49 @@ mod tests { assert!(result_matches_patterns(&tr, &["KDC_ERR_CLIENT_REVOKED"])); } + #[test] + fn result_matches_patterns_ignores_rust_runner_error_text() { + let tr = make_result_with_worker_pod( + Some(json!({})), + Some("Assistance needed: STATUS_ACCOUNT_DISABLED".to_string()), + Some("rust-llm-runner"), + ); + assert!(!result_matches_patterns(&tr, &["STATUS_ACCOUNT_DISABLED"])); + } + + #[test] + fn result_matches_patterns_ignores_rust_runner_scalar_output_text() { + let tr = make_result_with_worker_pod( + Some(json!({ + "output": "KDC_ERR_KEY_EXPIRED when requesting TGT for svc_web$@contoso.local" + })), + None, + Some("rust-llm-runner"), + ); + assert!(!result_matches_patterns(&tr, &["KDC_ERR_KEY_EXPIRED"])); + } + + #[test] + fn result_matches_patterns_detects_rust_runner_tool_outputs_object_text() { + let tr = make_result_with_worker_pod( + Some(json!({ + "tool_outputs": [ + {"output": "Error from KDC: KDC_ERR_CLIENT_REVOKED for svc_sql@contoso.local"} + ] + })), + None, + Some("rust-llm-runner"), + ); + assert!(result_matches_patterns(&tr, &["KDC_ERR_CLIENT_REVOKED"])); + } + #[test] fn has_permanent_revocation_status_account_disabled() { let tr = make_result( Some(json!({ - "summary": "STATUS_ACCOUNT_DISABLED for svc_sql$@contoso.local" + "tool_outputs": [ + {"output": "STATUS_ACCOUNT_DISABLED for svc_sql$@contoso.local"} + ] })), None, ); @@ -594,6 +668,7 @@ mod tests { success: true, result: Some(json!({"summary": "ticket obtained"})), error: None, + worker_pod: None, completed_at: Utc::now(), }; assert!(should_reset_failure_count(&tr)); @@ -606,6 +681,7 @@ mod tests { success: false, result: Some(json!({"summary": "S4U failed: KRB_AP_ERR_MODIFIED"})), error: None, + worker_pod: None, completed_at: Utc::now(), }; assert!(!should_reset_failure_count(&tr)); diff --git a/ares-cli/src/orchestrator/automation/secretsdump.rs b/ares-cli/src/orchestrator/automation/secretsdump.rs index 0fef13d6..ca6dd20a 100644 --- a/ares-cli/src/orchestrator/automation/secretsdump.rs +++ b/ares-cli/src/orchestrator/automation/secretsdump.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use std::time::Duration; +use ares_llm::ToolCall; +use serde_json::{json, Value}; use tokio::sync::watch; use tracing::{info, warn}; @@ -43,7 +45,11 @@ fn pth_secretsdump_dedup_key(dc_ip: &str, parent_domain: &str) -> String { /// (which is for full domain dumps) so a prior full-dump failure doesn't /// block the narrower `-just-dc-user krbtgt` attempt against the same DC. fn krbtgt_extraction_dedup_key(dc_ip: &str, domain: &str) -> String { - format!("{}:{}:krbtgt_extraction", dc_ip, domain.to_lowercase()) + format!( + "{}:{}:krbtgt_extraction_direct_v2", + dc_ip, + domain.to_lowercase() + ) } /// Find a usable Administrator NTLM hash for a domain. @@ -144,6 +150,103 @@ fn has_krbtgt_hash(state: &StateInner, domain: &str) -> bool { }) } +fn build_krbtgt_extraction_args(dc_ip: &str, domain: &str, hash_value: &str) -> Value { + json!({ + "target": dc_ip, + "target_ip": dc_ip, + "dc_ip": dc_ip, + "username": "Administrator", + "domain": domain, + "target_domain": domain, + "hash": hash_value, + "just_dc_user": "krbtgt", + "timeout_minutes": 3, + }) +} + +fn discoveries_include_krbtgt(discoveries: Option<&Value>, domain: &str) -> bool { + let dom = domain.to_lowercase(); + discoveries + .and_then(|d| d.get("hashes")) + .and_then(Value::as_array) + .is_some_and(|hashes| { + hashes.iter().any(|h| { + let username_matches = h + .get("username") + .and_then(Value::as_str) + .is_some_and(|u| u.eq_ignore_ascii_case("krbtgt")); + let domain_matches = h + .get("domain") + .and_then(Value::as_str) + .is_some_and(|d| d.eq_ignore_ascii_case(&dom)); + let hash_type_matches = h + .get("hash_type") + .and_then(Value::as_str) + .is_none_or(|t| t.eq_ignore_ascii_case("ntlm")); + username_matches && domain_matches && hash_type_matches + }) + }) +} + +async fn dispatch_krbtgt_extraction_direct( + dispatcher: &Dispatcher, + dc_ip: &str, + domain: &str, + hash_value: &str, +) -> bool { + let task_id = format!("krbtgt_extract_{}", uuid::Uuid::new_v4().simple()); + let call = ToolCall { + id: format!("{}_call", task_id), + name: "secretsdump".to_string(), + arguments: build_krbtgt_extraction_args(dc_ip, domain, hash_value), + }; + + info!( + task_id = %task_id, + dc = %dc_ip, + domain = %domain, + "krbtgt extraction dispatched (direct tool, just-dc-user krbtgt)" + ); + + match dispatcher + .llm_runner + .tool_dispatcher() + .dispatch_tool("credential_access", &task_id, &call) + .await + { + Ok(result) => { + let found = discoveries_include_krbtgt(result.discoveries.as_ref(), domain); + if found { + info!( + task_id = %task_id, + dc = %dc_ip, + domain = %domain, + "krbtgt extraction completed with parsed krbtgt hash" + ); + } else { + warn!( + task_id = %task_id, + dc = %dc_ip, + domain = %domain, + error = ?result.error, + output_len = result.output.len(), + "krbtgt extraction completed without parsed krbtgt hash; will retry" + ); + } + found + } + Err(e) => { + warn!( + err = %e, + dc = %dc_ip, + domain = %domain, + "Failed to dispatch direct krbtgt extraction" + ); + false + } + } +} + /// Dispatches secretsdump when admin credentials are detected. /// Interval: 30s. Matches Python `_auto_local_admin_secretsdump`. pub async fn auto_local_admin_secretsdump( @@ -254,14 +357,10 @@ pub async fn auto_local_admin_secretsdump( /// domain's krbtgt hash. This closes the gap between "DA captured" and /// "Golden Ticket forged": `auto_local_admin_secretsdump` only fires the PtH /// path on child→parent escalation (gated on `dominated_domains`), and the -/// generic credential_access prompt lets the LLM omit `-just-dc-user`, which -/// triggers full-dump DRSUAPI hardening rejections and frequent -/// STATUS_LOGON_FAILURE on cross-realm syntax mistakes. Once krbtgt lands, +/// generic credential_access prompt lets the LLM omit `-just-dc-user` or +/// mis-shape the hash argument. Dispatch the tool directly and only mark the +/// dedup after parser output confirms the krbtgt hash. Once krbtgt lands, /// `auto_golden_ticket` takes over. -/// -/// Priority 1 so it dominates the deferred-queue score ordering — the -/// existing soft/hard throttle caps still apply, but among queued work this -/// step jumps to the front. pub async fn auto_krbtgt_extraction( dispatcher: Arc, mut shutdown: watch::Receiver, @@ -303,36 +402,21 @@ pub async fn auto_krbtgt_extraction( }; for (dedup_key, dc_ip, domain, hash_value) in work.into_iter().take(2) { - match dispatcher - .request_secretsdump_hash( - &dc_ip, - "Administrator", - &domain, - &hash_value, - 1, - Some("krbtgt"), - ) - .await { - Ok(Some(task_id)) => { - info!( - task_id = %task_id, - dc = %dc_ip, - domain = %domain, - "krbtgt extraction dispatched (just-dc-user krbtgt)" - ); - { - let mut state = dispatcher.state.write().await; - state.mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); - state.mark_credential_capture_in_flight(&domain); - } - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &dedup_key) - .await; + let mut state = dispatcher.state.write().await; + state.mark_credential_capture_in_flight(&domain); + } + + if dispatch_krbtgt_extraction_direct(&dispatcher, &dc_ip, &domain, &hash_value).await { + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_SECRETSDUMP, dedup_key.clone()); + state.mark_credential_capture_in_flight(&domain); } - Ok(None) => {} - Err(e) => warn!(err = %e, "Failed to dispatch krbtgt extraction"), + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &dedup_key) + .await; } } } @@ -341,6 +425,7 @@ pub async fn auto_krbtgt_extraction( #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn valid_secretsdump_target_same_domain() { @@ -467,6 +552,67 @@ mod tests { assert_eq!(pth_secretsdump_dedup_key("", ""), "::pth_admin"); } + #[test] + fn krbtgt_extraction_dedup_key_is_direct_path() { + assert_eq!( + krbtgt_extraction_dedup_key("192.168.58.20", "CONTOSO.LOCAL"), + "192.168.58.20:contoso.local:krbtgt_extraction_direct_v2" + ); + } + + #[test] + fn build_krbtgt_extraction_args_uses_direct_secretsdump_shape() { + let args = build_krbtgt_extraction_args( + "192.168.58.20", + "contoso.local", + "aad3b435b51404eeaad3b435b51404ee:0123456789abcdef0123456789abcdef", + ); + assert_eq!(args["target"], "192.168.58.20"); + assert_eq!(args["target_ip"], "192.168.58.20"); + assert_eq!(args["dc_ip"], "192.168.58.20"); + assert_eq!(args["username"], "Administrator"); + assert_eq!(args["domain"], "contoso.local"); + assert_eq!(args["target_domain"], "contoso.local"); + assert_eq!( + args["hash"], + "aad3b435b51404eeaad3b435b51404ee:0123456789abcdef0123456789abcdef" + ); + assert_eq!(args["just_dc_user"], "krbtgt"); + assert_eq!(args["timeout_minutes"], 3); + } + + #[test] + fn discoveries_include_krbtgt_accepts_matching_ntlm_hash() { + let discoveries = json!({ + "hashes": [{ + "username": "krbtgt", + "domain": "contoso.local", + "hash_type": "ntlm", + "hash_value": "lm:nt" + }] + }); + assert!(discoveries_include_krbtgt( + Some(&discoveries), + "CONTOSO.LOCAL" + )); + } + + #[test] + fn discoveries_include_krbtgt_rejects_wrong_domain() { + let discoveries = json!({ + "hashes": [{ + "username": "krbtgt", + "domain": "fabrikam.local", + "hash_type": "ntlm", + "hash_value": "lm:nt" + }] + }); + assert!(!discoveries_include_krbtgt( + Some(&discoveries), + "contoso.local" + )); + } + // ── tests for select_local_admin_secretsdump_work / select_pth_secretsdump_work ── fn make_cred(user: &str, password: &str, domain: &str) -> ares_core::models::Credential { diff --git a/ares-cli/src/orchestrator/automation/trust.rs b/ares-cli/src/orchestrator/automation/trust.rs index 285fb57b..d9b6e8e3 100644 --- a/ares-cli/src/orchestrator/automation/trust.rs +++ b/ares-cli/src/orchestrator/automation/trust.rs @@ -2446,6 +2446,106 @@ async fn dispatch_post_ticket_user_enumeration( } } +/// Run ACL enumeration directly with the forged inter-realm ticket. +/// +/// The LLM-routed ACL fallback often burns a turn trying the placeholder +/// password before the resolver flips to GSSAPI. Dispatching the Kerberos +/// LDAP tool directly keeps the SID-filtered trust path productive: parser +/// discoveries from `ldap_acl_enumeration` feed DACL/RBCD/shadow-credential +/// automations without waiting for another recon agent round. +async fn dispatch_post_ticket_acl_enumeration( + dispatcher: &Dispatcher, + source_domain: &str, + target_domain: &str, +) { + let target_lower = target_domain.to_lowercase(); + + let (target_dc_ip, target_dc_fqdn) = { + let s = dispatcher.state.read().await; + let Some(dc_ip) = s.resolve_dc_ip(target_domain) else { + warn!( + source_domain, + target_domain, "post-ticket ACL enum skipped: no DC IP for target domain" + ); + return; + }; + let dc_fqdn = s + .hosts + .iter() + .find(|h| h.ip == dc_ip && !h.hostname.is_empty()) + .map(|h| { + let hn = h.hostname.to_lowercase(); + if hn.ends_with(&format!(".{target_lower}")) || hn == target_lower { + hn + } else { + format!("{hn}.{target_lower}") + } + }); + (dc_ip, dc_fqdn) + }; + + let target = target_dc_fqdn.unwrap_or_else(|| target_dc_ip.clone()); + let tool_args = json!({ + "target": target, + "target_ip": target_dc_ip, + "domain": target_domain, + "username": "Administrator", + "bind_domain": source_domain, + }); + let call = ToolCall { + id: format!("post_ticket_acl_{}", uuid::Uuid::new_v4().simple()), + name: "ldap_acl_enumeration".to_string(), + arguments: tool_args, + }; + let task_id = format!( + "post_ticket_acl_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + + info!( + task_id = %task_id, + source_domain, + target_domain, + "Post-ticket ACL enumeration dispatched (direct Kerberos LDAP tool)" + ); + + match dispatcher + .llm_runner + .tool_dispatcher() + .dispatch_tool("recon", &task_id, &call) + .await + { + Ok(exec) => { + if let Some(err) = exec.error { + warn!( + err = %err, + source_domain, + target_domain, + "Post-ticket ACL enumeration returned tool error" + ); + return; + } + let vuln_count = exec + .discoveries + .as_ref() + .and_then(|d| d.get("vulnerabilities")) + .and_then(|v| v.as_array()) + .map(|v| v.len()) + .unwrap_or(0); + info!( + source_domain, + target_domain, vuln_count, "Post-ticket ACL enumeration completed" + ); + } + Err(e) => warn!( + err = %e, + source_domain, + target_domain, + "Post-ticket ACL enumeration dispatch failed" + ), + } +} + /// Forge an inter-realm Kerberos ticket for a SID-filtered cross-forest trust. /// /// Called from the suppression branch of `auto_trust_follow` when @@ -2615,6 +2715,8 @@ async fn dispatch_create_inter_realm_ticket( // costs ~5-10 s on failure and saves the entire MSSQL-pivot wait // (historically ~60 min) on success. dispatch_post_ticket_secretsdump(dispatcher, source_domain, target_domain).await; + + dispatch_post_ticket_acl_enumeration(dispatcher, source_domain, target_domain).await; } Err(e) => { tracing::warn!( diff --git a/ares-cli/src/orchestrator/callback_handler/dispatch.rs b/ares-cli/src/orchestrator/callback_handler/dispatch.rs index 12888281..09e2fb3c 100644 --- a/ares-cli/src/orchestrator/callback_handler/dispatch.rs +++ b/ares-cli/src/orchestrator/callback_handler/dispatch.rs @@ -271,122 +271,4 @@ impl OrchestratorCallbackHandler { task_id.as_deref().unwrap_or("queued") ))) } - - /// Structured fallback for the cracker LLM agent. The preferred path is - /// still raw stdout extraction by `output_extraction.rs`, but when the LLM - /// summarizes its result instead of piping the raw `--show` line through - /// `tool_outputs`, the cleartext is lost. This callback gives the LLM an - /// unambiguous channel to land the credential. Every value passes through - /// `is_valid_credential`, which rejects hash-shaped strings and LLM - /// truncation artifacts — so a confused LLM can't pollute state with - /// fabricated passwords. - pub(super) async fn report_cracked_credential( - &self, - call: &ToolCall, - ) -> Result { - let username = call.arguments["username"] - .as_str() - .unwrap_or("") - .trim() - .to_string(); - let domain = call.arguments["domain"] - .as_str() - .unwrap_or("") - .trim() - .to_lowercase(); - let password = call.arguments["password"] - .as_str() - .unwrap_or("") - .to_string(); - let hash_type = call.arguments["hash_type"] - .as_str() - .unwrap_or("") - .to_string(); - - // Validate inputs BEFORE touching the dispatcher so rejection paths - // don't trip on a missing dispatcher in tests / partial init. - if username.is_empty() || domain.is_empty() || password.is_empty() { - return Ok(CallbackResult::Continue( - "report_cracked_credential requires non-empty username, domain, and password." - .to_string(), - )); - } - - // Reuse the same boundary validator that gates auto-extraction. This - // rejects NTLM-shaped 32-hex strings, $krb5*$ blobs, ellipsis-truncated - // hash displays, and the other shapes documented in - // `output_extraction/mod.rs::is_valid_credential`. - if !crate::orchestrator::output_extraction::is_valid_credential(&username, &password) { - warn!( - username = %username, - domain = %domain, - "report_cracked_credential rejected by validator (looks like a hash or truncated display, not a real password)" - ); - return Ok(CallbackResult::Continue( - "Rejected. The password you reported looks like a hash, a truncated display, \ - or otherwise not a real cleartext password. Re-run the cracker and emit the \ - actual `--show` plaintext, or run `crack_with_hashcat` again with a different \ - wordlist. Do not paraphrase or truncate cracked passwords." - .to_string(), - )); - } - - let dispatcher = self - .dispatcher - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Dispatcher not configured"))?; - - let credential = ares_core::models::Credential { - id: uuid::Uuid::new_v4().to_string(), - username: username.clone(), - password: password.clone(), - domain: domain.clone(), - source: format!( - "cracked:report_callback{}", - if hash_type.is_empty() { - String::new() - } else { - format!(":{hash_type}") - } - ), - discovered_at: Some(chrono::Utc::now()), - is_admin: false, - parent_id: None, - attack_step: 0, - }; - - match dispatcher - .state - .publish_credential(&dispatcher.queue, credential) - .await - { - Ok(true) => { - // Mirror the post-publish hash-annotation that the auto- - // extraction path runs, so the matching hash record reflects - // the cleartext regardless of which path produced it. - let _ = dispatcher - .state - .update_hash_cracked_password(&dispatcher.queue, &username, &domain, &password) - .await; - info!( - username = %username, - domain = %domain, - hash_type = %hash_type, - "Cracked credential published via report_cracked_credential" - ); - Ok(CallbackResult::Continue(format!( - "Cracked credential stored: {username}@{domain}. Annotated matching hash with the cleartext." - ))) - } - Ok(false) => Ok(CallbackResult::Continue(format!( - "Credential {username}@{domain} already known — no-op." - ))), - Err(e) => { - warn!(err = %e, "Failed to publish cracked credential"); - Ok(CallbackResult::Continue(format!( - "Failed to publish credential: {e}" - ))) - } - } - } } diff --git a/ares-cli/src/orchestrator/callback_handler/mod.rs b/ares-cli/src/orchestrator/callback_handler/mod.rs index b56a4df1..42c2cb88 100644 --- a/ares-cli/src/orchestrator/callback_handler/mod.rs +++ b/ares-cli/src/orchestrator/callback_handler/mod.rs @@ -84,8 +84,6 @@ impl CallbackHandler for OrchestratorCallbackHandler { "dispatch_privesc_exploit" => Some(self.dispatch_exploit(call).await), "dispatch_coercion" => Some(self.dispatch_coercion(call).await), "dispatch_crack" => Some(self.dispatch_crack(call).await), - // Cracker result — persist cracked credential and update hash - "report_cracked_credential" => Some(self.report_cracked_credential(call).await), // Not ours — let built-in handler take over _ => None, } diff --git a/ares-cli/src/orchestrator/callback_handler/tests.rs b/ares-cli/src/orchestrator/callback_handler/tests.rs index 843f555f..9f12fa3b 100644 --- a/ares-cli/src/orchestrator/callback_handler/tests.rs +++ b/ares-cli/src/orchestrator/callback_handler/tests.rs @@ -587,9 +587,7 @@ async fn record_timeline_event_disabled() { } #[tokio::test] -async fn report_cracked_credential_rejects_hash_shaped_value() { - // 32-char NTLM hash mis-reported as a "password" must be refused so the - // cleartext credentials set stays clean. +async fn report_cracked_credential_falls_through_to_builtin_handler() { let handler = make_handler(); let call = ToolCall { id: "rej-1".into(), @@ -597,58 +595,10 @@ async fn report_cracked_credential_rejects_hash_shaped_value() { arguments: json!({ "username": "alice", "domain": "contoso.local", - "password": "831486ac7f26860c9e2f51ac91e1a07a", + "password": "secret123", }), }; - let result = handler.handle_callback(&call).await.unwrap().unwrap(); - match result { - CallbackResult::Continue(msg) => { - assert!( - msg.contains("Rejected"), - "Expected rejection message, got: {msg}" - ); - } - other => panic!("Expected Continue, got: {:?}", other), - } -} - -#[tokio::test] -async fn report_cracked_credential_rejects_truncated_display() { - let handler = make_handler(); - let call = ToolCall { - id: "rej-2".into(), - name: "report_cracked_credential".into(), - arguments: json!({ - "username": "alice", - "domain": "contoso.local", - "password": "ef961e2fd18a412...6bf150", - }), - }; - let result = handler.handle_callback(&call).await.unwrap().unwrap(); - match result { - CallbackResult::Continue(msg) => assert!(msg.contains("Rejected")), - other => panic!("Expected Continue, got: {:?}", other), - } -} - -#[tokio::test] -async fn report_cracked_credential_requires_non_empty_fields() { - let handler = make_handler(); - let call = ToolCall { - id: "rej-3".into(), - name: "report_cracked_credential".into(), - arguments: json!({"username": "alice", "domain": "", "password": "secret123"}), - }; - let result = handler.handle_callback(&call).await.unwrap().unwrap(); - match result { - CallbackResult::Continue(msg) => { - assert!( - msg.contains("non-empty"), - "Expected validation message, got: {msg}" - ); - } - other => panic!("Expected Continue, got: {:?}", other), - } + assert!(handler.handle_callback(&call).await.is_none()); } #[tokio::test] diff --git a/ares-cli/src/orchestrator/completion.rs b/ares-cli/src/orchestrator/completion.rs index 91fdd6dd..c5ca5089 100644 --- a/ares-cli/src/orchestrator/completion.rs +++ b/ares-cli/src/orchestrator/completion.rs @@ -114,7 +114,7 @@ async fn redis_pending_red_tasks(dispatcher: &Arc) -> Result String { let lower = domain.to_lowercase(); @@ -318,11 +318,16 @@ pub async fn wait_for_completion( "Completion condition met" ); + let blue_enabled = std::env::var("ARES_BLUE_ENABLED").as_deref() == Ok("1"); + if let Err(e) = mark_red_completion_for_loot(dispatcher, reason, blue_enabled).await { + warn!(err = %e, "Failed to persist red completion metadata"); + } + // When blue team is enabled, auto-submit an investigation from the // operation state if none have been submitted yet, then wait for all // investigations to drain before signalling stop. // Cap at 45 minutes to avoid hanging forever if an investigation is stuck. - if std::env::var("ARES_BLUE_ENABLED").as_deref() == Ok("1") { + if blue_enabled { info!("Blue team enabled — waiting for investigations to finish before shutdown"); let mut conn = dispatcher.queue.connection(); @@ -483,6 +488,37 @@ pub async fn wait_for_completion( } } +async fn mark_red_completion_for_loot( + dispatcher: &Arc, + reason: &str, + blocked_on_blue: bool, +) -> Result<(), redis::RedisError> { + let key = + ares_core::state::build_key(&dispatcher.config.operation_id, ares_core::state::KEY_META); + let completed_at = Utc::now().to_rfc3339(); + let mut conn = dispatcher.queue.connection(); + redis::pipe() + .hset( + &key, + "red_completed_at", + serde_json::to_string(&completed_at).unwrap_or_default(), + ) + .hset( + &key, + "red_completion_reason", + serde_json::to_string(reason).unwrap_or_default(), + ) + .hset( + &key, + "red_blocked_on_blue", + serde_json::to_string(&blocked_on_blue).unwrap_or_default(), + ) + .expire(&key, 86400) + .query_async::<()>(&mut conn) + .await?; + Ok(()) +} + /// Auto-submit a blue team investigation from the current red team operation state. /// /// Mirrors the logic in `ares-cli/src/blue/submit.rs::blue_from_operation()` but @@ -652,12 +688,12 @@ mod tests { #[test] fn forest_root_of_child() { - assert_eq!(forest_root_of("north.contoso.local"), "contoso.local"); + assert_eq!(forest_root_of("child.contoso.local"), "contoso.local"); } #[test] fn forest_root_of_deep_child() { - assert_eq!(forest_root_of("sub.north.contoso.local"), "contoso.local"); + assert_eq!(forest_root_of("sub.child.contoso.local"), "contoso.local"); } fn make_trust(domain: &str, trust_type: &str) -> ares_core::models::TrustInfo { @@ -747,8 +783,8 @@ mod tests { // parent_child trust should NOT add a separate required forest let mut trusted = std::collections::HashMap::new(); trusted.insert( - "north.contoso.local".to_string(), - make_trust("north.contoso.local", "parent_child"), + "child.contoso.local".to_string(), + make_trust("child.contoso.local", "parent_child"), ); let mut dominated = HashSet::new(); @@ -761,7 +797,7 @@ mod tests { &dominated, &dcs, ); - // parent_child is NOT cross-forest, so north.contoso.local is not required + // parent_child is NOT cross-forest, so child.contoso.local is not required assert!(result.is_empty()); } @@ -772,7 +808,7 @@ mod tests { // trust escalation (ExtraSid / trust key). let trusted = std::collections::HashMap::new(); let mut dominated = HashSet::new(); - dominated.insert("north.contoso.local".to_string()); + dominated.insert("child.contoso.local".to_string()); let dcs = std::collections::HashMap::new(); let result = compute_undominated_forests( Some("contoso.local"), @@ -1004,7 +1040,7 @@ mod tests { let dcs = std::collections::HashMap::new(); let result = compute_undominated_forests( Some("contoso.local"), - Some("north.contoso.local"), + Some("child.contoso.local"), &trusted, &dominated, &dcs, @@ -1040,7 +1076,7 @@ mod tests { assert!(trust.is_cross_forest()); assert!(!trust.sid_filtering); - let parent_child = make_trust("north.contoso.local", "parent_child"); + let parent_child = make_trust("child.contoso.local", "parent_child"); assert!(!parent_child.is_cross_forest()); } diff --git a/ares-cli/src/orchestrator/dispatcher/submission.rs b/ares-cli/src/orchestrator/dispatcher/submission.rs index 32e63081..6ac595ef 100644 --- a/ares-cli/src/orchestrator/dispatcher/submission.rs +++ b/ares-cli/src/orchestrator/dispatcher/submission.rs @@ -679,8 +679,8 @@ pub(crate) fn parse_task_complete_result(result: &str, steps: u32, tool_calls: u /// Merge discoveries, LLM-fabricated findings, and raw tool outputs into a /// result-payload object. Pure JSON manipulation — drops any caller-supplied -/// `discoveries`/`llm_findings` keys first (LLM-controlled prose must never -/// shadow parser output) and only emits each section when non-empty. +/// evidence keys first (LLM-controlled prose must never shadow parser/tool +/// output) and only emits each section when non-empty. pub(crate) fn merge_result_extras( mut result_json: Value, merged_discoveries: Option, @@ -688,8 +688,23 @@ pub(crate) fn merge_result_extras( tool_outputs: Vec, ) -> Value { if let Some(obj) = result_json.as_object_mut() { - obj.remove("discoveries"); - obj.remove("llm_findings"); + for key in [ + "discoveries", + "llm_findings", + "tool_outputs", + "tool_output", + "output", + "has_domain_admin", + "domain_admin_path", + "has_golden_ticket", + "vuln_id", + "domain", + "target", + "target_ip", + "target_spn", + ] { + obj.remove(key); + } } if let Some(disc) = merged_discoveries { result_json["discoveries"] = disc; @@ -1054,6 +1069,15 @@ mod helper_tests { "summary": "ok", "discoveries": {"credentials": [{"forged_by_llm": "true"}]}, "llm_findings": [{"forged_by_llm": "true"}], + "tool_outputs": [{"output": "Saving ticket in fake.ccache"}], + "tool_output": "Domain Sid: S-1-5-21-1-2-3", + "output": "SeImpersonatePrivilege Enabled", + "has_domain_admin": true, + "domain_admin_path": "agent-made path", + "has_golden_ticket": true, + "vuln_id": "agent_supplied_vuln", + "domain": "agent.local", + "target_ip": "192.0.2.10", }); let m = merge_result_extras( base, @@ -1069,6 +1093,19 @@ mod helper_tests { ); // LLM-supplied `llm_findings` stripped entirely (caller passed None). assert!(m.get("llm_findings").is_none()); + for key in [ + "tool_outputs", + "tool_output", + "output", + "has_domain_admin", + "domain_admin_path", + "has_golden_ticket", + "vuln_id", + "domain", + "target_ip", + ] { + assert!(m.get(key).is_none(), "{key} should be stripped"); + } } #[test] diff --git a/ares-cli/src/orchestrator/output_extraction/tests.rs b/ares-cli/src/orchestrator/output_extraction/tests.rs index bf71a517..a7aa9c91 100644 --- a/ares-cli/src/orchestrator/output_extraction/tests.rs +++ b/ares-cli/src/orchestrator/output_extraction/tests.rs @@ -204,11 +204,11 @@ fn extract_users_smb_timestamp() { #[test] fn extract_users_domain_context_propagation() { let output = "\ -[*] Windows (name:DC01) (domain:north.contoso.local)\n\ +[*] Windows (name:DC01) (domain:child.contoso.local)\n\ user:[alice] rid:[0x1f4]"; let users = extract_users(output, "contoso.local"); let alice = users.iter().find(|u| u.username == "alice").unwrap(); - assert_eq!(alice.domain, "north.contoso.local"); + assert_eq!(alice.domain, "child.contoso.local"); } #[test] diff --git a/ares-cli/src/orchestrator/result_processing/admin_checks.rs b/ares-cli/src/orchestrator/result_processing/admin_checks.rs index 3c19a26f..be5afa05 100644 --- a/ares-cli/src/orchestrator/result_processing/admin_checks.rs +++ b/ares-cli/src/orchestrator/result_processing/admin_checks.rs @@ -127,13 +127,16 @@ pub(crate) fn extract_ip_from_line(line: &str) -> Option { /// Drives the SID extraction path so the same caller produces the same input /// regardless of which output convention the tool used. Pure — no Redis, no /// dispatcher. +#[cfg(test)] pub(crate) fn collect_payload_text_parts(payload: &Value) -> Vec { + collect_payload_text_parts_with_policy(payload, true) +} + +fn collect_payload_text_parts_with_policy( + payload: &Value, + include_legacy_scalar_outputs: bool, +) -> Vec { let mut parts: Vec = Vec::new(); - for key in &["tool_output", "output"] { - if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { - parts.push(s.to_string()); - } - } if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { for item in arr { if let Some(s) = item.as_str() { @@ -143,36 +146,33 @@ pub(crate) fn collect_payload_text_parts(payload: &Value) -> Vec { } } } + if include_legacy_scalar_outputs { + for key in &["tool_output", "output"] { + if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { + parts.push(s.to_string()); + } + } + } parts } -/// Scan a `payload`'s text fields for a "golden ticket saved" marker. +/// Scan trusted tool-output text fields for a "golden ticket saved" marker. /// /// Walks `tool_outputs` (string OR `{output: string}` form), then -/// `tool_output` / `output` / `summary`, then the explicit -/// `has_golden_ticket: true` flag. Mirrors the gate inside -/// `check_golden_ticket_completion` so the detection rule can be tested -/// against a synthetic payload without a Dispatcher. +/// legacy worker `tool_output` / `output`. Agent-completion `summary` and +/// `has_golden_ticket: true` are intentionally ignored. +#[cfg(test)] pub(crate) fn payload_contains_golden_ticket_marker(payload: &Value) -> bool { - if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { - for item in arr { - let text = item - .as_str() - .or_else(|| item.get("output").and_then(|v| v.as_str())) - .unwrap_or(""); - if has_golden_ticket_indicator(text) { - return true; - } - } - } - for key in &["tool_output", "output", "summary"] { - if let Some(text) = payload.get(*key).and_then(|v| v.as_str()) { - if has_golden_ticket_indicator(text) { - return true; - } - } - } - payload.get("has_golden_ticket").and_then(|v| v.as_bool()) == Some(true) + payload_contains_golden_ticket_marker_with_policy(payload, true) +} + +fn payload_contains_golden_ticket_marker_with_policy( + payload: &Value, + include_legacy_scalar_outputs: bool, +) -> bool { + collect_payload_text_parts_with_policy(payload, include_legacy_scalar_outputs) + .into_iter() + .any(|text| has_golden_ticket_indicator(&text)) } /// Extract a domain SID and (optional) flat name from already-collected text. @@ -269,6 +269,8 @@ pub(crate) async fn check_domain_admin_indicators(payload: &Value, dispatcher: & pub(crate) async fn check_golden_ticket_completion( payload: &Value, task_id: &str, + task_domain: Option<&str>, + include_legacy_scalar_outputs: bool, dispatcher: &Arc, ) { if !task_id.contains("exploit") && !task_id.contains("golden") { @@ -277,11 +279,13 @@ pub(crate) async fn check_golden_ticket_completion( // Per-domain dedup happens after we resolve `domain` below — a forge // for one domain must not block recording another (multi-domain ops // routinely capture krbtgt for parent + child or both forests). - if !payload_contains_golden_ticket_marker(payload) { + if !payload_contains_golden_ticket_marker_with_policy(payload, include_legacy_scalar_outputs) { return; } let mut domain = String::new(); - if let Some(d) = payload.get("domain").and_then(|v| v.as_str()) { + if let Some(d) = task_domain.filter(|d| !d.is_empty()) { + domain = d.to_string(); + } else if let Some(d) = payload.get("domain").and_then(|v| v.as_str()) { domain = d.to_string(); } // Require a krbtgt hash to actually exist for the chosen domain before @@ -435,8 +439,13 @@ pub(crate) async fn detect_and_upgrade_admin_credentials(text: &str, dispatcher: } } -pub(crate) async fn extract_and_cache_domain_sid(payload: &Value, dispatcher: &Arc) { - let text_parts = collect_payload_text_parts(payload); +pub(crate) async fn extract_and_cache_domain_sid( + payload: &Value, + task_domain: Option<&str>, + include_legacy_scalar_outputs: bool, + dispatcher: &Arc, +) { + let text_parts = collect_payload_text_parts_with_policy(payload, include_legacy_scalar_outputs); if text_parts.is_empty() { return; } @@ -468,12 +477,12 @@ pub(crate) async fn extract_and_cache_domain_sid(payload: &Value, dispatcher: &A // 1. Flat name parsed from the output — authoritative when present. For // impacket-lookupsid we get it from the RID lines (e.g. `500: FABRIKAM\…`); // for rpcclient lsaquery we get it from `Domain Name: FABRIKAM`. - // 2. Payload's `domain` field — used only when output has no flat name AND - // the field is a valid FQDN. The payload's domain is the *task* target, - // not necessarily the domain that produced the SID; trusting it blindly - // misattributed fabrikam.local's SID to child.contoso.local in - // op-20260429-112418. - // 3. State's primary domain — last resort, only when nothing else applies. + // 2. Trusted task domain captured from pending-task params before + // `complete_task` removed the entry. This is the orchestrator's own + // target realm, not an LLM-authored payload field. + // 3. Legacy payload `domain` field — only for non-rust-llm-runner paths + // where the result payload itself is still the tool transport. + // 4. State's primary domain — last resort, only when nothing else applies. let parsed_flat = lsaquery_flat.or_else(|| { ares_core::parsing::extract_domain_sid_and_flat_name(&combined).map(|(flat, _)| flat) }); @@ -492,12 +501,22 @@ pub(crate) async fn extract_and_cache_domain_sid(payload: &Value, dispatcher: &A None }) } else { - // No flat name in output. Fall back to payload domain or primary. - payload - .get("domain") - .and_then(|v| v.as_str()) + // No flat name in output. Fall back to trusted task domain, + // then legacy payload domain (if allowed), then primary. + task_domain .map(|d| d.to_lowercase()) .filter(|d| is_valid_domain_fqdn(d)) + .or_else(|| { + include_legacy_scalar_outputs + .then(|| { + payload + .get("domain") + .and_then(|v| v.as_str()) + .map(|d| d.to_lowercase()) + .filter(|d| is_valid_domain_fqdn(d)) + }) + .flatten() + }) .or_else(|| state.domains.first().map(|d| d.to_lowercase())) } }; @@ -831,7 +850,23 @@ mod tests { }); assert_eq!( collect_payload_text_parts(&p), - vec!["scalar", "bare-string", "from-object"] + vec!["bare-string", "from-object", "scalar"] + ); + } + + #[test] + fn collect_text_parts_policy_can_ignore_scalar_fields() { + let p = json!({ + "tool_output": "scalar", + "output": "also-scalar", + "tool_outputs": [ + "bare-string", + {"output": "from-object"}, + ], + }); + assert_eq!( + collect_payload_text_parts_with_policy(&p, false), + vec!["bare-string", "from-object"] ); } @@ -869,11 +904,11 @@ mod tests { } #[test] - fn gt_marker_in_summary() { + fn gt_marker_ignores_summary() { let p = json!({ "summary": "Saving ticket in admin.ccache; krbtgt forged", }); - assert!(payload_contains_golden_ticket_marker(&p)); + assert!(!payload_contains_golden_ticket_marker(&p)); } #[test] @@ -885,11 +920,21 @@ mod tests { } #[test] - fn gt_marker_via_explicit_flag() { + fn gt_marker_policy_ignores_scalar_fields_when_disabled() { + let p = json!({ + "tool_output": "Saving ticket in foo.ccache", + }); + assert!(!payload_contains_golden_ticket_marker_with_policy( + &p, false + )); + } + + #[test] + fn gt_marker_ignores_explicit_flag() { let p = json!({ "has_golden_ticket": true, }); - assert!(payload_contains_golden_ticket_marker(&p)); + assert!(!payload_contains_golden_ticket_marker(&p)); } #[test] diff --git a/ares-cli/src/orchestrator/result_processing/discovery_polling.rs b/ares-cli/src/orchestrator/result_processing/discovery_polling.rs index 69c2fbdd..ace11db0 100644 --- a/ares-cli/src/orchestrator/result_processing/discovery_polling.rs +++ b/ares-cli/src/orchestrator/result_processing/discovery_polling.rs @@ -185,28 +185,128 @@ async fn poll_discoveries(dispatcher: &Dispatcher) -> Result<()> { /// Check if a task result contains lockout error indicators. pub(crate) fn has_lockout_in_result(result: &crate::orchestrator::task_queue::TaskResult) -> bool { - if let Some(ref err) = result.error { - if LOCKOUT_PATTERNS.iter().any(|p| err.contains(p)) { - return true; + let from_rust_llm_runner = result.worker_pod.as_deref() == Some("rust-llm-runner"); + if !from_rust_llm_runner { + if let Some(ref err) = result.error { + if LOCKOUT_PATTERNS.iter().any(|p| err.contains(p)) { + return true; + } } } if let Some(ref payload) = result.result { if let Some(outputs) = payload.get("tool_outputs").and_then(|v| v.as_array()) { for output in outputs { - if let Some(text) = output.as_str() { - if LOCKOUT_PATTERNS.iter().any(|p| text.contains(p)) { - return true; - } + let text = output + .as_str() + .or_else(|| output.get("output").and_then(|v| v.as_str())); + if text.is_some_and(|t| LOCKOUT_PATTERNS.iter().any(|p| t.contains(p))) { + return true; } } } - for key in &["summary", "output", "tool_output"] { - if let Some(text) = payload.get(*key).and_then(|v| v.as_str()) { - if LOCKOUT_PATTERNS.iter().any(|p| text.contains(p)) { - return true; + if !from_rust_llm_runner { + for key in &["output", "tool_output"] { + if let Some(text) = payload.get(*key).and_then(|v| v.as_str()) { + if LOCKOUT_PATTERNS.iter().any(|p| text.contains(p)) { + return true; + } } } } } false } + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + use super::has_lockout_in_result; + use crate::orchestrator::task_queue::TaskResult; + + fn task_result( + result: Option, + error: Option<&str>, + worker_pod: Option<&str>, + ) -> TaskResult { + TaskResult { + task_id: "task-1".to_string(), + success: false, + result, + error: error.map(str::to_string), + completed_at: None, + worker_pod: worker_pod.map(str::to_string), + agent_name: None, + } + } + + #[test] + fn lockout_ignores_rust_llm_runner_error_text() { + let result = task_result( + None, + Some("Assistance needed: observed STATUS_ACCOUNT_LOCKED_OUT"), + Some("rust-llm-runner"), + ); + + assert!(!has_lockout_in_result(&result)); + } + + #[test] + fn lockout_detects_non_llm_worker_error_text() { + let result = task_result( + None, + Some("netexec failed with STATUS_ACCOUNT_LOCKED_OUT"), + Some("legacy-worker"), + ); + + assert!(has_lockout_in_result(&result)); + } + + #[test] + fn lockout_ignores_summary_text() { + let result = task_result( + Some(json!({"summary": "STATUS_ACCOUNT_LOCKED_OUT for alice"})), + None, + Some("rust-llm-runner"), + ); + + assert!(!has_lockout_in_result(&result)); + } + + #[test] + fn lockout_ignores_rust_llm_runner_scalar_output_text() { + let result = task_result( + Some(json!({"output": "STATUS_ACCOUNT_LOCKED_OUT for alice"})), + None, + Some("rust-llm-runner"), + ); + + assert!(!has_lockout_in_result(&result)); + } + + #[test] + fn lockout_detects_non_llm_worker_scalar_output_text() { + let result = task_result( + Some(json!({"output": "STATUS_ACCOUNT_LOCKED_OUT for alice"})), + None, + Some("legacy-worker"), + ); + + assert!(has_lockout_in_result(&result)); + } + + #[test] + fn lockout_detects_tool_output_text_for_rust_llm_runner() { + let result = task_result( + Some(json!({ + "tool_outputs": [ + {"output": "[-] CONTOSO\\alice:pw STATUS_ACCOUNT_LOCKED_OUT"} + ] + })), + None, + Some("rust-llm-runner"), + ); + + assert!(has_lockout_in_result(&result)); + } +} diff --git a/ares-cli/src/orchestrator/result_processing/impacket_recovery.rs b/ares-cli/src/orchestrator/result_processing/impacket_recovery.rs index 38dcd174..627584f6 100644 --- a/ares-cli/src/orchestrator/result_processing/impacket_recovery.rs +++ b/ares-cli/src/orchestrator/result_processing/impacket_recovery.rs @@ -57,11 +57,20 @@ impl ImpacketFailureClass { /// Returns `None` when no recognised Impacket failure pattern is present — /// genuinely bad credentials (with the same status code) fall through here and /// are filtered out by `credential_is_known_good`, not the classifier. +#[cfg(test)] pub fn classify_impacket_failure( result: &Option, error: Option<&str>, ) -> Option { - let text = collect_failure_text(result, error); + classify_impacket_failure_with_policy(result, error, true) +} + +fn classify_impacket_failure_with_policy( + result: &Option, + error: Option<&str>, + include_legacy_scalar_outputs: bool, +) -> Option { + let text = collect_failure_text_with_policy(result, error, include_legacy_scalar_outputs); if text.is_empty() { return None; } @@ -101,7 +110,16 @@ pub fn classify_impacket_failure( /// `result_has_seimpersonate_signal` so we only see tool stdout, not LLM /// commentary (LLM summaries can include status codes copied from a *prior* /// tool call and would false-positive the classifier). +#[cfg(test)] fn collect_failure_text(result: &Option, error: Option<&str>) -> String { + collect_failure_text_with_policy(result, error, true) +} + +fn collect_failure_text_with_policy( + result: &Option, + error: Option<&str>, + include_legacy_scalar_outputs: bool, +) -> String { let mut parts: Vec = Vec::new(); if let Some(err) = error { parts.push(err.to_string()); @@ -109,11 +127,6 @@ fn collect_failure_text(result: &Option, error: Option<&str>) -> String { let Some(payload) = result else { return parts.join("\n"); }; - for key in &["tool_output", "output", "summary"] { - if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { - parts.push(s.to_string()); - } - } if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { for item in arr { if let Some(s) = item.as_str() { @@ -123,6 +136,13 @@ fn collect_failure_text(result: &Option, error: Option<&str>) -> String { } } } + if include_legacy_scalar_outputs { + for key in &["tool_output", "output"] { + if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { + parts.push(s.to_string()); + } + } + } parts.join("\n") } @@ -195,6 +215,7 @@ pub async fn attempt_recovery( task_params: &HashMap, result: &Option, error: Option<&str>, + include_legacy_scalar_outputs: bool, ) -> bool { // Cheap exit: we only recover credential_access secretsdump tasks today. // Extending to lateral-movement / kerberoast lives behind the same gate. @@ -206,7 +227,9 @@ pub async fn attempt_recovery( return false; } - let Some(class) = classify_impacket_failure(result, error) else { + let Some(class) = + classify_impacket_failure_with_policy(result, error, include_legacy_scalar_outputs) + else { return false; }; @@ -480,4 +503,20 @@ mod tests { assert!(text.contains("first")); assert!(text.contains("second")); } + + #[test] + fn collect_failure_text_policy_ignores_scalar_output_when_disabled() { + let result = Some(json!({ + "tool_output": "stdout text", + "tool_outputs": [ + "first", + {"output": "second"} + ] + })); + let text = collect_failure_text_with_policy(&result, Some("task error"), false); + assert!(text.contains("task error")); + assert!(!text.contains("stdout text")); + assert!(text.contains("first")); + assert!(text.contains("second")); + } } diff --git a/ares-cli/src/orchestrator/result_processing/mod.rs b/ares-cli/src/orchestrator/result_processing/mod.rs index 18a73df6..02dc2871 100644 --- a/ares-cli/src/orchestrator/result_processing/mod.rs +++ b/ares-cli/src/orchestrator/result_processing/mod.rs @@ -47,6 +47,10 @@ use self::timeline::{ pub(crate) const LOCKOUT_PATTERNS: &[&str] = &["KDC_ERR_CLIENT_REVOKED", "STATUS_ACCOUNT_LOCKED_OUT"]; +fn legacy_scalar_outputs_allowed(worker_pod: Option<&str>) -> bool { + worker_pod != Some("rust-llm-runner") +} + /// Process a completed task result: extract discoveries and update state. pub async fn process_completed_task( completed: &CompletedTask, @@ -55,6 +59,7 @@ pub async fn process_completed_task( ) { let task_id = &completed.task_id; let result = &completed.result; + let include_legacy_scalar_outputs = legacy_scalar_outputs_allowed(result.worker_pod.as_deref()); // Extract task-level metadata from pending_tasks before complete_task removes it. // The full params snapshot is captured so the Impacket failure classifier @@ -98,6 +103,7 @@ pub async fn process_completed_task( success: result.success, result: result.result.clone(), error: result.error.clone(), + worker_pod: result.worker_pod.clone(), completed_at: result.completed_at.unwrap_or_else(chrono::Utc::now), }; let _ = dispatcher @@ -132,6 +138,7 @@ pub async fn process_completed_task( &task_params_snapshot, &result.result, result.error.as_deref(), + include_legacy_scalar_outputs, ) .await; } @@ -207,7 +214,13 @@ pub async fn process_completed_task( // Domain SID extraction: scan raw text for S-1-5-21-... patterns (from secretsdump). // Caches the SID for golden ticket generation without needing lookupsid. if let Some(ref payload) = result.result { - extract_and_cache_domain_sid(payload, dispatcher).await; + extract_and_cache_domain_sid( + payload, + task_domain.as_deref(), + include_legacy_scalar_outputs, + dispatcher, + ) + .await; } // S4U auto-chain: detect .ccache in output and dispatch secretsdump with ticket. @@ -215,12 +228,28 @@ pub async fn process_completed_task( // Kerberos ticket (.ccache), chain a secretsdump using that ticket for // immediate credential extraction. if let Some(ref payload) = result.result { - auto_chain_s4u_secretsdump(payload, dispatcher, &completed.task_id).await; + auto_chain_s4u_secretsdump( + payload, + dispatcher, + &completed.task_id, + &task_params_snapshot, + task_domain.as_deref(), + task_target_ip.as_deref(), + include_legacy_scalar_outputs, + ) + .await; } if result.success { if let Some(ref payload) = result.result { - check_golden_ticket_completion(payload, &completed.task_id, dispatcher).await; + check_golden_ticket_completion( + payload, + &completed.task_id, + task_domain.as_deref(), + include_legacy_scalar_outputs, + dispatcher, + ) + .await; } } @@ -247,8 +276,11 @@ pub async fn process_completed_task( // `discoveries`. Treat the ticket save as the success signal // for those vuln types so the scoreboard credits the // primitive on getST exit-0. - let has_ticket_evidence = - is_ticket_grant_vuln(&vuln_id) && result_has_ccache_evidence(&result.result); + let has_ticket_evidence = is_ticket_grant_vuln(&vuln_id) + && result_has_ccache_evidence_with_policy( + &result.result, + include_legacy_scalar_outputs, + ); // Stall-tolerance: when the LLM ends its turn without calling // task_complete (LoopEndReason::MaxSteps or budget exhaustion), // submission.rs stamps `success=false` with an error string @@ -343,7 +375,10 @@ pub async fn process_completed_task( // task — when a specific user trips STATUS_ACCOUNT_LOCKED_OUT we // remember that principal so future enum tasks can skip it. if has_lockout_in_result(result) { - let locked = extract_locked_usernames_from_result(&result.result); + let locked = extract_locked_usernames_from_result_with_policy( + &result.result, + include_legacy_scalar_outputs, + ); if !locked.is_empty() { let resolved_domain = if let Some(ref td) = task_domain { td.clone() @@ -373,7 +408,7 @@ pub async fn process_completed_task( // mark exploited so the scoreboard credits the primitive. The follow-on // potato dispatch is left for the existing privesc agent (already wired // with godpotato / printspoofer tools) to consume opportunistically. - if result_has_seimpersonate_signal(&result.result) { + if result_has_seimpersonate_signal_with_policy(&result.result, include_legacy_scalar_outputs) { let host_label = derive_seimpersonate_host_label(dispatcher, task_target_ip.as_deref()).await; let vuln_id = format!("seimpersonate_{}", host_label); @@ -493,7 +528,7 @@ pub async fn process_completed_task( // hash is trivially crackable). Tokenize on positive observation. if tech == "ntlmv1_downgrade_check" && result.success - && result_has_ntlmv1_signal(&result.result) + && result_has_ntlmv1_signal_with_policy(&result.result, include_legacy_scalar_outputs) { let dc_label = task_target_ip .clone() @@ -588,16 +623,8 @@ async fn task_relay_target_from_pending( /// authentication. Recognises both the explicit "NTLMv1 allowed" / "NTLM /// downgrade" prose forms and the canonical `LmCompatibilityLevel: <0..2>` /// registry probe output. -fn result_has_ntlmv1_signal(result: &Option) -> bool { - let Some(payload) = result.as_ref() else { - return false; - }; +fn collect_result_text_parts(payload: &Value, include_legacy_scalar_outputs: bool) -> Vec { let mut texts: Vec = Vec::new(); - for key in &["tool_output", "output", "summary"] { - if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { - texts.push(s.to_string()); - } - } if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { for item in arr { if let Some(s) = item.as_str() { @@ -607,6 +634,29 @@ fn result_has_ntlmv1_signal(result: &Option) -> bool { } } } + if include_legacy_scalar_outputs { + for key in &["tool_output", "output"] { + if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { + texts.push(s.to_string()); + } + } + } + texts +} + +#[cfg(test)] +fn result_has_ntlmv1_signal(result: &Option) -> bool { + result_has_ntlmv1_signal_with_policy(result, true) +} + +fn result_has_ntlmv1_signal_with_policy( + result: &Option, + include_legacy_scalar_outputs: bool, +) -> bool { + let Some(payload) = result.as_ref() else { + return false; + }; + let texts = collect_result_text_parts(payload, include_legacy_scalar_outputs); for text in texts { let lower = text.to_lowercase(); // Explicit positive verdict lines. Kept narrow on purpose — the @@ -665,31 +715,24 @@ async fn derive_seimpersonate_host_label( "unknown".to_string() } -/// Returns `true` when any text payload on the result contains a recognised +/// Returns `true` when trusted tool-output payloads contain a recognised /// SeImpersonate signal. Conservative — only matches `SeImpersonatePrivilege` /// alongside an `Enabled` token (the format `whoami /priv` uses). This avoids -/// false positives from output that merely *mentions* the privilege name -/// (e.g. recon plans or LLM commentary). +/// false positives from output that merely *mentions* the privilege name. +#[cfg(test)] fn result_has_seimpersonate_signal(result: &Option) -> bool { + result_has_seimpersonate_signal_with_policy(result, true) +} + +fn result_has_seimpersonate_signal_with_policy( + result: &Option, + include_legacy_scalar_outputs: bool, +) -> bool { let Some(payload) = result else { return false; }; - let mut texts: Vec = Vec::new(); - if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { - for item in arr { - if let Some(s) = item.as_str() { - texts.push(s.to_string()); - } else if let Some(s) = item.get("output").and_then(|v| v.as_str()) { - texts.push(s.to_string()); - } - } - } - for key in &["summary", "output", "tool_output"] { - if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { - texts.push(s.to_string()); - } - } + let texts = collect_result_text_parts(payload, include_legacy_scalar_outputs); for text in texts { for line in text.lines() { @@ -711,8 +754,8 @@ fn result_has_seimpersonate_signal(result: &Option) -> bool { } /// Extract `(username, optional domain)` pairs from a tool result that -/// reported a per-user lockout. Looks at `tool_outputs`, `output`, -/// `tool_output`, and `summary` fields for netexec-style lines such as: +/// reported a per-user lockout. Looks at trusted `tool_outputs`, `output`, +/// and `tool_output` fields for netexec-style lines such as: /// /// `[-] DOMAIN\\username:password STATUS_ACCOUNT_LOCKED_OUT` /// `[-] username:password KDC_ERR_CLIENT_REVOKED` @@ -720,29 +763,23 @@ fn result_has_seimpersonate_signal(result: &Option) -> bool { /// Returns lower-cased usernames; the domain (if present in the prefix) is /// also lowercased. Used by `process_completed_task` to populate /// `quarantined_principals` for enumeration tasks that lack a `cred_key`. +#[cfg(test)] pub(crate) fn extract_locked_usernames_from_result( result: &Option, +) -> Vec<(String, Option)> { + extract_locked_usernames_from_result_with_policy(result, true) +} + +fn extract_locked_usernames_from_result_with_policy( + result: &Option, + include_legacy_scalar_outputs: bool, ) -> Vec<(String, Option)> { let mut out: Vec<(String, Option)> = Vec::new(); let Some(payload) = result else { return out; }; - let mut texts: Vec = Vec::new(); - if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { - for item in arr { - if let Some(s) = item.as_str() { - texts.push(s.to_string()); - } else if let Some(s) = item.get("output").and_then(|v| v.as_str()) { - texts.push(s.to_string()); - } - } - } - for key in &["summary", "output", "tool_output"] { - if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { - texts.push(s.to_string()); - } - } + let texts = collect_result_text_parts(payload, include_legacy_scalar_outputs); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); for text in texts { @@ -871,25 +908,19 @@ fn is_ticket_grant_vuln(vuln_id: &str) -> bool { /// output blobs. Conservative — requires either the explicit "Saving /// ticket" preamble or a `.ccache` token to avoid crediting tasks that /// merely *reference* a ticket path in commentary. +#[cfg(test)] fn result_has_ccache_evidence(result: &Option) -> bool { + result_has_ccache_evidence_with_policy(result, true) +} + +fn result_has_ccache_evidence_with_policy( + result: &Option, + include_legacy_scalar_outputs: bool, +) -> bool { let Some(payload) = result.as_ref() else { return false; }; - let mut texts: Vec = Vec::new(); - for key in &["tool_output", "output", "summary"] { - if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { - texts.push(s.to_string()); - } - } - if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { - for item in arr { - if let Some(s) = item.as_str() { - texts.push(s.to_string()); - } else if let Some(s) = item.get("output").and_then(|v| v.as_str()) { - texts.push(s.to_string()); - } - } - } + let texts = collect_result_text_parts(payload, include_legacy_scalar_outputs); for text in texts { let lower = text.to_lowercase(); if lower.contains("saving ticket in") && lower.contains(".ccache") { @@ -1102,31 +1133,18 @@ fn roast_exploit_token(hash_value: &str, username: &str, domain: &str) -> Option } } -/// S4U auto-chain: detect .ccache ticket in task output and dispatch secretsdump. -/// -/// Mirrors Python's `_auto_chain_s4u_lateral_movement` — when a task produces a -/// Kerberos ticket file (.ccache), automatically dispatch a secretsdump task using -/// that ticket. This chains S4U/delegation → secretsdump without waiting for the -/// next automation cycle. -async fn auto_chain_s4u_secretsdump(payload: &Value, dispatcher: &Arc, task_id: &str) { - // Collect ONLY raw tool output fields — never LLM-generated summaries. - let mut text_parts: Vec<&str> = Vec::new(); - for key in &["tool_output", "output"] { - if let Some(s) = payload.get(*key).and_then(|v| v.as_str()) { - text_parts.push(s); - } - } - if let Some(arr) = payload.get("tool_outputs").and_then(|v| v.as_array()) { - for item in arr { - if let Some(s) = item.as_str() { - text_parts.push(s); - } else if let Some(s) = item.get("output").and_then(|v| v.as_str()) { - text_parts.push(s); - } - } - } - - let combined = text_parts.join("\n"); +async fn auto_chain_s4u_secretsdump( + payload: &Value, + dispatcher: &Arc, + task_id: &str, + task_params: &std::collections::HashMap, + task_domain: Option<&str>, + task_target_ip: Option<&str>, + include_legacy_scalar_outputs: bool, +) { + // Collect ONLY tool-emitted text. For rust-llm-runner tasks, top-level + // scalar `output` is LLM narrative and must not trigger follow-on work. + let combined = collect_result_text_parts(payload, include_legacy_scalar_outputs).join("\n"); let ticket_path = match ares_llm::routing::extract_ticket_path(&combined) { Some(p) => p, None => return, // No .ccache found @@ -1138,28 +1156,23 @@ async fn auto_chain_s4u_secretsdump(payload: &Value, dispatcher: &Arc = { - let state = dispatcher.state.read().await; - state - .pending_tasks - .get(task_id) - .map(|t| serde_json::to_value(&t.params).unwrap_or_default()) - }; - - // Helper: look up a string field from original params first, then result payload. + // Helper: prefer the trusted task snapshot captured before complete_task + // removed the pending-task entry. Only legacy workers may fall back to + // payload fields; rust-llm-runner payload scalars are model-authored. let get_param = |key: &str| -> Option<&str> { - original_params - .as_ref() - .and_then(|p| p.get(key)) - .and_then(|v| v.as_str()) - .or_else(|| payload.get(key).and_then(|v| v.as_str())) + task_params.get(key).and_then(|v| v.as_str()).or_else(|| { + include_legacy_scalar_outputs + .then(|| payload.get(key).and_then(|v| v.as_str())) + .flatten() + }) }; - // Try to extract target from the original task params or ccache filename + // Try to extract target from the trusted task params first, then ccache + // filename. Payload fallback is legacy-only via `get_param`. let target_ip = get_param("target_spn") .and_then(ares_llm::routing::extract_host_from_spn) + .or_else(|| get_param("target_ip").map(|s| s.to_string())) + .or_else(|| get_param("target").map(|s| s.to_string())) .or_else(|| { // Try to parse target from ccache filename: // Administrator@CIFS_dc01@CHILD.CONTOSO.LOCAL.ccache @@ -1178,8 +1191,7 @@ async fn auto_chain_s4u_secretsdump(payload: &Value, dispatcher: &Arc ip, @@ -1206,12 +1218,17 @@ async fn auto_chain_s4u_secretsdump(payload: &Value, dispatcher: &Arc bool { + value.len() == 32 && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn is_valid_ntlm_hash_value(value: &str) -> bool { + let parts: Vec<&str> = value.split(':').collect(); + match parts.as_slice() { + [nt] => is_hex32(nt), + [lm, nt] => is_hex32(lm) && is_hex32(nt), + _ => false, + } +} + impl SharedState { /// Add a credential to state and Redis (with dedup). /// @@ -142,18 +155,18 @@ impl SharedState { // Mirrors the credential-side fix in `sanitize_credential`. hash.domain = hash.domain.to_lowercase(); - // Reject malformed NTLM hashes before they enter state. NTLM relay - // artifacts sometimes produce 33-char values (relay timestamp suffix - // or partial capture); sprayhound/secretsdump both hard-fail on - // non-32-char hex, so storing them only causes agent confusion. + // Reject malformed NTLM hashes before they enter state. Accept both a + // bare NT half and standard secretsdump LM:NT pairs; tools can consume + // either, but relay artifacts with partial/extra bytes only cause + // downstream auth confusion. if hash.hash_type.to_lowercase() == "ntlm" { let v = &hash.hash_value; - if v.len() != 32 || !v.chars().all(|c| c.is_ascii_hexdigit()) { + if !is_valid_ntlm_hash_value(v) { tracing::warn!( username = %hash.username, domain = %hash.domain, hash_len = v.len(), - "Dropping malformed NTLM hash (expected 32 hex chars)" + "Dropping malformed NTLM hash (expected 32 hex chars or LM:NT)" ); return Ok(false); } @@ -766,6 +779,21 @@ mod tests { assert_eq!(s.hashes[0].username, "admin"); } + #[tokio::test] + async fn publish_hash_accepts_secretsdump_lm_nt_pair() { + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + + let lm_nt = format!("aad3b435b51404eeaad3b435b51404ee:{NTLM_HASH_A}"); + let hash = make_hash("admin", "contoso.local", "NTLM", &lm_nt); + let added = state.publish_hash(&q, hash).await.unwrap(); + assert!(added); + + let s = state.inner.read().await; + assert_eq!(s.hashes.len(), 1); + assert_eq!(s.hashes[0].hash_value, lm_nt); + } + #[tokio::test] async fn publish_hash_dedup() { let state = SharedState::new("op-1".to_string()); @@ -813,6 +841,26 @@ mod tests { assert!(s.dominated_domains.contains("contoso.local")); } + #[tokio::test] + async fn publish_krbtgt_lm_nt_hash_sets_domain_admin() { + let state = SharedState::new("op-1".to_string()); + let q = mock_queue(); + + { + let mut s = state.inner.write().await; + s.domains.push("contoso.local".to_string()); + } + + let lm_nt = format!("aad3b435b51404eeaad3b435b51404ee:{NTLM_HASH_A}"); + let hash = make_hash("krbtgt", "contoso.local", "NTLM", &lm_nt); + state.publish_hash(&q, hash).await.unwrap(); + + let s = state.inner.read().await; + assert!(s.has_domain_admin); + assert!(s.dominated_domains.contains("contoso.local")); + assert_eq!(s.hashes[0].hash_value, lm_nt); + } + #[tokio::test] async fn publish_krbtgt_hash_mirrors_dominated_to_redis_set() { // SCARD ares:op::dominated_domains should reflect the in-memory diff --git a/ares-cli/src/orchestrator/state/publishing/entities.rs b/ares-cli/src/orchestrator/state/publishing/entities.rs index 41396ae8..529bb473 100644 --- a/ares-cli/src/orchestrator/state/publishing/entities.rs +++ b/ares-cli/src/orchestrator/state/publishing/entities.rs @@ -784,6 +784,7 @@ mod tests { success: true, result: Some(serde_json::json!({"output": "NT AUTHORITY\\SYSTEM"})), error: None, + worker_pod: None, completed_at: Utc::now(), }; state.complete_task(&q, "task-42", result).await.unwrap(); diff --git a/ares-cli/src/worker/hosts.rs b/ares-cli/src/worker/hosts.rs index cedd6834..d4449ffe 100644 --- a/ares-cli/src/worker/hosts.rs +++ b/ares-cli/src/worker/hosts.rs @@ -219,12 +219,12 @@ mod tests { #[test] fn build_host_entries_dc_subdomain() { - let hosts = vec![make_host("192.168.58.15", "dc02.north.contoso.local", true)]; + let hosts = vec![make_host("192.168.58.15", "dc02.child.contoso.local", true)]; let entries = build_host_entries(&hosts, &HashSet::new()); assert_eq!(entries.len(), 1); assert_eq!( entries[0], - "192.168.58.15 dc02.north.contoso.local dc02 north.contoso.local" + "192.168.58.15 dc02.child.contoso.local dc02 child.contoso.local" ); } diff --git a/ares-core/src/models/operation.rs b/ares-core/src/models/operation.rs index c47767fc..511ceaa8 100644 --- a/ares-core/src/models/operation.rs +++ b/ares-core/src/models/operation.rs @@ -16,6 +16,9 @@ pub struct OperationMeta { pub domain_admin_path: Option, pub started_at: Option>, pub completed_at: Option>, + pub red_completed_at: Option>, + pub red_completion_reason: Option, + pub red_blocked_on_blue: bool, pub target_ip: Option, pub target_domain: Option, pub target_ips: Vec, @@ -40,6 +43,11 @@ impl OperationMeta { .and_then(|s| parse_meta_datetime(s)) .map(|dt| dt.with_timezone(&Utc)); + let red_completed_at = data + .get("red_completed_at") + .and_then(|s| parse_meta_datetime(s)) + .map(|dt| dt.with_timezone(&Utc)); + let target_ips = data .get("target_ips") .map(|s| parse_meta_string_list(s)) @@ -59,6 +67,14 @@ impl OperationMeta { .and_then(|s| parse_meta_string(s)), started_at, completed_at, + red_completed_at, + red_completion_reason: data + .get("red_completion_reason") + .and_then(|s| parse_meta_string(s)), + red_blocked_on_blue: data + .get("red_blocked_on_blue") + .map(|v| parse_meta_bool(v)) + .unwrap_or(false), target_ip: data.get("target_ip").and_then(|s| parse_meta_string(s)), target_domain: data.get("target_domain").and_then(|s| parse_meta_string(s)), target_ips, @@ -345,11 +361,37 @@ mod tests { assert!(meta.completed_at.is_some()); } + #[test] + fn operation_meta_red_completion_fields() { + let mut data = HashMap::new(); + data.insert( + "red_completed_at".to_string(), + r#""2026-05-15T22:04:43+00:00""#.to_string(), + ); + data.insert( + "red_completion_reason".to_string(), + r#""all forests dominated (post-exploitation complete)""#.to_string(), + ); + data.insert("red_blocked_on_blue".to_string(), "true".to_string()); + + let meta = OperationMeta::from_redis_hash(&data); + + assert!(meta.red_completed_at.is_some()); + assert_eq!( + meta.red_completion_reason.as_deref(), + Some("all forests dominated (post-exploitation complete)") + ); + assert!(meta.red_blocked_on_blue); + } + #[test] fn operation_meta_default_derives() { let meta = OperationMeta::default(); assert!(!meta.has_domain_admin); assert!(!meta.has_golden_ticket); + assert!(meta.red_completed_at.is_none()); + assert!(meta.red_completion_reason.is_none()); + assert!(!meta.red_blocked_on_blue); assert!(meta.target_ips.is_empty()); } @@ -860,6 +902,9 @@ pub struct SharedRedTeamState { pub target_ips: Vec, pub started_at: DateTime, pub completed_at: Option>, + pub red_completed_at: Option>, + pub red_completion_reason: Option, + pub red_blocked_on_blue: bool, // Global discoveries pub all_domains: Vec, @@ -913,6 +958,9 @@ impl SharedRedTeamState { target_ips: Vec::new(), started_at: Utc::now(), completed_at: None, + red_completed_at: None, + red_completion_reason: None, + red_blocked_on_blue: false, all_domains: Vec::new(), all_credentials: Vec::new(), all_hashes: Vec::new(), diff --git a/ares-core/src/models/task.rs b/ares-core/src/models/task.rs index 5a854f24..c3383972 100644 --- a/ares-core/src/models/task.rs +++ b/ares-core/src/models/task.rs @@ -98,7 +98,8 @@ pub struct TaskInfo { /// Result of a completed task. /// -/// Matches Python: `class TaskResult` dataclass +/// Matches Python's completed-task payload shape with optional worker-side +/// provenance used by Rust orchestrator automations. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskResult { pub task_id: String, @@ -107,6 +108,8 @@ pub struct TaskResult { pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_pod: Option, #[serde(default = "chrono::Utc::now")] pub completed_at: DateTime, } diff --git a/ares-core/src/parsing/domain_sid.rs b/ares-core/src/parsing/domain_sid.rs index 472dcf4e..546bd3f8 100644 --- a/ares-core/src/parsing/domain_sid.rs +++ b/ares-core/src/parsing/domain_sid.rs @@ -82,7 +82,7 @@ pub fn extract_rid500_name(output: &str) -> Option { /// Returns `None` if either the SID or the flat name is missing — the caller /// must then resolve the FQDN itself rather than guessing from task context. /// -/// Why this matters: a task targeting `north.contoso.local` can produce output +/// Why this matters: a task targeting `child.contoso.local` can produce output /// referencing `S-1-5-21-…` for the trusted forest's domain (e.g. via lookupsid /// over a foreign trust). Anchoring to the flat name lets the caller map the /// SID to the correct FQDN via `netbios_to_fqdn` instead of misattributing it diff --git a/ares-core/src/reports/mod.rs b/ares-core/src/reports/mod.rs index 7c08bfe5..e2c85284 100644 --- a/ares-core/src/reports/mod.rs +++ b/ares-core/src/reports/mod.rs @@ -145,6 +145,9 @@ mod tests { target_ips: vec!["192.168.58.10".to_string()], started_at: Utc::now() - chrono::Duration::hours(1), completed_at: Some(Utc::now()), + red_completed_at: None, + red_completion_reason: None, + red_blocked_on_blue: false, all_domains: vec!["contoso.local".to_string()], all_credentials: Vec::new(), all_hashes: Vec::new(), @@ -185,6 +188,9 @@ mod tests { target_ips: vec!["192.168.58.10".to_string()], started_at: Utc::now() - chrono::Duration::hours(2), completed_at: Some(Utc::now()), + red_completed_at: None, + red_completion_reason: None, + red_blocked_on_blue: false, all_domains: vec!["contoso.local".to_string()], all_credentials: vec![Credential { id: "1".to_string(), diff --git a/ares-core/src/state/operations.rs b/ares-core/src/state/operations.rs index 8b4c9730..f5fb88fb 100644 --- a/ares-core/src/state/operations.rs +++ b/ares-core/src/state/operations.rs @@ -87,6 +87,12 @@ pub async fn finalize_operation( .await?; conn.hset::<_, _, _, ()>(&meta_key, "completed_at", &completed_at_json) .await?; + conn.hset::<_, _, _, ()>( + &meta_key, + "red_blocked_on_blue", + serde_json::to_string(&false).unwrap_or_default(), + ) + .await?; conn.expire::<_, ()>(&meta_key, 86400).await?; // 2. Write status key diff --git a/ares-core/src/state/reader.rs b/ares-core/src/state/reader.rs index 05ff698f..50085dab 100644 --- a/ares-core/src/state/reader.rs +++ b/ares-core/src/state/reader.rs @@ -223,6 +223,9 @@ impl RedisStateReader { target_ips, started_at: meta.started_at.unwrap_or_else(Utc::now), completed_at: meta.completed_at, + red_completed_at: meta.red_completed_at, + red_completion_reason: meta.red_completion_reason, + red_blocked_on_blue: meta.red_blocked_on_blue, all_domains: domains, all_credentials: credentials, all_hashes: hashes, diff --git a/ares-llm/src/routing/credentials.rs b/ares-llm/src/routing/credentials.rs index fe81168e..c2d2f45f 100644 --- a/ares-llm/src/routing/credentials.rs +++ b/ares-llm/src/routing/credentials.rs @@ -28,7 +28,7 @@ pub fn is_valid_credential_for_domain( } // Parent → child: target has more FQDN parts and ends with cred domain - // e.g. cred=contoso.local, target=north.contoso.local + // e.g. cred=contoso.local, target=child.contoso.local if target_lower.ends_with(&format!(".{cred_lower}")) { return true; } @@ -36,7 +36,7 @@ pub fn is_valid_credential_for_domain( // Child → parent: valid — NTLM/Kerberos authentication traverses the // parent-child trust bidirectionally. The target DC forwards the auth // request to the child domain DC via the trust's secure channel. - // e.g. cred=north.contoso.local, target=contoso.local + // e.g. cred=child.contoso.local, target=contoso.local if cred_lower.ends_with(&format!(".{target_lower}")) { return true; } @@ -192,7 +192,7 @@ mod tests { let trusts = HashMap::new(); assert!(is_valid_credential_for_domain( "contoso.local", - "north.contoso.local", + "child.contoso.local", &trusts )); } @@ -201,7 +201,7 @@ mod tests { fn child_to_parent_valid() { let trusts = HashMap::new(); assert!(is_valid_credential_for_domain( - "north.contoso.local", + "child.contoso.local", "contoso.local", &trusts )); @@ -244,7 +244,7 @@ mod tests { let trusts = HashMap::new(); let creds = vec![make_cred("admin", "contoso.local", "P@ss1")]; let map = HashMap::new(); - let found = find_domain_credential("north.contoso.local", &creds, &map, &trusts); + let found = find_domain_credential("child.contoso.local", &creds, &map, &trusts); assert!(found.is_some()); assert_eq!(found.unwrap().domain, "contoso.local"); } @@ -252,10 +252,10 @@ mod tests { #[test] fn child_cred_valid_for_parent_domain() { let trusts = HashMap::new(); - let creds = vec![make_cred("admin", "north.contoso.local", "P@ss1")]; + let creds = vec![make_cred("admin", "child.contoso.local", "P@ss1")]; let map = HashMap::new(); let found = find_domain_credential("contoso.local", &creds, &map, &trusts); assert!(found.is_some()); - assert_eq!(found.unwrap().domain, "north.contoso.local"); + assert_eq!(found.unwrap().domain, "child.contoso.local"); } } diff --git a/ares-llm/src/tool_registry/cracker.rs b/ares-llm/src/tool_registry/cracker.rs index e463004b..59bd7a2e 100644 --- a/ares-llm/src/tool_registry/cracker.rs +++ b/ares-llm/src/tool_registry/cracker.rs @@ -96,81 +96,30 @@ pub(super) fn tool_definitions() -> Vec { } pub(super) fn callback_definitions() -> Vec { - vec![ - // Re-added as a structured fallback. The preferred path is still - // auto-extraction from raw hashcat/john stdout in `output_extraction.rs` - // — that's lossless and doesn't trust LLM-generated values. But the - // cracker LLM agent has been observed reporting its result as natural- - // language text ("password = fr3edom") without piping the raw - // `--show` line into `tool_outputs`, leaving the extraction regex with - // nothing to match. When that happens, the cracked plaintext is lost. - // This callback gives the LLM an unambiguous structured channel so the - // cleartext lands in `state.credentials` even when the raw stdout path - // misses. The handler validates `password` through `is_valid_credential` - // (rejecting hash-shaped strings, truncation ellipsis, etc.), so the - // LLM can't pollute credentials with fabricated values. - ToolDefinition { - name: "report_cracked_credential".into(), - description: "Report a successfully cracked credential from hashcat or john output. \ - Use this ONLY after a cracker tool has emitted the actual cleartext password — \ - pass the exact plaintext from `hashcat --show` / `john --show` output. The system \ - will store the resulting credential and annotate the corresponding hash. \ - Do NOT use this for hashes you haven't actually cracked, or for guessed/inferred \ - passwords — the validator rejects anything that looks like a hash or a truncated \ - display string." - .into(), - input_schema: json!({ - "type": "object", - "properties": { - "username": { - "type": "string", - "description": "Username the cracked plaintext belongs to (no domain prefix)." - }, - "domain": { - "type": "string", - "description": "FQDN of the domain the user belongs to (e.g. 'contoso.local')." - }, - "password": { - "type": "string", - "description": "Cleartext password as printed by the cracker. Must NOT contain '...' (LLM truncation) or look like a hash." - }, - "hash_type": { - "type": "string", - "description": "Hash type that was cracked (e.g. 'asrep', 'kerberoast', 'ntlm')." - }, - "task_id": { - "type": "string", - "description": "The cracking task ID this credential was produced from." - } - }, - "required": ["username", "domain", "password"] - }), - }, - ToolDefinition { - name: "report_crack_failed".into(), - description: "Report that a cracking attempt failed and no password was recovered. \ + vec![ToolDefinition { + name: "report_crack_failed".into(), + description: "Report that a cracking attempt failed and no password was recovered. \ This allows the orchestrator to update task status and potentially retry with \ different parameters or a larger wordlist." - .into(), - input_schema: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "The task ID associated with this cracking job" - }, - "hash_value": { - "type": "string", - "description": "The hash value that could not be cracked" - }, - "reason": { - "type": "string", - "description": "Reason the cracking failed (e.g. exhausted wordlist, timeout, unsupported format). Defaults to 'exhausted wordlist'.", - "default": "exhausted wordlist" - } + .into(), + input_schema: json!({ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The task ID associated with this cracking job" }, - "required": ["task_id", "hash_value"] - }), - }, - ] + "hash_value": { + "type": "string", + "description": "The hash value that could not be cracked" + }, + "reason": { + "type": "string", + "description": "Reason the cracking failed (e.g. exhausted wordlist, timeout, unsupported format). Defaults to 'exhausted wordlist'.", + "default": "exhausted wordlist" + } + }, + "required": ["task_id", "hash_value"] + }), + }] } diff --git a/ares-llm/src/tool_registry/mod.rs b/ares-llm/src/tool_registry/mod.rs index 499ce0df..2951c9bb 100644 --- a/ares-llm/src/tool_registry/mod.rs +++ b/ares-llm/src/tool_registry/mod.rs @@ -62,7 +62,7 @@ impl AgentRole { } } -/// Names of callback tools that the agent loop handles directly. +/// Names of supported callback tools that the agent loop handles directly. /// /// Includes orchestrator query and dispatch tools — these are handled by a /// custom `CallbackHandler` (if provided) rather than being dispatched to workers. @@ -70,10 +70,6 @@ pub const CALLBACK_TOOLS: &[&str] = &[ // Universal callbacks "task_complete", "request_assistance", - // Re-added as structured fallback when the LLM cracker summarizes the - // result instead of piping raw `hashcat --show` stdout. Validator on - // the handler side rejects hash-shaped / truncated values. - "report_cracked_credential", "report_crack_failed", "report_finding", "report_lateral_success", @@ -102,9 +98,18 @@ pub const CALLBACK_TOOLS: &[&str] = &[ "dispatch_crack", ]; +/// Removed callback names that are still trapped in-process so a hallucinated +/// call receives a deterministic "tool removed" response instead of being +/// dispatched to a worker. +const REMOVED_CALLBACK_TOOLS: &[&str] = &[ + "record_credential", + "record_timeline_event", + "report_cracked_credential", +]; + /// Check if a tool name is a callback (handled in Rust, not dispatched). pub fn is_callback_tool(name: &str) -> bool { - CALLBACK_TOOLS.contains(&name) + CALLBACK_TOOLS.contains(&name) || REMOVED_CALLBACK_TOOLS.contains(&name) } /// JSON schema property keys that contain secret material. @@ -383,8 +388,12 @@ mod tests { // Reporting tools are callbacks (not dispatched to workers) assert!(is_callback_tool("record_compromised_host")); assert!(!is_callback_tool("record_weakness")); - assert!(!is_callback_tool("record_timeline_event")); + assert!(is_callback_tool("record_timeline_event")); + assert!(is_callback_tool("record_credential")); assert!(is_callback_tool("report_cracked_credential")); + assert!(!CALLBACK_TOOLS.contains(&"record_timeline_event")); + assert!(!CALLBACK_TOOLS.contains(&"record_credential")); + assert!(!CALLBACK_TOOLS.contains(&"report_cracked_credential")); assert!(!is_callback_tool("list_weaknesses")); assert!(is_callback_tool("list_credentials")); assert!(!is_callback_tool("nmap_scan")); @@ -531,10 +540,8 @@ mod tests { let tools = tools_for_role(AgentRole::Cracker); let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); assert!(names.contains(&"crack_with_hashcat")); - // Structured fallback for the cracker LLM agent when it doesn't pipe - // raw `hashcat --show` stdout — see cracker.rs for full rationale. - assert!(names.contains(&"report_cracked_credential")); assert!(names.contains(&"report_crack_failed")); + assert!(!names.contains(&"report_cracked_credential")); } #[test] diff --git a/ares-llm/src/tool_registry/privesc/tickets.rs b/ares-llm/src/tool_registry/privesc/tickets.rs index ccb6ff4f..648d774f 100644 --- a/ares-llm/src/tool_registry/privesc/tickets.rs +++ b/ares-llm/src/tool_registry/privesc/tickets.rs @@ -51,7 +51,7 @@ pub fn definitions() -> Vec { "properties": { "child_domain": { "type": "string", - "description": "Child domain FQDN (e.g. north.contoso.local)" + "description": "Child domain FQDN (e.g. child.contoso.local)" }, "username": { "type": "string", diff --git a/ares-tools/src/acl.rs b/ares-tools/src/acl.rs index 8eb2ec84..85c6fe9c 100644 --- a/ares-tools/src/acl.rs +++ b/ares-tools/src/acl.rs @@ -361,8 +361,8 @@ mod tests { #[test] fn domain_to_base_dn_nested() { assert_eq!( - domain_to_base_dn("north.contoso.local"), - "DC=north,DC=contoso,DC=local" + domain_to_base_dn("child.contoso.local"), + "DC=child,DC=contoso,DC=local" ); } diff --git a/ares-tools/src/credential_access/mod.rs b/ares-tools/src/credential_access/mod.rs index 1ae21274..da8111df 100644 --- a/ares-tools/src/credential_access/mod.rs +++ b/ares-tools/src/credential_access/mod.rs @@ -31,13 +31,13 @@ mod tests { /// Verify that the base_dn builder handles a deeper domain. #[test] fn base_dn_from_child_domain() { - let domain = "north.contoso.local"; + let domain = "child.contoso.local"; let dn: String = domain .split('.') .map(|p| format!("DC={p}")) .collect::>() .join(","); - assert_eq!(dn, "DC=north,DC=contoso,DC=local"); + assert_eq!(dn, "DC=child,DC=contoso,DC=local"); } /// Verify password_spray builds args for jitter correctly (presence only). @@ -92,9 +92,9 @@ mod tests { #[test] fn ldap_bind_dn_format() { let username = "jsmith"; - let domain = "north.contoso.local"; + let domain = "child.contoso.local"; let bind_dn = format!("{username}@{domain}"); - assert_eq!(bind_dn, "jsmith@north.contoso.local"); + assert_eq!(bind_dn, "jsmith@child.contoso.local"); } /// Verify ldap_search_descriptions ldap_uri format. diff --git a/ares-tools/src/lateral/execution.rs b/ares-tools/src/lateral/execution.rs index 183d3dae..2c328838 100644 --- a/ares-tools/src/lateral/execution.rs +++ b/ares-tools/src/lateral/execution.rs @@ -543,13 +543,13 @@ mod tests { #[test] fn smbexec_kerberos_target_format() { - let domain = "north.contoso.local"; + let domain = "child.contoso.local"; let username = "admin"; - let target = "dc02.north.contoso.local"; + let target = "dc02.child.contoso.local"; let target_str = format!("{domain}/{username}@{target}"); assert_eq!( target_str, - "north.contoso.local/admin@dc02.north.contoso.local" + "child.contoso.local/admin@dc02.child.contoso.local" ); } diff --git a/ares-tools/src/lateral/mssql.rs b/ares-tools/src/lateral/mssql.rs index 9f8e0bb6..7d392ede 100644 --- a/ares-tools/src/lateral/mssql.rs +++ b/ares-tools/src/lateral/mssql.rs @@ -35,7 +35,8 @@ fn mssql_from_args(args: &Value) -> Result { let username = required_str(args, "username")?; let password = optional_str(args, "password"); let domain = optional_str(args, "domain"); - let windows_auth = optional_bool(args, "windows_auth").unwrap_or(false); + let windows_auth = optional_bool(args, "windows_auth") + .unwrap_or_else(|| domain.is_some_and(|d| !d.is_empty())); Ok(mssql_base(domain, username, password, target, windows_auth)) } @@ -214,12 +215,27 @@ mod tests { } #[test] - fn mssql_windows_auth_default_false() { + fn mssql_windows_auth_default_false_without_domain() { let args = json!({"target": "192.168.58.1", "username": "sa"}); - let windows_auth = optional_bool(&args, "windows_auth").unwrap_or(false); + let domain = optional_str(&args, "domain"); + let windows_auth = optional_bool(&args, "windows_auth") + .unwrap_or_else(|| domain.is_some_and(|d| !d.is_empty())); assert!(!windows_auth); } + #[test] + fn mssql_windows_auth_default_true_with_domain() { + let args = json!({ + "target": "192.168.58.1", + "username": "svc_sql", + "domain": "contoso.local" + }); + let domain = optional_str(&args, "domain"); + let windows_auth = optional_bool(&args, "windows_auth") + .unwrap_or_else(|| domain.is_some_and(|d| !d.is_empty())); + assert!(windows_auth); + } + #[test] fn mssql_windows_auth_explicit_true() { let args = json!({ diff --git a/ares-tools/src/parsers/mod.rs b/ares-tools/src/parsers/mod.rs index b807e499..95e53ffa 100644 --- a/ares-tools/src/parsers/mod.rs +++ b/ares-tools/src/parsers/mod.rs @@ -732,8 +732,8 @@ PORT STATE SERVICE\n\ #[test] fn parse_netexec_users_table_format() { - let output = r#"SMB 192.168.58.121 445 DC01 [*] Windows 10 / Server 2019 Build 17763 x64 (name:DC01) (domain:north.contoso.local) (signing:True) (SMBv1:False) -SMB 192.168.58.121 445 DC01 [+] north.contoso.local\: + let output = r#"SMB 192.168.58.121 445 DC01 [*] Windows 10 / Server 2019 Build 17763 x64 (name:DC01) (domain:child.contoso.local) (signing:True) (SMBv1:False) +SMB 192.168.58.121 445 DC01 [+] child.contoso.local\: SMB 192.168.58.121 445 DC01 -Username- -Last PW Set- -BadPW- -Description- SMB 192.168.58.121 445 DC01 alice.johnson 2026-03-25 23:21:09 0 Alice Johnson SMB 192.168.58.121 445 DC01 bob.smith 2026-03-25 23:21:09 0 Bob Smith @@ -757,7 +757,7 @@ SMB 192.168.58.121 445 DC01 [*] Enumerated 10 local users: CHI ); // Check domain was extracted from banner - assert_eq!(user_entries[0]["domain"], "north.contoso.local"); + assert_eq!(user_entries[0]["domain"], "child.contoso.local"); assert_eq!(user_entries[0]["username"], "alice.johnson"); // Check password leak extraction @@ -779,7 +779,7 @@ SMB 192.168.58.121 445 DC01 [*] Enumerated 10 local users: CHI #[test] fn parse_netexec_users_rid_brute_format() { - let output = r#"SMB 192.168.58.121 445 DC01 [+] north.contoso.local\: + let output = r#"SMB 192.168.58.121 445 DC01 [+] child.contoso.local\: SMB 192.168.58.121 445 DC01 CHILD\alice.johnson (SidTypeUser) SMB 192.168.58.121 445 DC01 CHILD\bob.smith (SidTypeUser)"#; diff --git a/ares-tools/src/parsers/trust.rs b/ares-tools/src/parsers/trust.rs index 661d01d9..66f6dd50 100644 --- a/ares-tools/src/parsers/trust.rs +++ b/ares-tools/src/parsers/trust.rs @@ -248,8 +248,8 @@ flatName: FABRIKAM #[test] fn parse_parent_child_trust() { - let output = r#"dn: CN=north.contoso.local,CN=System,DC=contoso,DC=local -cn: north.contoso.local + let output = r#"dn: CN=child.contoso.local,CN=System,DC=contoso,DC=local +cn: child.contoso.local trustDirection: 3 trustType: 1 trustAttributes: 0 @@ -257,7 +257,7 @@ flatName: CHILD "#; let trusts = parse_domain_trusts(output); assert_eq!(trusts.len(), 1); - assert_eq!(trusts[0]["domain"], "north.contoso.local"); + assert_eq!(trusts[0]["domain"], "child.contoso.local"); assert_eq!(trusts[0]["trust_type"], "parent_child"); assert!(!trusts[0]["sid_filtering"].as_bool().unwrap()); } @@ -271,8 +271,8 @@ trustType: 2 trustAttributes: 8 flatName: FABRIKAM -dn: CN=north.contoso.local,CN=System,DC=contoso,DC=local -cn: north.contoso.local +dn: CN=child.contoso.local,CN=System,DC=contoso,DC=local +cn: child.contoso.local trustDirection: 3 trustType: 1 trustAttributes: 0 diff --git a/ares-tools/src/recon.rs b/ares-tools/src/recon.rs index f5a87823..2da18e82 100644 --- a/ares-tools/src/recon.rs +++ b/ares-tools/src/recon.rs @@ -784,8 +784,8 @@ mod tests { #[test] fn domain_to_base_dn_nested() { assert_eq!( - domain_to_base_dn("north.contoso.local"), - "DC=north,DC=contoso,DC=local" + domain_to_base_dn("child.contoso.local"), + "DC=child,DC=contoso,DC=local" ); }