@holmes-lab/holmes-kit 0.3.11 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/ci-gate.js +3 -1
  4. package/dist/holmes/cpg/foundation/ast-store.d.ts +49 -0
  5. package/dist/holmes/cpg/foundation/ast-store.js +209 -0
  6. package/dist/holmes/cpg/foundation/cdg.d.ts +31 -0
  7. package/dist/holmes/cpg/foundation/cdg.js +83 -0
  8. package/dist/holmes/cpg/foundation/cfg.d.ts +60 -0
  9. package/dist/holmes/cpg/foundation/cfg.js +617 -0
  10. package/dist/holmes/cpg/foundation/ddg.d.ts +41 -0
  11. package/dist/holmes/cpg/foundation/ddg.js +394 -0
  12. package/dist/holmes/cpg/foundation/language-envelope.d.ts +29 -0
  13. package/dist/holmes/cpg/foundation/language-envelope.js +131 -0
  14. package/dist/holmes/cpg/foundation/language-matrix.d.ts +57 -0
  15. package/dist/holmes/cpg/foundation/language-matrix.js +142 -0
  16. package/dist/holmes/cpg/foundation/substrate-census.d.ts +36 -0
  17. package/dist/holmes/cpg/foundation/substrate-census.js +135 -0
  18. package/dist/holmes/cpg/language-capability.js +21 -3
  19. package/dist/holmes/cpg/language-parser-walk.js +137 -6
  20. package/dist/holmes/cpg/language-parser.d.ts +1 -0
  21. package/dist/holmes/hooks/pre-tool-use.d.ts +17 -2
  22. package/dist/holmes/hooks/pre-tool-use.js +21 -4
  23. package/dist/holmes/mcp/handlers.d.ts +17 -5
  24. package/dist/holmes/mcp/handlers.js +98 -1
  25. package/dist/holmes/mcp/supervisor.d.ts +24 -0
  26. package/dist/holmes/mcp/supervisor.js +63 -6
  27. package/dist/holmes/mcp/tool-schemas.js +8 -1
  28. package/dist/holmes/project/root.d.ts +1 -0
  29. package/dist/holmes/project/root.js +30 -0
  30. package/dist/holmes/review/test-runner.d.ts +15 -0
  31. package/dist/holmes/review/test-runner.js +87 -9
  32. package/dist/holmes/rtm/dataflow-taint.js +5 -1
  33. package/dist/holmes/rtm/flow-sensitive-taint.d.ts +41 -0
  34. package/dist/holmes/rtm/flow-sensitive-taint.js +109 -0
  35. package/dist/holmes/rtm/reaching-def-filter.d.ts +44 -0
  36. package/dist/holmes/rtm/reaching-def-filter.js +167 -0
  37. package/dist/holmes/rtm/sink-matching.d.ts +26 -0
  38. package/dist/holmes/rtm/sink-matching.js +73 -0
  39. package/dist/holmes/rtm/taint-benchmark.d.ts +13 -0
  40. package/dist/holmes/rtm/taint-benchmark.js +97 -9
  41. package/dist/holmes/rtm/taint-vocabulary.d.ts +24 -0
  42. package/dist/holmes/rtm/taint-vocabulary.js +58 -0
  43. package/grammars/manifest.json +23 -0
  44. package/grammars/tree-sitter-python.wasm +0 -0
  45. package/grammars/tree-sitter-tsx.wasm +0 -0
  46. package/grammars/tree-sitter-typescript.wasm +0 -0
  47. package/package.json +4 -2
@@ -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;
@@ -1,6 +1,41 @@
1
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
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.TAINT_CASES = exports.TAINT_CONFIG = void 0;
37
+ exports.runTaintBenchmarkAsync = runTaintBenchmarkAsync;
38
+ exports.compareEngines = compareEngines;
4
39
  exports.runTaintBenchmark = runTaintBenchmark;
5
40
  exports.taintOnRealCorpus = taintOnRealCorpus;
6
41
  // @implements A-SPEC-354
@@ -78,7 +113,67 @@ exports.TAINT_CASES = [
78
113
  code: `function h(req) { const c = req.body.cmd; return c.length; }`,
79
114
  },
80
115
  ];
