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