gitnexus 1.6.8-rc.21 → 1.6.8-rc.23
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/dist/core/incremental/subgraph-extract.js +12 -1
- package/dist/core/ingestion/pipeline-phases/index.d.ts +1 -0
- package/dist/core/ingestion/pipeline-phases/index.js +1 -0
- package/dist/core/ingestion/pipeline-phases/taint-summaries.d.ts +30 -0
- package/dist/core/ingestion/pipeline-phases/taint-summaries.js +81 -0
- package/dist/core/ingestion/pipeline.d.ts +13 -0
- package/dist/core/ingestion/pipeline.js +5 -1
- package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +7 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +15 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +17 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +37 -1
- package/dist/core/ingestion/taint/interproc-emit.d.ts +50 -0
- package/dist/core/ingestion/taint/interproc-emit.js +88 -0
- package/dist/core/ingestion/taint/interproc-solver.d.ts +110 -0
- package/dist/core/ingestion/taint/interproc-solver.js +312 -0
- package/dist/core/ingestion/taint/propagate.d.ts +1 -1
- package/dist/core/ingestion/taint/propagate.js +4 -9
- package/dist/core/ingestion/taint/source-sink-config.d.ts +13 -0
- package/dist/core/ingestion/taint/source-sink-config.js +24 -1
- package/dist/core/ingestion/taint/summary-harvest-driver.d.ts +56 -0
- package/dist/core/ingestion/taint/summary-harvest-driver.js +118 -0
- package/dist/core/ingestion/taint/summary-harvest.d.ts +89 -0
- package/dist/core/ingestion/taint/summary-harvest.js +537 -0
- package/dist/core/ingestion/taint/summary-model.d.ts +200 -0
- package/dist/core/ingestion/taint/summary-model.js +86 -0
- package/dist/core/lbug/lbug-adapter.d.ts +18 -0
- package/dist/core/lbug/lbug-adapter.js +55 -0
- package/dist/core/run-analyze.d.ts +7 -1
- package/dist/core/run-analyze.js +21 -1
- package/dist/mcp/local/local-backend.js +93 -10
- package/dist/mcp/tools.js +7 -6
- package/dist/storage/repo-manager.d.ts +10 -0
- package/hooks/antigravity/gitnexus-antigravity-hook.cjs +89 -3
- package/hooks/claude/gitnexus-hook.cjs +88 -4
- package/hooks/claude/hook-db-lock-probe.cjs +53 -21
- package/package.json +1 -1
- package/skills/gitnexus-taint-analysis.md +178 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interprocedural taint fixpoint (#2084 M4 U3).
|
|
3
|
+
*
|
|
4
|
+
* Composes per-function {@link FunctionSummary} objects over the resolved
|
|
5
|
+
* `CALLS` graph to find source→sink flows that cross function and file
|
|
6
|
+
* boundaries. PURE AND DETERMINISTIC (no graph, no I/O, no logger) — the phase
|
|
7
|
+
* builds the inputs from `ctx.graph` and persists the outputs.
|
|
8
|
+
*
|
|
9
|
+
* ## The model — whole-parameter taint reachability
|
|
10
|
+
*
|
|
11
|
+
* The unit of taint is `(function, parameter)`. The fixpoint computes the set
|
|
12
|
+
* of parameters that can hold source-derived data, then fires a finding
|
|
13
|
+
* whenever a tainted parameter feeds a modelled sink (`paramToSink`).
|
|
14
|
+
*
|
|
15
|
+
* - **Seeds** — every `sourceToCallArg` edge: a function generates a source and
|
|
16
|
+
* passes it into argument `argIndex` of a call at `callLine`. Resolving that
|
|
17
|
+
* call site against the caller's outgoing `CALLS` edges yields the callee;
|
|
18
|
+
* the callee's parameter `argIndex` becomes tainted, with the generating
|
|
19
|
+
* function recorded as the flow's source.
|
|
20
|
+
* - **Propagation** — every `paramToCallArg` edge of a function whose parameter
|
|
21
|
+
* is ALREADY tainted: `param i → arg j of callee` taints the callee's
|
|
22
|
+
* parameter `j` (TITO composition). Iterated to a fixpoint.
|
|
23
|
+
* - **Findings** — whenever a parameter becomes tainted and the owning
|
|
24
|
+
* function's `paramToSink` contains that parameter, a cross-function finding
|
|
25
|
+
* is emitted (source function → sink function, with the kind).
|
|
26
|
+
*
|
|
27
|
+
* ## Cycle safety (recursion)
|
|
28
|
+
*
|
|
29
|
+
* The tainted-parameter set is monotone over a FINITE lattice (`Σ functions ×
|
|
30
|
+
* params`), so the worklist fixpoint converges: a recursive or mutually
|
|
31
|
+
* recursive call merely re-proposes an already-tainted parameter, which the
|
|
32
|
+
* visited-set absorbs — no infinite descent. This is the functional/summary
|
|
33
|
+
* method's standard termination argument (Sharir-Pnueli; Pysa, Mariana Trench,
|
|
34
|
+
* and Infer all rely on it). SCC condensation would only refine the PROCESSING
|
|
35
|
+
* ORDER; correctness and termination do not require it.
|
|
36
|
+
*
|
|
37
|
+
* ## Context-insensitivity & the name-join over-approximation
|
|
38
|
+
*
|
|
39
|
+
* One summary per function, applied at every call site — return/param merging
|
|
40
|
+
* is accepted (the security-conservative direction). The call-arg→callee join
|
|
41
|
+
* is by callee NAME (not line), so when one caller invokes two DISTINCT
|
|
42
|
+
* same-named callees (`x.handler(src)` and `y.handler(clean)`), a source that
|
|
43
|
+
* flowed into ONE of them taints BOTH callees' parameter — an extra finding on
|
|
44
|
+
* the callee the source did not reach. This is sound (over-attribution, never a
|
|
45
|
+
* missed flow — the conservative direction for a security tool) and is the
|
|
46
|
+
* documented price of dropping the fragile line-based join; the `explain` tool
|
|
47
|
+
* surfaces it ("may over-attribute among same-named callees"). Other known
|
|
48
|
+
* precision losses (call-site conflation, shared dispatch, callbacks) are the
|
|
49
|
+
* documented M4 trade-offs; refinements are deferred (plan KTD).
|
|
50
|
+
*/
|
|
51
|
+
import type { SinkKind } from './source-sink-config.js';
|
|
52
|
+
import type { FunctionSummary } from './summary-model.js';
|
|
53
|
+
/**
|
|
54
|
+
* One resolved call edge from the `CALLS` graph. The join to a summary's
|
|
55
|
+
* call-arg edge is by CALLEE NAME (the callee node's declared name), NOT by
|
|
56
|
+
* call-site line — line-base parity between the CFG harvest (1-based) and the
|
|
57
|
+
* reference site is fragile, while the callee identity is exact and the
|
|
58
|
+
* context-insensitive model tatints the callee's parameter the same way at
|
|
59
|
+
* every call site to it.
|
|
60
|
+
*/
|
|
61
|
+
export interface InterprocCallEdge {
|
|
62
|
+
readonly callerId: string;
|
|
63
|
+
readonly calleeId: string;
|
|
64
|
+
/** The callee node's declared name (`helper`, `process`) — the join key. */
|
|
65
|
+
readonly calleeName: string;
|
|
66
|
+
}
|
|
67
|
+
/** One hop of a cross-function flow: the function entered, and how. */
|
|
68
|
+
export interface InterprocHop {
|
|
69
|
+
readonly fnId: string;
|
|
70
|
+
/** The call-site line in the PREVIOUS function that entered this one. */
|
|
71
|
+
readonly callLine?: number;
|
|
72
|
+
/** Argument position the taint entered through (undefined for the source fn). */
|
|
73
|
+
readonly argIndex?: number;
|
|
74
|
+
}
|
|
75
|
+
export interface InterprocFinding {
|
|
76
|
+
readonly sourceFnId: string;
|
|
77
|
+
readonly sinkFnId: string;
|
|
78
|
+
readonly sinkKind: SinkKind;
|
|
79
|
+
/** Ordered source→sink hop chain (functions). A prefix when `truncated`. */
|
|
80
|
+
readonly hops: readonly InterprocHop[];
|
|
81
|
+
readonly hopsTruncated: boolean;
|
|
82
|
+
}
|
|
83
|
+
export interface InterprocLimits {
|
|
84
|
+
/** Max functions in a single flow's hop chain. `undefined`/0 ⇒ default 32. */
|
|
85
|
+
readonly maxHops?: number;
|
|
86
|
+
/** Max findings overall (post-dedup). `undefined`/0 ⇒ unlimited. */
|
|
87
|
+
readonly maxFindings?: number;
|
|
88
|
+
}
|
|
89
|
+
export interface InterprocResult {
|
|
90
|
+
readonly findings: readonly InterprocFinding[];
|
|
91
|
+
/** Findings dropped by `maxFindings` (post-dedup). */
|
|
92
|
+
readonly droppedFindings: number;
|
|
93
|
+
/** Call edges whose call-site line matched no summary edge (diagnostics). */
|
|
94
|
+
readonly unmatchedCallSites: number;
|
|
95
|
+
}
|
|
96
|
+
export declare const DEFAULT_MAX_INTERPROC_HOPS = 32;
|
|
97
|
+
/**
|
|
98
|
+
* Default per-run cap on cross-function findings (#2084 review P1-3). Like the
|
|
99
|
+
* other pdg caps it is resolved into `RepoMeta.pdg` so `pdgModeMismatch`
|
|
100
|
+
* stamps it; `0` ⇒ unlimited. 2000 is generous for a real repo — more deduped
|
|
101
|
+
* `(source, sink, kind)` findings than that is a fixture or a runaway fan-in,
|
|
102
|
+
* and the overflow is deterministic + counted (`droppedFindings`).
|
|
103
|
+
*/
|
|
104
|
+
export declare const DEFAULT_PDG_MAX_INTERPROC_FINDINGS = 2000;
|
|
105
|
+
/**
|
|
106
|
+
* Run the interprocedural taint fixpoint. `summaries` is keyed by function node
|
|
107
|
+
* id; `callEdges` is the resolved `CALLS` graph (caller→callee with call-site
|
|
108
|
+
* lines). Deterministic: inputs in, sorted findings out.
|
|
109
|
+
*/
|
|
110
|
+
export declare function solveInterprocTaint(summaries: ReadonlyMap<string, FunctionSummary>, callEdges: readonly InterprocCallEdge[], limits?: InterprocLimits): InterprocResult;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interprocedural taint fixpoint (#2084 M4 U3).
|
|
3
|
+
*
|
|
4
|
+
* Composes per-function {@link FunctionSummary} objects over the resolved
|
|
5
|
+
* `CALLS` graph to find source→sink flows that cross function and file
|
|
6
|
+
* boundaries. PURE AND DETERMINISTIC (no graph, no I/O, no logger) — the phase
|
|
7
|
+
* builds the inputs from `ctx.graph` and persists the outputs.
|
|
8
|
+
*
|
|
9
|
+
* ## The model — whole-parameter taint reachability
|
|
10
|
+
*
|
|
11
|
+
* The unit of taint is `(function, parameter)`. The fixpoint computes the set
|
|
12
|
+
* of parameters that can hold source-derived data, then fires a finding
|
|
13
|
+
* whenever a tainted parameter feeds a modelled sink (`paramToSink`).
|
|
14
|
+
*
|
|
15
|
+
* - **Seeds** — every `sourceToCallArg` edge: a function generates a source and
|
|
16
|
+
* passes it into argument `argIndex` of a call at `callLine`. Resolving that
|
|
17
|
+
* call site against the caller's outgoing `CALLS` edges yields the callee;
|
|
18
|
+
* the callee's parameter `argIndex` becomes tainted, with the generating
|
|
19
|
+
* function recorded as the flow's source.
|
|
20
|
+
* - **Propagation** — every `paramToCallArg` edge of a function whose parameter
|
|
21
|
+
* is ALREADY tainted: `param i → arg j of callee` taints the callee's
|
|
22
|
+
* parameter `j` (TITO composition). Iterated to a fixpoint.
|
|
23
|
+
* - **Findings** — whenever a parameter becomes tainted and the owning
|
|
24
|
+
* function's `paramToSink` contains that parameter, a cross-function finding
|
|
25
|
+
* is emitted (source function → sink function, with the kind).
|
|
26
|
+
*
|
|
27
|
+
* ## Cycle safety (recursion)
|
|
28
|
+
*
|
|
29
|
+
* The tainted-parameter set is monotone over a FINITE lattice (`Σ functions ×
|
|
30
|
+
* params`), so the worklist fixpoint converges: a recursive or mutually
|
|
31
|
+
* recursive call merely re-proposes an already-tainted parameter, which the
|
|
32
|
+
* visited-set absorbs — no infinite descent. This is the functional/summary
|
|
33
|
+
* method's standard termination argument (Sharir-Pnueli; Pysa, Mariana Trench,
|
|
34
|
+
* and Infer all rely on it). SCC condensation would only refine the PROCESSING
|
|
35
|
+
* ORDER; correctness and termination do not require it.
|
|
36
|
+
*
|
|
37
|
+
* ## Context-insensitivity & the name-join over-approximation
|
|
38
|
+
*
|
|
39
|
+
* One summary per function, applied at every call site — return/param merging
|
|
40
|
+
* is accepted (the security-conservative direction). The call-arg→callee join
|
|
41
|
+
* is by callee NAME (not line), so when one caller invokes two DISTINCT
|
|
42
|
+
* same-named callees (`x.handler(src)` and `y.handler(clean)`), a source that
|
|
43
|
+
* flowed into ONE of them taints BOTH callees' parameter — an extra finding on
|
|
44
|
+
* the callee the source did not reach. This is sound (over-attribution, never a
|
|
45
|
+
* missed flow — the conservative direction for a security tool) and is the
|
|
46
|
+
* documented price of dropping the fragile line-based join; the `explain` tool
|
|
47
|
+
* surfaces it ("may over-attribute among same-named callees"). Other known
|
|
48
|
+
* precision losses (call-site conflation, shared dispatch, callbacks) are the
|
|
49
|
+
* documented M4 trade-offs; refinements are deferred (plan KTD).
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_MAX_INTERPROC_HOPS = 32;
|
|
52
|
+
/**
|
|
53
|
+
* Default per-run cap on cross-function findings (#2084 review P1-3). Like the
|
|
54
|
+
* other pdg caps it is resolved into `RepoMeta.pdg` so `pdgModeMismatch`
|
|
55
|
+
* stamps it; `0` ⇒ unlimited. 2000 is generous for a real repo — more deduped
|
|
56
|
+
* `(source, sink, kind)` findings than that is a fixture or a runaway fan-in,
|
|
57
|
+
* and the overflow is deterministic + counted (`droppedFindings`).
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_PDG_MAX_INTERPROC_FINDINGS = 2000;
|
|
60
|
+
/**
|
|
61
|
+
* Taint-state key — `(function, parameter, SOURCE)`. The source discriminator
|
|
62
|
+
* is load-bearing: without it, a parameter tainted by source A is marked
|
|
63
|
+
* visited and a later flow from source B to the SAME parameter is dropped
|
|
64
|
+
* before it can fire that function's sink, silently losing B→sink (the
|
|
65
|
+
* multi-source collapse — the recurring M3 bug class). Including the source
|
|
66
|
+
* keeps each origin's flow independent; the lattice stays finite (`fn × param ×
|
|
67
|
+
* source`), so the monotone worklist still terminates and is cycle-safe.
|
|
68
|
+
*/
|
|
69
|
+
const pkey = (fnId, param, sourceFnId) => `${fnId}#${param}#${sourceFnId}`;
|
|
70
|
+
/**
|
|
71
|
+
* Run the interprocedural taint fixpoint. `summaries` is keyed by function node
|
|
72
|
+
* id; `callEdges` is the resolved `CALLS` graph (caller→callee with call-site
|
|
73
|
+
* lines). Deterministic: inputs in, sorted findings out.
|
|
74
|
+
*/
|
|
75
|
+
export function solveInterprocTaint(summaries, callEdges, limits) {
|
|
76
|
+
const maxHops = limits?.maxHops && limits.maxHops > 0 ? limits.maxHops : DEFAULT_MAX_INTERPROC_HOPS;
|
|
77
|
+
// Adjacency built ONCE (#2084 review P3-8): callerId → outgoing edges, AND
|
|
78
|
+
// callerId → calleeName → edges. The summary's call-arg edges resolve by
|
|
79
|
+
// callee NAME, so the per-name index turns each resolution into an O(1)
|
|
80
|
+
// lookup instead of a per-worklist-step `.filter` allocation (the
|
|
81
|
+
// build-index-once pattern).
|
|
82
|
+
const callsByCaller = new Map();
|
|
83
|
+
const callsByCallerName = new Map();
|
|
84
|
+
for (const e of callEdges) {
|
|
85
|
+
const list = callsByCaller.get(e.callerId);
|
|
86
|
+
if (list)
|
|
87
|
+
list.push(e);
|
|
88
|
+
else
|
|
89
|
+
callsByCaller.set(e.callerId, [e]);
|
|
90
|
+
let byName = callsByCallerName.get(e.callerId);
|
|
91
|
+
if (!byName) {
|
|
92
|
+
byName = new Map();
|
|
93
|
+
callsByCallerName.set(e.callerId, byName);
|
|
94
|
+
}
|
|
95
|
+
const named = byName.get(e.calleeName);
|
|
96
|
+
if (named)
|
|
97
|
+
named.push(e);
|
|
98
|
+
else
|
|
99
|
+
byName.set(e.calleeName, [e]);
|
|
100
|
+
}
|
|
101
|
+
let unmatchedCallSites = 0;
|
|
102
|
+
/** Edges to `name` from `callerId` (O(1)); empty if none — non-counting. */
|
|
103
|
+
const calleesByName = (callerId, name) => callsByCallerName.get(callerId)?.get(name) ?? [];
|
|
104
|
+
// Resolve a caller's call-arg edge (by callee name) to concrete callee edges.
|
|
105
|
+
// An unknown callee name (chain not statically resolvable) conservatively
|
|
106
|
+
// matches EVERY outgoing call — sound over-approximation (may over-taint).
|
|
107
|
+
const resolveCallees = (callerId, calleeName) => {
|
|
108
|
+
const candidates = callsByCaller.get(callerId);
|
|
109
|
+
if (!candidates || candidates.length === 0) {
|
|
110
|
+
unmatchedCallSites++;
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
if (calleeName === undefined)
|
|
114
|
+
return candidates;
|
|
115
|
+
const named = calleesByName(callerId, calleeName);
|
|
116
|
+
if (named.length === 0) {
|
|
117
|
+
unmatchedCallSites++;
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
return named;
|
|
121
|
+
};
|
|
122
|
+
// ── findings + worklist ───────────────────────────────────────────────────
|
|
123
|
+
const findingsByKey = new Map();
|
|
124
|
+
const tainted = new Map();
|
|
125
|
+
const queue = [];
|
|
126
|
+
const recordFinding = (sourceFnId, sinkFnId, sinkKind, hops, truncated) => {
|
|
127
|
+
const key = `${sourceFnId}|${sinkFnId}|${sinkKind}`;
|
|
128
|
+
if (findingsByKey.has(key))
|
|
129
|
+
return;
|
|
130
|
+
findingsByKey.set(key, { sourceFnId, sinkFnId, sinkKind, hops, hopsTruncated: truncated });
|
|
131
|
+
};
|
|
132
|
+
/** Fire every `paramToSink` of `tp`'s param, except kinds it neutralised. */
|
|
133
|
+
const fireSinks = (tp) => {
|
|
134
|
+
const summary = summaries.get(tp.fnId);
|
|
135
|
+
if (!summary)
|
|
136
|
+
return;
|
|
137
|
+
for (const ps of summary.paramToSink) {
|
|
138
|
+
if (ps.param !== tp.paramIndex)
|
|
139
|
+
continue;
|
|
140
|
+
if (tp.neutralized.has(ps.sinkKind))
|
|
141
|
+
continue; // sanitised across the boundary (P1-2)
|
|
142
|
+
// `tp.hops` already terminates at this (tainted) function — it IS the
|
|
143
|
+
// source→sink chain, no extra hop to append.
|
|
144
|
+
recordFinding(tp.sourceFnId, tp.fnId, ps.sinkKind, tp.hops, tp.truncated);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Mark (fnId, paramIndex, source) tainted; enqueue. On a fresh key, taint +
|
|
149
|
+
* fire sinks. On revisit, INTERSECT the neutralised set (a kind stays
|
|
150
|
+
* neutralised only if EVERY path neutralises it — the sound direction); if it
|
|
151
|
+
* shrank, re-enqueue + re-fire so a less-neutralised path's sinks surface
|
|
152
|
+
* (the shrink-reprocess guard, mirroring `propagate.ts:deriveTaint`). Without
|
|
153
|
+
* it, a first more-neutralised path would freeze out a real finding (FN).
|
|
154
|
+
*/
|
|
155
|
+
const taint = (tp) => {
|
|
156
|
+
const key = pkey(tp.fnId, tp.paramIndex, tp.sourceFnId);
|
|
157
|
+
const existing = tainted.get(key);
|
|
158
|
+
if (existing) {
|
|
159
|
+
const inter = new Set();
|
|
160
|
+
for (const k of existing.neutralized)
|
|
161
|
+
if (tp.neutralized.has(k))
|
|
162
|
+
inter.add(k);
|
|
163
|
+
if (inter.size >= existing.neutralized.size)
|
|
164
|
+
return; // no shrink — cycle-safe
|
|
165
|
+
const merged = { ...existing, neutralized: inter };
|
|
166
|
+
tainted.set(key, merged);
|
|
167
|
+
queue.push(merged);
|
|
168
|
+
fireSinks(merged);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
tainted.set(key, tp);
|
|
172
|
+
queue.push(tp);
|
|
173
|
+
fireSinks(tp);
|
|
174
|
+
};
|
|
175
|
+
// ── seeds: every source→callee-arg, resolved against CALLS ────────────────
|
|
176
|
+
for (const [callerId, summary] of summaries) {
|
|
177
|
+
for (const sc of summary.sourceToCallArg) {
|
|
178
|
+
for (const edge of resolveCallees(callerId, sc.calleeName)) {
|
|
179
|
+
const callee = summaries.get(edge.calleeId);
|
|
180
|
+
if (!callee)
|
|
181
|
+
continue;
|
|
182
|
+
if (sc.argIndex >= callee.paramCount)
|
|
183
|
+
continue; // arity guard
|
|
184
|
+
// Build the seed path through the capped append so `maxHops` truncates
|
|
185
|
+
// the prefix (#2084 review P2-7), not a 2-entry path flagged truncated.
|
|
186
|
+
const seed = appendHop([{ fnId: callerId }], { fnId: edge.calleeId, callLine: sc.callLine, argIndex: sc.argIndex }, maxHops);
|
|
187
|
+
taint({
|
|
188
|
+
fnId: edge.calleeId,
|
|
189
|
+
paramIndex: sc.argIndex,
|
|
190
|
+
sourceFnId: callerId,
|
|
191
|
+
hops: seed.hops,
|
|
192
|
+
truncated: seed.truncated,
|
|
193
|
+
neutralized: new Set(sc.neutralized ?? []),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ── generative return composition (#2084 review P1-1) ─────────────────────
|
|
199
|
+
// `genReturns` = functions whose RETURN carries a generated source. Seed with
|
|
200
|
+
// `sourceToReturn`; a caller that returns the result of a generative call is
|
|
201
|
+
// itself generative (transitive — `wrap(){ return getInput() }`). Small
|
|
202
|
+
// monotone fixpoint over the name-resolved call graph (`calleesByName`).
|
|
203
|
+
const genReturns = new Set();
|
|
204
|
+
for (const [id, s] of summaries)
|
|
205
|
+
if (s.sourceToReturn.length > 0)
|
|
206
|
+
genReturns.add(id);
|
|
207
|
+
let grChanged = true;
|
|
208
|
+
while (grChanged) {
|
|
209
|
+
grChanged = false;
|
|
210
|
+
for (const [callerId, s] of summaries) {
|
|
211
|
+
if (genReturns.has(callerId))
|
|
212
|
+
continue;
|
|
213
|
+
for (const cr of s.callResults) {
|
|
214
|
+
if (cr.dest.to !== 'return')
|
|
215
|
+
continue;
|
|
216
|
+
if (calleesByName(callerId, cr.calleeName).some((e) => genReturns.has(e.calleeId))) {
|
|
217
|
+
genReturns.add(callerId);
|
|
218
|
+
grChanged = true;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Compose: a caller using a generative call's result either FIRES (the result
|
|
225
|
+
// hits a sink) or SEEDS (the result flows into another call's arg). The
|
|
226
|
+
// generated source's origin is the generative callee.
|
|
227
|
+
for (const [callerId, s] of summaries) {
|
|
228
|
+
for (const cr of s.callResults) {
|
|
229
|
+
const generative = calleesByName(callerId, cr.calleeName).filter((e) => genReturns.has(e.calleeId));
|
|
230
|
+
if (generative.length === 0)
|
|
231
|
+
continue;
|
|
232
|
+
for (const g of generative) {
|
|
233
|
+
const d = cr.dest;
|
|
234
|
+
if (d.to === 'sink') {
|
|
235
|
+
recordFinding(g.calleeId, callerId, d.sinkKind, [{ fnId: g.calleeId }, { fnId: callerId }], 2 > maxHops);
|
|
236
|
+
}
|
|
237
|
+
else if (d.to === 'callArg') {
|
|
238
|
+
for (const tc of d.toCallee === undefined
|
|
239
|
+
? (callsByCaller.get(callerId) ?? [])
|
|
240
|
+
: calleesByName(callerId, d.toCallee)) {
|
|
241
|
+
const callee = summaries.get(tc.calleeId);
|
|
242
|
+
if (!callee || d.argIndex >= callee.paramCount)
|
|
243
|
+
continue;
|
|
244
|
+
// Capped successive append so `maxHops` truncates the prefix (P2-7).
|
|
245
|
+
const h1 = appendHop([{ fnId: g.calleeId }], { fnId: callerId }, maxHops);
|
|
246
|
+
const h2 = appendHop(h1.hops, { fnId: tc.calleeId, argIndex: d.argIndex }, maxHops);
|
|
247
|
+
taint({
|
|
248
|
+
fnId: tc.calleeId,
|
|
249
|
+
paramIndex: d.argIndex,
|
|
250
|
+
sourceFnId: g.calleeId,
|
|
251
|
+
hops: h2.hops,
|
|
252
|
+
truncated: h1.truncated || h2.truncated,
|
|
253
|
+
neutralized: new Set(),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// dest:'return' is already folded into `genReturns` above.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ── propagation worklist ──────────────────────────────────────────────────
|
|
262
|
+
let head = 0;
|
|
263
|
+
while (head < queue.length) {
|
|
264
|
+
const tp = queue[head++];
|
|
265
|
+
const summary = summaries.get(tp.fnId);
|
|
266
|
+
if (!summary)
|
|
267
|
+
continue;
|
|
268
|
+
// This function's tainted param flows into callee args via paramToCallArg.
|
|
269
|
+
for (const pc of summary.paramToCallArg) {
|
|
270
|
+
if (pc.param !== tp.paramIndex)
|
|
271
|
+
continue;
|
|
272
|
+
for (const edge of resolveCallees(tp.fnId, pc.calleeName)) {
|
|
273
|
+
const callee = summaries.get(edge.calleeId);
|
|
274
|
+
if (!callee)
|
|
275
|
+
continue;
|
|
276
|
+
if (pc.argIndex >= callee.paramCount)
|
|
277
|
+
continue;
|
|
278
|
+
const next = appendHop(tp.hops, { fnId: edge.calleeId, callLine: pc.callLine, argIndex: pc.argIndex }, maxHops);
|
|
279
|
+
// Union the edge's neutralised kinds onto the composed path (a
|
|
280
|
+
// sanitizer between this param and the callee arg stays neutralised).
|
|
281
|
+
const neutralized = pc.neutralized && pc.neutralized.length > 0
|
|
282
|
+
? new Set([...tp.neutralized, ...pc.neutralized])
|
|
283
|
+
: tp.neutralized;
|
|
284
|
+
taint({
|
|
285
|
+
fnId: edge.calleeId,
|
|
286
|
+
paramIndex: pc.argIndex,
|
|
287
|
+
sourceFnId: tp.sourceFnId,
|
|
288
|
+
hops: next.hops,
|
|
289
|
+
truncated: tp.truncated || next.truncated,
|
|
290
|
+
neutralized,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// ── deterministic assembly ────────────────────────────────────────────────
|
|
296
|
+
const all = [...findingsByKey.values()].sort((a, b) => a.sourceFnId.localeCompare(b.sourceFnId) ||
|
|
297
|
+
a.sinkFnId.localeCompare(b.sinkFnId) ||
|
|
298
|
+
a.sinkKind.localeCompare(b.sinkKind));
|
|
299
|
+
const maxFindings = limits?.maxFindings && limits.maxFindings > 0 ? limits.maxFindings : Infinity;
|
|
300
|
+
const findings = all.length > maxFindings ? all.slice(0, maxFindings) : all;
|
|
301
|
+
return {
|
|
302
|
+
findings,
|
|
303
|
+
droppedFindings: all.length - findings.length,
|
|
304
|
+
unmatchedCallSites,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/** Append a hop, respecting the hop cap (keeps the source-side prefix). */
|
|
308
|
+
function appendHop(hops, hop, maxHops) {
|
|
309
|
+
if (hops.length >= maxHops)
|
|
310
|
+
return { hops, truncated: true };
|
|
311
|
+
return { hops: [...hops, hop], truncated: hops.length + 1 > maxHops };
|
|
312
|
+
}
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
import type { FunctionCfg } from '../cfg/types.js';
|
|
99
99
|
import type { FunctionDefUse, ProgramPoint } from '../cfg/reaching-defs.js';
|
|
100
100
|
import type { FunctionSiteMatches } from './match.js';
|
|
101
|
-
import type
|
|
101
|
+
import { type SinkKind, type SourceKind } from './source-sink-config.js';
|
|
102
102
|
/**
|
|
103
103
|
* Default per-function findings cap (U5 config resolution; cfg/emit.ts
|
|
104
104
|
* DEFAULT_* pattern). Resolved into the RepoMeta `pdg` stamp by
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
* bypassed sanitizer (`exec(x + escape(x))`) killed nothing.
|
|
97
97
|
*/
|
|
98
98
|
import { pointKey } from '../cfg/reaching-defs.js';
|
|
99
|
+
import { SINK_KIND_ORDER as KIND_ORDER, sortSinkKinds as sortKinds, } from './source-sink-config.js';
|
|
99
100
|
/**
|
|
100
101
|
* Default per-function findings cap (U5 config resolution; cfg/emit.ts
|
|
101
102
|
* DEFAULT_* pattern). Resolved into the RepoMeta `pdg` stamp by
|
|
@@ -113,16 +114,10 @@ export const DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION = 200;
|
|
|
113
114
|
* `hopsTruncated`, parsed downstream as "path incomplete", never an error.
|
|
114
115
|
*/
|
|
115
116
|
export const DEFAULT_PDG_MAX_TAINT_HOPS = 32;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
'command-injection',
|
|
120
|
-
'path-traversal',
|
|
121
|
-
'sql-injection',
|
|
122
|
-
'xss',
|
|
123
|
-
];
|
|
117
|
+
// Canonical SinkKind order + sort live in source-sink-config.ts (shared with
|
|
118
|
+
// the M4 summary harvest so the deterministic order never drifts); imported
|
|
119
|
+
// above as KIND_ORDER / sortKinds. `kindRank` is the local comparator index.
|
|
124
120
|
const kindRank = new Map(KIND_ORDER.map((k, i) => [k, i]));
|
|
125
|
-
const sortKinds = (kinds) => [...new Set(kinds)].sort((a, b) => (kindRank.get(a) ?? 99) - (kindRank.get(b) ?? 99));
|
|
126
121
|
const EMPTY_KINDS = new Set();
|
|
127
122
|
const DIRECT_PATH = { kinds: EMPTY_KINDS, viaCall: false, sanitizers: [] };
|
|
128
123
|
/**
|
|
@@ -105,3 +105,16 @@ export interface SourceSinkSanitizerSpec {
|
|
|
105
105
|
readonly sinks: readonly TaintSinkEntry[];
|
|
106
106
|
readonly sanitizers: readonly TaintSanitizerEntry[];
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Canonical deterministic ordering of {@link SinkKind} values. The single
|
|
110
|
+
* source of this order — the intra-procedural propagation engine
|
|
111
|
+
* (`propagate.ts`) and the M4 summary harvest (`summary-harvest.ts`) both sort
|
|
112
|
+
* `neutralized`/exclusion sets by it so their deterministic outputs (and the
|
|
113
|
+
* summary version stamp) stay stable. Lives here, next to the `SinkKind`
|
|
114
|
+
* union, so the two consumers never drift.
|
|
115
|
+
*/
|
|
116
|
+
export declare const SINK_KIND_ORDER: readonly SinkKind[];
|
|
117
|
+
/** Dedupe + sort sink kinds by {@link SINK_KIND_ORDER} (deterministic). */
|
|
118
|
+
export declare function sortSinkKinds(kinds: Iterable<SinkKind>): SinkKind[];
|
|
119
|
+
/** Rank of a sink kind in {@link SINK_KIND_ORDER} (for comparator chaining). */
|
|
120
|
+
export declare function sinkKindRank(kind: SinkKind): number;
|
|
@@ -13,4 +13,27 @@
|
|
|
13
13
|
* shadow checks, spread/template position rules) live in the matcher so the
|
|
14
14
|
* spec stays declarative data that can hash into `taintModelVersion`.
|
|
15
15
|
*/
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Canonical deterministic ordering of {@link SinkKind} values. The single
|
|
18
|
+
* source of this order — the intra-procedural propagation engine
|
|
19
|
+
* (`propagate.ts`) and the M4 summary harvest (`summary-harvest.ts`) both sort
|
|
20
|
+
* `neutralized`/exclusion sets by it so their deterministic outputs (and the
|
|
21
|
+
* summary version stamp) stay stable. Lives here, next to the `SinkKind`
|
|
22
|
+
* union, so the two consumers never drift.
|
|
23
|
+
*/
|
|
24
|
+
export const SINK_KIND_ORDER = [
|
|
25
|
+
'code-injection',
|
|
26
|
+
'command-injection',
|
|
27
|
+
'path-traversal',
|
|
28
|
+
'sql-injection',
|
|
29
|
+
'xss',
|
|
30
|
+
];
|
|
31
|
+
const SINK_KIND_RANK = new Map(SINK_KIND_ORDER.map((k, i) => [k, i]));
|
|
32
|
+
/** Dedupe + sort sink kinds by {@link SINK_KIND_ORDER} (deterministic). */
|
|
33
|
+
export function sortSinkKinds(kinds) {
|
|
34
|
+
return [...new Set(kinds)].sort((a, b) => (SINK_KIND_RANK.get(a) ?? 99) - (SINK_KIND_RANK.get(b) ?? 99));
|
|
35
|
+
}
|
|
36
|
+
/** Rank of a sink kind in {@link SINK_KIND_ORDER} (for comparator chaining). */
|
|
37
|
+
export function sinkKindRank(kind) {
|
|
38
|
+
return SINK_KIND_RANK.get(kind) ?? 99;
|
|
39
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summary-harvest driver (#2084 M4 U1) — the in-phase orchestration that turns
|
|
3
|
+
* per-function CFGs into call-graph-keyed {@link FunctionSummary} objects.
|
|
4
|
+
*
|
|
5
|
+
* Runs inside the scope-resolution pdg window (alongside `emitFileTaint`),
|
|
6
|
+
* where both the live CFG side channel AND the structure-phase `Function` /
|
|
7
|
+
* `Method` graph nodes are available. For each emit-safe CFG it:
|
|
8
|
+
*
|
|
9
|
+
* 1. resolves the CFG's source anchor `(filePath, functionStartLine)` to its
|
|
10
|
+
* graph node id, so the summary speaks the call graph's language directly —
|
|
11
|
+
* the interprocedural fixpoint then joins summaries to `CALLS` edges by node
|
|
12
|
+
* id with no fragile re-derivation;
|
|
13
|
+
* 2. runs the pure {@link harvestFunctionSummary} over the same RD facts +
|
|
14
|
+
* matched sites the M3 taint pass uses;
|
|
15
|
+
* 3. stamps the own-facts `version` (#2084 review P1-1: callee-version
|
|
16
|
+
* composition is RESERVED — the fixpoint does not recompute it today).
|
|
17
|
+
*
|
|
18
|
+
* ## The Function↔CFG join (load-bearing)
|
|
19
|
+
*
|
|
20
|
+
* `FunctionCfg.functionStartLine` is 1-based (the TS visitor's `row + 1`);
|
|
21
|
+
* `Function`/`Method` node `startLine` is 0-based (`startPosition.row`). The
|
|
22
|
+
* join therefore looks up node start line `functionStartLine - 1`
|
|
23
|
+
* ({@link NODE_TO_CFG_LINE_OFFSET}). Function nodes carry no start column, so a
|
|
24
|
+
* `(filePath, startLine)` collision — two functions opening on one line,
|
|
25
|
+
* `{ a: () => x(), b: () => y() }` — is ambiguous: the CFG disambiguates with
|
|
26
|
+
* `functionStartColumn` but the node does not, so a colliding anchor is DROPPED
|
|
27
|
+
* (counted as `unresolved`) rather than risk attaching a summary to the wrong
|
|
28
|
+
* function. Rare in practice; the alternative (cross-wired summaries) is unsound.
|
|
29
|
+
*/
|
|
30
|
+
import type { ParsedImport } from '../../../_shared/index.js';
|
|
31
|
+
import type { KnowledgeGraph } from '../../graph/types.js';
|
|
32
|
+
import type { FunctionCfg } from '../cfg/types.js';
|
|
33
|
+
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
|
|
34
|
+
import { type FunctionSummary } from './summary-model.js';
|
|
35
|
+
/** `cfg.functionStartLine` (1-based) − this = the node's 0-based `startLine`. */
|
|
36
|
+
export declare const NODE_TO_CFG_LINE_OFFSET = 1;
|
|
37
|
+
/**
|
|
38
|
+
* Index of functionish graph nodes by `filePath → startLine(0-based) → ids`.
|
|
39
|
+
* Built ONCE per scope-resolution pass (the graph is whole-repo); reused across
|
|
40
|
+
* every file's harvest.
|
|
41
|
+
*/
|
|
42
|
+
export type FunctionNodeIndex = ReadonlyMap<string, ReadonlyMap<number, readonly string[]>>;
|
|
43
|
+
export declare function buildFunctionNodeIndex(graph: KnowledgeGraph): FunctionNodeIndex;
|
|
44
|
+
export interface FileSummaryResult {
|
|
45
|
+
readonly summaries: readonly FunctionSummary[];
|
|
46
|
+
/** CFGs whose anchor resolved to no unique graph node (collision / missing). */
|
|
47
|
+
readonly unresolved: number;
|
|
48
|
+
/** CFGs whose reaching-defs were not `computed` (no summary produced). */
|
|
49
|
+
readonly gaps: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Harvest summaries for one file's emit-safe CFGs. `cfgs` MUST already be
|
|
53
|
+
* `isEmitSafeCfg`-filtered (the same `wellFormed` array fed to `emitFileTaint`).
|
|
54
|
+
* Pure aside from the read-only graph lookup; never throws on valid input.
|
|
55
|
+
*/
|
|
56
|
+
export declare function harvestFileSummaries(fnIndex: FunctionNodeIndex, cfgs: readonly FunctionCfg[], parsedImports: readonly ParsedImport[], spec: SourceSinkSanitizerSpec, maxFacts?: number): FileSummaryResult;
|