@holmes-lab/holmes-kit 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/ci-gate.js +3 -1
- package/dist/holmes/cpg/foundation/language-matrix.js +13 -5
- package/dist/holmes/cpg/language-parser-walk.js +5 -1
- package/dist/holmes/cpg/language-parser.d.ts +1 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +17 -2
- package/dist/holmes/hooks/pre-tool-use.js +21 -4
- package/dist/holmes/mcp/handlers.d.ts +17 -5
- package/dist/holmes/mcp/handlers.js +98 -1
- package/dist/holmes/mcp/supervisor.d.ts +24 -0
- package/dist/holmes/mcp/supervisor.js +63 -6
- package/dist/holmes/mcp/tool-schemas.js +8 -1
- package/dist/holmes/project/root.d.ts +1 -0
- package/dist/holmes/project/root.js +30 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +87 -9
- package/dist/holmes/rtm/dataflow-taint.js +5 -1
- package/dist/holmes/rtm/flow-sensitive-taint.d.ts +41 -0
- package/dist/holmes/rtm/flow-sensitive-taint.js +109 -0
- package/dist/holmes/rtm/reaching-def-filter.d.ts +44 -0
- package/dist/holmes/rtm/reaching-def-filter.js +167 -0
- package/dist/holmes/rtm/sink-matching.d.ts +26 -0
- package/dist/holmes/rtm/sink-matching.js +73 -0
- package/dist/holmes/rtm/taint-benchmark.d.ts +13 -0
- package/dist/holmes/rtm/taint-benchmark.js +97 -9
- package/dist/holmes/rtm/taint-vocabulary.d.ts +24 -0
- package/dist/holmes/rtm/taint-vocabulary.js +58 -0
- package/package.json +1 -1
|
@@ -46,11 +46,16 @@ exports.runGradle = runGradle;
|
|
|
46
46
|
exports.runDotnet = runDotnet;
|
|
47
47
|
exports.runGo = runGo;
|
|
48
48
|
exports.runTestScope = runTestScope;
|
|
49
|
+
exports.tailOf = tailOf;
|
|
50
|
+
exports.summarizeJestJson = summarizeJestJson;
|
|
49
51
|
// @implements A-SPEC-102.1
|
|
50
52
|
const node_child_process_1 = require("node:child_process");
|
|
51
53
|
const fs = __importStar(require("node:fs"));
|
|
52
54
|
const os = __importStar(require("node:os"));
|
|
53
55
|
const path = __importStar(require("node:path"));
|
|
56
|
+
// @implements A-SPEC-514.1 — every TEST spawn goes out through a cleaned environment. The
|
|
57
|
+
// `--version` probes above stay as they are: they do not judge anything.
|
|
58
|
+
const root_1 = require("../project/root");
|
|
54
59
|
const python_env_1 = require("./python-env");
|
|
55
60
|
// Runner stdout ceiling. Measured 2026-09-01 on this repo: a full `jest --json` for 289 suites /
|
|
56
61
|
// 4568 tests emits >2MB, and execFileSync's DEFAULT maxBuffer (1MB) kills the child with ENOBUFS —
|
|
@@ -239,8 +244,11 @@ function runJest(files, mode, cwd) {
|
|
|
239
244
|
? [...pre, '--silent', '--json', '--runTestsByPath', '--', ...safeFiles]
|
|
240
245
|
: [...pre, '--silent', '--json'];
|
|
241
246
|
try {
|
|
242
|
-
const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
243
|
-
|
|
247
|
+
const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
248
|
+
// @implements A-SPEC-515.1 — the green path says what happened instead of handing back the
|
|
249
|
+
// whole `--json` document. `tailOf` wraps the result too, so an unexpectedly long summary is
|
|
250
|
+
// still bounded: a cap that any one path can escape is not a cap.
|
|
251
|
+
return { passed: true, tail: tailOf(summarizeJestJson(out) ?? out), executed: parseExecutedCounts(out, cwd) };
|
|
244
252
|
}
|
|
245
253
|
catch (e) {
|
|
246
254
|
const err = e;
|
|
@@ -277,7 +285,7 @@ function runPytest(files, mode, cwd) {
|
|
|
277
285
|
}
|
|
278
286
|
};
|
|
279
287
|
try {
|
|
280
|
-
const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
288
|
+
const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
281
289
|
return { passed: true, tail: tailOf(out), executed: read() };
|
|
282
290
|
}
|
|
283
291
|
catch (e) {
|
|
@@ -332,7 +340,7 @@ function runCargo(files, _mode, cwd, opts) {
|
|
|
332
340
|
let out = '';
|
|
333
341
|
let ok = true;
|
|
334
342
|
try {
|
|
335
|
-
out = (0, node_child_process_1.execFileSync)('cargo', ['test'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
343
|
+
out = (0, node_child_process_1.execFileSync)('cargo', ['test'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
336
344
|
}
|
|
337
345
|
catch (e) {
|
|
338
346
|
const err = e;
|
|
@@ -425,7 +433,7 @@ function runGradle(files, _mode, cwd, opts) {
|
|
|
425
433
|
}
|
|
426
434
|
let ok = true;
|
|
427
435
|
try {
|
|
428
|
-
(0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
436
|
+
(0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
429
437
|
}
|
|
430
438
|
catch {
|
|
431
439
|
ok = false;
|
|
@@ -457,7 +465,7 @@ function runDotnet(files, _mode, cwd, opts) {
|
|
|
457
465
|
const report = path.join(dir, 'junit.xml');
|
|
458
466
|
let ok = true;
|
|
459
467
|
try {
|
|
460
|
-
(0, node_child_process_1.execFileSync)('dotnet', ['test', '--logger', `junit;LogFilePath=${report}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
468
|
+
(0, node_child_process_1.execFileSync)('dotnet', ['test', '--logger', `junit;LogFilePath=${report}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
461
469
|
}
|
|
462
470
|
catch {
|
|
463
471
|
ok = false;
|
|
@@ -504,7 +512,7 @@ function runGo(files, mode, cwd, opts) {
|
|
|
504
512
|
const runDir = (dirArg, creditFiles) => {
|
|
505
513
|
let out = '';
|
|
506
514
|
try {
|
|
507
|
-
out = (0, node_child_process_1.execFileSync)('go', ['test', '-json', dirArg], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
515
|
+
out = (0, node_child_process_1.execFileSync)('go', ['test', '-json', dirArg], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
508
516
|
}
|
|
509
517
|
catch (e) {
|
|
510
518
|
const err = e;
|
|
@@ -617,6 +625,76 @@ function runTestScope(scope, cwd) {
|
|
|
617
625
|
executedByFile: {}, unsupported, ranWith: [],
|
|
618
626
|
};
|
|
619
627
|
}
|
|
620
|
-
|
|
621
|
-
|
|
628
|
+
// @implements A-SPEC-515.1
|
|
629
|
+
/**
|
|
630
|
+
* The last few lines of a runner's output, bounded by SIZE as well as by line count.
|
|
631
|
+
*
|
|
632
|
+
* A line budget is only a size budget while the lines are short, and jest `--json` breaks that
|
|
633
|
+
* assumption completely: it emits one line. Measured 2026-09-02 — a GREEN `test_run` returned
|
|
634
|
+
* 1,432,092 characters, of which this field was 1,421,327 (99.2%) across "6 lines" whose longest
|
|
635
|
+
* was 1,420,959. The usefulness was inverted: a red run gave six clean lines of stderr summary,
|
|
636
|
+
* and a green run gave the whole document.
|
|
637
|
+
*
|
|
638
|
+
* The END is what survives a cut. A runner's conclusion is always at the bottom.
|
|
639
|
+
*/
|
|
640
|
+
const TAIL_MAX_CHARS = 4000;
|
|
641
|
+
function tailOf(s, opts = {}) {
|
|
642
|
+
// The numeric spelling is the one the go adapter uses (`tailOf(err.stderr, 2)`); keeping it means
|
|
643
|
+
// this change cannot silently alter a caller that only ever wanted fewer lines.
|
|
644
|
+
const { lines = 6, maxChars = TAIL_MAX_CHARS } = typeof opts === 'number' ? { lines: opts } : opts;
|
|
645
|
+
const picked = s.trim().split('\n').slice(-lines).join('\n');
|
|
646
|
+
if (picked.length <= maxChars)
|
|
647
|
+
return picked;
|
|
648
|
+
const dropped = picked.length - maxChars;
|
|
649
|
+
return `[…앞부분 ${dropped}자 생략…]\n${picked.slice(-maxChars)}`;
|
|
650
|
+
}
|
|
651
|
+
// @implements A-SPEC-515.1
|
|
652
|
+
/**
|
|
653
|
+
* The summary a green jest run always had — it was inside the `--json` payload rather than absent.
|
|
654
|
+
*
|
|
655
|
+
* jest writes its human summary to stderr, and the success branch reads stdout only, so the tail of
|
|
656
|
+
* a passing run carried no counts at all. The counts are right there in the JSON; this assembles
|
|
657
|
+
* them into the two lines a reader actually wanted.
|
|
658
|
+
*
|
|
659
|
+
* Returns null on anything it cannot read, and the caller falls back to the truncated original:
|
|
660
|
+
* a failed summary must not turn partial information into none.
|
|
661
|
+
*/
|
|
662
|
+
function summarizeJestJson(stdout) {
|
|
663
|
+
// The payload is not the whole of stdout. A suite that logs anything prints it BEFORE the JSON,
|
|
664
|
+
// and this repository's own suite does: measured, the first live run after this function landed
|
|
665
|
+
// still returned 1.4MB because `JSON.parse(stdout)` threw on the console noise and the fallback
|
|
666
|
+
// handed back the raw document. `parseExecutedCounts` already locates the payload from the first
|
|
667
|
+
// `{`; using a second, stricter rule for the same string is how the two quietly disagree.
|
|
668
|
+
const start = stdout.indexOf('{');
|
|
669
|
+
if (start < 0)
|
|
670
|
+
return null;
|
|
671
|
+
let j;
|
|
672
|
+
try {
|
|
673
|
+
j = JSON.parse(stdout.slice(start));
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
if (j === null || typeof j !== 'object')
|
|
679
|
+
return null;
|
|
680
|
+
const num = (k) => (typeof j[k] === 'number' ? j[k] : null);
|
|
681
|
+
const totalSuites = num('numTotalTestSuites');
|
|
682
|
+
const totalTests = num('numTotalTests');
|
|
683
|
+
if (totalSuites === null || totalTests === null)
|
|
684
|
+
return null;
|
|
685
|
+
const line = (label, failed, skipped, passed, total) => {
|
|
686
|
+
const parts = [];
|
|
687
|
+
if (failed !== null && failed > 0)
|
|
688
|
+
parts.push(`${failed} failed`);
|
|
689
|
+
if (skipped !== null && skipped > 0)
|
|
690
|
+
parts.push(`${skipped} skipped`);
|
|
691
|
+
if (passed !== null)
|
|
692
|
+
parts.push(`${passed} passed`);
|
|
693
|
+
parts.push(`${total} total`);
|
|
694
|
+
return `${label}${parts.join(', ')}`;
|
|
695
|
+
};
|
|
696
|
+
return [
|
|
697
|
+
line('Test Suites: ', num('numFailedTestSuites'), num('numPendingTestSuites'), num('numPassedTestSuites'), totalSuites),
|
|
698
|
+
line('Tests: ', num('numFailedTests'), num('numPendingTests'), num('numPassedTests'), totalTests),
|
|
699
|
+
].join('\n');
|
|
622
700
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DATAFLOW_LIMITS = void 0;
|
|
4
4
|
exports.taintDataFlow = taintDataFlow;
|
|
5
|
+
const sink_matching_1 = require("./sink-matching");
|
|
5
6
|
/**
|
|
6
7
|
* What this pass does NOT model. Carried in every result, for the same reason REQ-138 carries
|
|
7
8
|
* `TAINT_LIMITS`: a screening signal that does not state its blind spots is read as a proof.
|
|
@@ -226,7 +227,10 @@ function taintDataFlow(factSet, cfg) {
|
|
|
226
227
|
// SINK — reported after the fixpoint, so a finding always reflects the complete taint state.
|
|
227
228
|
const all = [];
|
|
228
229
|
for (const c of calls) {
|
|
229
|
-
|
|
230
|
+
// @implements A-SPEC-512.3 — the ONE sink decision, receiver-aware: an exact name match that
|
|
231
|
+
// is not a RegExp method on a regex receiver. 16 of this repository's 19 findings were
|
|
232
|
+
// `re.exec(...)` before this delegation.
|
|
233
|
+
if (!(0, sink_matching_1.isSinkCall)(c.calleeText ?? c.callee, c.callee, cfg.sinks))
|
|
230
234
|
continue;
|
|
231
235
|
for (let i = 0; i < c.args.length; i++) {
|
|
232
236
|
const from = carrier(c.args[i], fid(c.file, c.fn));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CPG foundation's FIRST consumer: taint judged on REACHING DEFINITIONS instead of on
|
|
3
|
+
* "was a sanitizer mentioned anywhere in this function".
|
|
4
|
+
*
|
|
5
|
+
* The rules do not change — sources, sinks and sanitizers come from the same config the
|
|
6
|
+
* flow-insensitive engine uses. What changes is what those rule matches are COMBINED over: a
|
|
7
|
+
* tainted definition is dangerous only if it actually REACHES the sink's use. Two measured false
|
|
8
|
+
* positives fall out of that automatically:
|
|
9
|
+
* `let c = req.body.cmd; c = "ls"; exec(c);` → the constant redefinition kills the taint
|
|
10
|
+
* `if (s) { c = clean(c); } else { c = clean(c); }` → every path redefines, so nothing tainted arrives
|
|
11
|
+
* and one thing deliberately does NOT change: when only ONE branch sanitizes, the tainted
|
|
12
|
+
* definition still reaches, so the finding stands. This is a may-analysis and stays one —
|
|
13
|
+
* trading a false positive for a false negative would not be an improvement (REQ-512).
|
|
14
|
+
*/
|
|
15
|
+
import type { PersistedAst } from '../cpg/foundation/ast-store';
|
|
16
|
+
import type { Cfg } from '../cpg/foundation/cfg';
|
|
17
|
+
import type { Ddg } from '../cpg/foundation/ddg';
|
|
18
|
+
import type { DataFlowTaintConfig } from './dataflow-taint';
|
|
19
|
+
export interface FlowTaintFinding {
|
|
20
|
+
fn: string;
|
|
21
|
+
callee: string;
|
|
22
|
+
line: number;
|
|
23
|
+
argIndex: number;
|
|
24
|
+
argText: string;
|
|
25
|
+
/** Why this is reported: the tainted definition that reaches this sink use. */
|
|
26
|
+
taintedDef: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FlowTaintResult {
|
|
29
|
+
findings: FlowTaintFinding[];
|
|
30
|
+
mode: 'flow-sensitive';
|
|
31
|
+
/** Per-statement classification, exposed so a test can prove the DDG was actually consulted. */
|
|
32
|
+
taintedDefStmts: number[];
|
|
33
|
+
}
|
|
34
|
+
export declare function flowSensitiveTaint(input: {
|
|
35
|
+
ast: PersistedAst;
|
|
36
|
+
cfg: Cfg;
|
|
37
|
+
ddg: Ddg;
|
|
38
|
+
source: string;
|
|
39
|
+
fnName: string;
|
|
40
|
+
config: DataFlowTaintConfig;
|
|
41
|
+
}): FlowTaintResult;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.flowSensitiveTaint = flowSensitiveTaint;
|
|
4
|
+
const cfg_1 = require("../cpg/foundation/cfg");
|
|
5
|
+
const sink_matching_1 = require("./sink-matching");
|
|
6
|
+
const lineOf = (source, offset) => source.slice(0, offset).split('\n').length;
|
|
7
|
+
/** The callee's bare last-segment name for a call node, or null when it is not a plain call. */
|
|
8
|
+
function calleeNameOf(ast, kids, call, source) {
|
|
9
|
+
const fnNode = kids.of(call).find((k) => ast.nodes[k].named);
|
|
10
|
+
if (fnNode === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
const text = source.slice(ast.nodes[fnNode].start, ast.nodes[fnNode].end);
|
|
13
|
+
const bare = text.split(/[.:]/).pop() ?? text;
|
|
14
|
+
return /^[A-Za-z_$][\w$]*$/.test(bare) ? bare : null;
|
|
15
|
+
}
|
|
16
|
+
// @implements A-SPEC-512.1
|
|
17
|
+
function flowSensitiveTaint(input) {
|
|
18
|
+
const { ast, cfg, ddg, source, fnName, config } = input;
|
|
19
|
+
const kids = (0, cfg_1.childrenIndex)(ast);
|
|
20
|
+
const text = (i) => source.slice(ast.nodes[i].start, ast.nodes[i].end);
|
|
21
|
+
const sinks = new Set(config.sinks.map((s) => s.toLowerCase()));
|
|
22
|
+
const sanitizers = new Set(config.sanitizers.map((s) => s.toLowerCase()));
|
|
23
|
+
const stmtOfBlock = new Set();
|
|
24
|
+
for (const b of cfg.blocks)
|
|
25
|
+
for (const s of b.stmts)
|
|
26
|
+
stmtOfBlock.add(s);
|
|
27
|
+
/** Every call node inside a statement, excluding nested function bodies. */
|
|
28
|
+
const callsIn = (stmt) => {
|
|
29
|
+
const out = [];
|
|
30
|
+
const walk = (n) => {
|
|
31
|
+
const ty = ast.nodes[n].type;
|
|
32
|
+
if (n !== stmt && stmtOfBlock.has(n))
|
|
33
|
+
return;
|
|
34
|
+
if (ty === 'call_expression' || ty === 'call')
|
|
35
|
+
out.push(n);
|
|
36
|
+
for (const c of kids.of(n))
|
|
37
|
+
walk(c);
|
|
38
|
+
};
|
|
39
|
+
walk(stmt);
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
// ── 1/2. Classify each definition statement: tainted (source text) vs sanitized (sanitizer call).
|
|
43
|
+
const taintedDefs = new Set();
|
|
44
|
+
for (const [stmt] of ddg.defs) {
|
|
45
|
+
const t = text(stmt);
|
|
46
|
+
const isSource = config.sources.some((s) => t.toLowerCase().includes(s.toLowerCase()));
|
|
47
|
+
if (!isSource)
|
|
48
|
+
continue;
|
|
49
|
+
// A definition whose value comes THROUGH a sanitizer is clean even if the source text is
|
|
50
|
+
// mentioned inside it: `c = escape(req.body.cmd)` reads the source but launders it.
|
|
51
|
+
const sanitized = callsIn(stmt).some((c) => {
|
|
52
|
+
const name = calleeNameOf(ast, kids, c, source);
|
|
53
|
+
return name !== null && sanitizers.has(name.toLowerCase());
|
|
54
|
+
});
|
|
55
|
+
if (!sanitized)
|
|
56
|
+
taintedDefs.add(stmt);
|
|
57
|
+
}
|
|
58
|
+
// ── 3/4. For every sink call, ask the DDG which definitions reach the names it consumes.
|
|
59
|
+
const findings = [];
|
|
60
|
+
for (const stmt of stmtOfBlock) {
|
|
61
|
+
for (const call of callsIn(stmt)) {
|
|
62
|
+
const callee = calleeNameOf(ast, kids, call, source);
|
|
63
|
+
if (callee === null)
|
|
64
|
+
continue;
|
|
65
|
+
// @implements A-SPEC-512.3 — the SAME sink decision the other engine uses, so the two
|
|
66
|
+
// cannot disagree about what a sink is (a second truth is a drifting truth).
|
|
67
|
+
const fnNode = kids.of(call).find((k) => ast.nodes[k].named);
|
|
68
|
+
const calleeText = fnNode === undefined ? callee : text(fnNode);
|
|
69
|
+
if (!(0, sink_matching_1.isSinkCall)(calleeText, callee, config.sinks))
|
|
70
|
+
continue;
|
|
71
|
+
const args = kids.of(call).find((k) => ast.nodes[k].type === 'arguments' || ast.nodes[k].type === 'argument_list');
|
|
72
|
+
const argNodes = args === undefined ? [] : kids.of(args).filter((k) => ast.nodes[k].named);
|
|
73
|
+
argNodes.forEach((arg, argIndex) => {
|
|
74
|
+
// Names this argument reads (identifiers inside the argument expression).
|
|
75
|
+
const names = new Set();
|
|
76
|
+
const collect = (n) => {
|
|
77
|
+
if (ast.nodes[n].type === 'identifier')
|
|
78
|
+
names.add(text(n));
|
|
79
|
+
for (const c of kids.of(n))
|
|
80
|
+
collect(c);
|
|
81
|
+
};
|
|
82
|
+
collect(arg);
|
|
83
|
+
// An argument that goes through a sanitizer inline is clean: `exec(escape(c))`.
|
|
84
|
+
const inlineSanitized = callsIn(arg).some((c) => {
|
|
85
|
+
const nm = calleeNameOf(ast, kids, c, source);
|
|
86
|
+
return nm !== null && sanitizers.has(nm.toLowerCase());
|
|
87
|
+
});
|
|
88
|
+
if (inlineSanitized)
|
|
89
|
+
return;
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const reaching = ddg.edges.filter((e) => e.useStmt === stmt && e.name === name);
|
|
92
|
+
const tainted = reaching.find((e) => taintedDefs.has(e.defStmt));
|
|
93
|
+
if (tainted === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
findings.push({
|
|
96
|
+
fn: fnName,
|
|
97
|
+
callee,
|
|
98
|
+
line: lineOf(source, ast.nodes[call].start),
|
|
99
|
+
argIndex,
|
|
100
|
+
argText: text(arg),
|
|
101
|
+
taintedDef: text(tainted.defStmt),
|
|
102
|
+
});
|
|
103
|
+
return; // one finding per argument
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { findings, mode: 'flow-sensitive', taintedDefStmts: [...taintedDefs] };
|
|
109
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The COMBINATION S-512.1's real-corpus measurement forced: keep the inter-procedural propagation
|
|
3
|
+
* (its `arg-param` hops are how real taint actually travels — every one of the ten findings on
|
|
4
|
+
* this repository crossed a function boundary), and use reaching definitions only to REFUTE a
|
|
5
|
+
* finding inside the function where the sink sits.
|
|
6
|
+
*
|
|
7
|
+
* Direction of the filter, stated once because it is the whole design: it may only REMOVE. A
|
|
8
|
+
* finding survives unless the DDG positively shows that nothing tainted reaches the sink's use —
|
|
9
|
+
* an unparseable file, an unsupported function, a name the span walk cannot locate, all keep the
|
|
10
|
+
* finding. Fail-open is the may-analysis direction: not knowing is not a licence to dismiss.
|
|
11
|
+
*
|
|
12
|
+
* What it removes is exactly the shape the benchmark measured: a definition that is dead by the
|
|
13
|
+
* time the sink runs (overwritten with a constant), or one that every path has replaced with a
|
|
14
|
+
* sanitized value.
|
|
15
|
+
*/
|
|
16
|
+
import type { PersistedAst } from '../cpg/foundation/ast-store';
|
|
17
|
+
import type { Cfg } from '../cpg/foundation/cfg';
|
|
18
|
+
import type { Ddg } from '../cpg/foundation/ddg';
|
|
19
|
+
import type { DataFlowFinding, DataFlowTaintConfig } from './dataflow-taint';
|
|
20
|
+
export interface FilterInput {
|
|
21
|
+
ast: PersistedAst;
|
|
22
|
+
cfg: Cfg;
|
|
23
|
+
ddg: Ddg;
|
|
24
|
+
source: string;
|
|
25
|
+
finding: Pick<DataFlowFinding, 'line' | 'callee' | 'argText'>;
|
|
26
|
+
config: DataFlowTaintConfig;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Should this finding survive? True unless reaching definitions positively refute it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function keepsFinding(input: FilterInput): boolean;
|
|
32
|
+
export interface FilterOutcome<T> {
|
|
33
|
+
kept: T[];
|
|
34
|
+
/** Findings the reaching-definition evidence positively refuted, with the reason. */
|
|
35
|
+
removed: Array<{
|
|
36
|
+
finding: T;
|
|
37
|
+
reason: string;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Apply the filter to a file's findings. Everything the substrate cannot analyse is KEPT, so the
|
|
42
|
+
* result is always a subset of the input and never a superset.
|
|
43
|
+
*/
|
|
44
|
+
export declare function filterFindingsForFile<T extends Pick<DataFlowFinding, 'line' | 'callee' | 'argText' | 'file'>>(root: string, relPath: string, findings: T[], config: DataFlowTaintConfig): Promise<FilterOutcome<T>>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.keepsFinding = keepsFinding;
|
|
37
|
+
exports.filterFindingsForFile = filterFindingsForFile;
|
|
38
|
+
const cfg_1 = require("../cpg/foundation/cfg");
|
|
39
|
+
const lineOf = (source, offset) => source.slice(0, offset).split('\n').length;
|
|
40
|
+
// @implements A-SPEC-512.2
|
|
41
|
+
/**
|
|
42
|
+
* Should this finding survive? True unless reaching definitions positively refute it.
|
|
43
|
+
*/
|
|
44
|
+
function keepsFinding(input) {
|
|
45
|
+
const { ast, cfg, ddg, source, finding, config } = input;
|
|
46
|
+
const kids = (0, cfg_1.childrenIndex)(ast);
|
|
47
|
+
const text = (i) => source.slice(ast.nodes[i].start, ast.nodes[i].end);
|
|
48
|
+
// Locate the sink statement: a statement on the reported line whose text names the callee.
|
|
49
|
+
const stmts = new Set();
|
|
50
|
+
for (const b of cfg.blocks)
|
|
51
|
+
for (const s of b.stmts)
|
|
52
|
+
stmts.add(s);
|
|
53
|
+
const sinkStmt = [...stmts].find((s) => lineOf(source, ast.nodes[s].start) === finding.line && text(s).includes(finding.callee));
|
|
54
|
+
if (sinkStmt === undefined)
|
|
55
|
+
return true; // cannot locate → keep (fail-open)
|
|
56
|
+
// The names the reported argument reads. If the argument text is not a bare name chain we can
|
|
57
|
+
// find in the statement, keep — an argument we cannot resolve is not an argument we can dismiss.
|
|
58
|
+
const names = new Set();
|
|
59
|
+
const collect = (n) => {
|
|
60
|
+
if (ast.nodes[n].type === 'identifier')
|
|
61
|
+
names.add(text(n));
|
|
62
|
+
for (const c of kids.of(n))
|
|
63
|
+
collect(c);
|
|
64
|
+
};
|
|
65
|
+
const argNode = (() => {
|
|
66
|
+
const stack = [sinkStmt];
|
|
67
|
+
while (stack.length) {
|
|
68
|
+
const n = stack.pop();
|
|
69
|
+
if (text(n) === finding.argText)
|
|
70
|
+
return n;
|
|
71
|
+
for (const c of kids.of(n))
|
|
72
|
+
stack.push(c);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
})();
|
|
76
|
+
if (argNode === undefined)
|
|
77
|
+
return true; // argument not found → keep
|
|
78
|
+
collect(argNode);
|
|
79
|
+
if (names.size === 0)
|
|
80
|
+
return true; // a literal argument: not our call
|
|
81
|
+
const paramPoints = new Set(ddg.params.values());
|
|
82
|
+
const launders = (t) => config.sanitizers.some((s) => new RegExp(`\\b${s.toLowerCase()}\\s*\\(`).test(t));
|
|
83
|
+
/**
|
|
84
|
+
* Can taint arrive at this definition? A BACKWARD walk over REACHING_DEF, not a one-hop look:
|
|
85
|
+
* `function h(cmd) { const c = cmd; exec(c); }` reaches the sink through an ordinary copy, and
|
|
86
|
+
* a one-hop filter would refute it — recreating the very under-detection this slice exists to
|
|
87
|
+
* prevent (measured in S-512.1). The walk stops at a parameter (taint entry), at a source, or
|
|
88
|
+
* at a laundering definition; `seen` bounds it on cyclic (loop-carried) definitions.
|
|
89
|
+
*/
|
|
90
|
+
const canCarryTaint = (defStmt, seen) => {
|
|
91
|
+
if (paramPoints.has(defStmt))
|
|
92
|
+
return true; // inter-procedural entry point
|
|
93
|
+
if (seen.has(defStmt))
|
|
94
|
+
return false;
|
|
95
|
+
seen.add(defStmt);
|
|
96
|
+
const t = text(defStmt).toLowerCase();
|
|
97
|
+
if (launders(t))
|
|
98
|
+
return false; // value washed here: this path is clean
|
|
99
|
+
if (config.sources.some((s) => t.includes(s.toLowerCase())))
|
|
100
|
+
return true;
|
|
101
|
+
for (const name of ddg.uses.get(defStmt) ?? []) {
|
|
102
|
+
for (const e of ddg.edges) {
|
|
103
|
+
if (e.useStmt !== defStmt || e.name !== name)
|
|
104
|
+
continue;
|
|
105
|
+
if (canCarryTaint(e.defStmt, seen))
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
for (const name of names) {
|
|
112
|
+
const reaching = ddg.edges.filter((e) => e.useStmt === sinkStmt && e.name === name);
|
|
113
|
+
if (reaching.length === 0)
|
|
114
|
+
continue; // no reaching info for this name
|
|
115
|
+
if (reaching.some((e) => canCarryTaint(e.defStmt, new Set())))
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// Every name we could resolve has reaching definitions, and none of them can carry taint.
|
|
119
|
+
const anyResolved = [...names].some((n) => ddg.edges.some((e) => e.useStmt === sinkStmt && e.name === n));
|
|
120
|
+
return !anyResolved; // resolved and refuted → drop
|
|
121
|
+
}
|
|
122
|
+
// @implements A-SPEC-512.2
|
|
123
|
+
/**
|
|
124
|
+
* Apply the filter to a file's findings. Everything the substrate cannot analyse is KEPT, so the
|
|
125
|
+
* result is always a subset of the input and never a superset.
|
|
126
|
+
*/
|
|
127
|
+
async function filterFindingsForFile(root, relPath, findings, config) {
|
|
128
|
+
if (findings.length === 0)
|
|
129
|
+
return { kept: [], removed: [] };
|
|
130
|
+
const { astOf } = await Promise.resolve().then(() => __importStar(require('../cpg/foundation/ast-store')));
|
|
131
|
+
const { cfgOf, functionsIn } = await Promise.resolve().then(() => __importStar(require('../cpg/foundation/cfg')));
|
|
132
|
+
const { ddgOf } = await Promise.resolve().then(() => __importStar(require('../cpg/foundation/ddg')));
|
|
133
|
+
const fs = await Promise.resolve().then(() => __importStar(require('node:fs')));
|
|
134
|
+
const path = await Promise.resolve().then(() => __importStar(require('node:path')));
|
|
135
|
+
let ast = null;
|
|
136
|
+
let source = '';
|
|
137
|
+
try {
|
|
138
|
+
ast = await astOf(root, relPath);
|
|
139
|
+
source = fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
140
|
+
}
|
|
141
|
+
catch { /* unreadable: keep everything */ }
|
|
142
|
+
if (!ast || ast.errorCount > 0)
|
|
143
|
+
return { kept: findings, removed: [] };
|
|
144
|
+
const kept = [];
|
|
145
|
+
const removed = [];
|
|
146
|
+
for (const finding of findings) {
|
|
147
|
+
let verdict = true; // default keep
|
|
148
|
+
for (const fn of functionsIn(ast)) {
|
|
149
|
+
const cfg = cfgOf(ast, fn, source);
|
|
150
|
+
if ('unsupported' in cfg)
|
|
151
|
+
continue; // unsupported function: no opinion
|
|
152
|
+
const ddg = ddgOf(ast, cfg, fn, source);
|
|
153
|
+
if ('unsupported' in ddg)
|
|
154
|
+
continue;
|
|
155
|
+
const inThisFn = cfg.blocks.some((b) => b.stmts.some((s) => lineOf(source, ast.nodes[s].start) === finding.line));
|
|
156
|
+
if (!inThisFn)
|
|
157
|
+
continue;
|
|
158
|
+
verdict = keepsFinding({ ast, cfg, ddg, source, finding, config });
|
|
159
|
+
break; // the function owning the line decides
|
|
160
|
+
}
|
|
161
|
+
if (verdict)
|
|
162
|
+
kept.push(finding);
|
|
163
|
+
else
|
|
164
|
+
removed.push({ finding, reason: 'no tainted definition reaches this sink use (reaching-definition refutation)' });
|
|
165
|
+
}
|
|
166
|
+
return { kept, removed };
|
|
167
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sink matching that reads the RECEIVER, not just the name.
|
|
3
|
+
*
|
|
4
|
+
* A-SPEC-140 already learned half of this: substring matching made `evaluateStop` an `eval` sink
|
|
5
|
+
* and `computeExecutedByAspec` an `exec` sink, so matching moved to exact names. Exact names close
|
|
6
|
+
* that hole and leave the other one open — `RegExp.prototype.exec` is spelled exactly `exec`.
|
|
7
|
+
* Measured on this repository (2026-09-02): 16 of 19 data-flow findings were `re.exec(...)` style
|
|
8
|
+
* regex calls; only three were real process execution. The dominant false-positive source was
|
|
9
|
+
* never the flow analysis, it was this.
|
|
10
|
+
*
|
|
11
|
+
* The exclusion is deliberately NARROW and fail-open. Only a receiver that is syntactically,
|
|
12
|
+
* unmistakably a regular expression suppresses the sink; an ordinary variable (`x.exec(cmd)`)
|
|
13
|
+
* stays a finding, because without type information calling it a regex would be a guess — and a
|
|
14
|
+
* guess that hides a command injection is the wrong direction to be wrong in.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Is this call's receiver certainly a regular expression? True only for shapes that cannot be
|
|
18
|
+
* anything else; everything ambiguous returns false so the caller keeps treating it as a sink.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isRegexReceiverCall(calleeText: string): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The ONE sink decision both taint engines consume: an exact name match that is not a regex
|
|
23
|
+
* method call on a regex receiver. `calleeText` is the call's callee as written (`re.exec`,
|
|
24
|
+
* `child_process.exec`, `exec`); `bareName` is its last segment, the form the config lists.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isSinkCall(calleeText: string, bareName: string, sinks: readonly string[]): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-512.3
|
|
3
|
+
/**
|
|
4
|
+
* Sink matching that reads the RECEIVER, not just the name.
|
|
5
|
+
*
|
|
6
|
+
* A-SPEC-140 already learned half of this: substring matching made `evaluateStop` an `eval` sink
|
|
7
|
+
* and `computeExecutedByAspec` an `exec` sink, so matching moved to exact names. Exact names close
|
|
8
|
+
* that hole and leave the other one open — `RegExp.prototype.exec` is spelled exactly `exec`.
|
|
9
|
+
* Measured on this repository (2026-09-02): 16 of 19 data-flow findings were `re.exec(...)` style
|
|
10
|
+
* regex calls; only three were real process execution. The dominant false-positive source was
|
|
11
|
+
* never the flow analysis, it was this.
|
|
12
|
+
*
|
|
13
|
+
* The exclusion is deliberately NARROW and fail-open. Only a receiver that is syntactically,
|
|
14
|
+
* unmistakably a regular expression suppresses the sink; an ordinary variable (`x.exec(cmd)`)
|
|
15
|
+
* stays a finding, because without type information calling it a regex would be a guess — and a
|
|
16
|
+
* guess that hides a command injection is the wrong direction to be wrong in.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.isRegexReceiverCall = isRegexReceiverCall;
|
|
20
|
+
exports.isSinkCall = isSinkCall;
|
|
21
|
+
/** Sink names that also exist as RegExp methods — the only names the receiver rule applies to. */
|
|
22
|
+
const REGEX_METHODS = new Set(['exec', 'test']);
|
|
23
|
+
/** Conventional lowercase names for a compiled pattern, matched WHOLE (never as a prefix). */
|
|
24
|
+
const REGEX_VAR_NAMES = new Set(['re', 'rx', 'regex', 'regexp', 'pattern', 'pat']);
|
|
25
|
+
// @implements A-SPEC-512.3
|
|
26
|
+
/**
|
|
27
|
+
* Is this call's receiver certainly a regular expression? True only for shapes that cannot be
|
|
28
|
+
* anything else; everything ambiguous returns false so the caller keeps treating it as a sink.
|
|
29
|
+
*/
|
|
30
|
+
function isRegexReceiverCall(calleeText) {
|
|
31
|
+
const text = calleeText.trim();
|
|
32
|
+
const dot = text.lastIndexOf('.');
|
|
33
|
+
if (dot <= 0)
|
|
34
|
+
return false; // a bare call has no receiver
|
|
35
|
+
const method = text.slice(dot + 1).trim();
|
|
36
|
+
if (!REGEX_METHODS.has(method.toLowerCase()))
|
|
37
|
+
return false;
|
|
38
|
+
const receiver = text.slice(0, dot).trim();
|
|
39
|
+
// A regex literal, with or without flags: /…/.exec, /…/gi.test
|
|
40
|
+
if (/^\/.*\/[dgimsuvy]*$/s.test(receiver))
|
|
41
|
+
return true;
|
|
42
|
+
// An explicitly constructed one: new RegExp(...).exec
|
|
43
|
+
if (/^new\s+RegExp\s*\(/.test(receiver))
|
|
44
|
+
return true;
|
|
45
|
+
// Conventional names. UPPER_SNAKE ending in _RE / RE is the house style for a module-level
|
|
46
|
+
// pattern (ANCHOR_RE, IMPL_LINE_RE); the lowercase set is the common local spelling.
|
|
47
|
+
const last = receiver.split(/[.[\]]/).filter(Boolean).pop() ?? receiver;
|
|
48
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(last) && (last === 'RE' || last.endsWith('_RE')))
|
|
49
|
+
return true;
|
|
50
|
+
if (REGEX_VAR_NAMES.has(last.toLowerCase()))
|
|
51
|
+
return true;
|
|
52
|
+
// camelCase pattern names — measured on a vendored corpus (2026-09-02): `filePattern.exec`,
|
|
53
|
+
// `codeBlockFilePattern.exec`, `funcPattern.exec`, `arrowPattern.exec` were four of the seven
|
|
54
|
+
// findings left after the first cut. A name ENDING in Pattern/Regex/Re is a compiled pattern by
|
|
55
|
+
// near-universal convention; `Re` requires the preceding character to be lowercase so that
|
|
56
|
+
// `Store` or `Compare` cannot match.
|
|
57
|
+
if (/(?:Pattern|Regex|RegExp|Rx)$/.test(last))
|
|
58
|
+
return true;
|
|
59
|
+
if (/[a-z]Re$/.test(last))
|
|
60
|
+
return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
// @implements A-SPEC-512.3
|
|
64
|
+
/**
|
|
65
|
+
* The ONE sink decision both taint engines consume: an exact name match that is not a regex
|
|
66
|
+
* method call on a regex receiver. `calleeText` is the call's callee as written (`re.exec`,
|
|
67
|
+
* `child_process.exec`, `exec`); `bareName` is its last segment, the form the config lists.
|
|
68
|
+
*/
|
|
69
|
+
function isSinkCall(calleeText, bareName, sinks) {
|
|
70
|
+
if (!sinks.some((s) => s.toLowerCase() === bareName.toLowerCase()))
|
|
71
|
+
return false;
|
|
72
|
+
return !isRegexReceiverCall(calleeText);
|
|
73
|
+
}
|
|
@@ -55,6 +55,19 @@ export interface TaintBenchmark {
|
|
|
55
55
|
controlFlowAttributable: number;
|
|
56
56
|
}
|
|
57
57
|
/** Run the current engine over the labelled cases and score it. */
|
|
58
|
+
export type TaintEngine = 'flow-insensitive' | 'flow-sensitive';
|
|
59
|
+
/** Score the corpus with EITHER engine — an improvement is reported as a contrast, never alone. */
|
|
60
|
+
export declare function runTaintBenchmarkAsync(cases?: readonly TaintCase[], engine?: TaintEngine): Promise<TaintBenchmark>;
|
|
61
|
+
/** before/after in ONE call — the single truth both the report and its test consume. */
|
|
62
|
+
export declare function compareEngines(cases?: readonly TaintCase[]): Promise<{
|
|
63
|
+
before: TaintBenchmark;
|
|
64
|
+
after: TaintBenchmark;
|
|
65
|
+
moved: Array<{
|
|
66
|
+
name: string;
|
|
67
|
+
from: CaseOutcome['cell'];
|
|
68
|
+
to: CaseOutcome['cell'];
|
|
69
|
+
}>;
|
|
70
|
+
}>;
|
|
58
71
|
export declare function runTaintBenchmark(cases?: readonly TaintCase[]): TaintBenchmark;
|
|
59
72
|
export interface RealCorpusTaint {
|
|
60
73
|
corpus: string;
|