f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
748 lines
27 KiB
Go
748 lines
27 KiB
Go
package secrets
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"math"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// rule is one secret detector. group selects a capture group as the
|
||
// reported span (0 = whole match); validate optionally gates a match
|
||
// (nil = always keep); mask renders the persisted/displayed redaction.
|
||
type rule struct {
|
||
name string
|
||
confidence string
|
||
prefilters []string // literal anchors; empty => always scan
|
||
re *regexp.Regexp
|
||
group int
|
||
validate func(string) bool
|
||
mask func(string) string
|
||
}
|
||
|
||
var rules = []rule{
|
||
{
|
||
name: "aws-access-key",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"AKIA", "ASIA"},
|
||
re: regexp.MustCompile(`\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`),
|
||
validate: notAWSPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 4, 4) },
|
||
},
|
||
{
|
||
name: "anthropic-key",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"sk-ant-"},
|
||
re: regexp.MustCompile(`\bsk-ant-[0-9A-Za-z][0-9A-Za-z_\-]{18,}`),
|
||
validate: notAnthropicKeyPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 7, 4) },
|
||
},
|
||
{
|
||
// openai-key: matches OpenAI's project (sk-proj-), service-account
|
||
// (sk-svcacct-), and admin (sk-admin-) key prefixes. These prefix
|
||
// patterns are scanner knowledge derived from observed token format;
|
||
// no clean upstream announcement URL is cited because none was found.
|
||
// Legacy sk- + 48-char keys are deliberately not matched (no anchor
|
||
// that doesn't collide with sk-ant-).
|
||
name: "openai-key",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"sk-proj-", "sk-svcacct-", "sk-admin-"},
|
||
re: regexp.MustCompile(
|
||
`\b(sk-(?:proj|svcacct|admin)-[0-9A-Za-z_\-]{20,})` +
|
||
`(?:[^0-9A-Za-z_\-]|$)`),
|
||
group: 1,
|
||
validate: notOpenAIKeyPlaceholder,
|
||
mask: maskOpenAIKey,
|
||
},
|
||
{
|
||
// gitlab-pat: GitLab personal/project access tokens. Prefix per
|
||
// https://docs.gitlab.com/security/tokens/token_prefixes/ — glpat-
|
||
// is documented as the PAT prefix; minimum body length is 20.
|
||
name: "gitlab-pat",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"glpat-"},
|
||
re: regexp.MustCompile(
|
||
`\b(glpat-[0-9A-Za-z_\-]{20,})(?:[^0-9A-Za-z_\-]|$)`),
|
||
group: 1,
|
||
validate: notGitLabPATPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 6, 4) },
|
||
},
|
||
{
|
||
// npm-token: npm access tokens since the 2021 format change. Per
|
||
// https://github.blog/2021-09-23-announcing-npms-new-access-token-format/
|
||
// the format is npm_ + 36 base62 chars (30 base62 payload + 6 base62
|
||
// CRC). Legacy classic tokens (bare 36-char hex, no prefix) are
|
||
// deliberately not matched — they would need a separate rule with no
|
||
// anchor and a high false-positive rate.
|
||
name: "npm-token",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"npm_"},
|
||
re: regexp.MustCompile(`\bnpm_[0-9A-Za-z]{36}\b`),
|
||
validate: notNPMTokenPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 4, 4) },
|
||
},
|
||
{
|
||
// pypi-token: PyPI API tokens. Per https://docs.pypi.org/api/secrets/
|
||
// the documented format is pypi-[A-Za-z0-9_-]{85,}. The literal
|
||
// "AgEIcHlwaS5vcmcC" is the base64 of the macaroon protocol-version
|
||
// and URL bytes that every PyPI token starts with — a much stronger
|
||
// anchor than just pypi-. The 16-char anchor counts toward the
|
||
// 85-char minimum, so the variable tail must be ≥69 chars.
|
||
name: "pypi-token",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"pypi-"},
|
||
re: regexp.MustCompile(
|
||
`\b(pypi-AgEIcHlwaS5vcmcC[0-9A-Za-z_\-]{69,})` +
|
||
`(?:[^0-9A-Za-z_\-]|$)`),
|
||
group: 1,
|
||
validate: notPyPITokenPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 21, 4) },
|
||
},
|
||
{
|
||
// huggingface-token: Hugging Face access tokens. Prefix hf_ +
|
||
// base62 body, ≥30 chars empirically (token lengths vary by
|
||
// generation, but observed minimum is well above 30).
|
||
name: "huggingface-token",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"hf_"},
|
||
re: regexp.MustCompile(`\bhf_[0-9A-Za-z]{30,}\b`),
|
||
validate: notHuggingFaceTokenPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 3, 4) },
|
||
},
|
||
{
|
||
// sendgrid-key: SendGrid API keys. Format SG.<22 base64url>.<43
|
||
// base64url> — two fixed-length segments separated by a literal '.'.
|
||
name: "sendgrid-key",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"SG."},
|
||
re: regexp.MustCompile(
|
||
`\b(SG\.[0-9A-Za-z_\-]{22}\.[0-9A-Za-z_\-]{43})` +
|
||
`(?:[^0-9A-Za-z_\-]|$)`),
|
||
group: 1,
|
||
validate: notSendGridKeyPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 3, 4) },
|
||
},
|
||
{
|
||
name: "basic-auth-url",
|
||
confidence: ConfidenceCandidate,
|
||
prefilters: []string{"://"},
|
||
re: regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s:/@]+:([^\s:/@]+)@[^\s/]+`),
|
||
group: 1, // mask the password, not the whole URL
|
||
mask: func(s string) string { return maskKeepEnds(s, 0, 0) },
|
||
},
|
||
{
|
||
name: "github-pat",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"ghp_", "github_pat_"},
|
||
re: regexp.MustCompile(`\b(?:ghp_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{40,})\b`),
|
||
validate: notGitHubPATPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 4, 4) },
|
||
},
|
||
{
|
||
name: "slack-token",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-"},
|
||
re: regexp.MustCompile(`\bxox[baprs]-[0-9A-Za-z]{10,}(?:-[0-9A-Za-z]+)*`),
|
||
validate: notSlackTokenPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 5, 4) },
|
||
},
|
||
{
|
||
name: "stripe-secret",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"sk_live_", "rk_live_"},
|
||
re: regexp.MustCompile(`\b[sr]k_live_[0-9A-Za-z]{16,}\b`),
|
||
validate: notStripeSecretPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 8, 4) },
|
||
},
|
||
{
|
||
name: "google-api-key",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"AIza"},
|
||
// Capture the key in group 1 and require a non-body terminator (or
|
||
// end of text) rather than a trailing \b, so keys ending in '-'
|
||
// still match instead of being silently skipped.
|
||
re: regexp.MustCompile(`\b(AIza[0-9A-Za-z_\-]{35})(?:[^0-9A-Za-z_\-]|$)`),
|
||
group: 1,
|
||
validate: notGoogleAPIKeyPlaceholder,
|
||
mask: func(s string) string { return maskKeepEnds(s, 4, 4) },
|
||
},
|
||
{
|
||
name: "private-key-block",
|
||
confidence: ConfidenceDefinite,
|
||
prefilters: []string{"-----BEGIN"},
|
||
re: regexp.MustCompile(
|
||
`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----`),
|
||
validate: notTrivialPEMBody,
|
||
mask: func(string) string { return "[redacted private key block]" },
|
||
},
|
||
{
|
||
name: "jwt",
|
||
confidence: ConfidenceCandidate,
|
||
prefilters: []string{"eyJ"},
|
||
re: regexp.MustCompile(
|
||
`\beyJ[0-9A-Za-z_\-]+\.[0-9A-Za-z_\-]+\.[0-9A-Za-z_\-]+`),
|
||
mask: func(s string) string { return maskKeepEnds(s, 3, 0) },
|
||
},
|
||
{
|
||
// Known false positives: filesystem paths and URLs can clear the
|
||
// entropy gate because the value charset includes '/'. Accepted for a
|
||
// candidate rule; do not lower the threshold to chase these.
|
||
name: "high-entropy-assignment",
|
||
confidence: ConfidenceCandidate,
|
||
prefilters: []string{"=", ":"},
|
||
re: regexp.MustCompile(
|
||
`(?i)\b[a-z][a-z0-9_]{2,}\s*[=:]\s*['"]?([A-Za-z0-9+/_\-]{20,}={0,2})['"]?`),
|
||
group: 1,
|
||
validate: highEntropyValue,
|
||
mask: func(s string) string { return maskKeepEnds(s, 0, 4) },
|
||
},
|
||
}
|
||
|
||
// definiteRules is the well-anchored vendor-format subset of rules, computed
|
||
// once at load. ScanDefinite uses it for the fast inline-sync path.
|
||
var definiteRules = filterByConfidence(rules, ConfidenceDefinite)
|
||
|
||
func filterByConfidence(rs []rule, confidence string) []rule {
|
||
out := make([]rule, 0, len(rs))
|
||
for _, r := range rs {
|
||
if r.confidence == confidence {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// shannonEntropy returns the per-byte Shannon entropy (bits) of s.
|
||
func shannonEntropy(s string) float64 {
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
var freq [256]float64
|
||
for i := 0; i < len(s); i++ {
|
||
freq[s[i]]++
|
||
}
|
||
n := float64(len(s))
|
||
var h float64
|
||
for _, c := range freq {
|
||
if c == 0 {
|
||
continue
|
||
}
|
||
p := c / n
|
||
h -= p * math.Log2(p)
|
||
}
|
||
return h
|
||
}
|
||
|
||
// highEntropyValue gates the high-entropy-assignment rule: the value must
|
||
// be long enough and random-looking to plausibly be a secret. Trailing '='
|
||
// padding is trimmed before the length and entropy checks so padding bytes
|
||
// cannot inflate the length gate or dilute entropy.
|
||
func highEntropyValue(s string) bool {
|
||
body := strings.TrimRight(s, "=")
|
||
return len(body) >= 20 && shannonEntropy(body) >= 3.5
|
||
}
|
||
|
||
// hasRepeatingBlock reports whether s is dominated by a short repeating
|
||
// seed pattern. This catches the dominant placeholder shapes that leak
|
||
// from agent transcripts: a single byte repeating ("aaaa…"), a short
|
||
// block repeating ("A1b2A1b2…", "aB3_xaB3_x…"). Real high-entropy
|
||
// secrets don't have this structure.
|
||
//
|
||
// Block size 1 is checked by single-byte frequency: any byte that
|
||
// covers ≥75% of s is a hit. Block sizes 2..6 try the leading block at
|
||
// each phase alignment; placeholders are typically unsalted repeats of
|
||
// the seed so phase 0 catches the well-known shapes. The 75% coverage
|
||
// threshold tolerates a stray salt character (a single byte slipped in
|
||
// or trimmed off) without losing detection.
|
||
func hasRepeatingBlock(s string) bool {
|
||
n := len(s)
|
||
if n < 8 {
|
||
return false
|
||
}
|
||
var freq [256]int
|
||
for i := range n {
|
||
freq[s[i]]++
|
||
}
|
||
for _, f := range freq {
|
||
if f*4 >= n*3 {
|
||
return true
|
||
}
|
||
}
|
||
for blockSize := 2; blockSize <= 6 && blockSize*3 <= n; blockSize++ {
|
||
for phase := 0; phase < blockSize && phase+blockSize <= n; phase++ {
|
||
block := s[phase : phase+blockSize]
|
||
coverage := 0
|
||
for i := phase; i+blockSize <= n; i += blockSize {
|
||
if s[i:i+blockSize] == block {
|
||
coverage += blockSize
|
||
}
|
||
}
|
||
if coverage*4 >= n*3 {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// hasMonotoneRun reports whether s contains a run of >= minRun bytes
|
||
// that step monotonically by +1 or -1 in ASCII codepoint. Placeholders
|
||
// are typically built from alphabet/digit runs ("abcdef", "1234567890",
|
||
// "ZYXWVU", "fedcba"); real secrets are random and don't produce these.
|
||
func hasMonotoneRun(s string, minRun int) bool {
|
||
if len(s) < minRun {
|
||
return false
|
||
}
|
||
run := 1
|
||
var dir int
|
||
for i := 1; i < len(s); i++ {
|
||
d := int(s[i]) - int(s[i-1])
|
||
if d != 1 && d != -1 {
|
||
run = 1
|
||
dir = 0
|
||
continue
|
||
}
|
||
if dir == 0 || dir == d {
|
||
dir = d
|
||
run++
|
||
if run >= minRun {
|
||
return true
|
||
}
|
||
} else {
|
||
run = 2
|
||
dir = d
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// bodyLooksRandom reports whether s plausibly contains real key
|
||
// material. It runs the cheap structural checks (Shannon entropy,
|
||
// repeating short blocks, sequential alphabet/digit runs) and rejects
|
||
// strings that fail any one. Each rule's validator strips the
|
||
// well-known vendor prefix (e.g. "ghp_") before calling so the fixed
|
||
// prefix doesn't drag entropy down or hide a body-level pattern.
|
||
//
|
||
// Callers with short fixed-length bodies (AWS access keys at 16 chars)
|
||
// should not use this function: Shannon entropy ≥3.5 is too tight a
|
||
// gate at 16 chars and would reject real random bodies that land
|
||
// ≤10 distinct chars by birthday luck. Those validators call
|
||
// bodyHasNoPlaceholderShape directly so only the structural checks
|
||
// (which scale to any length) apply.
|
||
func bodyLooksRandom(s string) bool {
|
||
if shannonEntropy(s) < 3.5 {
|
||
return false
|
||
}
|
||
return bodyHasNoPlaceholderShape(s)
|
||
}
|
||
|
||
// bodyHasNoPlaceholderShape runs the structural checks
|
||
// (hasRepeatingBlock, hasMonotoneRun) without the Shannon entropy
|
||
// gate. AWS validators use it because their body is exactly 16 chars
|
||
// and the entropy threshold would over-reject; every other definite
|
||
// rule has a body ≥19 chars where bodyLooksRandom's entropy check is
|
||
// still a useful filter against placeholders that pass the structural
|
||
// checks.
|
||
func bodyHasNoPlaceholderShape(s string) bool {
|
||
if hasRepeatingBlock(s) {
|
||
return false
|
||
}
|
||
if hasMonotoneRun(s, 6) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// notAWSPlaceholder rejects AWS access key IDs that look like
|
||
// placeholders. It first dismisses the canonical AWS-docs body
|
||
// ("IOSFODNN7EXAMPLE" and EXAMPL[0-9] variants) by substring, then runs
|
||
// the structural checks on the 16-char body that follows AKIA/ASIA.
|
||
// The 6–8 char EXAMPL/IOSFODNN markers have a vanishing chance of
|
||
// occurring in a real 16-char body (~3.5e-13 for IOSFODNN in random
|
||
// uppercase), so the substring gate is safe for a definite rule.
|
||
//
|
||
// Shannon entropy is deliberately not applied: a random 16-char
|
||
// base32 body can have ≤10 distinct chars by birthday luck and fall
|
||
// below 3.5 bits.
|
||
func notAWSPlaceholder(s string) bool {
|
||
if strings.Contains(s, "EXAMPL") || strings.Contains(s, "IOSFODNN") {
|
||
return false
|
||
}
|
||
body := strings.TrimPrefix(s, "AKIA")
|
||
body = strings.TrimPrefix(body, "ASIA")
|
||
return bodyHasNoPlaceholderShape(body)
|
||
}
|
||
|
||
// notAnthropicKeyPlaceholder rejects Anthropic API keys whose body
|
||
// (after "sk-ant-") fails structural checks: a 4-rune trailing repeat
|
||
// (sk-ant-...AAAA), a single byte dominating the body, a repeating
|
||
// short block, or a long sequential ASCII run.
|
||
func notAnthropicKeyPlaceholder(s string) bool {
|
||
if !notTrailingRunRepeat(s, 4) {
|
||
return false
|
||
}
|
||
return bodyLooksRandom(strings.TrimPrefix(s, "sk-ant-"))
|
||
}
|
||
|
||
// notOpenAIKeyPlaceholder rejects OpenAI keys whose body (after the
|
||
// matched sk-proj- / sk-svcacct- / sk-admin- prefix) fails the structural
|
||
// checks or shows a trailing-run repeat.
|
||
func notOpenAIKeyPlaceholder(s string) bool {
|
||
if !notTrailingRunRepeat(s, 4) {
|
||
return false
|
||
}
|
||
body := s
|
||
for _, p := range []string{"sk-svcacct-", "sk-admin-", "sk-proj-"} {
|
||
if after, ok := strings.CutPrefix(body, p); ok {
|
||
body = after
|
||
break
|
||
}
|
||
}
|
||
return bodyLooksRandom(body)
|
||
}
|
||
|
||
// maskOpenAIKey preserves the matched OpenAI prefix length when redacting.
|
||
// sk-proj- (8), sk-admin- (9), and sk-svcacct- (11) differ in length, so a
|
||
// fixed offset would truncate the longer prefixes mid-word in the display.
|
||
// Longest-first ordering is defensive in case a future prefix shares a
|
||
// shorter prefix with a current one (no such case exists today).
|
||
func maskOpenAIKey(s string) string {
|
||
for _, p := range []string{"sk-svcacct-", "sk-admin-", "sk-proj-"} {
|
||
if strings.HasPrefix(s, p) {
|
||
return maskKeepEnds(s, len(p), 4)
|
||
}
|
||
}
|
||
panic("openai-key: regex matched without known prefix; rule and " +
|
||
"maskOpenAIKey are out of sync")
|
||
}
|
||
|
||
// notGitLabPATPlaceholder rejects GitLab PATs whose body (after "glpat-")
|
||
// fails structural checks or shows a trailing-run repeat. Catches the
|
||
// placeholder shapes agents emit when illustrating PAT format:
|
||
// "glpat-AAAA…", "glpat-Xa9Kd…AAAA".
|
||
func notGitLabPATPlaceholder(s string) bool {
|
||
if !notTrailingRunRepeat(s, 4) {
|
||
return false
|
||
}
|
||
return bodyLooksRandom(strings.TrimPrefix(s, "glpat-"))
|
||
}
|
||
|
||
// notNPMTokenPlaceholder rejects npm tokens whose body fails structural
|
||
// checks or shows a trailing-run repeat. The fixed 36-char body uses
|
||
// bodyHasNoPlaceholderShape (no entropy gate) like AWS's 16-char body —
|
||
// a long enough random base62 body can legitimately have ≤10 distinct
|
||
// chars by birthday luck and fall below 3.5 bits.
|
||
func notNPMTokenPlaceholder(s string) bool {
|
||
if !notTrailingRunRepeat(s, 4) {
|
||
return false
|
||
}
|
||
return bodyHasNoPlaceholderShape(strings.TrimPrefix(s, "npm_"))
|
||
}
|
||
|
||
// notPyPITokenPlaceholder rejects PyPI tokens whose macaroon payload
|
||
// (after the full pypi-AgEIcHlwaS5vcmcC anchor) fails structural checks
|
||
// or shows a trailing-run repeat. Catches placeholder shapes agents emit:
|
||
// "pypi-AgEI…AAAA", "pypi-…AAAA".
|
||
func notPyPITokenPlaceholder(s string) bool {
|
||
if !notTrailingRunRepeat(s, 4) {
|
||
return false
|
||
}
|
||
return bodyLooksRandom(strings.TrimPrefix(s, "pypi-AgEIcHlwaS5vcmcC"))
|
||
}
|
||
|
||
// notHuggingFaceTokenPlaceholder rejects HF tokens whose body fails
|
||
// structural checks or shows a trailing-run repeat. Catches placeholder
|
||
// shapes agents emit: "hf_AAAA…", "hf_Xa9Kd…AAAA".
|
||
func notHuggingFaceTokenPlaceholder(s string) bool {
|
||
if !notTrailingRunRepeat(s, 4) {
|
||
return false
|
||
}
|
||
return bodyLooksRandom(strings.TrimPrefix(s, "hf_"))
|
||
}
|
||
|
||
// notSendGridKeyPlaceholder rejects SendGrid keys whose segments fail
|
||
// structural checks. The 22-char identifier uses bodyHasNoPlaceholderShape
|
||
// (short body, like AWS); the 43-char secret uses notTrailingRunRepeat +
|
||
// bodyLooksRandom to catch the same trailing-run placeholder shape that
|
||
// affects the other variable-length-body definite-tier validators.
|
||
func notSendGridKeyPlaceholder(s string) bool {
|
||
body := strings.TrimPrefix(s, "SG.")
|
||
ident, secret, ok := strings.Cut(body, ".")
|
||
if !ok {
|
||
return false
|
||
}
|
||
if !bodyHasNoPlaceholderShape(ident) {
|
||
return false
|
||
}
|
||
if !notTrailingRunRepeat(secret, 4) {
|
||
return false
|
||
}
|
||
return bodyLooksRandom(secret)
|
||
}
|
||
|
||
// notGitHubPATPlaceholder rejects GitHub PATs whose body (after "ghp_"
|
||
// or "github_pat_") fails structural checks. Catches the noisy shapes
|
||
// agents emit when illustrating PAT format: "ghp_aaaa…",
|
||
// "github_pat_A1b2A1b2…".
|
||
func notGitHubPATPlaceholder(s string) bool {
|
||
body := strings.TrimPrefix(s, "ghp_")
|
||
body = strings.TrimPrefix(body, "github_pat_")
|
||
return bodyLooksRandom(body)
|
||
}
|
||
|
||
// notStripeSecretPlaceholder rejects Stripe live secrets whose body
|
||
// fails structural checks. Strips the "sk_live_" / "rk_live_" prefix
|
||
// first so it doesn't anchor a coverage check on the fixed prefix.
|
||
func notStripeSecretPlaceholder(s string) bool {
|
||
body := strings.TrimPrefix(s, "sk_live_")
|
||
body = strings.TrimPrefix(body, "rk_live_")
|
||
return bodyLooksRandom(body)
|
||
}
|
||
|
||
// notGoogleAPIKeyPlaceholder rejects Google API keys whose body (after
|
||
// "AIza") fails structural checks. Catches the "AIza" + "aB3_xaB3_x…"
|
||
// pattern agents emit when explaining the key format.
|
||
func notGoogleAPIKeyPlaceholder(s string) bool {
|
||
return bodyLooksRandom(strings.TrimPrefix(s, "AIza"))
|
||
}
|
||
|
||
// notSlackTokenPlaceholder rejects Slack tokens that look like the
|
||
// well-known placeholder forms. It dismisses the canonical "0123"
|
||
// suffix (the Slack docs convention), then runs the structural checks
|
||
// on the part after the "xox?-" prefix so the workspace ID and token
|
||
// body are both subject to the repeating-block and sequential-run
|
||
// detectors.
|
||
func notSlackTokenPlaceholder(s string) bool {
|
||
if strings.HasSuffix(s, "0123") {
|
||
return false
|
||
}
|
||
if !notTrailingRunRepeat(s, 4) {
|
||
return false
|
||
}
|
||
body := s
|
||
if len(body) >= 5 && strings.HasPrefix(body, "xox") {
|
||
body = body[4:] // strip "xox?-"
|
||
}
|
||
return bodyLooksRandom(body)
|
||
}
|
||
|
||
// notTrailingRunRepeat rejects strings whose final n characters are a
|
||
// single repeated byte (...AAAA, ...0000). A 4-byte tail repeat has
|
||
// roughly (1/62)^3 ≈ 4e-6 odds of occurring naturally in a random
|
||
// base62 secret — acceptable false-negative loss for a rule that
|
||
// otherwise produces noise on every transcript quoting a doc
|
||
// placeholder.
|
||
func notTrailingRunRepeat(s string, n int) bool {
|
||
if len(s) < n {
|
||
return true
|
||
}
|
||
last := s[len(s)-1]
|
||
for i := len(s) - 2; i >= len(s)-n; i-- {
|
||
if s[i] != last {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// notTrivialPEMBody rejects PEM blocks whose payload is too short or
|
||
// too far from base64 to plausibly hold a real private key. The
|
||
// shortest real key material is PKCS#8 Ed25519, whose body is a
|
||
// single 64-character base64 line; the gate requires 48 non-whitespace
|
||
// payload bytes (leaving margin) AND ≥99% of them to be valid base64
|
||
// alphabet. The byte-length gate catches the "BEGIN ... key bytes
|
||
// here ... END" examples agents emit; the base64 purity gate catches
|
||
// docs/diffs that quote the BEGIN/END markers around prose or
|
||
// hand-written placeholders (".../(2-3 lines of base64)", "+" string
|
||
// concatenation, etc.). A real PEM body after header stripping is
|
||
// pure base64, so the threshold is set just below 100% to leave
|
||
// margin for whitespace handling edge cases without admitting any
|
||
// stray punctuation.
|
||
//
|
||
// Legacy encrypted PEM keys carry a "Proc-Type"/"DEK-Info" header
|
||
// block before the body; those lines contain ":", ",", and "-" which
|
||
// would tank the purity ratio if they were measured. The gate skips
|
||
// the header block (everything up to the first blank line) when one
|
||
// is present so it's measured only over the actual base64 payload.
|
||
func notTrivialPEMBody(s string) bool {
|
||
const minBody = 48
|
||
const beginMarker = "-----"
|
||
first := strings.Index(s, beginMarker)
|
||
if first < 0 {
|
||
return true
|
||
}
|
||
headerEnd := strings.Index(s[first+len(beginMarker):], beginMarker)
|
||
if headerEnd < 0 {
|
||
return true
|
||
}
|
||
bodyStart := first + len(beginMarker) + headerEnd + len(beginMarker)
|
||
endIdx := strings.LastIndex(s, "-----END")
|
||
if endIdx <= bodyStart {
|
||
return true
|
||
}
|
||
payload := stripPEMHeaders(s[bodyStart:endIdx])
|
||
nonWS, b64 := 0, 0
|
||
for _, c := range payload {
|
||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||
continue
|
||
}
|
||
nonWS++
|
||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||
(c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' {
|
||
b64++
|
||
}
|
||
}
|
||
if nonWS < minBody {
|
||
return false
|
||
}
|
||
return b64*100 >= nonWS*99
|
||
}
|
||
|
||
// stripPEMHeaders removes the legacy encrypted-PEM header block
|
||
// ("Proc-Type: 4,ENCRYPTED" / "DEK-Info: AES-128-CBC,...") from the
|
||
// start of body, returning the base64 payload. The header block is a
|
||
// sequence of "Name: Value" lines terminated by a blank line; modern
|
||
// PKCS#8 encrypted keys have no headers, so the function is a no-op
|
||
// in that case. A markdown diff that happens to wrap BEGIN/END
|
||
// markers around prose does not start with a "Name: Value" line, so
|
||
// stripPEMHeaders leaves it alone (its purity is measured over the
|
||
// full content and fails the 90% gate on its own).
|
||
func stripPEMHeaders(body string) string {
|
||
body = strings.TrimLeft(body, " \t\r\n")
|
||
if !looksLikePEMHeaderStart(body) {
|
||
return body
|
||
}
|
||
if _, after, ok := strings.Cut(body, "\n\n"); ok {
|
||
return after
|
||
}
|
||
if _, after, ok := strings.Cut(body, "\r\n\r\n"); ok {
|
||
return after
|
||
}
|
||
return body
|
||
}
|
||
|
||
// looksLikePEMHeaderStart reports whether body begins with a "Name:"
|
||
// header line in the form RFC 1421 allows: letters and hyphens before
|
||
// the colon, on the first line of the body.
|
||
func looksLikePEMHeaderStart(body string) bool {
|
||
eol := strings.IndexAny(body, "\r\n")
|
||
if eol < 0 {
|
||
eol = len(body)
|
||
}
|
||
colon := strings.Index(body[:eol], ":")
|
||
if colon <= 0 {
|
||
return false
|
||
}
|
||
for i := range colon {
|
||
c := body[i]
|
||
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && c != '-' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// rulesAlgorithmVersion bumps when matching *logic* changes (e.g. a new
|
||
// validate gate). Pattern edits are detected automatically because the
|
||
// regexes are folded into the hash; validate functions are not, so adding
|
||
// a new gate must bump this constant by hand. Mirrors db.ClassifierHash.
|
||
//
|
||
// v2: added doc-placeholder and trailing-run-repeat validators
|
||
// (AKIA*EXAMPL*, sk-ant-…AAAA, trivial PEM bodies, xoxb-…0123).
|
||
//
|
||
// v3: added structural bodyLooksRandom check (entropy + repeating
|
||
// blocks + sequential ASCII runs) and wired it into every definite
|
||
// rule, including github-pat, stripe-secret, and google-api-key which
|
||
// previously had no validator. Also tightened the PEM gate to require
|
||
// base64-purity of the body and lowered minBody to 48 so PKCS#8
|
||
// Ed25519 (64-char body) is still detected; the gate skips legacy
|
||
// "Proc-Type"/"DEK-Info" header lines before measuring purity. Shannon
|
||
// entropy is only applied to bodies ≥24 bytes via bodyLooksRandom;
|
||
// AWS validators call bodyHasNoPlaceholderShape so the 16-char base32
|
||
// body doesn't trip the gate by birthday-paradox luck.
|
||
//
|
||
// v4: added agentsviewTestFixtures deny-list. Scan and ScanDefinite drop
|
||
// matches from agentsview's own test files, so a development conversation that
|
||
// recorded a fixture doesn't report it as a definite leak on subsequent scans.
|
||
//
|
||
// v5: store and match fixture deny-list entries by SHA-256 hash and include
|
||
// those hashes in RulesVersion without committing the raw secret-shaped values.
|
||
//
|
||
// v6: added six well-anchored vendor rules (openai-key, gitlab-pat,
|
||
// npm-token, pypi-token, huggingface-token, sendgrid-key); widened the
|
||
// high-entropy-assignment capture group to include trailing base64 '='
|
||
// padding so the reported span matches the literal value, and updated
|
||
// highEntropyValue to trim trailing '=' before measuring entropy/length
|
||
// so the padding doesn't push a borderline body below the 3.5-bit floor.
|
||
const rulesAlgorithmVersion = 6
|
||
|
||
// Verify reports whether the named rule still produces a finding at exactly
|
||
// [start:end) within source. Used by --reveal to confirm a stored finding's
|
||
// coordinates still resolve to the same secret before printing a full value.
|
||
// It re-runs the canonical Scan (the same function that produces findings, so
|
||
// overlap suppression is applied identically) and matches grouped rules by
|
||
// their captured-group span.
|
||
func Verify(ruleName, source string, start, end int) bool {
|
||
if start < 0 || end > len(source) || start >= end {
|
||
return false
|
||
}
|
||
for _, m := range Scan(source) {
|
||
if m.Rule == ruleName && m.Start == start && m.End == end {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// RulesVersion is a stable hex SHA-256 over the algorithm version and the full
|
||
// ruleset (names, confidences, regexes, prefilters). It is the version a full
|
||
// Scan stamps. It changes when the ruleset changes, so persisted findings can
|
||
// be invalidated and rescanned.
|
||
func RulesVersion() string {
|
||
return rulesVersion("full", rules)
|
||
}
|
||
|
||
// DefiniteRulesVersion is the version stamped by the definite-only inline-sync
|
||
// scan. It is deliberately distinct from RulesVersion (a "definite" scope tag,
|
||
// plus a hash over only the definite rules) so secrets scan --backfill — which
|
||
// treats RulesVersion as current — re-scans sessions that received only the
|
||
// fast inline scan, letting an explicit scan add candidate findings. A later
|
||
// inline resync re-stamps this version, dropping those candidates by design.
|
||
func DefiniteRulesVersion() string {
|
||
return rulesVersion("definite", definiteRules)
|
||
}
|
||
|
||
// ActiveRulesVersions are the persisted scan versions that the current binary
|
||
// considers fresh for listing. Full scans stamp RulesVersion; inline sync scans
|
||
// stamp DefiniteRulesVersion. Both incorporate the fixture deny-list hashes.
|
||
func ActiveRulesVersions() []string {
|
||
full := RulesVersion()
|
||
definite := DefiniteRulesVersion()
|
||
if full == definite {
|
||
return []string{full}
|
||
}
|
||
return []string{full, definite}
|
||
}
|
||
|
||
// rulesVersion hashes the algorithm version, a scope tag, and the given rules.
|
||
// The scope tag guarantees the full and definite versions never collide even
|
||
// if their rule lists were identical.
|
||
func rulesVersion(scope string, rs []rule) string {
|
||
h := sha256.New()
|
||
fmt.Fprintf(h, "v%d\n%s\n", rulesAlgorithmVersion, scope)
|
||
for i := range rs {
|
||
r := &rs[i]
|
||
fmt.Fprintf(h, "R\x1f%s\x1f%s\x1f%d\x1f%s\n",
|
||
r.name, r.confidence, r.group, r.re.String())
|
||
for _, p := range r.prefilters {
|
||
fmt.Fprintf(h, "P\x1f%s\n", p)
|
||
}
|
||
}
|
||
for _, fixtureHash := range sortedAgentsviewFixtureHashes() {
|
||
fmt.Fprintf(h, "F\x1f%s\n", fixtureHash)
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil))
|
||
}
|