Files
vikingowl 8ba77c1685 fix(safety): env-template precision, label alignment, banner on bypass
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.
2026-05-23 22:32:26 +02:00

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
}