diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 1ab551c1..881e95b2 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -74,6 +74,13 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: llvm-tools-preview + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@735e5933943122c5ac182670a935f54a949265c1 # v2 + with: + tool: cargo-llvm-cov - name: Cache cargo registry and build uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -87,8 +94,14 @@ jobs: ${{ runner.os }}-cargo-test-${{ github.ref }}- ${{ runner.os }}-cargo-test-refs/heads/main- - - name: Run tests - run: cargo test --workspace + - name: Run tests with coverage + run: cargo llvm-cov --workspace --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 + with: + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} - name: Add test summary if: always() diff --git a/README.md b/README.md index 16666ae4..a905fe89 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/dreadnode/ares/blob/main/LICENSE) [![Tests](https://github.com/dreadnode/ares/actions/workflows/rust.yaml/badge.svg)](https://github.com/dreadnode/ares/actions/workflows/rust.yaml) [![Pre-Commit](https://github.com/dreadnode/ares/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/dreadnode/ares/actions/workflows/pre-commit.yaml) +[![codecov](https://codecov.io/github/dreadnode/ares/graph/badge.svg)](https://codecov.io/github/dreadnode/ares) diff --git a/ares-cli/src/dedup/tests.rs b/ares-cli/src/dedup/tests.rs index 25718c90..480eac39 100644 --- a/ares-cli/src/dedup/tests.rs +++ b/ares-cli/src/dedup/tests.rs @@ -351,3 +351,39 @@ fn test_sanitize_keeps_password_equals_username() { assert_eq!(creds[0].password, "admin"); assert_eq!(creds[1].username, "user1"); } + +#[test] +fn strip_trailing_dot_removes_dot() { + use super::strip_trailing_dot; + assert_eq!(strip_trailing_dot("contoso.local."), "contoso.local"); + assert_eq!(strip_trailing_dot("contoso.local"), "contoso.local"); + assert_eq!(strip_trailing_dot(""), ""); + assert_eq!(strip_trailing_dot("."), ""); +} + +#[test] +fn strip_ansi_removes_escape_sequences() { + use super::credentials::strip_ansi; + let input = "\x1b[31mred text\x1b[0m"; + assert_eq!(strip_ansi(input), "red text"); + assert_eq!(strip_ansi("plain"), "plain"); + assert_eq!(strip_ansi(""), ""); +} + +#[test] +fn dedup_credentials_skips_empty_password() { + let creds = vec![ + make_cred("contoso.local", "admin", ""), + make_cred("contoso.local", "admin", "P@ss1"), + ]; + let deduped = dedup_credentials(&creds); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].password, "P@ss1"); +} + +#[test] +fn dedup_credentials_normalizes_domain_case() { + let creds = vec![make_cred("CONTOSO.LOCAL", "admin", "P@ss1")]; + let deduped = dedup_credentials(&creds); + assert_eq!(deduped[0].domain, "contoso.local"); +} diff --git a/ares-cli/src/orchestrator/output_extraction/hashes.rs b/ares-cli/src/orchestrator/output_extraction/hashes.rs index 11ac84ea..3fe79fe2 100644 --- a/ares-cli/src/orchestrator/output_extraction/hashes.rs +++ b/ares-cli/src/orchestrator/output_extraction/hashes.rs @@ -306,3 +306,74 @@ pub fn extract_cracked_passwords(output: &str, default_domain: &str) -> Vec40 chars, all hex+$ → hash fragment + let hash = "aabbccddeeff00112233445566778899aabbccdd$"; + assert!(!is_valid_credential("alice", hash)); + } + + #[test] + fn is_valid_credential_rejects_evil_machine_account() { + assert!(!is_valid_credential("EVIL123$", "P@ssw0rd!")); + } + + #[test] + fn is_valid_credential_rejects_noise_passwords() { + for pw in &["(null)", "*blank*", "", "password", "none", "fail"] { + assert!(!is_valid_credential("alice", pw), "should reject: {pw}"); + } + } + + #[test] + fn strip_ansi_removes_color_codes() { + let input = "\x1b[32mGreen\x1b[0m text"; + assert_eq!(strip_ansi(input), "Green text"); + } + + #[test] + fn strip_ansi_no_codes_unchanged() { + let input = "plain text"; + assert_eq!(strip_ansi(input), "plain text"); + } + + #[test] + fn text_extractions_is_empty_default() { + let e = TextExtractions::default(); + assert!(e.is_empty()); + } + + #[test] + fn extract_from_output_text_empty() { + let result = extract_from_output_text("", "corp.local"); + assert!(result.is_empty()); + } +} diff --git a/ares-cli/src/orchestrator/output_extraction/users.rs b/ares-cli/src/orchestrator/output_extraction/users.rs index 27dfd2f6..6af5ab30 100644 --- a/ares-cli/src/orchestrator/output_extraction/users.rs +++ b/ares-cli/src/orchestrator/output_extraction/users.rs @@ -146,3 +146,74 @@ pub fn extract_users(output: &str, default_domain: &str) -> Vec { users } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_valid_extracted_user_accepts_normal() { + assert!(is_valid_extracted_user("alice", "corp.local")); + } + + #[test] + fn is_valid_extracted_user_rejects_machine_account() { + assert!(!is_valid_extracted_user("DC01$", "corp.local")); + } + + #[test] + fn is_valid_extracted_user_rejects_empty() { + assert!(!is_valid_extracted_user("", "corp.local")); + } + + #[test] + fn is_valid_extracted_user_rejects_single_char() { + assert!(!is_valid_extracted_user("a", "corp.local")); + } + + #[test] + fn is_valid_extracted_user_rejects_noise_names() { + for name in &["anonymous", "none", "null", "unknown", "local"] { + assert!( + !is_valid_extracted_user(name, "corp.local"), + "should reject: {name}" + ); + } + } + + #[test] + fn is_valid_extracted_user_rejects_underscore_domain() { + assert!(!is_valid_extracted_user("alice", "_corp.local")); + } + + #[test] + fn is_valid_extracted_user_rejects_long_netbios() { + // NetBIOS names > 15 chars without a dot are invalid + assert!(!is_valid_extracted_user("alice", "TOOLONGNETBIOSNAME")); + } + + #[test] + fn extract_users_domain_backslash() { + let users = extract_users("CORP\\alice (SidTypeUser)", "corp.local"); + assert_eq!(users.len(), 1); + assert_eq!(users[0].username, "alice"); + assert_eq!(users[0].domain, "CORP"); + } + + #[test] + fn extract_users_upn_format() { + let users = extract_users("bob@corp.local", "corp.local"); + assert!(users.iter().any(|u| u.username == "bob")); + } + + #[test] + fn extract_users_skips_machine_accounts() { + let users = extract_users("CORP\\DC01$", "corp.local"); + assert!(users.is_empty()); + } + + #[test] + fn extract_users_empty_output() { + assert!(extract_users("", "corp.local").is_empty()); + } +} diff --git a/ares-cli/src/util.rs b/ares-cli/src/util.rs index 014ab28c..ba5f1b96 100644 --- a/ares-cli/src/util.rs +++ b/ares-cli/src/util.rs @@ -178,4 +178,44 @@ mod tests { #[cfg(feature = "blue")] use chrono::Datelike; + + #[test] + fn format_number_small() { + assert_eq!(format_number(0), "0"); + assert_eq!(format_number(999), "999"); + } + + #[test] + fn format_number_thousands() { + assert_eq!(format_number(1000), "1,000"); + assert_eq!(format_number(1234567), "1,234,567"); + } + + #[test] + fn format_number_millions() { + assert_eq!(format_number(1_000_000), "1,000,000"); + assert_eq!(format_number(12_345_678), "12,345,678"); + } + + #[test] + fn format_duration_boundary_59s() { + assert_eq!(format_duration(59), "59s"); + } + + #[test] + fn format_duration_boundary_3600s() { + assert_eq!(format_duration(3600), "1h 0m 0s"); + } + + #[test] + fn truncate_str_unicode() { + // Unicode chars should count as single chars + let s = "héllo"; + assert_eq!(truncate_str(s, 3), "hél..."); + } + + #[test] + fn truncate_str_empty() { + assert_eq!(truncate_str("", 5), ""); + } } diff --git a/ares-core/src/models/core.rs b/ares-core/src/models/core.rs index 20f4be88..06c930f7 100644 --- a/ares-core/src/models/core.rs +++ b/ares-core/src/models/core.rs @@ -197,6 +197,69 @@ mod tests { let host = make_host("DC01.CONTOSO.LOCAL", vec![], vec![]); assert!(host.detect_dc()); } + + #[test] + fn test_detect_dc_by_kerberos_service_name() { + let host = make_host("server", vec!["kerberos"], vec![]); + assert!(host.detect_dc()); + } + + #[test] + fn test_detect_dc_by_ldap_service_name() { + let host = make_host("server", vec!["ldap"], vec![]); + assert!(host.detect_dc()); + } + + #[test] + fn test_trust_info_is_parent_child() { + let t = TrustInfo { + domain: "child.corp.local".to_string(), + flat_name: "CHILD".to_string(), + direction: "bidirectional".to_string(), + trust_type: "parent_child".to_string(), + sid_filtering: false, + }; + assert!(t.is_parent_child()); + assert!(!t.is_cross_forest()); + } + + #[test] + fn test_trust_info_is_cross_forest() { + let t = TrustInfo { + domain: "fabrikam.local".to_string(), + flat_name: "FABRIKAM".to_string(), + direction: "outbound".to_string(), + trust_type: "forest".to_string(), + sid_filtering: true, + }; + assert!(t.is_cross_forest()); + assert!(!t.is_parent_child()); + } + + #[test] + fn test_trust_info_external_is_cross_forest() { + let t = TrustInfo { + domain: "other.local".to_string(), + flat_name: "OTHER".to_string(), + direction: "inbound".to_string(), + trust_type: "external".to_string(), + sid_filtering: false, + }; + assert!(t.is_cross_forest()); + } + + #[test] + fn test_trust_info_unknown_type_not_cross_forest() { + let t = TrustInfo { + domain: "x.local".to_string(), + flat_name: String::new(), + direction: String::new(), + trust_type: "unknown".to_string(), + sid_filtering: false, + }; + assert!(!t.is_cross_forest()); + assert!(!t.is_parent_child()); + } } /// Trust relationship metadata for an AD domain trust. diff --git a/ares-core/src/parsing/types.rs b/ares-core/src/parsing/types.rs index 85ff27e5..5de59fc9 100644 --- a/ares-core/src/parsing/types.rs +++ b/ares-core/src/parsing/types.rs @@ -181,4 +181,10 @@ mod tests { assert_eq!(KerberosHashType::TGS, KerberosHashType::TGS); assert_ne!(KerberosHashType::TGS, KerberosHashType::AsRep); } + + #[test] + fn test_parse_delegation_type_error_display() { + let err = ParseDelegationTypeError("bogus".to_string()); + assert_eq!(err.to_string(), "unknown delegation type: bogus"); + } } diff --git a/ares-core/src/token_usage.rs b/ares-core/src/token_usage.rs index 8c0d0f23..d3fdd4b7 100644 --- a/ares-core/src/token_usage.rs +++ b/ares-core/src/token_usage.rs @@ -538,4 +538,68 @@ mod tests { "ares:op:op-abc-123:token_usage" ); } + + #[test] + fn blue_token_usage_key_format() { + assert_eq!( + blue_token_usage_key("inv-xyz-456"), + "ares:blue:inv:inv-xyz-456:token_usage" + ); + } + + #[test] + fn lookup_model_cost_exact_match() { + let result = lookup_model_cost("gpt-4o"); + assert!(result.is_some()); + let (input, output) = result.unwrap(); + assert!((input - 2.50).abs() < 0.001); + assert!((output - 10.0).abs() < 0.001); + } + + #[test] + fn lookup_model_cost_case_insensitive() { + // Model names are lowercased before lookup + let result = lookup_model_cost("GPT-4O"); + assert!(result.is_some()); + } + + #[test] + fn lookup_model_cost_unknown_returns_none() { + let result = lookup_model_cost("totally-unknown-model-xyz"); + assert!(result.is_none()); + } + + #[test] + fn model_field_roundtrip_simple() { + let field = model_field("gpt-4o", "input_tokens"); + let (model, token_type) = parse_model_field(&field).unwrap(); + assert_eq!(model, "gpt-4o"); + assert_eq!(token_type, "input_tokens"); + } + + #[test] + fn parse_model_field_invalid_prefix() { + assert!(parse_model_field("something_else").is_none()); + assert!(parse_model_field("").is_none()); + } + + #[test] + fn estimate_usage_cost_breakdown_total_tokens() { + let usage = OperationTokenUsage { + input_tokens: 500_000, + output_tokens: 500_000, + model: "gpt-4o".to_string(), + models: HashMap::from([( + "gpt-4o".to_string(), + ModelTokenUsage { + input_tokens: 500_000, + output_tokens: 500_000, + }, + )]), + }; + let (_, breakdown, _) = estimate_usage_cost(&usage); + assert_eq!(breakdown[0].total_tokens, 1_000_000); + assert_eq!(breakdown[0].input_tokens, 500_000); + assert_eq!(breakdown[0].output_tokens, 500_000); + } } diff --git a/ares-tools/src/parsers/mod.rs b/ares-tools/src/parsers/mod.rs index 1eafed05..534952b9 100644 --- a/ares-tools/src/parsers/mod.rs +++ b/ares-tools/src/parsers/mod.rs @@ -689,4 +689,46 @@ SMB 192.168.58.121 445 DC01 bob 2026-03-25 23:21:09 0 Bob"#; assert_eq!(u["source"], "kerberos_enum"); } } + + #[test] + fn test_parse_tool_output_username_as_password_filters() { + // Only creds where password == username should be kept + let output = "[+] 192.168.1.1 CONTOSO\\alice:alice (Pwn3d!)\n\ + [+] 192.168.1.1 CONTOSO\\bob:Password1 (Pwn3d!)"; + let params = json!({"domain": "contoso.local", "target_ip": "192.168.1.1"}); + let disc = parse_tool_output("username_as_password", output, ¶ms); + let creds = disc["credentials"].as_array().unwrap(); + assert_eq!(creds.len(), 1, "Only alice:alice should match"); + assert_eq!(creds[0]["username"].as_str().unwrap(), "alice"); + } + + #[test] + fn test_parse_tool_output_adidnsdump() { + let output = "dc01 A 192.168.1.10\nweb01 A 192.168.1.20"; + let disc = parse_tool_output("adidnsdump", output, &json!({})); + let hosts = disc["hosts"].as_array().unwrap(); + assert_eq!(hosts.len(), 2); + } + + #[test] + fn test_merge_discoveries_trusted_domains_dedup() { + let d1 = + json!({"trusted_domains": [{"domain": "child.contoso.local", "type": "ParentChild"}]}); + let d2 = + json!({"trusted_domains": [{"domain": "child.contoso.local", "type": "ParentChild"}]}); + let merged = merge_discoveries(&[d1, d2]); + let td = merged["trusted_domains"].as_array().unwrap(); + assert_eq!(td.len(), 1, "Duplicate trusted domains should be deduped"); + } + + #[test] + fn test_merge_discoveries_host_more_services_wins() { + let d1 = json!({"hosts": [{"ip": "10.0.0.1", "services": ["445/tcp"]}]}); + let d2 = + json!({"hosts": [{"ip": "10.0.0.1", "services": ["80/tcp", "443/tcp", "445/tcp"]}]}); + let merged = merge_discoveries(&[d1, d2]); + let hosts = merged["hosts"].as_array().unwrap(); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0]["services"].as_array().unwrap().len(), 3); + } } diff --git a/ares-tools/src/parsers/trust.rs b/ares-tools/src/parsers/trust.rs index dab23032..b3b7b425 100644 --- a/ares-tools/src/parsers/trust.rs +++ b/ares-tools/src/parsers/trust.rs @@ -213,4 +213,47 @@ flatName: CHILD let trusts = parse_domain_trusts(output); assert!(trusts.is_empty()); } + + #[test] + fn test_parse_outbound_trust() { + let output = "cn: external.com\ntrustDirection: 2\ntrustType: 3\ntrustAttributes: 0\nflatName: EXTERNAL\n"; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts.len(), 1); + assert_eq!(trusts[0]["direction"], "outbound"); + assert_eq!(trusts[0]["trust_type"], "external"); + assert!(!trusts[0]["sid_filtering"].as_bool().unwrap()); + } + + #[test] + fn test_parse_trust_unknown_direction() { + let output = "cn: mystery.local\ntrustDirection: 99\ntrustType: 1\ntrustAttributes: 0\nflatName: MYSTERY\n"; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts.len(), 1); + assert_eq!(trusts[0]["direction"], "unknown"); + } + + #[test] + fn test_parse_trust_tree_root_short_domain() { + // trustType=2 with short domain (< 3 labels) → forest + let output = "cn: fabrikam.com\ntrustDirection: 3\ntrustType: 2\ntrustAttributes: 0\nflatName: FABRIKAM\n"; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts.len(), 1); + assert_eq!(trusts[0]["trust_type"], "forest"); + } + + #[test] + fn test_parse_trust_tree_root_long_domain() { + // trustType=2 with 3+ labels → parent_child + let output = "cn: child.contoso.local\ntrustDirection: 3\ntrustType: 2\ntrustAttributes: 0\nflatName: CHILD\n"; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts.len(), 1); + assert_eq!(trusts[0]["trust_type"], "parent_child"); + } + + #[test] + fn test_parse_trust_domain_lowercased() { + let output = "cn: FABRIKAM.LOCAL\ntrustDirection: 3\ntrustType: 2\ntrustAttributes: 8\nflatName: FABRIKAM\n"; + let trusts = parse_domain_trusts(output); + assert_eq!(trusts[0]["domain"], "fabrikam.local"); + } }