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
1 change: 1 addition & 0 deletions .taskfiles/remote/Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,7 @@ tasks:
# zigbuild's Zig ar wrapper on Darwin), prefer zigbuild on Linux,
# fall back to raw cargo
if [ "$(uname)" = "Darwin" ] && command -v cross >/dev/null 2>&1; then
export AWS_LC_SYS_CMAKE_BUILDER=1
cross build --release --target {{.RUST_TARGET}}
elif command -v cargo-zigbuild >/dev/null 2>&1; then
cargo zigbuild --release --target {{.RUST_TARGET}}
Expand Down
36 changes: 35 additions & 1 deletion ares-cli/src/dedup/domains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,43 @@ pub(crate) fn normalize_state_domains(
}
}

// Also keep child domains whose suffix parent is already valid.
// e.g. north.sevenkingdoms.local survives when sevenkingdoms.local is valid,
// even before any north users/hosts have been enumerated.
let child_domains: Vec<String> = domains
.iter()
.filter_map(|d| {
let lower = d.trim().to_lowercase();
let parts: Vec<&str> = lower.split('.').collect();
if parts.len() > 2 {
let parent = parts[1..].join(".");
if valid_domains.contains(&parent) {
return Some(lower);
}
}
None
})
.collect();
valid_domains.extend(child_domains);

// A string that appears as the suffix (parts[1..]) of any host FQDN is a real
// domain, even if it also happens to appear as a host's own hostname field
// (e.g. a DC recorded as hostname="north.sevenkingdoms.local" while
// castelblack.north.sevenkingdoms.local is another host in the same op).
let confirmed_domains: HashSet<String> = hosts
.iter()
.filter(|h| !h.hostname.is_empty() && h.hostname.contains('.'))
.map(|h| {
let lower = h.hostname.to_lowercase();
let parts: Vec<&str> = lower.split('.').collect();
parts[1..].join(".")
})
.collect();

domains.retain(|d| {
let lower = d.to_lowercase();
valid_domains.contains(&lower) && !host_fqdns.contains(&lower)
valid_domains.contains(&lower)
&& (!host_fqdns.contains(&lower) || confirmed_domains.contains(&lower))
});
}
}
59 changes: 59 additions & 0 deletions ares-cli/src/dedup/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,32 @@ fn normalize_state_domains_drops_host_fqdn_masquerading_as_domain() {
assert_eq!(domains, vec!["c26h.local".to_string()]);
}

#[test]
fn normalize_state_domains_domain_kept_when_hostname_matches_but_subdomain_host_exists() {
// A DC whose hostname field equals the domain name (e.g. the DC for
// child.contoso.local is stored as hostname="child.contoso.local") must NOT be
// excluded when another host confirms it is a real domain
// (dc01.child.contoso.local → suffix = child.contoso.local).
let users = vec![make_user("contoso.local", "admin")];
let mut creds = vec![];
let mut hashes = vec![];
let mut domains = vec![
"contoso.local".to_string(),
"child.contoso.local".to_string(),
];
let hosts = vec![
make_host("192.168.58.150", "child.contoso.local"),
make_host("192.168.58.51", "dc01.child.contoso.local"),
];

normalize_state_domains(&users, &mut creds, &mut hashes, &mut domains, &hosts, None);

assert!(
domains.contains(&"child.contoso.local".to_string()),
"child domain should survive: dc01.child.* confirms it is a real domain"
);
}