81
- /** Run the current engine over the labelled cases and score it. */
116
+ // @implements A-SPEC-512.1
117
+ /**
118
+ * The flow-SENSITIVE verdict for one case, judged on reaching definitions (the CPG foundation's
119
+ * first consumer). Async because the wasm substrate parses; `runTaintBenchmarkAsync` is the entry
120
+ * point that can use it, while the synchronous `runTaintBenchmark` keeps the original engine.
121
+ */
122
+ async function flowSensitiveVerdict(code) {
123
+ const { parseAst } = await Promise.resolve().then(() => __importStar(require('../cpg/foundation/ast-store')));
124
+ const { cfgOf, functionsIn } = await Promise.resolve().then(() => __importStar(require('../cpg/foundation/cfg')));
125
+ const { ddgOf } = await Promise.resolve().then(() => __importStar(require('../cpg/foundation/ddg')));
126
+ const { flowSensitiveTaint } = await Promise.resolve().then(() => __importStar(require('./flow-sensitive-taint')));
127
+ const ast = await parseAst(code, 'case.ts');
128
+ if (!ast)
129
+ return false;
130
+ for (const fn of functionsIn(ast)) {
131
+ const cfg = cfgOf(ast, fn, code);
132
+ if ('unsupported' in cfg)
133
+ continue;
134
+ const ddg = ddgOf(ast, cfg, fn, code);
135
+ if ('unsupported' in ddg)
136
+ continue;
137
+ if (flowSensitiveTaint({ ast, cfg, ddg, source: code, fnName: 'case', config: exports.TAINT_CONFIG }).findings.length > 0) {
138
+ return true;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+ // @implements A-SPEC-512.1
144
+ /** Score the corpus with EITHER engine — an improvement is reported as a contrast, never alone. */
145
+ async function runTaintBenchmarkAsync(cases = exports.TAINT_CASES, engine = 'flow-insensitive') {
146
+ if (engine === 'flow-insensitive')
147
+ return runTaintBenchmark(cases);
148
+ const outcomes = [];
149
+ for (const c of cases) {
150
+ const actual = await flowSensitiveVerdict(c.code);
151
+ const cell = c.vulnerable ? (actual ? 'tp' : 'fn') : (actual ? 'fp' : 'tn');
152
+ outcomes.push({ name: c.name, difficulty: c.difficulty, expected: c.vulnerable, actual, cell });
153
+ }
154
+ return scoreOutcomes(outcomes);
155
+ }
156
+ // @implements A-SPEC-512.1
157
+ /** before/after in ONE call — the single truth both the report and its test consume. */
158
+ async function compareEngines(cases = exports.TAINT_CASES) {
159
+ const before = await runTaintBenchmarkAsync(cases, 'flow-insensitive');
160
+ const after = await runTaintBenchmarkAsync(cases, 'flow-sensitive');
161
+ const moved = after.outcomes
162
+ .map((a, i) => ({ name: a.name, from: before.outcomes[i].cell, to: a.cell }))
163
+ .filter((m) => m.from !== m.to);
164
+ return { before, after, moved };
165
+ }
166
+ /** Cells → metrics. Shared so both engines are scored by exactly the same arithmetic. */
167
+ function scoreOutcomes(outcomes) {
168
+ const count = (k) => outcomes.filter((o) => o.cell === k).length;
169
+ const tp = count('tp'), tn = count('tn'), fp = count('fp'), fn = count('fn');
170
+ return {
171
+ outcomes, tp, tn, fp, fn,
172
+ precision: tp + fp === 0 ? null : tp / (tp + fp),
173
+ recall: tp + fn === 0 ? null : tp / (tp + fn),
174
+ controlFlowAttributable: outcomes.filter((o) => (o.cell === 'fp' || o.cell === 'fn') && o.difficulty !== 'flow-free').length,
175
+ };
176
+ }
82
177
  function runTaintBenchmark(cases = exports.TAINT_CASES) {
83
178
  const parser = new language_parser_1.TreeSitterTsParser();
84
179
  const outcomes = [];
@@ -94,14 +189,7 @@ function runTaintBenchmark(cases = exports.TAINT_CASES) {
94
189
  : (actual ? 'fp' : 'tn');
95
190
  outcomes.push({ name: c.name, difficulty: c.difficulty, expected: c.vulnerable, actual, cell });
96
191
  }
97
- const count = (k) => outcomes.filter((o) => o.cell === k).length;
98
- const tp = count('tp'), tn = count('tn'), fp = count('fp'), fn = count('fn');
99
- return {
100
- outcomes, tp, tn, fp, fn,
101
- precision: tp + fp === 0 ? null : tp / (tp + fp),
102
- recall: tp + fn === 0 ? null : tp / (tp + fn),
103
- controlFlowAttributable: outcomes.filter((o) => (o.cell === 'fp' || o.cell === 'fn') && o.difficulty !== 'flow-free').length,
104
- };
192
+ return scoreOutcomes(outcomes);
105
193
  }
106
194
  /**
107
195
  * How often the taint analysis fires on REAL code.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Taint vocabulary, per language — because "we found nothing" and "we do not speak this language"
3
+ * are different sentences and the tool was saying the first while meaning the second.
4
+ *
5
+ * Measured on jarvis (27,232 Python files) 2026-09-02: the shipped list matched `subprocess.Popen`
6
+ * and `exec(`, and missed `subprocess.run` (7 files), `os.environ` (5) and `input(` (4) — the three
7
+ * most common Python spellings. The engine already handled Python (its CFG/DDG landed in S-510.7);
8
+ * it simply had no Python words to look for.
9
+ *
10
+ * The lists are DATA and the judgement is one function: adding a language grows this table, not the
11
+ * code. And the vocabulary is SELECTED by file language, never unioned — a union would let Python's
12
+ * `input(` taint an ordinary TypeScript `input` and each language would inherit the other's false
13
+ * positives.
14
+ */
15
+ import type { DataFlowTaintConfig } from './dataflow-taint';
16
+ export declare const TAINT_VOCABULARY: Record<'typescript' | 'python', DataFlowTaintConfig>;
17
+ /**
18
+ * The vocabulary this file should be judged with, or null when the file is outside the analysable
19
+ * set. Null is deliberate: degrading an unknown language to the TypeScript list would produce
20
+ * findings in a language whose control flow we cannot even build.
21
+ */
22
+ export declare function vocabularyFor(relPath: string): DataFlowTaintConfig | null;
23
+ /** Languages this vocabulary table covers — the matrix derives its taint row from this. */
24
+ export declare const TAINT_LANGUAGES: ReadonlySet<string>;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TAINT_LANGUAGES = exports.TAINT_VOCABULARY = void 0;
4
+ exports.vocabularyFor = vocabularyFor;
5
+ /**
6
+ * TypeScript / JavaScript — value-identical to the shipped list, which is what makes "no regression
7
+ * for TS" checkable rather than asserted (a test compares the two).
8
+ */
9
+ const TYPESCRIPT_VOCABULARY = {
10
+ sources: ['getenv', 'argv', 'req.body', 'request', 'stdin', 'readfile', 'readinput', 'query.get'],
11
+ sinks: ['exec', 'eval', 'system', 'query', 'spawn', 'popen', 'deserialize'],
12
+ sanitizers: ['escape', 'sanitize', 'encodeuricomponent'],
13
+ };
14
+ /**
15
+ * Python. Sinks are matched on the callee's LAST segment, which is why `subprocess.run` appears as
16
+ * `run` and `pickle.loads` as `loads`. Sources are matched against expression TEXT, so they carry
17
+ * their qualifier (`os.environ`) — that qualifier is what keeps a local variable named `environ`
18
+ * from becoming a source.
19
+ *
20
+ * `input` is listed with its opening parenthesis on purpose: bare `input` is an extremely common
21
+ * identifier (a parameter, a field, a React prop in mixed repos), so only the CALL is a source.
22
+ * That decision closes H-SPEC-513's open question, and it is the same "the act, not the name" rule
23
+ * the sink matcher follows.
24
+ */
25
+ const PYTHON_VOCABULARY = {
26
+ sources: [
27
+ 'os.environ', 'os.getenv', 'sys.argv', 'sys.stdin', 'input(',
28
+ 'request.args', 'request.form', 'request.json', 'request.data', 'request.values',
29
+ ],
30
+ sinks: [
31
+ 'run', 'call', 'check_output', 'check_call', // subprocess.*
32
+ 'Popen', 'popen', 'system', // subprocess.Popen / os.popen / os.system
33
+ 'exec', 'eval', 'compile', // built-in code execution
34
+ 'loads', 'load', // pickle / yaml deserialization
35
+ ],
36
+ sanitizers: ['quote', 'escape', 'shlex.quote', 're.escape'],
37
+ };
38
+ exports.TAINT_VOCABULARY = {
39
+ typescript: TYPESCRIPT_VOCABULARY,
40
+ python: PYTHON_VOCABULARY,
41
+ };
42
+ const TS_EXT = /\.(ts|mts|cts|tsx|js|mjs|cjs|jsx)$/i;
43
+ const PY_EXT = /\.py$/i;
44
+ // @implements A-SPEC-513.1
45
+ /**
46
+ * The vocabulary this file should be judged with, or null when the file is outside the analysable
47
+ * set. Null is deliberate: degrading an unknown language to the TypeScript list would produce
48
+ * findings in a language whose control flow we cannot even build.
49
+ */
50
+ function vocabularyFor(relPath) {
51
+ if (TS_EXT.test(relPath))
52
+ return exports.TAINT_VOCABULARY.typescript;
53
+ if (PY_EXT.test(relPath))
54
+ return exports.TAINT_VOCABULARY.python;
55
+ return null;
56
+ }
57
+ /** Languages this vocabulary table covers — the matrix derives its taint row from this. */
58
+ exports.TAINT_LANGUAGES = new Set(Object.keys(exports.TAINT_VOCABULARY));