@clear-capabilities/agentic-security-scanner 0.172.0 → 0.173.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 +29 -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/1336.index.js +101 -101
- 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/7405.index.js +23 -6
- 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 +338 -48
- 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 +28 -28
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/adapters/haskell-hie.js +28 -1
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/canonical.js +25 -0
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +16 -10
- package/src/context-intelligence/config.js +4 -1
- package/src/context-intelligence/controls.js +108 -10
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/entrypoints.js +2 -2
- package/src/context-intelligence/evaluation/index.js +11 -4
- package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
- 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 +10 -4
- package/src/context-intelligence/review.js +129 -16
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- 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/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/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
|
}
|
|
@@ -1323,9 +1480,20 @@ function _memberWriteSinkFindings(hits, sourceExpr, state, callContext, line, ta
|
|
|
1323
1480
|
// nested in runTaintEngine) because step()'s assign/plain-call interproc
|
|
1324
1481
|
// branches call it too, and step() is a top-level function with no access
|
|
1325
1482
|
// to runTaintEngine's locals.
|
|
1483
|
+
// 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
|
|
1484
|
+
// 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
|
|
1485
|
+
// 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
|
|
1486
|
+
// same sink reached again by the same finding is one finding, so a copy is skipped when this context already holds it.
|
|
1487
|
+
function _findingKey(f) { return `${f.file ?? ''}|${f.line ?? ''}|${f.cwe ?? ''}|${f.vuln ?? ''}|${typeof f.callee === 'string' ? f.callee : ''}|${f.id ?? ''}`; }
|
|
1326
1488
|
function _mergeSummaryFindings(callContext, callerQid, sum, via) {
|
|
1327
1489
|
if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
|
|
1328
|
-
callContext.
|
|
1490
|
+
if (!callContext._findingKeys) callContext._findingKeys = new Set(callContext._findings.map(_findingKey));
|
|
1491
|
+
for (const f of sum.findings) {
|
|
1492
|
+
const key = _findingKey(f);
|
|
1493
|
+
if (callContext._findingKeys.has(key)) continue;
|
|
1494
|
+
callContext._findingKeys.add(key);
|
|
1495
|
+
callContext._findings.push({ ...f, _funcQid: callerQid || null, _via: via });
|
|
1496
|
+
}
|
|
1329
1497
|
}
|
|
1330
1498
|
|
|
1331
1499
|
// Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
|
|
@@ -1435,9 +1603,9 @@ function step(node, stateIn, callContext) {
|
|
|
1435
1603
|
const callerTainted = newState;
|
|
1436
1604
|
const callArgs = (node.source.args || []);
|
|
1437
1605
|
const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
|
|
1438
|
-
const entry = paramNames.length
|
|
1606
|
+
const entry = _withCapturedTaint(paramNames.length
|
|
1439
1607
|
? entryStateFromCall(paramNames, callArgs, callerTainted, (a) => exprTaint(a, callerTainted, callContext))
|
|
1440
|
-
: new Set();
|
|
1608
|
+
: new Set(), fn, callerTainted, callContext);
|
|
1441
1609
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
1442
1610
|
if (!sum && fn && fn.cfg) {
|
|
1443
1611
|
// Lazy compute under this entry state. Use a fresh ctx so we
|
|
@@ -1537,6 +1705,25 @@ function step(node, stateIn, callContext) {
|
|
|
1537
1705
|
}
|
|
1538
1706
|
}
|
|
1539
1707
|
}
|
|
1708
|
+
if (target && node.source && node.source.kind === 'array' && !src && _isJsFile(_currentFile) && !(node.source.spreads && node.source.spreads.length)) {
|
|
1709
|
+
// E2-03: an array literal without spread elements is assigned index by index, so `[tainted, 'ls']` leaves `arr[1]` clean. Reading the
|
|
1710
|
+
// 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.
|
|
1711
|
+
const lit = _arrayLiteralTaint(node.source, '', newState, callContext);
|
|
1712
|
+
newState = removePathAndDescendants(newState, target);
|
|
1713
|
+
if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
|
|
1714
|
+
for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
|
|
1715
|
+
return { state: newState, findings };
|
|
1716
|
+
}
|
|
1717
|
+
if (target && node.source && node.source.kind === 'object' && !src && _isJsFile(_currentFile)) {
|
|
1718
|
+
// 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
|
|
1719
|
+
// a tainted value or a computed key (whose property is unknown) taints the whole object. The literal is a fresh value, so whatever `o`
|
|
1720
|
+
// held before is replaced, which also makes a later `o.cmd = 'ls'` an effective overwrite.
|
|
1721
|
+
const lit = _objectLiteralTaint(node.source, '', newState, callContext);
|
|
1722
|
+
newState = removePathAndDescendants(newState, target);
|
|
1723
|
+
if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
|
|
1724
|
+
for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
|
|
1725
|
+
return { state: newState, findings };
|
|
1726
|
+
}
|
|
1540
1727
|
if (target && _isCoercionCall(node.source)) {
|
|
1541
1728
|
// See _isCoercionCall's header comment: a real type coercion kills
|
|
1542
1729
|
// taint outright, unconditionally of sink family — the one case in
|
|
@@ -1581,9 +1768,9 @@ function step(node, stateIn, callContext) {
|
|
|
1581
1768
|
const qid = _resolvedTarget && _resolvedTarget.qid;
|
|
1582
1769
|
if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
|
|
1583
1770
|
const paramNames = fn.params;
|
|
1584
|
-
const entry = paramNames.length
|
|
1771
|
+
const entry = _withCapturedTaint(paramNames.length
|
|
1585
1772
|
? entryStateFromCall(paramNames, node.args || [], state, (a) => exprTaint(a, state, callContext))
|
|
1586
|
-
: new Set();
|
|
1773
|
+
: new Set(), fn, state, callContext);
|
|
1587
1774
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
1588
1775
|
// FR-SEM-2: context-sensitive lazy compute at the plain-call site,
|
|
1589
1776
|
// mirroring the assign-call site. On a miss for a NON-empty entry,
|
|
@@ -1733,7 +1920,15 @@ function step(node, stateIn, callContext) {
|
|
|
1733
1920
|
if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
|
|
1734
1921
|
}
|
|
1735
1922
|
}
|
|
1736
|
-
if (_recv)
|
|
1923
|
+
if (_recv) {
|
|
1924
|
+
// E2-03: `m.set('k', v)` with a literal key taints that key only, so a later `m.get('other')` stays clean.
|
|
1925
|
+
const keyArg = node.args && node.args[0];
|
|
1926
|
+
const onlyValueTainted = argTaints.length >= 2 && !argTaints[0] && argTaints.slice(1).some(Boolean);
|
|
1927
|
+
if (node.callee && node.callee.kind === 'member' && node.callee.prop === 'set' && node.args && node.args.length === 2
|
|
1928
|
+
&& keyArg && keyArg.kind === 'literal' && typeof keyArg.value === 'string' && onlyValueTainted && accessPathOf(node.callee.object) === _recv) {
|
|
1929
|
+
state.add(`${_recv}.$${keyArg.value}`);
|
|
1930
|
+
} else state.add(_recv);
|
|
1931
|
+
}
|
|
1737
1932
|
}
|
|
1738
1933
|
findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line, node.kwargs).findings);
|
|
1739
1934
|
findings.push(..._nestedSinkFindings(node.args, state, callContext, node.line));
|
|
@@ -1751,7 +1946,7 @@ function step(node, stateIn, callContext) {
|
|
|
1751
1946
|
const dot = callee.lastIndexOf('.');
|
|
1752
1947
|
if (dot <= 0) return null;
|
|
1753
1948
|
const recv = callee.slice(0, dot);
|
|
1754
|
-
const recvTainted = isCoveredBy(state, recv);
|
|
1949
|
+
const recvTainted = isCoveredBy(state, recv) || hasTaintedDescendant(state, recv);
|
|
1755
1950
|
// higherOrderTaintFlow requires a flattened STRING callee (its own
|
|
1756
1951
|
// `typeof callee !== 'string'` guard) — passing the raw `node` here
|
|
1757
1952
|
// handed it a JS/TS structured callee expr ({kind:'member',...})
|
|
@@ -1760,6 +1955,7 @@ function step(node, stateIn, callContext) {
|
|
|
1760
1955
|
// `callee` here is `_plainCallCalleeName`, already flattened above.
|
|
1761
1956
|
return higherOrderTaintFlow({ ...node, callee }, recvTainted);
|
|
1762
1957
|
})();
|
|
1958
|
+
_recordCallbackInvocations(node, state, callContext);
|
|
1763
1959
|
if (hoFlow && hoFlow.taintsCallbackParam === 0) {
|
|
1764
1960
|
// The first arg should be the callback. If it's a plain ident or
|
|
1765
1961
|
// function-value, the engine's per-callee summary path will pick it
|
|
@@ -1892,7 +2088,42 @@ function _siteKey(expr) {
|
|
|
1892
2088
|
return h.toString(16);
|
|
1893
2089
|
}
|
|
1894
2090
|
|
|
2091
|
+
// E2-04: framework propagation through a shared request object. In Express/Koa/Connect style apps a middleware writes `req.user = ...` and
|
|
2092
|
+
// a later handler reads `req.user`; the engine analyses each function alone, so the handler never saw the write. Here every function whose first
|
|
2093
|
+
// 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
|
|
2094
|
+
// 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
|
|
2095
|
+
// only for fields some function assigns, so `req.other` stays clean. Cross-file middleware chains need a router model and are out of scope.
|
|
2096
|
+
const _REQ_PARAM = /^(?:req|request|ctx)$/;
|
|
2097
|
+
let _requestFieldSeeds = new Map(); // file -> Set<'field' | 'a.b'>
|
|
2098
|
+
function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
|
|
2099
|
+
_requestFieldSeeds = new Map();
|
|
2100
|
+
for (const fn of fnList) {
|
|
2101
|
+
if (!fn || !Array.isArray(fn.params) || !_REQ_PARAM.test(fn.params[0] || '') || !fn.cfg || !fn.cfg.nodes) continue;
|
|
2102
|
+
const p0 = fn.params[0];
|
|
2103
|
+
const targets = new Set();
|
|
2104
|
+
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);
|
|
2105
|
+
if (!targets.size) continue;
|
|
2106
|
+
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 };
|
|
2107
|
+
let exit = null;
|
|
2108
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), ctx); exit = ctx._exitState; } catch { exit = null; }
|
|
2109
|
+
if (!exit) continue;
|
|
2110
|
+
for (const t of targets) {
|
|
2111
|
+
// the target itself (or a prefix of it) tainted seeds the target; otherwise each tainted path below it is seeded, so a literal
|
|
2112
|
+
// `req.ctx = { cmd: tainted, safe: 'ls' }` seeds `ctx.cmd` and leaves `ctx.safe` clean
|
|
2113
|
+
const paths = isCoveredBy(exit, t) ? [t] : [...exit].filter((q) => typeof q === 'string' && q.startsWith(`${t}.`));
|
|
2114
|
+
for (const q of paths) {
|
|
2115
|
+
if (!_requestFieldSeeds.has(fn.file)) _requestFieldSeeds.set(fn.file, new Set());
|
|
2116
|
+
_requestFieldSeeds.get(fn.file).add(q.slice(p0.length + 1));
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
|
|
1895
2122
|
function _unionAnnotationTaint(fn, entrySet) {
|
|
2123
|
+
const seeds = _requestFieldSeeds.get(fn.file);
|
|
2124
|
+
if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
|
|
2125
|
+
entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
|
|
2126
|
+
}
|
|
1896
2127
|
if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
|
|
1897
2128
|
const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
|
|
1898
2129
|
if (!extra.size) return entrySet;
|
|
@@ -1950,7 +2181,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1950
2181
|
if (++iterations > ITER_BUDGET) break;
|
|
1951
2182
|
// Check the global deadline every 100 iterations — Date.now() is cheap
|
|
1952
2183
|
// but not free; this keeps overhead negligible on small functions.
|
|
1953
|
-
if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) break;
|
|
2184
|
+
if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) { _noteIncomplete('deadline', `${fn.qid} worklist`); break; }
|
|
1954
2185
|
const nid = work.shift();
|
|
1955
2186
|
const node = nodes[nid];
|
|
1956
2187
|
if (!node) continue;
|
|
@@ -2118,7 +2349,24 @@ function stateEq(a, b) {
|
|
|
2118
2349
|
// The infra for it is in callContext.)
|
|
2119
2350
|
//
|
|
2120
2351
|
// Returns a flat array of findings, each enriched with file/line/etc.
|
|
2352
|
+
// 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
|
|
2353
|
+
// that runs out used to leave a result that looked complete; now `runTaintEngine`'s return value carries `analysisIncomplete`, and the scan
|
|
2354
|
+
// reports it as `deepStatus.incomplete`.
|
|
2355
|
+
let _incomplete = new Map();
|
|
2356
|
+
function _noteIncomplete(reason, detail) {
|
|
2357
|
+
const cur = _incomplete.get(reason);
|
|
2358
|
+
if (cur) cur.count += 1; else _incomplete.set(reason, { reason, count: 1, example: String(detail ?? '').slice(0, 160) });
|
|
2359
|
+
}
|
|
2360
|
+
export const INCOMPLETE_REASONS = Object.freeze({
|
|
2361
|
+
'summary-budget': 'the interprocedural summary budget ran out; later callees were treated as having no effect',
|
|
2362
|
+
'deadline': 'the deep-analysis time budget ran out before every function was analysed',
|
|
2363
|
+
'function-limit': 'more functions than the per-scan function limit; the rest were not analysed',
|
|
2364
|
+
'higher-order-limit': 'more callback invocations than the per-function limit; the rest were not analysed',
|
|
2365
|
+
'analysis-error': 'the analysis of a function threw and its result was discarded',
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2121
2368
|
export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
2369
|
+
_incomplete = new Map();
|
|
2122
2370
|
const all = [];
|
|
2123
2371
|
const seen = new Set();
|
|
2124
2372
|
const fnLimit = opts.fnLimit || 5000;
|
|
@@ -2154,6 +2402,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2154
2402
|
a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
|
|
2155
2403
|
);
|
|
2156
2404
|
_buildRegexNames(fnList);
|
|
2405
|
+
_buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs);
|
|
2157
2406
|
// PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
|
|
2158
2407
|
// silently `break`s once `n > fnLimit` (default 5000), which for a single
|
|
2159
2408
|
// large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
|
|
@@ -2578,8 +2827,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2578
2827
|
}
|
|
2579
2828
|
const _progressTotal = Math.min(fnList.length, fnLimit);
|
|
2580
2829
|
for (const fn of fnList) {
|
|
2581
|
-
if (++n > fnLimit) break;
|
|
2582
|
-
if (Date.now() > deadlineMs) break; // global timeout
|
|
2830
|
+
if (++n > fnLimit) { _noteIncomplete('function-limit', `limit ${fnLimit}`); break; }
|
|
2831
|
+
if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'main function loop'); break; } // global timeout
|
|
2583
2832
|
if (onProgress) onProgress({ current: n, total: _progressTotal });
|
|
2584
2833
|
// Module-level functions: analyze with an empty entry state. The function
|
|
2585
2834
|
// discovers its own sources from req.body/process.env/etc. as it walks.
|
|
@@ -2605,13 +2854,14 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2605
2854
|
};
|
|
2606
2855
|
try {
|
|
2607
2856
|
analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
|
|
2608
|
-
} catch { continue; }
|
|
2857
|
+
} catch (e) { _noteIncomplete('analysis-error', `${fn.qid}: ${e && e.message}`); continue; }
|
|
2609
2858
|
// Process higher-order invocations: resolve callbacks and analyze with
|
|
2610
2859
|
// tainted first-param. Feed findings back into the caller's finding set.
|
|
2611
2860
|
const hoInvocations = callContext._higherOrderInvocations || [];
|
|
2612
2861
|
const HO_CAP = 50;
|
|
2862
|
+
if (hoInvocations.length > HO_CAP) _noteIncomplete('higher-order-limit', `${fn.qid}: ${hoInvocations.length} callbacks`);
|
|
2613
2863
|
for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
|
|
2614
|
-
if (Date.now() > deadlineMs) break;
|
|
2864
|
+
if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'callback analysis'); break; }
|
|
2615
2865
|
const inv = hoInvocations[hi];
|
|
2616
2866
|
if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
|
|
2617
2867
|
// Two resolution strategies, matching the two shapes the push site can
|
|
@@ -2677,6 +2927,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2677
2927
|
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
2678
2928
|
}
|
|
2679
2929
|
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
2930
|
+
if (summaryCache._budgetHits) _noteIncomplete('summary-budget', `${summaryCache._budgetHits} summary requests after ${summaryCache._maxIter} computations`);
|
|
2931
|
+
Object.defineProperty(all, 'analysisIncomplete', { value: [..._incomplete.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1)), enumerable: false });
|
|
2680
2932
|
_collapseNestedHsSinks(all);
|
|
2681
2933
|
return all;
|
|
2682
2934
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Static service relationships (E2-07): which service calls, publishes to, or stores for which other service, read from the code.
|
|
2
|
+
//
|
|
3
|
+
// OPT-IN. Nothing here runs unless a caller passes the service roots; a single-service repository gets nothing, and no relationship is invented
|
|
4
|
+
// from a naming coincidence alone.
|
|
5
|
+
//
|
|
6
|
+
// WHAT AN EDGE CARRIES. Each edge says how it is known:
|
|
7
|
+
// observed both ends are literal in the code and agree (an HTTP client calling `http://orders/api/items` and an `orders` route `POST /api/items`;
|
|
8
|
+
// a producer and a consumer naming the same literal topic; two services naming the same table AND the project declaring one shared database)
|
|
9
|
+
// inferred the ends match but one rests on a weaker fact (two services name the same table with no declaration that they share a database;
|
|
10
|
+
// a client URL whose host comes from a configured base URL)
|
|
11
|
+
// unknown one end is dynamic (a computed topic, a URL assembled from user data) or has no counterpart: it is recorded and never connected
|
|
12
|
+
//
|
|
13
|
+
// WHAT IT NEVER SAYS. A chain is `static-observed` only when every hop is observed, `static-inferred` when the weakest hop is inferred, and
|
|
14
|
+
// `unproven` is reserved for a caller that supplies its own edges: this module never connects across an unknown one. No label is ever `proven`: this module reads text, it does not run the services, so ambiguity across
|
|
15
|
+
// a service boundary cannot be described as a demonstrated exploit chain. Traversal is bounded by hop count and a visited set, so a cycle ends.
|
|
16
|
+
//
|
|
17
|
+
// SCOPE. JavaScript, TypeScript and Python sources. HTTP (Express/Koa/Fastify-style routes, Flask/FastAPI decorators; fetch/axios/requests/got
|
|
18
|
+
// clients), messaging (Kafka/AMQP/Redis/NATS style publish and subscribe with a literal topic), persistence (SQL text and common collection calls).
|
|
19
|
+
// Serializer contracts (protobuf/JSON-schema sharing) are not read: recorded as a limit.
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const EDGE_KINDS = Object.freeze(['http', 'messaging', 'persistence']);
|
|
23
|
+
const EVIDENCE = Object.freeze(['observed', 'inferred', 'unknown']);
|
|
24
|
+
export const MAX_HOPS = 6;
|
|
25
|
+
const RANK = { observed: 0, inferred: 1, unknown: 2 };
|
|
26
|
+
const id = (parts) => `svcedge:${createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)}`;
|
|
27
|
+
const posix = (p) => String(p).replace(/\\/g, '/');
|
|
28
|
+
const lineOf = (code, index) => code.slice(0, index).split('\n').length;
|
|
29
|
+
|
|
30
|
+
function serviceOf(file, services) {
|
|
31
|
+
const f = posix(file);
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const s of services) { const root = posix(s.root).replace(/\/$/, ''); if ((f === root || f.startsWith(`${root}/`)) && (!best || root.length > posix(best.root).length)) best = s; }
|
|
34
|
+
return best;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const norm = (p) => `/${String(p).replace(/^https?:\/\/[^/]+/, '').replace(/[?#].*$/, '').split('/').filter(Boolean).join('/')}`;
|
|
38
|
+
// `/items/:id`, `/items/{id}`, `/items/<id>`, `/items/${id}` all name the same shape
|
|
39
|
+
// a concrete segment (`/items/42`) matches a parameter segment of a route; two concrete segments must be equal
|
|
40
|
+
const matchesShape = (routePath, clientPath) => { const r = shape(routePath).split('/'); const c = shape(clientPath).split('/'); return r.length === c.length && r.every((seg, i) => seg === ':p' || seg === c[i]); };
|
|
41
|
+
const shape = (p) => norm(p).split('/').map((seg) => (/^(?::|\{|<|\$\{)/.test(seg) ? ':p' : seg)).join('/');
|
|
42
|
+
|
|
43
|
+
// ---- extraction -------------------------------------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const ROUTE_JS = /\b(?:app|router|server|fastify|api)\s*\.\s*(get|post|put|patch|delete|all)\s*\(\s*(['"`])(\/[^'"`]*)\2/g;
|
|
46
|
+
const ROUTE_PY = /@\s*(?:app|router|bp|blueprint)\s*\.\s*(get|post|put|patch|delete|route)\s*\(\s*(['"])(\/[^'"]*)\2([^)]*)\)/g;
|
|
47
|
+
const CLIENT_JS = /\b(fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|got(?:\s*\.\s*(?:get|post|put|patch|delete))?)\s*\(\s*([`'"])([^`'"]*)\2/g;
|
|
48
|
+
const CLIENT_PY = /\brequests\s*\.\s*(get|post|put|patch|delete)\s*\(\s*(['"])([^'"]*)\2/g;
|
|
49
|
+
const CLIENT_DYN = /\b(?:fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|requests\s*\.\s*(?:get|post|put|patch|delete)|got)\s*\(\s*([A-Za-z_$][\w$.]*)\s*[,)]/g;
|
|
50
|
+
const PRODUCE = /\b(?:producer|channel|publisher|pubsub|nats|client|redis|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue|xadd|lpush)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
|
|
51
|
+
const PRODUCE_DYN = /\b(?:producer|channel|publisher|pubsub|nats|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue)\s*\(\s*(?:\{\s*topic\s*:\s*)?([A-Za-z_$][\w$.]*)\s*[,})]/g;
|
|
52
|
+
const CONSUME = /\b(?:consumer|channel|subscriber|pubsub|nats|bus|queue|client)\s*\.\s*(?:subscribe|consume|on|psubscribe|xread|brpop)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
|
|
53
|
+
const SQL_WRITE = /\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[`"']?([A-Za-z_][\w.]*)/gi;
|
|
54
|
+
const SQL_READ = /\bSELECT\b[^;]{0,300}?\bFROM\s+[`"']?([A-Za-z_][\w.]*)/gi;
|
|
55
|
+
const COLL_WRITE = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|save|create|insert|update|remove)\s*\(/g;
|
|
56
|
+
const COLL_READ = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:find|findOne|findMany|findById|get|select|aggregate|count)\s*\(/g;
|
|
57
|
+
const DB_URL = /\b(DATABASE_URL|DB_URL|MONGO_URL|MONGODB_URI|POSTGRES_URL|REDIS_URL)\b/g;
|
|
58
|
+
|
|
59
|
+
function* matches(re, code) { re.lastIndex = 0; let m; while ((m = re.exec(code))) yield m; }
|
|
60
|
+
|
|
61
|
+
/** @returns {{routes:object[], clients:object[], produces:object[], consumes:object[], writes:object[], reads:object[], dbRefs:string[], unknown:object[]}} */
|
|
62
|
+
export function extractFacts(file, code) {
|
|
63
|
+
const out = { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: [], unknown: [] };
|
|
64
|
+
const py = /\.py$/.test(file);
|
|
65
|
+
const at = (m) => lineOf(code, m.index);
|
|
66
|
+
for (const m of matches(py ? ROUTE_PY : ROUTE_JS, code)) {
|
|
67
|
+
let method = m[1].toUpperCase();
|
|
68
|
+
if (py && method === 'ROUTE') { const mm = /methods\s*=\s*\[\s*['"](\w+)['"]/.exec(m[4] || ''); method = mm ? mm[1].toUpperCase() : 'GET'; }
|
|
69
|
+
out.routes.push({ method, path: m[3], file, line: at(m) });
|
|
70
|
+
}
|
|
71
|
+
for (const m of matches(py ? CLIENT_PY : CLIENT_JS, code)) {
|
|
72
|
+
const target = py ? m[3] : m[3];
|
|
73
|
+
// `fetch(url, { method: 'POST' })` names its verb in the options object; `axios.post` and `requests.post` in the callee
|
|
74
|
+
const optionVerb = /\bmethod\s*:\s*['"`](\w+)['"`]/.exec(code.slice(m.index, m.index + 300));
|
|
75
|
+
const verb = py ? m[1].toUpperCase() : ((/\.\s*(get|post|put|patch|delete)/.exec(m[1]) || [null, optionVerb ? optionVerb[1] : 'GET'])[1]).toUpperCase();
|
|
76
|
+
if (/^https?:\/\//.test(target) || /^\$\{[^}]+\}\//.test(target)) out.clients.push({ method: verb, url: target, file, line: at(m), literalHost: /^https?:\/\/[^/${]+/.test(target) });
|
|
77
|
+
}
|
|
78
|
+
for (const m of matches(CLIENT_DYN, code)) out.unknown.push({ kind: 'http', file, line: at(m), reason: `client URL comes from \`${m[1]}\`, not a literal` });
|
|
79
|
+
for (const m of matches(PRODUCE, code)) out.produces.push({ topic: m[2], file, line: at(m) });
|
|
80
|
+
for (const m of matches(PRODUCE_DYN, code)) out.unknown.push({ kind: 'messaging', file, line: at(m), reason: `topic comes from \`${m[1]}\`, not a literal` });
|
|
81
|
+
for (const m of matches(CONSUME, code)) out.consumes.push({ topic: m[2], file, line: at(m) });
|
|
82
|
+
for (const m of matches(SQL_WRITE, code)) out.writes.push({ table: m[1].toLowerCase(), file, line: at(m) });
|
|
83
|
+
for (const m of matches(SQL_READ, code)) out.reads.push({ table: m[1].toLowerCase(), file, line: at(m) });
|
|
84
|
+
for (const m of matches(COLL_WRITE, code)) out.writes.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
|
|
85
|
+
for (const m of matches(COLL_READ, code)) out.reads.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
|
|
86
|
+
for (const m of matches(DB_URL, code)) out.dbRefs.push(m[1]);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {Record<string,string>} fileContents repository-relative path -> source
|
|
92
|
+
* @param {{services:{name:string, root:string, hosts?:string[]}[], sharedDatabases?:{name?:string, services:string[], tables?:string[]}[]}} opts
|
|
93
|
+
* `services` is REQUIRED: without it nothing is inferred. `hosts` are the names other services use to reach this one.
|
|
94
|
+
* `sharedDatabases` is the declaration that makes a persistence edge observed rather than inferred.
|
|
95
|
+
*/
|
|
96
|
+
export function buildServiceEdges(fileContents, opts = {}) {
|
|
97
|
+
const services = (opts.services || []).filter((s) => s && s.name && s.root);
|
|
98
|
+
if (services.length < 2) return { edges: [], unknown: [], services: services.map((s) => s.name), limits: LIMITS, optedIn: false, note: 'fewer than two services declared: no service relationships are inferred' };
|
|
99
|
+
const facts = new Map(services.map((s) => [s.name, { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: new Set(), unknown: [] }]));
|
|
100
|
+
for (const [file, code] of Object.entries(fileContents || {}).sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
101
|
+
if (typeof code !== 'string' || !/\.(?:[cm]?[jt]sx?|py)$/.test(file)) continue;
|
|
102
|
+
const svc = serviceOf(file, services);
|
|
103
|
+
if (!svc) continue;
|
|
104
|
+
const f = extractFacts(posix(file), code);
|
|
105
|
+
const acc = facts.get(svc.name);
|
|
106
|
+
for (const k of ['routes', 'clients', 'produces', 'consumes', 'writes', 'reads', 'unknown']) acc[k].push(...f[k].map((x) => ({ ...x, service: svc.name })));
|
|
107
|
+
for (const d of f.dbRefs) acc.dbRefs.add(d);
|
|
108
|
+
}
|
|
109
|
+
const hostsOf = (s) => new Set([s.name, ...(s.hosts || [])].map((h) => h.toLowerCase()));
|
|
110
|
+
const edges = []; const unknown = [];
|
|
111
|
+
for (const [name, f] of facts) for (const u of f.unknown) unknown.push({ service: name, ...u, evidence: 'unknown' });
|
|
112
|
+
|
|
113
|
+
// HTTP: a client URL names a host and a path; the host picks the service, the method and path shape pick the route
|
|
114
|
+
for (const [from, f] of facts) for (const c of f.clients) {
|
|
115
|
+
const hm = /^https?:\/\/([^/:${]+)/.exec(c.url);
|
|
116
|
+
const host = hm ? hm[1].toLowerCase() : null;
|
|
117
|
+
const target = host ? services.find((s) => s.name !== from && hostsOf(s).has(host)) : null;
|
|
118
|
+
if (!target) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: host ? `host ${host} is not a declared service` : 'the host is configured at run time', evidence: 'unknown' }); continue; }
|
|
119
|
+
const route = facts.get(target.name).routes.find((r) => (r.method === c.method || r.method === 'ALL') && matchesShape(r.path, c.url));
|
|
120
|
+
if (!route) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: `${target.name} has no ${c.method} route matching ${norm(c.url)}`, evidence: 'unknown' }); continue; }
|
|
121
|
+
edges.push({ id: id(['http', from, c.file, c.method, shape(c.url), target.name, route.file]), kind: 'http', from: { service: from, file: c.file, line: c.line }, to: { service: target.name, file: route.file, line: route.line }, via: { method: c.method, path: shape(route.path) }, evidence: 'observed', basis: 'a literal client URL and a route with the same method and path shape' });
|
|
122
|
+
}
|
|
123
|
+
// Messaging: the same literal topic on a producer and a consumer in different services
|
|
124
|
+
for (const [from, f] of facts) for (const p of f.produces) {
|
|
125
|
+
const consumers = [...facts].filter(([n]) => n !== from).flatMap(([n, g]) => g.consumes.filter((c) => c.topic === p.topic).map((c) => ({ ...c, service: n })));
|
|
126
|
+
if (!consumers.length) { unknown.push({ service: from, kind: 'messaging', file: p.file, line: p.line, reason: `no other service consumes topic "${p.topic}"`, evidence: 'unknown' }); continue; }
|
|
127
|
+
for (const c of consumers) edges.push({ id: id(['messaging', from, p.file, p.topic, c.service, c.file]), kind: 'messaging', from: { service: from, file: p.file, line: p.line }, to: { service: c.service, file: c.file, line: c.line }, via: { topic: p.topic }, evidence: 'observed', basis: 'the same literal topic is produced and consumed' });
|
|
128
|
+
}
|
|
129
|
+
// Persistence: one service writes a table another reads
|
|
130
|
+
const shared = (opts.sharedDatabases || []).map((d) => ({ services: new Set(d.services), tables: d.tables ? new Set(d.tables.map((t) => t.toLowerCase())) : null }));
|
|
131
|
+
for (const [from, f] of facts) for (const w of f.writes) for (const [to, g] of facts) {
|
|
132
|
+
if (to === from) continue;
|
|
133
|
+
for (const r of g.reads.filter((x) => x.table === w.table)) {
|
|
134
|
+
const declared = shared.some((d) => d.services.has(from) && d.services.has(to) && (!d.tables || d.tables.has(w.table)));
|
|
135
|
+
edges.push({ id: id(['persistence', from, w.file, w.table, to, r.file]), kind: 'persistence', from: { service: from, file: w.file, line: w.line }, to: { service: to, file: r.file, line: r.line }, via: { table: w.table }, evidence: declared ? 'observed' : 'inferred', basis: declared ? 'both services use the table and the project declares them as sharing a database' : 'both services name the table, but nothing declares that they share a database' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
edges.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
139
|
+
unknown.sort((a, b) => (a.service + a.file + a.line < b.service + b.file + b.line ? -1 : 1));
|
|
140
|
+
return { edges, unknown, services: services.map((s) => s.name), limits: LIMITS, optedIn: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const LIMITS = Object.freeze([
|
|
144
|
+
'only JavaScript, TypeScript and Python sources are read',
|
|
145
|
+
'a topic, URL host or table assembled at run time is recorded as unknown and never connected',
|
|
146
|
+
'serializer contracts (protobuf, JSON schema) are not read, so a shared message shape is not an edge',
|
|
147
|
+
'an edge says two services are related in the code; it does not say attacker data crosses it. That needs a finding on each side',
|
|
148
|
+
'no chain is ever labelled proven: this module reads text and does not run the services',
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bounded breadth-first trace from a service. Each path carries the weakest evidence on it. A path through an inferred edge is
|
|
153
|
+
* `static-inferred`; the label `static-observed` needs every hop observed; nothing is `proven`.
|
|
154
|
+
*/
|
|
155
|
+
export function traceFrom(graph, start, { maxHops = MAX_HOPS, maxPaths = 200 } = {}) {
|
|
156
|
+
const hops = Math.max(1, Math.min(MAX_HOPS, maxHops));
|
|
157
|
+
const byFrom = new Map();
|
|
158
|
+
for (const e of graph.edges) { if (!byFrom.has(e.from.service)) byFrom.set(e.from.service, []); byFrom.get(e.from.service).push(e); }
|
|
159
|
+
const out = []; let truncated = false;
|
|
160
|
+
const queue = [{ at: start, path: [], seen: new Set([start]) }];
|
|
161
|
+
while (queue.length) {
|
|
162
|
+
const cur = queue.shift();
|
|
163
|
+
if (cur.path.length >= hops) { if ((byFrom.get(cur.at) || []).length) truncated = true; continue; }
|
|
164
|
+
for (const e of byFrom.get(cur.at) || []) {
|
|
165
|
+
if (cur.seen.has(e.to.service)) continue;
|
|
166
|
+
const path = [...cur.path, e];
|
|
167
|
+
if (out.length >= maxPaths) { truncated = true; break; }
|
|
168
|
+
const weakest = path.reduce((w, x) => (RANK[x.evidence] > RANK[w] ? x.evidence : w), 'observed');
|
|
169
|
+
out.push({ services: [start, ...path.map((x) => x.to.service)], edges: path.map((x) => x.id), kinds: path.map((x) => x.kind), evidence: weakest, label: weakest === 'observed' ? 'static-observed' : weakest === 'inferred' ? 'static-inferred' : 'unproven' });
|
|
170
|
+
queue.push({ at: e.to.service, path, seen: new Set([...cur.seen, e.to.service]) });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { start, paths: out, truncated, maxHops: hops };
|
|
174
|
+
}
|