From 1c0896347bf213ccab8a8e6c7f99c5332268fe64 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Thu, 18 Jun 2026 14:48:30 -0700 Subject: [PATCH] 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 --- internal/discover/aws.go | 65 ++++++++++++- internal/discover/env.go | 37 ++++++- internal/discover/scanners_test.go | 126 ++++++++++++++++++++++-- internal/discover/ssh.go | 149 +++++++++++++++++++++++------ internal/discover/tea.go | 98 +++++++++++++++++++ 5 files changed, 433 insertions(+), 42 deletions(-) create mode 100644 internal/discover/tea.go diff --git a/internal/discover/aws.go b/internal/discover/aws.go index 00dcebf..129cb36 100644 --- a/internal/discover/aws.go +++ b/internal/discover/aws.go @@ -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://:@aws/?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 { diff --git a/internal/discover/env.go b/internal/discover/env.go index 0948b69..57bc4bc 100644 --- a/internal/discover/env.go +++ b/internal/discover/env.go @@ -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 { diff --git a/internal/discover/scanners_test.go b/internal/discover/scanners_test.go index 80e94e4..b8fcfa7 100644 --- a/internal/discover/scanners_test.go +++ b/internal/discover/scanners_test.go @@ -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://:@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") diff --git a/internal/discover/ssh.go b/internal/discover/ssh.go index c883072..1aec8c4 100644 --- a/internal/discover/ssh.go +++ b/internal/discover/ssh.go @@ -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 " — 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 +} diff --git a/internal/discover/tea.go b/internal/discover/tea.go new file mode 100644 index 0000000..60eb970 --- /dev/null +++ b/internal/discover/tea.go @@ -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 +// "://:@/" (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 +}