@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.
- package/CHANGELOG.md +283 -0
- package/dist/113.index.js +79 -3
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +77 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +12 -0
- package/dist/526.index.js +79 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +34 -7
- package/dist/compliance-frameworks/eu-ai-act.json +65 -14
- package/dist/compliance-frameworks/gdpr.json +56 -12
- package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/dist/compliance-frameworks/nist-csf-2.json +78 -16
- package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/package.json +19 -5
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog.js +61 -0
- package/src/dataflow/engine.js +95 -0
- package/src/dataflow/sanitizer-gate.js +61 -0
- package/src/engine.js +353 -31
- package/src/mcp/tools.js +12 -0
- package/src/posture/accuracy-scorecard.js +57 -0
- package/src/posture/aibom.js +110 -1
- package/src/posture/auditor-walkthrough.js +56 -17
- package/src/posture/compliance-frameworks/ccpa.json +34 -7
- package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
- package/src/posture/compliance-frameworks/gdpr.json +56 -12
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/src/posture/concurrency-checker.js +3 -3
- package/src/posture/coverage-strength.js +182 -0
- package/src/posture/epss.js +17 -1
- package/src/posture/family-registry.js +103 -0
- package/src/posture/family-resolve.js +47 -0
- package/src/posture/fix-coverage.js +113 -0
- package/src/posture/fix-metrics.js +76 -0
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-inprocess.js +217 -1
- package/src/posture/proof-coverage.js +162 -0
- package/src/posture/reachability-filter.js +44 -0
- package/src/posture/sbom.js +12 -3
- package/src/runScan.js +56 -5
- package/src/sast/CLAUDE.md +2 -2
- package/src/sast/claude-md-prompt-injection.js +47 -3
- package/src/sast/cloud-iam.js +23 -0
- package/src/sast/convention-deviation.js +66 -3
- package/src/sast/crypto-protocol.js +23 -0
- package/src/sast/dapp-frontend.js +20 -0
- package/src/sast/iac-cloud-templates.js +346 -0
- package/src/sast/k8s-admission.js +27 -0
- package/src/sast/ml-supply-chain.js +22 -0
- package/src/sast/ruby.js +132 -0
- package/src/sast/web3-advanced.js +26 -0
- package/src/sca/CLAUDE.md +21 -4
- package/src/sca/container.js +18 -1
- package/src/sca/dep-confusion.js +69 -3
package/src/dataflow/catalog.js
CHANGED
|
@@ -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' },
|
package/src/dataflow/engine.js
CHANGED
|
@@ -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 };
|