blast+cli: read-only blast-radius map + wire rotate --execute / --blast
internal/blast builds a read-only consumer map (which other files appear to use each credential) from NON-secret metadata only — env-var names, hostnames, identifiers — never the vault secret; rotation needs it to know what to redeploy after a change. cmd/incredigo wires Mode A `rotate --execute` (reconstructs Source from the gopass path segment, runs the execute spine) and surfaces the blast-radius map. Adjusts a worklist test accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+157
-12
@@ -19,6 +19,7 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
"incredigo/internal/audit"
|
||||
"incredigo/internal/blast"
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/policy"
|
||||
"incredigo/internal/rotate"
|
||||
@@ -79,6 +80,42 @@ func applyPaths() {
|
||||
flagSources = append(flagSources, "file")
|
||||
}
|
||||
|
||||
// gopassCredsForExecute sources the credentials to rotate from gopass (Mode A).
|
||||
// It lists the entries under prefix, loads each secret into the vault, and rebuilds
|
||||
// a Credential whose Source is the path's first segment after the prefix
|
||||
// (e.g. imported/postgres/labapp -> Source "postgres") so the right driver Detects
|
||||
// it, and whose Location is the exact gopass path the new secret must overwrite.
|
||||
func gopassCredsForExecute(ctx context.Context, gp *sink.Gopass, v *vault.Vault, prefix string) ([]discover.Credential, error) {
|
||||
paths, err := gp.ListPaths(ctx, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var creds []discover.Credential
|
||||
for _, p := range paths {
|
||||
h, err := gp.Show(ctx, v, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creds = append(creds, discover.Credential{
|
||||
Source: sourceFromStorePath(p, prefix),
|
||||
Identity: p,
|
||||
Location: p,
|
||||
Secret: h,
|
||||
})
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// sourceFromStorePath recovers the scanner source from a gopass entry path:
|
||||
// "<prefix>/<source>/<slug...>" -> "<source>".
|
||||
func sourceFromStorePath(p, prefix string) string {
|
||||
rest := strings.TrimPrefix(p, strings.TrimSuffix(prefix, "/")+"/")
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
return rest[:i]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// withVault sets up the RAM vault and guarantees it is purged on exit.
|
||||
func withVault(fn func(ctx context.Context, v *vault.Vault) error) error {
|
||||
v := vault.New()
|
||||
@@ -333,16 +370,29 @@ func importCmd() *cobra.Command {
|
||||
// are zero registered drivers, and --execute is refused.
|
||||
func rotateCmd() *cobra.Command {
|
||||
var prefix, backupOut string
|
||||
var execute bool
|
||||
var execute, dryRun, blastScan bool
|
||||
var blastRoots []string
|
||||
c := &cobra.Command{
|
||||
Use: "rotate",
|
||||
Short: "Plan rotation + take a verified backup (DESIGN PHASE — rotates nothing)",
|
||||
Long: "Rotation is not implemented yet (see docs/ROTATION.md). This command runs the\n" +
|
||||
"MANDATORY backup gate (sealed, verified snapshot of the gopass prefix) and prints\n" +
|
||||
"the rotation plan. It never changes a credential.",
|
||||
Short: "Plan/execute rotation behind a MANDATORY verified backup gate",
|
||||
Long: "Runs the MANDATORY backup gate (sealed, verified snapshot of the gopass prefix)\n" +
|
||||
"first, then EITHER plans (default) or executes rotation.\n\n" +
|
||||
"Default: prints the rotation plan and changes nothing. With --dry-run it walks the\n" +
|
||||
"full spine (rotate→verify→revoke) using the noop driver, touching no service.\n\n" +
|
||||
"--execute performs REAL, destructive rotation of the gopass entries under --prefix\n" +
|
||||
"(rotate→verify→store→re-read→revoke). It is refused unless INCREDIGO_ALLOW_EXECUTE=1\n" +
|
||||
"is set and at least one driver is registered. See docs/ROTATION.md §16.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if execute {
|
||||
return fmt.Errorf("rotate --execute refused: no rotation drivers are implemented (design phase) — see docs/ROTATION.md")
|
||||
// Real, destructive rotation. Gated by an explicit env authorization
|
||||
// on top of the flag (ROTATION.md §16), and refused if no driver can
|
||||
// actually perform a rotation.
|
||||
if os.Getenv("INCREDIGO_ALLOW_EXECUTE") != "1" {
|
||||
return fmt.Errorf("rotate --execute refused: set INCREDIGO_ALLOW_EXECUTE=1 to authorize real, destructive rotation (see docs/ROTATION.md §16)")
|
||||
}
|
||||
if len(rotate.Drivers()) == 0 {
|
||||
return fmt.Errorf("rotate --execute refused: no rotation drivers registered")
|
||||
}
|
||||
}
|
||||
applyPaths()
|
||||
gp := &sink.Gopass{Prefix: prefix}
|
||||
@@ -378,26 +428,118 @@ func rotateCmd() *cobra.Command {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "✓ backup gate: %d entr(ies) sealed + verified -> %s\n\n", n, backupOut)
|
||||
|
||||
log, _ := audit.Open(flagAuditLog, time.Now)
|
||||
defer log.Close()
|
||||
|
||||
if execute {
|
||||
// Mode A — rotate what's already in gopass. Source the stored
|
||||
// entries under the prefix (the backup gate just proved coverage
|
||||
// of exactly these), then run the real rotate→verify→store→
|
||||
// re-read→revoke spine. Backup has already succeeded above.
|
||||
ecreds, err := gopassCredsForExecute(ctx, gp, v, prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results := rotate.Execute(ctx, gp, ecreds, v)
|
||||
ew := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(ew, "DRIVER\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR")
|
||||
var rotated, failed int
|
||||
for _, r := range results {
|
||||
errStr := ""
|
||||
if r.Err != nil {
|
||||
errStr = r.Err.Error()
|
||||
failed++
|
||||
} else {
|
||||
rotated++
|
||||
}
|
||||
fmt.Fprintf(ew, "%s\t%s\t%t\t%t\t%t\t%t\t%s\n",
|
||||
r.Driver, r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr)
|
||||
out := "ok"
|
||||
if r.Err != nil {
|
||||
out = r.Err.Error()
|
||||
}
|
||||
log.Write(audit.Entry{Action: "rotate-execute", Source: r.Credential.Source,
|
||||
Identity: r.StorePath, Location: backupOut, Outcome: out})
|
||||
}
|
||||
ew.Flush()
|
||||
fmt.Fprintf(os.Stderr, "\nROTATED %d credential(s), %d failed. Backup: %s\n", rotated, failed, backupOut)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("rotation completed with %d failure(s) — old credential(s) left intact; restore from backup if needed", failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-destructive plan over discoverable credentials.
|
||||
creds, err := discover.ScanAll(ctx, v, flagSources...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Optional read-only blast-radius map: which files consume each
|
||||
// credential (searched by non-secret markers only — never the secret).
|
||||
var radius []blast.CredConsumers
|
||||
if blastScan {
|
||||
radius, err = blast.Map(creds, blast.Options{Roots: blastRoots})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY")
|
||||
for _, p := range rotate.PlanAll(creds) {
|
||||
if blastScan {
|
||||
fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY\tCONSUMERS")
|
||||
} else {
|
||||
fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY")
|
||||
}
|
||||
for i, p := range rotate.PlanAll(creds) {
|
||||
drv := p.Driver
|
||||
if drv == "" {
|
||||
drv = "(none)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", drv, p.Credential.Source, p.Credential.Identity)
|
||||
if blastScan {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%d file(s)\n",
|
||||
drv, p.Credential.Source, p.Credential.Identity, len(radius[i].Files()))
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", drv, p.Credential.Source, p.Credential.Identity)
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
log, _ := audit.Open(flagAuditLog, time.Now)
|
||||
defer log.Close()
|
||||
if blastScan {
|
||||
fmt.Fprintln(os.Stderr, "\nblast radius (read-only; matched by non-secret markers):")
|
||||
for _, cc := range radius {
|
||||
files := cc.Files()
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " %s -> %s\n", cc.Credential.Identity, strings.Join(files, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
log.Write(audit.Entry{Action: "rotate-plan", Outcome: "ok", Location: backupOut})
|
||||
fmt.Fprintf(os.Stderr, "\nDESIGN PHASE: %d rotation driver(s) registered — nothing rotated. See docs/ROTATION.md\n",
|
||||
|
||||
if dryRun {
|
||||
// Exercise the FULL rotation spine with the noop driver: it mints
|
||||
// an in-vault secret, verifies it, "revokes", and persists nothing.
|
||||
// No real credential at any service is touched.
|
||||
fmt.Fprintln(os.Stderr, "\n--dry-run: walking rotate→verify→revoke with the noop driver (touches no service)")
|
||||
results := rotate.DryRun(ctx, &rotate.NoopRotator{}, creds, v)
|
||||
dw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(dw, "DRIVER\tIDENTITY\tROTATED\tVERIFIED\tREVOKED\tERROR")
|
||||
for _, r := range results {
|
||||
errStr := ""
|
||||
if r.Err != nil {
|
||||
errStr = r.Err.Error()
|
||||
}
|
||||
fmt.Fprintf(dw, "%s\t%s\t%t\t%t\t%t\t%s\n",
|
||||
r.Driver, r.Credential.Identity, r.Rotated, r.Verified, r.Revoked, errStr)
|
||||
}
|
||||
dw.Flush()
|
||||
log.Write(audit.Entry{Action: "rotate-dryrun", Outcome: "ok", Location: backupOut})
|
||||
fmt.Fprintf(os.Stderr, "\nDRY RUN complete: %d credential(s) walked, nothing persisted, no service touched.\n", len(results))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nPLAN ONLY: %d rotation driver(s) registered — nothing rotated. Add --execute (with INCREDIGO_ALLOW_EXECUTE=1) to rotate. See docs/ROTATION.md\n",
|
||||
len(rotate.Drivers()))
|
||||
return nil
|
||||
})
|
||||
@@ -406,6 +548,9 @@ func rotateCmd() *cobra.Command {
|
||||
c.Flags().StringVar(&prefix, "prefix", "imported/", "gopass prefix to back up and plan")
|
||||
c.Flags().StringVar(&backupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/rotation-backups/<ts>.age)")
|
||||
c.Flags().BoolVar(&execute, "execute", false, "(reserved) perform rotation — refused in design phase")
|
||||
c.Flags().BoolVar(&dryRun, "dry-run", false, "walk the full rotation spine with the noop driver (touches no service, persists nothing)")
|
||||
c.Flags().BoolVar(&blastScan, "blast", false, "map each credential's blast radius — which files consume it (read-only, non-secret markers)")
|
||||
c.Flags().StringArrayVar(&blastRoots, "blast-root", nil, "directories to search for consumers (default: current directory)")
|
||||
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include in the plan")
|
||||
c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners")
|
||||
return c
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package blast builds a read-only "blast-radius" map: for each discovered
|
||||
// credential, which OTHER files on disk appear to consume it. Rotation needs
|
||||
// this so a later driver knows what to redeploy after a secret changes.
|
||||
//
|
||||
// SECURITY: markers are derived ONLY from a credential's non-secret metadata
|
||||
// (env-var names, hostnames, identifiers in Identity/Meta) — NEVER from the
|
||||
// vault secret value. We grep consumer files for those markers; the secret
|
||||
// plaintext never leaves the vault and is never searched for. The scan is
|
||||
// strictly read-only.
|
||||
package blast
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
)
|
||||
|
||||
// Hit is one place a credential marker was found.
|
||||
type Hit struct {
|
||||
File string
|
||||
Line int
|
||||
Marker string
|
||||
}
|
||||
|
||||
// CredConsumers is the blast radius of a single credential.
|
||||
type CredConsumers struct {
|
||||
Credential discover.Credential
|
||||
Markers []string // the non-secret tokens we searched for
|
||||
Hits []Hit // consumer references found (excludes the cred's own source file)
|
||||
}
|
||||
|
||||
// Files returns the distinct consumer files for this credential.
|
||||
func (cc CredConsumers) Files() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, h := range cc.Hits {
|
||||
if !seen[h.File] {
|
||||
seen[h.File] = true
|
||||
out = append(out, h.File)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Options tunes the (read-only) filesystem walk.
|
||||
type Options struct {
|
||||
Roots []string // directories to search; defaults to "." when empty
|
||||
MaxFileSize int64 // skip files larger than this; defaults to 1 MiB
|
||||
}
|
||||
|
||||
// skipDirs are noise directories we never descend into.
|
||||
var skipDirs = map[string]bool{
|
||||
".git": true, "node_modules": true, "vendor": true,
|
||||
".incredigo": true, ".terraform": true, "dist": true, "build": true,
|
||||
}
|
||||
|
||||
// Map computes the blast radius for each credential. Credentials with no usable
|
||||
// non-secret marker yield an empty Hits slice (e.g. a fully-redacted AWS key id).
|
||||
func Map(creds []discover.Credential, opts Options) ([]CredConsumers, error) {
|
||||
if len(opts.Roots) == 0 {
|
||||
opts.Roots = []string{"."}
|
||||
}
|
||||
if opts.MaxFileSize == 0 {
|
||||
opts.MaxFileSize = 1 << 20
|
||||
}
|
||||
|
||||
out := make([]CredConsumers, len(creds))
|
||||
markersByCred := make([][]string, len(creds))
|
||||
sourceFiles := map[string]bool{} // a cred's own location is never its own consumer
|
||||
for i, c := range creds {
|
||||
out[i] = CredConsumers{Credential: c}
|
||||
markersByCred[i] = Markers(c)
|
||||
out[i].Markers = markersByCred[i]
|
||||
if c.Location != "" {
|
||||
if abs, err := filepath.Abs(c.Location); err == nil {
|
||||
sourceFiles[abs] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, root := range opts.Roots {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // unreadable entry: skip, don't abort the whole walk
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil || info.Size() == 0 || info.Size() > opts.MaxFileSize {
|
||||
return nil
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
if sourceFiles[abs] {
|
||||
return nil // don't report a credential's own file as a consumer
|
||||
}
|
||||
scanFile(path, creds, markersByCred, out)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanFile reads one file read-only and records marker hits into out.
|
||||
func scanFile(path string, creds []discover.Credential, markersByCred [][]string, out []CredConsumers) {
|
||||
f, err := os.Open(path) // read-only
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
r := bufio.NewReader(f)
|
||||
// Binary sniff: if the first chunk holds a NUL byte, treat as binary and skip.
|
||||
head, _ := r.Peek(512)
|
||||
if bytes.IndexByte(head, 0) >= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
line := sc.Text()
|
||||
for i := range creds {
|
||||
for _, m := range markersByCred[i] {
|
||||
if containsToken(line, m) {
|
||||
out[i].Hits = append(out[i].Hits, Hit{File: path, Line: lineNo, Marker: m})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsToken reports whether marker appears in line bounded by non-identifier
|
||||
// characters (so API_TOKEN matches ${API_TOKEN} but not MY_API_TOKEN_EXTRA).
|
||||
func containsToken(line, marker string) bool {
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(line[from:], marker)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
i += from
|
||||
leftOK := i == 0 || !isIdentByte(line[i-1])
|
||||
end := i + len(marker)
|
||||
rightOK := end == len(line) || !isIdentByte(line[end])
|
||||
if leftOK && rightOK {
|
||||
return true
|
||||
}
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isIdentByte(b byte) bool {
|
||||
return b == '_' || b == '.' || // '.' so hostnames stay whole
|
||||
(b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
// Markers derives the non-secret search tokens for a credential from its
|
||||
// Identity and Meta. It NEVER touches the secret value. Redacted fields (those
|
||||
// containing '*') are skipped wholesale so masked key-ids don't leak partial
|
||||
// tokens. A token qualifies if it is env-var-like (has '_', len>=4), an
|
||||
// all-caps identifier (len>=6), or a hostname (contains '.').
|
||||
func Markers(c discover.Credential) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(tok string) {
|
||||
if tok != "" && !seen[tok] {
|
||||
seen[tok] = true
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
|
||||
consider := func(field string) {
|
||||
if strings.ContainsRune(field, '*') {
|
||||
return // redacted — skip the whole field
|
||||
}
|
||||
for _, tok := range tokenize(field) {
|
||||
if qualifies(tok) {
|
||||
add(tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
consider(c.Identity)
|
||||
for _, v := range c.Meta {
|
||||
consider(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tokenize splits on runs of non-identifier characters, keeping '.' inside a
|
||||
// token so hostnames survive.
|
||||
func tokenize(s string) []string {
|
||||
return strings.FieldsFunc(s, func(r rune) bool {
|
||||
return !(r == '_' || r == '.' ||
|
||||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'))
|
||||
})
|
||||
}
|
||||
|
||||
func qualifies(tok string) bool {
|
||||
if !strings.ContainsFunc(tok, func(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}) {
|
||||
return false // need at least one letter
|
||||
}
|
||||
if strings.Contains(tok, "_") && len(tok) >= 4 {
|
||||
return true // env-var style: API_TOKEN, AWS_ACCESS_KEY_ID
|
||||
}
|
||||
if strings.Contains(tok, ".") && len(tok) >= 4 &&
|
||||
!strings.HasPrefix(tok, ".") && !strings.HasSuffix(tok, ".") {
|
||||
return true // hostname: github.com (but not a dotfile like .env)
|
||||
}
|
||||
if len(tok) >= 6 && tok == strings.ToUpper(tok) {
|
||||
return true // all-caps identifier: SENDGRID
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package blast
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
func TestMarkers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cred discover.Credential
|
||||
want []string
|
||||
}{
|
||||
{"env var name", discover.Credential{Identity: ".env / API_TOKEN"}, []string{"API_TOKEN"}},
|
||||
{"hostname", discover.Credential{Identity: "github.com / alice"}, []string{"github.com"}},
|
||||
{"redacted aws key yields nothing", discover.Credential{
|
||||
Identity: "default / AKIA****Xy",
|
||||
Meta: map[string]string{"access_key_id": "AKIA****Xy"},
|
||||
}, nil},
|
||||
{"all-caps service", discover.Credential{Identity: "SENDGRID / mail"}, []string{"SENDGRID"}},
|
||||
{"generic profile dropped", discover.Credential{Identity: "default / prod"}, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := Markers(c.cred)
|
||||
if !eq(got, c.want) {
|
||||
t.Fatalf("Markers(%q) = %v, want %v", c.cred.Identity, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapFindsConsumersNotSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// The credential's own source file (should NOT be reported as a consumer).
|
||||
src := filepath.Join(dir, ".env")
|
||||
secret := "s3cr3t-VALUE-do-not-find-me"
|
||||
write(t, src, "API_TOKEN="+secret+"\n")
|
||||
// A genuine consumer referencing the var name.
|
||||
consumer := filepath.Join(dir, "docker-compose.yml")
|
||||
write(t, consumer, "services:\n app:\n environment:\n - API_TOKEN=${API_TOKEN}\n")
|
||||
// A file that contains ONLY the secret value, never the marker — must be ignored.
|
||||
leak := filepath.Join(dir, "random.txt")
|
||||
write(t, leak, secret+"\n")
|
||||
// Noise dir that must be skipped.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(dir, "node_modules", "x.js"), "API_TOKEN here\n")
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
creds := []discover.Credential{{
|
||||
Source: "env",
|
||||
Identity: ".env / API_TOKEN",
|
||||
Location: src,
|
||||
Secret: v.Store([]byte(secret)),
|
||||
}}
|
||||
|
||||
res, err := Map(creds, Options{Roots: []string{dir}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("got %d results, want 1", len(res))
|
||||
}
|
||||
|
||||
files := res[0].Files()
|
||||
if len(files) != 1 || filepath.Base(files[0]) != "docker-compose.yml" {
|
||||
t.Fatalf("consumers = %v, want only docker-compose.yml", files)
|
||||
}
|
||||
|
||||
// Leak-check: the secret value must never appear as a marker or a hit.
|
||||
for _, m := range res[0].Markers {
|
||||
if m == secret {
|
||||
t.Fatal("secret value leaked into markers")
|
||||
}
|
||||
}
|
||||
for _, h := range res[0].Hits {
|
||||
if filepath.Base(h.File) == "random.txt" {
|
||||
t.Fatal("matched a file by secret value — must only match non-secret markers")
|
||||
}
|
||||
if h.Marker == secret {
|
||||
t.Fatal("secret value used as a marker")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
line, marker string
|
||||
want bool
|
||||
}{
|
||||
{"- API_TOKEN=${API_TOKEN}", "API_TOKEN", true},
|
||||
{"MY_API_TOKEN_EXTRA=1", "API_TOKEN", false}, // bounded, not a substring hit
|
||||
{"host: github.com", "github.com", true},
|
||||
{"github.community", "github.com", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := containsToken(c.line, c.marker); got != c.want {
|
||||
t.Errorf("containsToken(%q,%q)=%t want %t", c.line, c.marker, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func eq(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -17,11 +17,15 @@ func TestBuildAndMarkdown(t *testing.T) {
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("got %d items, want 3", len(items))
|
||||
}
|
||||
// No drivers are registered yet, so everything is manual.
|
||||
for _, it := range items {
|
||||
if !it.Manual() {
|
||||
t.Errorf("item %q unexpectedly has driver %q", it.Credential.Identity, it.Driver)
|
||||
}
|
||||
// git and docker have no driver (manual); ssh now has a registered driver.
|
||||
if !items[0].Manual() {
|
||||
t.Errorf("git item should be manual, got driver %q", items[0].Driver)
|
||||
}
|
||||
if items[1].Manual() || items[1].Driver != "ssh" {
|
||||
t.Errorf("ssh item should be auto via driver %q, got %q", "ssh", items[1].Driver)
|
||||
}
|
||||
if !items[2].Manual() {
|
||||
t.Errorf("docker item should be manual, got driver %q", items[2].Driver)
|
||||
}
|
||||
// github link is curated; ssh has no web page.
|
||||
if items[0].Link.URL != "https://github.com/settings/tokens" {
|
||||
@@ -37,7 +41,8 @@ func TestBuildAndMarkdown(t *testing.T) {
|
||||
"3 credential(s)",
|
||||
"https://github.com/settings/tokens",
|
||||
"alice @ github.com",
|
||||
"— (no web page)", // ssh row
|
||||
"auto: ssh", // ssh now driver-handled
|
||||
"— (no web page)", // ssh row has no link
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("markdown missing %q\n---\n%s", want, md)
|
||||
|
||||
Reference in New Issue
Block a user