@clear-capabilities/agentic-security-scanner 0.141.0 → 0.143.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.
@@ -1 +1 @@
1
- deeb49d41f22000f35683bc1e9dc2c2368c4b79af6272740c5e96cece4732475 agentic-security.mjs
1
+ 2a984aea7cf61f1508f690678b1ff656829081cb34b3e8483cc8ae04ae3c934c agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.141.0",
3
+ "version": "0.143.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -67,7 +67,7 @@
67
67
  "test:posture": "node --test test/privacy-framework.test.js test/material-change.test.js test/drift.test.js test/scorecard.test.js test/accuracy-scorecard.test.js test/scorecard-gate.test.js test/predeploy-gate.test.js test/release-check.test.js test/pre-push-gate.test.js test/dependency-currency.test.js test/package-contents-check.test.js test/gate-verdict-cache.test.js test/independent-population.test.js test/why-missed.test.js test/independent-scoring.test.js test/evidence-bundle.test.js test/discovery-memory.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/epss-enrich.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/falsification.test.js test/verification-separation.test.js test/logic-claims.test.js test/attestation.test.js test/verify-attestation-cli.test.js test/comparison.test.js test/determinism-cross-machine.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/relevance.test.js test/untrusted.test.js test/agent-hardening.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-verify-tests.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/ignore-pragma.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js test/execution-proof.test.js test/fix-metrics.test.js test/corpus-enroll.test.js test/prove-findings.test.js test/corpus-match.test.js test/corpus-provenance.test.js test/learning-quorum.test.js test/llm-cache-integrity.test.js test/proof-artifact.test.js test/providers.test.js test/fleet.test.js test/autopilot.test.js test/autopilot-cli.test.js test/verifier-independence.test.js test/integrity-legacy-key.test.js test/suppression-visibility.test.js test/cost-ceiling.test.js test/local-endpoint.test.js test/model-trust.test.js test/vuln-archaeology.test.js test/scan-checkpoint.test.js test/llm-redact.test.js test/world-class-batch2.test.js test/world-class-modules.test.js test/integration-end-to-end.test.js test/streak-cli-wiring.test.js test/stack-playbook.test.js test/machine-output-flag.test.js test/stdout-flush.test.js test/format-determinism.test.js test/only-flag-channels.test.js test/confidence.test.js test/security-trend.test.js test/calibration-drift.test.js test/validator-metrics.test.js test/clustering-ir-taint-line.test.js test/concurrency-cwe.test.js test/compliance-mapping-liveness.test.js test/family-registry.test.js test/coverage-strength.test.js test/framework-provenance.test.js test/third-party-verification.test.js test/proof-coverage.test.js test/calibration-holdout.test.js test/catalog-freshness.test.js test/incremental-parity.test.js test/surface-smoke.test.js test/fix-coverage.test.js test/raw-source-carveout.test.js test/mlbom-conformance.test.js test/deploy-gate-replay.test.js test/sbom-conformance.test.js test/unscored-claims.test.js test/mcp-rug-pull.test.js test/agent-boundary-taint.test.js test/confinement-adversarial.test.js test/comment-strip-cost.test.js test/reachability-claim.test.js test/entrypoint-breadth.test.js test/dep-file-admission.test.js",
68
68
  "test:dataflow": "node --test test/container-taint.test.js test/stored-taint-families.test.js test/py-annotation-sources.test.js test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-cs-annotations.test.js test/parser-cs-control-flow.test.js test/parser-kt-control-flow.test.js test/parser-js-annotations.test.js test/parser-java-annotations.test.js test/parser-java-control-flow.test.js test/parser-java-calls.test.js test/parser-go.test.js test/parser-php-rb.test.js test/parser-php-control-flow.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/layer-recall.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/sanitizer-typed-flow.test.js test/sanitizer-gate-unit.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js test/ir-stats.test.js test/parser-cpp.test.js test/parser-js-decorators.test.js test/parser-js-if-else-cfg.test.js test/cpp-integration.test.js test/engine-reconnect.test.js test/phase2-scoping.test.js test/engine-recall.test.js test/catalog-annotation-source.test.js test/catalog-cs-p1.test.js test/catalog-expanded.test.js test/builtin-summaries.test.js test/interproc-mutation-crash.test.js test/mutated-param-taint.test.js test/return-sink-check.test.js test/higher-order-inline-callback.test.js test/interproc-findings-discard.test.js test/runscan-deep-option.test.js test/summary-cache-convergence.test.js test/parser-rb-calls.test.js test/parser-rb-module-level.test.js test/balanced-call.test.js test/destructuring-taint.test.js test/computed-member-taint.test.js test/string-interpolation-taint.test.js test/dataflow-deadcode-severity.test.js test/catalog-dotted-callee-lookup.test.js test/builtin-summary-family-blind-kill.test.js test/deep-mode-annotator-pipeline.test.js test/receiver-type-and-nested-calls.test.js test/member-write-and-loop-taint.test.js test/parser-py-module-level.test.js test/parser-php-module-level.test.js test/r14b-module-level-e2e.test.js test/annotation-taint-engine.test.js test/interproc-nested-call-taint.test.js test/catalog-xpath-injection.test.js test/catalog-ldap-injection.test.js test/catalog-xxe.test.js test/catalog-response-splitting.test.js test/catalog-code-injection.test.js test/parser-rb-control-flow.test.js test/catalog-ruby-p1.test.js test/callee-receiver-taint.test.js test/catalog-command-injection-p4.test.js test/catalog-xss-p4.test.js test/catalog-path-ssrf-p3.test.js",
