@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9
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 +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// → new state with the implicit-tainted vars added at confidence 0.5
|
|
33
33
|
|
|
34
34
|
import { addPath } from './access-paths.js';
|
|
35
|
+
import { computeDominators } from '../ir/ssa.js';
|
|
35
36
|
|
|
36
37
|
export function isImplicitFlowEnabled() {
|
|
37
38
|
// OPT-IN (default OFF): implicit/control-dependence flow is famously noisy, so
|
|
@@ -47,48 +48,79 @@ export function isImplicitFlowEnabled() {
|
|
|
47
48
|
* cfg: the function CFG
|
|
48
49
|
* exprTaint: callback (expr) -> bool, using the current taint state
|
|
49
50
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* Correctness (Stage 6 audit): this used to be a path-dependent DFS that
|
|
52
|
+
* incremented a "depth" counter on entering a tainted `if`'s successors and
|
|
53
|
+
* only ever decremented it at a loop-header — there was no mechanism to
|
|
54
|
+
* detect a branch's natural JOIN point, so once a path entered depth 1,
|
|
55
|
+
* every node reachable afterward (including everything textually AFTER the
|
|
56
|
+
* if/else closes, for the rest of the function) stayed marked
|
|
57
|
+
* `tainted: true` forever. A first fix attempt (reset depth at the join)
|
|
58
|
+
* was reverted because it broke the equally-real, intentional pattern
|
|
59
|
+
* `if (tainted) { p = x; } eval(p)`: `p`'s taint must survive past the
|
|
60
|
+
* branch on the VARIABLE (via markImplicitTaint → the normal forward-taint
|
|
61
|
+
* state, entirely independent of this map), even though `eval(p)` itself is
|
|
62
|
+
* not lexically inside the branch.
|
|
63
|
+
*
|
|
64
|
+
* The actual invariant this map must encode is narrower than "reachable
|
|
65
|
+
* after a tainted if": a node N is genuinely INSIDE the branch rooted at
|
|
66
|
+
* successor B if and only if B DOMINATES N — every path from the function
|
|
67
|
+
* entry to N passes through B. A join point (reachable via the other
|
|
68
|
+
* branch, or via any path that bypasses the if) is by definition NOT
|
|
69
|
+
* dominated by B. This is exactly what `computeDominators` (shared with
|
|
70
|
+
* ir/ssa.js's SSA φ-node placement) computes, so reuse it instead of a
|
|
71
|
+
* path-dependent walk that structurally cannot distinguish "inside" from
|
|
72
|
+
* "after".
|
|
73
|
+
*
|
|
74
|
+
* One more wrinkle the CFG shape forces on us: parser-js.js's IfStatement
|
|
75
|
+
* lowering models a missing `else` as a DIRECT edge from the `if` node to
|
|
76
|
+
* the post-if join node (`if (!path.node.alternate) linkCfg(fn, condId,
|
|
77
|
+
* joinId)`), so `n.succ` for an else-less `if` is `[branchEntry, joinId]`
|
|
78
|
+
* — the join node is literally one of the "successors" too. Naively
|
|
79
|
+
* dominance-checking every successor as if it were a branch root treats
|
|
80
|
+
* `joinId` itself as something to dominate through, and `joinId` trivially
|
|
81
|
+
* dominates everything after it (there's no other way to reach that code),
|
|
82
|
+
* which resurrects the exact over-attribution this fix exists to close.
|
|
83
|
+
* Distinguish a genuine branch entry from a join/skip target structurally:
|
|
84
|
+
* a real branch entry has EXACTLY ONE predecessor — this `if` node itself.
|
|
85
|
+
* A join node always has a second predecessor (the branch's own tail, or —
|
|
86
|
+
* for the else-less case — this same direct skip edge), so it never
|
|
87
|
+
* qualifies.
|
|
53
88
|
*/
|
|
54
89
|
export function buildImplicitContext(cfg, exprTaint) {
|
|
55
90
|
const ctxByNid = new Map();
|
|
56
91
|
if (!cfg || !cfg.nodes) return ctxByNid;
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (n.kind
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
92
|
+
const dom = computeDominators(cfg);
|
|
93
|
+
const predCount = new Map();
|
|
94
|
+
const soleParent = new Map();
|
|
95
|
+
for (const [pid, p] of Object.entries(cfg.nodes)) {
|
|
96
|
+
for (const s of (p?.succ || [])) {
|
|
97
|
+
predCount.set(s, (predCount.get(s) || 0) + 1);
|
|
98
|
+
if (!soleParent.has(s)) soleParent.set(s, pid);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const [nid, n] of Object.entries(cfg.nodes)) {
|
|
102
|
+
if (!n || n.kind !== 'if' || !n.cond || !exprTaint(n.cond)) continue;
|
|
103
|
+
// Config-constant filter: if condition is `ident === literal` where
|
|
104
|
+
// ident is NOT tainted, skip (it's a config check, not a taint branch).
|
|
105
|
+
if (n.cond.kind === 'binary' && (n.cond.op === '===' || n.cond.op === '==' || n.cond.op === 'Eq') &&
|
|
106
|
+
n.cond.right && n.cond.right.kind === 'literal' &&
|
|
107
|
+
n.cond.left && n.cond.left.kind === 'ident' &&
|
|
108
|
+
!exprTaint(n.cond.left)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const label = _formatCondLabel(n.cond);
|
|
112
|
+
for (const branchRoot of (n.succ || [])) {
|
|
113
|
+
if (branchRoot === nid) continue; // guard against a malformed self-loop
|
|
114
|
+
// Not a genuine branch entry — it's a join/skip target reachable
|
|
115
|
+
// some other way too (see header). Marking it (or anything only
|
|
116
|
+
// reachable through it) would leak taint past the branch's real scope.
|
|
117
|
+
if ((predCount.get(branchRoot) || 0) !== 1 || soleParent.get(branchRoot) !== nid) continue;
|
|
118
|
+
for (const [candidate, domSet] of dom) {
|
|
119
|
+
if (candidate === nid) continue;
|
|
120
|
+
if (domSet && domSet.has(branchRoot) && !ctxByNid.has(candidate)) {
|
|
121
|
+
ctxByNid.set(candidate, { tainted: true, conditionLabel: label });
|
|
80
122
|
}
|
|
81
123
|
}
|
|
82
|
-
} else if (n.kind === 'loop-header' && depth > 0) {
|
|
83
|
-
// Loop-body exclusion: don't escalate implicit depth inside loops —
|
|
84
|
-
// loop iteration count is not a taint channel for most vuln classes.
|
|
85
|
-
for (const s of (n.succ || [])) {
|
|
86
|
-
stack.push({ nid: s, depth: Math.max(depth - 1, 0), label });
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
for (const s of (n.succ || [])) {
|
|
90
|
-
stack.push({ nid: s, depth, label });
|
|
91
|
-
}
|
|
92
124
|
}
|
|
93
125
|
}
|
|
94
126
|
return ctxByNid;
|
|
@@ -31,7 +31,8 @@ import * as fs from 'node:fs';
|
|
|
31
31
|
import * as path from 'node:path';
|
|
32
32
|
import * as crypto from 'node:crypto';
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
|
|
35
|
+
|
|
35
36
|
const FILES_PATH = 'files.json';
|
|
36
37
|
const SUMMARIES_PATH = 'summaries.json';
|
|
37
38
|
const VERSION_PATH = 'version.json';
|
|
@@ -44,7 +45,7 @@ export function hashFileContent(stripped) {
|
|
|
44
45
|
|
|
45
46
|
/** Read the persisted state. Returns a fresh empty state on any error. */
|
|
46
47
|
export function readIncrementalState(projectRoot) {
|
|
47
|
-
const dir =
|
|
48
|
+
const dir = statePath(projectRoot, 'incremental');
|
|
48
49
|
try {
|
|
49
50
|
const versionFp = path.join(dir, VERSION_PATH);
|
|
50
51
|
if (!fs.existsSync(versionFp)) return _emptyState();
|
|
@@ -161,9 +162,24 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
|
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
/**
|
|
164
|
-
* Serialize a SummaryCache for persistence.
|
|
165
|
-
*
|
|
166
|
-
*
|
|
165
|
+
* Serialize a SummaryCache for persistence.
|
|
166
|
+
*
|
|
167
|
+
* ACTUAL current filter: excludes `_budgetExceeded` and `_recursive`
|
|
168
|
+
* summaries, and caps at MAX_PERSISTED_SUMMARIES. There is no
|
|
169
|
+
* context-independence filter — nothing in this codebase ever sets a
|
|
170
|
+
* `_persistable` flag (this comment previously claimed one existed and gated
|
|
171
|
+
* on it; grep confirms no such flag is set anywhere). Every non-recursive,
|
|
172
|
+
* non-budget-exceeded summary is persisted regardless of whether it depends
|
|
173
|
+
* on the entry taint-state it was computed under.
|
|
174
|
+
*
|
|
175
|
+
* OPEN QUESTION, not resolved by this comment fix: whether the reseeding
|
|
176
|
+
* side (`pickReusableSummaries`/`seedSummaryCache`) re-validates a persisted
|
|
177
|
+
* summary against the CALLER's actual entry taint-state before reuse, or
|
|
178
|
+
* trusts it as-is. If it trusts it as-is, a summary computed under one
|
|
179
|
+
* tainted-arg shape could be replayed for a call site with a different shape
|
|
180
|
+
* next scan — a real correctness question for the k-CFA context-sensitivity
|
|
181
|
+
* this cache exists to provide, worth a dedicated investigation rather than
|
|
182
|
+
* a guess made while reconciling documentation.
|
|
167
183
|
*
|
|
168
184
|
* Returns a plain object `{ qid: summary }` safe for JSON.stringify.
|
|
169
185
|
*/
|
|
@@ -197,9 +213,10 @@ export function serializeSummaries(summaryCache) {
|
|
|
197
213
|
*/
|
|
198
214
|
export function commitIncrementalState(projectRoot, state, currentVersion) {
|
|
199
215
|
if (!projectRoot) return false;
|
|
200
|
-
const dir =
|
|
216
|
+
const dir = statePath(projectRoot, 'incremental');
|
|
201
217
|
try {
|
|
202
|
-
|
|
218
|
+
if (!stateWritesEnabled()) return;
|
|
219
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
203
220
|
fs.writeFileSync(path.join(dir, VERSION_PATH), JSON.stringify(currentVersion, null, 2));
|
|
204
221
|
fs.writeFileSync(path.join(dir, FILES_PATH), JSON.stringify(state.files || {}, null, 2));
|
|
205
222
|
const payload = {
|
|
@@ -215,7 +232,7 @@ export function commitIncrementalState(projectRoot, state, currentVersion) {
|
|
|
215
232
|
|
|
216
233
|
/** Drop persisted state — used when a version mismatch is detected. */
|
|
217
234
|
export function dropIncrementalState(projectRoot) {
|
|
218
|
-
const dir =
|
|
235
|
+
const dir = statePath(projectRoot, 'incremental');
|
|
219
236
|
try {
|
|
220
237
|
if (!fs.existsSync(dir)) return true;
|
|
221
238
|
for (const fn of [VERSION_PATH, FILES_PATH, SUMMARIES_PATH]) {
|
package/src/dataflow/index.js
CHANGED
|
@@ -137,7 +137,8 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
137
137
|
// stub signatures (loaded by ir/type-stubs.js when AGENTIC_SECURITY_TYPE_STUBS=1).
|
|
138
138
|
// If a finding's source type is provably non-stringy (number, boolean,
|
|
139
139
|
// Date, RegExp) AND the sink class can't be triggered by that type,
|
|
140
|
-
// demote the finding's severity
|
|
140
|
+
// demote the finding's confidence/tier (recall-preserving — severity
|
|
141
|
+
// is never touched).
|
|
141
142
|
if (process.env.AGENTIC_SECURITY_TYPE_STUBS === '1' && opts.scanRoot) {
|
|
142
143
|
try {
|
|
143
144
|
const stubs = loadProjectStubs(opts.scanRoot);
|
|
@@ -110,17 +110,58 @@ function _findFunction(finding, perFileIR) {
|
|
|
110
110
|
return chosen;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Backward BFS via `pred` edges from `startId` — every node that can reach
|
|
114
|
+
// `startId` by following the CFG forward from itself.
|
|
115
|
+
function _backwardReachable(fn, startId) {
|
|
116
|
+
const visited = new Set();
|
|
117
|
+
const stack = [startId];
|
|
118
|
+
while (stack.length) {
|
|
119
|
+
const id = stack.pop();
|
|
120
|
+
if (visited.has(id)) continue;
|
|
121
|
+
visited.add(id);
|
|
122
|
+
const node = fn.cfg.nodes[id];
|
|
123
|
+
if (!node) continue;
|
|
124
|
+
for (const p of node.pred || []) stack.push(p);
|
|
125
|
+
}
|
|
126
|
+
return visited;
|
|
127
|
+
}
|
|
128
|
+
|
|
113
129
|
function _allCallNodesBetween(fn, trace, sinkLine) {
|
|
114
130
|
if (!fn || !fn.cfg || !fn.cfg.nodes) return [];
|
|
115
131
|
const earliestSrcLine = Math.min(
|
|
116
132
|
...trace.map(t => (typeof t.line === 'number' ? t.line : sinkLine))
|
|
117
133
|
);
|
|
134
|
+
// Stage 3 correctness audit (detection depth): this used to be a pure
|
|
135
|
+
// line-number-range filter — ANY call node between the earliest source
|
|
136
|
+
// line and the sink line, anywhere in the function, regardless of
|
|
137
|
+
// whether it could ever actually execute before the sink. A
|
|
138
|
+
// parameterizer call inside an unrelated `if` branch that RETURNS
|
|
139
|
+
// before ever reaching the sink (a completely different, non-converging
|
|
140
|
+
// path) satisfied that filter and could falsely "prove" a genuinely
|
|
141
|
+
// vulnerable SQL concatenation on a different branch was clean — the
|
|
142
|
+
// dangerous direction for a proof gate, since `provenClean` de-emphasizes
|
|
143
|
+
// the finding in reports and risk scoring.
|
|
144
|
+
//
|
|
145
|
+
// Restrict candidates to nodes CFG-reachable as a predecessor of the
|
|
146
|
+
// sink's own node (backward BFS via `pred`): a call that cannot execute
|
|
147
|
+
// on ANY path leading to the sink is excluded. This is still not a full
|
|
148
|
+
// per-path proof (a parameterizer reachable via SOME but not ALL paths
|
|
149
|
+
// to the sink, or one that sanitizes an unrelated variable, still
|
|
150
|
+
// passes) — that's the v2 SMT work the module header already scopes
|
|
151
|
+
// out — but it closes the specific "unrelated branch that never reaches
|
|
152
|
+
// the sink at all" false-proof case.
|
|
153
|
+
const sinkNodeId = Object.keys(fn.cfg.nodes).find(id => {
|
|
154
|
+
const n = fn.cfg.nodes[id];
|
|
155
|
+
return n && n.kind === 'call' && n.line === sinkLine;
|
|
156
|
+
});
|
|
157
|
+
const reachable = sinkNodeId ? _backwardReachable(fn, sinkNodeId) : null;
|
|
118
158
|
const out = [];
|
|
119
159
|
for (const id of Object.keys(fn.cfg.nodes)) {
|
|
120
160
|
const node = fn.cfg.nodes[id];
|
|
121
161
|
if (!node || node.kind !== 'call') continue;
|
|
122
162
|
if (typeof node.line !== 'number') continue;
|
|
123
163
|
if (node.line < earliestSrcLine || node.line > sinkLine) continue;
|
|
164
|
+
if (reachable && !reachable.has(id)) continue;
|
|
124
165
|
out.push({ line: node.line, callee: node.callee });
|
|
125
166
|
}
|
|
126
167
|
return out;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Generalised, recall-preserving sanitizer gate.
|
|
2
2
|
//
|
|
3
|
-
// The catalog carries
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The catalog carries 381 sanitizer entries (all languages, all kinds; count
|
|
4
|
+
// via CATALOG.filter(e => e.kind === 'sanitizer').length — re-derive rather
|
|
5
|
+
// than trust a hardcoded number here, since this comment has been wrong
|
|
6
|
+
// before) tagged by family via `appliesTo` (sql, xss, url, cmd, *, …). Before
|
|
7
|
+
// this module only `appliesTo: ['sql']` was ever consumed — by
|
|
8
|
+
// proven-clean.js — so a correctly sanitized xss/url/cmd flow was still
|
|
9
|
+
// reported at full confidence. That is a pure false-positive source.
|
|
7
10
|
//
|
|
8
11
|
// This gate NEVER removes a finding. It sets `sanitized` plus a proof object and
|
|
9
12
|
// lets the existing proof gate in engine.js do the demotion, exactly as
|
|
@@ -15,20 +18,38 @@ import { CATALOG } from './catalog.js';
|
|
|
15
18
|
|
|
16
19
|
// Map a finding to a sanitizer family using its CWE first (stable) and its vuln
|
|
17
20
|
// text second (human-authored, so only a fallback).
|
|
21
|
+
//
|
|
22
|
+
// CWE-22 (path traversal) maps to the catalog's real 'path' family
|
|
23
|
+
// (py-pathlib-resolve / cs-path-getfullpath / kt-path-canonical, all
|
|
24
|
+
// appliesTo:['path']) — it used to collapse to 'url' alongside CWE-918/601,
|
|
25
|
+
// which meant those genuine path-containment sanitizers could never match a
|
|
26
|
+
// CWE-22 finding at all (permanently inert gate for that CWE), AND any
|
|
27
|
+
// url-family sanitizer (URL-percent-encoding: encodeURIComponent etc.)
|
|
28
|
+
// satisfied the family check for CWE-22/918/601 findings even though
|
|
29
|
+
// percent-encoding neutralizes none of them. SSRF (CWE-918) and open
|
|
30
|
+
// redirect (CWE-601) are host/scheme allow-list problems, not encoding
|
|
31
|
+
// problems, and the catalog has no dedicated family for either — mapping
|
|
32
|
+
// them to a family with zero real catalog matches (rather than borrowing
|
|
33
|
+
// 'url') means this gate correctly never fires for them, leaving that
|
|
34
|
+
// containment to engine.js's separate dropGuardedFindings host-allowlist
|
|
35
|
+
// recognition instead of a false "proven clean" from an unrelated encoder.
|
|
18
36
|
const _CWE_FAMILY = {
|
|
19
37
|
'CWE-89': 'sql',
|
|
20
38
|
'CWE-79': 'xss',
|
|
21
39
|
'CWE-78': 'cmd',
|
|
22
|
-
'CWE-22': '
|
|
23
|
-
'CWE-918': '
|
|
24
|
-
'CWE-601': '
|
|
40
|
+
'CWE-22': 'path',
|
|
41
|
+
'CWE-918': 'ssrf-host-allowlist-only', // no catalog family — see above
|
|
42
|
+
'CWE-601': 'redirect-allowlist-only', // no catalog family — see above
|
|
25
43
|
};
|
|
26
44
|
|
|
27
45
|
const _TEXT_FAMILY = [
|
|
28
46
|
[/sql/i, 'sql'],
|
|
29
47
|
[/xss|cross-site scripting/i, 'xss'],
|
|
30
48
|
[/command injection/i, 'cmd'],
|
|
31
|
-
[/path traversal
|
|
49
|
+
[/path traversal/i, 'path'],
|
|
50
|
+
// No catalog family for ssrf/redirect — deliberately no fallback entry
|
|
51
|
+
// (see the _CWE_FAMILY comment above); familyOfFinding returns null and
|
|
52
|
+
// applySanitizerGate's `if (!fam) continue;` safely no-ops.
|
|
32
53
|
];
|
|
33
54
|
|
|
34
55
|
export function familyOfFinding(f) {
|
|
@@ -78,7 +99,12 @@ export function applySanitizerGate(findings, ctx) {
|
|
|
78
99
|
if (!Array.isArray(observed) || !observed.length) continue;
|
|
79
100
|
const matching = observed.filter(name => {
|
|
80
101
|
const fams = index.get(name);
|
|
81
|
-
|
|
102
|
+
// `*` is the catalog's universal tag, carried by the 17 type-coercion
|
|
103
|
+
// entries (parseInt/intval/Atoi/TryParse/…) that neutralise every
|
|
104
|
+
// injection family by making the value non-stringy. Matching it
|
|
105
|
+
// literally against 'sql'/'xss' never succeeds, which left that whole
|
|
106
|
+
// tier inert.
|
|
107
|
+
return fams && (fams.has(fam) || fams.has('*'));
|
|
82
108
|
});
|
|
83
109
|
if (!matching.length) continue;
|
|
84
110
|
// Label only. The proof gate decides what to do with the label.
|
|
@@ -117,11 +117,29 @@ export function verifyProjectSanitizers(perFileIR, catalog) {
|
|
|
117
117
|
if (!calleeName) continue;
|
|
118
118
|
const fns = fnByName.get(calleeName);
|
|
119
119
|
if (!fns || !fns.length) continue; // not a project-local sanitizer
|
|
120
|
+
// Stage 3 correctness audit (detection depth): this used to take only
|
|
121
|
+
// `appliesTo[0]`, so a multi-family entry (`appliesTo: ['sql', 'xss']`)
|
|
122
|
+
// was only ever verified against its FIRST tag — silently skipping
|
|
123
|
+
// whether the project's local function actually behaves as a
|
|
124
|
+
// sanitizer for the OTHER families it claims to cover. It also fell
|
|
125
|
+
// back to the literal string `'*'` when `appliesTo` was absent or
|
|
126
|
+
// wildcard-only, and `'*'` has no entry in `_SHAPE_RULES` — every
|
|
127
|
+
// wildcard-tagged sanitizer (the 17 catalog type-coercion entries:
|
|
128
|
+
// parseInt, Number(), etc.) was unconditionally verdicted
|
|
129
|
+
// `trusted: false, reason: "no shape rule for family '*'"`, even
|
|
130
|
+
// though `'*'` legitimately covers every family by design (see
|
|
131
|
+
// sanitizer-gate.js's own header comment on the same wildcard tag).
|
|
132
|
+
// Fixed: verify against every concrete (non-'*') family the entry
|
|
133
|
+
// claims; a wildcard-only entry has no per-family shape to check
|
|
134
|
+
// against and contributes no verdict rather than a misleading false one.
|
|
135
|
+
const families = (Array.isArray(entry.appliesTo) ? entry.appliesTo : []).filter(f => f !== '*');
|
|
136
|
+
if (!families.length) continue;
|
|
120
137
|
for (const fn of fns) {
|
|
121
138
|
const bodyText = _stringifyCfgBody(fn);
|
|
122
|
-
const family
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
for (const family of families) {
|
|
140
|
+
const verdict = isValidSanitizerFor(bodyText, family);
|
|
141
|
+
out.push({ fnQid: fn.qid, family, trusted: verdict.trusted, reason: verdict.reason });
|
|
142
|
+
}
|
|
125
143
|
}
|
|
126
144
|
}
|
|
127
145
|
return out;
|
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
// Cmd inj (CWE-78): source type ∈ {number, boolean} → demote
|
|
18
18
|
// Path trav (CWE-22): source type ∈ {number, boolean} → demote
|
|
19
19
|
//
|
|
20
|
-
// Demotion lowers
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
20
|
+
// Demotion lowers confidence + confidenceTier + exploitabilityTier by one
|
|
21
|
+
// step and sets `_stubTypeDemoted: true` with a `_stubTypeReason` — the
|
|
22
|
+
// same recall-preserving shape every other precision annotator in this
|
|
23
|
+
// directory uses (proof-gate.js, verification-separation.js). Severity is
|
|
24
|
+
// never touched and findings are never DROPPED — the stub-aware reason is
|
|
25
|
+
// shown to the operator so they can override if the stub is wrong or out
|
|
26
|
+
// of date. (This module used to mutate severity directly; that violated
|
|
27
|
+
// the convention and was flagged, not designed — see dataflow/CLAUDE.md.)
|
|
24
28
|
|
|
25
29
|
const FAMILY_SAFE_TYPES = {
|
|
26
30
|
'CWE-79': new Set(['number', 'boolean', 'Date', 'RegExp', 'bigint']),
|
|
@@ -64,10 +68,33 @@ function _normalizeType(t) {
|
|
|
64
68
|
return trimmed;
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
// Tier ladders, lowest → highest — identical shape to proof-gate.js's, kept
|
|
72
|
+
// as a local copy rather than importing that module's test-only `_internals`
|
|
73
|
+
// across a module boundary.
|
|
74
|
+
const CONFIDENCE_TIERS = ['very-low', 'low', 'medium', 'high'];
|
|
75
|
+
const EXPLOITABILITY_TIERS = ['low', 'medium', 'high', 'critical'];
|
|
76
|
+
const CONFIDENCE_DEMOTE_FACTOR = 0.4;
|
|
77
|
+
|
|
78
|
+
function _demoteTier(tier, ladder) {
|
|
79
|
+
const i = ladder.indexOf(tier);
|
|
80
|
+
if (i <= 0) return ladder[0];
|
|
81
|
+
return ladder[i - 1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _demote(f) {
|
|
85
|
+
if (typeof f.confidence === 'number') {
|
|
86
|
+
f._confidenceBeforeStubFilter = f.confidence;
|
|
87
|
+
f.confidence = Math.max(0.01, Number((f.confidence * CONFIDENCE_DEMOTE_FACTOR).toFixed(4)));
|
|
88
|
+
}
|
|
89
|
+
if (f.confidenceTier) f.confidenceTier = _demoteTier(f.confidenceTier, CONFIDENCE_TIERS);
|
|
90
|
+
if (f.exploitabilityTier) f.exploitabilityTier = _demoteTier(f.exploitabilityTier, EXPLOITABILITY_TIERS);
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
/**
|
|
68
94
|
* Post-pass entry. Mutates findings in place: adds `_stubTypeDemoted`,
|
|
69
|
-
* `_stubTypeReason`,
|
|
70
|
-
* type is in the family-safe set for the
|
|
95
|
+
* `_stubTypeReason`, demotes confidence/confidenceTier/exploitabilityTier
|
|
96
|
+
* by one step when the source type is in the family-safe set for the
|
|
97
|
+
* finding's CWE. Severity is never touched (recall-preserving).
|
|
71
98
|
*
|
|
72
99
|
* Returns the (mutated) findings array with `_stubFilterStats` non-
|
|
73
100
|
* enumerable sidecar.
|
|
@@ -131,9 +158,7 @@ export function applyStubAwareFilter(findings, stubs, perFileIR) {
|
|
|
131
158
|
if (sourceType && safeSet.has(sourceType)) {
|
|
132
159
|
f._stubTypeDemoted = true;
|
|
133
160
|
f._stubTypeReason = `source type ${sourceType} cannot carry ${f.cwe} metacharacters`;
|
|
134
|
-
f
|
|
135
|
-
const downgrade = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
136
|
-
if (downgrade[f.severity]) f.severity = downgrade[f.severity];
|
|
161
|
+
_demote(f);
|
|
137
162
|
demoted++;
|
|
138
163
|
continue;
|
|
139
164
|
}
|
|
@@ -142,9 +167,7 @@ export function applyStubAwareFilter(findings, stubs, perFileIR) {
|
|
|
142
167
|
if (guardType && safeSet.has(guardType)) {
|
|
143
168
|
f._stubTypeDemoted = true;
|
|
144
169
|
f._stubTypeReason = `type guard narrows to ${guardType}, safe for ${f.cwe}`;
|
|
145
|
-
f
|
|
146
|
-
const downgrade = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
147
|
-
if (downgrade[f.severity]) f.severity = downgrade[f.severity];
|
|
170
|
+
_demote(f);
|
|
148
171
|
demoted++;
|
|
149
172
|
}
|
|
150
173
|
}
|
|
@@ -155,4 +178,4 @@ export function applyStubAwareFilter(findings, stubs, perFileIR) {
|
|
|
155
178
|
return findings;
|
|
156
179
|
}
|
|
157
180
|
|
|
158
|
-
export const _internal = { FAMILY_SAFE_TYPES, _sourceTypeFromStubs, _normalizeType };
|
|
181
|
+
export const _internal = { FAMILY_SAFE_TYPES, _sourceTypeFromStubs, _normalizeType, CONFIDENCE_TIERS, EXPLOITABILITY_TIERS, _demoteTier };
|
|
@@ -28,7 +28,13 @@
|
|
|
28
28
|
// - Field sensitivity is at the parameter granularity only (not arbitrary
|
|
29
29
|
// access paths). `f(obj)` with obj.foo tainted is treated the same as
|
|
30
30
|
// obj.bar tainted.
|
|
31
|
-
// -
|
|
31
|
+
// - Higher-order tracking IS modelled, in engine.js (not here): a callback
|
|
32
|
+
// passed as an argument is recorded via `_higherOrderInvocations`, then
|
|
33
|
+
// resolved and analyzed with the callback's own tainted parameter, with
|
|
34
|
+
// its findings merged back into the caller (`higher-order.js`). This
|
|
35
|
+
// summary cache is unaware of it; the higher-order path is a separate
|
|
36
|
+
// mechanism layered on top by the caller. (This bullet previously said
|
|
37
|
+
// no such tracking existed at all, which was true only of THIS module.)
|
|
32
38
|
// - Recursion: when we'd recurse into a function already on the analysis
|
|
33
39
|
// stack, we return the bottom summary (no-taint) and rely on fixed-point
|
|
34
40
|
// iteration. With k=1 this converges in ≤2 iterations for typical code.
|
|
@@ -185,10 +191,23 @@ export class SummaryCache {
|
|
|
185
191
|
clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; }
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
// Stage 3 correctness audit (detection depth): this compared mutatedParams
|
|
195
|
+
// by SIZE only, not membership — two summaries with the same cardinality
|
|
196
|
+
// but different members (e.g. {'fieldA'} vs {'fieldB'}, plausible across
|
|
197
|
+
// fixed-point iterations of a recursive helper whose mutated-target
|
|
198
|
+
// identity depends on recursion depth) were treated as equal. compute()'s
|
|
199
|
+
// fixed-point loop (above) then `break`s WITHOUT caching the just-computed
|
|
200
|
+
// `summary` — but still returns it to its immediate caller. Any OTHER,
|
|
201
|
+
// later call site doing a plain cache `.get(qid, sameEntry)` would then
|
|
202
|
+
// silently receive the stale `prev` summary the loop declared "equal to"
|
|
203
|
+
// the fresher one it never cached.
|
|
188
204
|
function _summaryEq(a, b) {
|
|
189
205
|
if (!a || !b) return a === b;
|
|
190
206
|
if (!!a.returnTainted !== !!b.returnTainted) return false;
|
|
191
|
-
|
|
207
|
+
const am = a.mutatedParams || new Set();
|
|
208
|
+
const bm = b.mutatedParams || new Set();
|
|
209
|
+
if (am.size !== bm.size) return false;
|
|
210
|
+
for (const p of am) if (!bm.has(p)) return false;
|
|
192
211
|
return true;
|
|
193
212
|
}
|
|
194
213
|
|
package/src/discovery/CLAUDE.md
CHANGED
|
@@ -34,5 +34,15 @@ panel · `judge.js` shapes findings and dedupes against the prior scan ·
|
|
|
34
34
|
This layer never emits `critical`.
|
|
35
35
|
- **Ids are content digests.** No clock, no randomness, anywhere in an id, a
|
|
36
36
|
digest, or a sort key.
|
|
37
|
+
- **Bounded by default.** The pipeline is multiplicative — areas × lenses
|
|
38
|
+
hunter calls, then three refutation votes per surviving candidate. Six
|
|
39
|
+
files produced 168 LLM calls before C3. `makeBudget` in `index.js` wraps
|
|
40
|
+
`llmInvoke` so every call is counted against a ceiling, and `maxCandidates`
|
|
41
|
+
caps what reaches the panel. Never remove a bound without replacing it:
|
|
42
|
+
an unbounded run's cost is a function of repository size.
|
|
43
|
+
- **An exhausted budget means INCOMPLETE, not clean.** Exhaustion arrives
|
|
44
|
+
through the same degradation path as a dead endpoint and lands in
|
|
45
|
+
`coverage.reasons`. A capped candidate is neither a finding nor cleared —
|
|
46
|
+
it was not examined, and the report says so.
|
|
37
47
|
- **Coverage is reported.** Degraded runs and their reasons appear in every
|
|
38
48
|
report. A half-failed pass must never read as a clean one.
|