test(clustering): fixture 5 multi_operator + c2/shift/composite refs
Three new reference clusterers in fixture_harness: * c2_callback_clusterer — union-find on overlapping C2 callback sets across an attacker's sessions. Pass-clusterer for fixture 5 where two operators with distinct tooling share a C2 endpoint as the campaign signal. * shift_clusterer — deliberately-bad reference that buckets attackers by majority session-start hour into night/day/swing. Adversarial reference for fixture 5; proves operational schedule is NOT a campaign signal. * composite_signals_clusterer — union-find combining (ja3, hassh) match OR overlapping C2 callback. Will serve as the pass- clusterer for fixture 6 (noise_floor) where multiple campaigns with heterogeneous signal types are scored together. Also factored a small _union_find helper for the new clusterers (existing time_window/credential_jaccard left untouched to avoid mixing refactor with feature work). Fixture 5 (multi_operator): one campaign, two operators with distinct UKC roles. Actor A (broker, night shift): Delivery → Exploitation → Persistence → C2. Actor B (post-ex, day shift): Discovery → Lateral Movement → Collection → Exfiltration. Distinct JA3/HASSH/ASN/IPs; shared C2 + payload hash. Four tests: corpus shape (distinct fingerprints, shared C2, disjoint shifts), pipeline pass via c2_callback_clusterer, explicit harness sanity that fingerprint_clusterer cannot resolve this fixture (documents which signal carries the campaign), and adversarial shift_clusterer fragmentation. Phase-handoff edges (the real load-bearing signal per the design doc) wait for the production clusterer; this fixture will prove they're needed when it ships.
This commit is contained in:
25
tests/fixtures/campaigns/multi_operator.expected.yaml
vendored
Normal file
25
tests/fixtures/campaigns/multi_operator.expected.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Bounds for fixture 5 (multi_operator).
|
||||
#
|
||||
# Ground truth at campaign-level: 1 campaign of 2 observation rows
|
||||
# (one per DSL actor). A correct algorithm scores 1.0 across every
|
||||
# metric on this fixture.
|
||||
#
|
||||
# Completeness is the load-bearing metric: a clusterer that splits
|
||||
# the two operators by shift / by tooling / by ASN tanks
|
||||
# completeness (the one true class is split across two predicted
|
||||
# clusters). The adversarial shift_clusterer demonstrates this and
|
||||
# the bound below rejects it.
|
||||
#
|
||||
# Campaign-level fixture only — the two DSL actors model two
|
||||
# distinct identities (different tooling, different operators) by
|
||||
# design. See the YAML header for the modeling note.
|
||||
#
|
||||
# Bounds are loose at v1; tighten as the algorithm matures.
|
||||
adjusted_rand_index:
|
||||
min: 0.85
|
||||
homogeneity:
|
||||
min: 0.90
|
||||
completeness:
|
||||
min: 0.80
|
||||
singleton_recall:
|
||||
min: 0.95
|
||||
108
tests/fixtures/campaigns/multi_operator.yaml
vendored
Normal file
108
tests/fixtures/campaigns/multi_operator.yaml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
# Fixture 5 (multi_operator) — see development/CAMPAIGN_CLUSTERING.md §2.
|
||||
#
|
||||
# One campaign, two operators with distinct UKC roles. Phase-handoff is
|
||||
# the load-bearing signal; this fixture is what proves the algorithm
|
||||
# needs it.
|
||||
#
|
||||
# Actor A (night shift, hours 22-03 UTC):
|
||||
# Delivery → Exploitation → Persistence → Command-and-Control
|
||||
#
|
||||
# Actor B (day shift, hours 10-15 UTC):
|
||||
# Discovery → Lateral Movement → Collection → Exfiltration
|
||||
#
|
||||
# Different IPs, different ASNs, different JA3+HASSH (different
|
||||
# tools — A is the access broker, B is the post-exploitation
|
||||
# operator). What ties them is shared C2 callback and shared
|
||||
# stage-1 payload hash.
|
||||
#
|
||||
# Pass condition: a clusterer that resolves on shared C2 callback
|
||||
# (or, more generally, the planned similarity graph's payload +
|
||||
# C2 + phase-handoff signals) folds the two actors into one
|
||||
# campaign cluster. Demonstrated by `c2_callback_clusterer`.
|
||||
#
|
||||
# Adversarial condition: `shift_clusterer` (group attackers by
|
||||
# majority shift bucket — night/day/swing) puts A in "night" and B
|
||||
# in "day", fragmenting the campaign. Completeness collapses; the
|
||||
# bound floor on completeness rejects the bad clusterer. This is
|
||||
# the canonical demonstration that operational-schedule overlap is
|
||||
# NOT a campaign signal — different operators on different shifts
|
||||
# can still be one campaign.
|
||||
#
|
||||
# Like fixture 4, this is a CAMPAIGN-LEVEL fixture only. The two
|
||||
# DSL actors mint two distinct truth_identity_id rows by design
|
||||
# (different operators, different tools — they are different
|
||||
# identities even though they're one campaign). Identity-level
|
||||
# scoring is fixture 2's job.
|
||||
campaign:
|
||||
id: multi-operator-001
|
||||
duration_days: 3
|
||||
actors:
|
||||
- id: ops-broker-night
|
||||
asn: 64530
|
||||
ip_pool: sticky
|
||||
# Tool A's TLS stack — older OpenSSL signature.
|
||||
ja3: "771,49195-49199-49196-49200-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27,29-23-24,0"
|
||||
hassh: "ops-broker-eeeeeeee-eeeeeeee-eeeeeeee"
|
||||
hours_active_utc: [22, 23, 0, 1, 2, 3]
|
||||
jitter_seconds: 60
|
||||
- id: ops-postex-day
|
||||
asn: 64531
|
||||
ip_pool: sticky
|
||||
# Tool B's TLS stack — distinctly different from A.
|
||||
ja3: "769,49162-49161-49171-49172-51-50-47,0-10-11-13-23-65281,29-23-24-25,0"
|
||||
hassh: "ops-postex-ffffffff-ffffffff-ffffffff"
|
||||
hours_active_utc: [9, 10, 11, 12, 13]
|
||||
jitter_seconds: 60
|
||||
phases:
|
||||
# Actor A — initial access path, owns the foothold.
|
||||
- name: delivery
|
||||
actor: ops-broker-night
|
||||
tool_signature:
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { service: ssh, count: 2 }
|
||||
dwell_seconds: 1
|
||||
- name: exploitation
|
||||
actor: ops-broker-night
|
||||
tool_signature:
|
||||
payload_hash: "shared-op-stage1-payload"
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { service: ssh, count: 2 }
|
||||
dwell_seconds: 5
|
||||
- name: persistence
|
||||
actor: ops-broker-night
|
||||
tool_signature:
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { decky: previous_success, count: 1 }
|
||||
dwell_seconds: 5
|
||||
- name: command_and_control
|
||||
actor: ops-broker-night
|
||||
tool_signature:
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { decky: previous_success, count: 1 }
|
||||
dwell_seconds: 5
|
||||
# Actor B — picks up after A's foothold; shares C2 + payload.
|
||||
- name: discovery
|
||||
actor: ops-postex-day
|
||||
tool_signature:
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { decky: previous_success, count: 2 }
|
||||
dwell_seconds: 5
|
||||
- name: lateral_movement
|
||||
actor: ops-postex-day
|
||||
tool_signature:
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { service: ssh, count: 2 }
|
||||
dwell_seconds: 5
|
||||
- name: collection
|
||||
actor: ops-postex-day
|
||||
tool_signature:
|
||||
payload_hash: "shared-op-stage1-payload"
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { service: ssh, count: 2 }
|
||||
dwell_seconds: 5
|
||||
- name: exfiltration
|
||||
actor: ops-postex-day
|
||||
tool_signature:
|
||||
c2_callback: "c2.shared-op.example"
|
||||
target_selector: { service: ssh, count: 2 }
|
||||
dwell_seconds: 5
|
||||
Reference in New Issue
Block a user