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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user