Initial commit: Incredigo — local-first RAM-only credential custody

v1: discover → migrate → expiry tracking → sealed export/import.

- vault: memguard mlock/DONTDUMP arena, handle indirection, zeroize-on-drop
- discover: registry + 8 read-only scanners (aws, env, netrc, ssh, docker,
  kube, git) and a file/dir harvester (--path)
- sink: gopass streaming insert; length-prefixed bundle framing; Sealer
  interface with three impls — age (default, authenticated), hmac
  (authenticated, openssl-only encrypt-then-MAC), openssl (CBC fallback,
  unauthenticated; OpenSSL 3.x enc refuses AEAD)
- policy: local expiry engine, 60d/8w threshold parser
- audit: redacted append-only JSONL, injectable clock
- export/import: passphrase from no-echo prompt or $INCREDIGO_PASSPHRASE into
  locked memory; secrets stream gopass<->Sealer as bytes, never Go strings
- tests: scanner leak-checks, vault zeroize, bundle round-trip via fake gopass;
  go test ./... green, -race clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-15 07:56:31 -07:00
commit 425c299359
39 changed files with 3552 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
package discover
import (
"bufio"
"context"
"os"
"path/filepath"
"strings"
"incredigo/internal/vault"
)
func init() { Register(&awsScanner{}) }
// awsScanner reads ~/.aws/credentials (INI). Read-only: opens O_RDONLY and never
// writes back.
type awsScanner struct{}
func (a *awsScanner) Name() string { return "aws" }
func (a *awsScanner) path() string {
if p := os.Getenv("AWS_SHARED_CREDENTIALS_FILE"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".aws", "credentials")
}
func (a *awsScanner) Available() bool {
_, err := os.Stat(a.path())
return err == nil
}
func (a *awsScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
p := a.path()
f, err := os.Open(p) // read-only
if err != nil {
return nil, err
}
defer f.Close()
fi, _ := f.Stat()
var creds []Credential
var profile, keyID string
flush := func(secret string) {
if profile == "" || secret == "" {
return
}
creds = append(creds, Credential{
Source: a.Name(),
Kind: KindAWSKey,
Identity: profile + " / " + redactMiddle(keyID),
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(secret)),
Meta: map[string]string{"access_key_id": redactMiddle(keyID)},
})
keyID = ""
}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
profile = strings.TrimSpace(line[1 : len(line)-1])
keyID = ""
continue
}
k, val, ok := splitKV(line)
if !ok {
continue
}
switch strings.ToLower(k) {
case "aws_access_key_id":
keyID = val
case "aws_secret_access_key":
flush(val)
}
}
return creds, sc.Err()
}
func splitKV(line string) (k, v string, ok bool) {
i := strings.IndexByte(line, '=')
if i < 0 {
return "", "", false
}
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true
}
// redactMiddle keeps the first 4 and last 2 chars, masking the middle, so labels
// are human-recognizable without leaking the value.
func redactMiddle(s string) string {
if len(s) <= 8 {
return strings.Repeat("*", len(s))
}
return s[:4] + strings.Repeat("*", len(s)-6) + s[len(s)-2:]
}
+101
View File
@@ -0,0 +1,101 @@
// Package discover holds the read-only credential scanners and their registry.
//
// Each scanner understands exactly one location/format, never modifies the files
// it reads, and emits Credential records whose secret value is a handle into the
// RAM vault — never a plaintext copy.
package discover
import (
"context"
"sort"
"sync"
"time"
"incredigo/internal/vault"
)
// Kind classifies a credential for policy and pathing purposes.
type Kind string
const (
KindAWSKey Kind = "aws_key"
KindToken Kind = "token"
KindPassword Kind = "password"
KindPrivateKey Kind = "private_key"
)
// Credential is a normalized, non-secret-bearing record. The actual secret is
// referenced by Secret (a vault handle); nothing here should ever be a plaintext
// secret. Identity must be pre-redacted (e.g. "default / AKIA…REDACTED").
type Credential struct {
Source string // scanner name, e.g. "aws"
Kind Kind // classification
Identity string // non-secret label
Location string // file the cred came from
Modified time.Time // source mtime — input to expiry policy
Secret *vault.Handle // opaque handle into the RAM vault
Meta map[string]string // non-secret extras
}
// Scanner is the single interface a source must implement. Add a source by
// dropping one new file in this package that Registers a Scanner in init().
type Scanner interface {
Name() string
// Available reports whether this source exists on the current machine, so we
// can skip cleanly rather than erroring on absent files.
Available() bool
// Scan reads the source read-only and stores any secrets in v, returning
// metadata records that reference them.
Scan(ctx context.Context, v *vault.Vault) ([]Credential, error)
}
var (
regMu sync.Mutex
registry = map[string]Scanner{}
)
// Register adds a scanner to the global registry. Call from a scanner's init().
func Register(s Scanner) {
regMu.Lock()
defer regMu.Unlock()
registry[s.Name()] = s
}
// Scanners returns all registered scanners sorted by name. If names is non-empty,
// only those are returned (unknown names are silently ignored by the caller's
// filtering; use Lookup if you need to detect unknown names).
func Scanners(names ...string) []Scanner {
regMu.Lock()
defer regMu.Unlock()
var out []Scanner
if len(names) == 0 {
for _, s := range registry {
out = append(out, s)
}
} else {
for _, n := range names {
if s, ok := registry[n]; ok {
out = append(out, s)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
// ScanAll runs the selected (or all) available scanners and aggregates results.
func ScanAll(ctx context.Context, v *vault.Vault, names ...string) ([]Credential, error) {
var all []Credential
for _, s := range Scanners(names...) {
if !s.Available() {
continue
}
creds, err := s.Scan(ctx, v)
if err != nil {
return all, err
}
all = append(all, creds...)
}
return all, nil
}
+78
View File
@@ -0,0 +1,78 @@
package discover
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"incredigo/internal/vault"
)
func init() { Register(&dockerScanner{}) }
// dockerScanner reads ~/.docker/config.json. Each auths entry holds a base64
// user:pass in "auth" (or an "identitytoken"). Read-only.
type dockerScanner struct{}
func (d *dockerScanner) Name() string { return "docker" }
func (d *dockerScanner) path() string {
if p := os.Getenv("DOCKER_CONFIG"); p != "" {
return filepath.Join(p, "config.json")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".docker", "config.json")
}
func (d *dockerScanner) Available() bool {
_, err := os.Stat(d.path())
return err == nil
}
func (d *dockerScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
p := d.path()
b, err := os.ReadFile(p) // read-only
if err != nil {
return nil, err
}
fi, _ := os.Stat(p)
var cfg struct {
Auths map[string]struct {
Auth string `json:"auth"`
IdentityToken string `json:"identitytoken"`
} `json:"auths"`
}
if err := json.Unmarshal(b, &cfg); err != nil {
return nil, err
}
registries := make([]string, 0, len(cfg.Auths))
for r := range cfg.Auths {
registries = append(registries, r)
}
sort.Strings(registries) // deterministic order
var creds []Credential
for _, registry := range registries {
a := cfg.Auths[registry]
secret, kind := a.Auth, KindPassword
if secret == "" && a.IdentityToken != "" {
secret, kind = a.IdentityToken, KindToken
}
if secret == "" {
continue
}
creds = append(creds, Credential{
Source: d.Name(),
Kind: kind,
Identity: registry,
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(secret)),
})
}
return creds, nil
}
+105
View File
@@ -0,0 +1,105 @@
package discover
import (
"bufio"
"context"
"math"
"os"
"path/filepath"
"strings"
"incredigo/internal/vault"
)
func init() { Register(&envScanner{}) }
// envScanner reads .env files in the current working directory. It keeps only
// values that look secret-ish: the key name matches a sensitive pattern OR the
// value has high Shannon entropy. This avoids hoovering up PORT=8080 etc.
type envScanner struct{}
func (e *envScanner) Name() string { return "env" }
func (e *envScanner) files() []string {
matches, _ := filepath.Glob(".env")
more, _ := filepath.Glob(".env.*")
return append(matches, more...)
}
func (e *envScanner) Available() bool { return len(e.files()) > 0 }
var sensitiveHints = []string{
"secret", "token", "passwd", "password", "apikey", "api_key",
"private", "key", "credential", "auth", "access",
}
func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
var creds []Credential
for _, p := range e.files() {
f, err := os.Open(p) // read-only
if err != nil {
return creds, err
}
fi, _ := f.Stat()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
k, val, ok := splitKV(line)
if !ok || val == "" {
continue
}
val = strings.Trim(val, `"'`)
if !looksSecret(k, val) {
continue
}
creds = append(creds, Credential{
Source: e.Name(),
Kind: KindToken,
Identity: filepath.Base(p) + " / " + k,
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(val)),
})
}
f.Close()
if err := sc.Err(); err != nil {
return creds, err
}
}
return creds, nil
}
func looksSecret(key, val string) bool {
lk := strings.ToLower(key)
for _, h := range sensitiveHints {
if strings.Contains(lk, h) {
return true
}
}
// Fallback: long, high-entropy values are probably secrets.
return len(val) >= 16 && shannonEntropy(val) >= 3.5
}
func shannonEntropy(s string) float64 {
if s == "" {
return 0
}
var freq [256]float64
for i := 0; i < len(s); i++ {
freq[s[i]]++
}
n := float64(len(s))
var h float64
for _, c := range freq {
if c == 0 {
continue
}
p := c / n
h -= p * math.Log2(p)
}
return h
}
+120
View File
@@ -0,0 +1,120 @@
package discover
import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"incredigo/internal/vault"
)
// maxHarvestFileSize bounds how large a file the "file" scanner will slurp as a
// single credential. Credential material (keys, tokens, service-account JSON) is
// small; this keeps an accidental directory of large blobs from being vaulted.
const maxHarvestFileSize = 1 << 20 // 1 MiB
var fileSingleton = &fileScanner{}
func init() { Register(fileSingleton) }
// AddPath registers an extra file or directory for the "file" scanner to harvest.
// Call before ScanAll — the CLI does this for each --path. A directory is walked
// recursively; a single file is harvested directly.
func AddPath(p string) {
if p != "" {
fileSingleton.targets = append(fileSingleton.targets, p)
}
}
// fileScanner harvests whole files as credentials from user-supplied paths. Unlike
// the format-specific scanners (aws, env, …), it does no field parsing: each
// regular, non-empty, reasonably-sized file becomes ONE Credential whose secret is
// the file's entire contents — the natural fit for "point me at a directory of
// credential files and take them all." Strictly read-only.
type fileScanner struct{ targets []string }
func (f *fileScanner) Name() string { return "file" }
func (f *fileScanner) Available() bool { return len(f.targets) > 0 }
func (f *fileScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
var creds []Credential
for _, t := range f.targets {
info, err := os.Stat(t)
if err != nil {
return creds, fmt.Errorf("file scanner: %q: %w", t, err)
}
if !info.IsDir() {
if c, ok := f.harvest(v, t, filepath.Dir(t)); ok {
creds = append(creds, c)
}
continue
}
// Directory: walk recursively, harvesting each regular file. Per-entry
// errors (unreadable, vanished) are skipped so one bad file doesn't abort
// the whole harvest.
walkErr := filepath.WalkDir(t, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
if c, ok := f.harvest(v, p, t); ok {
creds = append(creds, c)
}
return nil
})
if walkErr != nil {
return creds, walkErr
}
}
return creds, nil
}
// harvest reads one file and turns it into a Credential. root is the target the
// file was found under, used to build a stable relative Identity. Returns ok=false
// for empty, oversized, or unreadable files (silently skipped).
func (f *fileScanner) harvest(v *vault.Vault, path, root string) (Credential, bool) {
info, err := os.Stat(path)
if err != nil || info.Size() == 0 || info.Size() > maxHarvestFileSize {
return Credential{}, false
}
b, err := os.ReadFile(path) // read-only
if err != nil {
return Credential{}, false
}
kind := classifyContent(b) // inspect bytes BEFORE Store wipes them
rel, e := filepath.Rel(root, path)
if e != nil {
rel = filepath.Base(path)
}
// 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>.
return Credential{
Source: f.Name(),
Kind: kind,
Identity: rel,
Location: path,
Modified: info.ModTime(),
Secret: v.Store(b), // copies into locked mem, wipes b
}, true
}
// classifyContent guesses a Kind from the file's leading bytes WITHOUT turning the
// secret into a Go string (it inspects the byte slice in place).
func classifyContent(b []byte) Kind {
head := b
if len(head) > 256 {
head = head[:256]
}
if bytes.Contains(head, []byte("PRIVATE KEY-----")) {
return KindPrivateKey
}
return KindToken
}
+71
View File
@@ -0,0 +1,71 @@
package discover
import (
"bufio"
"context"
"net/url"
"os"
"path/filepath"
"strings"
"incredigo/internal/vault"
)
func init() { Register(&gitScanner{}) }
// gitScanner reads ~/.git-credentials, whose lines are URLs with embedded
// credentials, e.g. https://user:ghp_xxx@github.com. The userinfo password is the
// secret. Read-only.
type gitScanner struct{}
func (g *gitScanner) Name() string { return "git" }
func (g *gitScanner) path() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".git-credentials")
}
func (g *gitScanner) Available() bool {
_, err := os.Stat(g.path())
return err == nil
}
func (g *gitScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
p := g.path()
f, err := os.Open(p) // read-only
if err != nil {
return nil, err
}
defer f.Close()
fi, _ := f.Stat()
var creds []Credential
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
u, err := url.Parse(line)
if err != nil || u.User == nil {
continue
}
pass, ok := u.User.Password()
if !ok || pass == "" {
continue
}
id := u.Host
if user := u.User.Username(); user != "" {
id = user + " @ " + u.Host
}
creds = append(creds, Credential{
Source: g.Name(),
Kind: KindToken,
Identity: id,
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(pass)),
})
}
return creds, sc.Err()
}
+84
View File
@@ -0,0 +1,84 @@
package discover
import (
"context"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"incredigo/internal/vault"
)
func init() { Register(&kubeScanner{}) }
// kubeScanner reads ~/.kube/config (YAML) and emits a credential per user that
// carries a static secret: a bearer token, a client key, or a basic-auth
// password. Dynamic exec/auth-provider users (no embedded secret) are skipped.
// Read-only.
type kubeScanner struct{}
func (k *kubeScanner) Name() string { return "kube" }
func (k *kubeScanner) path() string {
if p := os.Getenv("KUBECONFIG"); p != "" {
// KUBECONFIG may be a colon-separated list; use the first entry.
return strings.SplitN(p, ":", 2)[0]
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".kube", "config")
}
func (k *kubeScanner) Available() bool {
_, err := os.Stat(k.path())
return err == nil
}
func (k *kubeScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
p := k.path()
b, err := os.ReadFile(p) // read-only
if err != nil {
return nil, err
}
fi, _ := os.Stat(p)
var cfg struct {
Users []struct {
Name string `yaml:"name"`
User struct {
Token string `yaml:"token"`
Password string `yaml:"password"`
ClientKeyData string `yaml:"client-key-data"`
} `yaml:"user"`
} `yaml:"users"`
}
if err := yaml.Unmarshal(b, &cfg); err != nil {
return nil, err
}
var creds []Credential
for _, u := range cfg.Users {
var secret string
kind := KindToken
switch {
case u.User.Token != "":
secret, kind = u.User.Token, KindToken
case u.User.ClientKeyData != "":
secret, kind = u.User.ClientKeyData, KindPrivateKey
case u.User.Password != "":
secret, kind = u.User.Password, KindPassword
default:
continue // dynamic credential (exec/auth-provider) — nothing static to take
}
creds = append(creds, Credential{
Source: k.Name(),
Kind: kind,
Identity: u.Name,
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(secret)),
})
}
return creds, nil
}
+109
View File
@@ -0,0 +1,109 @@
package discover
import (
"bufio"
"context"
"io"
"os"
"path/filepath"
"incredigo/internal/vault"
)
func init() { Register(&netrcScanner{}) }
// netrcScanner reads ~/.netrc and ~/.authinfo (whitespace-delimited
// machine/login/password tokens). Read-only.
type netrcScanner struct{}
func (n *netrcScanner) Name() string { return "netrc" }
func (n *netrcScanner) paths() []string {
home, _ := os.UserHomeDir()
return []string{
filepath.Join(home, ".netrc"),
filepath.Join(home, ".authinfo"),
}
}
func (n *netrcScanner) Available() bool {
for _, p := range n.paths() {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
func (n *netrcScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
var creds []Credential
for _, p := range n.paths() {
f, err := os.Open(p) // read-only
if err != nil {
continue // a missing alternate file is fine
}
fi, _ := f.Stat()
toks := tokenizeWords(f)
f.Close()
var machine, login, password string
flush := func() {
if machine != "" && password != "" {
id := machine
if login != "" {
id = login + " @ " + machine
}
creds = append(creds, Credential{
Source: n.Name(),
Kind: KindPassword,
Identity: id,
Location: p,
Modified: fi.ModTime(),
Secret: v.Store([]byte(password)),
})
}
machine, login, password = "", "", ""
}
for i := 0; i < len(toks); i++ {
switch toks[i] {
case "machine":
flush()
if i+1 < len(toks) {
machine = toks[i+1]
i++
}
case "default":
flush()
machine = "default"
case "login":
if i+1 < len(toks) {
login = toks[i+1]
i++
}
case "password":
if i+1 < len(toks) {
password = toks[i+1]
i++
}
case "account", "macdef":
if i+1 < len(toks) { // skip the value token
i++
}
}
}
flush()
}
return creds, nil
}
// tokenizeWords splits an io.Reader into whitespace-delimited tokens.
func tokenizeWords(r io.Reader) []string {
sc := bufio.NewScanner(r)
sc.Split(bufio.ScanWords)
var t []string
for sc.Scan() {
t = append(t, sc.Text())
}
return t
}
+287
View File
@@ -0,0 +1,287 @@
package discover
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"incredigo/internal/vault"
)
func writeFixture(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
// assertNoLeak fails if any secret substring appears in a non-secret field
// (Identity, Location, Source, Kind, or any Meta key/value).
func assertNoLeak(t *testing.T, creds []Credential, secrets ...string) {
t.Helper()
for _, c := range creds {
fields := []string{c.Identity, c.Source, c.Location, string(c.Kind)}
for k, val := range c.Meta {
fields = append(fields, k, val)
}
for _, f := range fields {
for _, s := range secrets {
if s != "" && strings.Contains(f, s) {
t.Errorf("secret %q leaked into non-secret field %q", s, f)
}
}
}
}
}
func openSecret(t *testing.T, v *vault.Vault, c Credential) string {
t.Helper()
buf, err := v.Open(c.Secret)
if err != nil {
t.Fatalf("open vaulted secret: %v", err)
}
return string(buf.Bytes())
}
// byIdentity indexes creds by Identity for assertions.
func byIdentity(creds []Credential) map[string]Credential {
m := make(map[string]Credential, len(creds))
for _, c := range creds {
m[c.Identity] = c
}
return m
}
func TestAWSScanner(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "credentials")
writeFixture(t, p, "[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\naws_secret_access_key = wJalrSECRETkeyMaterial0123\n")
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", p)
v := vault.New()
defer v.Purge()
creds, err := (&awsScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 1 {
t.Fatalf("want 1 cred, got %d", len(creds))
}
c := creds[0]
if c.Kind != KindAWSKey {
t.Errorf("kind = %s, want aws_key", c.Kind)
}
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)
}
assertNoLeak(t, creds, "wJalrSECRETkeyMaterial0123", "AKIAIOSFODNN7EXAMPLE")
}
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")
v := vault.New()
defer v.Purge()
creds, err := (&envScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Fatalf("want 2 creds (secret-ish only), got %d: %+v", len(creds), creds)
}
idents := byIdentity(creds)
if _, ok := idents[".env / STRIPE_API_KEY"]; !ok {
t.Errorf("missing STRIPE_API_KEY; got %v", idents)
}
for id := range idents {
if strings.Contains(id, "PORT") || strings.Contains(id, "DEBUG") {
t.Errorf("non-secret %q should have been filtered out", id)
}
}
assertNoLeak(t, creds, "sk_live_envSECRET0123456789", "pw-envSECRET-xyz")
}
func TestNetrcScanner(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
writeFixture(t, filepath.Join(home, ".netrc"),
"machine api.example.com login alice password netrcSECRETaaa\ndefault login bob password netrcDEFAULTbbb\n")
v := vault.New()
defer v.Purge()
creds, err := (&netrcScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Fatalf("want 2 creds, got %d", len(creds))
}
idents := byIdentity(creds)
if c, ok := idents["alice @ api.example.com"]; !ok {
t.Errorf("missing machine entry; got %v", idents)
} else if got := openSecret(t, v, c); got != "netrcSECRETaaa" {
t.Errorf("secret = %q", got)
}
if _, ok := idents["bob @ default"]; !ok {
t.Errorf("missing default entry; got %v", idents)
}
assertNoLeak(t, creds, "netrcSECRETaaa", "netrcDEFAULTbbb")
}
func TestDockerScanner(t *testing.T) {
dir := t.TempDir()
t.Setenv("DOCKER_CONFIG", dir)
writeFixture(t, filepath.Join(dir, "config.json"),
`{"auths":{"registry.example.com":{"auth":"ZG9ja2VyU0VDUkVUZWVl"},"ghcr.io":{"identitytoken":"dockerTOKENfff"}}}`)
v := vault.New()
defer v.Purge()
creds, err := (&dockerScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Fatalf("want 2 creds, got %d", len(creds))
}
idents := byIdentity(creds)
if c, ok := idents["registry.example.com"]; !ok || c.Kind != KindPassword {
t.Errorf("registry entry wrong: %+v", c)
}
if c, ok := idents["ghcr.io"]; !ok || c.Kind != KindToken {
t.Errorf("ghcr entry wrong: %+v", c)
}
assertNoLeak(t, creds, "ZG9ja2VyU0VDUkVUZWVl", "dockerTOKENfff")
}
func TestKubeScanner(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config")
t.Setenv("KUBECONFIG", p)
writeFixture(t, p, `apiVersion: v1
users:
- name: admin
user:
token: kubeSECRETggg
- name: certuser
user:
client-key-data: a3ViZUtFWWhoaA==
- name: execuser
user:
exec:
command: aws
`)
v := vault.New()
defer v.Purge()
creds, err := (&kubeScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Fatalf("want 2 creds (exec user skipped), got %d", len(creds))
}
idents := byIdentity(creds)
if c, ok := idents["admin"]; !ok || c.Kind != KindToken {
t.Errorf("admin entry wrong: %+v", c)
}
if c, ok := idents["certuser"]; !ok || c.Kind != KindPrivateKey {
t.Errorf("certuser entry wrong: %+v", c)
}
if _, ok := idents["execuser"]; ok {
t.Error("execuser (dynamic) should be skipped")
}
assertNoLeak(t, creds, "kubeSECRETggg", "a3ViZUtFWWhoaA==")
}
func TestGitScanner(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
writeFixture(t, filepath.Join(home, ".git-credentials"),
"https://alice:gitSECRETiii@github.com\nhttps://gitlab.com\n")
v := vault.New()
defer v.Purge()
creds, err := (&gitScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 1 {
t.Fatalf("want 1 cred (no-cred line skipped), got %d", len(creds))
}
c := creds[0]
if c.Identity != "alice @ github.com" {
t.Errorf("identity = %q", c.Identity)
}
if got := openSecret(t, v, c); got != "gitSECRETiii" {
t.Errorf("secret = %q", got)
}
assertNoLeak(t, creds, "gitSECRETiii")
}
func TestSSHScanner(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
writeFixture(t, filepath.Join(home, ".ssh", "id_ed25519"),
"-----BEGIN OPENSSH PRIVATE KEY-----\nsshSECRETkeyccc\n-----END OPENSSH PRIVATE KEY-----\n")
writeFixture(t, filepath.Join(home, ".ssh", "id_ed25519.pub"), "ssh-ed25519 AAAA pub")
writeFixture(t, filepath.Join(home, ".ssh", "id_rsa_enc"),
"-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nsshSECRETencddd\n-----END RSA PRIVATE KEY-----\n")
v := vault.New()
defer v.Purge()
creds, err := (&sshScanner{}).Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Fatalf("want 2 private keys (.pub ignored), got %d: %+v", len(creds), creds)
}
idents := byIdentity(creds)
if c, ok := idents["id_rsa_enc"]; !ok || c.Meta["encrypted"] != "true" {
t.Errorf("encrypted key not flagged: %+v", c)
}
if c, ok := idents["id_ed25519"]; !ok || c.Meta["encrypted"] != "false" {
t.Errorf("plaintext key wrongly flagged: %+v", c)
}
assertNoLeak(t, creds, "sshSECRETkeyccc", "sshSECRETencddd")
}
func TestFileScanner(t *testing.T) {
root := t.TempDir()
writeFixture(t, filepath.Join(root, "github-token"), "fileSECRETtok")
writeFixture(t, filepath.Join(root, "keys", "deploy.pem"),
"-----BEGIN OPENSSH PRIVATE KEY-----\nfileKEYmaterial\n-----END OPENSSH PRIVATE KEY-----\n")
writeFixture(t, filepath.Join(root, "empty"), "")
v := vault.New()
defer v.Purge()
fs := &fileScanner{targets: []string{root}}
creds, err := fs.Scan(context.Background(), v)
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Fatalf("want 2 creds (empty skipped), got %d: %+v", len(creds), creds)
}
idents := byIdentity(creds)
if c, ok := idents["github-token"]; !ok {
t.Errorf("missing github-token; got %v", idents)
} else if got := openSecret(t, v, c); got != "fileSECRETtok" {
t.Errorf("secret = %q", got)
}
if c, ok := idents[filepath.Join("keys", "deploy.pem")]; !ok || c.Kind != KindPrivateKey {
t.Errorf("pem not classified as private_key: %+v", c)
}
assertNoLeak(t, creds, "fileSECRETtok", "fileKEYmaterial")
}
+67
View File
@@ -0,0 +1,67 @@
package discover
import (
"bytes"
"context"
"os"
"path/filepath"
"strconv"
"strings"
"incredigo/internal/vault"
)
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.
type sshScanner struct{}
func (s *sshScanner) Name() string { return "ssh" }
func (s *sshScanner) dir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".ssh")
}
func (s *sshScanner) Available() bool {
fi, err := os.Stat(s.dir())
return err == nil && fi.IsDir()
}
func (s *sshScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, error) {
entries, err := os.ReadDir(s.dir())
if err != nil {
return nil, err
}
var creds []Credential
for _, e := range entries {
name := e.Name()
if !e.Type().IsRegular() || !strings.HasPrefix(name, "id_") || strings.HasSuffix(name, ".pub") {
continue
}
p := filepath.Join(s.dir(), name)
b, err := os.ReadFile(p) // read-only
if err != nil {
continue
}
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)},
})
}
return creds, nil
}