diff --git a/internal/discover/env.go b/internal/discover/env.go index f8ff03a..575f1b2 100644 --- a/internal/discover/env.go +++ b/internal/discover/env.go @@ -74,6 +74,15 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er } else if !looksSecret(k, val) { continue } + // For a generic env token (not a driver-ready DB URL / appsecret), record + // a non-secret service hint when the value carries a well-known provider + // prefix, so the worklist can offer a guided change-password page. + var meta map[string]string + if source == e.Name() { + if svc := ServiceForSecret([]byte(val)); svc != "" { + meta = map[string]string{MetaService: svc} + } + } creds = append(creds, Credential{ Source: source, Kind: kind, @@ -81,6 +90,7 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er Location: p, Modified: fi.ModTime(), Secret: v.Store([]byte(secretBlob)), + Meta: meta, }) } f.Close() diff --git a/internal/discover/file.go b/internal/discover/file.go index 51b4d88..c79c0a5 100644 --- a/internal/discover/file.go +++ b/internal/discover/file.go @@ -89,10 +89,15 @@ func (f *fileScanner) harvest(v *vault.Vault, path, root string) (Credential, bo } kind := classifyContent(b) // inspect bytes BEFORE Store wipes them + svc := ServiceForSecret(b) // recognise provider prefix BEFORE Store wipes them rel, e := filepath.Rel(root, path) if e != nil { rel = filepath.Base(path) } + var meta map[string]string + if svc != "" { + meta = map[string]string{MetaService: svc} + } // Identity is the relative path (non-secret, never the contents). We do NOT // prefix the scanner name here — StorePath already prepends the source // segment, so a "file / " prefix would double it to imported/file/file/. @@ -103,6 +108,7 @@ func (f *fileScanner) harvest(v *vault.Vault, path, root string) (Credential, bo Location: path, Modified: info.ModTime(), Secret: v.Store(b), // copies into locked mem, wipes b + Meta: meta, }, true } diff --git a/internal/discover/scanners_test.go b/internal/discover/scanners_test.go index f8d77b8..f569fc2 100644 --- a/internal/discover/scanners_test.go +++ b/internal/discover/scanners_test.go @@ -118,8 +118,12 @@ func TestEnvScanner(t *testing.T) { t.Fatalf("want 5 creds (secret-ish + DB URLs), got %d: %+v", len(creds), creds) } idents := byIdentity(creds) - if _, ok := idents[".env / STRIPE_API_KEY"]; !ok { + if sk, ok := idents[".env / STRIPE_API_KEY"]; !ok { t.Errorf("missing STRIPE_API_KEY; got %v", idents) + } else if sk.Meta[MetaService] != "stripe" { + // The sk_live_ prefix must be recognised as a non-secret service hint so the + // worklist can offer a guided change-password page (Stripe has no rotate API). + t.Errorf("STRIPE_API_KEY missing service hint; Meta=%v", sk.Meta) } for id := range idents { if strings.Contains(id, "PORT") || strings.Contains(id, "DEBUG") { diff --git a/internal/discover/service.go b/internal/discover/service.go new file mode 100644 index 0000000..760907d --- /dev/null +++ b/internal/discover/service.go @@ -0,0 +1,45 @@ +package discover + +import "bytes" + +// MetaService is the Meta key under which a scanner records the issuing service of +// a credential it recognised by value-prefix (e.g. "github", "stripe"). It is a +// NON-SECRET hint: the value is a fixed service name, never the secret material. +// The links/worklist layer turns it into a guided change-password URL for +// credentials that have no scriptable rotation API (GitHub PATs, Stripe keys). +const MetaService = "service" + +// servicePrefixes maps a credential's well-known, PUBLIC value prefix to the +// service that issued it. Each prefix is a documented format marker — not the +// secret itself — so matching one and recording the service name leaks nothing. +var servicePrefixes = []struct { + prefix []byte + service string +}{ + {[]byte("github_pat_"), "github"}, // fine-grained PAT + {[]byte("ghp_"), "github"}, // classic personal access token + {[]byte("gho_"), "github"}, // OAuth access token + {[]byte("ghu_"), "github"}, // user-to-server token + {[]byte("ghs_"), "github"}, // server-to-server token + {[]byte("ghr_"), "github"}, // refresh token + {[]byte("sk_live_"), "stripe"}, // secret key (live) + {[]byte("sk_test_"), "stripe"}, // secret key (test) + {[]byte("rk_live_"), "stripe"}, // restricted key (live) + {[]byte("rk_test_"), "stripe"}, // restricted key (test) +} + +// ServiceForSecret recognises a credential by its well-known value prefix and +// returns the issuing service ("github", "stripe", …) or "" if unrecognised. It +// inspects only the leading bytes and returns a fixed service NAME, so no secret +// material escapes. Callers attach the result as Meta[MetaService] so the +// links/worklist layer can offer a guided rotation page for credentials that +// cannot be rotated through a provider API. +func ServiceForSecret(b []byte) string { + head := bytes.TrimLeft(b, " \t\r\n") + for _, sp := range servicePrefixes { + if bytes.HasPrefix(head, sp.prefix) { + return sp.service + } + } + return "" +} diff --git a/internal/discover/service_test.go b/internal/discover/service_test.go new file mode 100644 index 0000000..ecf0200 --- /dev/null +++ b/internal/discover/service_test.go @@ -0,0 +1,30 @@ +package discover + +import "testing" + +func TestServiceForSecret(t *testing.T) { + cases := []struct { + name string + val string + want string + }{ + {"github classic pat", "ghp_0123456789abcdefABCDEF", "github"}, + {"github fine-grained pat", "github_pat_11ABCDE0000xyz", "github"}, + {"github oauth", "gho_abcdef0123456789", "github"}, + {"github server", "ghs_abcdef0123456789", "github"}, + {"stripe secret live", "sk_live_51HxYzAbCdEf", "stripe"}, + {"stripe secret test", "sk_test_51HxYzAbCdEf", "stripe"}, + {"stripe restricted", "rk_live_51HxYzAbCdEf", "stripe"}, + {"leading whitespace", "\n ghp_abc123", "github"}, + {"unknown opaque token", "xoxb-not-a-known-prefix", ""}, + {"random high entropy", "Zk9fQ2pVw7eR1tY3", ""}, + {"empty", "", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ServiceForSecret([]byte(c.val)); got != c.want { + t.Errorf("ServiceForSecret(%q) = %q, want %q", c.val, got, c.want) + } + }) + } +} diff --git a/internal/links/links.go b/internal/links/links.go index 091bb55..9b2fe56 100644 --- a/internal/links/links.go +++ b/internal/links/links.go @@ -54,6 +54,16 @@ var serviceByScanner = map[string]string{ "aws": "console.aws.amazon.com", } +// serviceHost maps a scanner's non-secret service hint (discover.MetaService) to +// the web host whose curated page rotates that credential. This is how guided-only +// credentials — GitHub PATs and Stripe keys, which have no scriptable rotation API +// — get a change-password link even though they arrive from the env/file scanner +// with no host of their own. +var serviceHost = map[string]string{ + "github": "github.com", + "stripe": "stripe.com", +} + // For returns the best rotation URL for a host. func For(host string) Link { h := normalize(host) @@ -72,6 +82,13 @@ func For(host string) Link { // HostFor derives a web host from a discovered credential, or "" if none applies // (e.g. ssh keys and opaque file/env secrets have no rotation page). func HostFor(c discover.Credential) string { + // A scanner-recognised provider (GitHub PAT, Stripe key) carries a non-secret + // service hint — the strongest signal, since the value prefix is unambiguous. + if svc := c.Meta[discover.MetaService]; svc != "" { + if h, ok := serviceHost[svc]; ok { + return h + } + } // "user @ host" identities (git, netrc). if i := strings.LastIndex(c.Identity, " @ "); i >= 0 { if host := strings.TrimSpace(c.Identity[i+3:]); host != "" && host != "default" { diff --git a/internal/links/links_test.go b/internal/links/links_test.go index e89a9e7..6752860 100644 --- a/internal/links/links_test.go +++ b/internal/links/links_test.go @@ -54,3 +54,48 @@ func TestLinkForGit(t *testing.T) { t.Errorf("LinkFor git github = {%q,%s}", l.URL, l.Source) } } + +// A GitHub PAT / Stripe key arrives from the env or file scanner with no host of +// its own, only a non-secret service hint. HostFor must turn that hint into the +// curated change-password host so the guided worklist gets a real link. +func TestHostForServiceHint(t *testing.T) { + cases := []struct { + service string + want string + }{ + {"github", "github.com"}, + {"stripe", "stripe.com"}, + {"unknown", ""}, + } + for _, c := range cases { + cred := discover.Credential{ + Source: "env", + Identity: ".env / API_KEY", + Meta: map[string]string{discover.MetaService: c.service}, + } + if got := HostFor(cred); got != c.want { + t.Errorf("HostFor(service=%q) = %q, want %q", c.service, got, c.want) + } + } +} + +func TestLinkForServiceHint(t *testing.T) { + cases := []struct { + service string + wantURL string + }{ + {"github", "https://github.com/settings/tokens"}, + {"stripe", "https://dashboard.stripe.com/apikeys"}, + } + for _, c := range cases { + cred := discover.Credential{ + Source: "env", + Identity: ".env / TOKEN", + Meta: map[string]string{discover.MetaService: c.service}, + } + l := LinkFor(cred) + if l.Source != SourceKnown || l.URL != c.wantURL { + t.Errorf("LinkFor(service=%q) = {%q,%s}, want {%q,known}", c.service, l.URL, l.Source, c.wantURL) + } + } +}