@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.0
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 +101 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +209 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -162
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +22 -14
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +154 -7
- package/src/integrations/index.js +1 -1
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/posture/CLAUDE.md +115 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-verify.js +55 -2
- package/src/posture/license-policy.js +1 -1
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/yaml.js +24 -0
package/src/dataflow/engine.js
CHANGED
|
@@ -30,10 +30,17 @@
|
|
|
30
30
|
// Sinks: anywhere a CFG node calls a catalog-registered sink with a tainted
|
|
31
31
|
// argument, we emit a finding.
|
|
32
32
|
//
|
|
33
|
-
// Sanitizers:
|
|
34
|
-
//
|
|
33
|
+
// Sanitizers: NOT consulted by this walk. `matchSinkOrSanitizer()` returns
|
|
34
|
+
// catalog hits of both kinds, but every consumer in this directory keeps only
|
|
35
|
+
// `e.kind === 'sink'`; no `'sanitizer'` branch exists anywhere in dataflow/*.
|
|
36
|
+
// Taint dies here only when a variable is re-assigned from a clean expression
|
|
37
|
+
// (removePathAndDescendants, below) — which is orthogonal to whether the RHS
|
|
38
|
+
// happens to be a catalog sanitizer. Recognising sanitizer entries at a call
|
|
39
|
+
// site is open work; until it lands, treat the catalog's sanitizer half as
|
|
40
|
+
// documentation consumed by other layers, not by this engine.
|
|
35
41
|
|
|
36
42
|
import { matchSource, matchSinkOrSanitizer } from './catalog.js';
|
|
43
|
+
import { functionRecord } from '../ir/callgraph.js';
|
|
37
44
|
import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
|
|
38
45
|
import { aliasesForVar } from './points-to.js';
|
|
39
46
|
import { higherOrderTaintFlow } from './higher-order.js';
|
|
@@ -64,9 +71,54 @@ function _addPathAliasAware(state, path, callContext) {
|
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
let _activeConstantVars = null;
|
|
74
|
+
// The file of the function currently being analyzed. Set at the top of
|
|
75
|
+
// analyzeFunction (same context-threading pattern as _activeConstantVars
|
|
76
|
+
// above) so exprIsSource/exprTaint/step can pass it to matchSource /
|
|
77
|
+
// matchSinkOrSanitizer without plumbing it through every call signature.
|
|
78
|
+
// It scopes language-specific catalog entries (currently just `cpp`) to
|
|
79
|
+
// files of that language — see the header comment in catalog.js.
|
|
80
|
+
let _currentFile = null;
|
|
81
|
+
|
|
82
|
+
// Flatten a callee — which may be a plain dotted STRING (Go/PHP/Ruby/C++/
|
|
83
|
+
// Python parsers all emit call targets this way) or an expression object
|
|
84
|
+
// (JS/TS's `exprOf`-shaped `{kind:'ident',name}` / `{kind:'member',...}`) —
|
|
85
|
+
// into a name `callGraph.resolve()` can look up. Mirrors the normalisation
|
|
86
|
+
// catalog.js's matchSinkOrSanitizer/matchSource already apply to callees, so
|
|
87
|
+
// the interprocedural resolve path and the catalog match agree on shape.
|
|
88
|
+
// The string path is returned unchanged — languages that already flatten to
|
|
89
|
+
// a string at parse time must keep working exactly as before.
|
|
90
|
+
function _flattenCalleeName(calleeExpr) {
|
|
91
|
+
if (!calleeExpr) return null;
|
|
92
|
+
if (typeof calleeExpr === 'string') return calleeExpr;
|
|
93
|
+
if (calleeExpr.kind === 'ident') return calleeExpr.name || null;
|
|
94
|
+
if (calleeExpr.kind === 'member' && calleeExpr.prop) {
|
|
95
|
+
return (calleeExpr.object && calleeExpr.object.kind === 'ident')
|
|
96
|
+
? `${calleeExpr.object.name}.${calleeExpr.prop}`
|
|
97
|
+
: calleeExpr.prop;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Narrower than _flattenCalleeName: the name to hand to callGraph.resolve().
|
|
103
|
+
// Only a bare identifier call (`helper()`) — or a pre-flattened STRING, which
|
|
104
|
+
// is how the Go/PHP/Ruby/Python/C++ parsers already emit call targets —
|
|
105
|
+
// genuinely identifies one resolvable function. A JS/TS *member* call
|
|
106
|
+
// (`loader.read()`) does not: resolve()'s generic dotted-name fallback
|
|
107
|
+
// strips a dotted name to its last segment and matches ANY same-named
|
|
108
|
+
// function project-wide, inventing a call edge that may not exist (found in
|
|
109
|
+
// engine-reconnect review — `loader.read()` resolved to an unrelated local
|
|
110
|
+
// `read()`, producing 8 false positives on this repo's own hooks/scripts).
|
|
111
|
+
// A missing edge here is a false negative; a wrong edge invents a data-flow
|
|
112
|
+
// path that isn't there — refuse to guess.
|
|
113
|
+
function _resolvableCalleeName(calleeExpr) {
|
|
114
|
+
if (!calleeExpr) return null;
|
|
115
|
+
if (typeof calleeExpr === 'string') return calleeExpr;
|
|
116
|
+
if (calleeExpr.kind === 'ident') return calleeExpr.name || null;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
67
119
|
|
|
68
120
|
function exprTaint(expr, state) {
|
|
69
|
-
if (expr && expr.kind === 'member' && exprIsSource(expr)) return true;
|
|
121
|
+
if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
|
|
70
122
|
if (!expr) return false;
|
|
71
123
|
// Constant propagation: variables assigned from literals are never tainted
|
|
72
124
|
if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
|
|
@@ -155,14 +207,14 @@ function _sourcesReachingExpr(expr, _state, taintSources) {
|
|
|
155
207
|
function exprIsSource(expr) {
|
|
156
208
|
if (!expr) return null;
|
|
157
209
|
if (expr.kind === 'member') {
|
|
158
|
-
const hit = matchSource(expr);
|
|
210
|
+
const hit = matchSource(expr, _currentFile);
|
|
159
211
|
if (hit) return hit;
|
|
160
212
|
}
|
|
161
213
|
// R3 (PRD §5): call-shaped sources (r.FormValue(), r.URL.Query(), c.Query()).
|
|
162
214
|
// Previously only member reads were recognized, so Go's call-style sources
|
|
163
215
|
// never tainted the assignment target. matchSource now resolves call sources.
|
|
164
216
|
if (expr.kind === 'call') {
|
|
165
|
-
const hit = matchSource(expr);
|
|
217
|
+
const hit = matchSource(expr, _currentFile);
|
|
166
218
|
if (hit) return hit;
|
|
167
219
|
}
|
|
168
220
|
if (expr.kind === 'member' && expr.object) {
|
|
@@ -194,6 +246,78 @@ function literalSkeletonMatchesFamily(expr, cwe) {
|
|
|
194
246
|
return true;
|
|
195
247
|
}
|
|
196
248
|
|
|
249
|
+
// Shared sink-matching logic for a call expression, regardless of whether
|
|
250
|
+
// that call appears in statement position (`case 'call'`) or on an
|
|
251
|
+
// assignment's right-hand side (`case 'assign'`). Split into two parts
|
|
252
|
+
// (compute, then emit) so `case 'call'` can compute `cat`/`argTaints` at its
|
|
253
|
+
// original position — BEFORE the mutated-param / Object.assign / array-taint
|
|
254
|
+
// passes that follow it read and rely on those values, and that themselves
|
|
255
|
+
// mutate `state` and `callContext._taintSources` in ways the finding-emission
|
|
256
|
+
// step (further below, unchanged position) must observe — while still
|
|
257
|
+
// sharing the actual matching + emission code with `case 'assign'`, which has
|
|
258
|
+
// no such ordering constraint. Extracted rather than duplicated: this
|
|
259
|
+
// repository has twice had a rule implemented at one call site and re-broken
|
|
260
|
+
// by the next change.
|
|
261
|
+
|
|
262
|
+
// calleeExpr / argExprs: the IR nodes for the call's callee and arguments.
|
|
263
|
+
// state: the taint-state Set to evaluate argument taint against.
|
|
264
|
+
// Returns { cat, argTaints }.
|
|
265
|
+
function _matchCallCatalog(calleeExpr, argExprs, state) {
|
|
266
|
+
const cat = matchSinkOrSanitizer(calleeExpr, _currentFile);
|
|
267
|
+
const argTaints = (argExprs || []).map(a => exprTaint(a, state));
|
|
268
|
+
return { cat, argTaints };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// cat / argTaints: the result of _matchCallCatalog (computed by the caller,
|
|
272
|
+
// at whatever point in its case is appropriate for its own state-mutation
|
|
273
|
+
// ordering).
|
|
274
|
+
// state: used only to attribute reaching sources to the tainted argument
|
|
275
|
+
// expression (via callContext._taintSources, not the state Set itself).
|
|
276
|
+
// line: source line to attach to any emitted finding.
|
|
277
|
+
// Returns { findings }.
|
|
278
|
+
function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callContext, line) {
|
|
279
|
+
const findings = [];
|
|
280
|
+
if (cat) {
|
|
281
|
+
for (const e of cat) {
|
|
282
|
+
if (e.kind === 'sink' && (
|
|
283
|
+
e.argIndex === 'all' ? argTaints.some(Boolean) :
|
|
284
|
+
(typeof e.argIndex === 'number' && argTaints[e.argIndex])
|
|
285
|
+
)) {
|
|
286
|
+
const taintedArgIdx = e.argIndex === 'all'
|
|
287
|
+
? argTaints.findIndex(Boolean) : e.argIndex;
|
|
288
|
+
const taintedArgExpr = (argExprs || [])[taintedArgIdx];
|
|
289
|
+
// String content analysis: skip if literal skeleton doesn't match injection family
|
|
290
|
+
if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
|
|
291
|
+
// Premortem #10: attribute the source for THIS sink to the
|
|
292
|
+
// source(s) that taint the actual argument expression — not the
|
|
293
|
+
// first source the worklist happened to record. We walk the
|
|
294
|
+
// expression's free vars / access paths against the recorded
|
|
295
|
+
// _taintSources and keep entries whose root variable still
|
|
296
|
+
// covers something in the expression.
|
|
297
|
+
const reachingSources = _sourcesReachingExpr(taintedArgExpr, state, callContext._taintSources);
|
|
298
|
+
const traceForThisFinding = reachingSources.length
|
|
299
|
+
? reachingSources.slice(0, 5)
|
|
300
|
+
// Fallback: better to surface "no precise source" than the wrong source.
|
|
301
|
+
: [];
|
|
302
|
+
findings.push({
|
|
303
|
+
kind: 'taint',
|
|
304
|
+
sinkId: e.id,
|
|
305
|
+
vuln: e.vuln?.name || 'Tainted Sink',
|
|
306
|
+
severity: e.vuln?.severity || 'high',
|
|
307
|
+
cwe: e.vuln?.cwe || null,
|
|
308
|
+
remediation: e.vuln?.remediation || null,
|
|
309
|
+
line,
|
|
310
|
+
argIndex: taintedArgIdx,
|
|
311
|
+
callee: calleeExpr,
|
|
312
|
+
sourceProvenance: (traceForThisFinding[0]?.provenance) || null,
|
|
313
|
+
trace: traceForThisFinding,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { findings };
|
|
319
|
+
}
|
|
320
|
+
|
|
197
321
|
// Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
|
|
198
322
|
function step(node, stateIn, callContext) {
|
|
199
323
|
const state = new Set(stateIn);
|
|
@@ -209,6 +333,21 @@ function step(node, stateIn, callContext) {
|
|
|
209
333
|
case 'assign': {
|
|
210
334
|
const src = exprIsSource(node.source);
|
|
211
335
|
const target = typeof node.target === 'string' ? node.target : null;
|
|
336
|
+
// Sink matching is additive to this case's existing source/target/taint
|
|
337
|
+
// handling below: an assignment's RHS can itself be a sink call (e.g.
|
|
338
|
+
// `const rows = db.query(tainted)`), which previously went unreported
|
|
339
|
+
// because this case never consulted the catalog at all. Computed here,
|
|
340
|
+
// against the incoming (pre-mutation) `state`, mirroring how `case
|
|
341
|
+
// 'call'` computes its own cat/argTaints before its mutation passes —
|
|
342
|
+
// and pushed into the shared `findings` array so it survives every
|
|
343
|
+
// return path below, including the early interprocedural returns.
|
|
344
|
+
if (node.source && node.source.kind === 'call') {
|
|
345
|
+
const { cat: _sinkCat, argTaints: _sinkArgTaints } =
|
|
346
|
+
_matchCallCatalog(node.source.callee, node.source.args, state);
|
|
347
|
+
findings.push(..._sinkFindingsForCall(
|
|
348
|
+
node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
|
|
349
|
+
state, callContext, node.line).findings);
|
|
350
|
+
}
|
|
212
351
|
// Constant propagation: track variables assigned from literals
|
|
213
352
|
if (target && _activeConstantVars) {
|
|
214
353
|
if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
|
|
@@ -220,12 +359,20 @@ function step(node, stateIn, callContext) {
|
|
|
220
359
|
// the return is tainted, taint the assignment target. This makes the
|
|
221
360
|
// simplest cross-function flow (helper reads req.body and returns it)
|
|
222
361
|
// visible to the engine — the case the cache was built for.
|
|
223
|
-
const calleeName = node.source && node.source.kind === 'call'
|
|
224
|
-
? node.source.callee : null;
|
|
362
|
+
const calleeName = node.source && node.source.kind === 'call'
|
|
363
|
+
? _flattenCalleeName(node.source.callee) : null;
|
|
225
364
|
if (target && calleeName && callContext._summaryCache && callContext._callGraph) {
|
|
226
365
|
const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
|
|
227
|
-
const
|
|
228
|
-
|
|
366
|
+
const _resolvableName = node.source && node.source.kind === 'call'
|
|
367
|
+
? _resolvableCalleeName(node.source.callee) : null;
|
|
368
|
+
// resolveKnownCallee: never guess via resolve()'s bare-tail
|
|
369
|
+
// fallback. _resolvableCalleeName already refuses JS member
|
|
370
|
+
// expressions, but a pre-flattened STRING callee (Go/PHP/Ruby/
|
|
371
|
+
// C++/Python parsers) can still be dotted, and only the resolver
|
|
372
|
+
// itself can tell — see callgraph.js.
|
|
373
|
+
const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
|
|
374
|
+
? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
|
|
375
|
+
const fn = functionRecord(callContext._callGraph, resolved);
|
|
229
376
|
const qid = resolved && (resolved.qid || resolved);
|
|
230
377
|
if (typeof qid === 'string') {
|
|
231
378
|
// v0.66 — context-sensitive lookup. Build the entry-state from
|
|
@@ -277,7 +424,7 @@ function step(node, stateIn, callContext) {
|
|
|
277
424
|
sum, paramNames, callArgs, callerTainted);
|
|
278
425
|
for (const v of mutated.mutated) newState = addPath(newState, v);
|
|
279
426
|
}
|
|
280
|
-
if (sum && sum.returnTainted) return { state: newState, findings
|
|
427
|
+
if (sum && sum.returnTainted) return { state: newState, findings };
|
|
281
428
|
} else if (target && calleeName) {
|
|
282
429
|
// Fallback: check builtin summaries for unresolved external calls
|
|
283
430
|
const builtin = lookupBuiltinSummary(calleeName);
|
|
@@ -286,7 +433,7 @@ function step(node, stateIn, callContext) {
|
|
|
286
433
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
287
434
|
} else if (!builtin.returnTainted) {
|
|
288
435
|
newState = removePathAndDescendants(newState, target);
|
|
289
|
-
return { state: newState, findings
|
|
436
|
+
return { state: newState, findings };
|
|
290
437
|
}
|
|
291
438
|
if (builtin.mutatedParams && builtin.mutatedParams.size) {
|
|
292
439
|
for (const idx of builtin.mutatedParams) {
|
|
@@ -325,16 +472,22 @@ function step(node, stateIn, callContext) {
|
|
|
325
472
|
|
|
326
473
|
case 'call': {
|
|
327
474
|
// 1. Catalog match: sanitizer, sink, or just an external/unresolved call.
|
|
328
|
-
|
|
329
|
-
|
|
475
|
+
// Computed here (before the mutation passes below) so that argTaints
|
|
476
|
+
// reflects the pre-mutation state, exactly as before this logic was
|
|
477
|
+
// extracted into _matchCallCatalog/_sinkFindingsForCall.
|
|
478
|
+
const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state);
|
|
330
479
|
// v0.66 — apply mutated-param taint at plain (non-assign) call sites.
|
|
331
480
|
// Object.assign(target, tainted) → target becomes tainted in caller.
|
|
332
|
-
|
|
333
|
-
|
|
481
|
+
const _plainCallCalleeName = _flattenCalleeName(node.callee);
|
|
482
|
+
if (callContext._summaryCache && callContext._callGraph && _plainCallCalleeName) {
|
|
334
483
|
const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
484
|
+
const _resolvableName = _resolvableCalleeName(node.callee);
|
|
485
|
+
// resolveKnownCallee: see the comment at the sibling call site above
|
|
486
|
+
// — a pre-flattened dotted STRING callee must not be guessed via
|
|
487
|
+
// resolve()'s bare-tail fallback.
|
|
488
|
+
const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
|
|
489
|
+
? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
|
|
490
|
+
const fn = functionRecord(callContext._callGraph, resolved);
|
|
338
491
|
const qid = resolved && (resolved.qid || resolved);
|
|
339
492
|
if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
|
|
340
493
|
const paramNames = fn.params;
|
|
@@ -376,7 +529,7 @@ function step(node, stateIn, callContext) {
|
|
|
376
529
|
// Built-in mutation functions: Object.assign(target, ...sources),
|
|
377
530
|
// _.merge(target, ...sources), etc. When any source arg is tainted,
|
|
378
531
|
// taint the target in the caller's scope.
|
|
379
|
-
const calleeName =
|
|
532
|
+
const calleeName = _plainCallCalleeName;
|
|
380
533
|
if (calleeName && /^(?:Object\.assign|_\.merge|_\.extend|_\.defaultsDeep|_\.defaults|Object\.defineProperties?)$/.test(calleeName)) {
|
|
381
534
|
const targetArg = (node.args || [])[0];
|
|
382
535
|
const sourceArgsTainted = argTaints.slice(1).some(Boolean);
|
|
@@ -405,44 +558,7 @@ function step(node, stateIn, callContext) {
|
|
|
405
558
|
const _arrRecv = accessPathOf(node.callee.object);
|
|
406
559
|
if (_arrRecv) state.add(_arrRecv);
|
|
407
560
|
}
|
|
408
|
-
|
|
409
|
-
for (const e of cat) {
|
|
410
|
-
if (e.kind === 'sink' && (
|
|
411
|
-
e.argIndex === 'all' ? argTaints.some(Boolean) :
|
|
412
|
-
(typeof e.argIndex === 'number' && argTaints[e.argIndex])
|
|
413
|
-
)) {
|
|
414
|
-
const taintedArgIdx = e.argIndex === 'all'
|
|
415
|
-
? argTaints.findIndex(Boolean) : e.argIndex;
|
|
416
|
-
const taintedArgExpr = (node.args || [])[taintedArgIdx];
|
|
417
|
-
// String content analysis: skip if literal skeleton doesn't match injection family
|
|
418
|
-
if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
|
|
419
|
-
// Premortem #10: attribute the source for THIS sink to the
|
|
420
|
-
// source(s) that taint the actual argument expression — not the
|
|
421
|
-
// first source the worklist happened to record. We walk the
|
|
422
|
-
// expression's free vars / access paths against the recorded
|
|
423
|
-
// _taintSources and keep entries whose root variable still
|
|
424
|
-
// covers something in the expression.
|
|
425
|
-
const reachingSources = _sourcesReachingExpr(taintedArgExpr, state, callContext._taintSources);
|
|
426
|
-
const traceForThisFinding = reachingSources.length
|
|
427
|
-
? reachingSources.slice(0, 5)
|
|
428
|
-
// Fallback: better to surface "no precise source" than the wrong source.
|
|
429
|
-
: [];
|
|
430
|
-
findings.push({
|
|
431
|
-
kind: 'taint',
|
|
432
|
-
sinkId: e.id,
|
|
433
|
-
vuln: e.vuln?.name || 'Tainted Sink',
|
|
434
|
-
severity: e.vuln?.severity || 'high',
|
|
435
|
-
cwe: e.vuln?.cwe || null,
|
|
436
|
-
remediation: e.vuln?.remediation || null,
|
|
437
|
-
line: node.line,
|
|
438
|
-
argIndex: taintedArgIdx,
|
|
439
|
-
callee: node.callee,
|
|
440
|
-
sourceProvenance: (traceForThisFinding[0]?.provenance) || null,
|
|
441
|
-
trace: traceForThisFinding,
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
561
|
+
findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line).findings);
|
|
446
562
|
// 2. P1.3 — higher-order taint flow. When the call is `arr.map(fn)` or
|
|
447
563
|
// `promise.then(fn)` and the receiver is tainted, propagate taint
|
|
448
564
|
// into the callback's first parameter. v1: we propagate AT THE
|
|
@@ -452,7 +568,7 @@ function step(node, stateIn, callContext) {
|
|
|
452
568
|
const hoFlow = (() => {
|
|
453
569
|
// Heuristic receiver-tainted check: if the callee string is
|
|
454
570
|
// "<recv>.<method>", check whether <recv> is in state.
|
|
455
|
-
const callee =
|
|
571
|
+
const callee = _plainCallCalleeName;
|
|
456
572
|
if (!callee) return null;
|
|
457
573
|
const dot = callee.lastIndexOf('.');
|
|
458
574
|
if (dot <= 0) return null;
|
|
@@ -526,6 +642,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
526
642
|
inStates.set(fn.cfg.entry, new Set(entryState));
|
|
527
643
|
work.push(fn.cfg.entry);
|
|
528
644
|
_activeConstantVars = new Map();
|
|
645
|
+
_currentFile = fn.file || null;
|
|
529
646
|
// v0.70 #2 — points-to context for the step() transfer. Setting it here
|
|
530
647
|
// (instead of plumbing through step's signature) keeps the worklist loop
|
|
531
648
|
// unchanged and lets `step` consult `aliasesForVar` when callContext._pointsTo
|
|
@@ -583,7 +700,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
583
700
|
for (const [nid, ctx] of ictx) {
|
|
584
701
|
const node = nodes[nid];
|
|
585
702
|
if (!node || node.kind !== 'call') continue;
|
|
586
|
-
const cat = matchSinkOrSanitizer(node.callee);
|
|
703
|
+
const cat = matchSinkOrSanitizer(node.callee, _currentFile);
|
|
587
704
|
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
588
705
|
if (!sink) continue;
|
|
589
706
|
const inS = inStates.get(nid) || new Set();
|
|
@@ -783,8 +900,12 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
783
900
|
if (Date.now() > deadlineMs) break;
|
|
784
901
|
const inv = hoInvocations[hi];
|
|
785
902
|
if (!inv.callee || !inv.taintedParam) continue;
|
|
786
|
-
|
|
787
|
-
|
|
903
|
+
// inv.callee is always a bare ident (only cb.kind === 'ident' pushes a
|
|
904
|
+
// higher-order invocation — see the push site above), so resolve()'s
|
|
905
|
+
// bare-tail guess never triggers here either way; resolveKnownCallee
|
|
906
|
+
// for consistency with the other call-graph lookups in this file.
|
|
907
|
+
const resolved = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(inv.callee, fn && fn.file) : null;
|
|
908
|
+
const cbFn = functionRecord(callGraph, resolved);
|
|
788
909
|
if (!cbFn || !cbFn.params || !cbFn.params.length) continue;
|
|
789
910
|
const cbEntry = new Set([cbFn.params[inv.paramIndex || 0]]);
|
|
790
911
|
let cbSummary = summaryCache.get(cbFn.qid, cbEntry);
|
package/src/dataflow/ifds.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// engine is gated on the CVE-replay regression corpus.
|
|
29
29
|
|
|
30
30
|
import { matchSource, matchSinkOrSanitizer } from './catalog.js';
|
|
31
|
+
import { functionRecord } from '../ir/callgraph.js';
|
|
31
32
|
|
|
32
33
|
// Special bottom fact: "no taint yet, but reachable." Every node propagates
|
|
33
34
|
// 0̂ → 0̂ to mark reachability, then layers domain facts on top.
|
|
@@ -129,10 +130,10 @@ function _exprAccessPath(expr) {
|
|
|
129
130
|
|
|
130
131
|
// Sink detection at call nodes. Returns a list of sink-finding records
|
|
131
132
|
// {sinkId, vuln, severity, cwe, line} when `fact` reaches a sink arg.
|
|
132
|
-
function _detectSinkAtCall(node, fact) {
|
|
133
|
+
function _detectSinkAtCall(node, fact, file = null) {
|
|
133
134
|
if (!node || node.kind !== 'call') return [];
|
|
134
135
|
if (fact === ZERO) return [];
|
|
135
|
-
const cat = matchSinkOrSanitizer(node.callee);
|
|
136
|
+
const cat = matchSinkOrSanitizer(node.callee, file);
|
|
136
137
|
if (!cat) return [];
|
|
137
138
|
const findings = [];
|
|
138
139
|
const args = node.args || [];
|
|
@@ -251,14 +252,18 @@ export class IFDSSolver {
|
|
|
251
252
|
if (!node) return;
|
|
252
253
|
// Emit findings if this is a sink.
|
|
253
254
|
if (node.kind === 'call') {
|
|
254
|
-
for (const f of _detectSinkAtCall(node, currentFact)) {
|
|
255
|
+
for (const f of _detectSinkAtCall(node, currentFact, fn && fn.file)) {
|
|
255
256
|
this.findings.push({ ...f, _fnQid: fn.qid, _entryFact: entryFact });
|
|
256
257
|
}
|
|
257
258
|
// v0.73 — interprocedural call: if the callee is resolved AND we
|
|
258
259
|
// can derive an entry fact for it, look up (or queue) its summary
|
|
259
260
|
// and apply the resulting exit facts at the return site.
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
// resolveKnownCallee (not resolve): never invent an edge from a dotted
|
|
262
|
+
// name's last segment. `fn.file` enables the same-file preference — a
|
|
263
|
+
// bare `handler` would otherwise bind to whichever file iterates first.
|
|
264
|
+
const resolved = this.callGraph.resolveKnownCallee
|
|
265
|
+
? this.callGraph.resolveKnownCallee(node.callee, fn && fn.file) : null;
|
|
266
|
+
const callee = functionRecord(this.callGraph, resolved);
|
|
262
267
|
if (callee && callee.cfg) {
|
|
263
268
|
const calleeEntryFact = _entryFactForCall(node, currentFact, callee);
|
|
264
269
|
// Seed the callee at its entry with the derived fact.
|
package/src/dataflow/index.js
CHANGED
|
@@ -173,9 +173,21 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
173
173
|
if (callGraph && callGraph.functions) {
|
|
174
174
|
for (const fn of callGraph.functions.values()) {
|
|
175
175
|
if (!Array.isArray(fn.calls)) continue;
|
|
176
|
-
for (const
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
for (const c of fn.calls) {
|
|
177
|
+
// fn.calls entries are objects ({site, callee, args, line}); using one
|
|
178
|
+
// as an object key stringifies it to "[object Object]" and collapses
|
|
179
|
+
// every entry into a single bogus bucket. Key by the RESOLVED qid so
|
|
180
|
+
// this map is queryable by the qids the worklist actually uses.
|
|
181
|
+
const name = c && typeof c === 'object' ? c.callee : c;
|
|
182
|
+
if (!name) continue;
|
|
183
|
+
// resolveKnownCallee (not resolve): a raw fn.calls name may be a
|
|
184
|
+
// flattened member-call string ("loader.read"); the plain
|
|
185
|
+
// resolve() would guess via its bare-tail fallback and invent an
|
|
186
|
+
// edge to an unrelated same-named function. See callgraph.js.
|
|
187
|
+
const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
|
|
188
|
+
if (!qid) continue;
|
|
189
|
+
if (!callers[qid]) callers[qid] = [];
|
|
190
|
+
callers[qid].push(fn.qid);
|
|
179
191
|
}
|
|
180
192
|
}
|
|
181
193
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { functionRecord } from '../ir/callgraph.js';
|
|
2
|
+
|
|
1
3
|
// Steensgaard points-to / alias analysis (v0.70 #2).
|
|
2
4
|
//
|
|
3
5
|
// Unification-based, near-linear pointer analysis. The classical reference
|
|
@@ -247,8 +249,12 @@ export function buildPointsTo(perFileIR, callGraph) {
|
|
|
247
249
|
for (const nid of Object.keys(fn.cfg.nodes)) {
|
|
248
250
|
const node = fn.cfg.nodes[nid];
|
|
249
251
|
if (!node || node.kind !== 'call') continue;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
// resolveKnownCallee (not resolve): never invent an edge from a dotted
|
|
253
|
+
// name's last segment. `fn.file` enables the same-file preference — a
|
|
254
|
+
// bare `handler` would otherwise bind to whichever file iterates first.
|
|
255
|
+
const resolved = callGraph.resolveKnownCallee
|
|
256
|
+
? callGraph.resolveKnownCallee(node.callee, fn && fn.file) : null;
|
|
257
|
+
const target = functionRecord(callGraph, resolved);
|
|
252
258
|
if (!target || !Array.isArray(target.params)) continue;
|
|
253
259
|
const args = node.args || [];
|
|
254
260
|
for (let i = 0; i < target.params.length && i < args.length; i++) {
|
|
@@ -48,6 +48,13 @@ export function verdictForFinding(f) {
|
|
|
48
48
|
const sanitizers = (f.provenanceProof && f.provenanceProof.sanitizers) || [];
|
|
49
49
|
return { verdict: 'proven-clean', reason: sanitizers.length ? `parameterized via ${sanitizers.join(', ')}` : 'sql-parameterizer-on-path' };
|
|
50
50
|
}
|
|
51
|
+
// sanitizer-gate.js — same "proven-clean" demotion, generalised beyond SQL to
|
|
52
|
+
// every family the catalog's `appliesTo` tags cover (xss, url, cmd, ...).
|
|
53
|
+
// Purely additive: it never overrides f.provenClean above.
|
|
54
|
+
if (f.sanitized === true) {
|
|
55
|
+
const sanitizers = (f.sanitizerProof && f.sanitizerProof.sanitizers) || [];
|
|
56
|
+
return { verdict: 'proven-clean', reason: sanitizers.length ? `sanitized via ${sanitizers.join(', ')}` : 'sanitizer-on-path' };
|
|
57
|
+
}
|
|
51
58
|
if (f._provenUnreachable === true) {
|
|
52
59
|
return { verdict: 'proven-infeasible', reason: f._provenUnreachableReason || 'sanitizer-excludes-metacharacters' };
|
|
53
60
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Generalised, recall-preserving sanitizer gate.
|
|
2
|
+
//
|
|
3
|
+
// The catalog carries 65 sanitizer entries tagged by family via `appliesTo`
|
|
4
|
+
// (sql, xss, url, cmd). Before this module only `appliesTo: ['sql']` was ever
|
|
5
|
+
// consumed — by proven-clean.js — so a correctly sanitized xss/url/cmd flow was
|
|
6
|
+
// still reported at full confidence. That is a pure false-positive source.
|
|
7
|
+
//
|
|
8
|
+
// This gate NEVER removes a finding. It sets `sanitized` plus a proof object and
|
|
9
|
+
// lets the existing proof gate in engine.js do the demotion, exactly as
|
|
10
|
+
// proven-clean.js does. That matters because a mislabelled sanitizer would
|
|
11
|
+
// otherwise hide a real vulnerability: the C/C++ work found strncpy and snprintf
|
|
12
|
+
// tagged effect:'strip' when they bound length rather than sanitising content.
|
|
13
|
+
|
|
14
|
+
import { CATALOG } from './catalog.js';
|
|
15
|
+
|
|
16
|
+
// Map a finding to a sanitizer family using its CWE first (stable) and its vuln
|
|
17
|
+
// text second (human-authored, so only a fallback).
|
|
18
|
+
const _CWE_FAMILY = {
|
|
19
|
+
'CWE-89': 'sql',
|
|
20
|
+
'CWE-79': 'xss',
|
|
21
|
+
'CWE-78': 'cmd',
|
|
22
|
+
'CWE-22': 'url',
|
|
23
|
+
'CWE-918': 'url',
|
|
24
|
+
'CWE-601': 'url',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const _TEXT_FAMILY = [
|
|
28
|
+
[/sql/i, 'sql'],
|
|
29
|
+
[/xss|cross-site scripting/i, 'xss'],
|
|
30
|
+
[/command injection/i, 'cmd'],
|
|
31
|
+
[/path traversal|ssrf|redirect/i, 'url'],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function familyOfFinding(f) {
|
|
35
|
+
if (!f) return null;
|
|
36
|
+
if (f.cwe && _CWE_FAMILY[f.cwe]) return _CWE_FAMILY[f.cwe];
|
|
37
|
+
const text = `${f.vuln || ''} ${f.family || ''}`;
|
|
38
|
+
for (const [re, fam] of _TEXT_FAMILY) if (re.test(text)) return fam;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// callee name → set of families it sanitizes, built once from the catalog.
|
|
43
|
+
let _index = null;
|
|
44
|
+
function _sanitizerIndex() {
|
|
45
|
+
if (_index) return _index;
|
|
46
|
+
_index = new Map();
|
|
47
|
+
for (const e of CATALOG) {
|
|
48
|
+
if (!e || e.kind !== 'sanitizer') continue;
|
|
49
|
+
const callee = e.match && e.match.type === 'call' ? e.match.callee : null;
|
|
50
|
+
if (!callee) continue;
|
|
51
|
+
const fams = Array.isArray(e.appliesTo) ? e.appliesTo : [];
|
|
52
|
+
const cur = _index.get(callee) || new Set();
|
|
53
|
+
for (const f of fams) cur.add(f);
|
|
54
|
+
_index.set(callee, cur);
|
|
55
|
+
}
|
|
56
|
+
return _index;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function _sanitizerFamilies() {
|
|
60
|
+
const out = new Set();
|
|
61
|
+
for (const fams of _sanitizerIndex().values()) for (const f of fams) out.add(f);
|
|
62
|
+
return [...out].sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ctx.sanitizersOnPath: { [findingId]: string[] } — callee names observed on the
|
|
66
|
+
// flow that produced the finding. The engine supplies it; when absent the gate
|
|
67
|
+
// is a no-op, which keeps this safe to call unconditionally.
|
|
68
|
+
export function applySanitizerGate(findings, ctx) {
|
|
69
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
70
|
+
const onPath = (ctx && ctx.sanitizersOnPath) || null;
|
|
71
|
+
if (!onPath) return list;
|
|
72
|
+
const index = _sanitizerIndex();
|
|
73
|
+
|
|
74
|
+
for (const f of list) {
|
|
75
|
+
const fam = familyOfFinding(f);
|
|
76
|
+
if (!fam) continue;
|
|
77
|
+
const observed = onPath[f.id] || onPath[f.stableId];
|
|
78
|
+
if (!Array.isArray(observed) || !observed.length) continue;
|
|
79
|
+
const matching = observed.filter(name => {
|
|
80
|
+
const fams = index.get(name);
|
|
81
|
+
return fams && fams.has(fam);
|
|
82
|
+
});
|
|
83
|
+
if (!matching.length) continue;
|
|
84
|
+
// Label only. The proof gate decides what to do with the label.
|
|
85
|
+
f.sanitized = true;
|
|
86
|
+
f.sanitizerProof = { sanitizers: matching, family: fam };
|
|
87
|
+
}
|
|
88
|
+
return list;
|
|
89
|
+
}
|
|
@@ -49,7 +49,7 @@ export function enumerateSinks(perFileIR, callGraph) {
|
|
|
49
49
|
for (const nid of Object.keys(cfg.nodes)) {
|
|
50
50
|
const node = cfg.nodes[nid];
|
|
51
51
|
if (!node || node.kind !== 'call') continue;
|
|
52
|
-
const cat = matchSinkOrSanitizer(node.callee);
|
|
52
|
+
const cat = matchSinkOrSanitizer(node.callee, fn.file || null);
|
|
53
53
|
if (!cat) continue;
|
|
54
54
|
for (const e of cat) {
|
|
55
55
|
if (e.kind !== 'sink') continue;
|
|
@@ -84,8 +84,19 @@ export function reachabilitySliceFromSinks(sinks, callGraph, maxCallerDepth = MA
|
|
|
84
84
|
const callersOf = new Map();
|
|
85
85
|
for (const fn of callGraph.functions.values()) {
|
|
86
86
|
for (const callee of (fn.calls || [])) {
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// callee.callee is a SOURCE-LEVEL name ("b.fill"); the worklist below
|
|
88
|
+
// looks this map up by qid, so keying by the name means the lookup can
|
|
89
|
+
// never match. Resolve to a qid first.
|
|
90
|
+
const name = callee && typeof callee === 'object' ? callee.callee : callee;
|
|
91
|
+
if (!name) continue;
|
|
92
|
+
// resolveKnownCallee (not resolve): callee.callee may be a flattened
|
|
93
|
+
// member-call string ("loader.read"); the plain resolve() would guess
|
|
94
|
+
// via its bare-tail fallback and invent an edge to an unrelated
|
|
95
|
+
// same-named function. See callgraph.js.
|
|
96
|
+
const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
|
|
97
|
+
if (!qid) continue;
|
|
98
|
+
if (!callersOf.has(qid)) callersOf.set(qid, []);
|
|
99
|
+
callersOf.get(qid).push(fn.qid);
|
|
89
100
|
}
|
|
90
101
|
}
|
|
91
102
|
|