@clear-capabilities/agentic-security-scanner 0.140.0 → 0.142.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/dist/113.index.js +79 -3
  3. package/dist/178.index.js +1 -1
  4. package/dist/238.index.js +77 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/435.index.js +12 -0
  7. package/dist/526.index.js +79 -3
  8. package/dist/637.index.js +1 -1
  9. package/dist/agentic-security.mjs +14 -14
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/compliance-frameworks/ccpa.json +34 -7
  12. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  13. package/dist/compliance-frameworks/gdpr.json +56 -12
  14. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  15. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  16. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  17. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  18. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  19. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  20. package/package.json +19 -5
  21. package/src/dataflow/CLAUDE.md +9 -0
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/dataflow/engine.js +95 -0
  24. package/src/dataflow/sanitizer-gate.js +61 -0
  25. package/src/engine.js +353 -31
  26. package/src/mcp/tools.js +12 -0
  27. package/src/posture/accuracy-scorecard.js +57 -0
  28. package/src/posture/aibom.js +110 -1
  29. package/src/posture/auditor-walkthrough.js +56 -17
  30. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  31. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  32. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  33. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  34. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  35. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  36. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  37. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  38. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  39. package/src/posture/concurrency-checker.js +3 -3
  40. package/src/posture/coverage-strength.js +182 -0
  41. package/src/posture/epss.js +17 -1
  42. package/src/posture/family-registry.js +103 -0
  43. package/src/posture/family-resolve.js +47 -0
  44. package/src/posture/fix-coverage.js +113 -0
  45. package/src/posture/fix-metrics.js +76 -0
  46. package/src/posture/mcp-rug-pull.js +144 -0
  47. package/src/posture/poc-inprocess.js +217 -1
  48. package/src/posture/proof-coverage.js +162 -0
  49. package/src/posture/reachability-filter.js +44 -0
  50. package/src/posture/sbom.js +12 -3
  51. package/src/runScan.js +56 -5
  52. package/src/sast/CLAUDE.md +2 -2
  53. package/src/sast/claude-md-prompt-injection.js +47 -3
  54. package/src/sast/cloud-iam.js +23 -0
  55. package/src/sast/convention-deviation.js +66 -3
  56. package/src/sast/crypto-protocol.js +23 -0
  57. package/src/sast/dapp-frontend.js +20 -0
  58. package/src/sast/iac-cloud-templates.js +346 -0
  59. package/src/sast/k8s-admission.js +27 -0
  60. package/src/sast/ml-supply-chain.js +22 -0
  61. package/src/sast/ruby.js +132 -0
  62. package/src/sast/web3-advanced.js +26 -0
  63. package/src/sca/CLAUDE.md +21 -4
  64. package/src/sca/container.js +18 -1
  65. package/src/sca/dep-confusion.js +69 -3