69
69
  "test:mcp": "node --test test/mcp.test.js test/mcp-protocol-smoke.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js test/lsp-server.test.js",
70
- "test:report": "node --test test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
70
+ "test:report": "node --test test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js test/oscal-conformance.test.js",
71
71
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js test/proof-corpus-lib.test.js test/proof-corpus-runner.test.js",
72
72
  "test:lifecycle": "node --test test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
73
73
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
@@ -107,6 +107,7 @@
107
107
  "bench:prompt-injection:fetch": "node ../bench/prompt-injection/fetch.mjs",
108
108
  "bench:secrets-precision": "node ../bench/secrets-precision/runner.mjs",
109
109
  "bench:sca-replay": "node ../bench/sca-replay/runner.mjs",
110
+ "bench:sca-replay:reachability": "node ../bench/sca-replay/reachability.mjs",
110
111
  "bench:sca-replay:fetch": "node ../bench/sca-replay/fetch.mjs",
111
112
  "bench:sca-replay:label": "node ../bench/sca-replay/label.mjs",
112
113
  "bench:layer-recall": "node ../bench/layer-recall/runner.mjs",
@@ -134,6 +135,8 @@
134
135
  "bench:independent:materialise": "node ../bench/independent/materialise-cli.mjs",
135
136
  "bench:independent:why-missed": "node ../bench/independent/why-missed.mjs",
136
137
  "bench:independent:materialise-new": "node ../bench/independent/materialise-new.mjs",
138
+ "bench:independent:merge": "node ../bench/independent/merge-chunks.mjs",
139
+ "bench:independent:agent-delta": "node ../bench/independent/agent-boundary-delta.mjs",
137
140
  "bench:independent:deep": "node ../bench/independent/runner.mjs --deep",
138
141
  "test:ci-parity": "CI=true GITHUB_ACTIONS=true node --test test/backward-default.test.js test/ci-parity.test.js test/container-taint.test.js test/deep-taint.test.js test/engine-recall.test.js test/engine-reconnect.test.js test/gate-verdict-cache.test.js test/ignore-pragma.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/py-annotation-sources.test.js test/runscan-deep-option.test.js test/sanitizer-typed-flow.test.js"
139
142
  },
@@ -42,6 +42,15 @@ Layer-2 taint engine. Walks the Layer-1 IR (`../ir/`) with field-sensitive forwa
42
42
  - **Call-string (k>1) context-sensitivity.** Context is the *value* abstraction — which params are tainted at entry — not the call stack. Two call paths that reach a helper with the same tainted-arg shape share a summary. Entry-state granularity is also param-level, not arbitrary access paths (`f(obj)` with `obj.a` tainted ≡ `obj.b` tainted).
43
43
  - **Contexts beyond the per-function cap.** Once a function has been computed under `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS` distinct tainted-arg shapes, further shapes fall back to the empty-entry summary (an under-approximation, bounded on purpose).
44
44
  - **Implicit flow.** `implicit-flow.js` exists for `if (tainted) { x = "yes" }` propagation but is conservative-by-default.
