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) {
|
} else if !looksSecret(k, val) {
|
||||||
continue
|
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{
|
creds = append(creds, Credential{
|
||||||
Source: source,
|
Source: source,
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
@@ -81,6 +90,7 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er
|
|||||||
Location: p,
|
Location: p,
|
||||||
Modified: fi.ModTime(),
|
Modified: fi.ModTime(),
|
||||||
Secret: v.Store([]byte(secretBlob)),
|
Secret: v.Store([]byte(secretBlob)),
|
||||||
|
Meta: meta,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
f.Close()
|
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
|
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)
|
rel, e := filepath.Rel(root, path)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
rel = filepath.Base(path)
|
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
|
// Identity is the relative path (non-secret, never the contents). We do NOT
|
||||||
// prefix the scanner name here — StorePath already prepends the source
|
// prefix the scanner name here — StorePath already prepends the source
|
||||||
// segment, so a "file / " prefix would double it to imported/file/file/<rel>.
|
// 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,
|
Location: path,
|
||||||
Modified: info.ModTime(),
|
Modified: info.ModTime(),
|
||||||
Secret: v.Store(b), // copies into locked mem, wipes b
|
Secret: v.Store(b), // copies into locked mem, wipes b
|
||||||
|
Meta: meta,
|
||||||
}, true
|
}, 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)
|
t.Fatalf("want 5 creds (secret-ish + DB URLs), got %d: %+v", len(creds), creds)
|
||||||
}
|
}
|
||||||
idents := byIdentity(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)
|
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 {
|
for id := range idents {
|
||||||
if strings.Contains(id, "PORT") || strings.Contains(id, "DEBUG") {
|
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",
|
"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.
|
// For returns the best rotation URL for a host.
|
||||||
func For(host string) Link {
|
func For(host string) Link {
|
||||||
h := normalize(host)
|
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
|
// 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).
|
// (e.g. ssh keys and opaque file/env secrets have no rotation page).
|
||||||
func HostFor(c discover.Credential) string {
|
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).
|
// "user @ host" identities (git, netrc).
|
||||||
if i := strings.LastIndex(c.Identity, " @ "); i >= 0 {
|
if i := strings.LastIndex(c.Identity, " @ "); i >= 0 {
|
||||||
if host := strings.TrimSpace(c.Identity[i+3:]); host != "" && host != "default" {
|
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)
|
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