#[test]
fn normalize_state_domains_domain_kept_from_target_domain() {
// target_domain should cause that domain to be retained even without hosts/users.
Expand All @@ -686,6 +712,39 @@ fn normalize_state_domains_domain_kept_from_target_domain() {
assert_eq!(domains[0], "fabrikam.local");
}

#[test]
fn normalize_state_domains_child_domain_kept_when_parent_valid() {
// A child domain (3+ labels) should survive the filter when its
// suffix parent is already in valid_domains, even if no users/hosts
// have been enumerated in the child domain yet.
let users = vec![make_user("contoso.local", "admin")];
let mut creds = vec![];
let mut hashes = vec![];
let mut domains = vec![
"contoso.local".to_string(),
"child.contoso.local".to_string(),
"orphan.other".to_string(),
];
let hosts: Vec<Host> = vec![];

normalize_state_domains(
&users,
&mut creds,
&mut hashes,
&mut domains,
&hosts,
Some("contoso.local"),
);

assert!(domains.contains(&"contoso.local".to_string()));
assert!(
domains.contains(&"child.contoso.local".to_string()),
"child domain should survive when parent is valid"
);
// orphan.other has no parent in valid_domains — dropped
assert!(!domains.contains(&"orphan.other".to_string()));
}

#[test]
fn normalize_state_domains_hash_not_corrected_when_domain_is_known() {
// When hash domain IS in known_domains, it should NOT be corrected even if user
Expand Down
64 changes: 54 additions & 10 deletions ares-cli/src/ops/loot/format/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,8 @@ pub(super) fn print_loot_human(
) {
println!("Operation: {}", state.operation_id);

let started = state.started_at.format("%Y-%m-%d %H:%M:%S UTC");
if let Some(completed) = state.completed_at {
let ended = completed.format("%Y-%m-%d %H:%M:%S UTC");
let elapsed = format_duration(completed - state.started_at);
println!("Started: {started}");
println!("Completed: {ended} ({elapsed})");
} else {
let elapsed = format_duration(chrono::Utc::now() - state.started_at);
println!("Started: {started}");
println!("Running: {elapsed}");
for line in operation_timing_lines(state, chrono::Utc::now()) {
println!("{line}");
}

let topology = compute_forest_topology(domains_input);
Expand Down Expand Up @@ -284,6 +276,34 @@ pub(super) fn print_loot_human(
print_mitre_techniques(&state.all_techniques, &state.all_timeline_events);
}

fn operation_timing_lines(
state: &SharedRedTeamState,
now: chrono::DateTime<chrono::Utc>,
) -> Vec<String> {
let started = state.started_at.format("%Y-%m-%d %H:%M:%S UTC");
let mut lines = vec![format!("Started: {started}")];

if let Some(completed) = state.completed_at {
let ended = completed.format("%Y-%m-%d %H:%M:%S UTC");
let elapsed = format_duration(completed - state.started_at);
lines.push(format!("Completed: {ended} ({elapsed})"));
} else if let Some(red_completed) = state.red_completed_at {
let ended = red_completed.format("%Y-%m-%d %H:%M:%S UTC");
let elapsed = format_duration(red_completed - state.started_at);
lines.push(format!("Completed: {ended} ({elapsed})"));
if state.red_blocked_on_blue {
lines.push("Finalizing: waiting on blue investigations".to_string());
} else if let Some(reason) = &state.red_completion_reason {
lines.push(format!("Finalizing: {reason}"));
}
} else {
let elapsed = format_duration(now - state.started_at);
lines.push(format!("Running: {elapsed}"));
}

lines
}

/// Compact summary used by `ops runtime`: DA/GT banner with per-domain
/// breakdown plus a one-line host/DC count. Shares formatting with
/// `print_loot_human` so the live-watch view stays consistent with `ops loot`.
Expand Down Expand Up @@ -1202,6 +1222,30 @@ mod tests {
}
}

#[test]
fn operation_timing_red_complete_waiting_on_blue_prints_completed() {
let mut state = empty_state();
state.started_at = chrono::DateTime::parse_from_rfc3339("2026-05-15T20:53:56Z")
.unwrap()
.with_timezone(&chrono::Utc);
state.red_completed_at = Some(
chrono::DateTime::parse_from_rfc3339("2026-05-15T22:04:43Z")
.unwrap()
.with_timezone(&chrono::Utc),
);
state.red_blocked_on_blue = true;

let now = chrono::DateTime::parse_from_rfc3339("2026-05-15T22:10:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let lines = operation_timing_lines(&state, now);

assert_eq!(lines[0], "Started: 2026-05-15 20:53:56 UTC");
assert_eq!(lines[1], "Completed: 2026-05-15 22:04:43 UTC (1h 10m 47s)");
assert_eq!(lines[2], "Finalizing: waiting on blue investigations");
assert!(!lines.iter().any(|line| line.starts_with("Running:")));
}

// capitalize

#[test]
Expand Down
3 changes: 3 additions & 0 deletions ares-cli/src/ops/loot/format/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ pub(super) fn print_loot_json(
"operation_id": state.operation_id,
"started_at": state.started_at.to_rfc3339(),
"completed_at": state.completed_at.map(|dt| dt.to_rfc3339()),
"red_completed_at": state.red_completed_at.map(|dt| dt.to_rfc3339()),
"red_completion_reason": state.red_completion_reason,
"red_blocked_on_blue": state.red_blocked_on_blue,
"has_domain_admin": state.has_domain_admin,
"domain_admin_path": state.domain_admin_path,
"has_golden_ticket": state.has_golden_ticket,
Expand Down
2 changes: 1 addition & 1 deletion ares-cli/src/orchestrator/automation/crack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ mod tests {
// eligible for retry — this was the bug. Confirm that the dedup
// marker is NOT written before the cap.
let mut state = StateInner::new("op-test".into());
let key = "north.contoso.local:svc_sql:abcdef0123456789abcdef0123456789";
let key = "child.contoso.local:svc_sql:abcdef0123456789abcdef0123456789";
for _ in 0..(MAX_CRACK_ATTEMPTS - 1) {
simulate_attempt(&mut state, key);
}
Expand Down
54 changes: 46 additions & 8 deletions ares-cli/src/orchestrator/automation/gpp_sysvol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ use tracing::{debug, info, warn};
use crate::orchestrator::dispatcher::Dispatcher;
use crate::orchestrator::state::*;

fn same_forest_domain(a: &str, b: &str) -> bool {
let a = a.to_lowercase();
let b = b.to_lowercase();
!a.is_empty()
&& !b.is_empty()
&& (a == b || a.ends_with(&format!(".{b}")) || b.ends_with(&format!(".{a}")))
}

fn credential_for_domain(
state: &StateInner,
domain: &str,
) -> Option<ares_core::models::Credential> {
state
.credentials
.iter()
.find(|c| {
!c.password.is_empty()
&& !state.is_principal_quarantined(&c.username, &c.domain)
&& c.domain.eq_ignore_ascii_case(domain)
})
.or_else(|| {
state.credentials.iter().find(|c| {
!c.password.is_empty()
&& !state.is_principal_quarantined(&c.username, &c.domain)
&& same_forest_domain(&c.domain, domain)
})
})
.cloned()
}

/// Collect GPP/SYSVOL work items from state (pure logic, no async).
fn collect_gpp_sysvol_work(state: &StateInner) -> Vec<GppSysvolWork> {
if state.credentials.is_empty() {
Expand All @@ -32,12 +62,7 @@ fn collect_gpp_sysvol_work(state: &StateInner) -> Vec<GppSysvolWork> {
continue;
}

let cred = match state
.credentials
.iter()
.find(|c| c.domain.to_lowercase() == domain.to_lowercase())
.or_else(|| state.credentials.first())
{
let cred = match credential_for_domain(state, domain) {
Some(c) => c.clone(),
None => continue,
};
Expand Down Expand Up @@ -274,7 +299,7 @@ mod tests {
}

#[test]
fn collect_falls_back_to_first_credential() {
fn collect_skips_unrelated_cross_forest_credential() {
let mut state = StateInner::new("test".into());
state
.domain_controllers
Expand All @@ -283,8 +308,21 @@ mod tests {
.credentials
.push(make_cred("fabuser", "fabrikam.local"));
let work = collect_gpp_sysvol_work(&state);
assert!(work.is_empty());
}

#[test]
fn collect_allows_child_domain_credential() {
let mut state = StateInner::new("test".into());
state
.domain_controllers
.insert("contoso.local".into(), "192.168.58.10".into());
state
.credentials
.push(make_cred("childuser", "child.contoso.local"));
let work = collect_gpp_sysvol_work(&state);
assert_eq!(work.len(), 1);
assert_eq!(work[0].credential.username, "fabuser");
assert_eq!(work[0].credential.username, "childuser");
}

#[test]
Expand Down
4 changes: 2 additions & 2 deletions ares-cli/src/orchestrator/automation/group_enumeration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,15 +588,15 @@ mod tests {
// Child-domain cred should work for parent-domain via trust
state
.credentials
.push(make_credential("admin", "P@ssw0rd!", "north.contoso.local")); // pragma: allowlist secret
.push(make_credential("admin", "P@ssw0rd!", "child.contoso.local")); // pragma: allowlist secret
let work = collect_group_enum_work(&state);
assert_eq!(
work.len(),
1,
"child-domain cred should fall back for parent"
);
assert_eq!(work[0].dedup_key, "group_enum:contoso.local:trust");
assert_eq!(work[0].credential.domain, "north.contoso.local");
assert_eq!(work[0].credential.domain, "child.contoso.local");
}

#[tokio::test]
Expand Down
Loading
Loading