discover: broaden discovery to feed the new drivers

Per the standing directive (dynamic discovery per credential type as each
driver lands):
- env.go: tag DB connection URLs by scheme (postgres/postgresql, mysql/mariadb,
  redis/rediss) with a user:password authority, routing DATABASE_URL/REDIS_URL/
  MYSQL_URL straight to the in-place DB drivers, kept regardless of the
  secret-name heuristic.
- aws.go: emit the driver-ready single-line blob (was the bare secret) and
  resolve region from env / ~/.aws/config.
- ssh.go: content-based key detection (not just id_*) + ~/.ssh/config
  IdentityFile parsing.
- tea.go: new scanner mapping Gitea `tea` CLI config to a driver-ready PAT blob.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-18 14:48:30 -07:00
parent 59af53dcc0
commit 1c0896347b
5 changed files with 433 additions and 42 deletions
+63 -2
View File
@@ -3,6 +3,7 @@ package discover
import (
"bufio"
"context"
"net/url"
"os"
"path/filepath"
"strings"
@@ -14,6 +15,13 @@ func init() { Register(&awsScanner{}) }
// awsScanner reads ~/.aws/credentials (INI). Read-only: opens O_RDONLY and never
// writes back.
//
// The emitted secret is the driver-ready single-line blob
// "aws://<AccessKeyId>:<SecretAccessKey>@aws/?region=<region>" (see
// internal/rotate/aws.go), so a discovered key can be rotated directly in Mode B
// (scan → migrate → rotate). The AccessKeyId is non-secret and stays full in the
// blob (the rotation driver deletes the OLD key by id); Identity/Meta keep it
// redacted for display.
type awsScanner struct{}
func (a *awsScanner) Name() string { return "aws" }
@@ -45,16 +53,24 @@ func (a *awsScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er
var profile, keyID string
flush := func(secret string) {
if profile == "" || secret == "" {
if profile == "" || secret == "" || keyID == "" {
return
}
// Driver-ready single-line blob; the secret lives only here (and the vault).
blob := (&url.URL{
Scheme: "aws",
Host: "aws",
User: url.UserPassword(keyID, secret),
Path: "/",
RawQuery: "region=" + url.QueryEscape(awsRegion(profile)),
}).String()
creds = append(creds, Credential{
Source: a.Name(),
Kind: KindAWSKey,
Identity: profile + " / " + redactMiddle(keyID),
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(secret)),
Secret: v.Store([]byte(blob)),
Meta: map[string]string{"access_key_id": redactMiddle(keyID)},
})
keyID = ""
@@ -85,6 +101,51 @@ func (a *awsScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er
return creds, sc.Err()
}
// awsRegion resolves the signing region for a profile: AWS_REGION /
// AWS_DEFAULT_REGION env first, then the `region` key under the matching profile in
// ~/.aws/config (or AWS_CONFIG_FILE), defaulting to us-east-1. Read-only.
func awsRegion(profile string) string {
if r := os.Getenv("AWS_REGION"); r != "" {
return r
}
if r := os.Getenv("AWS_DEFAULT_REGION"); r != "" {
return r
}
cfg := os.Getenv("AWS_CONFIG_FILE")
if cfg == "" {
home, _ := os.UserHomeDir()
cfg = filepath.Join(home, ".aws", "config")
}
f, err := os.Open(cfg) // read-only
if err != nil {
return "us-east-1"
}
defer f.Close()
// In ~/.aws/config non-default profiles are headed "[profile NAME]"; default is
// "[default]".
want := "profile " + profile
if profile == "default" {
want = "default"
}
inSection := false
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
inSection = strings.TrimSpace(line[1:len(line)-1]) == want
continue
}
if !inSection {
continue
}
if k, val, ok := splitKV(line); ok && strings.ToLower(k) == "region" && val != "" {
return val
}
}
return "us-east-1"
}
func splitKV(line string) (k, v string, ok bool) {
i := strings.IndexByte(line, '=')
if i < 0 {
+34 -3
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"math"
"net/url"
"os"
"path/filepath"
"strings"
@@ -53,12 +54,19 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er
continue
}
val = strings.Trim(val, `"'`)
if !looksSecret(k, val) {
// A connection-string value (DATABASE_URL, REDIS_URL, …) is tagged with
// the rotation driver's Source so it becomes driver-ready, and is kept
// regardless of the secret-ish heuristic: a weak/empty DB password is
// exactly the kind of leak we must not miss.
source, kind := e.Name(), Kind(KindToken)
if dbSrc := dbSourceForURL(val); dbSrc != "" {
source, kind = dbSrc, KindPassword
} else if !looksSecret(k, val) {
continue
}
creds = append(creds, Credential{
Source: e.Name(),
Kind: KindToken,
Source: source,
Kind: kind,
Identity: filepath.Base(p) + " / " + k,
Location: p,
Modified: fi.ModTime(),
@@ -73,6 +81,29 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er
return creds, nil
}
// dbSourceForURL maps a value that is a database connection URL to the rotation
// driver Source that can rotate it ("postgres"/"mysql"/"redis"), or "" if the value
// is not a recognised DB URL. It requires a user:password authority so a bare
// "redis://localhost" (nothing to rotate) is not mis-tagged.
func dbSourceForURL(val string) string {
u, err := url.Parse(val)
if err != nil || u.User == nil {
return ""
}
if _, hasPw := u.User.Password(); !hasPw && u.User.Username() == "" {
return ""
}
switch strings.ToLower(u.Scheme) {
case "postgres", "postgresql":
return "postgres"
case "mysql", "mariadb":
return "mysql"
case "redis", "rediss":
return "redis"
}
return ""
}
func looksSecret(key, val string) bool {
lk := strings.ToLower(key)
for _, h := range sensitiveHints {
+120 -6
View File
@@ -2,6 +2,7 @@ package discover
import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
@@ -79,9 +80,22 @@ func TestAWSScanner(t *testing.T) {
if !strings.Contains(c.Identity, "default") {
t.Errorf("identity %q missing profile", c.Identity)
}
if got := openSecret(t, v, c); got != "wJalrSECRETkeyMaterial0123" {
t.Errorf("secret round-trip = %q", got)
// Secret is now the driver-ready blob: aws://<keyID>:<secret>@aws/?region=...
blob := openSecret(t, v, c)
u, err := url.Parse(blob)
if err != nil {
t.Fatalf("secret is not a parseable blob: %v", err)
}
if u.Scheme != "aws" || u.User.Username() != "AKIAIOSFODNN7EXAMPLE" {
t.Errorf("blob keyID wrong: %q", blob)
}
if pw, _ := u.User.Password(); pw != "wJalrSECRETkeyMaterial0123" {
t.Errorf("blob secret round-trip = %q", pw)
}
if u.Query().Get("region") == "" {
t.Errorf("blob missing region: %q", blob)
}
// Identity/Meta must stay redacted even though the blob carries the full values.
assertNoLeak(t, creds, "wJalrSECRETkeyMaterial0123", "AKIAIOSFODNN7EXAMPLE")
}
@@ -89,7 +103,10 @@ func TestEnvScanner(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeFixture(t, filepath.Join(dir, ".env"),
"PORT=8080\nDEBUG=true\nSTRIPE_API_KEY=sk_live_envSECRET0123456789\nexport DB_PASSWORD=\"pw-envSECRET-xyz\"\n")
"PORT=8080\nDEBUG=true\nSTRIPE_API_KEY=sk_live_envSECRET0123456789\nexport DB_PASSWORD=\"pw-envSECRET-xyz\"\n"+
"DATABASE_URL=postgres://labapp:pgSECRET@127.0.0.1:5432/labdb\n"+
"REDIS_URL=redis://:redisSECRET@127.0.0.1:6379\n"+
"MYSQL_URL=mysql://root:weak@127.0.0.1:3306/app\n")
v := vault.New()
defer v.Purge()
@@ -97,8 +114,8 @@ func TestEnvScanner(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Fatalf("want 2 creds (secret-ish only), got %d: %+v", len(creds), creds)
if len(creds) != 5 {
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 {
@@ -109,7 +126,19 @@ func TestEnvScanner(t *testing.T) {
t.Errorf("non-secret %q should have been filtered out", id)
}
}
assertNoLeak(t, creds, "sk_live_envSECRET0123456789", "pw-envSECRET-xyz")
// DB connection strings must be re-tagged with their rotation-driver Source so
// the postgres/mysql/redis drivers can Detect them straight from a .env scan.
bySource := map[string]string{} // source -> identity
for _, c := range creds {
bySource[c.Source] = c.Identity
}
for _, want := range []string{"postgres", "mysql", "redis"} {
if _, ok := bySource[want]; !ok {
t.Errorf("DB URL not tagged Source=%q; got sources %v", want, bySource)
}
}
assertNoLeak(t, creds, "sk_live_envSECRET0123456789", "pw-envSECRET-xyz",
"pgSECRET", "redisSECRET", "weak")
}
func TestNetrcScanner(t *testing.T) {
@@ -257,6 +286,91 @@ func TestSSHScanner(t *testing.T) {
assertNoLeak(t, creds, "sshSECRETkeyccc", "sshSECRETencddd")
}
// TestSSHScannerDynamicDiscovery covers the broadened discovery: a key with a
// non-conventional name (no id_ prefix) inside ~/.ssh, and a key in a
// non-default location referenced by an IdentityFile directive in ~/.ssh/config.
func TestSSHScannerDynamicDiscovery(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Oddly-named key in ~/.ssh — found by content, not by id_ prefix.
writeFixture(t, filepath.Join(home, ".ssh", "work-deploy"),
"-----BEGIN OPENSSH PRIVATE KEY-----\nsshSECRETwork111\n-----END OPENSSH PRIVATE KEY-----\n")
// Non-key companions that must be ignored.
writeFixture(t, filepath.Join(home, ".ssh", "known_hosts"), "host ssh-ed25519 AAAA notakey")
writeFixture(t, filepath.Join(home, ".ssh", "work-deploy.pub"), "ssh-ed25519 AAAA pub")
// Key outside ~/.ssh, referenced via IdentityFile in config.
writeFixture(t, filepath.Join(home, "keys", "prod.key"),
"-----BEGIN OPENSSH PRIVATE KEY-----\nsshSECRETprod222\n-----END OPENSSH PRIVATE KEY-----\n")
writeFixture(t, filepath.Join(home, ".ssh", "config"),
"Host prod\n HostName prod.example.com\n IdentityFile ~/keys/prod.key\n")
v := vault.New()
defer v.Purge()
creds, err := (&sshScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
idents := byIdentity(creds)
if _, ok := idents["work-deploy"]; !ok {
t.Errorf("non-id_ key not discovered by content: %v", idents)
}
if c, ok := idents["prod.key"]; !ok {
t.Errorf("IdentityFile-referenced key not discovered: %v", idents)
} else if got := openSecret(t, v, c); !strings.Contains(got, "sshSECRETprod222") {
t.Errorf("prod.key secret = %q", got)
}
if _, ok := idents["known_hosts"]; ok {
t.Error("known_hosts wrongly treated as a key")
}
if _, ok := idents["work-deploy.pub"]; ok {
t.Error(".pub wrongly treated as a key")
}
if len(creds) != 2 {
t.Fatalf("want 2 keys (work-deploy + prod.key), got %d: %v", len(creds), idents)
}
assertNoLeak(t, creds, "sshSECRETwork111", "sshSECRETprod222")
}
func TestTeaScanner(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "") // force ~/.config fallback
writeFixture(t, filepath.Join(home, ".config", "tea", "config.yml"),
"logins:\n"+
"- name: work\n"+
" url: https://gitea.example.com\n"+
" user: alice\n"+
" token: teaSECRETtok111\n"+
" default: true\n"+
"- name: notoken\n"+
" url: https://other.example.com\n")
v := vault.New()
defer v.Purge()
creds, err := (&teaScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 1 {
t.Fatalf("want 1 gitea login (token-less skipped), got %d: %+v", len(creds), creds)
}
c := creds[0]
if c.Source != "gitea" {
t.Errorf("source = %q, want gitea (so the driver Detects it)", c.Source)
}
if c.Identity != "alice @ gitea.example.com" {
t.Errorf("identity = %q", c.Identity)
}
// Secret is the driver-ready blob carrying the token.
if got := openSecret(t, v, c); !strings.Contains(got, "teaSECRETtok111") ||
!strings.HasPrefix(got, "https://alice:") {
t.Errorf("secret blob = %q", got)
}
assertNoLeak(t, creds, "teaSECRETtok111")
}
func TestFileScanner(t *testing.T) {
root := t.TempDir()
writeFixture(t, filepath.Join(root, "github-token"), "fileSECRETtok")
+118 -31
View File
@@ -1,6 +1,7 @@
package discover
import (
"bufio"
"bytes"
"context"
"os"
@@ -13,8 +14,15 @@ import (
func init() { Register(&sshScanner{}) }
// sshScanner harvests private keys from ~/.ssh (files named id_* that aren't
// .pub). The whole key file is the secret. Read-only.
// sshScanner harvests SSH private keys from their common locations:
//
// - every regular file in ~/.ssh whose CONTENT is a private key (not just the
// conventional id_* names — a key may be named anything), excluding the
// obvious non-key companions (*.pub, known_hosts, authorized_keys, config…);
// - any path referenced by an `IdentityFile` directive in ~/.ssh/config, which
// legitimately points keys at non-default locations.
//
// The whole key file is the secret. Read-only; deduplicated by absolute path.
type sshScanner struct{}
func (s *sshScanner) Name() string { return "ssh" }
@@ -29,39 +37,118 @@ func (s *sshScanner) Available() bool {
return err == nil && fi.IsDir()
}
// nonKeyNames are files that live in ~/.ssh but are never private keys; skipping
// them by name avoids reading large/irrelevant files (the content gate would also
// reject them, this is just cheaper and clearer).
var nonKeyNames = map[string]bool{
"known_hosts": true,
"known_hosts.old": true,
"authorized_keys": true,
"config": true,
"environment": true,
"rc": true,
}
func (s *sshScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
entries, err := os.ReadDir(s.dir())
if err != nil {
return nil, err
}
seen := map[string]bool{}
var creds []Credential
for _, e := range entries {
name := e.Name()
if !e.Type().IsRegular() || !strings.HasPrefix(name, "id_") || strings.HasSuffix(name, ".pub") {
continue
add := func(p string) {
abs, err := filepath.Abs(p)
if err != nil || seen[abs] {
return
}
p := filepath.Join(s.dir(), name)
b, err := os.ReadFile(p) // read-only
if err != nil {
continue
seen[abs] = true
if c, ok := s.keyCred(abs, v); ok {
creds = append(creds, c)
}
if !bytes.Contains(b, []byte("PRIVATE KEY")) {
continue // not a private key file
}
fi, _ := os.Stat(p)
// Best-effort: PEM-encrypted keys carry an explicit marker. (OpenSSH-format
// encryption isn't detectable without parsing the body, so this can
// under-report; it never claims a plaintext key is encrypted.)
encrypted := bytes.Contains(b, []byte("ENCRYPTED"))
creds = append(creds, Credential{
Source: s.Name(),
Kind: KindPrivateKey,
Identity: name,
Location: p,
Modified: fi.ModTime(),
Secret: v.Store(b),
Meta: map[string]string{"encrypted": strconv.FormatBool(encrypted)},
})
}
// 1. Every regular file directly in ~/.ssh, filtered by name then content.
if entries, err := os.ReadDir(s.dir()); err == nil {
for _, e := range entries {
name := e.Name()
if !e.Type().IsRegular() {
continue
}
if strings.HasSuffix(name, ".pub") || nonKeyNames[name] {
continue
}
add(filepath.Join(s.dir(), name))
}
}
// 2. Keys referenced from ~/.ssh/config (often outside ~/.ssh).
for _, p := range s.identityFiles() {
add(p)
}
return creds, nil
}
// keyCred reads p and, if it is a private key file, returns a Credential for it.
func (s *sshScanner) keyCred(p string, v *vault.Vault) (Credential, bool) {
b, err := os.ReadFile(p) // read-only
if err != nil {
return Credential{}, false
}
if !bytes.Contains(b, []byte("PRIVATE KEY")) {
return Credential{}, false // not a private key file
}
fi, _ := os.Stat(p)
// Best-effort: PEM-encrypted keys carry an explicit marker. (OpenSSH-format
// encryption isn't detectable without parsing the body, so this can
// under-report; it never claims a plaintext key is encrypted.)
encrypted := bytes.Contains(b, []byte("ENCRYPTED"))
c := Credential{
Source: s.Name(),
Kind: KindPrivateKey,
Identity: filepath.Base(p),
Location: p,
Secret: v.Store(b),
Meta: map[string]string{"encrypted": strconv.FormatBool(encrypted)},
}
if fi != nil {
c.Modified = fi.ModTime()
}
return c, true
}
// identityFiles parses ~/.ssh/config and returns the expanded paths of every
// IdentityFile directive. Tilde and $HOME are expanded; relative paths resolve
// against ~/.ssh (the ssh client's own behavior).
func (s *sshScanner) identityFiles() []string {
f, err := os.Open(filepath.Join(s.dir(), "config"))
if err != nil {
return nil
}
defer f.Close()
home, _ := os.UserHomeDir()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// "IdentityFile <path>" — keyword is case-insensitive, may use '=' .
fields := strings.FieldsFunc(line, func(r rune) bool {
return r == ' ' || r == '\t' || r == '='
})
if len(fields) < 2 || !strings.EqualFold(fields[0], "IdentityFile") {
continue
}
p := strings.Trim(fields[1], `"`)
switch {
case strings.HasPrefix(p, "~/"):
p = filepath.Join(home, p[2:])
case strings.HasPrefix(p, "$HOME/"):
p = filepath.Join(home, p[len("$HOME/"):])
case !filepath.IsAbs(p):
p = filepath.Join(s.dir(), p)
}
out = append(out, p)
}
return out
}
+98
View File
@@ -0,0 +1,98 @@
package discover
import (
"context"
"net/url"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"incredigo/internal/vault"
)
func init() { Register(&teaScanner{}) }
// teaScanner reads the Gitea `tea` CLI config (~/.config/tea/config.yml), which
// stores one or more logins as {name, url, user, token}. Because this file is
// unambiguously Gitea, entries are tagged Source "gitea" so the gitea rotation
// driver Detects them directly.
//
// The emitted secret is the driver-ready single-line blob
// "<scheme>://<user>:<token>@<host>/" (see internal/rotate/gitea.go). The token
// name is unknown from tea config, so it is omitted — RevokeOld then degrades to
// the guided worklist for the old-token deletion, which is the correct behavior.
// Read-only.
type teaScanner struct{}
func (t *teaScanner) Name() string { return "gitea" }
func (t *teaScanner) path() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "tea", "config.yml")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "tea", "config.yml")
}
func (t *teaScanner) Available() bool {
_, err := os.Stat(t.path())
return err == nil
}
func (t *teaScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
p := t.path()
b, err := os.ReadFile(p) // read-only
if err != nil {
return nil, err
}
fi, _ := os.Stat(p)
var cfg struct {
Logins []struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
User string `yaml:"user"`
Token string `yaml:"token"`
} `yaml:"logins"`
}
if err := yaml.Unmarshal(b, &cfg); err != nil {
return nil, err
}
var creds []Credential
for _, l := range cfg.Logins {
if l.Token == "" || l.URL == "" {
continue
}
u, err := url.Parse(l.URL)
if err != nil || u.Host == "" {
continue
}
user := l.User
if user == "" {
user = l.Name
}
scheme := u.Scheme
if scheme == "" {
scheme = "https"
}
// Driver-ready blob; the token lives only here (and the vault).
blob := (&url.URL{
Scheme: scheme,
Host: u.Host,
User: url.UserPassword(user, l.Token),
Path: "/",
}).String()
creds = append(creds, Credential{
Source: t.Name(),
Kind: KindToken,
Identity: user + " @ " + u.Host,
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(blob)),
})
}
return creds, nil
}