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:
leetcrypt
2026-06-18 14:48:42 -07:00
parent 09cd7208d6
commit 12192a8423
4 changed files with 528 additions and 18 deletions
+233
View File
@@ -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
}
+127
View File
@@ -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
}
+11 -6
View File
@@ -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)