@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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 +26 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +282 -24
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/language/haskell-findings.js +2 -0
- package/src/language/haskell-guards.js +1 -1
- package/src/language/haskell-ir.js +3 -1
- package/src/language/haskell-models.js +94 -0
- package/src/language/haskell-security-rules.js +20 -0
- package/src/language/nixos-hardening.js +72 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/sast/zip-slip.js +79 -0
- package/src/util/string-memo.js +38 -0
package/src/dataflow/engine.js
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
|
|
46
46
|
import { functionRecord } from '../ir/callgraph.js';
|
|
47
|
-
import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual, pathIsCoveredByPrefix } from './access-paths.js';
|
|
47
|
+
import { accessPathOf, isCoveredBy, hasTaintedDescendant, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual, pathIsCoveredByPrefix } from './access-paths.js';
|
|
48
48
|
import { aliasesForVar } from './points-to.js';
|
|
49
49
|
import { higherOrderTaintFlow } from './higher-order.js';
|
|
50
50
|
import { SummaryCache, entryStateFromCall } from './summaries.js';
|
|
@@ -342,6 +342,14 @@ function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
|
|
|
342
342
|
function _resolveCalleeForSummary(calleeExpr, callContext) {
|
|
343
343
|
if (!callContext || !callContext._callGraph || !callContext._callGraph.resolveKnownCallee) return null;
|
|
344
344
|
const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
|
|
345
|
+
// E2-02: a member call through an IMPORTED MODULE (`const svc = require('./service'); svc.run(x)`) is resolved by following the import,
|
|
346
|
+
// not by name. This is exact (the binding names the module), so it does not reopen the guessed-edge problem described above: only a
|
|
347
|
+
// callee whose head identifier is an import binding of this very file is eligible, and the answer is the module's own export.
|
|
348
|
+
if (calleeExpr && calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string' && calleeExpr.object && calleeExpr.object.kind === 'ident' && callContext._callGraph.moduleIndex && _callerFile) {
|
|
349
|
+
let viaImport = null;
|
|
350
|
+
try { viaImport = callContext._callGraph.moduleIndex.resolveCall(_callerFile, `${calleeExpr.object.name}.${calleeExpr.prop}`); } catch { viaImport = null; }
|
|
351
|
+
if (viaImport && viaImport.status === 'resolved') return { qid: viaImport.qid, fn: functionRecord(callContext._callGraph, viaImport.qid) };
|
|
352
|
+
}
|
|
345
353
|
const _resolvableName = _resolvableCalleeName(calleeExpr);
|
|
346
354
|
if (_resolvableName) {
|
|
347
355
|
const resolved = callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile);
|
|
@@ -379,9 +387,9 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext, argT
|
|
|
379
387
|
if (!target) return false;
|
|
380
388
|
const { qid, fn } = target;
|
|
381
389
|
const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
|
|
382
|
-
const entry = paramNames.length
|
|
390
|
+
const entry = _withCapturedTaint(paramNames.length
|
|
383
391
|
? entryStateFromCall(paramNames, argExprs || [], state, argTaint || ((a) => exprTaint(a, state, callContext)))
|
|
384
|
-
: new Set();
|
|
392
|
+
: new Set(), fn, state, callContext);
|
|
385
393
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
386
394
|
if (!sum && fn && fn.cfg) {
|
|
387
395
|
sum = callContext._summaryCache.compute(qid, entry, () => {
|
|
@@ -422,18 +430,38 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext, argT
|
|
|
422
430
|
// after its LAST '.' and checking that prefix directly against the access-
|
|
423
431
|
// path lattice via `isCoveredBy` (handles both a bare var and a longer
|
|
424
432
|
// chain, e.g. "req.session.user.toString" -> receiver "req.session.user").
|
|
425
|
-
function _calleeReceiverTainted(callee, state, callContext) {
|
|
433
|
+
function _calleeReceiverTainted(callee, state, callContext, args) {
|
|
426
434
|
if (!callee) return false;
|
|
435
|
+
// E2-03: `m.get('k')` reads ONE key of a keyed collection. It is tainted when the collection as a whole is, or when that key was set
|
|
436
|
+
// tainted (`m.set('k', v)` records `m.$k`); a sibling key's taint (`m.$other`) does not reach it. Non-path receivers, and receivers that
|
|
437
|
+
// are themselves sources, take the generic route below.
|
|
438
|
+
if (typeof callee === 'object' && callee.kind === 'member' && callee.prop === 'get' && Array.isArray(args) && args.length === 1
|
|
439
|
+
&& args[0] && args[0].kind === 'literal' && typeof args[0].value === 'string') {
|
|
440
|
+
const recvPath = accessPathOf(callee.object);
|
|
441
|
+
if (recvPath) return exprIsSource(callee.object, callContext) ? true : (isCoveredBy(state, recvPath) || isCoveredBy(state, `${recvPath}.$${args[0].value}`));
|
|
442
|
+
}
|
|
427
443
|
if (typeof callee === 'string') {
|
|
428
444
|
const idx = callee.lastIndexOf('.');
|
|
429
445
|
if (idx <= 0) return false;
|
|
430
|
-
return isCoveredBy(state, callee.slice(0, idx));
|
|
446
|
+
return isCoveredBy(state, callee.slice(0, idx)) || hasTaintedDescendant(state, callee.slice(0, idx));
|
|
431
447
|
}
|
|
432
448
|
if (callee.kind === 'member' && callee.object) {
|
|
449
|
+
// A tainted FIELD below the receiver (`o.cmd`) taints a call on the whole receiver only for methods that read the container's contents
|
|
450
|
+
// (`arr.join()`, `list.map(..)`); on an arbitrary object it does not, because the method need not touch that field (object-instance
|
|
451
|
+
// sensitivity: see the single-field getter handling).
|
|
452
|
+
const recvPath = accessPathOf(callee.object);
|
|
453
|
+
if (recvPath && typeof callee.prop === 'string') {
|
|
454
|
+
if (exprIsSource(callee.object, callContext) || isCoveredBy(state, recvPath)) return true;
|
|
455
|
+
if (_CONTAINER_READ.test(callee.prop)) return hasTaintedDescendant(state, recvPath);
|
|
456
|
+
// `m.get(k)` with a non-literal key reads any key a literal `m.set('x', v)` recorded as `m.$x`
|
|
457
|
+
if (callee.prop === 'get') { const kp = `${recvPath}.$`; for (const st of state) if (typeof st === 'string' && st.startsWith(kp)) return true; }
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
433
460
|
return exprTaint(callee.object, state, callContext);
|
|
434
461
|
}
|
|
435
462
|
return false;
|
|
436
463
|
}
|
|
464
|
+
const _CONTAINER_READ = /^(?:join|map|filter|flatMap|flat|slice|concat|reduce|reduceRight|find|findLast|findIndex|some|every|forEach|at|toString|toSorted|toReversed|entries|values|keys|pop|shift|sort|reverse|includes|indexOf|lastIndexOf)$/;
|
|
437
465
|
|
|
438
466
|
// Next-gen taint capability #3 — object-instance field taint via method
|
|
439
467
|
// calls (JS/TS). `classTaintedFields` (below, in the pre-pass) already
|
|
@@ -526,7 +554,8 @@ function exprTaint(expr, state, callContext) {
|
|
|
526
554
|
// shorter prefix in the state covers it. This is what makes
|
|
527
555
|
// `user.password` distinguishable from `user.email`.
|
|
528
556
|
const ap = accessPathOf(expr);
|
|
529
|
-
|
|
557
|
+
// E2-03: a whole-value read of `o` is tainted when any field below it is (`o.cmd`); a sibling read (`o.safe`) is not.
|
|
558
|
+
if (ap !== null) return isCoveredBy(state, ap) || hasTaintedDescendant(state, ap);
|
|
530
559
|
switch (expr.kind) {
|
|
531
560
|
case 'literal': return false;
|
|
532
561
|
case 'binary':
|
|
@@ -595,7 +624,7 @@ function exprTaint(expr, state, callContext) {
|
|
|
595
624
|
// receiver taint matters exactly as much as `tainted.toString()`'s, and
|
|
596
625
|
// this is already OR'd with argsTainted so it only WIDENS recall,
|
|
597
626
|
// matching this engine's recall-preserving precedent everywhere else.
|
|
598
|
-
const receiverTainted = _calleeReceiverTainted(expr.callee, state, callContext);
|
|
627
|
+
const receiverTainted = _calleeReceiverTainted(expr.callee, state, callContext, expr.args);
|
|
599
628
|
return argsTainted || nestedTainted || receiverTainted;
|
|
600
629
|
}
|
|
601
630
|
case 'unknown': return false;
|
|
@@ -772,9 +801,137 @@ function literalSkeletonMatchesFamily(expr, cwe) {
|
|
|
772
801
|
// state: the taint-state Set to evaluate argument taint against.
|
|
773
802
|
// callContext: context from the engine (contains _cha for CHA lookups).
|
|
774
803
|
// Returns { cat, argTaints }.
|
|
804
|
+
// E2-02: a member call through an imported PROJECT module (`util.exec(c)` where `util` is `require('./util')`) calls that module's own
|
|
805
|
+
// function, whatever it is named. The catalog matches sinks by callee name, so `util.exec` looked like child_process's `exec` and every
|
|
806
|
+
// caller of a helper called `exec`/`query`/`send` was reported as a sink. When the import resolves to a function in this project the sink
|
|
807
|
+
// match is dropped: the helper's body is analysed through its summary, so a real sink inside it is still found, at the real line.
|
|
808
|
+
function _isInternalModuleCall(calleeExpr, callContext) {
|
|
809
|
+
let text = null;
|
|
810
|
+
if (calleeExpr && calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string' && calleeExpr.object && calleeExpr.object.kind === 'ident') text = `${calleeExpr.object.name}.${calleeExpr.prop}`;
|
|
811
|
+
else if (calleeExpr && calleeExpr.kind === 'ident' && typeof calleeExpr.name === 'string') text = calleeExpr.name; // `const { exec } = require('./util'); exec(c)`
|
|
812
|
+
if (!text) return false;
|
|
813
|
+
const mi = callContext && callContext._callGraph && callContext._callGraph.moduleIndex;
|
|
814
|
+
if (!mi || !_currentFile) return false;
|
|
815
|
+
try { return mi.resolveCall(_currentFile, text).status === 'resolved'; } catch { return false; }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// The field-by-field literal rules use the access paths parser-js.js produces (`o.cmd`, `arr.0`). The other parsers lower subscripts and
|
|
819
|
+
// comprehensions to different path shapes (`d.[]`), so the rules apply to JavaScript and TypeScript only; elsewhere the whole-value rule is unchanged.
|
|
820
|
+
const _JS_FILE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
821
|
+
function _isJsFile(file) { return typeof file === 'string' && _JS_FILE.test(file); }
|
|
822
|
+
|
|
823
|
+
function _arrayLiteralTaint(expr, prefix, state, callContext) {
|
|
824
|
+
const out = { whole: false, paths: [] };
|
|
825
|
+
(expr.elements || []).forEach((el, i) => {
|
|
826
|
+
if (!el) return;
|
|
827
|
+
const path = prefix ? `${prefix}.${i}` : String(i);
|
|
828
|
+
if (el.kind === 'object') {
|
|
829
|
+
const inner = _objectLiteralTaint(el, path, state, callContext);
|
|
830
|
+
if (inner.whole) out.paths.push(path);
|
|
831
|
+
out.paths.push(...inner.paths);
|
|
832
|
+
} else if (el.kind === 'array' && !(el.spreads && el.spreads.length)) {
|
|
833
|
+
const inner = _arrayLiteralTaint(el, path, state, callContext);
|
|
834
|
+
if (inner.whole) out.paths.push(path);
|
|
835
|
+
out.paths.push(...inner.paths);
|
|
836
|
+
} else if (exprTaint(el, state, callContext)) out.paths.push(path);
|
|
837
|
+
});
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function _objectLiteralTaint(expr, prefix, state, callContext) {
|
|
842
|
+
const out = { whole: false, paths: [] };
|
|
843
|
+
for (const p of expr.props || []) {
|
|
844
|
+
if (!p || !p.value) continue;
|
|
845
|
+
if (p.spread) {
|
|
846
|
+
// `{ ...a }` copies the fields of `a`: a tainted FIELD of `a` stays that field, a wholly tainted `a` (or a spread of something that is not
|
|
847
|
+
// a plain variable, such as a request object) has unknown keys and taints everything.
|
|
848
|
+
const sp = accessPathOf(p.value);
|
|
849
|
+
if (sp && !isCoveredBy(state, sp) && !exprIsSource(p.value, callContext)) {
|
|
850
|
+
for (const st of state) if (typeof st === 'string' && st.startsWith(`${sp}.`)) out.paths.push(prefix ? `${prefix}.${st.slice(sp.length + 1)}` : st.slice(sp.length + 1));
|
|
851
|
+
} else if (exprTaint(p.value, state, callContext)) out.whole = true;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (p.key === '*' || typeof p.key !== 'string') { if (exprTaint(p.value, state, callContext)) out.whole = true; continue; }
|
|
855
|
+
const path = prefix ? `${prefix}.${p.key}` : p.key;
|
|
856
|
+
// a later explicit key overrides whatever an earlier spread brought in under that name
|
|
857
|
+
out.paths = out.paths.filter((q) => q !== path && !q.startsWith(`${path}.`));
|
|
858
|
+
if (p.value.kind === 'object') {
|
|
859
|
+
const inner = _objectLiteralTaint(p.value, path, state, callContext);
|
|
860
|
+
if (inner.whole) out.paths.push(path); // unknown keys below: the whole sub-object
|
|
861
|
+
out.paths.push(...inner.paths);
|
|
862
|
+
} else if (exprTaint(p.value, state, callContext)) out.paths.push(path);
|
|
863
|
+
}
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// E2-03: a function defined INSIDE the one being analysed (`const f = () => exec(c)`) reads the caller's variables without receiving them as
|
|
868
|
+
// parameters. When such a callee is entered, the caller's tainted paths whose root is not one of the callee's own parameters are carried in as
|
|
869
|
+
// its entry state, so `c` is tainted inside `f`. Lexical nesting comes from the qid (`file::<enclosing function>::name@line`); a callee defined
|
|
870
|
+
// at module level or elsewhere gets nothing extra. An assignment inside the callee still kills the taint, as it would anywhere.
|
|
871
|
+
function _withCapturedTaint(entry, fn, state, callContext) {
|
|
872
|
+
if (!fn || typeof fn.qid !== 'string' || !callContext || !callContext._currentFnQid) return entry;
|
|
873
|
+
const callee = fn.qid.split('::');
|
|
874
|
+
const caller = String(callContext._currentFnQid).split('::');
|
|
875
|
+
if (callee.length < 3 || caller.length < 3 || callee[0] !== caller[0]) return entry;
|
|
876
|
+
const callerName = caller[caller.length - 1].replace(/@\d+$/, '');
|
|
877
|
+
if (callee[1] !== callerName || callee[1] === '<module>') return entry;
|
|
878
|
+
const params = new Set(fn.params || []);
|
|
879
|
+
let out = entry;
|
|
880
|
+
for (const p of state) {
|
|
881
|
+
const root = String(p).split('.')[0];
|
|
882
|
+
if (params.has(root)) continue;
|
|
883
|
+
if (out === entry) out = new Set(entry);
|
|
884
|
+
out.add(p);
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// E2-04: callbacks. A function passed as an argument runs later with values the call site supplies, so its parameters can be tainted. Two
|
|
890
|
+
// shapes are recognised, both resolved structurally and never by guessing:
|
|
891
|
+
// 1. promise and iterator methods (`p.then(cb)`, `list.map(cb)`): the callback's first parameter receives the RECEIVER's value, so it is
|
|
892
|
+
// tainted when the receiver expression is (a call, an awaited value, a path);
|
|
893
|
+
// 2. a project function that CALLS its own parameter (`function later(v, cb) { cb(v); }`): the callback's parameter k is tainted when the
|
|
894
|
+
// argument that function passes at position k derives from one of its own tainted parameters.
|
|
895
|
+
// Each tainted parameter becomes a `_higherOrderInvocations` entry, which the engine already consumes by analysing the callback with that
|
|
896
|
+
// parameter tainted.
|
|
897
|
+
const _RECEIVER_TO_CALLBACK = /^(?:then|catch|finally|map|forEach|filter|flatMap|find|findLast|some|every|reduce|reduceRight|sort)$/;
|
|
898
|
+
function _recordCallbackInvocations(node, state, callContext) {
|
|
899
|
+
if (!node || !Array.isArray(node.args) || !node.args.length) return;
|
|
900
|
+
const push = (cb, paramIndex, via) => {
|
|
901
|
+
callContext._higherOrderInvocations = callContext._higherOrderInvocations || [];
|
|
902
|
+
callContext._higherOrderInvocations.push({ callee: cb.kind === 'ident' ? cb.name : null, calleeQid: cb.kind === 'function-value' ? (cb.qid || null) : null, paramIndex, taintedParam: true, line: node.line, via });
|
|
903
|
+
};
|
|
904
|
+
const isCb = (a) => a && (a.kind === 'function-value' || a.kind === 'ident');
|
|
905
|
+
const callee = node.callee;
|
|
906
|
+
if (callee && callee.kind === 'member' && typeof callee.prop === 'string' && _RECEIVER_TO_CALLBACK.test(callee.prop) && isCb(node.args[0]) && callee.prop !== 'catch') {
|
|
907
|
+
if (exprTaint(callee.object, state, callContext)) push(node.args[0], callee.prop === 'reduce' || callee.prop === 'reduceRight' ? 1 : 0, `receiver:${callee.prop}`);
|
|
908
|
+
}
|
|
909
|
+
// 2. project function that invokes its own callback parameter
|
|
910
|
+
const target = _resolveCalleeForSummary(node.callee, callContext);
|
|
911
|
+
const fn = target && target.fn;
|
|
912
|
+
if (!fn || !Array.isArray(fn.params) || !Array.isArray(fn.calls)) return;
|
|
913
|
+
node.args.forEach((cb, i) => {
|
|
914
|
+
if (!isCb(cb)) return;
|
|
915
|
+
const cbParam = fn.params[i];
|
|
916
|
+
if (!cbParam) return;
|
|
917
|
+
for (const inv of fn.calls) {
|
|
918
|
+
if (inv.callee !== cbParam) continue;
|
|
919
|
+
(inv.args || []).forEach((innerArg, k) => {
|
|
920
|
+
const root = innerArg && innerArg.kind === 'ident' ? innerArg.name : (accessPathOf(innerArg) || '').split('.')[0];
|
|
921
|
+
const j = root ? fn.params.indexOf(root) : -1;
|
|
922
|
+
if (j >= 0 && j !== i && node.args[j] && exprTaint(node.args[j], state, callContext)) push(cb, k, 'user-callback');
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
775
928
|
function _matchCallCatalog(calleeExpr, argExprs, state, callContext) {
|
|
776
929
|
const receiverType = _receiverTypeFor(calleeExpr, callContext);
|
|
777
|
-
|
|
930
|
+
let cat = matchSinkOrSanitizer(calleeExpr, _currentFile, receiverType);
|
|
931
|
+
if (Array.isArray(cat) && _isInternalModuleCall(calleeExpr, callContext)) {
|
|
932
|
+
const kept = cat.filter((e) => e.kind !== 'sink');
|
|
933
|
+
cat = kept.length ? kept : null;
|
|
934
|
+
}
|
|
778
935
|
const argTaints = (argExprs || []).map(a => exprTaint(a, state, callContext));
|
|
779
936
|
return { cat, argTaints };
|
|
780
937
|
}
|
|
@@ -1266,13 +1423,18 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
1266
1423
|
// real-world nesting. Deliberately does NOT re-check the TOP-LEVEL call
|
|
1267
1424
|
// itself (the caller already did that via its own _matchCallCatalog +
|
|
1268
1425
|
// _sinkFindingsForCall), so this never duplicates a finding.
|
|
1426
|
+
// Haskell only: a call written on its own line inside a larger expression (`get "/r" $ do ... liftIO (callCommand c)`) carries that line, while the node that
|
|
1427
|
+
// finally executes it sits on the line the handler starts. The sink is where the call is written. Other IRs lower nested calls differently and keep the node's line.
|
|
1428
|
+
const _sinkLine = (call, fallback) => (/\.(?:hs|lhs)$/i.test(_currentFile || '') && call && Number.isInteger(call.line) && call.line >= 1 ? call.line : fallback);
|
|
1429
|
+
|
|
1269
1430
|
function _nestedSinkFindings(argExprs, state, callContext, line) {
|
|
1270
1431
|
const findings = [];
|
|
1271
1432
|
for (const arg of (argExprs || [])) {
|
|
1272
1433
|
if (!arg || arg.kind !== 'call') continue;
|
|
1273
1434
|
const { cat, argTaints } = _matchCallCatalog(arg.callee, arg.args, state, callContext);
|
|
1274
|
-
|
|
1275
|
-
findings.push(...
|
|
1435
|
+
const at = _sinkLine(arg, line);
|
|
1436
|
+
findings.push(..._sinkFindingsForCall(arg.callee, arg.args, cat, argTaints, state, callContext, at).findings);
|
|
1437
|
+
findings.push(..._nestedSinkFindings(arg.args, state, callContext, at));
|
|
1276
1438
|
}
|
|
1277
1439
|
return findings;
|
|
1278
1440
|
}
|
|
@@ -1323,9 +1485,20 @@ function _memberWriteSinkFindings(hits, sourceExpr, state, callContext, line, ta
|
|
|
1323
1485
|
// nested in runTaintEngine) because step()'s assign/plain-call interproc
|
|
1324
1486
|
// branches call it too, and step() is a top-level function with no access
|
|
1325
1487
|
// to runTaintEngine's locals.
|
|
1488
|
+
// E2-05: a callee's findings are merged into its caller's every time the call is evaluated, and a call can be evaluated several times (the
|
|
1489
|
+
// fixed-point passes, and JavaScript emits both a call node and a return node for `return g(x)`). Each level of a call chain then doubled the
|
|
1490
|
+
// list: a chain of 20 one-line wrappers produced 131072 copies of one finding, and the explosion ended in the whole result being dropped. The
|
|
1491
|
+
// same sink reached again by the same finding is one finding, so a copy is skipped when this context already holds it.
|
|
1492
|
+
function _findingKey(f) { return `${f.file ?? ''}|${f.line ?? ''}|${f.cwe ?? ''}|${f.vuln ?? ''}|${typeof f.callee === 'string' ? f.callee : ''}|${f.id ?? ''}`; }
|
|
1326
1493
|
function _mergeSummaryFindings(callContext, callerQid, sum, via) {
|
|
1327
1494
|
if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
|
|
1328
|
-
callContext.
|
|
1495
|
+
if (!callContext._findingKeys) callContext._findingKeys = new Set(callContext._findings.map(_findingKey));
|
|
1496
|
+
for (const f of sum.findings) {
|
|
1497
|
+
const key = _findingKey(f);
|
|
1498
|
+
if (callContext._findingKeys.has(key)) continue;
|
|
1499
|
+
callContext._findingKeys.add(key);
|
|
1500
|
+
callContext._findings.push({ ...f, _funcQid: callerQid || null, _via: via });
|
|
1501
|
+
}
|
|
1329
1502
|
}
|
|
1330
1503
|
|
|
1331
1504
|
// Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
|
|
@@ -1435,9 +1608,9 @@ function step(node, stateIn, callContext) {
|
|
|
1435
1608
|
const callerTainted = newState;
|
|
1436
1609
|
const callArgs = (node.source.args || []);
|
|
1437
1610
|
const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
|
|
1438
|
-
const entry = paramNames.length
|
|
1611
|
+
const entry = _withCapturedTaint(paramNames.length
|
|
1439
1612
|
? entryStateFromCall(paramNames, callArgs, callerTainted, (a) => exprTaint(a, callerTainted, callContext))
|
|
1440
|
-
: new Set();
|
|
1613
|
+
: new Set(), fn, callerTainted, callContext);
|
|
1441
1614
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
1442
1615
|
if (!sum && fn && fn.cfg) {
|
|
1443
1616
|
// Lazy compute under this entry state. Use a fresh ctx so we
|
|
@@ -1537,6 +1710,25 @@ function step(node, stateIn, callContext) {
|
|
|
1537
1710
|
}
|
|
1538
1711
|
}
|
|
1539
1712
|
}
|
|
1713
|
+
if (target && node.source && node.source.kind === 'array' && !src && _isJsFile(_currentFile) && !(node.source.spreads && node.source.spreads.length)) {
|
|
1714
|
+
// E2-03: an array literal without spread elements is assigned index by index, so `[tainted, 'ls']` leaves `arr[1]` clean. Reading the
|
|
1715
|
+
// array as a whole (`arr.join()`, passing `arr`) is still tainted through the descendant rule. A literal with a spread keeps the whole-array rule.
|
|
1716
|
+
const lit = _arrayLiteralTaint(node.source, '', newState, callContext);
|
|
1717
|
+
newState = removePathAndDescendants(newState, target);
|
|
1718
|
+
if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
|
|
1719
|
+
for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
|
|
1720
|
+
return { state: newState, findings };
|
|
1721
|
+
}
|
|
1722
|
+
if (target && node.source && node.source.kind === 'object' && !src && _isJsFile(_currentFile)) {
|
|
1723
|
+
// E2-03: an object literal is assigned FIELD BY FIELD. `{ cmd: tainted, safe: 'ls' }` taints `o.cmd` and leaves `o.safe` clean; a spread of
|
|
1724
|
+
// a tainted value or a computed key (whose property is unknown) taints the whole object. The literal is a fresh value, so whatever `o`
|
|
1725
|
+
// held before is replaced, which also makes a later `o.cmd = 'ls'` an effective overwrite.
|
|
1726
|
+
const lit = _objectLiteralTaint(node.source, '', newState, callContext);
|
|
1727
|
+
newState = removePathAndDescendants(newState, target);
|
|
1728
|
+
if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
|
|
1729
|
+
for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
|
|
1730
|
+
return { state: newState, findings };
|
|
1731
|
+
}
|
|
1540
1732
|
if (target && _isCoercionCall(node.source)) {
|
|
1541
1733
|
// See _isCoercionCall's header comment: a real type coercion kills
|
|
1542
1734
|
// taint outright, unconditionally of sink family — the one case in
|
|
@@ -1581,9 +1773,9 @@ function step(node, stateIn, callContext) {
|
|
|
1581
1773
|
const qid = _resolvedTarget && _resolvedTarget.qid;
|
|
1582
1774
|
if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
|
|
1583
1775
|
const paramNames = fn.params;
|
|
1584
|
-
const entry = paramNames.length
|
|
1776
|
+
const entry = _withCapturedTaint(paramNames.length
|
|
1585
1777
|
? entryStateFromCall(paramNames, node.args || [], state, (a) => exprTaint(a, state, callContext))
|
|
1586
|
-
: new Set();
|
|
1778
|
+
: new Set(), fn, state, callContext);
|
|
1587
1779
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
1588
1780
|
// FR-SEM-2: context-sensitive lazy compute at the plain-call site,
|
|
1589
1781
|
// mirroring the assign-call site. On a miss for a NON-empty entry,
|
|
@@ -1733,7 +1925,15 @@ function step(node, stateIn, callContext) {
|
|
|
1733
1925
|
if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
|
|
1734
1926
|
}
|
|
1735
1927
|
}
|
|
1736
|
-
if (_recv)
|
|
1928
|
+
if (_recv) {
|
|
1929
|
+
// E2-03: `m.set('k', v)` with a literal key taints that key only, so a later `m.get('other')` stays clean.
|
|
1930
|
+
const keyArg = node.args && node.args[0];
|
|
1931
|
+
const onlyValueTainted = argTaints.length >= 2 && !argTaints[0] && argTaints.slice(1).some(Boolean);
|
|
1932
|
+
if (node.callee && node.callee.kind === 'member' && node.callee.prop === 'set' && node.args && node.args.length === 2
|
|
1933
|
+
&& keyArg && keyArg.kind === 'literal' && typeof keyArg.value === 'string' && onlyValueTainted && accessPathOf(node.callee.object) === _recv) {
|
|
1934
|
+
state.add(`${_recv}.$${keyArg.value}`);
|
|
1935
|
+
} else state.add(_recv);
|
|
1936
|
+
}
|
|
1737
1937
|
}
|
|
1738
1938
|
findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line, node.kwargs).findings);
|
|
1739
1939
|
findings.push(..._nestedSinkFindings(node.args, state, callContext, node.line));
|
|
@@ -1751,7 +1951,7 @@ function step(node, stateIn, callContext) {
|
|
|
1751
1951
|
const dot = callee.lastIndexOf('.');
|
|
1752
1952
|
if (dot <= 0) return null;
|
|
1753
1953
|
const recv = callee.slice(0, dot);
|
|
1754
|
-
const recvTainted = isCoveredBy(state, recv);
|
|
1954
|
+
const recvTainted = isCoveredBy(state, recv) || hasTaintedDescendant(state, recv);
|
|
1755
1955
|
// higherOrderTaintFlow requires a flattened STRING callee (its own
|
|
1756
1956
|
// `typeof callee !== 'string'` guard) — passing the raw `node` here
|
|
1757
1957
|
// handed it a JS/TS structured callee expr ({kind:'member',...})
|
|
@@ -1760,6 +1960,7 @@ function step(node, stateIn, callContext) {
|
|
|
1760
1960
|
// `callee` here is `_plainCallCalleeName`, already flattened above.
|
|
1761
1961
|
return higherOrderTaintFlow({ ...node, callee }, recvTainted);
|
|
1762
1962
|
})();
|
|
1963
|
+
_recordCallbackInvocations(node, state, callContext);
|
|
1763
1964
|
if (hoFlow && hoFlow.taintsCallbackParam === 0) {
|
|
1764
1965
|
// The first arg should be the callback. If it's a plain ident or
|
|
1765
1966
|
// function-value, the engine's per-callee summary path will pick it
|
|
@@ -1827,9 +2028,10 @@ function step(node, stateIn, callContext) {
|
|
|
1827
2028
|
// expression is directly a sink.
|
|
1828
2029
|
if (node.value && node.value.kind === 'call') {
|
|
1829
2030
|
const { cat, argTaints } = _matchCallCatalog(node.value.callee, node.value.args, state, callContext);
|
|
2031
|
+
const _at = _sinkLine(node.value, node.line);
|
|
1830
2032
|
findings.push(..._sinkFindingsForCall(
|
|
1831
|
-
node.value.callee, node.value.args, cat, argTaints, state, callContext,
|
|
1832
|
-
findings.push(..._nestedSinkFindings(node.value.args, state, callContext,
|
|
2033
|
+
node.value.callee, node.value.args, cat, argTaints, state, callContext, _at, node.value.kwargs).findings);
|
|
2034
|
+
findings.push(..._nestedSinkFindings(node.value.args, state, callContext, _at));
|
|
1833
2035
|
} else if (node.value && node.value.kind === 'union' && /\.(?:hs|lhs)$/i.test(_currentFile || '')) {
|
|
1834
2036
|
// Haskell: `if c then A else B` and guards return a union of the branch ACTIONS, each of which
|
|
1835
2037
|
// runs when its branch is taken, so every branch call is a sink site in its own right. (Scoped
|
|
@@ -1892,7 +2094,42 @@ function _siteKey(expr) {
|
|
|
1892
2094
|
return h.toString(16);
|
|
1893
2095
|
}
|
|
1894
2096
|
|
|
2097
|
+
// E2-04: framework propagation through a shared request object. In Express/Koa/Connect style apps a middleware writes `req.user = ...` and
|
|
2098
|
+
// a later handler reads `req.user`; the engine analyses each function alone, so the handler never saw the write. Here every function whose first
|
|
2099
|
+
// parameter is a request (`req`/`request`/`ctx`) and which ASSIGNS to a field of it is analysed once with nothing tainted; the fields that end up
|
|
2100
|
+
// tainted are remembered per file and seeded into the entry state of the other request-taking functions of the SAME file. Same-file only, and
|
|
2101
|
+
// only for fields some function assigns, so `req.other` stays clean. Cross-file middleware chains need a router model and are out of scope.
|
|
2102
|
+
const _REQ_PARAM = /^(?:req|request|ctx)$/;
|
|
2103
|
+
let _requestFieldSeeds = new Map(); // file -> Set<'field' | 'a.b'>
|
|
2104
|
+
function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
|
|
2105
|
+
_requestFieldSeeds = new Map();
|
|
2106
|
+
for (const fn of fnList) {
|
|
2107
|
+
if (!fn || !Array.isArray(fn.params) || !_REQ_PARAM.test(fn.params[0] || '') || !fn.cfg || !fn.cfg.nodes) continue;
|
|
2108
|
+
const p0 = fn.params[0];
|
|
2109
|
+
const targets = new Set();
|
|
2110
|
+
for (const n of Object.values(fn.cfg.nodes)) if (n && n.kind === 'assign' && typeof n.target === 'string' && n.target.startsWith(`${p0}.`)) targets.add(n.target);
|
|
2111
|
+
if (!targets.size) continue;
|
|
2112
|
+
const ctx = { _findings: [], _taintSources: [], _returnTainted: false, _stack: new Set(), deadlineMs, _summaryCache: new SummaryCache(), _callGraph: callGraph, _mutatedParamsOut: new Set(), _currentFnQid: fn.qid, _cha: opts && opts._cha, _pointsTo: opts && opts._pointsTo };
|
|
2113
|
+
let exit = null;
|
|
2114
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), ctx); exit = ctx._exitState; } catch { exit = null; }
|
|
2115
|
+
if (!exit) continue;
|
|
2116
|
+
for (const t of targets) {
|
|
2117
|
+
// the target itself (or a prefix of it) tainted seeds the target; otherwise each tainted path below it is seeded, so a literal
|
|
2118
|
+
// `req.ctx = { cmd: tainted, safe: 'ls' }` seeds `ctx.cmd` and leaves `ctx.safe` clean
|
|
2119
|
+
const paths = isCoveredBy(exit, t) ? [t] : [...exit].filter((q) => typeof q === 'string' && q.startsWith(`${t}.`));
|
|
2120
|
+
for (const q of paths) {
|
|
2121
|
+
if (!_requestFieldSeeds.has(fn.file)) _requestFieldSeeds.set(fn.file, new Set());
|
|
2122
|
+
_requestFieldSeeds.get(fn.file).add(q.slice(p0.length + 1));
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
1895
2128
|
function _unionAnnotationTaint(fn, entrySet) {
|
|
2129
|
+
const seeds = _requestFieldSeeds.get(fn.file);
|
|
2130
|
+
if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
|
|
2131
|
+
entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
|
|
2132
|
+
}
|
|
1896
2133
|
if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
|
|
1897
2134
|
const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
|
|
1898
2135
|
if (!extra.size) return entrySet;
|
|
@@ -1950,7 +2187,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1950
2187
|
if (++iterations > ITER_BUDGET) break;
|
|
1951
2188
|
// Check the global deadline every 100 iterations — Date.now() is cheap
|
|
1952
2189
|
// but not free; this keeps overhead negligible on small functions.
|
|
1953
|
-
if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) break;
|
|
2190
|
+
if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) { _noteIncomplete('deadline', `${fn.qid} worklist`); break; }
|
|
1954
2191
|
const nid = work.shift();
|
|
1955
2192
|
const node = nodes[nid];
|
|
1956
2193
|
if (!node) continue;
|
|
@@ -2118,7 +2355,24 @@ function stateEq(a, b) {
|
|
|
2118
2355
|
// The infra for it is in callContext.)
|
|
2119
2356
|
//
|
|
2120
2357
|
// Returns a flat array of findings, each enriched with file/line/etc.
|
|
2358
|
+
// E2-05: places where the analysis stopped short, reported instead of left silent. Each entry is a reason with a count and one example. A budget
|
|
2359
|
+
// that runs out used to leave a result that looked complete; now `runTaintEngine`'s return value carries `analysisIncomplete`, and the scan
|
|
2360
|
+
// reports it as `deepStatus.incomplete`.
|
|
2361
|
+
let _incomplete = new Map();
|
|
2362
|
+
function _noteIncomplete(reason, detail) {
|
|
2363
|
+
const cur = _incomplete.get(reason);
|
|
2364
|
+
if (cur) cur.count += 1; else _incomplete.set(reason, { reason, count: 1, example: String(detail ?? '').slice(0, 160) });
|
|
2365
|
+
}
|
|
2366
|
+
export const INCOMPLETE_REASONS = Object.freeze({
|
|
2367
|
+
'summary-budget': 'the interprocedural summary budget ran out; later callees were treated as having no effect',
|
|
2368
|
+
'deadline': 'the deep-analysis time budget ran out before every function was analysed',
|
|
2369
|
+
'function-limit': 'more functions than the per-scan function limit; the rest were not analysed',
|
|
2370
|
+
'higher-order-limit': 'more callback invocations than the per-function limit; the rest were not analysed',
|
|
2371
|
+
'analysis-error': 'the analysis of a function threw and its result was discarded',
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2121
2374
|
export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
2375
|
+
_incomplete = new Map();
|
|
2122
2376
|
const all = [];
|
|
2123
2377
|
const seen = new Set();
|
|
2124
2378
|
const fnLimit = opts.fnLimit || 5000;
|
|
@@ -2154,6 +2408,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2154
2408
|
a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
|
|
2155
2409
|
);
|
|
2156
2410
|
_buildRegexNames(fnList);
|
|
2411
|
+
_buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs);
|
|
2157
2412
|
// PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
|
|
2158
2413
|
// silently `break`s once `n > fnLimit` (default 5000), which for a single
|
|
2159
2414
|
// large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
|
|
@@ -2578,8 +2833,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2578
2833
|
}
|
|
2579
2834
|
const _progressTotal = Math.min(fnList.length, fnLimit);
|
|
2580
2835
|
for (const fn of fnList) {
|
|
2581
|
-
if (++n > fnLimit) break;
|
|
2582
|
-
if (Date.now() > deadlineMs) break; // global timeout
|
|
2836
|
+
if (++n > fnLimit) { _noteIncomplete('function-limit', `limit ${fnLimit}`); break; }
|
|
2837
|
+
if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'main function loop'); break; } // global timeout
|
|
2583
2838
|
if (onProgress) onProgress({ current: n, total: _progressTotal });
|
|
2584
2839
|
// Module-level functions: analyze with an empty entry state. The function
|
|
2585
2840
|
// discovers its own sources from req.body/process.env/etc. as it walks.
|
|
@@ -2605,13 +2860,14 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2605
2860
|
};
|
|
2606
2861
|
try {
|
|
2607
2862
|
analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
|
|
2608
|
-
} catch { continue; }
|
|
2863
|
+
} catch (e) { _noteIncomplete('analysis-error', `${fn.qid}: ${e && e.message}`); continue; }
|
|
2609
2864
|
// Process higher-order invocations: resolve callbacks and analyze with
|
|
2610
2865
|
// tainted first-param. Feed findings back into the caller's finding set.
|
|
2611
2866
|
const hoInvocations = callContext._higherOrderInvocations || [];
|
|
2612
2867
|
const HO_CAP = 50;
|
|
2868
|
+
if (hoInvocations.length > HO_CAP) _noteIncomplete('higher-order-limit', `${fn.qid}: ${hoInvocations.length} callbacks`);
|
|
2613
2869
|
for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
|
|
2614
|
-
if (Date.now() > deadlineMs) break;
|
|
2870
|
+
if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'callback analysis'); break; }
|
|
2615
2871
|
const inv = hoInvocations[hi];
|
|
2616
2872
|
if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
|
|
2617
2873
|
// Two resolution strategies, matching the two shapes the push site can
|
|
@@ -2677,6 +2933,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2677
2933
|
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
2678
2934
|
}
|
|
2679
2935
|
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
2936
|
+
if (summaryCache._budgetHits) _noteIncomplete('summary-budget', `${summaryCache._budgetHits} summary requests after ${summaryCache._maxIter} computations`);
|
|
2937
|
+
Object.defineProperty(all, 'analysisIncomplete', { value: [..._incomplete.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1)), enumerable: false });
|
|
2680
2938
|
_collapseNestedHsSinks(all);
|
|
2681
2939
|
return all;
|
|
2682
2940
|
|