Skip to content

Commit 0cb56a3

Browse files
authored
Merge pull request #10 from decksoftware/feat/engine-quickwins
feat: phase-9 preflight modules + engine quick-win fixes
2 parents af24057 + a45a5a6 commit 0cb56a3

30 files changed

Lines changed: 3145 additions & 194 deletions

.gitattributes

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Normalize line endings to LF in the repository and on checkout for ALL
2+
# platforms. Without this, GitHub's actions/checkout on windows-latest converts
3+
# text files to CRLF, which makes `prettier --check` (default endOfLine: lf)
4+
# fail in CI even though the committed blobs are LF. Forcing eol=lf keeps the
5+
# lint/format gate consistent across Windows, macOS, and Linux.
6+
* text=auto eol=lf
7+
8+
# Windows-only script types keep CRLF (none are currently committed, but this
9+
# avoids surprising future contributors).
10+
*.bat text eol=crlf
11+
*.cmd text eol=crlf
12+
*.ps1 text eol=crlf
13+
14+
# Binary assets must never be line-ending normalized.
15+
*.png binary
16+
*.jpg binary
17+
*.jpeg binary
18+
*.gif binary
19+
*.ico binary
20+
*.svg binary
21+
*.pdf binary
22+
*.woff binary
23+
*.woff2 binary
24+
*.ttf binary
25+
*.eot binary

.gitguardian.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
version: 2
2+
3+
# CSReview is a security scanner, so its test corpus and detector test fixtures
4+
# intentionally contain fake/example credentials (e.g. AWS's canonical
5+
# documentation key) used to verify detection. These are not real secrets.
6+
secret:
7+
ignored-paths:
8+
- 'csreview/test/**'
9+
ignored-matches:
10+
- name: AWS documentation example access key (not a real credential)
11+
match: AKIAIOSFODNN7EXAMPLE

README.md

Lines changed: 23 additions & 20 deletions
Large diffs are not rendered by default.

csreview/SKILL.md

Lines changed: 22 additions & 19 deletions
Large diffs are not rendered by default.

csreview/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"node": ">=18"
1212
},
1313
"scripts": {
14-
"test": "node --test test/analysis.test.js",
14+
"test": "node --test",
1515
"lint": "eslint . && prettier --check \"src/**/*.js\" \"test/**/*.js\" \"*.js\" \"*.json\"",
1616
"format": "prettier --write \"src/**/*.js\" \"test/**/*.js\" \"*.js\" \"*.json\"",
1717
"typecheck": "tsc --noEmit --project jsconfig.json",
@@ -90,6 +90,15 @@
9090
"Install pnpm"
9191
],
9292
"verify": "pnpm --version"
93+
},
94+
{
95+
"name": "bun audit",
96+
"purpose": "bun dependency vulnerability scanning selected when bun.lockb or bun.lock exists",
97+
"required": false,
98+
"install": [
99+
"Install Bun (https://bun.sh)"
100+
],
101+
"verify": "bun --version"
93102
}
94103
]
95104
},