45
+ - **Sanitizer effect through a summarised return.** `_sanitizersByVar` records the sanitizer callees seen *in this function*, so a sanitizer hoisted into a helper —
46
+
47
+ ```js
48
+ function clean(v) { return escapeHtml(v); }
49
+ const name = clean(req.query.name);
50
+ ```
51
+
52
+ — is not attributed to the value at the sink, and the flow reads as **unsanitized**. Taint itself propagates through the return correctly (`SummaryCache`); only the sanitizer LABEL does not. Measured while writing the `bench/mutation` cases for PRD F12.5, and deliberately **not** added as a mutation case: it would make that gate permanently red, and a gate nobody can pass gets deleted. The error direction is precision (a clean flow reported at full confidence), never a missed vulnerability, which is why it is documented rather than treated as a release blocker.
53
+ - **Sanitizer REVERSAL is modelled, but only for a named list.** `_UNSANITIZER_CALLEES` in `engine.js` carries the well-known inverses (`he.decode`, `unescape`, `html_entity_decode`, `decodeURIComponent`, …) and `sanitizer-gate.js` maps them to the family they reverse — percent-decoding does not undo HTML escaping, so the mapping is family-keyed rather than a flat list. A project-local decoder (`function unwrap(s) { … }`) is not recognised. Before this existed, `he.decode(escapeHtml(x))` reaching an HTML sink was reported as SANITIZED: a missed XSS, found by `bench/mutation`.
45
54
 
46
55
  ## Precision: centralized SSRF/path guard recognition
47
56
 
