@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.
- package/CHANGELOG.md +230 -0
- package/bin/agentic-security.js +53 -7
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +5 -2
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/engine.js +95 -0
- package/src/dataflow/sanitizer-gate.js +61 -0
- package/src/engine.js +91 -9
- package/src/report/index.js +32 -16
- package/src/report/oscal.js +630 -0
- package/src/sast/iac-cloud-templates.js +10 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
},
|
package/src/dataflow/CLAUDE.md
CHANGED
|
@@ -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
|
|
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 };
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
6653
|
-
|
|
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 (
|
|
9073
|
-
|
|
9074
|
-
|
|
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
|
package/src/report/index.js
CHANGED
|
@@ -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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
{
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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',
|