Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

748 lines
27 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 68 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))
}