Skip to content

Commit ba352ae

Browse files
dev-ecd-dmclaude
andcommitted
fix(detector): calibrate WEAK_CIPHER, unique IDs, downgrade non-source findings
User feedback: the internal detector "shouted fire because it saw the word match in the dictionary." On a real project it flagged args.includes('--no-update-check') and ids.includes('postgres') as CRITICAL Weak Cipher, because the WEAK_CIPHER regex matched the "des" substring in inclu-des / exclu-des / mo-des (the bare (?:DES|RC4|Blowfish|ECB)\b alternative: case-insensitive, no leading boundary). 150 of 192 detector findings on csreview's own code were this FP, cratering the score to 0/100. - WEAK_CIPHER is now context-bound: only real crypto APIs (createCipheriv, Cipher.getInstance, CryptoJS.*, <Algo>.new(), MODE_ECB, algorithm/cipher config) with proper word boundaries. Never a bare cipher-name substring. - Deterministic, globally-unique finding IDs (the old ${id}_${index} scheme collided across files, e.g. WEAK_CIPHER_0 in every file). - Findings in non-source paths (tests/fixtures/examples/docs) are downgraded to LOW + confidence LOW so intentional test vectors and example keys do not crater the score; they stay visible rather than hidden. Effect on csreview's own tree: 192 -> 51 detector findings, 0 duplicate IDs, the .includes() false positives gone. Recall preserved (real weak-cipher usage still flagged). Remaining src CRITICAL/HIGH are csreview matching its own rule definitions in detector.js (only when scanning csreview itself). Tests: +4 calibration + 3 negative-corpus guards. 175/175 - lint clean - typecheck 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 38702ec commit ba352ae

3 files changed

Lines changed: 134 additions & 1 deletion

File tree

