Skip to content
Open
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
47 changes: 44 additions & 3 deletions internal/modules/catalog_header.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
91 changes: 91 additions & 0 deletions internal/modules/dynatrace.go
Original file line number Diff line number Diff line change
@@ -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<x><nn>.<public-id>.<secret>
// (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)
}
205 changes: 205 additions & 0 deletions internal/modules/dynatrace_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}