Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions node/resources/skills/rafter/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scan-id>`
Expand Down
44 changes: 38 additions & 6 deletions node/src/commands/agent/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { id: string; name: string; shortDescription: string }>();
interface SarifRule {
id: string;
name: string;
shortDescription: { text: string };
help?: { text: string };
properties?: { confidence?: string };
}
const rules = new Map<string, SarifRule>();
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,
Expand All @@ -197,6 +211,10 @@ function outputSarif(results: ScanResult[]): void {
},
},
],
properties: {
confidence: m.pattern.confidence ?? "high",
fingerprint: m.fingerprint,
},
});
}
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
}
}
Expand Down
6 changes: 6 additions & 0 deletions node/src/commands/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;
}

Expand All @@ -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,
})),
}));
}
Expand Down
19 changes: 18 additions & 1 deletion node/src/core/custom-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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 });
Expand All @@ -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;
Expand Down
59 changes: 55 additions & 4 deletions node/src/core/pattern-engine.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,13 +18,47 @@ export interface PatternMatch {
line?: number;
column?: number;
redacted?: string;
fingerprint?: string;
entropy?: number;
}

const GENERIC_PATTERN_NAMES = new Set(["Generic API Key", "Generic Secret"]);
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<string, number> = {};
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[];

Expand All @@ -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) {
Expand All @@ -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),
});
}
}
Expand All @@ -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");

Expand All @@ -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),
});
}
}
Expand Down
51 changes: 47 additions & 4 deletions node/src/scanners/gitleaks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
// `<file>:<rule>:<line>` 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
*/
Expand Down
4 changes: 2 additions & 2 deletions node/src/scanners/regex-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading