From 12192a84233cd4b83b7233f96ec0ea3a2bcb0d86 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Thu, 18 Jun 2026 14:48:42 -0700 Subject: [PATCH] blast+cli: read-only blast-radius map + wire rotate --execute / --blast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/incredigo/main.go | 169 +++++++++++++++++++-- internal/blast/blast.go | 233 +++++++++++++++++++++++++++++ internal/blast/blast_test.go | 127 ++++++++++++++++ internal/worklist/worklist_test.go | 17 ++- 4 files changed, 528 insertions(+), 18 deletions(-) create mode 100644 internal/blast/blast.go create mode 100644 internal/blast/blast_test.go diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go index cf8e727..af9bd17 100644 --- a/cmd/incredigo/main.go +++ b/cmd/incredigo/main.go @@ -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: +// "//" -> "". +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/.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 diff --git a/internal/blast/blast.go b/internal/blast/blast.go new file mode 100644 index 0000000..b136c25 --- /dev/null +++ b/internal/blast/blast.go @@ -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 +} diff --git a/internal/blast/blast_test.go b/internal/blast/blast_test.go new file mode 100644 index 0000000..02f968f --- /dev/null +++ b/internal/blast/blast_test.go @@ -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 +} diff --git a/internal/worklist/worklist_test.go b/internal/worklist/worklist_test.go index 921500f..f847903 100644 --- a/internal/worklist/worklist_test.go +++ b/internal/worklist/worklist_test.go @@ -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)