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