@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.
@@ -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
- return { passed: true, tail: tailOf(out), executed: parseExecutedCounts(out, cwd) };
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
- function tailOf(s, n = 6) {
621
- return s.trim().split('\n').slice(-n).join('\n');
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
- if (!matchesName(c.callee, cfg.sinks))
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;