@@ -163,6 +163,41 @@ export const CATALOG = [
163
163
  // `dict.get(...)`/`config.get(...)` elsewhere in the file does not also
164
164
  // fire.
165
165
  { kind: 'source', id: 'py-flask-args-get', language: 'py', framework: 'flask', match: { type: 'call', callee: 'get', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values/headers/cookies/json/data.get() (Flask/Django)', provenance: 'url-param' },
166
+ // PRD F2.2 — the MULTI-VALUE accessors, which were missing entirely.
167
+ //
168
+ // `getlist` is the standard Flask/Werkzeug and Django QueryDict API for a
169
+ // repeated query parameter (`?host=a&host=b`), and `getall` is its
170
+ // multidict equivalent. Only `get` was modelled, so every repeated-parameter
171
+ // flow was invisible to the taint engine.
172
+ //
173
+ // This is worth recording because the PRD attributed the miss to Python
174
+ // COMPREHENSIONS (`[x for x in request.args.getlist(...)]`) and proposed
175
+ // modelling them. Comprehensions already flow — verified with the same shape
176
+ // over `request.args.get()`, which the engine tracks end to end. The example
177
+ // failed on its SOURCE, not on its loop, and modelling comprehensions would
178
+ // have changed nothing while looking like a fix.
179
+ { kind: 'source', id: 'py-flask-args-getlist', language: 'py', framework: 'flask', match: { type: 'call', callee: 'getlist', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values.getlist() (Flask/Django)', provenance: 'url-param' },
180
+ { kind: 'source', id: 'py-flask-args-getall', language: 'py', framework: 'flask', match: { type: 'call', callee: 'getall', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values.getall() (multidict)', provenance: 'url-param' },
181
+
182
+ // PRD F2.3 — NETWORK-RESPONSE sources.
183
+ //
184
+ // Only two existed, both C++ (`recv`, `recvfrom`), so a response body from an
185
+ // external service was trusted input everywhere else. It is not: the upstream
186
+ // may be compromised, attacker-influenced (the far end of an SSRF), or simply
187
+ // a third party whose output this code renders, executes or shells out with.
188
+ // This is the same trust boundary as an HTTP request arriving — the direction
189
+ // is reversed, not the trust.
190
+ //
191
+ // Kept under its OWN provenance ('network') rather than folded into
192
+ // http-body, so a report can say where the value came from and a team that
193
+ // genuinely trusts its own internal API can filter on it. Collapsing them
194
+ // would remove exactly the fact needed to triage these.
195
+ { kind: 'source', id: 'js-fetch-json', language: 'js', framework: 'fetch', match: { type: 'call', callee: 'json', receiverBase: '^(?:res|resp|response|r)$' }, label: 'HTTP response .json() [fetch]', provenance: 'network' },
196
+ { kind: 'source', id: 'js-fetch-text', language: 'js', framework: 'fetch', match: { type: 'call', callee: 'text', receiverBase: '^(?:res|resp|response|r)$' }, label: 'HTTP response .text() [fetch]', provenance: 'network' },
197
+ { kind: 'source', id: 'js-axios-data', language: 'js', framework: 'axios', match: { type: 'member', object: 'response', prop: 'data' }, label: 'axios response.data', provenance: 'network' },
198
+ { kind: 'source', id: 'py-requests-text', language: 'py', framework: 'requests', match: { type: 'member', object: 'resp', prop: 'text' }, label: 'requests response .text', provenance: 'network' },
199
+ { kind: 'source', id: 'py-requests-json', language: 'py', framework: 'requests', match: { type: 'call', callee: 'json', receiverBase: '^(?:resp|response|r)$' }, label: 'requests response .json()', provenance: 'network' },
200
+ { kind: 'source', id: 'py-urlopen-read', language: 'py', framework: 'urllib', match: { type: 'call', callee: 'read', receiverBase: '^(?:resp|response|r|f)$' }, label: 'urlopen read() [urllib]', provenance: 'network' },
166
201
  { kind: 'source', id: 'py-fastapi-request-query',language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Query' }, label: 'fastapi.Query()' },
167
202
  { kind: 'source', id: 'py-fastapi-request-body', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Body' }, label: 'fastapi.Body()' },
168
203
  { kind: 'source', id: 'py-fastapi-form', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Form' }, label: 'fastapi.Form()' },
@@ -228,6 +263,32 @@ export const CATALOG = [
228
263
  { kind: 'source', id: 'py-mcp-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'mcp.tool' }, label: '@mcp.tool() parameter', provenance: 'agent-tool' },
229
264
  { kind: 'source', id: 'py-mcp-server-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'server.tool' }, label: '@server.tool() parameter', provenance: 'agent-tool' },
230
265
 
266
+ // PRD F5.3 — the SAME trust boundary in JavaScript/TypeScript.
267
+ //
268
+ // The agent-tool boundary was modelled for Python only, while the
269
+ // TypeScript SDK (@modelcontextprotocol/sdk) is the dominant implementation.
270
+ // A tool argument is attacker-influenced in exactly the way an HTTP body is:
271
+ // whatever the model was persuaded to pass, by a web page it read, a file it
272
+ // opened, or another tool's output. Treating it as trusted because "the model
273
+ // sent it" is the confused-deputy assumption this whole feature exists to
274
+ // reject.
275
+ //
276
+ // `request.params.arguments` is the CallToolRequest shape every SDK server
277
+ // handler receives; `extra.arguments` covers the newer callback signature.
278
+ { kind: 'source', id: 'js-mcp-call-args', language: 'js', framework: 'mcp', match: { type: 'member', object: 'params', prop: 'arguments' }, label: 'MCP tool call arguments', provenance: 'agent-tool' },
279
+ { kind: 'source', id: 'js-mcp-request-params', language: 'js', framework: 'mcp', match: { type: 'member', object: 'request', prop: 'params' }, label: 'MCP request.params', provenance: 'agent-tool' },
280
+ { kind: 'source', id: 'js-mcp-extra-args', language: 'js', framework: 'mcp', match: { type: 'member', object: 'extra', prop: 'arguments' }, label: 'MCP tool callback arguments', provenance: 'agent-tool' },
281
+
282
+ // TOOL OUTPUT is the other half of F5.3's shape (tool output -> model context
283
+ // -> tool invocation). Content returned by ANOTHER tool or an MCP resource is
284
+ // not the agent's own reasoning — it is third-party text that reached the
285
+ // context window. A server that reads a resource and passes it onward is the
286
+ // indirect-injection path, and it was invisible while only tool INPUT was a
287
+ // source.
288
+ { kind: 'source', id: 'js-mcp-tool-result', language: 'js', framework: 'mcp', match: { type: 'member', object: 'result', prop: 'content' }, label: 'MCP tool result content', provenance: 'agent-tool' },
289
+ { kind: 'source', id: 'js-mcp-resource-contents', language: 'js', framework: 'mcp', match: { type: 'member', object: 'resource', prop: 'contents' }, label: 'MCP resource contents', provenance: 'agent-tool' },
290
+ { kind: 'source', id: 'py-mcp-tool-result', language: 'py', framework: 'mcp', match: { type: 'member', object: 'result', prop: 'content' }, label: 'MCP tool result content', provenance: 'agent-tool' },
291
+
231
292
  // ─── SOURCES (Go) ─────────────────────────────────────────────────────────
232
293
  { kind: 'source', id: 'go-r-form', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'Form' }, label: 'r.Form' },
233
294
  { kind: 'source', id: 'go-r-postform', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'PostForm' }, label: 'r.PostForm' },
@@ -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 };