diff --git a/node/resources/skills/rafter/docs/cli-reference.md b/node/resources/skills/rafter/docs/cli-reference.md index 6330e513..dc257b39 100644 --- a/node/resources/skills/rafter/docs/cli-reference.md +++ b/node/resources/skills/rafter/docs/cli-reference.md @@ -40,6 +40,16 @@ Useful flags: `--history` (scan git history with Gitleaks), `--format json`, `-- Example: `rafter secrets . --format json` +Each finding includes: + +- `pattern.severity` — blast radius if exploited (`low|medium|high|critical`). +- `pattern.confidence` — how sure we are it's a real secret (`low|medium|high`). Independent of severity. +- `redacted` — first/last 4 chars only. **Never log or echo the raw value.** +- `fingerprint` — 16-hex sha256 of `(file + rule + redacted)`. Use this in `.rafterignore` to suppress one specific finding without depending on line numbers. +- `remediation` — rotation/storage guidance for that pattern category. + +**Hard rule for agents:** never print, paste, or transmit a raw matched secret. The CLI never emits raw values; if you've extracted one some other way (e.g. by reading the file directly), redact it before mentioning it in a PR description, issue, log, or chat. Even `redacted` previews should not be quoted in user-facing artifacts unless necessary. + (Back-compat aliases: `rafter scan local` and `rafter agent scan`. Prefer `rafter secrets`.) ### `rafter get ` diff --git a/node/src/commands/agent/scan.ts b/node/src/commands/agent/scan.ts index af779b88..08fb4254 100644 --- a/node/src/commands/agent/scan.ts +++ b/node/src/commands/agent/scan.ts @@ -172,18 +172,32 @@ export function createSecretsCommand(): Command { * Emit SARIF 2.1.0 JSON for GitHub/GitLab security tab integration */ function outputSarif(results: ScanResult[]): void { - const rules = new Map(); + interface SarifRule { + id: string; + name: string; + shortDescription: { text: string }; + help?: { text: string }; + properties?: { confidence?: string }; + } + const rules = new Map(); const sarifResults: object[] = []; for (const r of results) { for (const m of r.matches) { const ruleId = m.pattern.name.toLowerCase().replace(/\s+/g, "-"); if (!rules.has(ruleId)) { - rules.set(ruleId, { + const rule: SarifRule = { id: ruleId, name: m.pattern.name, - shortDescription: m.pattern.description || m.pattern.name, - }); + shortDescription: { text: m.pattern.description || m.pattern.name }, + }; + if (m.pattern.remediation) { + rule.help = { text: m.pattern.remediation }; + } + if (m.pattern.confidence) { + rule.properties = { confidence: m.pattern.confidence }; + } + rules.set(ruleId, rule); } sarifResults.push({ ruleId, @@ -197,6 +211,10 @@ function outputSarif(results: ScanResult[]): void { }, }, ], + properties: { + confidence: m.pattern.confidence ?? "high", + fingerprint: m.fingerprint, + }, }); } } @@ -248,10 +266,17 @@ function outputScanResults( const resultsOut = results.map((r) => ({ file: r.file, matches: r.matches.map((m) => ({ - pattern: { name: m.pattern.name, severity: m.pattern.severity, description: m.pattern.description || "" }, + pattern: { + name: m.pattern.name, + severity: m.pattern.severity, + confidence: m.pattern.confidence ?? "high", + description: m.pattern.description || "", + }, line: m.line ?? null, column: m.column ?? null, redacted: m.redacted || "", + fingerprint: m.fingerprint ?? null, + remediation: m.pattern.remediation ?? null, })), })); const out = { @@ -288,11 +313,18 @@ function outputScanResults( totalMatches++; const location = match.line ? `Line ${match.line}` : "Unknown location"; const sev = fmt.severity(match.pattern.severity); + const conf = match.pattern.confidence ?? "high"; - console.log(` ${sev} ${match.pattern.name}`); + console.log(` ${sev} ${match.pattern.name} (confidence: ${conf})`); console.log(` Location: ${location}`); console.log(` Pattern: ${match.pattern.description || match.pattern.regex}`); console.log(` Redacted: ${match.redacted}`); + if (match.fingerprint) { + console.log(` Fingerprint: ${match.fingerprint}`); + } + if (match.pattern.remediation) { + console.log(` Remediation: ${match.pattern.remediation}`); + } console.log(); } } diff --git a/node/src/commands/mcp/server.ts b/node/src/commands/mcp/server.ts index 5423b0a1..0d52d18c 100644 --- a/node/src/commands/mcp/server.ts +++ b/node/src/commands/mcp/server.ts @@ -23,8 +23,11 @@ interface ScanResultOutput { matches: Array<{ pattern: string; severity: string; + confidence: string; line: number | undefined; redacted: string; + fingerprint: string | null; + remediation: string | null; }>; } @@ -34,8 +37,11 @@ function formatScanResults(results: Array<{ file: string; matches: any[] }>): Sc matches: r.matches.map(m => ({ pattern: m.pattern.name, severity: m.pattern.severity, + confidence: m.pattern.confidence ?? "high", line: m.line, redacted: m.redacted || m.match.slice(0, 4) + "****", + fingerprint: m.fingerprint ?? null, + remediation: m.pattern.remediation ?? null, })), })); } diff --git a/node/src/core/custom-patterns.ts b/node/src/core/custom-patterns.ts index 499bbd47..237b155c 100644 --- a/node/src/core/custom-patterns.ts +++ b/node/src/core/custom-patterns.ts @@ -108,6 +108,8 @@ export interface Suppression { pathGlob: string; /** Optional pattern name to suppress, e.g. "generic-api-key". Empty = suppress all patterns for matching files. */ patternName?: string; + /** Optional fingerprint hash (16-hex-char prefix from PatternEngine fingerprintFor). */ + fingerprint?: string; } /** @@ -129,6 +131,14 @@ export function loadSuppressions(projectRoot: string = process.cwd()): Suppressi for (const raw of lines) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; + // fingerprint:<16hex> → suppress by fingerprint hash, regardless of path/pattern + if (line.toLowerCase().startsWith("fingerprint:")) { + const fp = line.slice("fingerprint:".length).trim(); + if (/^[0-9a-f]{16}$/i.test(fp)) { + suppressions.push({ pathGlob: "**", fingerprint: fp.toLowerCase() }); + } + continue; + } const colonIdx = line.indexOf(":"); if (colonIdx === -1) { suppressions.push({ pathGlob: line }); @@ -151,9 +161,16 @@ export function loadSuppressions(projectRoot: string = process.cwd()): Suppressi export function isSuppressed( filePath: string, patternName: string, - suppressions: Suppression[] + suppressions: Suppression[], + fingerprint?: string ): boolean { for (const s of suppressions) { + if (s.fingerprint) { + if (fingerprint && s.fingerprint.toLowerCase() === fingerprint.toLowerCase()) { + return true; + } + continue; + } if (matchGlob(s.pathGlob, filePath)) { if (!s.patternName || s.patternName.toLowerCase() === patternName.toLowerCase()) { return true; diff --git a/node/src/core/pattern-engine.ts b/node/src/core/pattern-engine.ts index 00535eb8..9a3f2afa 100644 --- a/node/src/core/pattern-engine.ts +++ b/node/src/core/pattern-engine.ts @@ -1,8 +1,15 @@ +import { createHash } from "crypto"; + +export type Confidence = "low" | "medium" | "high"; + export interface Pattern { name: string; regex: string; severity: "low" | "medium" | "high" | "critical"; description?: string; + confidence?: Confidence; + remediation?: string; + minEntropy?: number; } export interface PatternMatch { @@ -11,6 +18,8 @@ export interface PatternMatch { line?: number; column?: number; redacted?: string; + fingerprint?: string; + entropy?: number; } const GENERIC_PATTERN_NAMES = new Set(["Generic API Key", "Generic Secret"]); @@ -18,6 +27,38 @@ const VARIABLE_NAME_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/; const LOWERCASE_IDENT_RE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/; const QUOTED_VALUE_RE = /['"]([^'"]+)['"]/; +/** + * Shannon entropy of a string (log base 2). + * Used to filter low-entropy false-positives on generic patterns. + */ +export function shannonEntropy(s: string): number { + if (!s) return 0; + const freq: Record = {}; + for (const ch of s) freq[ch] = (freq[ch] || 0) + 1; + let h = 0; + const len = s.length; + for (const k in freq) { + const p = freq[k] / len; + h -= p * Math.log2(p); + } + return h; +} + +/** + * Stable fingerprint for suppression. Hashes (file + rule + redacted) so the + * fingerprint survives line moves but changes if the secret value changes. + * Never includes the raw secret value. + */ +export function fingerprintFor(filePath: string, ruleName: string, redacted: string): string { + const h = createHash("sha256"); + h.update(filePath); + h.update("\0"); + h.update(ruleName); + h.update("\0"); + h.update(redacted); + return h.digest("hex").substring(0, 16); +} + export class PatternEngine { private patterns: Pattern[]; @@ -28,7 +69,7 @@ export class PatternEngine { /** * Scan text for pattern matches */ - scan(text: string): PatternMatch[] { + scan(text: string, filePath: string = ""): PatternMatch[] { const matches: PatternMatch[] = []; for (const pattern of this.patterns) { @@ -37,10 +78,15 @@ export class PatternEngine { while ((match = regex.exec(text)) !== null) { if (this.isFalsePositive(pattern, match[0])) continue; + const entropy = shannonEntropy(match[0]); + if (pattern.minEntropy !== undefined && entropy < pattern.minEntropy) continue; + const redacted = this.redact(match[0]); matches.push({ pattern, match: match[0], - redacted: this.redact(match[0]) + redacted, + entropy, + fingerprint: fingerprintFor(filePath, pattern.name, redacted), }); } } @@ -51,7 +97,7 @@ export class PatternEngine { /** * Scan text with line/column information */ - scanWithPosition(text: string): PatternMatch[] { + scanWithPosition(text: string, filePath: string = ""): PatternMatch[] { const matches: PatternMatch[] = []; const lines = text.split("\n"); @@ -64,12 +110,17 @@ export class PatternEngine { while ((match = regex.exec(line)) !== null) { if (this.isFalsePositive(pattern, match[0])) continue; + const entropy = shannonEntropy(match[0]); + if (pattern.minEntropy !== undefined && entropy < pattern.minEntropy) continue; + const redacted = this.redact(match[0]); matches.push({ pattern, match: match[0], line: lineNum + 1, column: match.index + 1, - redacted: this.redact(match[0]) + redacted, + entropy, + fingerprint: fingerprintFor(filePath, pattern.name, redacted), }); } } diff --git a/node/src/scanners/gitleaks.ts b/node/src/scanners/gitleaks.ts index 41056901..3bdea7e0 100644 --- a/node/src/scanners/gitleaks.ts +++ b/node/src/scanners/gitleaks.ts @@ -2,7 +2,7 @@ import { execFile } from "child_process"; import { promisify } from "util"; import { randomBytes } from "crypto"; import { BinaryManager } from "../utils/binary-manager.js"; -import { PatternMatch } from "../core/pattern-engine.js"; +import { PatternMatch, fingerprintFor } from "../core/pattern-engine.js"; import fs from "fs"; import os from "os"; import path from "path"; @@ -201,21 +201,64 @@ export class GitleaksScanner { private convertToPatternMatch(result: GitleaksResult): PatternMatch { // Map Gitleaks severity to our levels const severity = this.getSeverity(result.RuleID, result.Tags); + const confidence = this.getConfidence(result.RuleID, result.Entropy); + const remediation = this.getRemediation(result.RuleID, result.Tags); + const secret = result.Secret || result.Match; + const redacted = this.redact(secret); return { pattern: { name: result.RuleID || result.Description, regex: "", // Gitleaks doesn't expose the regex severity, - description: result.Description + confidence, + description: result.Description, + remediation, }, - match: result.Secret || result.Match, + match: secret, line: result.StartLine, column: result.StartColumn, - redacted: this.redact(result.Secret || result.Match) + redacted, + entropy: result.Entropy, + // Always compute our own stable hash; Gitleaks's Fingerprint format is + // `::` which leaks path data and isn't a hash. + fingerprint: fingerprintFor(result.File || "", result.RuleID || result.Description, redacted), }; } + /** + * Confidence tier based on Gitleaks rule ID + entropy + */ + private getConfidence(ruleID: string, entropy: number): "low" | "medium" | "high" { + const id = (ruleID || "").toLowerCase(); + if (id.includes("generic") || id.startsWith("token-")) { + if (entropy >= 4.5) return "medium"; + return "low"; + } + return "high"; + } + + /** + * Remediation suggestion based on Gitleaks rule ID + tags + */ + private getRemediation(ruleID: string, tags: string[]): string { + const id = (ruleID || "").toLowerCase(); + const t = tags.map(x => x.toLowerCase()); + if (id.includes("private-key") || t.includes("private-key")) { + return "Generate a new keypair, deploy the new public key, and revoke the old one. Never commit private keys; use ssh-agent or a KMS for storage."; + } + if (id.includes("aws") || id.includes("gcp") || id.includes("azure")) { + return "Rotate the credential in the provider's console immediately. Reference via env var or a secret manager. Git history retains the secret — rotation is mandatory."; + } + if (id.includes("database") || id.includes("postgres") || id.includes("mysql") || id.includes("mongo")) { + return "Rotate database credentials immediately. Reference via env var or a secret manager. Audit access logs for unauthorized use."; + } + if (id.includes("jwt")) { + return "If real, rotate the JWT signing key — every token signed with the old key is now untrusted. If example/test data, move to a fixture not committed to git."; + } + return "Revoke the credential at the issuer, generate a new one, and reference via env var or secret manager. Git history retains the secret — rotation is mandatory."; + } + /** * Determine severity from Gitleaks rule ID and tags */ diff --git a/node/src/scanners/regex-scanner.ts b/node/src/scanners/regex-scanner.ts index c93570e0..299c1ddc 100644 --- a/node/src/scanners/regex-scanner.ts +++ b/node/src/scanners/regex-scanner.ts @@ -34,9 +34,9 @@ export class RegexScanner { scanFile(filePath: string): ScanResult { try { const content = fs.readFileSync(filePath, "utf-8"); - const raw = this.engine.scanWithPosition(content); + const raw = this.engine.scanWithPosition(content, filePath); const matches = raw.filter( - (m) => !isSuppressed(filePath, m.pattern.name, this.suppressions) + (m) => !isSuppressed(filePath, m.pattern.name, this.suppressions, m.fingerprint) ); return { file: filePath, matches }; } catch (e) { diff --git a/node/src/scanners/secret-patterns.ts b/node/src/scanners/secret-patterns.ts index 9f550052..a15ffce4 100644 --- a/node/src/scanners/secret-patterns.ts +++ b/node/src/scanners/secret-patterns.ts @@ -1,5 +1,22 @@ import { Pattern } from "../core/pattern-engine.js"; +const REM_CLOUD = + "Rotate the credential in the provider's console immediately. Reference via env var or a secret manager. Even after removing this from your working tree, git history still contains the secret — rotation is mandatory."; +const REM_API_KEY = + "Revoke the key in the provider's dashboard, generate a new one, and reference via env var or secret manager. Git history retains the secret — rotation is mandatory."; +const REM_PRIVATE_KEY = + "Generate a new keypair, deploy the new public key, and revoke the old one. Never commit private keys; use ssh-agent or a KMS for storage."; +const REM_DB = + "Rotate database credentials immediately. Reference via env var (e.g. DATABASE_URL) or a secret manager. Audit access logs for unauthorized use."; +const REM_JWT = + "If real, rotate the JWT signing key — every token signed with the old key is now untrusted. If example/test data, move to a fixture not committed to git."; +const REM_BEARER = + "Revoke the token at the issuer; rotate. Reference via env var or short-lived credential exchange (OIDC)."; +const REM_GENERIC = + "Treat as a real credential: rotate at the issuer, move to env var or secret manager, and audit recent commits for related leaks."; +const REM_WEBHOOK = + "Regenerate the webhook URL in the provider's admin panel. Treat webhook URLs as credentials and reference via env var."; + /** * Default secret detection patterns * Based on common secret formats and Gitleaks rules @@ -10,13 +27,17 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ name: "AWS Access Key ID", regex: "(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}", severity: "critical", - description: "AWS Access Key ID detected" + confidence: "high", + description: "AWS Access Key ID detected", + remediation: REM_CLOUD, }, { name: "AWS Secret Access Key", regex: "(?i)aws(.{0,20})?['\"]?[0-9a-zA-Z/+]{40}['\"]?", severity: "critical", - description: "AWS Secret Access Key detected" + confidence: "medium", + description: "AWS Secret Access Key detected", + remediation: REM_CLOUD, }, // GitHub @@ -24,25 +45,33 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ name: "GitHub Personal Access Token", regex: "ghp_[0-9a-zA-Z]{36}", severity: "critical", - description: "GitHub Personal Access Token detected" + confidence: "high", + description: "GitHub Personal Access Token detected", + remediation: REM_API_KEY, }, { name: "GitHub OAuth Token", regex: "gho_[0-9a-zA-Z]{36}", severity: "critical", - description: "GitHub OAuth Token detected" + confidence: "high", + description: "GitHub OAuth Token detected", + remediation: REM_API_KEY, }, { name: "GitHub App Token", regex: "(ghu|ghs)_[0-9a-zA-Z]{36}", severity: "critical", - description: "GitHub App Token detected" + confidence: "high", + description: "GitHub App Token detected", + remediation: REM_API_KEY, }, { name: "GitHub Refresh Token", regex: "ghr_[0-9a-zA-Z]{76}", severity: "critical", - description: "GitHub Refresh Token detected" + confidence: "high", + description: "GitHub Refresh Token detected", + remediation: REM_API_KEY, }, // Google @@ -50,13 +79,17 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ name: "Google API Key", regex: "AIza[0-9A-Za-z\\-_]{35}", severity: "critical", - description: "Google API Key detected" + confidence: "high", + description: "Google API Key detected", + remediation: REM_API_KEY, }, { name: "Google OAuth", regex: "[0-9]+-[0-9A-Za-z_]{32}\\.apps\\.googleusercontent\\.com", severity: "critical", - description: "Google OAuth Client ID detected" + confidence: "high", + description: "Google OAuth Client ID detected", + remediation: REM_API_KEY, }, // Slack @@ -64,13 +97,17 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ name: "Slack Token", regex: "xox[baprs]-([0-9a-zA-Z]{10,48})", severity: "critical", - description: "Slack Token detected" + confidence: "high", + description: "Slack Token detected", + remediation: REM_API_KEY, }, { name: "Slack Webhook", regex: "https://hooks\\.slack\\.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}", severity: "high", - description: "Slack Webhook URL detected" + confidence: "high", + description: "Slack Webhook URL detected", + remediation: REM_WEBHOOK, }, // Stripe @@ -78,13 +115,17 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ name: "Stripe API Key", regex: "(?i)sk_live_[0-9a-zA-Z]{24}", severity: "critical", - description: "Stripe Live API Key detected" + confidence: "high", + description: "Stripe Live API Key detected", + remediation: REM_API_KEY, }, { name: "Stripe Restricted API Key", regex: "(?i)rk_live_[0-9a-zA-Z]{24}", severity: "critical", - description: "Stripe Restricted API Key detected" + confidence: "high", + description: "Stripe Restricted API Key detected", + remediation: REM_API_KEY, }, // Twilio @@ -92,7 +133,9 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ name: "Twilio API Key", regex: "SK[0-9a-fA-F]{32}", severity: "critical", - description: "Twilio API Key detected" + confidence: "high", + description: "Twilio API Key detected", + remediation: REM_API_KEY, }, // Generic patterns @@ -100,25 +143,36 @@ export const DEFAULT_SECRET_PATTERNS: Pattern[] = [ name: "Generic API Key", regex: "(?i)(? { + it("every default pattern has a confidence tier", () => { + for (const p of DEFAULT_SECRET_PATTERNS) { + expect(p.confidence, `pattern ${p.name} missing confidence`).toBeDefined(); + expect(["low", "medium", "high"]).toContain(p.confidence); + } + }); + + it("every default pattern has a remediation string", () => { + for (const p of DEFAULT_SECRET_PATTERNS) { + expect(p.remediation, `pattern ${p.name} missing remediation`).toBeDefined(); + expect(p.remediation!.length).toBeGreaterThan(20); + } + }); +}); + +describe("shannonEntropy", () => { + it("returns 0 for empty string", () => { + expect(shannonEntropy("")).toBe(0); + }); + + it("returns 0 for single repeated char", () => { + expect(shannonEntropy("aaaaaaa")).toBe(0); + }); + + it("returns ~1 for two-char alphabet", () => { + const e = shannonEntropy("ababab"); + expect(e).toBeCloseTo(1.0, 5); + }); + + it("returns higher entropy for diverse strings", () => { + const low = shannonEntropy("aaaaaaaaaa"); + const high = shannonEntropy("Xy7" + "&!Qz#9p"); + expect(high).toBeGreaterThan(low); + }); +}); + +describe("fingerprintFor", () => { + it("is deterministic", () => { + expect(fingerprintFor("a.txt", "rule", "redacted-x")) + .toBe(fingerprintFor("a.txt", "rule", "redacted-x")); + }); + + it("is 16 hex chars", () => { + const fp = fingerprintFor("a.txt", "rule", "x"); + expect(fp).toMatch(/^[0-9a-f]{16}$/); + }); + + it("changes if any input changes", () => { + const a = fingerprintFor("a.txt", "rule", "x"); + expect(a).not.toBe(fingerprintFor("b.txt", "rule", "x")); + expect(a).not.toBe(fingerprintFor("a.txt", "rule2", "x")); + expect(a).not.toBe(fingerprintFor("a.txt", "rule", "y")); + }); + + it("does not encode raw secret in fingerprint output", () => { + const rawSecret = "super-leaky-value-1234"; + const redacted = "supe****1234"; + const fp = fingerprintFor("a.txt", "rule", redacted); + expect(fp).not.toContain(rawSecret); + }); +}); + +describe("Entropy filter on Generic patterns", () => { + it("drops low-entropy Generic Secret values", () => { + const text = buildLine(PASSWORD, FAKE.lowEntropySecret); + const scanner = new RegexScanner(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-entropy-")); + const fp = path.join(tmp, "config.txt"); + fs.writeFileSync(fp, text); + try { + const r = scanner.scanFile(fp); + const generic = r.matches.filter(m => m.pattern.name === "Generic Secret"); + expect(generic.length).toBe(0); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("keeps high-entropy Generic API Key values", () => { + const text = buildLine(APIKEY, FAKE.highEntApiKey); + const scanner = new RegexScanner(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-entropy-")); + const fp = path.join(tmp, "config.txt"); + fs.writeFileSync(fp, text); + try { + const r = scanner.scanFile(fp); + const generic = r.matches.filter(m => m.pattern.name === "Generic API Key"); + expect(generic.length).toBeGreaterThan(0); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe("Adversarial fixture: shape-only fakes are detected and redacted", () => { + let tmp: string; + let fp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-adversarial-")); + fp = path.join(tmp, "fake-secrets.txt"); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("detects AWS-shaped fake; redacted output never equals raw value", () => { + fs.writeFileSync(fp, buildLine("aws_key", FAKE.awsKey) + "\n"); + const scanner = new RegexScanner(); + const r = scanner.scanFile(fp); + const aws = r.matches.find(m => m.pattern.name === "AWS Access Key ID"); + expect(aws).toBeDefined(); + expect(aws!.redacted).toBeTruthy(); + expect(aws!.redacted).not.toBe(aws!.match); + expect(aws!.redacted!.startsWith(aws!.match.slice(0, 4))).toBe(true); + expect(aws!.redacted!.endsWith(aws!.match.slice(-4))).toBe(true); + expect(aws!.pattern.confidence).toBe("high"); + expect(aws!.pattern.remediation).toBeTruthy(); + expect(aws!.fingerprint).toMatch(/^[0-9a-f]{16}$/); + }); + + it("detects high-entropy GitHub PAT-shaped fake", () => { + fs.writeFileSync(fp, buildLine("token", FAKE.ghpHighEnt) + "\n"); + const scanner = new RegexScanner(); + const r = scanner.scanFile(fp); + const ghp = r.matches.find(m => m.pattern.name === "GitHub Personal Access Token"); + expect(ghp).toBeDefined(); + expect(ghp!.pattern.confidence).toBe("high"); + }); + + it("ignores variable-name-shaped placeholders", () => { + // UPPER_SNAKE and lowercase_snake placeholders should be dropped by FP heuristic + const lines = [ + buildLine(APIKEY, "EXAMPLE_API_KEY_PLACEHOLDER"), + buildLine("secret", "REPLACE_ME_BEFORE_PROD"), + buildLine(PASSWORD, "your_password_here"), + ].join("\n"); + fs.writeFileSync(fp, lines); + const scanner = new RegexScanner(); + const r = scanner.scanFile(fp); + const generic = r.matches.filter(m => + m.pattern.name === "Generic API Key" || m.pattern.name === "Generic Secret" + ); + expect(generic.length).toBe(0); + }); +}); + +/** + * Hard-rule regression: scan output (JSON, text, SARIF) MUST NOT contain + * the raw secret value. The redacted value should be present. + */ +describe("Hard rule: no raw secret values in any output surface", () => { + let tmp: string; + let fixturePath: string; + const fake = FAKE.awsKey; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-noleak-")); + fixturePath = path.join(tmp, "leak.txt"); + fs.writeFileSync(fixturePath, buildLine("aws_key", fake) + "\n"); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + function runCli(args: string) { + const result = spawnSync(`node ${CLI_PATH} secrets ${args}`, { + encoding: "utf-8", + shell: true, + timeout: 15_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + return { stdout: result.stdout || "", stderr: result.stderr || "" }; + } + + it("text output does not contain raw secret", () => { + const r = runCli(`${tmp}`); + expect(r.stdout + r.stderr).not.toContain(fake); + }); + + it("JSON output does not contain raw secret", () => { + const r = runCli(`${tmp} --json`); + expect(r.stdout + r.stderr).not.toContain(fake); + }); + + it("SARIF output does not contain raw secret", () => { + const r = runCli(`${tmp} --format sarif`); + expect(r.stdout + r.stderr).not.toContain(fake); + }); + + it("JSON output includes confidence + remediation + fingerprint fields", () => { + const r = runCli(`${tmp} --json`); + const parsed = JSON.parse(r.stdout); + const findings = parsed.results; + expect(findings.length).toBeGreaterThan(0); + const m = findings[0].matches[0]; + expect(m.pattern.confidence).toBeDefined(); + expect(m.remediation).toBeTruthy(); + expect(m.fingerprint).toMatch(/^[0-9a-f]{16}$/); + }); +}); diff --git a/python/rafter_cli/commands/agent.py b/python/rafter_cli/commands/agent.py index 60241be2..273aa861 100644 --- a/python/rafter_cli/commands/agent.py +++ b/python/rafter_cli/commands/agent.py @@ -1224,8 +1224,19 @@ def _output_scan_results( if json_output or format == "json": results_out = [ {"file": r.file, "matches": [ - {"pattern": {"name": m.pattern.name, "severity": m.pattern.severity, "description": m.pattern.description or ""}, - "line": m.line, "column": m.column, "redacted": m.redacted} + { + "pattern": { + "name": m.pattern.name, + "severity": m.pattern.severity, + "confidence": getattr(m.pattern, "confidence", None) or "high", + "description": m.pattern.description or "", + }, + "line": m.line, + "column": m.column, + "redacted": m.redacted, + "fingerprint": getattr(m, "fingerprint", "") or None, + "remediation": getattr(m.pattern, "remediation", "") or None, + } for m in r.matches ]} for r in results @@ -1262,10 +1273,17 @@ def _output_scan_results( for m in r.matches: total += 1 loc = f"Line {m.line}" if m.line else "Unknown location" - rprint(f" {fmt.severity(m.pattern.severity)} {m.pattern.name}") + conf = getattr(m.pattern, "confidence", None) or "high" + rprint(f" {fmt.severity(m.pattern.severity)} {m.pattern.name} (confidence: {conf})") rprint(f" Location: {loc}") rprint(f" Pattern: {m.pattern.description or m.pattern.regex}") rprint(f" Redacted: {m.redacted}") + fp = getattr(m, "fingerprint", "") + if fp: + rprint(f" Fingerprint: {fp}") + rem = getattr(m.pattern, "remediation", "") + if rem: + rprint(f" Remediation: {rem}") rprint() rprint(f"\n{fmt.warning(f'Total: {total} secret(s) detected in {len(results)} file(s)')}\n") @@ -1380,11 +1398,18 @@ def _output_sarif(results: list[ScanResult]) -> None: for m in r.matches: rule_id = re.sub(r"\s+", "-", m.pattern.name.lower()) if rule_id not in rules: - rules[rule_id] = { + rule: dict[str, Any] = { "id": rule_id, "name": m.pattern.name, "shortDescription": {"text": m.pattern.description or m.pattern.name}, } + rem = getattr(m.pattern, "remediation", "") + if rem: + rule["help"] = {"text": rem} + conf_attr = getattr(m.pattern, "confidence", None) + if conf_attr: + rule["properties"] = {"confidence": conf_attr} + rules[rule_id] = rule level = "error" if m.pattern.severity in ("critical", "high") else "warning" location: dict[str, Any] = { "artifactLocation": {"uri": r.file.replace("\\", "/"), "uriBaseId": "%SRCROOT%"}, @@ -1396,6 +1421,10 @@ def _output_sarif(results: list[ScanResult]) -> None: "level": level, "message": {"text": f"{m.pattern.name} detected"}, "locations": [{"physicalLocation": location}], + "properties": { + "confidence": getattr(m.pattern, "confidence", None) or "high", + "fingerprint": getattr(m, "fingerprint", "") or None, + }, }) sarif = { "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", diff --git a/python/rafter_cli/commands/mcp_server.py b/python/rafter_cli/commands/mcp_server.py index 1b339409..9dc5bf11 100644 --- a/python/rafter_cli/commands/mcp_server.py +++ b/python/rafter_cli/commands/mcp_server.py @@ -41,8 +41,11 @@ def handle_scan_secrets(path: str, engine: str = "auto") -> list[dict]: { "pattern": m.pattern.name, "severity": m.pattern.severity, + "confidence": getattr(m.pattern, "confidence", None) or "high", "line": m.line, "redacted": m.redacted or m.match[:4] + "****", + "fingerprint": getattr(m, "fingerprint", "") or None, + "remediation": getattr(m.pattern, "remediation", "") or None, } for m in r.matches ], @@ -72,8 +75,11 @@ def handle_scan_secrets(path: str, engine: str = "auto") -> list[dict]: { "pattern": m.pattern.name, "severity": m.pattern.severity, + "confidence": getattr(m.pattern, "confidence", None) or "high", "line": m.line, "redacted": m.redacted or m.match[:4] + "****", + "fingerprint": getattr(m, "fingerprint", "") or None, + "remediation": getattr(m.pattern, "remediation", "") or None, } for m in r.matches ], diff --git a/python/rafter_cli/core/custom_patterns.py b/python/rafter_cli/core/custom_patterns.py index 263bb65e..045446dd 100644 --- a/python/rafter_cli/core/custom_patterns.py +++ b/python/rafter_cli/core/custom_patterns.py @@ -81,7 +81,10 @@ def _load_json(path: Path) -> list[Pattern]: name=entry.get("name", f"Custom ({path.stem})"), regex=entry["pattern"], severity=severity, - description=entry.get("description"), + description=entry.get("description") or "", + confidence=entry.get("confidence", "high"), + remediation=entry.get("remediation", "") or "", + min_entropy=entry.get("min_entropy"), )) return patterns except (OSError, json.JSONDecodeError, KeyError): @@ -96,6 +99,10 @@ def _load_json(path: Path) -> list[Pattern]: class Suppression: path_glob: str pattern_name: Optional[str] = None + fingerprint: Optional[str] = None + + +_FINGERPRINT_HEX = re.compile(r"^[0-9a-f]{16}$", re.IGNORECASE) def load_suppressions(project_root: str | Path | None = None) -> list[Suppression]: @@ -104,6 +111,7 @@ def load_suppressions(project_root: str | Path | None = None) -> list[Suppressio Format — one entry per line: path/glob → suppress all findings in matching files path/glob:pattern-name → suppress specific pattern in matching files + fingerprint:<16hex> → suppress by stable hash, regardless of path Lines starting with # are comments. """ @@ -118,6 +126,11 @@ def load_suppressions(project_root: str | Path | None = None) -> list[Suppressio line = raw.strip() if not line or line.startswith("#"): continue + if line.lower().startswith("fingerprint:"): + fp = line[len("fingerprint:"):].strip() + if _FINGERPRINT_HEX.match(fp): + suppressions.append(Suppression(path_glob="**", fingerprint=fp.lower())) + continue colon = line.find(":") if colon == -1: suppressions.append(Suppression(path_glob=line)) @@ -131,9 +144,18 @@ def load_suppressions(project_root: str | Path | None = None) -> list[Suppressio return suppressions -def is_suppressed(file_path: str, pattern_name: str, suppressions: list[Suppression]) -> bool: +def is_suppressed( + file_path: str, + pattern_name: str, + suppressions: list[Suppression], + fingerprint: str | None = None, +) -> bool: """Return True if this finding should be suppressed.""" for s in suppressions: + if s.fingerprint: + if fingerprint and s.fingerprint.lower() == fingerprint.lower(): + return True + continue if _match_glob(s.path_glob, file_path): if s.pattern_name is None or s.pattern_name.lower() == pattern_name.lower(): return True diff --git a/python/rafter_cli/core/pattern_engine.py b/python/rafter_cli/core/pattern_engine.py index 15c701a0..4b24af2b 100644 --- a/python/rafter_cli/core/pattern_engine.py +++ b/python/rafter_cli/core/pattern_engine.py @@ -1,8 +1,11 @@ """Regex pattern engine for secret detection.""" from __future__ import annotations +import hashlib +import math import re -from dataclasses import dataclass +from collections import Counter +from dataclasses import dataclass, field from typing import Sequence @@ -12,6 +15,9 @@ class Pattern: regex: str severity: str # low | medium | high | critical description: str = "" + confidence: str = "high" # low | medium | high + remediation: str = "" + min_entropy: float | None = None @dataclass @@ -21,6 +27,28 @@ class PatternMatch: line: int | None = None column: int | None = None redacted: str = "" + fingerprint: str = "" + entropy: float = 0.0 + + +def shannon_entropy(s: str) -> float: + """Shannon entropy (log base 2). 0.0 for empty strings.""" + if not s: + return 0.0 + counts = Counter(s) + n = len(s) + return -sum((c / n) * math.log2(c / n) for c in counts.values()) + + +def fingerprint_for(file_path: str, rule_name: str, redacted: str) -> str: + """Stable suppression fingerprint. Never includes raw secret value.""" + h = hashlib.sha256() + h.update(file_path.encode("utf-8")) + h.update(b"\0") + h.update(rule_name.encode("utf-8")) + h.update(b"\0") + h.update(redacted.encode("utf-8")) + return h.hexdigest()[:16] _GENERIC_PATTERN_NAMES = frozenset({"Generic API Key", "Generic Secret"}) @@ -34,7 +62,7 @@ class PatternEngine: def __init__(self, patterns: Sequence[Pattern]): self._patterns = list(patterns) - def scan(self, text: str) -> list[PatternMatch]: + def scan(self, text: str, file_path: str = "") -> list[PatternMatch]: """Scan text for pattern matches (no line info).""" matches: list[PatternMatch] = [] for pattern in self._patterns: @@ -42,16 +70,23 @@ def scan(self, text: str) -> list[PatternMatch]: if compiled is None: continue for m in compiled.finditer(text): - if self._is_false_positive(pattern, m.group(0)): + value = m.group(0) + if self._is_false_positive(pattern, value): + continue + entropy = shannon_entropy(value) + if pattern.min_entropy is not None and entropy < pattern.min_entropy: continue + redacted = self._redact(value) matches.append(PatternMatch( pattern=pattern, - match=m.group(0), - redacted=self._redact(m.group(0)), + match=value, + redacted=redacted, + entropy=entropy, + fingerprint=fingerprint_for(file_path, pattern.name, redacted), )) return matches - def scan_with_position(self, text: str) -> list[PatternMatch]: + def scan_with_position(self, text: str, file_path: str = "") -> list[PatternMatch]: """Scan text with line/column information.""" matches: list[PatternMatch] = [] for line_num, line in enumerate(text.split("\n"), start=1): @@ -60,14 +95,21 @@ def scan_with_position(self, text: str) -> list[PatternMatch]: if compiled is None: continue for m in compiled.finditer(line): - if self._is_false_positive(pattern, m.group(0)): + value = m.group(0) + if self._is_false_positive(pattern, value): + continue + entropy = shannon_entropy(value) + if pattern.min_entropy is not None and entropy < pattern.min_entropy: continue + redacted = self._redact(value) matches.append(PatternMatch( pattern=pattern, - match=m.group(0), + match=value, line=line_num, column=m.start() + 1, - redacted=self._redact(m.group(0)), + redacted=redacted, + entropy=entropy, + fingerprint=fingerprint_for(file_path, pattern.name, redacted), )) return matches diff --git a/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md b/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md index 6330e513..dc257b39 100644 --- a/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md +++ b/python/rafter_cli/resources/skills/rafter/docs/cli-reference.md @@ -40,6 +40,16 @@ Useful flags: `--history` (scan git history with Gitleaks), `--format json`, `-- Example: `rafter secrets . --format json` +Each finding includes: + +- `pattern.severity` — blast radius if exploited (`low|medium|high|critical`). +- `pattern.confidence` — how sure we are it's a real secret (`low|medium|high`). Independent of severity. +- `redacted` — first/last 4 chars only. **Never log or echo the raw value.** +- `fingerprint` — 16-hex sha256 of `(file + rule + redacted)`. Use this in `.rafterignore` to suppress one specific finding without depending on line numbers. +- `remediation` — rotation/storage guidance for that pattern category. + +**Hard rule for agents:** never print, paste, or transmit a raw matched secret. The CLI never emits raw values; if you've extracted one some other way (e.g. by reading the file directly), redact it before mentioning it in a PR description, issue, log, or chat. Even `redacted` previews should not be quoted in user-facing artifacts unless necessary. + (Back-compat aliases: `rafter scan local` and `rafter agent scan`. Prefer `rafter secrets`.) ### `rafter get ` diff --git a/python/rafter_cli/scanners/gitleaks.py b/python/rafter_cli/scanners/gitleaks.py index a27d3967..4480cfbf 100644 --- a/python/rafter_cli/scanners/gitleaks.py +++ b/python/rafter_cli/scanners/gitleaks.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from typing import NamedTuple -from ..core.pattern_engine import Pattern, PatternMatch +from ..core.pattern_engine import Pattern, PatternMatch, fingerprint_for from ..utils.binary_manager import BinaryManager @@ -154,19 +154,31 @@ def _run_scan(self, target: str, *, use_git: bool = False) -> list[dict]: @staticmethod def _convert(result: dict) -> PatternMatch: rule_id = result.get("RuleID", result.get("Description", "unknown")) - severity = GitleaksScanner._get_severity(rule_id, result.get("Tags", [])) + tags = result.get("Tags", []) or [] + entropy = float(result.get("Entropy", 0.0) or 0.0) + severity = GitleaksScanner._get_severity(rule_id, tags) + confidence = GitleaksScanner._get_confidence(rule_id, entropy) + remediation = GitleaksScanner._get_remediation(rule_id, tags) secret = result.get("Secret", result.get("Match", "")) + redacted = GitleaksScanner._redact(secret) + # Always compute our own stable hash; Gitleaks's Fingerprint format is + # `::` which leaks path data and isn't a hash. + fingerprint = fingerprint_for(result.get("File", "") or "", rule_id, redacted) return PatternMatch( pattern=Pattern( name=rule_id, regex="", severity=severity, description=result.get("Description", ""), + confidence=confidence, + remediation=remediation, ), match=secret, line=result.get("StartLine"), column=result.get("StartColumn"), - redacted=GitleaksScanner._redact(secret), + redacted=redacted, + fingerprint=fingerprint, + entropy=entropy, ) @staticmethod @@ -180,6 +192,32 @@ def _get_severity(rule_id: str, tags: list) -> str: return "medium" return "high" + @staticmethod + def _get_confidence(rule_id: str, entropy: float) -> str: + lower = (rule_id or "").lower() + if "generic" in lower or lower.startswith("token-"): + return "medium" if entropy >= 4.5 else "low" + return "high" + + @staticmethod + def _get_remediation(rule_id: str, tags: list) -> str: + lower = (rule_id or "").lower() + t = [x.lower() for x in tags] + if "private-key" in lower or "private-key" in t: + return ("Generate a new keypair, deploy the new public key, and revoke the old one. " + "Never commit private keys; use ssh-agent or a KMS for storage.") + if any(k in lower for k in ("aws", "gcp", "azure")): + return ("Rotate the credential in the provider's console immediately. Reference via env var " + "or a secret manager. Git history retains the secret — rotation is mandatory.") + if any(k in lower for k in ("database", "postgres", "mysql", "mongo")): + return ("Rotate database credentials immediately. Reference via env var or a secret manager. " + "Audit access logs for unauthorized use.") + if "jwt" in lower: + return ("If real, rotate the JWT signing key — every token signed with the old key is now " + "untrusted. If example/test data, move to a fixture not committed to git.") + return ("Revoke the credential at the issuer, generate a new one, and reference via env var or " + "secret manager. Git history retains the secret — rotation is mandatory.") + @staticmethod def _redact(match: str) -> str: if len(match) <= 8: diff --git a/python/rafter_cli/scanners/regex_scanner.py b/python/rafter_cli/scanners/regex_scanner.py index e9f75a98..c4c5f89b 100644 --- a/python/rafter_cli/scanners/regex_scanner.py +++ b/python/rafter_cli/scanners/regex_scanner.py @@ -42,6 +42,9 @@ def __init__(self, custom_patterns: list[dict] | None = None): name=cp.get("name", "Custom"), regex=cp.get("regex", ""), severity=cp.get("severity", "high"), + confidence=cp.get("confidence", "high"), + remediation=cp.get("remediation", ""), + min_entropy=cp.get("min_entropy"), )) self._engine = PatternEngine(patterns) self._suppressions = load_suppressions() @@ -51,8 +54,11 @@ def scan_file(self, file_path: str) -> ScanResult: content = Path(file_path).read_text(errors="ignore") except (OSError, UnicodeDecodeError): return ScanResult(file=file_path) - raw = self._engine.scan_with_position(content) - matches = [m for m in raw if not is_suppressed(file_path, m.pattern.name, self._suppressions)] + raw = self._engine.scan_with_position(content, file_path) + matches = [ + m for m in raw + if not is_suppressed(file_path, m.pattern.name, self._suppressions, m.fingerprint) + ] return ScanResult(file=file_path, matches=matches) def scan_files(self, file_paths: list[str]) -> list[ScanResult]: diff --git a/python/rafter_cli/scanners/secret_patterns.py b/python/rafter_cli/scanners/secret_patterns.py index c512eb75..8ab99f68 100644 --- a/python/rafter_cli/scanners/secret_patterns.py +++ b/python/rafter_cli/scanners/secret_patterns.py @@ -3,143 +3,223 @@ from ..core.pattern_engine import Pattern +REM_CLOUD = ( + "Rotate the credential in the provider's console immediately. Reference via " + "env var or a secret manager. Even after removing this from your working tree, " + "git history still contains the secret — rotation is mandatory." +) +REM_API_KEY = ( + "Revoke the key in the provider's dashboard, generate a new one, and reference " + "via env var or secret manager. Git history retains the secret — rotation is mandatory." +) +REM_PRIVATE_KEY = ( + "Generate a new keypair, deploy the new public key, and revoke the old one. " + "Never commit private keys; use ssh-agent or a KMS for storage." +) +REM_DB = ( + "Rotate database credentials immediately. Reference via env var (e.g. DATABASE_URL) " + "or a secret manager. Audit access logs for unauthorized use." +) +REM_JWT = ( + "If real, rotate the JWT signing key — every token signed with the old key is now " + "untrusted. If example/test data, move to a fixture not committed to git." +) +REM_BEARER = ( + "Revoke the token at the issuer; rotate. Reference via env var or short-lived " + "credential exchange (OIDC)." +) +REM_GENERIC = ( + "Treat as a real credential: rotate at the issuer, move to env var or secret manager, " + "and audit recent commits for related leaks." +) +REM_WEBHOOK = ( + "Regenerate the webhook URL in the provider's admin panel. Treat webhook URLs as " + "credentials and reference via env var." +) + + DEFAULT_SECRET_PATTERNS: list[Pattern] = [ # AWS Pattern( name="AWS Access Key ID", regex=r"(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}", severity="critical", + confidence="high", description="AWS Access Key ID detected", + remediation=REM_CLOUD, ), Pattern( name="AWS Secret Access Key", regex=r"(?i)aws(.{0,20})?['\"]?[0-9a-zA-Z/+]{40}['\"]?", severity="critical", + confidence="medium", description="AWS Secret Access Key detected", + remediation=REM_CLOUD, ), # GitHub Pattern( name="GitHub Personal Access Token", regex=r"ghp_[0-9a-zA-Z]{36}", severity="critical", + confidence="high", description="GitHub Personal Access Token detected", + remediation=REM_API_KEY, ), Pattern( name="GitHub OAuth Token", regex=r"gho_[0-9a-zA-Z]{36}", severity="critical", + confidence="high", description="GitHub OAuth Token detected", + remediation=REM_API_KEY, ), Pattern( name="GitHub App Token", regex=r"(ghu|ghs)_[0-9a-zA-Z]{36}", severity="critical", + confidence="high", description="GitHub App Token detected", + remediation=REM_API_KEY, ), Pattern( name="GitHub Refresh Token", regex=r"ghr_[0-9a-zA-Z]{76}", severity="critical", + confidence="high", description="GitHub Refresh Token detected", + remediation=REM_API_KEY, ), # Google Pattern( name="Google API Key", regex=r"AIza[0-9A-Za-z\-_]{35}", severity="critical", + confidence="high", description="Google API Key detected", + remediation=REM_API_KEY, ), Pattern( name="Google OAuth", regex=r"[0-9]+-[0-9A-Za-z_]{32}\.apps\.googleusercontent\.com", severity="critical", + confidence="high", description="Google OAuth Client ID detected", + remediation=REM_API_KEY, ), # Slack Pattern( name="Slack Token", regex=r"xox[baprs]-([0-9a-zA-Z]{10,48})", severity="critical", + confidence="high", description="Slack Token detected", + remediation=REM_API_KEY, ), Pattern( name="Slack Webhook", regex=r"https://hooks\.slack\.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}", severity="high", + confidence="high", description="Slack Webhook URL detected", + remediation=REM_WEBHOOK, ), # Stripe Pattern( name="Stripe API Key", regex=r"(?i)sk_live_[0-9a-zA-Z]{24}", severity="critical", + confidence="high", description="Stripe Live API Key detected", + remediation=REM_API_KEY, ), Pattern( name="Stripe Restricted API Key", regex=r"(?i)rk_live_[0-9a-zA-Z]{24}", severity="critical", + confidence="high", description="Stripe Restricted API Key detected", + remediation=REM_API_KEY, ), # Twilio Pattern( name="Twilio API Key", regex=r"SK[0-9a-fA-F]{32}", severity="critical", + confidence="high", description="Twilio API Key detected", + remediation=REM_API_KEY, ), # Generic Pattern( name="Generic API Key", regex=r"(?i)(? str: + return f"{key} = {_QUOTE}{value}{_QUOTE}" + + +_FAKE_AWS = "AKIA" + "IOSFODNN7" + "EXAMPLEZZ" +_FAKE_GHP = "ghp_" + "abcdef0123456789ABCDEFghijklmnoPQRSTU"[:36] +_FAKE_LOW_ENTROPY = "a" * 12 + "1" +_FAKE_HIGH_ENT_API = "Xy" + "7Qz" + "9p2" + "Rt5" + "Wn8" + "Bm4" + "Jc6" + "Kd" + + +# -- Pattern annotations -------------------------------------------------- + + +def test_every_pattern_has_confidence(): + for p in DEFAULT_SECRET_PATTERNS: + assert p.confidence in {"low", "medium", "high"}, ( + f"pattern {p.name} has invalid confidence: {p.confidence!r}" + ) + + +def test_every_pattern_has_remediation(): + for p in DEFAULT_SECRET_PATTERNS: + assert p.remediation, f"pattern {p.name} missing remediation" + assert len(p.remediation) > 20 + + +# -- Shannon entropy ------------------------------------------------------ + + +def test_shannon_entropy_empty(): + assert shannon_entropy("") == 0.0 + + +def test_shannon_entropy_single_char(): + assert shannon_entropy("aaaa") == 0.0 + + +def test_shannon_entropy_two_chars(): + assert shannon_entropy("abab") == pytest.approx(1.0, abs=1e-6) + + +def test_shannon_entropy_higher_for_diverse(): + assert shannon_entropy("Xy7" + "&!Qz#9p") > shannon_entropy("aaaaaaa") + + +# -- Fingerprint ---------------------------------------------------------- + + +def test_fingerprint_deterministic(): + a = fingerprint_for("a.txt", "rule", "redacted-x") + b = fingerprint_for("a.txt", "rule", "redacted-x") + assert a == b + + +def test_fingerprint_format(): + fp = fingerprint_for("a.txt", "rule", "x") + assert re.match(r"^[0-9a-f]{16}$", fp) + + +def test_fingerprint_changes_per_input(): + base = fingerprint_for("a.txt", "rule", "x") + assert base != fingerprint_for("b.txt", "rule", "x") + assert base != fingerprint_for("a.txt", "rule2", "x") + assert base != fingerprint_for("a.txt", "rule", "y") + + +def test_fingerprint_does_not_encode_raw_secret(): + raw = "super-leaky-1234" + redacted = "supe****1234" + fp = fingerprint_for("a.txt", "rule", redacted) + assert raw not in fp + + +# -- Entropy filter on Generic patterns ----------------------------------- + + +def test_entropy_filter_drops_low_entropy_secret(tmp_path: Path): + text = _line(_PASSWORD, _FAKE_LOW_ENTROPY) + fp = tmp_path / "config.txt" + fp.write_text(text) + scanner = RegexScanner() + r = scanner.scan_file(str(fp)) + generic = [m for m in r.matches if m.pattern.name == "Generic Secret"] + assert generic == [] + + +def test_entropy_filter_keeps_high_entropy_apikey(tmp_path: Path): + text = _line(_APIKEY, _FAKE_HIGH_ENT_API) + fp = tmp_path / "config.txt" + fp.write_text(text) + scanner = RegexScanner() + r = scanner.scan_file(str(fp)) + generic = [m for m in r.matches if m.pattern.name == "Generic API Key"] + assert len(generic) > 0 + + +# -- Adversarial fixture: shape-only fakes -------------------------------- + + +def test_aws_shape_detected_and_redacted(tmp_path: Path): + fp = tmp_path / "fake.txt" + fp.write_text(_line("aws_key", _FAKE_AWS) + "\n") + scanner = RegexScanner() + r = scanner.scan_file(str(fp)) + aws = next((m for m in r.matches if m.pattern.name == "AWS Access Key ID"), None) + assert aws is not None + assert aws.redacted + assert aws.redacted != aws.match + assert aws.redacted.startswith(aws.match[:4]) + assert aws.redacted.endswith(aws.match[-4:]) + assert aws.pattern.confidence == "high" + assert aws.pattern.remediation + assert re.match(r"^[0-9a-f]{16}$", aws.fingerprint) + + +def test_ghp_high_entropy_detected(tmp_path: Path): + fp = tmp_path / "fake.txt" + fp.write_text(_line("token", _FAKE_GHP) + "\n") + scanner = RegexScanner() + r = scanner.scan_file(str(fp)) + ghp = next((m for m in r.matches if m.pattern.name == "GitHub Personal Access Token"), None) + assert ghp is not None + assert ghp.pattern.confidence == "high" + + +def test_placeholders_ignored(tmp_path: Path): + """UPPER_SNAKE and lowercase_snake placeholders → dropped by FP heuristic.""" + fp = tmp_path / "fake.txt" + fp.write_text( + "\n".join([ + _line(_APIKEY, "EXAMPLE_API_KEY_PLACEHOLDER"), + _line("secret", "REPLACE_ME_BEFORE_PROD"), + _line(_PASSWORD, "your_password_here"), + ]) + ) + scanner = RegexScanner() + r = scanner.scan_file(str(fp)) + generic = [ + m for m in r.matches + if m.pattern.name in {"Generic API Key", "Generic Secret"} + ] + assert generic == [] + + +# -- Hard rule: no raw secrets in any output surface ---------------------- + + +def _run_cli(args: list[str], cwd: str | None = None) -> tuple[str, str]: + """Invoke `rafter secrets ...` and return (stdout, stderr).""" + cmd = [sys.executable, "-m", "rafter_cli", "secrets", *args] + env = {**os.environ, "NO_COLOR": "1"} + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=30, cwd=cwd, env=env + ) + return proc.stdout, proc.stderr + + +@pytest.fixture +def leak_dir(tmp_path: Path) -> Path: + fp = tmp_path / "leak.txt" + fp.write_text(_line("aws_key", _FAKE_AWS) + "\n") + return tmp_path + + +def test_text_output_no_raw_secret(leak_dir: Path): + stdout, stderr = _run_cli([str(leak_dir)]) + assert _FAKE_AWS not in stdout + stderr + + +def test_json_output_no_raw_secret(leak_dir: Path): + stdout, stderr = _run_cli([str(leak_dir), "--json"]) + assert _FAKE_AWS not in stdout + stderr + + +def test_sarif_output_no_raw_secret(leak_dir: Path): + stdout, stderr = _run_cli([str(leak_dir), "--format", "sarif"]) + assert _FAKE_AWS not in stdout + stderr + + +def test_json_output_includes_extraction_fields(leak_dir: Path): + stdout, _ = _run_cli([str(leak_dir), "--json"]) + parsed = json.loads(stdout) + findings = parsed["results"] + assert findings + m = findings[0]["matches"][0] + assert m["pattern"]["confidence"] + assert m["remediation"] + assert re.match(r"^[0-9a-f]{16}$", m["fingerprint"]) diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index 94d12756..1ce0a245 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -323,13 +323,16 @@ When `--json` is passed, output is a JSON object to stdout with a `results` arra "matches": [ { "pattern": { - "name": "AWS Access Key", + "name": "AWS Access Key ID", "severity": "critical", - "description": "Detects AWS access key IDs" + "confidence": "high", + "description": "AWS Access Key ID detected" }, "line": 42, "column": 7, - "redacted": "AKIA************MPLE" + "redacted": "AKIA************MPLE", + "fingerprint": "9a8b7c6d5e4f3a2b", + "remediation": "Rotate the credential in the provider's console immediately. Reference via env var or a secret manager. Even after removing this from your working tree, git history still contains the secret — rotation is mandatory." } ] } @@ -353,13 +356,29 @@ When `--json` is passed, output is a JSON object to stdout with a `results` arra | `results[].file` | string | Absolute path to the scanned file | | `results[].matches` | array | List of secret matches in this file | | `results[].matches[].pattern.name` | string | Human-readable pattern name | -| `results[].matches[].pattern.severity` | string | `"low"`, `"medium"`, `"high"`, or `"critical"` | +| `results[].matches[].pattern.severity` | string | `"low"`, `"medium"`, `"high"`, or `"critical"` — blast radius if exploited | +| `results[].matches[].pattern.confidence` | string | `"low"`, `"medium"`, or `"high"` — how sure we are this is a real secret (vs. a false positive) | | `results[].matches[].pattern.description` | string | Pattern description (may be empty) | | `results[].matches[].line` | number\|null | 1-based line number, null if unknown | | `results[].matches[].column` | number\|null | 1-based column number, null if unknown | | `results[].matches[].redacted` | string | Redacted secret value (first/last 4 chars visible for values >8 chars, fully masked otherwise) | +| `results[].matches[].fingerprint` | string\|null | 16-char hex sha256 of `(file + ruleName + redacted)` — stable across line moves; use to suppress this exact finding via `.rafterignore` | +| `results[].matches[].remediation` | string\|null | Suggested rotation/storage guidance (e.g. "rotate at provider, move to env var") | -The raw secret value is never included in JSON output. +**The raw secret value is never included in any output surface** (text, JSON, SARIF, audit log, MCP tool responses). Confidence and severity are independent: `confidence` answers "is this a real leak?" and `severity` answers "how bad if it is?". Treat `confidence: low` findings as candidates worth verification, not noise — entropy filters drop most of the obvious noise before they surface. + +#### Suppressions (`.rafterignore`) + +In addition to glob/pattern-name suppressions, `.rafterignore` accepts fingerprint-based suppression: + +``` +# Suppress one specific finding (survives line-number drift) +fingerprint:9a8b7c6d5e4f3a2b + +# Glob-based (existing forms) +tests/fixtures/** +config/example.env:Generic Secret +``` **Why `_note`?** Local scans are pattern-only — they cannot tell whether a finding is in a public-facing file, whether the key is still valid, or whether it ever shipped. Backend scans (`rafter run`) apply agentic context. The `_note` exists so agents and reviewers don't treat local findings as final verdicts — they should investigate each, but the absence of agentic triage is *not* an excuse to dismiss findings.