csreview/src/baseline.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// @ts-check
2+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3+
import { dirname } from 'path';
4+
5+
/**
6+
* `--baseline` support.
7+
*
8+
* A baseline is a JSON file of finding fingerprints that are already known and
9+
* accepted. On later runs, baselined findings are suppressed so CI only fails
10+
* on NEW findings. Fingerprints are intentionally line-independent so they
11+
* survive code shifting up/down. This module is read-only with respect to the
12+
* audited project; the only file it writes is the baseline file the user
13+
* explicitly asked for via --update-baseline.
14+
*/
15+
16+
const BASELINE_VERSION = 1;
17+
const DEFAULT_BASELINE_FILE = '.csreview-baseline.json';
18+
19+
function normalizeKeyPart(value) {
20+
return String(value || '')
21+
.trim()
22+
.toLowerCase()
23+
.replace(/\\/g, '/')
24+
.replace(/\s+/g, ' ');
25+
}
26+
27+
function canonicalCwe(cwe) {
28+
const match = String(cwe || '').match(/CWE-\d+/i);
29+
return match ? match[0].toUpperCase() : '';
30+
}
31+
32+
/**
33+
* Stable, line-independent fingerprint for a finding. Two findings of the same
34+
* class in the same file collapse to one baseline entry on purpose: baselining
35+
* a class of issue in a file accepts that class in that file.
36+
*
37+
* @param {Record<string, any>} finding
38+
* @returns {string}
39+
*/
40+
export function fingerprintFinding(finding) {
41+
const file = normalizeKeyPart(finding?.file);
42+
const cwe = canonicalCwe(finding?.cwe);
43+
const category = normalizeKeyPart(finding?.category);
44+
const name = normalizeKeyPart(finding?.name);
45+
return [file, cwe || category, name].join('|');
46+
}
47+
48+
/**
49+
* Read a baseline file into a Set of fingerprints (fail-open: returns an empty
50+
* Set when the file is absent or invalid, so a first run with --baseline simply
51+
* treats every finding as new).
52+
*
53+
* @param {string} filePath
54+
* @returns {Set<string>}
55+
*/
56+
export function loadBaseline(filePath) {
57+
try {
58+
if (!filePath || !existsSync(filePath)) {
59+
return new Set();
60+
}
61+
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
62+
const list = Array.isArray(parsed) ? parsed : parsed?.fingerprints;
63+
if (!Array.isArray(list)) return new Set();
64+
return new Set(list.map((value) => String(value)));
65+
} catch {
66+
return new Set();
67+
}
68+
}
69+
70+
/**
71+
* Partition findings into new vs baselined.
72+
*
73+
* @param {Array<Record<string, any>>} findings
74+
* @param {Set<string>} baselineSet
75+
* @returns {{newFindings: Array<object>, baselined: Array<object>}}
76+
*/
77+
export function applyBaseline(findings = [], baselineSet = new Set()) {
78+
const newFindings = [];
79+
const baselined = [];
80+
for (const finding of findings || []) {
81+
if (finding && baselineSet.has(fingerprintFinding(finding))) {
82+
baselined.push(finding);
83+
} else {
84+
newFindings.push(finding);
85+
}
86+
}
87+
return { newFindings, baselined };
88+
}
89+
90+
/**
91+
* Serialize findings into a baseline document. Deterministic (no timestamp) so
92+
* regenerating an unchanged project produces an identical file.
93+
*
94+
* @param {Array<Record<string, any>>} findings
95+
* @returns {{version: number, tool: string, fingerprints: string[], entries: Array<object>}}
96+
*/
97+
export function serializeBaseline(findings = []) {
98+
const byFingerprint = new Map();
99+
for (const finding of findings || []) {
100+
if (!finding) continue;
101+
const fingerprint = fingerprintFinding(finding);
102+
if (!byFingerprint.has(fingerprint)) {
103+
byFingerprint.set(fingerprint, {
104+
fingerprint,
105+
severity: finding.severity || 'INFO',
106+
file: String(finding.file || ''),
107+
name: String(finding.name || ''),
108+
});
109+
}
110+
}
111+
const entries = [...byFingerprint.values()].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint));
112+
return {
113+
version: BASELINE_VERSION,
114+
tool: 'csreview',
115+
fingerprints: entries.map((entry) => entry.fingerprint),
116+
entries,
117+
};
118+
}
119+
120+
/**
121+
* Write a baseline file for the given findings, creating parent dirs as needed.
122+
*
123+
* @param {string} filePath
124+
* @param {Array<Record<string, any>>} findings
125+
* @returns {string}
126+
*/
127+
export function writeBaseline(filePath, findings = []) {
128+
// INVARIANT: filePath must originate from a trusted source (the user's
129+
// --baseline/--update-baseline argv), never from audited project content.
130+
// This is the only function in CSReview that writes outside csreview-reports/,
131+
// so a caller that sourced the path from scanned files would enable an
132+
// arbitrary file write. Do not wire untrusted input here.
133+
const dir = dirname(filePath);
134+
if (dir && !existsSync(dir)) {
135+
mkdirSync(dir, { recursive: true });
136+
}
137+
writeFileSync(filePath, `${JSON.stringify(serializeBaseline(findings), null, 2)}\n`, 'utf8');
138+
return filePath;
139+
}
140+
141+
export { BASELINE_VERSION, DEFAULT_BASELINE_FILE };

0 commit comments

Comments
 (0)