Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/rust.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<!-- END_AUTO_BADGES -->

Expand Down
36 changes: 36 additions & 0 deletions ares-cli/src/dedup/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
71 changes: 71 additions & 0 deletions ares-cli/src/orchestrator/output_extraction/hashes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,3 +306,74 @@ pub fn extract_cracked_passwords(output: &str, default_domain: &str) -> Vec<Cred

credentials
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extract_hashes_ntlm_plain() {
let output = "Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::";
let hashes = extract_hashes(output, "CORP");
assert_eq!(hashes.len(), 1);
assert_eq!(hashes[0].username, "Administrator");
assert_eq!(hashes[0].hash_type, "ntlm");
assert_eq!(hashes[0].domain, "CORP");
}

#[test]
fn extract_hashes_ntlm_with_domain() {
let output =
"CORP\\jdoe:1001:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::";
let hashes = extract_hashes(output, "DEFAULT");
assert_eq!(hashes.len(), 1);
assert_eq!(hashes[0].username, "jdoe");
assert_eq!(hashes[0].domain, "CORP");
}

#[test]
fn extract_hashes_tgs_kerberoast() {
let output = "$krb5tgs$23$*svc_sql$CONTOSO.LOCAL$MSSQLSvc/db01*$aabb$ccdd";
let hashes = extract_hashes(output, "CONTOSO.LOCAL");
assert_eq!(hashes.len(), 1);
assert_eq!(hashes[0].hash_type, "kerberoast");
assert_eq!(hashes[0].username, "svc_sql");
}

#[test]
fn extract_hashes_asrep() {
let output = "$krb5asrep$23$jdoe@CORP.LOCAL:aabbccddeeff00112233445566778899";
let hashes = extract_hashes(output, "CORP.LOCAL");
assert_eq!(hashes.len(), 1);
assert_eq!(hashes[0].hash_type, "asrep");
assert_eq!(hashes[0].username, "jdoe");
}

#[test]
fn extract_hashes_dedup_same_user_domain() {
let line = "Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::";
let output = format!("{line}\n{line}");
let hashes = extract_hashes(&output, "CORP");
assert_eq!(hashes.len(), 1);
}

#[test]
fn extract_hashes_empty_output() {
assert!(extract_hashes("", "CORP").is_empty());
}

#[test]
fn extract_cracked_passwords_hashcat_tgs() {
let output = "$krb5tgs$23$*svc_sql$CORP.LOCAL$MSSQLSvc/db01*$aabb$ccdd:Summer2024!";
let creds = extract_cracked_passwords(output, "CORP.LOCAL");
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].username, "svc_sql");
assert_eq!(creds[0].password, "Summer2024!");
assert_eq!(creds[0].source, "cracked:hashcat");
}

#[test]
fn extract_cracked_passwords_empty() {
assert!(extract_cracked_passwords("", "CORP").is_empty());
}
}
90 changes: 90 additions & 0 deletions ares-cli/src/orchestrator/output_extraction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,93 @@ pub(crate) fn make_credential(
attack_step: 0,
}
}

#[cfg(test)]
mod unit_tests {
use super::*;

#[test]
fn is_valid_credential_accepts_normal() {
assert!(is_valid_credential("alice", "P@ssw0rd!"));
}

#[test]
fn is_valid_credential_rejects_empty_user() {
assert!(!is_valid_credential("", "P@ssw0rd!"));
}

#[test]
fn is_valid_credential_rejects_empty_pass() {
assert!(!is_valid_credential("alice", ""));
}

#[test]
fn is_valid_credential_rejects_path_in_user() {
assert!(!is_valid_credential("alice/bob", "P@ssw0rd!"));
}

#[test]
fn is_valid_credential_rejects_txt_suffix_pass() {
assert!(!is_valid_credential("alice", "users.txt"));
}

#[test]
fn is_valid_credential_rejects_none_user() {
assert!(!is_valid_credential("none", "P@ssw0rd!"));
assert!(!is_valid_credential("(none)", "P@ssw0rd!"));
}

#[test]
fn is_valid_credential_rejects_short_pass() {
assert!(!is_valid_credential("alice", "ab"));
}

#[test]
fn is_valid_credential_rejects_long_pass() {
let long = "a".repeat(129);
assert!(!is_valid_credential("alice", &long));
}

#[test]
fn is_valid_credential_rejects_hash_body_pass() {
// >40 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*", "<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());
}
}
71 changes: 71 additions & 0 deletions ares-cli/src/orchestrator/output_extraction/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,74 @@ pub fn extract_users(output: &str, default_domain: &str) -> Vec<User> {

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());
}
}
40 changes: 40 additions & 0 deletions ares-cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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), "");
}
}
Loading
Loading