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