csreview/src/detector.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -692,8 +692,12 @@ const VULNERABILITY_PATTERNS = [
692692
category: 'Cryptography',
693693
name: 'Weak Cipher Algorithm',
694694
description: 'DES, RC4, Blowfish, or ECB mode.',
695+
// Context-bound: only real crypto APIs (createCipheriv, Cipher.getInstance,
696+
// CryptoJS.*, <Algo>.new(), MODE_ECB, algorithm/cipher config). Never a bare
697+
// "des"/"rc4"/"ecb" substring — the old `(?:DES|RC4|Blowfish|ECB)\b` (case-
698+
// insensitive, no leading boundary) matched inclu*des*/exclu*des*/mo*des*.
695699
regex:
696-
/(?:createCipheriv?\s*\(\s*['"](?:des|rc4|bf|blowfish|ecb)|(?:DES|RC4|Blowfish|ECB)\b|(?:Cipher|cipher)\s*\(\s*['"](?:des|rc4|bf))/gi,
700+
/(?:createCipher(?:iv)?\s*\(\s*['"`](?:des|3des|rc4|rc2|bf|blowfish)\b|createCipheriv\s*\(\s*['"`][a-z0-9-]*-ecb\b|Cipher\.getInstance\s*\(\s*['"`][^'"`]*(?:\bDES|\bRC4|\bRC2|\bBlowfish|\bECB)|CryptoJS\.(?:DES|TripleDES|RC4|RC2|Blowfish)\b|\b(?:DES3?|ARC4|RC4|RC2|Blowfish)\.new\s*\(|\bMODE_ECB\b|(?:algorithm|cipher)\s*[:=]\s*['"`](?:des|3des|rc4|rc2|bf|blowfish)\b)/gi,
697701
cwe: 'CWE-327',
698702
owasp: 'A02:2021-Cryptographic Failures',
699703
fix: 'Use AES-256-GCM or ChaCha20-Poly1305.',
@@ -1660,6 +1664,29 @@ export function detectVulnerabilities(projectInfo) {
16601664
allFindings.push(...vulnFindings);
16611665
}
16621666

1667+
// Post-process the merged set:
1668+
// 1) Deterministic, globally-unique IDs. The per-file `${id}_${index}` scheme
1669+
// collided across files (WEAK_CIPHER_0 appeared in every file).
1670+
// 2) Downgrade findings in non-source paths (tests/fixtures/examples/docs):
1671+
// intentional test vectors and example keys should not crater the score.
1672+
// They stay visible at LOW rather than being hidden entirely.
1673+
const idCounts = new Map();
1674+
for (const finding of allFindings) {
1675+
const base = String(finding.id).replace(/_\d+$/, '');
1676+
const slug = String(finding.file || 'unknown')
1677+
.replace(/[^a-zA-Z0-9]+/g, '-')
1678+
.replace(/^-+|-+$/g, '');
1679+
const key = `${base}_${slug}_${finding.line}`;
1680+
const seen = idCounts.get(key) || 0;
1681+
idCounts.set(key, seen + 1);
1682+
finding.id = seen > 0 ? `${key}_${seen}` : key;
1683+
1684+
if (finding.severity !== 'INFO' && isNonSourcePath(finding.file)) {
1685+
finding.severity = 'LOW';
1686+
finding.confidence = 'LOW';
1687+
}
1688+
}
1689+
16631690
allFindings.sort((a, b) => {
16641691
const sa = SEVERITY_ORDER[a.severity] ?? 99;
16651692
const sb = SEVERITY_ORDER[b.severity] ?? 99;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// @ts-check
2+
import test from 'node:test';
3+
import assert from 'node:assert/strict';
4+
import fs from 'node:fs';
5+
import os from 'node:os';
6+
import path from 'node:path';
7+
import { detectVulnerabilities } from '../src/detector.js';
8+
9+
// Calibration guards driven by real user feedback: the internal detector was
10+
// "shouting fire because it saw the word match in the dictionary" — WEAK_CIPHER
11+
// matched the "des" substring inside includes/excludes/modes, IDs collided
12+
// (WEAK_CIPHER_0 repeated), and test fixtures cratered the score.
13+
14+
function scan(files) {
15+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'csreview-calib-'));
16+
for (const f of files) {
17+
const abs = path.join(root, f.path);
18+
fs.mkdirSync(path.dirname(abs), { recursive: true });
19+
fs.writeFileSync(abs, f.code, 'utf8');
20+
}
21+
return detectVulnerabilities({
22+
root,
23+
files: files.map((f) => ({ path: f.path, language: f.language || 'javascript', kind: f.kind || 'source' })),
24+
});
25+
}
26+
27+
test('WEAK_CIPHER does not fire on the "des" substring in includes/excludes/modes', () => {
28+
const findings = scan([
29+
{ path: 'src/a.js', code: "export const f = (args) => args.includes('--no-update-check');\n" },
30+
{ path: 'src/b.js', code: "export const ok = (ids) => ids.includes('postgres');\n" },
31+
{ path: 'src/c.js', code: 'export const modes = (x) => x.excludes && x.decodes;\n' },
32+
{ path: 'src/d.js', code: 'export const nodes = ["a"];\nexport const provides = true;\n' },
33+
]);
34+
const wc = findings.filter((f) => String(f.id).startsWith('WEAK_CIPHER'));
35+
assert.equal(wc.length, 0, `unexpected WEAK_CIPHER FPs: ${wc.map((f) => `${f.file}:${f.line}`).join(', ')}`);
36+
});
37+
38+
test('WEAK_CIPHER still detects real weak-cipher usage (recall preserved)', () => {
39+
const findings = scan([
40+
{
41+
path: 'src/n.js',
42+
code: 'import crypto from "crypto";\nexport const e = (k, iv) => crypto.createCipheriv("des-ede3-cbc", k, iv);\n',
43+
},
44+
{
45+
path: 'src/cjs.js',
46+
code: 'import CryptoJS from "crypto-js";\nexport const e = (d) => CryptoJS.DES.encrypt(d, "key");\n',
47+
},
48+
{
49+
path: 'src/ecb.js',
50+
code: 'import crypto from "crypto";\nexport const e = (k, iv) => crypto.createCipheriv("aes-128-ecb", k, iv);\n',
51+
},
52+
{
53+
path: 'src/J.java',
54+
language: 'java',
55+
code: 'class A { void f() throws Exception { Cipher.getInstance("DES"); } }\n',
56+
},
57+
]);
58+
const wc = findings.filter((f) => f.cwe === 'CWE-327');
59+
assert.ok(wc.length >= 4, `expected >= 4 weak-cipher detections, got ${wc.length}`);
60+
});
61+
62+
test('detector assigns globally-unique finding IDs across files (no WEAK_CIPHER_0 collision)', () => {
63+
const findings = scan([
64+
{
65+
path: 'src/a.js',
66+
code: 'import crypto from "crypto";\nexport const e = (k, iv) => crypto.createCipheriv("rc4", k, iv);\n',
67+
},
68+
{
69+
path: 'src/b.js',
70+
code: 'import crypto from "crypto";\nexport const e = (k, iv) => crypto.createCipheriv("rc4", k, iv);\n',
71+
},
72+
]);
73+
const ids = findings.map((f) => f.id);
74+
assert.equal(new Set(ids).size, ids.length, `duplicate IDs present: ${ids.join(', ')}`);
75+
});
76+
77+
test('findings in non-source paths (test/fixtures) are downgraded; real source keeps its severity', () => {
78+
const srcFindings = scan([{ path: 'src/x.js', code: 'export const key = "AKIAIOSFODNN7EXAMPLE";\n' }]);
79+
const testFindings = scan([{ path: 'test/x.test.js', code: 'export const key = "AKIAIOSFODNN7EXAMPLE";\n' }]);
80+
const inSrc = srcFindings.find((f) => String(f.id).startsWith('AWS_ACCESS_KEY'));
81+
const inTest = testFindings.find((f) => String(f.id).startsWith('AWS_ACCESS_KEY'));
82+
assert.ok(inSrc && inTest, 'AWS key detected in both src and test');
83+
assert.equal(inSrc.severity, 'CRITICAL', 'real source finding keeps full severity');
84+
assert.equal(inTest.severity, 'LOW', 'test/fixture finding is downgraded to LOW');
85+
assert.equal(inTest.confidence, 'LOW');
86+
});

csreview/test/detector-precision.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,26 @@ const NEGATIVES = [
153153
code: 'import crypto from "crypto";\nexport const token = () => crypto.randomBytes(32).toString("hex");\n',
154154
},
155155
{ name: 'plain', file: 'src/n10.js', language: 'javascript', code: 'export const x = (a, b) => compute(a, b);\n' },
156+
// WEAK_CIPHER must not fire on the "des" substring inside includes/excludes/modes
157+
// (the reported false positives: args.includes('--no-update-check'), ids.includes('postgres')).
158+
{
159+
name: 'includes-not-cipher',
160+
file: 'src/n11.js',
161+
language: 'javascript',
162+
code: "export const f = (args) => args.includes('--no-update-check');\n",
163+
},
164+
{
165+
name: 'includes-postgres',
166+
file: 'src/n12.js',
167+
language: 'javascript',
168+
code: "export const ok = (ids) => ids.includes('postgres');\n",
169+
},
170+
{
171+
name: 'excludes-modes',
172+
file: 'src/n13.js',
173+
language: 'javascript',
174+
code: 'export const modes = ["a"];\nexport const g = (x) => x.excludes;\n',
175+
},
156176
];
157177

158178
function runOne(sample) {

0 commit comments

Comments
 (0)