guide: wire GitHub PATs + Stripe keys into the guided worklist

GitHub PATs and Stripe secret keys have no scriptable rotation API (GitHub's
create-token API was removed in 2020; Stripe has no create-key endpoint), so
they belong in the guided change-password layer, not as Rotators. But they
arrive from the env/file scanner as generic Source="env" tokens with no host,
so the worklist showed them as "manual — no web page".

Recognise them by their well-known PUBLIC value prefixes (ghp_/gho_/ghs_/
github_pat_/…, sk_live_/sk_test_/rk_live_/…) at scan time and record a
non-secret Meta["service"] hint — a fixed service NAME, never the secret bytes.
discover.ServiceForSecret does the detection; env.go attaches it for generic
tokens, file.go before Store wipes the buffer. links.HostFor consults the hint
and maps github→github.com / stripe→stripe.com, so the curated change-password
URLs (already in the table) now light up for these credentials.

Leak-safe (service name is derived from a public prefix, not the secret) and
verified by the existing assertNoLeak checks. go build/vet clean; full suite
179 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-20 15:35:42 -07:00
parent 88e603f3c0
commit d237098d83
7 changed files with 158 additions and 1 deletions
+10
View File
@@ -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()
+6
View File
@@ -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/<rel>.
@@ -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
}
+5 -1
View File
@@ -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") {
+45
View File
@@ -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 ""
}
+30
View File
@@ -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)
}
})
}
}
+17
View File
@@ -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" {
+45
View File
@@ -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)
}
}
}