@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.
- package/CHANGELOG.md +94 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/ci-gate.js +3 -1
- package/dist/holmes/cpg/foundation/ast-store.d.ts +49 -0
- package/dist/holmes/cpg/foundation/ast-store.js +209 -0
- package/dist/holmes/cpg/foundation/cdg.d.ts +31 -0
- package/dist/holmes/cpg/foundation/cdg.js +83 -0
- package/dist/holmes/cpg/foundation/cfg.d.ts +60 -0
- package/dist/holmes/cpg/foundation/cfg.js +617 -0
- package/dist/holmes/cpg/foundation/ddg.d.ts +41 -0
- package/dist/holmes/cpg/foundation/ddg.js +394 -0
- package/dist/holmes/cpg/foundation/language-envelope.d.ts +29 -0
- package/dist/holmes/cpg/foundation/language-envelope.js +131 -0
- package/dist/holmes/cpg/foundation/language-matrix.d.ts +57 -0
- package/dist/holmes/cpg/foundation/language-matrix.js +142 -0
- package/dist/holmes/cpg/foundation/substrate-census.d.ts +36 -0
- package/dist/holmes/cpg/foundation/substrate-census.js +135 -0
- package/dist/holmes/cpg/language-capability.js +21 -3
- package/dist/holmes/cpg/language-parser-walk.js +137 -6
- 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/grammars/manifest.json +23 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- 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
|
-
|
|
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
|
-
|
|
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));
|