@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
|
@@ -312,15 +312,27 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
312
312
|
*/
|
|
313
313
|
taint_scan(a: {
|
|
314
314
|
root: string;
|
|
315
|
+
dataFlow?: boolean;
|
|
315
316
|
}): Promise<{
|
|
317
|
+
kind: string;
|
|
318
|
+
limits: ("name-based matching" | "no def-use" | "no sanitizers" | "call-edge-only")[];
|
|
319
|
+
maxPaths: number | undefined;
|
|
320
|
+
truncated: number;
|
|
321
|
+
pairs: import("../rtm/taint").TaintPair[];
|
|
322
|
+
} | {
|
|
316
323
|
ok: boolean;
|
|
317
324
|
reason: string;
|
|
318
|
-
kind?: undefined;
|
|
319
|
-
limits?: undefined;
|
|
320
|
-
maxPaths?: undefined;
|
|
321
|
-
truncated?: undefined;
|
|
322
|
-
pairs?: undefined;
|
|
323
325
|
} | {
|
|
326
|
+
dataFlow: {
|
|
327
|
+
findings: Record<string, unknown>[];
|
|
328
|
+
/** Findings the reaching-definition evidence positively refuted (dead or fully sanitized). */
|
|
329
|
+
refutedByReachingDefs: number;
|
|
330
|
+
limits: ("no field sensitivity (obj.a and obj.b are one value)" | "no aliasing" | "no path sensitivity" | "no container/element tracking" | "no reflection or dynamic dispatch" | "sink matching is by callee NAME with no receiver type — RE.exec(s) is indistinguishable from child_process.exec(s)" | "no anonymous-function parameters (arrow/function-expression params are not bound)" | "not statement-order sensitive — a variable tainted anywhere in a function is tainted throughout it" | "reports reachability of tainted data to a sink, never exploitability")[];
|
|
331
|
+
converged: boolean;
|
|
332
|
+
truncated: number;
|
|
333
|
+
/** Which languages this run could actually judge, so a zero is readable. */
|
|
334
|
+
languagesAnalysed: (string | null)[];
|
|
335
|
+
};
|
|
324
336
|
kind: string;
|
|
325
337
|
limits: ("name-based matching" | "no def-use" | "no sanitizers" | "call-edge-only")[];
|
|
326
338
|
maxPaths: number | undefined;
|
|
@@ -1688,7 +1688,104 @@ function makeRawHandlers(store, opts) {
|
|
|
1688
1688
|
(0, rtm_builder_1.buildRtm)(await store.list(), scanned, g);
|
|
1689
1689
|
const cfg = taint_1.DEFAULT_TAINT_CONFIG;
|
|
1690
1690
|
const { pairs, truncated } = (0, taint_1.taintReachability)(g, cfg);
|
|
1691
|
-
|
|
1691
|
+
const base = { kind: 'call-reachability', limits: [...taint_1.TAINT_LIMITS], maxPaths: cfg.maxPaths, truncated, pairs };
|
|
1692
|
+
if (a.dataFlow !== true)
|
|
1693
|
+
return base;
|
|
1694
|
+
// @implements A-SPEC-512.2
|
|
1695
|
+
// OPT-IN def-use lane. Measured 2026-09-02: extracting data-flow facts over this
|
|
1696
|
+
// repository costs 7.6s on top of the scan, which is why this follows the `cpg_scan
|
|
1697
|
+
// lints:true` precedent instead of running by default. Before this slice the engine
|
|
1698
|
+
// (A-SPEC-140.1, with its own 13-case benchmark) had NO caller at all — capability
|
|
1699
|
+
// present, unreachable. Findings are filtered by reaching definitions: inter-procedural
|
|
1700
|
+
// arrival is preserved, and only what the DDG positively refutes is dropped.
|
|
1701
|
+
const { TreeSitterTsParser } = require('../cpg/language-parser');
|
|
1702
|
+
const { taintDataFlow, DATAFLOW_LIMITS } = require('../rtm/dataflow-taint');
|
|
1703
|
+
const { filterFindingsForFile } = require('../rtm/reaching-def-filter');
|
|
1704
|
+
// @implements A-SPEC-513.1 — vocabulary is SELECTED per file language, never unioned:
|
|
1705
|
+
// Python's `input(` must not taint a TypeScript `input`, and `req.body` means nothing in
|
|
1706
|
+
// Python. Measured on jarvis: the shipped (JS) list missed `subprocess.run`, `os.environ`
|
|
1707
|
+
// and `input(` entirely, so a Python repository could only ever report zero.
|
|
1708
|
+
const { vocabularyFor } = require('../rtm/taint-vocabulary');
|
|
1709
|
+
const parser = new TreeSitterTsParser();
|
|
1710
|
+
const factSet = [];
|
|
1711
|
+
const pythonFiles = [];
|
|
1712
|
+
for (const f of scanned) {
|
|
1713
|
+
const vocab = vocabularyFor(f.sourcePath);
|
|
1714
|
+
if (!vocab)
|
|
1715
|
+
continue;
|
|
1716
|
+
if (/\.py$/i.test(f.sourcePath)) {
|
|
1717
|
+
pythonFiles.push(f.sourcePath);
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
let src;
|
|
1721
|
+
try {
|
|
1722
|
+
src = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
|
|
1723
|
+
}
|
|
1724
|
+
catch {
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
const facts = parser.extractDataFlow(src, f.sourcePath.endsWith('x') ? 'tsx' : 'typescript');
|
|
1728
|
+
if (facts)
|
|
1729
|
+
factSet.push({ file: f.sourcePath, facts });
|
|
1730
|
+
}
|
|
1731
|
+
const tsVocab = vocabularyFor('x.ts');
|
|
1732
|
+
const raw = taintDataFlow(factSet, tsVocab);
|
|
1733
|
+
const byFile = new Map();
|
|
1734
|
+
for (const fnd of raw.findings)
|
|
1735
|
+
byFile.set(fnd.file, [...(byFile.get(fnd.file) ?? []), fnd]);
|
|
1736
|
+
const kept = [];
|
|
1737
|
+
let refuted = 0;
|
|
1738
|
+
for (const [rel, fs2] of byFile) {
|
|
1739
|
+
const out = await filterFindingsForFile(root, rel, fs2, tsVocab);
|
|
1740
|
+
kept.push(...out.kept.map((f) => ({ ...f, lane: 'facts+reaching-defs' })));
|
|
1741
|
+
refuted += out.removed.length;
|
|
1742
|
+
}
|
|
1743
|
+
// @implements A-SPEC-513.1 — Python takes the flow-sensitive lane directly: its facts
|
|
1744
|
+
// extractor has no Python walk, but its CFG/DDG do (S-510.7), and the judgement above
|
|
1745
|
+
// them is language-agnostic.
|
|
1746
|
+
const pyVocab = vocabularyFor('x.py');
|
|
1747
|
+
if (pythonFiles.length > 0) {
|
|
1748
|
+
const { parseAst } = require('../cpg/foundation/ast-store');
|
|
1749
|
+
const { cfgOf, functionsIn } = require('../cpg/foundation/cfg');
|
|
1750
|
+
const { ddgOf } = require('../cpg/foundation/ddg');
|
|
1751
|
+
const { flowSensitiveTaint } = require('../rtm/flow-sensitive-taint');
|
|
1752
|
+
for (const rel of pythonFiles) {
|
|
1753
|
+
let src;
|
|
1754
|
+
try {
|
|
1755
|
+
src = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
1756
|
+
}
|
|
1757
|
+
catch {
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
const ast = await parseAst(src, rel);
|
|
1761
|
+
if (!ast || ast.errorCount > 0)
|
|
1762
|
+
continue;
|
|
1763
|
+
for (const fn of functionsIn(ast)) {
|
|
1764
|
+
const fcfg = cfgOf(ast, fn, src);
|
|
1765
|
+
if ('unsupported' in fcfg)
|
|
1766
|
+
continue;
|
|
1767
|
+
const fddg = ddgOf(ast, fcfg, fn, src);
|
|
1768
|
+
if ('unsupported' in fddg)
|
|
1769
|
+
continue;
|
|
1770
|
+
const r = flowSensitiveTaint({ ast, cfg: fcfg, ddg: fddg, source: src, fnName: rel, config: pyVocab });
|
|
1771
|
+
for (const f of r.findings)
|
|
1772
|
+
kept.push({ ...f, file: rel, lane: 'flow-sensitive' });
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return {
|
|
1777
|
+
...base,
|
|
1778
|
+
dataFlow: {
|
|
1779
|
+
findings: kept,
|
|
1780
|
+
/** Findings the reaching-definition evidence positively refuted (dead or fully sanitized). */
|
|
1781
|
+
refutedByReachingDefs: refuted,
|
|
1782
|
+
limits: [...DATAFLOW_LIMITS],
|
|
1783
|
+
converged: raw.converged,
|
|
1784
|
+
truncated: raw.truncated,
|
|
1785
|
+
/** Which languages this run could actually judge, so a zero is readable. */
|
|
1786
|
+
languagesAnalysed: [...new Set([factSet.length > 0 ? 'typescript' : null, pythonFiles.length > 0 ? 'python' : null].filter(Boolean))],
|
|
1787
|
+
},
|
|
1788
|
+
};
|
|
1692
1789
|
}
|
|
1693
1790
|
finally {
|
|
1694
1791
|
g.close();
|
|
@@ -15,6 +15,30 @@ export declare function autoreloadEnabled(env: NodeJS.ProcessEnv): boolean;
|
|
|
15
15
|
* question, which is worse than the staleness it was fixing.
|
|
16
16
|
*/
|
|
17
17
|
export declare function shouldSwap(loaded: string, disk: string | undefined, inflight: number): boolean;
|
|
18
|
+
export interface ResponseCounter {
|
|
19
|
+
/** How many JSON-RPC replies COMPLETED in this chunk. */
|
|
20
|
+
push(chunk: string): number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Counts JSON-RPC replies by LINE, because a chunk is not a line.
|
|
24
|
+
*
|
|
25
|
+
* The previous rule — `line.startsWith('{') && line.includes('"id"')` applied to each stdout chunk —
|
|
26
|
+
* held only while replies were small. JSON-RPC puts `"id"` at the END of a reply object, so a 1.4MB
|
|
27
|
+
* response arrives as ~22 chunks of which the first begins with `{` but carries no `"id"`, and the
|
|
28
|
+
* last carries `"id"` but does not begin with `{`. Measured: zero decrements, `inflight` stuck at 1
|
|
29
|
+
* forever, and the child never replaced again — live, the server ran three commits behind across
|
|
30
|
+
* four rebuilds while the isolated supervisor swapped correctly every time.
|
|
31
|
+
*
|
|
32
|
+
* The root was an asymmetry inside this one file: the stdin direction already buffers into lines and
|
|
33
|
+
* only this direction did not. It is fixed here rather than by buffering the line, because holding a
|
|
34
|
+
* 1.4MB string purely to count it would allocate a second copy of something already relayed. Two
|
|
35
|
+
* bits and a three-character tail are all the decision needs.
|
|
36
|
+
*
|
|
37
|
+
* Pure and exported for the same reason `shouldSwap` is: the sibling of this bug (incrementing
|
|
38
|
+
* before deciding, A-SPEC-162) also passed every unit test and was caught only by a live probe.
|
|
39
|
+
* The predicate was testable and its caller was not, which is exactly where both defects lived.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createResponseCounter(): ResponseCounter;
|
|
18
42
|
/**
|
|
19
43
|
* Runs the real server as a child and relays stdio, replacing the child when the build changes.
|
|
20
44
|
*
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.Supervisor = exports.AUTORELOAD_ENV = void 0;
|
|
37
37
|
exports.autoreloadEnabled = autoreloadEnabled;
|
|
38
38
|
exports.shouldSwap = shouldSwap;
|
|
39
|
+
exports.createResponseCounter = createResponseCounter;
|
|
39
40
|
// @implements A-SPEC-162
|
|
40
41
|
const node_child_process_1 = require("node:child_process");
|
|
41
42
|
const path = __importStar(require("node:path"));
|
|
@@ -66,6 +67,59 @@ function shouldSwap(loaded, disk, inflight) {
|
|
|
66
67
|
return false;
|
|
67
68
|
return inflight === 0;
|
|
68
69
|
}
|
|
70
|
+
const ID_NEEDLE = '"id"';
|
|
71
|
+
// @implements A-SPEC-516.1
|
|
72
|
+
/**
|
|
73
|
+
* Counts JSON-RPC replies by LINE, because a chunk is not a line.
|
|
74
|
+
*
|
|
75
|
+
* The previous rule — `line.startsWith('{') && line.includes('"id"')` applied to each stdout chunk —
|
|
76
|
+
* held only while replies were small. JSON-RPC puts `"id"` at the END of a reply object, so a 1.4MB
|
|
77
|
+
* response arrives as ~22 chunks of which the first begins with `{` but carries no `"id"`, and the
|
|
78
|
+
* last carries `"id"` but does not begin with `{`. Measured: zero decrements, `inflight` stuck at 1
|
|
79
|
+
* forever, and the child never replaced again — live, the server ran three commits behind across
|
|
80
|
+
* four rebuilds while the isolated supervisor swapped correctly every time.
|
|
81
|
+
*
|
|
82
|
+
* The root was an asymmetry inside this one file: the stdin direction already buffers into lines and
|
|
83
|
+
* only this direction did not. It is fixed here rather than by buffering the line, because holding a
|
|
84
|
+
* 1.4MB string purely to count it would allocate a second copy of something already relayed. Two
|
|
85
|
+
* bits and a three-character tail are all the decision needs.
|
|
86
|
+
*
|
|
87
|
+
* Pure and exported for the same reason `shouldSwap` is: the sibling of this bug (incrementing
|
|
88
|
+
* before deciding, A-SPEC-162) also passed every unit test and was caught only by a live probe.
|
|
89
|
+
* The predicate was testable and its caller was not, which is exactly where both defects lived.
|
|
90
|
+
*/
|
|
91
|
+
function createResponseCounter() {
|
|
92
|
+
let startsWithBrace = null;
|
|
93
|
+
let sawId = false;
|
|
94
|
+
let carry = '';
|
|
95
|
+
const reset = () => { startsWithBrace = null; sawId = false; carry = ''; };
|
|
96
|
+
return {
|
|
97
|
+
push(chunk) {
|
|
98
|
+
let counted = 0;
|
|
99
|
+
const parts = chunk.split('\n');
|
|
100
|
+
parts.forEach((part, i) => {
|
|
101
|
+
if (startsWithBrace === null) {
|
|
102
|
+
const firstChar = part.trimStart().charAt(0);
|
|
103
|
+
if (firstChar !== '')
|
|
104
|
+
startsWithBrace = firstChar === '{';
|
|
105
|
+
}
|
|
106
|
+
// The carry covers a needle torn across the boundary; without it a reply whose `"id"` lands
|
|
107
|
+
// on a chunk seam is invisible to both chunks.
|
|
108
|
+
if (!sawId && (carry + part).includes(ID_NEEDLE))
|
|
109
|
+
sawId = true;
|
|
110
|
+
if (i < parts.length - 1) {
|
|
111
|
+
if (startsWithBrace === true && sawId)
|
|
112
|
+
counted++;
|
|
113
|
+
reset(); // the line ended; nothing carries into the next
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
carry = part.slice(-(ID_NEEDLE.length - 1));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return counted;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
69
123
|
/**
|
|
70
124
|
* Runs the real server as a child and relays stdio, replacing the child when the build changes.
|
|
71
125
|
*
|
|
@@ -160,15 +214,18 @@ class Supervisor {
|
|
|
160
214
|
});
|
|
161
215
|
child.stderr.on('data', (d) => process.stderr.write(d));
|
|
162
216
|
this.child = child;
|
|
217
|
+
// @implements A-SPEC-516.1 — one counter per child: a half-read line from the process being
|
|
218
|
+
// replaced must not be finished by its successor's first chunk.
|
|
219
|
+
const counter = createResponseCounter();
|
|
163
220
|
child.stdout.on('data', (d) => {
|
|
164
221
|
const text = d.toString();
|
|
165
|
-
//
|
|
166
|
-
// that a response arrived for something that was counted in.
|
|
167
|
-
for (const line of text.split('\n')) {
|
|
168
|
-
if (line.trim().startsWith('{') && line.includes('"id"') && this.inflight > 0)
|
|
169
|
-
this.inflight--;
|
|
170
|
-
}
|
|
222
|
+
// Relay FIRST. Counting is an observation and must never delay or alter the bytes.
|
|
171
223
|
stdout.write(text);
|
|
224
|
+
// Count replies out so `inflight` returns to zero; the id itself does not matter here, only
|
|
225
|
+
// that a response arrived for something that was counted in. Never below zero — a negative
|
|
226
|
+
// count would let a swap happen mid-request, which is the mis-delivered answer this whole
|
|
227
|
+
// mechanism defers swaps to avoid.
|
|
228
|
+
this.inflight = Math.max(0, this.inflight - counter.push(text));
|
|
172
229
|
});
|
|
173
230
|
}
|
|
174
231
|
stop() {
|
|
@@ -182,7 +182,14 @@ exports.TOOL_SCHEMAS = {
|
|
|
182
182
|
description: 'Call-graph taint REACHABILITY screen: source-named functions (getenv/argv/req.body/…) that reach sink-named functions (exec/eval/query/…) through call edges. Returns { kind:"call-reachability", limits, maxPaths, truncated, pairs } where each pair is a source→sink candidate call path. This is a SCREENING signal that routes a security review — NOT a data-flow proof and NOT a vulnerability verdict (no def-use, no sanitizers, name-based matching, call-edge-only, as the limits state).',
|
|
183
183
|
inputSchema: {
|
|
184
184
|
type: 'object',
|
|
185
|
-
properties: {
|
|
185
|
+
properties: {
|
|
186
|
+
root: ROOT,
|
|
187
|
+
// @implements A-SPEC-512.2 — opt-in def-use lane (measured +7.6s on a 500-file repo), the
|
|
188
|
+
// same shape `cpg_scan lints` uses. Adds `dataFlow: { findings, refutedByReachingDefs,
|
|
189
|
+
// limits }`: source→sink flows recovered from definitions and uses, with findings the
|
|
190
|
+
// reaching-definition evidence refutes (dead or fully sanitized values) removed.
|
|
191
|
+
dataFlow: { type: 'boolean', description: 'Also run the def-use data-flow lane (slower). Adds a `dataFlow` section with reaching-definition-filtered findings.' },
|
|
192
|
+
},
|
|
186
193
|
required: ['root'],
|
|
187
194
|
},
|
|
188
195
|
},
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.canonicalPath = exports.MARKER = void 0;
|
|
37
37
|
exports.cleanSubprocessEnv = cleanSubprocessEnv;
|
|
38
|
+
exports.cleanTestEnv = cleanTestEnv;
|
|
38
39
|
exports.resolveProjectRoot = resolveProjectRoot;
|
|
39
40
|
// @implements A-SPEC-205
|
|
40
41
|
// @implements A-SPEC-128
|
|
@@ -56,6 +57,35 @@ function cleanSubprocessEnv(env = process.env) {
|
|
|
56
57
|
}
|
|
57
58
|
return cleaned;
|
|
58
59
|
}
|
|
60
|
+
// @implements A-SPEC-514.1
|
|
61
|
+
/**
|
|
62
|
+
* The environment a TEST subprocess gets — the secret scrub plus the configuration that steers a
|
|
63
|
+
* project's own judgement.
|
|
64
|
+
*
|
|
65
|
+
* `cleanSubprocessEnv` removes secrets, and configuration is not a secret, which is exactly how
|
|
66
|
+
* `HOLMES_SPECS` reached jest. Measured 2026-09-02: `test_run` reported 1 failed / 2142 passed
|
|
67
|
+
* twice on a commit whose full suite was green, because the MCP server's `HOLMES_SPECS` reached
|
|
68
|
+
* the suite and `isGovernedProject` short-circuited on it. The recorded lesson until then was
|
|
69
|
+
* contamination masking RED as GREEN; this is the same root pointing the other way. Evidence that
|
|
70
|
+
* depends on the shell it was measured in is not evidence.
|
|
71
|
+
*
|
|
72
|
+
* The list is EXPLICIT rather than a `/^HOLMES_/` sweep. A prefix sweep would pass every test here
|
|
73
|
+
* and still take a variable some project's own suite needs — the same false red, from the other
|
|
74
|
+
* side.
|
|
75
|
+
*
|
|
76
|
+
* `HOLMES_GATE_BYPASS` is scrubbed too: the operator's out-of-band escape hatch leaking into an
|
|
77
|
+
* evidence run would disarm every test that verifies a gate, and that contamination masks red as
|
|
78
|
+
* green — the worse direction of the two.
|
|
79
|
+
*/
|
|
80
|
+
const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE']);
|
|
81
|
+
function cleanTestEnv(env = process.env) {
|
|
82
|
+
const cleaned = cleanSubprocessEnv(env);
|
|
83
|
+
for (const k of Object.keys(cleaned)) {
|
|
84
|
+
if (TEST_SCRUB_KEYS.has(k.toUpperCase()))
|
|
85
|
+
delete cleaned[k];
|
|
86
|
+
}
|
|
87
|
+
return cleaned;
|
|
88
|
+
}
|
|
59
89
|
/** The marker directory that makes a directory a Holmes-Kit project. */
|
|
60
90
|
exports.MARKER = '.ax';
|
|
61
91
|
/**
|
|
@@ -160,3 +160,18 @@ export declare function runGo(files: string[], mode: TestRunPlan['mode'], cwd: s
|
|
|
160
160
|
* gate that ran part of its scope has verified less than it reports.
|
|
161
161
|
*/
|
|
162
162
|
export declare function runTestScope(scope: TestScope, cwd: string): TestRunResult;
|
|
163
|
+
export declare function tailOf(s: string, opts?: number | {
|
|
164
|
+
lines?: number;
|
|
165
|
+
maxChars?: number;
|
|
166
|
+
}): string;
|
|
167
|
+
/**
|
|
168
|
+
* The summary a green jest run always had — it was inside the `--json` payload rather than absent.
|
|
169
|
+
*
|
|
170
|
+
* jest writes its human summary to stderr, and the success branch reads stdout only, so the tail of
|
|
171
|
+
* a passing run carried no counts at all. The counts are right there in the JSON; this assembles
|
|
172
|
+
* them into the two lines a reader actually wanted.
|
|
173
|
+
*
|
|
174
|
+
* Returns null on anything it cannot read, and the caller falls back to the truncated original:
|
|
175
|
+
* a failed summary must not turn partial information into none.
|
|
176
|
+
*/
|
|
177
|
+
export declare function summarizeJestJson(stdout: string): string | null;
|
|
@@ -46,11 +46,16 @@ exports.runGradle = runGradle;
|
|
|
46
46
|
exports.runDotnet = runDotnet;
|
|
47
47
|
exports.runGo = runGo;
|
|
48
48
|
exports.runTestScope = runTestScope;
|
|
49
|
+
exports.tailOf = tailOf;
|
|
50
|
+
exports.summarizeJestJson = summarizeJestJson;
|
|
49
51
|
// @implements A-SPEC-102.1
|
|
50
52
|
const node_child_process_1 = require("node:child_process");
|
|
51
53
|
const fs = __importStar(require("node:fs"));
|
|
52
54
|
const os = __importStar(require("node:os"));
|
|
53
55
|
const path = __importStar(require("node:path"));
|
|
56
|
+
// @implements A-SPEC-514.1 — every TEST spawn goes out through a cleaned environment. The
|
|
57
|
+
// `--version` probes above stay as they are: they do not judge anything.
|
|
58
|
+
const root_1 = require("../project/root");
|
|
54
59
|
const python_env_1 = require("./python-env");
|
|
55
60
|
// Runner stdout ceiling. Measured 2026-09-01 on this repo: a full `jest --json` for 289 suites /
|
|
56
61
|
// 4568 tests emits >2MB, and execFileSync's DEFAULT maxBuffer (1MB) kills the child with ENOBUFS —
|
|
@@ -239,8 +244,11 @@ function runJest(files, mode, cwd) {
|
|
|
239
244
|
? [...pre, '--silent', '--json', '--runTestsByPath', '--', ...safeFiles]
|
|
240
245
|
: [...pre, '--silent', '--json'];
|
|
241
246
|
try {
|
|
242
|
-
const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
243
|
-
|
|
247
|
+
const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
248
|
+
// @implements A-SPEC-515.1 — the green path says what happened instead of handing back the
|
|
249
|
+
// whole `--json` document. `tailOf` wraps the result too, so an unexpectedly long summary is
|
|
250
|
+
// still bounded: a cap that any one path can escape is not a cap.
|
|
251
|
+
return { passed: true, tail: tailOf(summarizeJestJson(out) ?? out), executed: parseExecutedCounts(out, cwd) };
|
|
244
252
|
}
|
|
245
253
|
catch (e) {
|
|
246
254
|
const err = e;
|
|
@@ -277,7 +285,7 @@ function runPytest(files, mode, cwd) {
|
|
|
277
285
|
}
|
|
278
286
|
};
|
|
279
287
|
try {
|
|
280
|
-
const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
288
|
+
const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
281
289
|
return { passed: true, tail: tailOf(out), executed: read() };
|
|
282
290
|
}
|
|
283
291
|
catch (e) {
|
|
@@ -332,7 +340,7 @@ function runCargo(files, _mode, cwd, opts) {
|
|
|
332
340
|
let out = '';
|
|
333
341
|
let ok = true;
|
|
334
342
|
try {
|
|
335
|
-
out = (0, node_child_process_1.execFileSync)('cargo', ['test'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
343
|
+
out = (0, node_child_process_1.execFileSync)('cargo', ['test'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
336
344
|
}
|
|
337
345
|
catch (e) {
|
|
338
346
|
const err = e;
|
|
@@ -425,7 +433,7 @@ function runGradle(files, _mode, cwd, opts) {
|
|
|
425
433
|
}
|
|
426
434
|
let ok = true;
|
|
427
435
|
try {
|
|
428
|
-
(0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
436
|
+
(0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
429
437
|
}
|
|
430
438
|
catch {
|
|
431
439
|
ok = false;
|
|
@@ -457,7 +465,7 @@ function runDotnet(files, _mode, cwd, opts) {
|
|
|
457
465
|
const report = path.join(dir, 'junit.xml');
|
|
458
466
|
let ok = true;
|
|
459
467
|
try {
|
|
460
|
-
(0, node_child_process_1.execFileSync)('dotnet', ['test', '--logger', `junit;LogFilePath=${report}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
468
|
+
(0, node_child_process_1.execFileSync)('dotnet', ['test', '--logger', `junit;LogFilePath=${report}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
461
469
|
}
|
|
462
470
|
catch {
|
|
463
471
|
ok = false;
|
|
@@ -504,7 +512,7 @@ function runGo(files, mode, cwd, opts) {
|
|
|
504
512
|
const runDir = (dirArg, creditFiles) => {
|
|
505
513
|
let out = '';
|
|
506
514
|
try {
|
|
507
|
-
out = (0, node_child_process_1.execFileSync)('go', ['test', '-json', dirArg], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
|
|
515
|
+
out = (0, node_child_process_1.execFileSync)('go', ['test', '-json', dirArg], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
|
|
508
516
|
}
|
|
509
517
|
catch (e) {
|
|
510
518
|
const err = e;
|
|
@@ -617,6 +625,76 @@ function runTestScope(scope, cwd) {
|
|
|
617
625
|
executedByFile: {}, unsupported, ranWith: [],
|
|
618
626
|
};
|
|
619
627
|
}
|
|
620
|
-
|
|
621
|
-
|
|
628
|
+
// @implements A-SPEC-515.1
|
|
629
|
+
/**
|
|
630
|
+
* The last few lines of a runner's output, bounded by SIZE as well as by line count.
|
|
631
|
+
*
|
|
632
|
+
* A line budget is only a size budget while the lines are short, and jest `--json` breaks that
|
|
633
|
+
* assumption completely: it emits one line. Measured 2026-09-02 — a GREEN `test_run` returned
|
|
634
|
+
* 1,432,092 characters, of which this field was 1,421,327 (99.2%) across "6 lines" whose longest
|
|
635
|
+
* was 1,420,959. The usefulness was inverted: a red run gave six clean lines of stderr summary,
|
|
636
|
+
* and a green run gave the whole document.
|
|
637
|
+
*
|
|
638
|
+
* The END is what survives a cut. A runner's conclusion is always at the bottom.
|
|
639
|
+
*/
|
|
640
|
+
const TAIL_MAX_CHARS = 4000;
|
|
641
|
+
function tailOf(s, opts = {}) {
|
|
642
|
+
// The numeric spelling is the one the go adapter uses (`tailOf(err.stderr, 2)`); keeping it means
|
|
643
|
+
// this change cannot silently alter a caller that only ever wanted fewer lines.
|
|
644
|
+
const { lines = 6, maxChars = TAIL_MAX_CHARS } = typeof opts === 'number' ? { lines: opts } : opts;
|
|
645
|
+
const picked = s.trim().split('\n').slice(-lines).join('\n');
|
|
646
|
+
if (picked.length <= maxChars)
|
|
647
|
+
return picked;
|
|
648
|
+
const dropped = picked.length - maxChars;
|
|
649
|
+
return `[…앞부분 ${dropped}자 생략…]\n${picked.slice(-maxChars)}`;
|
|
650
|
+
}
|
|
651
|
+
// @implements A-SPEC-515.1
|
|
652
|
+
/**
|
|
653
|
+
* The summary a green jest run always had — it was inside the `--json` payload rather than absent.
|
|
654
|
+
*
|
|
655
|
+
* jest writes its human summary to stderr, and the success branch reads stdout only, so the tail of
|
|
656
|
+
* a passing run carried no counts at all. The counts are right there in the JSON; this assembles
|
|
657
|
+
* them into the two lines a reader actually wanted.
|
|
658
|
+
*
|
|
659
|
+
* Returns null on anything it cannot read, and the caller falls back to the truncated original:
|
|
660
|
+
* a failed summary must not turn partial information into none.
|
|
661
|
+
*/
|
|
662
|
+
function summarizeJestJson(stdout) {
|
|
663
|
+
// The payload is not the whole of stdout. A suite that logs anything prints it BEFORE the JSON,
|
|
664
|
+
// and this repository's own suite does: measured, the first live run after this function landed
|
|
665
|
+
// still returned 1.4MB because `JSON.parse(stdout)` threw on the console noise and the fallback
|
|
666
|
+
// handed back the raw document. `parseExecutedCounts` already locates the payload from the first
|
|
667
|
+
// `{`; using a second, stricter rule for the same string is how the two quietly disagree.
|
|
668
|
+
const start = stdout.indexOf('{');
|
|
669
|
+
if (start < 0)
|
|
670
|
+
return null;
|
|
671
|
+
let j;
|
|
672
|
+
try {
|
|
673
|
+
j = JSON.parse(stdout.slice(start));
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
if (j === null || typeof j !== 'object')
|
|
679
|
+
return null;
|
|
680
|
+
const num = (k) => (typeof j[k] === 'number' ? j[k] : null);
|
|
681
|
+
const totalSuites = num('numTotalTestSuites');
|
|
682
|
+
const totalTests = num('numTotalTests');
|
|
683
|
+
if (totalSuites === null || totalTests === null)
|
|
684
|
+
return null;
|
|
685
|
+
const line = (label, failed, skipped, passed, total) => {
|
|
686
|
+
const parts = [];
|
|
687
|
+
if (failed !== null && failed > 0)
|
|
688
|
+
parts.push(`${failed} failed`);
|
|
689
|
+
if (skipped !== null && skipped > 0)
|
|
690
|
+
parts.push(`${skipped} skipped`);
|
|
691
|
+
if (passed !== null)
|
|
692
|
+
parts.push(`${passed} passed`);
|
|
693
|
+
parts.push(`${total} total`);
|
|
694
|
+
return `${label}${parts.join(', ')}`;
|
|
695
|
+
};
|
|
696
|
+
return [
|
|
697
|
+
line('Test Suites: ', num('numFailedTestSuites'), num('numPendingTestSuites'), num('numPassedTestSuites'), totalSuites),
|
|
698
|
+
line('Tests: ', num('numFailedTests'), num('numPendingTests'), num('numPassedTests'), totalTests),
|
|
699
|
+
].join('\n');
|
|
622
700
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DATAFLOW_LIMITS = void 0;
|
|
4
4
|
exports.taintDataFlow = taintDataFlow;
|
|
5
|
+
const sink_matching_1 = require("./sink-matching");
|
|
5
6
|
/**
|
|
6
7
|
* What this pass does NOT model. Carried in every result, for the same reason REQ-138 carries
|
|
7
8
|
* `TAINT_LIMITS`: a screening signal that does not state its blind spots is read as a proof.
|
|
@@ -226,7 +227,10 @@ function taintDataFlow(factSet, cfg) {
|
|
|
226
227
|
// SINK — reported after the fixpoint, so a finding always reflects the complete taint state.
|
|
227
228
|
const all = [];
|
|
228
229
|
for (const c of calls) {
|
|
229
|
-
|
|
230
|
+
// @implements A-SPEC-512.3 — the ONE sink decision, receiver-aware: an exact name match that
|
|
231
|
+
// is not a RegExp method on a regex receiver. 16 of this repository's 19 findings were
|
|
232
|
+
// `re.exec(...)` before this delegation.
|
|
233
|
+
if (!(0, sink_matching_1.isSinkCall)(c.calleeText ?? c.callee, c.callee, cfg.sinks))
|
|
230
234
|
continue;
|
|
231
235
|
for (let i = 0; i < c.args.length; i++) {
|
|
232
236
|
const from = carrier(c.args[i], fid(c.file, c.fn));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CPG foundation's FIRST consumer: taint judged on REACHING DEFINITIONS instead of on
|
|
3
|
+
* "was a sanitizer mentioned anywhere in this function".
|
|
4
|
+
*
|
|
5
|
+
* The rules do not change — sources, sinks and sanitizers come from the same config the
|
|
6
|
+
* flow-insensitive engine uses. What changes is what those rule matches are COMBINED over: a
|
|
7
|
+
* tainted definition is dangerous only if it actually REACHES the sink's use. Two measured false
|
|
8
|
+
* positives fall out of that automatically:
|
|
9
|
+
* `let c = req.body.cmd; c = "ls"; exec(c);` → the constant redefinition kills the taint
|
|
10
|
+
* `if (s) { c = clean(c); } else { c = clean(c); }` → every path redefines, so nothing tainted arrives
|
|
11
|
+
* and one thing deliberately does NOT change: when only ONE branch sanitizes, the tainted
|
|
12
|
+
* definition still reaches, so the finding stands. This is a may-analysis and stays one —
|
|
13
|
+
* trading a false positive for a false negative would not be an improvement (REQ-512).
|
|
14
|
+
*/
|
|
15
|
+
import type { PersistedAst } from '../cpg/foundation/ast-store';
|
|
16
|
+
import type { Cfg } from '../cpg/foundation/cfg';
|
|
17
|
+
import type { Ddg } from '../cpg/foundation/ddg';
|
|
18
|
+
import type { DataFlowTaintConfig } from './dataflow-taint';
|
|
19
|
+
export interface FlowTaintFinding {
|
|
20
|
+
fn: string;
|
|
21
|
+
callee: string;
|
|
22
|
+
line: number;
|
|
23
|
+
argIndex: number;
|
|
24
|
+
argText: string;
|
|
25
|
+
/** Why this is reported: the tainted definition that reaches this sink use. */
|
|
26
|
+
taintedDef: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FlowTaintResult {
|
|
29
|
+
findings: FlowTaintFinding[];
|
|
30
|
+
mode: 'flow-sensitive';
|
|
31
|
+
/** Per-statement classification, exposed so a test can prove the DDG was actually consulted. */
|
|
32
|
+
taintedDefStmts: number[];
|
|
33
|
+
}
|
|
34
|
+
export declare function flowSensitiveTaint(input: {
|
|
35
|
+
ast: PersistedAst;
|
|
36
|
+
cfg: Cfg;
|
|
37
|
+
ddg: Ddg;
|
|
38
|
+
source: string;
|
|
39
|
+
fnName: string;
|
|
40
|
+
config: DataFlowTaintConfig;
|
|
41
|
+
}): FlowTaintResult;
|