8ba77c1685
Three polish items surfaced during the maintainer's manual smoke
of the previous safety commit.
env-template precision (false-positive fix):
The "env file" rule matched .env.* universally, which flagged
conventional templates like .env.example / .env.sample /
.env.template / .env.dist / .env.default — these hold variable
NAMES, no values, and are commonly committed. Now skipped.
Real env files (.env, .env.local, .env.production) still match.
New envTemplateSuffixes table + isEnvTemplate helper; check runs
only inside the env-file rule so the suffix denylist is scoped.
Tests added for both directions: 6 templates that must NOT flag,
6 real env files that must.
Banner label alignment:
Field labels were padded to 8 chars except "sensitive" at 9,
producing visible misalignment in the rendered banner:
cwd : /...
provider : ollama / ...
sensitive : 0 matches in cwd <- one extra space
Padded all labels to 9 chars so the ":" separators line up.
Context banner on bypass:
--dangerously-allow-anywhere previously suppressed the entire
safety block, including the informational context banner.
Bypassing the GATE is not the same as opting out of the info —
the user still wants to see cwd / git state / sensitive files
nearby. Restructured the safety block so classification + banner
always run; the bypass only skips the refuse/warn FLOW. The
bypass warning log now also includes the classified tier and
cwd path for diagnostics.
166 lines
4.4 KiB
Go
166 lines
4.4 KiB
Go
package safety
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Match represents a sensitive file found in the cwd's top level.
|
|
type Match struct {
|
|
Path string // path relative to cwd, e.g. ".env" or ".ssh"
|
|
Reason string // short label, e.g. "env file", "private key"
|
|
}
|
|
|
|
// sensitivePatterns is the rule table. Each entry has a check that
|
|
// runs against a single dirent (with d.Name() and d.IsDir() readily
|
|
// available) plus a label for reporting.
|
|
var sensitivePatterns = []struct {
|
|
Label string
|
|
Match func(name string, isDir bool) bool
|
|
}{
|
|
{"env file", func(name string, isDir bool) bool {
|
|
if isDir {
|
|
return false
|
|
}
|
|
low := strings.ToLower(name)
|
|
// Match `.env`, `.env.foo`, `env.local`, but NOT `.envrc`
|
|
// (envrc is direnv config, not credential storage) and NOT
|
|
// conventional templates like `.env.example`, `.env.sample`,
|
|
// `.env.template`, `.env.dist`, `.env.default` (which hold
|
|
// variable LISTS, no values).
|
|
if low == ".env" {
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(low, ".env.") && !strings.HasPrefix(low, "env.local") {
|
|
return false
|
|
}
|
|
if isEnvTemplate(low) {
|
|
return false
|
|
}
|
|
return true
|
|
}},
|
|
{"private key", func(name string, isDir bool) bool {
|
|
if isDir {
|
|
return false
|
|
}
|
|
low := strings.ToLower(name)
|
|
if strings.HasSuffix(low, ".pem") || strings.HasSuffix(low, ".key") ||
|
|
strings.HasSuffix(low, ".crt") || strings.HasSuffix(low, ".p12") ||
|
|
strings.HasSuffix(low, ".pfx") {
|
|
return true
|
|
}
|
|
// SSH private-key default names.
|
|
if name == "id_rsa" || name == "id_ed25519" || name == "id_ecdsa" || name == "id_dsa" {
|
|
return true
|
|
}
|
|
return false
|
|
}},
|
|
{"credentials file", func(name string, isDir bool) bool {
|
|
if isDir {
|
|
return false
|
|
}
|
|
low := strings.ToLower(name)
|
|
// Match credential-y filenames without being too aggressive.
|
|
// "credentials" as a substring is fine (e.g. ".aws_credentials")
|
|
// but we'd rather not flag every "secret-something.go" source
|
|
// file. Restrict "secret" matches to filenames that look like
|
|
// data, not source.
|
|
if strings.Contains(low, "credentials") {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(low, ".secret") || strings.HasSuffix(low, ".secrets") {
|
|
return true
|
|
}
|
|
return false
|
|
}},
|
|
{"shell secrets", func(name string, isDir bool) bool {
|
|
if isDir {
|
|
return false
|
|
}
|
|
return name == ".netrc" || name == ".pgpass"
|
|
}},
|
|
{"password vault", func(name string, isDir bool) bool {
|
|
if isDir {
|
|
return false
|
|
}
|
|
low := strings.ToLower(name)
|
|
return strings.HasSuffix(low, ".kdbx") || strings.HasSuffix(low, ".kbdx")
|
|
}},
|
|
{"credentials directory", func(name string, isDir bool) bool {
|
|
if !isDir {
|
|
return false
|
|
}
|
|
switch name {
|
|
case ".ssh", ".aws", ".kube", ".gcloud", ".azure", ".docker":
|
|
return true
|
|
}
|
|
return false
|
|
}},
|
|
}
|
|
|
|
// envTemplateSuffixes lists conventional .env template suffixes that
|
|
// hold variable names without values — `.env.example`, `.env.sample`,
|
|
// etc. Skipped during the sensitive scan to keep the banner honest;
|
|
// real credential files (.env, .env.production, .env.local) still
|
|
// match.
|
|
var envTemplateSuffixes = []string{
|
|
".example",
|
|
".sample",
|
|
".template",
|
|
".dist",
|
|
".default",
|
|
}
|
|
|
|
func isEnvTemplate(low string) bool {
|
|
for _, suf := range envTemplateSuffixes {
|
|
if strings.HasSuffix(low, suf) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// scanLimit caps the number of dir entries inspected. Prevents a
|
|
// pathological case (cwd handed a giant temp dir, /tmp with thousands
|
|
// of files, etc.) from making the safety scan slow.
|
|
const scanLimit = 1000
|
|
|
|
// ScanCWDForSensitive walks the cwd's top level (no recursion) and
|
|
// returns sensitive matches. Conservative by design: only matches the
|
|
// rules in sensitivePatterns. Bounded to scanLimit entries to keep
|
|
// the safety check fast even in pathological directories.
|
|
//
|
|
// Results are sorted by path for deterministic ordering — both the
|
|
// banner and the tests rely on this.
|
|
func ScanCWDForSensitive(cwd string) []Match {
|
|
entries, err := os.ReadDir(cwd)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var matches []Match
|
|
for i, entry := range entries {
|
|
if i >= scanLimit {
|
|
break
|
|
}
|
|
name := entry.Name()
|
|
isDir := entry.IsDir()
|
|
for _, p := range sensitivePatterns {
|
|
if p.Match(name, isDir) {
|
|
matches = append(matches, Match{
|
|
Path: filepath.Join(cwd, name),
|
|
Reason: p.Label,
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
return matches[i].Path < matches[j].Path
|
|
})
|
|
return matches
|
|
}
|