From 35e99c752678bcd406429a8687fbb0bf92f30047 Mon Sep 17 00:00:00 2001 From: Stefan <102912454+beer4code@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:27:27 +0200 Subject: [PATCH] feat(dynatrace): validate tokens via v2 lookup + scope-based blast radius --- internal/modules/catalog_header.go | 47 ++++++- internal/modules/dynatrace.go | 91 +++++++++++++ internal/modules/dynatrace_test.go | 205 +++++++++++++++++++++++++++++ 3 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 internal/modules/dynatrace.go create mode 100644 internal/modules/dynatrace_test.go diff --git a/internal/modules/catalog_header.go b/internal/modules/catalog_header.go index 2e3e7ff..92f0824 100644 --- a/internal/modules/catalog_header.go +++ b/internal/modules/catalog_header.go @@ -99,9 +99,50 @@ func init() { Calls: []r.Call{r.GET("/services").CountArray("services", "services")}, }.Module()) - add("dynatrace-api-token", r.HTTP{ - ModuleName: "dynatrace", Base: "{endpoint}", Auth: r.AuthSpec{Kind: r.Header, HeaderName: "Authorization", ValuePrefix: "Api-Token "}, - Whoami: r.GET("/api/v1/time").Field("server-time", ""), + add("", r.HTTP{ + ModuleName: "dynatrace", Base: "{endpoint}", + Auth: r.AuthSpec{Kind: r.Header, HeaderName: "Authorization", ValuePrefix: "Api-Token "}, + Accept: "application/json", + Whoami: r.Call{ + Method: "POST", Path: "/api/v2/apiTokens/lookup", Body: `{"token":"{token}"}`, ReadOnlyPOST: true, + Fields: []r.Extract{ + {Key: "name", Path: "name"}, + {Key: "owner", Path: "owner"}, + {Key: "personal access token", Path: "personalAccessToken"}, + {Key: "expires", Path: "expirationDate"}, + {Key: "last used", Path: "lastUsedDate"}, + {Key: "last used IP", Path: "lastUsedIpAddress"}, + {Key: "scopes", Path: "scopes", Flag: warnFlag}, + }, + Signals: []r.Signal{ + {Path: "scopes", Regex: `(?i)\b(apiTokens\.write|settings\.write|WriteConfig|extensions\.write|PluginUpload|extensionConfigurations\.write|credentialVault\.write|ExternalSyntheticIntegration|syntheticExecutions\.write|CaptureRequestData|DataPrivacy)\b`, + Key: "privilege", Value: "high-impact scope — mint tokens / write tenant config / deploy extensions (RCE) / run synthetic JS in-network / capture request data (wiretap) / disable PII masking / inject credentials", Flag: fmFlag}, + {Path: "scopes", Regex: `(?i)\b(logs\.read|LogExport|DTAQLAccess|DataExport|settings\.read|ReadConfig|apiTokens\.read|auditLogs\.read|RestRequestForwarding|InstallerDownload)\b`, + Key: "exposure", Value: "sensitive read / exfil — logs & session replay (PII, secrets), config & token enumeration, cross-environment data pull", Flag: warnFlag}, + {Path: "scopes", Regex: `(?i)\b(\w+\.ingest|entities\.write|problems\.write|securityProblems\.write|oneAgents\.write|activeGateTokenManagement\.write|tenantTokenRotation\.write)\b`, + Key: "integrity", Value: "integrity/availability write — inject/forge telemetry (poison dashboards, log injection, mask attacks), corrupt topology, hide problems, or tamper the OneAgent fleet", Flag: warnFlag}, + }, + }, + Summarize: func(fs []module.Finding) string { + var exposure, integrity bool + for _, f := range fs { + switch f.Key { + case "privilege": + return "Dynatrace token — config write / token minting / extension deploy (RCE)" + case "exposure": + exposure = true + case "integrity": + integrity = true + } + } + switch { + case exposure: + return "Dynatrace token — sensitive data read (logs/sessions/config)" + case integrity: + return "Dynatrace token — telemetry/config mutation (integrity & evasion)" + } + return "Dynatrace token — scoped observability access" + }, }.Module()) add("honeycomb", r.HTTP{ diff --git a/internal/modules/dynatrace.go b/internal/modules/dynatrace.go new file mode 100644 index 0000000..f6861e0 --- /dev/null +++ b/internal/modules/dynatrace.go @@ -0,0 +1,91 @@ +package modules + +import ( + "regexp" + "strings" + + "github.com/puck-security/geiger/internal/module" + "github.com/puck-security/geiger/internal/parse" + "github.com/puck-security/geiger/internal/recognize" +) + +// Dynatrace API/PAT/platform tokens have the shape dt0.. +// (e.g. dt0c01 API token/PAT, dt0s16 platform token). The token authenticates +// against a specific tenant, whose URL almost always sits next to the token in a +// real leak (a config value, a logged request URL, a query param). +var ( + dtTokenRe = regexp.MustCompile(`dt0[a-z][0-9]{2}\.[A-Za-z0-9-]{8,128}\.[A-Za-z0-9]{64}`) + dtTenantRe = regexp.MustCompile(`[a-z0-9-]+\.(?:live\.dynatrace|apps\.dynatrace|(?:dev|sprint)\.dynatracelabs|(?:dev|sprint)\.apps\.dynatracelabs)\.com`) +) + +func init() { recognize.RegisterRecognizer(recognizeDynatrace) } + +func recognizeDynatrace(b parse.Blob, endpoint string, _ *module.Registry) []recognize.Match { + tok := dtTokenRe.FindString(b.Raw) + if tok == "" { + tok = firstVar(b.Vars, "DT_API_TOKEN", "DYNATRACE_API_TOKEN", "DYNATRACE_TOKEN", "DT_TOKEN") + } + if tok == "" { + return nil + } + + // Resolve the tenant, first source wins: the URL discovered next to the token, + // then an env var, then the --endpoint override. The reported tenant keeps its + // original host; the endpoint we call is normalized to the classic API host. + var tenant, apiURL string + if host := dtTenantRe.FindString(b.Raw); host != "" { + tenant, apiURL = host, "https://"+dynatraceAPIHost(host) + } else if env := firstVar(b.Vars, "DT_ENV_URL", "DYNATRACE_ENV_URL", "DYNATRACE_URL", "DT_TENANT"); env != "" { + tenant, apiURL = env, dynatraceAPIURL(env) + } else if endpoint != "" { + apiURL = dynatraceAPIURL(endpoint) + } + + if apiURL == "" { + // Token but no tenant: surface a "needs endpoint" hint rather than dropping + // it, so a responder learns the token exists and how to characterize it. + return []recognize.Match{{ + Module: "needs_endpoint", + Fields: module.Fields{ + "service": "Dynatrace", + "impact": "observability data + tenant config API (scope-dependent: metrics/logs read → config write / token minting / extension deploy)", + "endpoint_var": "DYNATRACE_ENV_URL", + }, + Secret: tok, Label: "DYNATRACE_API_TOKEN", + }} + } + + fields := module.Fields{"token": tok, "endpoint": apiURL} + if tenant != "" { + fields["tenant"] = tenant + } + return []recognize.Match{{Module: "dynatrace", Fields: fields, Secret: tok, Label: "DYNATRACE_API_TOKEN"}} +} + +// dynatraceAPIHost maps a tenant host to the classic API host. The 3rd-gen +// Platform host (*.apps.dynatrace.com, and dev/sprint *.apps.dynatracelabs.com) +// has no usable validation endpoint, but every token — including platform tokens — +// authenticates against the classic API, where /api/v2/apiTokens/lookup lives. The +// live/dev/sprint qualifier is never changed; only the extra "apps" label is +// dropped/rewritten. +func dynatraceAPIHost(host string) string { + h := strings.Replace(host, ".apps.dynatrace.com", ".live.dynatrace.com", 1) // prod 3rd-gen → classic API + h = strings.Replace(h, ".apps.", ".", 1) // dev/sprint 3rd-gen → classic API + return h +} + +// dynatraceAPIURL turns a host or full URL (from an env var or --endpoint) into a +// normalized https API base URL. +func dynatraceAPIURL(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "https://") + v = strings.TrimPrefix(v, "http://") + if i := strings.IndexAny(v, "/?#"); i >= 0 { + v = v[:i] // drop any path/query + } + v = strings.TrimSuffix(v, ".") + if v == "" { + return "" + } + return "https://" + dynatraceAPIHost(v) +} diff --git a/internal/modules/dynatrace_test.go b/internal/modules/dynatrace_test.go new file mode 100644 index 0000000..a177e56 --- /dev/null +++ b/internal/modules/dynatrace_test.go @@ -0,0 +1,205 @@ +package modules + +import ( + "net/http" + "strings" + "testing" + + "github.com/puck-security/geiger/internal/module" + "github.com/puck-security/geiger/internal/parse" + "github.com/puck-security/geiger/internal/recognize" +) + +// a syntactically valid three-segment token (prefix.id.secret); secret is exactly +// 64 alphanumerics, id within 8..128. +var ( + dtID = strings.Repeat("B", 24) + dtSecret = strings.Repeat("A", 64) + dtAPITok = "dt0c01." + dtID + "." + dtSecret // classic API token / PAT + dtPlatTk = "dt0s16." + dtID + "." + dtSecret // 3rd-gen platform token +) + +func TestDynatraceRecognizer(t *testing.T) { + cases := []struct { + name string + raw string + wantModule string // "" = expect neither dynatrace nor needs_endpoint + wantEndpoint string + wantTenant string + }{ + {"token + live tenant", dtAPITok + " https://qxz71834.live.dynatrace.com/api", "dynatrace", + "https://qxz71834.live.dynatrace.com", "qxz71834.live.dynatrace.com"}, + {"prod apps normalized to live", dtAPITok + " mwk52907.apps.dynatrace.com", "dynatrace", + "https://mwk52907.live.dynatrace.com", "mwk52907.apps.dynatrace.com"}, + {"dev apps label dropped", dtAPITok + " tnv38115.dev.apps.dynatracelabs.com", "dynatrace", + "https://tnv38115.dev.dynatracelabs.com", "tnv38115.dev.apps.dynatracelabs.com"}, + {"dev host unchanged", dtAPITok + " tnv38115.dev.dynatracelabs.com", "dynatrace", + "https://tnv38115.dev.dynatracelabs.com", "tnv38115.dev.dynatracelabs.com"}, + {"platform token recognized", dtPlatTk + " https://qxz71834.live.dynatrace.com", "dynatrace", + "https://qxz71834.live.dynatrace.com", "qxz71834.live.dynatrace.com"}, + {"token via env vars", "DT_API_TOKEN=" + dtAPITok + "\nDT_ENV_URL=https://jrb64200.live.dynatrace.com\n", "dynatrace", + "https://jrb64200.live.dynatrace.com", ""}, + {"token without tenant", dtAPITok + "\n", "needs_endpoint", "", ""}, + {"two-segment client id ignored", "dt0s02." + dtID + "\n", "", "", ""}, + {"short secret ignored", "dt0c01." + dtID + "." + strings.Repeat("A", 32) + "\n", "", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + by := modulesOf(recognize.Recognize(parse.Parse(tc.raw, "test.env"), "", module.Default)) + for k := range by { + if strings.HasPrefix(k, "__unknown__") { + t.Errorf("leftover generic hit %q should be suppressed by our recognizer", k) + } + } + if tc.wantModule == "" { + if _, ok := by["dynatrace"]; ok { + t.Errorf("must not recognize as dynatrace: %+v", by) + } + if _, ok := by["needs_endpoint"]; ok { + t.Errorf("must not emit needs_endpoint: %+v", by) + } + return + } + m, ok := by[tc.wantModule] + if !ok { + t.Fatalf("not recognized as %s: %+v", tc.wantModule, by) + } + if m.Secret != dtAPITok && m.Secret != dtPlatTk { + t.Errorf("secret = %q", m.Secret) + } + if tc.wantEndpoint != "" && m.Fields["endpoint"] != tc.wantEndpoint { + t.Errorf("endpoint = %q, want %q", m.Fields["endpoint"], tc.wantEndpoint) + } + if tc.wantTenant != "" && m.Fields["tenant"] != tc.wantTenant { + t.Errorf("tenant = %q, want %q", m.Fields["tenant"], tc.wantTenant) + } + }) + } +} + +func TestDynatraceAPIHost(t *testing.T) { + cases := []struct{ in, want string }{ + {"qxz71834.live.dynatrace.com", "qxz71834.live.dynatrace.com"}, // classic API, unchanged + {"mwk52907.apps.dynatrace.com", "mwk52907.live.dynatrace.com"}, // prod 3rd-gen → classic + {"tnv38115.dev.apps.dynatracelabs.com", "tnv38115.dev.dynatracelabs.com"}, // dev 3rd-gen → classic + {"tnv38115.sprint.dynatracelabs.com", "tnv38115.sprint.dynatracelabs.com"}, // qualifier preserved + } + for _, tc := range cases { + if got := dynatraceAPIHost(tc.in); got != tc.want { + t.Errorf("dynatraceAPIHost(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func dtReconMux(t *testing.T, status int, body string) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/apiTokens/lookup", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("lookup must POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Api-Token dttok" { + t.Errorf("auth header = %q", r.Header.Get("Authorization")) + } + w.WriteHeader(status) + respond(w, body) + }) + return mux +} + +func dtDrive(t *testing.T, status int, body string) map[string]module.Finding { + return driveModule(t, "dynatrace", + module.Fields{"token": "dttok", "endpoint": "https://qxz71834.live.dynatrace.com"}, + dtReconMux(t, status, body)) +} + +func TestDynatraceReconPrivilegeScope(t *testing.T) { + got := dtDrive(t, 200, `{"name":"ci","owner":"joe@acme.com","personalAccessToken":false,`+ + `"lastUsedIpAddress":"1.2.3.4","scopes":["apiTokens.write","entities.read"]}`) + if got["privilege"].Flag != module.FlagForceMultiplier { + t.Errorf("takeover scope must be a force multiplier: %+v", got["privilege"]) + } + if got["owner"].Value != "joe@acme.com" { + t.Errorf("owner = %q", got["owner"].Value) + } + if got["last used IP"].Value != "1.2.3.4" { + t.Errorf("last used IP = %q", got["last used IP"].Value) + } +} + +func TestDynatraceReconSensitiveReadIsMediumNotHigh(t *testing.T) { + // Bulk sensitive read (logs) → exposure warn (MEDIUM), NOT a force multiplier. + got := dtDrive(t, 200, `{"name":"ro","owner":"a@b.com","scopes":["logs.read"]}`) + if got["exposure"].Flag != module.FlagWarn { + t.Errorf("logs.read should be an exposure warn: %+v", got["exposure"]) + } + if _, ok := got["privilege"]; ok { + t.Errorf("sensitive read must NOT be a force multiplier: %+v", got["privilege"]) + } +} + +func TestDynatraceReconIngestIsIntegrity(t *testing.T) { + // A valid write/ingest token (the live BMO case) must outrank read-only: it + // gets an integrity warn but not a force multiplier → MEDIUM, not LOW. + got := dtDrive(t, 200, `{"name":"ai-obs","owner":"a@b.com","scopes":["metrics.ingest","logs.ingest"]}`) + if got["integrity"].Flag != module.FlagWarn { + t.Errorf("ingest scope should be an integrity warn: %+v", got["integrity"]) + } + if _, ok := got["privilege"]; ok { + t.Errorf("ingest must NOT be a force multiplier: %+v", got["privilege"]) + } +} + +func TestDynatraceReconCredentialVaultReadStaysLow(t *testing.T) { + // credentialVault.read returns metadata only (no secret material) → recon. + // It must not fire ANY severity signal (LOW baseline). Guards against a future + // edit silently re-promoting it based on the scary name. + got := dtDrive(t, 200, `{"name":"ro","owner":"a@b.com","scopes":["credentialVault.read"]}`) + for _, k := range []string{"privilege", "exposure", "integrity"} { + if _, ok := got[k]; ok { + t.Errorf("credentialVault.read must not fire %q (metadata-only recon): %+v", k, got[k]) + } + } +} + +func TestDynatraceReconTrivialWriteStaysLow(t *testing.T) { + // Curated, not blanket: a trivial write (slo.write) must not be flagged as + // integrity — it falls to the LOW baseline. + got := dtDrive(t, 200, `{"name":"ro","owner":"a@b.com","scopes":["slo.write"]}`) + for _, k := range []string{"privilege", "exposure", "integrity"} { + if _, ok := got[k]; ok { + t.Errorf("trivial write slo.write must not fire %q: %+v", k, got[k]) + } + } +} + +func TestDynatraceReconReadOnlyStaysLow(t *testing.T) { + got := dtDrive(t, 200, `{"name":"ro","owner":"a@b.com","scopes":["entities.read"]}`) + for _, k := range []string{"privilege", "exposure", "integrity"} { + if _, ok := got[k]; ok { + t.Errorf("read-only token must not be flagged %q: %+v", k, got[k]) + } + } + if got["scopes"].Value != "entities.read" { + t.Errorf("scopes = %q", got["scopes"].Value) + } + if len(got) == 0 { + t.Error("read-only token should still produce findings (not dead)") + } +} + +func TestDynatraceReconInvalidToken(t *testing.T) { + // 401 on the lookup (dead/expired/invalid) → no findings → Summarize marks DEAD. + got := dtDrive(t, 401, `{"error":{"code":401,"message":"Token is invalid"}}`) + if len(got) != 0 { + t.Errorf("401 must yield no findings (DEAD), got %+v", got) + } +} + +func TestDynatraceReconValidButScoped(t *testing.T) { + // 403 = valid platform token lacking apiTokens.read to introspect itself. + got := dtDrive(t, 403, `{"error":{"code":403,"message":"Token is missing required scope"}}`) + if got["authenticated"].Flag != module.FlagWarn { + t.Errorf("403 should report an accepted-but-scoped credential, not dead: %+v", got) + } +}