@@ -572,6 +572,62 @@ function _matchCallCatalog(calleeExpr, argExprs, state, callContext) {
572
572
  // labels the finding, and the proof gate demotes it. Recall-preserving, same
573
573
  // precedent as falsification.js / proof-gate.js: never removed, never
574
574
  // severity-touched.
575
+ // Calls that UNDO an encoding. The catalog has no entry for these — it models
576
+ // sanitizers, and a decoder is the opposite — so they would never be recorded
577
+ // on the path and `sanitizer-gate.js` could not see them.
578
+ //
579
+ // Measured by bench/mutation: `he.decode(escapeHtml(req.query.name))` reaching
580
+ // an HTML sink was labelled SANITIZED. The decode puts back exactly what the
581
+ // escape removed, so that was a missed XSS reported as clean.
582
+ //
583
+ // Deliberately a NAME list rather than a catalog kind: these are not analysis
584
+ // entries with families and effects, they are a short list of well-known
585
+ // inverses, and the gate maps them to the family they reverse.
586
+ const _UNSANITIZER_CALLEES = new Set([
587
+ 'unescape', 'unescapeHtml', 'unescapeHtml3', 'unescapeHtml4',
588
+ 'StringEscapeUtils.unescapeHtml3', 'StringEscapeUtils.unescapeHtml4',
589
+ 'he.decode', 'entities.decode', 'html.decode', 'decodeHTML',
590
+ 'decodeHTMLStrict', 'decodeEntities', 'html.unescape',
591
+ 'html_entity_decode', 'htmlspecialchars_decode', '_.unescape', 'lodash.unescape',
592
+ 'decodeURI', 'decodeURIComponent', 'unquote', 'unquote_plus',
593
+ 'URLDecoder.decode', 'urldecode', 'querystring.unescape',
594
+ ]);
595
+
596
+ // `callee` is an IR NODE, not a string: `{kind:'ident',name:'escapeHtml'}` or
597
+ // `{kind:'member',object:{kind:'ident',name:'he'},prop:'decode'}`. Flattened to
598
+ // the dotted form the name list is written in, plus the bare leaf so a call
599
+ // through an alias (`const {decode} = he`) still matches on an unambiguous name.
600
+ function _calleeNames(callee) {
601
+ if (!callee || typeof callee !== 'object') return [];
602
+ if (callee.kind === 'ident' && callee.name) return [callee.name];
603
+ if (callee.kind === 'member' && callee.prop) {
604
+ const obj = callee.object;
605
+ const base = obj && obj.kind === 'ident' && obj.name ? obj.name : null;
606
+ return base ? [`${base}.${callee.prop}`, callee.prop] : [callee.prop];
607
+ }
608
+ return [];
609
+ }
610
+
611
+ function _unsanitizersInExprTree(expr, out) {
612
+ if (!expr || typeof expr !== 'object') return;
613
+ if (expr.kind === 'call') {
614
+ for (const n of _calleeNames(expr.callee)) {
615
+ // The bare leaf is accepted only when it is unambiguous on its own.
616
+ // `decode` is not — jwt.decode, base64 decode, protobuf decode — and
617
+ // matching it would void correct sanitization claims all over the tree.
618
+ if (n === 'decode') continue;
619
+ if (_UNSANITIZER_CALLEES.has(n)) { out.add(n); break; }
620
+ }
621
+ }
622
+ for (const k of ['left', 'right', 'callee', 'object', 'property', 'value']) {
623
+ if (expr[k] && typeof expr[k] === 'object') _unsanitizersInExprTree(expr[k], out);
624
+ }
625
+ for (const k of ['args', 'parts', 'branches', 'elements']) {
626
+ if (Array.isArray(expr[k])) for (const e of expr[k]) _unsanitizersInExprTree(e, out);
627
+ }
628
+ if (Array.isArray(expr.props)) for (const p of expr.props) _unsanitizersInExprTree(p && p.value, out);
629
+ }
630
+
575
631
  function _sanitizersInExprTree(expr, out) {
576
632
  if (!expr || typeof expr !== 'object') return;
577
633
  if (expr.kind === 'call') {
@@ -592,6 +648,26 @@ function _sanitizersInExprTree(expr, out) {
592
648
  }
593
649
 
594
650
  // Sanitizers applied to `expr`: those called inline within it, plus those
651
+ // Un-sanitizers applied to `expr`, by the same rule: inline in the expression,
652
+ // or inherited from a variable it reads. The inheritance half is what makes it
653
+ // work at all — `const escaped = escapeHtml(x); const name = he.decode(escaped);
654
+ // sink(name)` puts the decode on an ASSIGNMENT, so an expression-tree walk of
655
+ // the sink argument sees only `name` and would never find it.
656
+ function _unsanitizersForExpr(expr, callContext) {
657
+ const out = new Set();
658
+ _unsanitizersInExprTree(expr, out);
659
+ const byVar = callContext && callContext._unsanitizersByVar;
660
+ if (byVar && byVar.size) {
661
+ const vars = new Set();
662
+ _collectExprVars(expr, vars);
663
+ for (const v of vars) {
664
+ const s = byVar.get(v);
665
+ if (s) for (const n of s) out.add(n);
666
+ }
667
+ }
668
+ return out;
669
+ }
670
+
595
671
  // recorded against any variable it reads (`const safe = escapeHtml(x); sink(safe)`).
596
672
  function _sanitizersForExpr(expr, callContext) {
597
673
  const out = new Set();
@@ -695,8 +771,10 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
695
771
  // covers the finding's threat class — an xss escaper on a SQL sink
696
772
  // must not read as sanitised.
697
773
  const _sanNames = _sanitizersForExpr(taintedArgExpr, callContext);
774
+ const _unsanNames = _unsanitizersForExpr(taintedArgExpr, callContext);
698
775
  findings.push({
699
776
  ...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
777
+ ...(_unsanNames.size ? { _unsanitizersOnPath: [..._unsanNames] } : {}),
700
778
  kind: 'taint',
701
779
  sinkId: e.id,
702
780
  vuln: e.vuln?.name || 'Tainted Sink',
@@ -759,8 +837,10 @@ function _memberWriteSinkFindings(hits, sourceExpr, state, callContext, line, ta
759
837
  const reachingSources = _sourcesReachingExpr(sourceExpr, state, callContext._taintSources);
760
838
  const traceForThisFinding = reachingSources.length ? reachingSources.slice(0, 5) : [];
761
839
  const _sanNames = _sanitizersForExpr(sourceExpr, callContext);
840
+ const _unsanNames = _unsanitizersForExpr(sourceExpr, callContext);
762
841
  findings.push({
763
842
  ...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
843
+ ...(_unsanNames.size ? { _unsanitizersOnPath: [..._unsanNames] } : {}),
764
844
  kind: 'taint',
765
845
  sinkId: e.id,
766
846
  vuln: e.vuln?.name || 'Tainted Sink',
@@ -859,6 +939,13 @@ function step(node, stateIn, callContext) {
859
939
  const _byVar = (callContext._sanitizersByVar ||= new Map());
860
940
  if (_san.size) _byVar.set(target, _san);
861
941
  else _byVar.delete(target);
942
+ // The same bookkeeping for reversals, and for the same reason: a clean
943
+ // re-assignment must clear it, or a decode recorded once would void
944
+ // every later sanitization claim on that name.
945
+ const _unsan = _unsanitizersForExpr(node.source, callContext);
946
+ const _unByVar = (callContext._unsanitizersByVar ||= new Map());
947
+ if (_unsan.size) _unByVar.set(target, _unsan);
948
+ else _unByVar.delete(target);
862
949
  }
863
950
  // Constant propagation: track variables assigned from literals
864
951
  if (target && _activeConstantVars) {
@@ -1710,6 +1797,14 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
1710
1797
  // dropped — which is what previously left sanitizer-gate.js inert.
1711
1798
  ...(Array.isArray(f._sanitizersOnPath) && f._sanitizersOnPath.length
1712
1799
  ? { _sanitizersOnPath: f._sanitizersOnPath } : {}),
1800
+ // The reversal half, and it hit the very trap this comment warns about:
1801
+ // the walk collected `he.decode` correctly, the gate was wired to
1802
+ // consume it, and the field was dropped HERE — so the fix looked inert
1803
+ // through three rounds of debugging. An explicit allowlist is the right
1804
+ // design and this is its standing cost: every new field must be added
1805
+ // in two places, and the omission is silent.
1806
+ ...(Array.isArray(f._unsanitizersOnPath) && f._unsanitizersOnPath.length
1807
+ ? { _unsanitizersOnPath: f._unsanitizersOnPath } : {}),
1713
1808
  // _funcQid: the enclosing function's qid, set upstream during the walk
1714
1809
  // but silently dropped by this allowlist before backward.js's
1715
1810
  // annotateBackwardSlices ever saw it — the same class of omission
@@ -86,9 +86,60 @@ export function _sanitizerFamilies() {
86
86
  // ctx.sanitizersOnPath: { [findingId]: string[] } — callee names observed on the
87
87
  // flow that produced the finding. The engine supplies it; when absent the gate
88
88
  // is a no-op, which keeps this safe to call unconditionally.
89
+ // Functions that UNDO an encoding, keyed by the family they reverse.
90
+ //
91
+ // The gate labelled a flow `sanitized` whenever a matching sanitizer appeared
92
+ // on the path, with no notion that a later call could reverse it. Measured by
93
+ // bench/mutation: `he.decode(escapeHtml(req.query.name))` reaching an HTML sink
94
+ // was reported as SANITIZED — a missed XSS, because the decode puts back
95
+ // exactly what the escape took out.
96
+ //
97
+ // Family-keyed, not a flat list, because reversal is family-specific:
98
+ // `decodeURIComponent` undoes percent-encoding and does nothing at all to HTML
99
+ // entities, so treating it as a universal un-sanitizer would throw away correct
100
+ // sanitization claims.
101
+ //
102
+ // A match REFUSES the label rather than removing a finding. That is the safe
103
+ // direction: the cost of being wrong here is a finding kept at full confidence
104
+ // (a possible false positive), while the cost of the previous behaviour was a
105
+ // real vulnerability reported as clean.
106
+ const _UNSANITIZERS = {
107
+ xss: new Set([
108
+ 'unescape', 'unescapeHtml', 'unescapeHtml3', 'unescapeHtml4',
109
+ 'StringEscapeUtils.unescapeHtml3', 'StringEscapeUtils.unescapeHtml4',
110
+ 'he.decode', 'entities.decode', 'html.decode', 'htmlparser2.decodeHTML',
111
+ 'decodeHTML', 'decodeHTMLStrict', 'decodeEntities',
112
+ 'html.unescape', 'html_entity_decode', 'htmlspecialchars_decode',
113
+ '_.unescape', 'lodash.unescape',
114
+ ]),
115
+ url: new Set([
116
+ 'decodeURI', 'decodeURIComponent', 'unquote', 'unquote_plus',
117
+ 'URLDecoder.decode', 'urldecode', 'querystring.unescape',
118
+ ]),
119
+ // sql / cmd / path have no encoding to reverse in the same sense: their
120
+ // sanitizers parameterise or canonicalise rather than encode, and there is no
121
+ // inverse call. Deliberately absent rather than guessed at.
122
+ };
123
+
124
+ /** Does any callee observed on this path undo the sanitization claimed for `fam`? */
125
+ export function _reversedOnPath(observed, fam) {
126
+ const undoers = _UNSANITIZERS[fam];
127
+ if (!undoers) return null;
128
+ for (const name of observed) {
129
+ if (undoers.has(name)) return name;
130
+ // A member call may be recorded bare. Only accept a leaf that is
131
+ // unambiguous on its own — `decode` alone is not (jwt.decode, base64
132
+ // decode), and matching it would void correct claims.
133
+ const leaf = String(name).split('.').pop();
134
+ if (leaf !== name && undoers.has(leaf) && leaf !== 'decode') return name;
135
+ }
136
+ return null;
137
+ }
138
+
89
139
  export function applySanitizerGate(findings, ctx) {
90
140
  const list = Array.isArray(findings) ? findings : [];
91
141
  const onPath = (ctx && ctx.sanitizersOnPath) || null;
142
+ const undoPath = (ctx && ctx.unsanitizersOnPath) || {};
92
143
  if (!onPath) return list;
93
144
  const index = _sanitizerIndex();
94
145
 
@@ -107,6 +158,16 @@ export function applySanitizerGate(findings, ctx) {
107
158
  return fams && (fams.has(fam) || fams.has('*'));
108
159
  });
109
160
  if (!matching.length) continue;
161
+ // A reversal anywhere on the path voids the claim. Order is not consulted:
162
+ // `sanitizersOnPath` is a set of observed callees, and a decode that
163
+ // precedes the escape is not a shape worth modelling separately when the
164
+ // conservative answer — refuse the label — is also the safe one.
165
+ const undone = undoPath[f.id] || undoPath[f.stableId] || [];
166
+ const reversedBy = _reversedOnPath(undone, fam);
167
+ if (reversedBy) {
168
+ f.sanitizerReversedBy = reversedBy;
169
+ continue;
170
+ }
110
171
  // Label only. The proof gate decides what to do with the label.
111
172
  f.sanitized = true;
112
173
  f.sanitizerProof = { sanitizers: matching, family: fam };
package/src/engine.js CHANGED
@@ -6607,6 +6607,11 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
6607
6607
  if (!sites.length) { sc.functionReachable = 'unknown'; sc.routeReachable = false; continue; }
6608
6608
  let functionReachable = false;
6609
6609
  let routeReachable = false;
6610
+ // Did ANY call site yield enough structure to reason about? If not, the
6611
+ // honest answer is `unknown` — see the verdict assignment below.
6612
+ let analysable = false;
6613
+ // At least one call site sits inside an exported function — see below.
6614
+ let publicApiSite = false;
6610
6615
  for (const site of sites) {
6611
6616
  // Classifier 1: site is inline inside a route handler (within 25 lines
6612
6617
  // of the route def, no intervening function declaration). This is the
@@ -6629,8 +6634,16 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
6629
6634
  // If any caller-chain hits a known route-handler function, the site
6630
6635
  // is route-reachable-via-function. If no caller at all, we keep
6631
6636
  // functionReachable=false for this site.
6632
- const enclosing = _enclosingFn(fc[site.file] || '', site.line);
6637
+ const encInfo = _enclosingFnInfo(fc[site.file] || '', site.line);
6638
+ const enclosing = encInfo && encInfo.name;
6639
+ // Not being able to name the enclosing function is INCONCLUSIVE, not
6640
+ // evidence of unreachability. Tracked so the verdict below can say so.
6633
6641
  if (!enclosing) continue;
6642
+ // A public-API function with no in-tree caller is the NORMAL case, not
6643
+ // dead code: its callers are its users. Only a private function can be
6644
+ // shown unreachable by the absence of callers.
6645
+ if (encInfo.exported) { publicApiSite = true; continue; }
6646
+ analysable = true;
6634
6647
  const callers = _reverseCallGraphReachable(callGraph, enclosing, 4);
6635
6648
  if (callers.size > 1) functionReachable = true; // at least one caller exists
6636
6649
  for (const callerFn of callers) {
@@ -6642,18 +6655,76 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
6642
6655
  }
6643
6656
  if (routeReachable) break;
6644
6657
  }
6645
- sc.functionReachable = functionReachable ? 'reachable' : 'unreachable';
6658
+ // ABSENCE OF PROOF IS NOT PROOF OF ABSENCE.
6659
+ //
6660
+ // This read `functionReachable ? 'reachable' : 'unreachable'`, so a site the
6661
+ // analysis could not reason about — no recognisable enclosing function, no
6662
+ // routes in the project at all — was reported as UNREACHABLE and the finding
6663
+ // demoted to `info`. For a LIBRARY that is every site: express has no routes,
6664
+ // so `cookie` and `send`, both required at the top of `lib/response.js` and
6665
+ // called directly, were demoted out of the report.
6666
+ //
6667
+ // Measured by bench/sca-replay's reachability scorer (PRD F3.2): of the three
6668
+ // demotions it could adjudicate against an import-level oracle, ALL THREE were
6669
+ // false — express/cookie, express/send, poetry/requests, each genuinely
6670
+ // imported. A false `unreachable` is a MISSED EXPLOIT, the expensive
6671
+ // direction, so inconclusive now yields `unknown`: the finding keeps its
6672
+ // severity and the analysis stops making a claim it cannot support.
6673
+ // A project with NO ROUTES is a library, and "not reachable from any route"
6674
+ // is not a claim that can be made about one: its callers are its users, who
6675
+ // are not in this tree. express requires `cookie` and `send` at the top of
6676
+ // lib/response.js and calls both directly — there is simply no route to
6677
+ // trace them to, and reporting that as `unreachable` demoted two live
6678
+ // dependencies to `info`. Same shape for poetry/requests.
6679
+ //
6680
+ // This is the library-vs-application distinction the PRD already flags for
6681
+ // the taint engine (F2.3, "for a library the caller IS the attacker"),
6682
+ // showing up here as a false demotion instead of a false negative.
6683
+ const projectHasRoutes = Array.isArray(routes) && routes.length > 0;
6684
+ sc.functionReachable = functionReachable
6685
+ ? 'reachable'
6686
+ : (analysable && projectHasRoutes && !publicApiSite ? 'unreachable' : 'unknown');
6646
6687
  sc.routeReachable = routeReachable;
6647
6688
  }
6648
6689
  }
6649
- function _enclosingFn(content,line){
6690
+ // The declaration form of the function enclosing `line`, scanning backwards.
6691
+ //
6692
+ // Recognises four shapes, not one. The original matched only `function name(`
6693
+ // and `const name = (`, so an ANONYMOUS function assigned to a member —
6694
+ // `res.cookie = function (name, value, options) {`, which is how most of
6695
+ // express, and most of the JS ecosystem, defines a public method — was invisible.
6696
+ // The scan then walked past it and attributed the call site to whatever
6697
+ // unrelated function appeared further up the file.
6698
+ //
6699
+ // `exported` matters as much as the name: a function assigned to a member or to
6700
+ // module.exports is PUBLIC API, so having no caller inside this repository is
6701
+ // the normal case and says nothing about whether it can be reached. Treating it
6702
+ // as evidence of unreachability is what demoted express's live `cookie`
6703
+ // dependency to `info`.
6704
+ function _enclosingFnInfo(content,line){
6650
6705
  const lines=content.split('\n');
6651
6706
  for(let i=line-2;i>=0;i--){
6652
- const m=lines[i].match(/(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\()/);
6653
- if(m)return m[1]||m[2]||null;
6707
+ const l=lines[i];
6708
+ let m=l.match(/(?:^|\s)(?:async\s+)?function\s+(\w+)\s*\(/);
6709
+ if(m)return {name:m[1],exported:/^\s*export\b/.test(l)};
6710
+ m=l.match(/^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(/);
6711
+ if(m)return {name:m[1],exported:/^\s*export\b/.test(l)};
6712
+ // `X.y = function (…)` / `X.y = async (…) => ` / `module.exports.y = …`
6713
+ m=l.match(/^\s*(?:module\.)?(\w+)\.(\w+)\s*=\s*(?:async\s*)?(?:function\b|\()/);
6714
+ if(m)return {name:m[2],exported:true};
6715
+ // `module.exports = function name(…)`
6716
+ m=l.match(/^\s*module\.exports\s*=\s*(?:async\s*)?function\s*(\w*)\s*\(/);
6717
+ if(m)return {name:m[1]||'module.exports',exported:true};
6718
+ // Object-literal method / class method: `cookie: function (` or `cookie(a) {`
6719
+ m=l.match(/^\s*(\w+)\s*:\s*(?:async\s*)?(?:function\b|\()/);
6720
+ if(m)return {name:m[1],exported:false};
6654
6721
  }
6655
6722
  return null;
6656
6723
  }
6724
+ function _enclosingFn(content,line){
6725
+ const info=_enclosingFnInfo(content,line);
6726
+ return info?info.name:null;
6727
+ }
6657
6728
 
6658
6729
  // 0.6.0 Feat-2: Toxic-combinations score — composes multi-signal risk into 0–100.
6659
6730
  // Composes existing per-finding signals into a 0–100 toxicity score with a
@@ -9067,13 +9138,24 @@ function _deterministicFileTimings(timings) {
9067
9138
  // sanitizer would then hide a real vulnerability outright, whereas a label
9068
9139
  // only demotes confidence here. Recall-preserving, on purpose.
9069
9140
  const sanitizersOnPath = {};
9141
+ // The same shape for calls that UNDO an encoding. A sanitizer whose effect
9142
+ // is reversed later on the path is not a sanitizer, and the gate could not
9143
+ // see that before: the catalog only models sanitizers, so a decoder was
9144
+ // never recorded at all and `he.decode(escapeHtml(x))` read as clean.
9145
+ const unsanitizersOnPath = {};
9070
9146
  for (const f of finalFindings) {
9071
9147
  const names = f && f._sanitizersOnPath;
9072
- if (!Array.isArray(names) || !names.length) continue;
9073
- if (f.id) sanitizersOnPath[f.id] = names;
9074
- if (f.stableId) sanitizersOnPath[f.stableId] = names;
9148
+ if (Array.isArray(names) && names.length) {
9149
+ if (f.id) sanitizersOnPath[f.id] = names;
9150
+ if (f.stableId) sanitizersOnPath[f.stableId] = names;
9151
+ }
9152
+ const undo = f && f._unsanitizersOnPath;
9153
+ if (Array.isArray(undo) && undo.length) {
9154
+ if (f.id) unsanitizersOnPath[f.id] = undo;
9155
+ if (f.stableId) unsanitizersOnPath[f.stableId] = undo;
9156
+ }
9075
9157
  }
9076
- _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
9158
+ _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath, unsanitizersOnPath }); });
9077
9159
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
9078
9160
  }
9079
9161
  // Addition #1 — default falsification pass. Actively tries to DISPROVE each
@@ -736,6 +736,26 @@ export function toMarkdown(scan, meta={}){
736
736
  return lines.join('\n');
737
737
  }
738
738
 
739
+ // The load-bearing caveats every machine-readable artifact must carry, in one
740
+ // place. A consumer that ingests our output without seeing these will read an
741
+ // ordinal priority score as a probability, and will read a benchmark-tuned F1
742
+ // as a general-purpose quality claim. Both are wrong, and both are the kind of
743
+ // wrong that only shows up in somebody else's risk-acceptance decision.
744
+ export const TOOL_CAVEATS = Object.freeze([
745
+ {
746
+ id: 'scores-are-ordinal',
747
+ name: 'ScoresAreOrdinal',
748
+ shortDescription: 'priority/exploitability scores are ordinal, not calibrated probabilities',
749
+ fullDescription: 'The properties.exploitability and properties.confidence fields on each result are ORDINAL priority scores used to rank findings within a scan. They are NOT calibrated probabilities; do not render them as percentages or feed them into pricing / risk-acceptance decisions. Use the tier labels (critical/high/medium/low) for coarse bucketing. See bench/README.md for the open calibration work.',
750
+ },
751
+ {
752
+ id: 'owasp-benchmark-tuning',
753
+ name: 'OwaspBenchmarkTuning',
754
+ shortDescription: 'engine ships OWASP-Benchmark-shape precision lifters; F1 numbers do not generalize',
755
+ fullDescription: 'The engine includes precision lifters (sast/primary-cwe-java.js, sast/java-constant-fold.js) whose heuristics are tuned to OWASP Benchmark v1.2 file shape (servlet-style files <=300 LoC, canonical variable names). F1 numbers cited against OWASP Benchmark do NOT generalize to arbitrary Java code. Expect higher FP rates on real-world codebases until per-customer tuning lands. See bench/README.md.',
756
+ },
757
+ ]);
758
+
739
759
  export function toSARIF(scan, meta={}){
740
760
  const findings = normalizeFindings(scan);
741
761
  const ruleMap = new Map();
@@ -758,22 +778,18 @@ export function toSARIF(scan, meta={}){
758
778
  // SARIF run itself so machine consumers see them. Without these, a CI that
759
779
  // ingests SARIF treats "confidence: 0.9" as a probability and the
760
780
  // benchmark-tuned 0.907 number as quality evidence.
761
- const SARIF_NOTIFICATIONS = [
762
- {
763
- id: 'scores-are-ordinal',
764
- name: 'ScoresAreOrdinal',
765
- shortDescription: { text: 'priority/exploitability scores are ordinal, not calibrated probabilities' },
766
- defaultConfiguration: { level: 'note' },
767
- fullDescription: { text: 'The properties.exploitability and properties.confidence fields on each result are ORDINAL priority scores used to rank findings within a scan. They are NOT calibrated probabilities; do not render them as percentages or feed them into pricing / risk-acceptance decisions. Use the tier labels (critical/high/medium/low) for coarse bucketing. See bench/README.md for the open calibration work.' },
768
- },
769
- {
770
- id: 'owasp-benchmark-tuning',
771
- name: 'OwaspBenchmarkTuning',
772
- shortDescription: { text: 'engine ships OWASP-Benchmark-shape precision lifters; F1 numbers do not generalize' },
773
- defaultConfiguration: { level: 'note' },
774
- fullDescription: { text: 'The engine includes precision lifters (sast/primary-cwe-java.js, sast/java-constant-fold.js) whose heuristics are tuned to OWASP Benchmark v1.2 file shape (servlet-style files <=300 LoC, canonical variable names). F1 numbers cited against OWASP Benchmark do NOT generalize to arbitrary Java code. Expect higher FP rates on real-world codebases until per-customer tuning lands. See bench/README.md.' },
775
- },
776
- ];
781
+ //
782
+ // The texts live in TOOL_CAVEATS (module scope) because SARIF is no longer
783
+ // the only machine format that has to carry them: report/oscal.js emits the
784
+ // same list as back-matter resources. Two copies of a caveat is one copy
785
+ // that goes stale, and the stale one is always the one somebody reads.
786
+ const SARIF_NOTIFICATIONS = TOOL_CAVEATS.map(c => ({
787
+ id: c.id,
788
+ name: c.name,
789
+ shortDescription: { text: c.shortDescription },
790
+ defaultConfiguration: { level: 'note' },
791
+ fullDescription: { text: c.fullDescription },
792
+ }));
777
793
  return {
778
794
  $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
779
795
  version: '2.1.0',