From 74cbee91c94899dc2b8e58d5ce22067ad46a1453 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Thu, 30 Jul 2026 12:00:21 -0600 Subject: [PATCH] refactor: credit ares techniques from token_coverage instead of Redis exploited set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Added:** - `aresTokenCoverage` struct and `TokenCoverage` map field on `aresLoot` to parse ares's `token_coverage` JSON, which is keyed by scoreboard category and reports proven-only exploit counts - `transport_ares.go` - `aresCategoryToTechniqueID` translating residual category-to-technique-ID disagreements (`forest_trust`→`cross_forest_trust`) and refusing categories the scoreboard must not credit (`zerologon`, `golden_ticket`, `other`) - `transport_ares.go` - `writeTokenCoverageEntries` crediting one objective per category with a proven exploit, walking categories in sorted order for byte-stable output across polls - `transport_ares.go` - Test coverage for the new path: `TestAresCategoryToTechniqueID`, `TestWriteTokenCoverageEntries` and its determinism/nil/emitted-map variants, `TestSynthesizeJSONL_GoldenTicketStillPerDomain`, and `TestAresCategoriesExistInAnswerKey` - `transport_ares_test.go` **Changed:** - `FetchReport` now sources technique credit from `token_coverage` via the `jqFilter`, dropping the second Redis round-trip; ares owns the category mapping and superseded subtraction so the two sides can no longer drift on naming - `transport_ares.go` - `synthesizeJSONL` signature drops the `exploited []string` parameter, updating all call sites in tests - `transport_ares.go`, `verify_test.go` - Answer-key guard test reworked to validate categories through `aresCategoryToTechniqueID` rather than raw `vuln_id` prefixes - `transport_ares_test.go` **Removed:** - `fetchExploited` and its `ares:op::exploited`/`superseded` Redis SDIFF round-trip, now handled by ares's own superseded subtraction - `transport_ares.go` - `aresExploitedToTechniqueIDs` hand-maintained prefix table and its `TestAresExploitedToTechniqueIDs`/`TestAresExploitedTechniquesExistInAnswerKey` tests, which duplicated ares's category mapping - `transport_ares.go`, `transport_ares_test.go` --- cli/internal/scoreboard/transport_ares.go | 217 ++++++-------- .../scoreboard/transport_ares_test.go | 274 ++++++++++++------ cli/internal/scoreboard/verify_test.go | 7 +- 3 files changed, 286 insertions(+), 212 deletions(-) diff --git a/cli/internal/scoreboard/transport_ares.go b/cli/internal/scoreboard/transport_ares.go index e614dd8b..2df5f01e 100644 --- a/cli/internal/scoreboard/transport_ares.go +++ b/cli/internal/scoreboard/transport_ares.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "sort" "strings" awsclient "github.com/dreadnode/dreadgoad/internal/aws" @@ -48,11 +49,28 @@ func NewAresTransport(ctx context.Context, instanceID, binaryPath, region, profi } type aresLoot struct { - OperationID string `json:"operation_id"` - StartedAt string `json:"started_at"` - Credentials []aresCredEntry `json:"credentials"` - Hashes []aresHashEntry `json:"hashes"` - DomainCompromise []aresDomainCompromise `json:"domain_compromise"` + OperationID string `json:"operation_id"` + StartedAt string `json:"started_at"` + Credentials []aresCredEntry `json:"credentials"` + Hashes []aresHashEntry `json:"hashes"` + DomainCompromise []aresDomainCompromise `json:"domain_compromise"` + TokenCoverage map[string]aresTokenCoverage `json:"token_coverage"` +} + +// aresTokenCoverage mirrors one entry of the ares loot JSON's `token_coverage` +// map, which ares emits keyed by scoreboard category (`ares-cli` +// `ops/loot/format/json.rs`, `build_token_coverage_json`). Its doc comment +// names the dreadgoad scoreboard verifier as an intended consumer, precisely so +// downstream stops re-deriving category mapping from raw `vuln_id` strings. +// +// `Exploited` is proven-only: ares subtracts its superseded set before +// counting, so back-credited techniques (a goal another path already reached) +// do not inflate it. That subtraction is what lets DreadGOAD drop its own +// `SDIFF exploited superseded` round-trip against Redis. +type aresTokenCoverage struct { + Discovered int `json:"discovered"` + Exploited int `json:"exploited"` + Status string `json:"status"` } type aresCredEntry struct { @@ -85,13 +103,18 @@ type aresDomainCompromise struct { KrbtgtHashTypes []string `json:"krbtgt_hash_types"` } -// FetchReport runs `ares ops loot --latest --json` on the remote instance and, -// if successful, also fetches the proven subset of the `ares:op::exploited` -// Redis set so technique objectives can be credited directly. Both payloads are +// FetchReport runs `ares ops loot --latest --json` on the remote instance and +// translates the result into synthetic JSONL findings. The payload is // gzip+base64-encoded to sidestep SSM's 24KB stdout cap. Returns ErrNoReport // when the operation hasn't produced any state yet. +// +// Technique credit comes from `token_coverage`, which ares computes with its +// own category mapping and its own superseded subtraction. Reading it replaces +// both the second Redis round-trip and the hand-maintained prefix table that +// used to translate raw `vuln_id` strings here, so the two sides can no longer +// drift apart on category naming. func (t *AresTransport) FetchReport(ctx context.Context) (string, error) { - const jqFilter = `{operation_id, started_at,` + + const jqFilter = `{operation_id, started_at, token_coverage,` + ` credentials: [.credentials[] | {username, password, domain, is_admin}],` + ` hashes: [.hashes[] | {username, domain, hash_value, hash_type, source}],` + ` domain_compromise: [.domain_compromise[] | {domain, has_domain_admin, has_golden_ticket, admin_users, krbtgt_hash_types}]}` @@ -120,41 +143,7 @@ func (t *AresTransport) FetchReport(ctx context.Context) (string, error) { return "", fmt.Errorf("parse ares loot json: %w", err) } - exploited := t.fetchExploited(ctx, loot.OperationID) - return synthesizeJSONL(&loot, exploited), nil -} - -// fetchExploited reads the `ares:op::exploited` Redis set minus -// `ares:op::superseded`; failures are non-fatal (just means no technique -// findings get emitted this poll). -// -// ares credits a vuln as exploited when a *different* path already reached the -// same goal, so the technique itself was never proven: an mssql_impersonation -// win back-credits the host's mssql_access, and a dc_secretsdump_ -// back-credits child_to_parent for that domain (ares-cli -// `orchestrator/state/dedup.rs`). Those ids are mirrored into `:superseded`, -// which ares documents as "subset of KEY_EXPLOITED; the technique itself was -// never proven to work". SDIFF drops them server-side and degrades to plain -// SMEMBERS when `:superseded` is absent, so the scoreboard only credits -// techniques ares actually walked. -func (t *AresTransport) fetchExploited(ctx context.Context, opID string) []string { - if opID == "" { - return nil - } - cmd := fmt.Sprintf("redis-cli SDIFF %s %s", - shellQuote(fmt.Sprintf("ares:op:%s:exploited", opID)), - shellQuote(fmt.Sprintf("ares:op:%s:superseded", opID))) - out, status, _, err := runSSMShell(ctx, t.Client, t.InstanceID, cmd) - if err != nil || status != ssmtypes.CommandInvocationStatusSuccess { - return nil - } - var entries []string - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - if line = strings.TrimSpace(line); line != "" { - entries = append(entries, line) - } - } - return entries + return synthesizeJSONL(&loot), nil } func decodeGzipBase64(s string) ([]byte, error) { @@ -184,71 +173,7 @@ func (t *AresTransport) DeleteReport(_ context.Context) (bool, error) { return false, nil } -// aresExploitedToTechniqueIDs maps an entry from `ares:op::exploited` to -// the answer-key technique IDs it represents. Returns nil for entries that -// don't correspond to any answer-key technique. The exploited set uses prefix -// names like `mssql_linked_server__` or bare names like -// `constrained_delegation_`; we match on the prefix. -func aresExploitedToTechniqueIDs(entry string) []string { - prefixes := []struct { - prefix string - ids []string - }{ - {"mssql_linked_server_", []string{"mssql_linked_server"}}, - {"mssql_impersonation_", []string{"mssql_exploit"}}, - {"mssql_", []string{"mssql_exploit"}}, - {"constrained_delegation_", []string{"constrained_delegation"}}, - {"unconstrained_delegation_", []string{"unconstrained_delegation"}}, - {"forest_trust_", []string{"cross_forest_trust"}}, - {"child_to_parent_", []string{"child_to_parent"}}, - // ares emits the granted right in the id (acl_genericall_*, - // acl_writeproperty_*, ...); they all collapse to one objective. - {"acl_", []string{"acl_abuse"}}, - {"asrep_roast_", []string{"asrep_roast"}}, - {"kerberoast_", []string{"kerberoast"}}, - {"llmnr_", []string{"llmnr_nbtns_poisoning"}}, - {"ntlm_relay_", []string{"ntlm_relay"}}, - {"ntlmv1_", []string{"ntlmv1_downgrade"}}, - {"seimpersonate_", []string{"seimpersonate"}}, - {"adcs_esc1_", []string{"adcs_esc1"}}, - {"adcs_esc2_", []string{"adcs_esc2"}}, - {"adcs_esc3_", []string{"adcs_esc3"}}, // collapses ESC3 + ESC3-CRA - {"adcs_esc4_", []string{"adcs_esc4"}}, - {"adcs_esc6_", []string{"adcs_esc6"}}, - {"adcs_esc7_", []string{"adcs_esc7"}}, - {"adcs_esc8_", []string{"adcs_esc8"}}, - {"adcs_esc9_", []string{"adcs_esc9"}}, - {"adcs_esc10_case1_", []string{"adcs_esc10_case1"}}, - {"adcs_esc10_case2_", []string{"adcs_esc10_case2"}}, - {"adcs_esc11_", []string{"adcs_esc11"}}, - {"adcs_esc13_", []string{"adcs_esc13"}}, - {"adcs_esc15_", []string{"adcs_esc15"}}, - // Same shape as acl_: ares emits gpo___. - {"gpo_", []string{"gpo_abuse"}}, - {"gmsa_", []string{"gmsa_password_read"}}, - {"laps_", []string{"laps_password_read"}}, - {"sid_history_", []string{"sid_history_abuse"}}, - {"rbcd_", []string{"rbcd"}}, - {"shadow_credentials_", []string{"shadow_credentials"}}, - } - // Per-domain golden ticket: `golden_ticket_` → `golden_ticket-`. - // One scoreboard objective per domain because forging requires that domain's - // krbtgt hash; a multi-domain forest can have a separate GT per domain. - if strings.HasPrefix(entry, "golden_ticket_") { - domain := strings.ToLower(strings.TrimPrefix(entry, "golden_ticket_")) - if domain != "" { - return []string{"golden_ticket-" + domain} - } - } - for _, p := range prefixes { - if strings.HasPrefix(entry, p.prefix) || entry == strings.TrimSuffix(p.prefix, "_") { - return p.ids - } - } - return nil -} - -func synthesizeJSONL(l *aresLoot, exploited []string) string { +func synthesizeJSONL(l *aresLoot) string { var b strings.Builder writeJSONLEntry(&b, map[string]string{ "agent_id": "ares:" + l.OperationID, @@ -261,7 +186,7 @@ func synthesizeJSONL(l *aresLoot, exploited []string) string { writeHashEntry(&b, h) } emitted := map[string]bool{} - writeExploitedEntries(&b, exploited, emitted) + writeTokenCoverageEntries(&b, l.TokenCoverage, emitted) writeDomainCompromiseEntries(&b, l.DomainCompromise, emitted) return b.String() } @@ -310,19 +235,67 @@ func writeHashEntry(b *strings.Builder, h aresHashEntry) { }) } -func writeExploitedEntries(b *strings.Builder, exploited []string, emitted map[string]bool) { - for _, ex := range exploited { - for _, techID := range aresExploitedToTechniqueIDs(ex) { - if emitted[techID] { - continue - } - emitted[techID] = true - writeJSONLEntry(b, map[string]string{ - "target": "tech:" + techID, - "evidence": "ares: " + ex, - "description": "exploited", - }) +// aresCategoryToTechniqueID translates an ares `token_coverage` category key to +// the answer-key technique ID it credits, or "" for categories the scoreboard +// deliberately does not credit. +// +// ares already normalises most aliases to the scoreboard's own names in +// `token_category` (`gpo`→`gpo_abuse`, `mssql_impersonation`→`mssql_exploit`, +// `ntlmv1`→`ntlmv1_downgrade`, `llmnr`→`llmnr_nbtns_poisoning`, +// `sid_history`→`sid_history_abuse`, `gmsa`/`laps`→`*_password_read`), so the +// vast majority pass through untouched. Only the exceptions below need stating, +// and each one is a real disagreement rather than a naming preference. +func aresCategoryToTechniqueID(category string) string { + switch category { + case "forest_trust": + // ares has no normalisation arm for this one, so it emits the bare + // prefix while the answer key declares `cross_forest_trust`. Passing it + // through unchanged silently drops the objective. + return "cross_forest_trust" + case "zerologon": + // ares mints a zerologon id from the nxc *check* module and never runs + // the password reset, so the id records a detection rather than an + // exploit. Crediting it would flip an objective nothing exercised. + return "" + case "golden_ticket": + // ares collapses `golden_ticket_` to a single bare category, + // discarding the domain. The answer key declares one objective per + // domain because forging needs that domain's krbtgt hash, so these are + // credited from `domain_compromise[]` instead, which keeps the domain. + return "" + case "other": + // token_category's catch-all for ids it does not recognise. + return "" + default: + return category + } +} + +// writeTokenCoverageEntries credits one technique objective per ares category +// with at least one proven exploit. Categories are walked in sorted order so +// the synthesized JSONL is byte-stable across polls of unchanged state. +func writeTokenCoverageEntries(b *strings.Builder, coverage map[string]aresTokenCoverage, emitted map[string]bool) { + categories := make([]string, 0, len(coverage)) + for c := range coverage { + categories = append(categories, c) + } + sort.Strings(categories) + + for _, category := range categories { + cov := coverage[category] + if cov.Exploited <= 0 { + continue + } + techID := aresCategoryToTechniqueID(category) + if techID == "" || emitted[techID] { + continue } + emitted[techID] = true + writeJSONLEntry(b, map[string]string{ + "target": "tech:" + techID, + "evidence": fmt.Sprintf("ares: token_coverage %s exploited=%d discovered=%d", category, cov.Exploited, cov.Discovered), + "description": "exploited", + }) } } diff --git a/cli/internal/scoreboard/transport_ares_test.go b/cli/internal/scoreboard/transport_ares_test.go index b133a283..4e8fd370 100644 --- a/cli/internal/scoreboard/transport_ares_test.go +++ b/cli/internal/scoreboard/transport_ares_test.go @@ -1,93 +1,188 @@ package scoreboard import ( - "reflect" + "strings" "testing" ) -// TestAresExploitedToTechniqueIDs pins the mapping against the vuln_id shapes -// ares actually SADDs into `ares:op::exploited`. The literals below are -// taken from the ares construction sites, not invented: acl_* from -// `orchestrator/result_processing/acl_grants.rs`, gpo_* from -// `ares-tools/src/parsers/ntsd.rs`, and the rest from -// `orchestrator/result_processing/mod.rs` and `orchestrator/automation/*`. -// ares mirrors this table in `ops/loot/format/display.rs` (token_category); -// the two must agree or the loot view and the status board disagree. -func TestAresExploitedToTechniqueIDs(t *testing.T) { +// TestAresCategoryToTechniqueID pins the residual translation between ares's +// `token_coverage` category keys and answer-key technique IDs. +// +// ares normalises most aliases to the scoreboard's own names in +// `token_category` (`ares-cli/src/ops/loot/format/display.rs`), so the table +// here is deliberately tiny: every case below is either a real disagreement or +// a category the scoreboard refuses on purpose. If this test grows, the two +// sides have started drifting again and the fix belongs in ares, not here. +func TestAresCategoryToTechniqueID(t *testing.T) { tests := []struct { - name string - entry string - want []string + name string + category string + want string }{ - // ares emits the granted right in the id, never the literal - // "acl_abuse". Matching on "acl_abuse_" credited nothing. - {"acl generic all", "acl_genericall_tywin.lannister_kingsguard", []string{"acl_abuse"}}, - {"acl write property", "acl_writeproperty_stannis.baratheon_dragonstone", []string{"acl_abuse"}}, - {"acl write dacl", "acl_writedacl_alice_dc01", []string{"acl_abuse"}}, - {"acl all extended rights", "acl_allextendedrights_bob_carol", []string{"acl_abuse"}}, - - // Same shape: gpo___. - {"gpo write property", "gpo_writeproperty_alice__31b2f340_016d_11d2_945f_00c04fb984f9_", []string{"gpo_abuse"}}, - {"gpo generic all", "gpo_genericall_bob_default_domain_policy", []string{"gpo_abuse"}}, - - // ESC8 is an answer-key objective and is in ares's - // EXPLOITABLE_ESC_TYPES, but had no entry in the prefix table. - {"adcs esc8", "adcs_esc8_192.168.58.50_ca01", []string{"adcs_esc8"}}, - - // ESC1 must not swallow the longer ESC10/ESC11/ESC13/ESC15 forms. - {"adcs esc1", "adcs_esc1_192.168.58.50_ESC1", []string{"adcs_esc1"}}, - {"adcs esc10 case1", "adcs_esc10_case1_192.168.58.50", []string{"adcs_esc10_case1"}}, - {"adcs esc10 case2", "adcs_esc10_case2_192.168.58.50", []string{"adcs_esc10_case2"}}, - {"adcs esc11", "adcs_esc11_192.168.58.50", []string{"adcs_esc11"}}, - {"adcs esc13", "adcs_esc13_192.168.58.50_group", []string{"adcs_esc13"}}, - {"adcs esc15", "adcs_esc15_192.168.58.50", []string{"adcs_esc15"}}, - - // mssql_ is a prefix of the two longer forms; order decides. - {"mssql linked server", "mssql_linked_server_192_168_58_22_sql01", []string{"mssql_linked_server"}}, - {"mssql impersonation", "mssql_impersonation_192_168_58_22", []string{"mssql_exploit"}}, - {"mssql bare", "mssql_192_168_58_22", []string{"mssql_exploit"}}, - - {"kerberoast", "kerberoast_svc_sql", []string{"kerberoast"}}, - {"asrep roast", "asrep_roast_contoso.local", []string{"asrep_roast"}}, - {"ntlm relay", "ntlm_relay_192_168_58_10", []string{"ntlm_relay"}}, - {"ntlmv1 downgrade", "ntlmv1_192_168_58_12", []string{"ntlmv1_downgrade"}}, - {"seimpersonate", "seimpersonate_sql01", []string{"seimpersonate"}}, - {"sid history", "sid_history_alice", []string{"sid_history_abuse"}}, - {"constrained delegation", "constrained_delegation_svc_web", []string{"constrained_delegation"}}, - {"unconstrained delegation", "unconstrained_delegation_dc01", []string{"unconstrained_delegation"}}, - {"shadow credentials", "shadow_credentials_dc01", []string{"shadow_credentials"}}, - {"rbcd", "rbcd_dc01", []string{"rbcd"}}, - {"gmsa", "gmsa_svc_gmsa", []string{"gmsa_password_read"}}, - {"laps", "laps_sql01", []string{"laps_password_read"}}, - {"forest trust", "forest_trust_escalation_fabrikam.local", []string{"cross_forest_trust"}}, - {"child to parent", "child_to_parent_contoso.local", []string{"child_to_parent"}}, - - // Per-domain objective, lowercased. - {"golden ticket", "golden_ticket_CONTOSO.LOCAL", []string{"golden_ticket-contoso.local"}}, - - // Discovery-only ids ares tracks that are not scoreboard techniques. - {"smb signing", "smb_signing_192_168_58_10", nil}, - {"webdav", "webdav_enabled_192_168_58_22", nil}, - {"spooler", "spooler_192_168_58_10", nil}, - {"ldap signing", "ldap_signing_192_168_58_10", nil}, - {"dc secretsdump", "dc_secretsdump_contoso.local", nil}, - {"empty", "", nil}, + // ares emits the bare prefix; the answer key says cross_forest_trust. + // Passing this through unchanged silently drops a real objective. + {"forest trust is renamed", "forest_trust", "cross_forest_trust"}, + + // ares only ever runs the nxc zerologon *check* module, never the + // password reset, so its id is a detection and not an exploit. + {"zerologon is refused", "zerologon", ""}, + + // ares discards the domain; the answer key needs one objective per + // domain, so these come from domain_compromise[] instead. + {"golden ticket is refused", "golden_ticket", ""}, + + // token_category's catch-all for ids it does not recognise. + {"other is refused", "other", ""}, + + // Everything ares already normalises passes through untouched. + {"acl abuse", "acl_abuse", "acl_abuse"}, + {"gpo abuse", "gpo_abuse", "gpo_abuse"}, + {"mssql exploit", "mssql_exploit", "mssql_exploit"}, + {"mssql linked server", "mssql_linked_server", "mssql_linked_server"}, + {"ntlmv1 downgrade", "ntlmv1_downgrade", "ntlmv1_downgrade"}, + {"llmnr", "llmnr_nbtns_poisoning", "llmnr_nbtns_poisoning"}, + {"sid history", "sid_history_abuse", "sid_history_abuse"}, + {"gmsa", "gmsa_password_read", "gmsa_password_read"}, + {"laps", "laps_password_read", "laps_password_read"}, + {"child to parent", "child_to_parent", "child_to_parent"}, + {"kerberoast", "kerberoast", "kerberoast"}, + {"asrep roast", "asrep_roast", "asrep_roast"}, + {"nopac", "nopac", "nopac"}, + {"printnightmare", "printnightmare", "printnightmare"}, + {"adcs esc8", "adcs_esc8", "adcs_esc8"}, + {"adcs esc10 case1", "adcs_esc10_case1", "adcs_esc10_case1"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := aresExploitedToTechniqueIDs(tc.entry) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("aresExploitedToTechniqueIDs(%q) = %v, want %v", tc.entry, got, tc.want) + if got := aresCategoryToTechniqueID(tc.category); got != tc.want { + t.Errorf("aresCategoryToTechniqueID(%q) = %q, want %q", tc.category, got, tc.want) } }) } } -// TestAresExploitedTechniquesExistInAnswerKey guards against the drift that -// motivated this file: a prefix that maps to a technique ID the generated -// answer key never declares is a silently dead credit. -func TestAresExploitedTechniquesExistInAnswerKey(t *testing.T) { +// Only categories with a proven exploit may credit an objective. A category +// ares discovered but never exploited must stay dark, which is the whole point +// of reading `exploited` rather than `discovered`. +func TestWriteTokenCoverageEntries(t *testing.T) { + coverage := map[string]aresTokenCoverage{ + "acl_abuse": {Discovered: 12, Exploited: 3, Status: "partial"}, + "adcs_esc1": {Discovered: 2, Exploited: 2, Status: "ok"}, + "kerberoast": {Discovered: 4, Exploited: 0, Status: "missing"}, + "forest_trust": {Discovered: 1, Exploited: 1, Status: "ok"}, + "zerologon": {Discovered: 1, Exploited: 1, Status: "ok"}, + "golden_ticket": {Discovered: 0, Exploited: 1, Status: "ok"}, + "other": {Discovered: 9, Exploited: 5, Status: "partial"}, + } + + var b strings.Builder + writeTokenCoverageEntries(&b, coverage, map[string]bool{}) + got := b.String() + + for _, want := range []string{"tech:acl_abuse", "tech:adcs_esc1", "tech:cross_forest_trust"} { + if !strings.Contains(got, want) { + t.Errorf("expected %q in output:\n%s", want, got) + } + } + for _, notWant := range []string{ + "tech:kerberoast", // discovered but never exploited + "tech:zerologon", // check-only, refused + "tech:golden_ticket", // credited from domain_compromise instead + "tech:other", // catch-all, refused + "tech:forest_trust", // must appear only under its renamed ID + } { + if strings.Contains(got, notWant) { + t.Errorf("did not expect %q in output:\n%s", notWant, got) + } + } +} + +// A technique already credited by an earlier writer must not be emitted twice; +// synthesizeJSONL shares one `emitted` map across all writers so the scoreboard +// sees a single finding per objective. +func TestWriteTokenCoverageEntries_RespectsEmitted(t *testing.T) { + coverage := map[string]aresTokenCoverage{ + "acl_abuse": {Discovered: 1, Exploited: 1, Status: "ok"}, + } + emitted := map[string]bool{"acl_abuse": true} + + var b strings.Builder + writeTokenCoverageEntries(&b, coverage, emitted) + + if got := b.String(); got != "" { + t.Errorf("expected no output for an already-emitted technique, got:\n%s", got) + } +} + +// Output order must not depend on Go's randomised map iteration, or an +// unchanged operation would synthesize a different report on every poll. +func TestWriteTokenCoverageEntries_DeterministicOrder(t *testing.T) { + coverage := map[string]aresTokenCoverage{ + "acl_abuse": {Discovered: 1, Exploited: 1}, + "adcs_esc1": {Discovered: 1, Exploited: 1}, + "kerberoast": {Discovered: 1, Exploited: 1}, + "rbcd": {Discovered: 1, Exploited: 1}, + "gpo_abuse": {Discovered: 1, Exploited: 1}, + } + + var first strings.Builder + writeTokenCoverageEntries(&first, coverage, map[string]bool{}) + want := first.String() + + for i := 0; i < 20; i++ { + var b strings.Builder + writeTokenCoverageEntries(&b, coverage, map[string]bool{}) + if got := b.String(); got != want { + t.Fatalf("output not stable across iterations:\nfirst:\n%s\ngot:\n%s", want, got) + } + } +} + +// An ares build that predates token_coverage, or an operation with no state +// yet, yields a nil map. That must be a quiet no-op rather than a panic. +func TestWriteTokenCoverageEntries_NilCoverage(t *testing.T) { + var b strings.Builder + writeTokenCoverageEntries(&b, nil, map[string]bool{}) + if got := b.String(); got != "" { + t.Errorf("expected no output for nil coverage, got %q", got) + } +} + +// Golden-ticket objectives are per-domain and must survive the switch to +// token_coverage, which throws the domain away. They ride on domain_compromise[] +// instead, and the shared emitted map must not let the two paths collide. +func TestSynthesizeJSONL_GoldenTicketStillPerDomain(t *testing.T) { + loot := &aresLoot{ + OperationID: "op1", + StartedAt: "2026-07-30T00:00:00Z", + TokenCoverage: map[string]aresTokenCoverage{ + "golden_ticket": {Discovered: 0, Exploited: 1, Status: "ok"}, + "acl_abuse": {Discovered: 2, Exploited: 1, Status: "partial"}, + }, + DomainCompromise: []aresDomainCompromise{ + {Domain: "ESSOS.LOCAL", HasDomainAdmin: true, HasGoldenTicket: true}, + }, + } + + got := synthesizeJSONL(loot) + + if !strings.Contains(got, "tech:golden_ticket-essos.local") { + t.Errorf("expected per-domain golden ticket objective in output:\n%s", got) + } + if strings.Contains(got, `"target":"tech:golden_ticket"`) { + t.Errorf("bare golden_ticket must not be credited:\n%s", got) + } + if !strings.Contains(got, "tech:acl_abuse") { + t.Errorf("expected acl_abuse in output:\n%s", got) + } +} + +// Every technique ID this transport can emit must exist in the generated answer +// key. A category that maps to an ID the answer key never declares is a silently +// dead credit, which is the failure mode that motivated reading token_coverage +// in the first place. +func TestAresCategoriesExistInAnswerKey(t *testing.T) { ak, err := GenerateAnswerKey("../../../ad/GOAD/data/config.json") if err != nil { t.Fatal(err) @@ -99,23 +194,26 @@ func TestAresExploitedTechniquesExistInAnswerKey(t *testing.T) { } } - entries := []string{ - "acl_genericall_alice_bob", - "gpo_writeproperty_alice_policy", - "adcs_esc8_192.168.58.50_ca01", - "kerberoast_svc_sql", - "mssql_linked_server_192_168_58_22_sql01", + // The categories ares can emit, taken from token_category's ADCS list and + // CATEGORIES table in ares-cli display.rs. + categories := []string{ + "acl_abuse", "gpo_abuse", "mssql_exploit", "mssql_linked_server", + "constrained_delegation", "unconstrained_delegation", "shadow_credentials", + "ntlm_relay", "child_to_parent", "forest_trust", "sid_history_abuse", + "asrep_roast", "seimpersonate", "printnightmare", "kerberoast", "nopac", + "ntlmv1_downgrade", "llmnr_nbtns_poisoning", "gmsa_password_read", + "laps_password_read", "rbcd", + "adcs_esc1", "adcs_esc2", "adcs_esc3", "adcs_esc4", "adcs_esc6", + "adcs_esc7", "adcs_esc8", "adcs_esc9", "adcs_esc10_case1", + "adcs_esc10_case2", "adcs_esc11", "adcs_esc13", "adcs_esc15", } - for _, entry := range entries { - ids := aresExploitedToTechniqueIDs(entry) - if len(ids) == 0 { - t.Errorf("%q maps to no technique", entry) - continue + for _, category := range categories { + techID := aresCategoryToTechniqueID(category) + if techID == "" { + continue // deliberately refused } - for _, id := range ids { - if !known[id] { - t.Errorf("%q maps to %q, which is not an answer-key objective", entry, id) - } + if !known[techID] { + t.Errorf("category %q maps to %q, which is not an answer-key objective", category, techID) } } } diff --git a/cli/internal/scoreboard/verify_test.go b/cli/internal/scoreboard/verify_test.go index a137ea30..d8e38840 100644 --- a/cli/internal/scoreboard/verify_test.go +++ b/cli/internal/scoreboard/verify_test.go @@ -209,7 +209,7 @@ func TestSynthesizeJSONLDomainCompromise(t *testing.T) { }, }, } - jsonl := synthesizeJSONL(loot, nil) + jsonl := synthesizeJSONL(loot) report := ParseReport(jsonl) owned := domainsFromKrbtgt(report.Findings) @@ -265,8 +265,11 @@ func TestVerifyDomainCompromiseWithoutGoldenTicket(t *testing.T) { AdminUsers: []string{"administrator"}, }, }, + TokenCoverage: map[string]aresTokenCoverage{ + "adcs_esc1": {Discovered: 1, Exploited: 1, Status: "ok"}, + }, } - report := ParseReport(synthesizeJSONL(loot, []string{"adcs_esc1_10.1.2.254"})) + report := ParseReport(synthesizeJSONL(loot)) status := VerifyReport(report, ak) verified := verifiedObjectiveIDs(status)