Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ Serve the example remote bundle locally (`examples/rules/README.md`):
npx --yes serve examples/rules -l 8765
```

Remote rule bundles are cached under `.codefence/cache/secret-rules/` for offline and low-latency scans.
Remote rule bundles are cached under `.codefence/cache/secret-rules/` for offline and low-latency scans. Use `--secret-rules-refresh` or `CODEFENCE_SECRET_RULES_REFRESH=1` to force a re-download before scanning.

**Environment:** `CODEFENCE_ASPECTS`, `CODEFENCE_ONLY`, `CODEFENCE_SKIP`, `CODEFENCE_SECRET_RULES`, `CODEFENCE_SECRET_DEFAULT_RULES`, `CODEFENCE_SECRET_DEFAULT_RULES_VERSION`, `CODEFENCE_SECRET_RULES_UPDATE_URL`, `CODEFENCE_SECRET_RULES_CACHE_TTL`, `CODEFENCE_SECRET_ENTROPY_THRESHOLD`, `CODEFENCE_SECRET_MIN_LENGTH`, `CODEFENCE_SECRET_MIN_CONFIDENCE`.
**Environment:** `CODEFENCE_ASPECTS`, `CODEFENCE_ONLY`, `CODEFENCE_SKIP`, `CODEFENCE_SECRET_RULES`, `CODEFENCE_SECRET_DEFAULT_RULES`, `CODEFENCE_SECRET_DEFAULT_RULES_VERSION`, `CODEFENCE_SECRET_RULES_UPDATE_URL`, `CODEFENCE_SECRET_RULES_REFRESH`, `CODEFENCE_SECRET_RULES_CACHE_TTL`, `CODEFENCE_SECRET_ENTROPY_THRESHOLD`, `CODEFENCE_SECRET_MIN_LENGTH`, `CODEFENCE_SECRET_MIN_CONFIDENCE`.

## Git pre-commit and background scanning

Expand Down
2 changes: 2 additions & 0 deletions rules/secret/builtin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ rules:
severity: ERROR
metadata:
confidence: medium
case-insensitive: true
remediation: Do not commit passwords; use environment variables or a secret manager instead.
pattern-regex: '(?:password|passwd|pwd)\s*[:=]\s*["''][^"''\n]{8,}["'']'

Expand All @@ -70,5 +71,6 @@ rules:
severity: ERROR
metadata:
confidence: medium
case-insensitive: true
remediation: Replace embedded credentials with runtime-configured secrets.
pattern-regex: '(?:api[_-]?key|secret|token|access[_-]?token|client[_-]?secret)\s*[:=]\s*["''][A-Za-z0-9_\-+/=]{12,}["'']'
1 change: 1 addition & 0 deletions src/scan/parseOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ Environment:
CODEFENCE_SECRET_DEFAULT_RULES Same as --secret-default-rules
CODEFENCE_SECRET_DEFAULT_RULES_VERSION Same as --secret-default-rules-version
CODEFENCE_SECRET_RULES_UPDATE_URL Same as --secret-rules-update-url
CODEFENCE_SECRET_RULES_REFRESH Same as --secret-rules-refresh
CODEFENCE_SECRET_RULES_CACHE_TTL Same as --secret-rules-cache-ttl
CODEFENCE_SECRET_ENTROPY_THRESHOLD Same as --secret-entropy-threshold
CODEFENCE_SECRET_MIN_LENGTH Same as --secret-min-length
Expand Down
13 changes: 9 additions & 4 deletions src/scan/secret/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,25 @@ export function readCachedSecretRules(workspace: string, url: string): CachedSec
}
}

export function isSecretRulesCacheFresh(entry: CachedSecretRules, now = Date.now()): boolean {
return new Date(entry.fetchedAt).getTime() + entry.ttlMs > now;
export function isSecretRulesCacheFresh(
entry: CachedSecretRules,
ttlMs: number,
now = Date.now()
): boolean {
return new Date(entry.fetchedAt).getTime() + ttlMs > now;
}

export function writeCachedSecretRules(
workspace: string,
url: string,
body: string,
ttlMs: number
ttlMs: number,
options?: { fetchedAt?: string }
): CachedSecretRules {
const entry: CachedSecretRules = {
version: 1,
url,
fetchedAt: new Date().toISOString(),
fetchedAt: options?.fetchedAt ?? new Date().toISOString(),
ttlMs,
sha256: hashContent(body),
body
Expand Down
118 changes: 88 additions & 30 deletions src/scan/secret/engine.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { ConfidenceLevel, Finding } from "../../types";
import { confidenceWeight } from "./config";
import { findEntropySecrets } from "./entropy";
import { loadSecretRules } from "./ruleLoader";
import { loadSecretRulesForScan } from "./rulesCache";
import { SecretEngineInput, SecretRule } from "./types";

function ruleRegex(pattern: string): RegExp {
return new RegExp(pattern, "gi");
function ruleRegex(pattern: string, caseInsensitive = false): RegExp {
return new RegExp(pattern, caseInsensitive ? "gi" : "g");
}

function summarizeMatch(match: string): string {
Expand Down Expand Up @@ -37,7 +37,7 @@ function buildRuleFindings(filePath: string, lines: string[], rules: SecretRule[
continue;
}

const regex = ruleRegex(pattern.value);
const regex = ruleRegex(pattern.value, pattern.caseInsensitive);
const match = regex.exec(line);
if (!match) {
continue;
Expand Down Expand Up @@ -66,6 +66,18 @@ function findingKey(finding: Finding): string {
return `${finding.filePath}:${finding.line}:${finding.ruleId}:${finding.message}`;
}

function locationKey(finding: Finding): string {
return `${finding.filePath}:${finding.line}`;
}

function isRuleBasedSecret(finding: Finding): boolean {
return finding.kind === "secret" && finding.ruleId !== "secret-high-entropy";
}

function isEntropySecret(finding: Finding): boolean {
return finding.kind === "secret" && finding.ruleId === "secret-high-entropy";
}

function strongerConfidence(a: ConfidenceLevel | undefined, b: ConfidenceLevel | undefined): ConfidenceLevel {
const left = a ?? "low";
const right = b ?? "low";
Expand All @@ -80,49 +92,95 @@ function strongerSeverity(
return weights[left] >= weights[right] ? left : right;
}

function combineEvidence(left?: string, right?: string): string | undefined {
const parts = [left, right].filter((value): value is string => Boolean(value));
return parts.length > 0 ? parts.join("; ") : undefined;
}

function combineSecretFindings(left: Finding, right: Finding): Finding {
const leftIsEntropy = isEntropySecret(left);
const rightIsEntropy = isEntropySecret(right);
const base = leftIsEntropy && !rightIsEntropy ? right : left;
const extra = base === left ? right : left;

let detectionMethod = base.detectionMethod ?? "rule";
if (leftIsEntropy !== rightIsEntropy) {
detectionMethod = "rule+entropy";
} else if (detectionMethod === (extra.detectionMethod ?? detectionMethod)) {
detectionMethod = base.detectionMethod ?? extra.detectionMethod ?? "rule";
} else {
detectionMethod = "rule+entropy";
}

return {
...base,
severity: strongerSeverity(base.severity, extra.severity),
confidence: strongerConfidence(base.confidence, extra.confidence),
evidence: combineEvidence(base.evidence, extra.evidence),
remediation: base.remediation ?? extra.remediation,
detectionMethod
};
}

function combineRuleWithEntropy(rule: Finding, entropy: Finding): Finding {
return {
...rule,
severity: strongerSeverity(rule.severity, entropy.severity),
confidence: strongerConfidence(rule.confidence, entropy.confidence),
evidence: combineEvidence(rule.evidence, entropy.evidence),
remediation: rule.remediation ?? entropy.remediation,
detectionMethod: "rule+entropy"
};
}

function mergeFindings(findings: Finding[]): Finding[] {
const ruleHitLocations = new Set<string>();
for (const finding of findings) {
if (isRuleBasedSecret(finding)) {
ruleHitLocations.add(locationKey(finding));
}
}

const entropyByLocation = new Map<string, Finding>();
for (const finding of findings) {
if (!isEntropySecret(finding)) {
continue;
}
const loc = locationKey(finding);
const existing = entropyByLocation.get(loc);
entropyByLocation.set(loc, existing ? combineSecretFindings(existing, finding) : finding);
}

const merged = new Map<string, Finding>();

for (const finding of findings) {
if (
finding.ruleId === "secret-high-entropy" &&
findings.some(
(other) =>
other !== finding &&
other.kind === "secret" &&
other.ruleId !== "secret-high-entropy" &&
other.filePath === finding.filePath &&
other.line === finding.line
)
) {
if (isEntropySecret(finding) && ruleHitLocations.has(locationKey(finding))) {
continue;
}

const key = findingKey(finding);
let candidate = finding;
const existing = merged.get(key);
if (!existing) {
merged.set(key, finding);
continue;
if (existing) {
candidate = combineSecretFindings(existing, candidate);
}

if (isRuleBasedSecret(candidate)) {
const entropy = entropyByLocation.get(locationKey(candidate));
if (entropy) {
candidate = combineRuleWithEntropy(candidate, entropy);
}
}

merged.set(key, {
...existing,
severity: strongerSeverity(existing.severity, finding.severity),
confidence: strongerConfidence(existing.confidence, finding.confidence),
evidence: existing.evidence ?? finding.evidence,
remediation: existing.remediation ?? finding.remediation,
detectionMethod:
existing.detectionMethod === finding.detectionMethod
? existing.detectionMethod
: "rule+entropy"
});
merged.set(key, candidate);
}

return [...merged.values()];
}

export async function scanSecretFindings(input: SecretEngineInput): Promise<Finding[]> {
const rules = await loadSecretRules(input.workspace, input.options);
const rules =
input.rules ?? (await loadSecretRulesForScan(input.workspace, input.options));
const lines = input.content.split(/\r?\n/);
const ruleFindings = buildRuleFindings(input.filePath, lines, rules);
const entropyFindings = findEntropySecrets(input.filePath, lines, input.options);
Expand Down
13 changes: 11 additions & 2 deletions src/scan/secret/remoteRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ function requestRuleBundle(
return Promise.reject(new Error("Too many redirects while downloading secret rules"));
}

try {
validateRulesUrl(url);
} catch (error) {
return Promise.reject(error);
}

return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === "https:" ? https : http;
Expand All @@ -42,7 +48,7 @@ function requestRuleBundle(
if (statusCode >= 300 && statusCode < 400 && res.headers.location) {
res.resume();
const nextUrl = new URL(res.headers.location, url).href;
resolve(requestRuleBundle(nextUrl, redirectDepth + 1));
requestRuleBundle(nextUrl, redirectDepth + 1).then(resolve, reject);
return;
}

Expand Down Expand Up @@ -72,7 +78,10 @@ export async function loadRemoteRuleBundle(
validateRulesUrl(url);

const cached = readCachedSecretRules(workspace, url);
if (!refresh && cached && isSecretRulesCacheFresh(cached)) {
if (!refresh && cached && isSecretRulesCacheFresh(cached, ttlMs)) {
if (cached.ttlMs !== ttlMs) {
writeCachedSecretRules(workspace, url, cached.body, ttlMs, { fetchedAt: cached.fetchedAt });
}
return cached.body;
}

Expand Down
47 changes: 47 additions & 0 deletions src/scan/secret/rulesCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import path from "node:path";
import { loadSecretRules } from "./ruleLoader";
import { SecretRule, SecretScanOptions } from "./types";

const rulesByScan = new Map<string, Promise<SecretRule[]>>();

function secretScanCacheKey(workspace: string, options: SecretScanOptions): string {
return JSON.stringify({
workspace: path.resolve(workspace),
rulePaths: [...options.rulePaths].sort(),
defaultRules: options.defaultRules,
defaultRulesVersion: options.defaultRulesVersion,
rulesUpdateUrl: options.rulesUpdateUrl,
rulesRefresh: options.rulesRefresh,
rulesCacheTtlMs: options.rulesCacheTtlMs,
entropyThreshold: options.entropyThreshold,
minLength: options.minLength,
minConfidence: options.minConfidence
});
}

/** Clears in-memory secret rule memoization (for tests). */
export function clearSecretRulesScanCache(): void {
rulesByScan.clear();
}

/**
* Load secret rules once per (workspace, options) for a scan invocation.
* Concurrent callers share the same in-flight promise.
*/
export function loadSecretRulesForScan(
workspace: string,
options: SecretScanOptions
): Promise<SecretRule[]> {
const key = secretScanCacheKey(workspace, options);
const cached = rulesByScan.get(key);
if (cached) {
return cached;
}

const pending = loadSecretRules(workspace, options);
rulesByScan.set(key, pending);
return pending.catch((error) => {
rulesByScan.delete(key);
throw error;
});
}
4 changes: 4 additions & 0 deletions src/scan/secret/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface SecretScanOptions {
export interface SecretRulePattern {
type: "regex" | "literal";
value: string;
/** When true, compile pattern-regex with case-insensitive matching (Semgrep default is sensitive). */
caseInsensitive?: boolean;
}

export interface SecretRule {
Expand All @@ -39,4 +41,6 @@ export interface SecretEngineInput {
content: string;
workspace: string;
options: SecretScanOptions;
/** When set, skips reloading rule bundles for each file in a batch scan. */
rules?: SecretRule[];
}
28 changes: 23 additions & 5 deletions src/scan/secret/yamlRuleParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,25 @@ function normalizeConfidence(value: unknown): ConfidenceLevel {
return "medium";
}

function collectPatterns(node: unknown): SecretRulePattern[] {
function parseCaseInsensitiveFlag(value: unknown): boolean {
return value === true || value === "true";
}

function ruleCaseInsensitive(rule: Record<string, unknown>, metadata: Record<string, unknown>): boolean {
const options = (rule.options ?? {}) as Record<string, unknown>;
if (parseCaseInsensitiveFlag(options.generic_caseless)) {
return true;
}
if (options.case_sensitive === false) {
return true;
}
return (
parseCaseInsensitiveFlag(metadata["case-insensitive"]) ||
parseCaseInsensitiveFlag(metadata.case_insensitive)
);
}

function collectPatterns(node: unknown, caseInsensitive = false): SecretRulePattern[] {
if (!node || typeof node !== "object") {
return [];
}
Expand All @@ -33,7 +51,7 @@ function collectPatterns(node: unknown): SecretRulePattern[] {
const patterns: SecretRulePattern[] = [];

if (typeof entry["pattern-regex"] === "string") {
patterns.push({ type: "regex", value: entry["pattern-regex"] });
patterns.push({ type: "regex", value: entry["pattern-regex"], caseInsensitive });
}

if (typeof entry.pattern === "string") {
Expand All @@ -42,13 +60,13 @@ function collectPatterns(node: unknown): SecretRulePattern[] {

if (Array.isArray(entry.patterns)) {
for (const child of entry.patterns) {
patterns.push(...collectPatterns(child));
patterns.push(...collectPatterns(child, caseInsensitive));
}
}

if (Array.isArray(entry["pattern-either"])) {
for (const child of entry["pattern-either"]) {
patterns.push(...collectPatterns(child));
patterns.push(...collectPatterns(child, caseInsensitive));
}
}

Expand All @@ -72,7 +90,7 @@ function parseRuleObject(
}

const metadata = (rule.metadata ?? {}) as Record<string, unknown>;
const patterns = collectPatterns(rule);
const patterns = collectPatterns(rule, ruleCaseInsensitive(rule, metadata));
if (patterns.length === 0) {
return null;
}
Expand Down
Loading
Loading