@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.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 +125 -0
- package/dist/1336.index.js +16010 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/backward.js +104 -7
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/cross-service-taint.js +154 -11
- package/src/dataflow/engine.js +875 -90
- package/src/dataflow/exploit-prover.js +111 -23
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +29 -6
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +168 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +118 -9
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/balanced-call.js +33 -1
- package/src/ir/callgraph.js +26 -2
- package/src/ir/class-hierarchy.js +261 -7
- package/src/ir/csharp-ir.js +97 -8
- package/src/ir/index.js +21 -1
- package/src/ir/parser-cs.js +706 -24
- package/src/ir/parser-java.js +498 -17
- package/src/ir/parser-js.js +22 -0
- package/src/ir/parser-php.js +446 -43
- package/src/ir/parser-rust.js +1338 -0
- package/src/ir/php-include-merge.js +190 -0
- package/src/lineage/sink-registry.js +18 -0
- package/src/lineage/source-registry.js +23 -0
- package/src/posture/csharp-analysis.js +341 -43
- package/src/posture/finding-defaults.js +5 -1
- package/src/posture/fix-coverage.js +1 -0
- package/src/posture/proof-coverage.js +1 -0
- package/src/report/index.js +12 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/authz.js +144 -1
- package/src/sast/crypto-protocol.js +65 -7
- package/src/sast/csharp-cross-file.js +125 -0
- package/src/sast/csharp-structural.js +78 -2
- package/src/sast/csharp.js +574 -42
- package/src/sast/java-ast-folding.js +268 -11
- package/src/sast/java-bench-extras.js +530 -2
- package/src/sast/java-deserialization.js +31 -2
- package/src/sast/java-structural-cross-file.js +146 -0
- package/src/sast/java-structural.js +499 -6
- package/src/sast/ldap-injection.js +133 -3
- package/src/sast/rust.js +57 -0
- package/src/sast/xss-reflected-multilang.js +252 -3
- package/src/dataflow/smt-feasibility.js +0 -189
|
@@ -91,9 +91,26 @@ export class SummaryCache {
|
|
|
91
91
|
_key(qid, taintedParams, receiverType) {
|
|
92
92
|
// P1.2: when a receiver type is provided, extend the cache key with
|
|
93
93
|
// its hash. Backward-compatible: no receiverType → same key as before.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
const stateHash = _hashState(taintedParams);
|
|
95
|
+
let base = `${qid}::${stateHash}`;
|
|
96
|
+
// R2: append the caller qid when call-string sensitivity is enabled —
|
|
97
|
+
// but ONLY for a NON-EMPTY entry state. The EMPTY-entry baseline is the
|
|
98
|
+
// one summary `engine.js`'s outer fixed-point pre-pass loop shares and
|
|
99
|
+
// progressively refines across BOTH multiple passes AND every call path
|
|
100
|
+
// that reaches a function with no tainted args — splitting it by caller
|
|
101
|
+
// breaks that sharing outright: a 3-hop empty-entry mutation chain
|
|
102
|
+
// (leaf mutates its own param unconditionally, mid calls leaf, top calls
|
|
103
|
+
// mid) stopped propagating entirely once enabled, confirmed via direct
|
|
104
|
+
// instrumentation, not a hypothetical concern — the pre-pass loop's own
|
|
105
|
+
// `bMid`/`cLeaf` iterations write to a caller-LESS key nothing else ever
|
|
106
|
+
// reads once a NESTED call from a different caller starts writing to a
|
|
107
|
+
// caller-SUFFIXED key instead. A non-empty entry state has no such
|
|
108
|
+
// shared-convergence dependency to begin with — the value-context cache
|
|
109
|
+
// (`AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`) already computes each distinct
|
|
110
|
+
// tainted-arg shape independently, lazily, per call site, so adding a
|
|
111
|
+
// caller dimension there only ever narrows an already-isolated lookup,
|
|
112
|
+
// never disconnects a shared one.
|
|
113
|
+
if (this._callString && this._callerCtx && stateHash !== 'empty') base = `${base}@${this._callerCtx}`;
|
|
97
114
|
if (!receiverType) return base;
|
|
98
115
|
return `${base}::${hashReceiverType(receiverType)}`;
|
|
99
116
|
}
|
|
@@ -103,7 +120,8 @@ export class SummaryCache {
|
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
set(qid, taintedParams, summary, receiverType) {
|
|
106
|
-
this.
|
|
123
|
+
const k = this._key(qid, taintedParams, receiverType);
|
|
124
|
+
this._cache.set(k, summary);
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
has(qid, taintedParams, receiverType) {
|
|
@@ -172,8 +190,16 @@ export class SummaryCache {
|
|
|
172
190
|
// Helper: apply a summary to a caller's taint state given the call site's
|
|
173
191
|
// argument bindings. Returns { calleeReturnTainted, mutated: Set of caller-side
|
|
174
192
|
// var names that should become tainted because the callee mutated them }.
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
// `receiverPath` (optional, next-gen taint capability #3): the caller-side
|
|
194
|
+
// access path of a receiver-qualified call's OWN receiver (`bad` for
|
|
195
|
+
// `bad.setData(x)`). When present and `summary.mutatedThisFields` is
|
|
196
|
+
// non-empty, also returns `mutatedThisPaths` — the receiver's own field
|
|
197
|
+
// paths (`bad.data`) to taint in the caller, mirroring `mutated` above but
|
|
198
|
+
// for the implicit `this` parameter instead of a declared one. Omitting
|
|
199
|
+
// `receiverPath` (every pre-existing call site) leaves `mutatedThisPaths`
|
|
200
|
+
// empty, so this is purely additive for callers that don't pass it.
|
|
201
|
+
applyAtCallSite(summary, paramNames, callArgs, callerTaintedVars, receiverPath) {
|
|
202
|
+
if (!summary) return { returnTainted: false, mutated: new Set(), mutatedThisPaths: new Set() };
|
|
177
203
|
const mutated = new Set();
|
|
178
204
|
if (summary.mutatedParams && summary.mutatedParams.size) {
|
|
179
205
|
// Map each mutated parameter position back to the caller-side argument name.
|
|
@@ -184,7 +210,11 @@ export class SummaryCache {
|
|
|
184
210
|
if (arg && arg.kind === 'ident') mutated.add(arg.name);
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
|
-
|
|
213
|
+
const mutatedThisPaths = new Set();
|
|
214
|
+
if (receiverPath && summary.mutatedThisFields && summary.mutatedThisFields.size) {
|
|
215
|
+
for (const field of summary.mutatedThisFields) mutatedThisPaths.add(`${receiverPath}.${field}`);
|
|
216
|
+
}
|
|
217
|
+
return { returnTainted: !!summary.returnTainted, mutated, mutatedThisPaths };
|
|
188
218
|
}
|
|
189
219
|
|
|
190
220
|
size() { return this._cache.size; }
|
|
@@ -208,6 +238,16 @@ function _summaryEq(a, b) {
|
|
|
208
238
|
const bm = b.mutatedParams || new Set();
|
|
209
239
|
if (am.size !== bm.size) return false;
|
|
210
240
|
for (const p of am) if (!bm.has(p)) return false;
|
|
241
|
+
// Next-gen taint capability #3's `mutatedThisFields` is a THIRD summary
|
|
242
|
+
// field alongside `returnTainted`/`mutatedParams` — deliberately compared
|
|
243
|
+
// here too, not left out, per this function's own header comment above:
|
|
244
|
+
// omitting a field from this equality check is exactly how a fresher,
|
|
245
|
+
// more-refined summary can be silently discarded by the fixed-point loop
|
|
246
|
+
// while still being returned to its immediate caller.
|
|
247
|
+
const atf = a.mutatedThisFields || new Set();
|
|
248
|
+
const btf = b.mutatedThisFields || new Set();
|
|
249
|
+
if (atf.size !== btf.size) return false;
|
|
250
|
+
for (const f of atf) if (!btf.has(f)) return false;
|
|
211
251
|
return true;
|
|
212
252
|
}
|
|
213
253
|
|
|
@@ -244,10 +284,31 @@ export function entryStateFromCall(paramNames, callArgs, callerTaintedVars, isAr
|
|
|
244
284
|
// ASSIGNED locals, not raw globals) took the member branch, found
|
|
245
285
|
// nothing, and — being an else-if — never reached isArgTaintedExpr at
|
|
246
286
|
// all. Confirmed via a real corpus fixture.
|
|
287
|
+
// PRD W2 (SARD_80_F1_EXECUTION_PRD.md) — an object argument whose FIELD
|
|
288
|
+
// was tainted before the call (`c.field = tainted; step(c);`) is a
|
|
289
|
+
// documented scope gap: entry-state granularity here is param-level,
|
|
290
|
+
// not per-access-path, so `c` itself was never marked tainted even
|
|
291
|
+
// though `c.field` genuinely is in the caller's field-sensitive lattice
|
|
292
|
+
// — the callee then reads `c.field` back out with no provenance at all.
|
|
293
|
+
// Found sampling real Juliet variant 67 false negatives (a tainted
|
|
294
|
+
// value stored into a `Container` object's field, the object passed
|
|
295
|
+
// whole to another function, the field read back out there). Widens
|
|
296
|
+
// the SAME direction this codebase already takes for "unknown-key
|
|
297
|
+
// container writes" and collection taint (dataflow/CLAUDE.md's own
|
|
298
|
+
// documented precedent): if ANY access path prefixed by this
|
|
299
|
+
// identifier is tainted, treat the whole object as tainted when it
|
|
300
|
+
// crosses a call boundary — over-approximating (a clean sibling field
|
|
301
|
+
// read downstream may falsely inherit taint) is the accepted direction
|
|
302
|
+
// for a security scanner, the same tradeoff every other "widen to the
|
|
303
|
+
// container" rule in this codebase already makes.
|
|
247
304
|
let tainted = false;
|
|
248
305
|
if (arg.kind === 'ident' && callerTaintedVars.has(arg.name)) {
|
|
249
306
|
tainted = true;
|
|
250
|
-
} else if (arg.kind === '
|
|
307
|
+
} else if (arg.kind === 'ident') {
|
|
308
|
+
const prefix = `${arg.name}.`;
|
|
309
|
+
for (const v of callerTaintedVars) { if (v.startsWith(prefix)) { tainted = true; break; } }
|
|
310
|
+
}
|
|
311
|
+
if (!tainted && arg.kind === 'member' && arg.object?.kind === 'ident') {
|
|
251
312
|
const base = arg.object.name;
|
|
252
313
|
if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`)) tainted = true;
|
|
253
314
|
}
|
package/src/engine.js
CHANGED
|
@@ -107,6 +107,8 @@ import { scanFileUpload } from './sast/file-upload.js';
|
|
|
107
107
|
import { scanHostHeader } from './sast/host-header.js';
|
|
108
108
|
import { scanPythonSinks } from './sast/python-sinks.js';
|
|
109
109
|
import { scanCSharp } from './sast/csharp.js';
|
|
110
|
+
import { scanCsharpCrossFile } from './sast/csharp-cross-file.js';
|
|
111
|
+
import { computeJavaStructuralCrossFileSuppressions } from './sast/java-structural-cross-file.js';
|
|
110
112
|
import { scanCpp } from './sast/cpp.js';
|
|
111
113
|
import { scanJulietShape, applyJulietJavaSuppressions, applyJulietCsSuppressions } from './sast/juliet-shape.js';
|
|
112
114
|
import { scanCppDataflow, _parseErrorCount as _cppDataflowParseErrors } from './sast/cpp-dataflow.js';
|
|
@@ -237,7 +239,6 @@ import { runCrossServiceTaint } from './dataflow/cross-service-taint.js';
|
|
|
237
239
|
import { annotateRuntimeCorrelation } from './posture/runtime-correlation.js';
|
|
238
240
|
import { applyLearnedCalibration } from './posture/triage-learning.js';
|
|
239
241
|
import { annotateFormalVerification } from './dataflow/formal-verify.js';
|
|
240
|
-
import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
|
|
241
242
|
import { annotateProofGate } from './dataflow/proof-gate.js';
|
|
242
243
|
import { applySanitizerGate } from './dataflow/sanitizer-gate.js';
|
|
243
244
|
import { annotateFalsification } from './posture/falsification.js';
|
|
@@ -1551,7 +1552,11 @@ function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSR
|
|
|
1551
1552
|
// before `Path.Combine(..., filename)`) left the finding firing on the FIXED
|
|
1552
1553
|
// revision. Safe to match on shape because _guardMatchNearSinkIdentifier
|
|
1553
1554
|
// already requires the guard to name the same identifier as the sink.
|
|
1554
|
-
|
|
1555
|
+
// `starts_with`/`strip_prefix` (Rust's own snake_case naming) and
|
|
1556
|
+
// `canonicalize` (Rust's `Path::canonicalize`, the containment check's
|
|
1557
|
+
// resolve-then-compare half) join the existing camelCase/PascalCase forms —
|
|
1558
|
+
// same guard, a different language's naming convention.
|
|
1559
|
+
const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix|starts_with|strip_prefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bcanonicalize\s*\(|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b|\b(?:[Ee]nsure|[Vv]alidate|[Aa]ssert|[Rr]equire|[Vv]erify|[Cc]heck)\w{1,40}\s*\(/;
|
|
1555
1560
|
function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
|
|
1556
1561
|
|
|
1557
1562
|
// Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
|
|
@@ -5007,7 +5012,17 @@ function scanJavaSAST(fp, raw) {
|
|
|
5007
5012
|
// Per-family file-level predicate (custom logic). Returns truthy to suppress.
|
|
5008
5013
|
if (typeof rule.fileSafePredicate === 'function' && rule.fileSafePredicate(cleaned, raw)) continue;
|
|
5009
5014
|
|
|
5010
|
-
|
|
5015
|
+
// Use the match's END, not its start: every sinkRe here terminates
|
|
5016
|
+
// right at the sink call's own opening `(`, but a few (ldap-injection's
|
|
5017
|
+
// `\bjavax.naming...\b[\s\S]{0,12000}?\w+\.search\(`) anchor on an EARLIER
|
|
5018
|
+
// token (an import statement, or the first `DirContext` mention) and lazily
|
|
5019
|
+
// span forward to the real call, which can be dozens of lines later. Using
|
|
5020
|
+
// the start position reported the anchor's line (e.g. an `import` line) as
|
|
5021
|
+
// the "vulnerable line" — a wrong, misleading finding location/snippet, and
|
|
5022
|
+
// it also broke `dedupeFindingsWithEvidence`'s per-sink-line collapse with
|
|
5023
|
+
// other detectors (ldap-injection.js, the IR-TAINT engine) that correctly
|
|
5024
|
+
// report the real call site, since a stale anchor line never matches it.
|
|
5025
|
+
const sinkLine = cleaned.substring(0, sinkMatch.index + sinkMatch[0].length).split('\n').length;
|
|
5011
5026
|
|
|
5012
5027
|
// Optional precision filter: when the rule is taint-aware, extract the
|
|
5013
5028
|
// sink call's argument expression and check whether a tainted variable
|
|
@@ -6372,6 +6387,9 @@ const _VULN_FAMILY_PREFIX = [
|
|
|
6372
6387
|
['SQL Injection', 'sql-injection'],
|
|
6373
6388
|
['Command Injection', 'command-injection'],
|
|
6374
6389
|
['Code Injection', 'code-injection'],
|
|
6390
|
+
['LDAP Injection', 'ldap-injection'],
|
|
6391
|
+
['XPath Injection', 'xpath-injection'],
|
|
6392
|
+
['Local/Remote File Inclusion', 'code-injection'],
|
|
6375
6393
|
['Reflected XSS', 'xss'],
|
|
6376
6394
|
['Stored XSS', 'xss'],
|
|
6377
6395
|
['DOM XSS', 'xss'],
|
|
@@ -6383,6 +6401,18 @@ const _VULN_FAMILY_PREFIX = [
|
|
|
6383
6401
|
['Prototype Pollution', 'prototype-pollution'],
|
|
6384
6402
|
['IDOR', 'idor'],
|
|
6385
6403
|
['Potential IDOR', 'idor'],
|
|
6404
|
+
// SARD_80_F1 W5.13 — must precede the generic 'AuthZ:' catch-all below: a
|
|
6405
|
+
// CWE-862 (Missing Authorization) finding from authz.js's PHP IDOR
|
|
6406
|
+
// detector also starts with "AuthZ:", and this array's first-match-wins
|
|
6407
|
+
// iteration would otherwise sweep it into the SAME 'idor' family as every
|
|
6408
|
+
// OTHER authz.js pattern (JWT/OAuth/session-fixation/CWE-639), overriding
|
|
6409
|
+
// finding-defaults.js's own correct CWE-862 -> 'missing-authz' mapping —
|
|
6410
|
+
// confirmed as the exact reason a genuinely firing, correctly-CWE-tagged
|
|
6411
|
+
// finding never scored a tp (dedupeFindingsWithEvidence runs before the
|
|
6412
|
+
// finding-defaults backfill and stamps `f.family` first).
|
|
6413
|
+
['AuthZ: raw SQL where-by-id from request input without an ownership check', 'missing-authz'],
|
|
6414
|
+
['AuthZ: fopen() on request-supplied path/id without an ownership check', 'missing-authz'],
|
|
6415
|
+
['AuthZ: XPath query built from request input without an ownership check', 'missing-authz'],
|
|
6386
6416
|
['AuthZ:', 'idor'],
|
|
6387
6417
|
['MD5/SHA1', 'weak-crypto'],
|
|
6388
6418
|
['Weak Cryptographic Hash', 'weak-crypto'],
|
|
@@ -6396,6 +6426,41 @@ const _VULN_FAMILY_PREFIX = [
|
|
|
6396
6426
|
['OpenSSH Private Key', 'hardcoded-secret'],
|
|
6397
6427
|
['Password in URL', 'hardcoded-secret'],
|
|
6398
6428
|
['OAuth Authorization Code Theft', 'open-redirect'],
|
|
6429
|
+
// SARD_80_F1 W4: every IR-TAINT open-redirect catalog sink names its own
|
|
6430
|
+
// API in the vuln string ("Open Redirect (Controller.Redirect)",
|
|
6431
|
+
// "Open Redirect (response.sendRedirect)", "Open Redirect (flask.redirect)",
|
|
6432
|
+
// "Open Redirect (Rails redirect_to)", ...) — with no prefix entry here,
|
|
6433
|
+
// each one fell through to the generic auto-slug fallback and got its OWN
|
|
6434
|
+
// unique family string, distinct from open-redirect.js's/csharp.js's
|
|
6435
|
+
// explicit `family: 'open-redirect'`. Two detectors correctly finding the
|
|
6436
|
+
// SAME real vulnerability at the SAME line then failed to collapse via
|
|
6437
|
+
// dedupeFindingsWithEvidence's (file, sink-line, family) key, producing an
|
|
6438
|
+
// extra unmatched/scored FP — the exact bug class found in W4.J15 for
|
|
6439
|
+
// Java's LDAP rule, here caused by family-string divergence instead of a
|
|
6440
|
+
// wrong line. Found via the C# public mirror: CWE601_Open_Redirect__Web_
|
|
6441
|
+
// QueryString_Web_01.cs's resp.Redirect(data) produced both a `CSHARP`
|
|
6442
|
+
// finding (family 'open-redirect') and an `IR-TAINT` finding (family
|
|
6443
|
+
// 'open-redirect-controller-redirect') at the identical line.
|
|
6444
|
+
['Open Redirect', 'open-redirect'],
|
|
6445
|
+
// Same bug, same fix, found by checking every other injection family with
|
|
6446
|
+
// BOTH a structural detector AND IR-TAINT catalog sinks for the identical
|
|
6447
|
+
// divergence: every CWE-113 IR-TAINT sink's vuln string starts with "HTTP
|
|
6448
|
+
// Response Splitting" (12 catalog entries across Java/JS/PHP/Go/C#), with
|
|
6449
|
+
// no prefix entry here — each fell through to its own unique auto-slug
|
|
6450
|
+
// family, distinct from `response-splitting.js`'s explicit
|
|
6451
|
+
// `family: 'response-splitting'`. Verified directly: a Java servlet
|
|
6452
|
+
// `response.setHeader("X-User", request.getParameter(name))` probe produced
|
|
6453
|
+
// both a `RESPONSE-SPLITTING` finding (family 'response-splitting') and an
|
|
6454
|
+
// `IR-TAINT` finding (family 'http-response-splitting-header-injection') at
|
|
6455
|
+
// the identical line. CWE-113 has carried this session's largest persistent
|
|
6456
|
+
// fp counts (399 on Java dev, 348 on C# train, per W4.C1) and this exact
|
|
6457
|
+
// duplicate-detection mechanism was never checked as a contributing cause
|
|
6458
|
+
// during the earlier sanitizer-focused CWE-113 investigation (commit
|
|
6459
|
+
// 4f41daec) — distinct from `test/benchmark/expected.json`'s `_familyMap`
|
|
6460
|
+
// (the SEPARATE table that resolves scoring family from vuln text and
|
|
6461
|
+
// already maps this same prefix to 'header-hardening'; this table only
|
|
6462
|
+
// controls ENGINE-level dedup, not scoring attribution).
|
|
6463
|
+
['HTTP Response Splitting', 'response-splitting'],
|
|
6399
6464
|
['Synchronous Blocking I/O', 'dos-sync-io'],
|
|
6400
6465
|
['Missing Timeout', 'dos-no-timeout'],
|
|
6401
6466
|
['GraphQL Missing Query', 'graphql-dos'],
|
|
@@ -9116,6 +9181,25 @@ function _deterministicFileTimings(timings) {
|
|
|
9116
9181
|
}
|
|
9117
9182
|
const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
|
|
9118
9183
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
|
|
9184
|
+
aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
|
|
9185
|
+
// SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
|
|
9186
|
+
// this one SUPPRESSES an already-emitted java-structural.js or
|
|
9187
|
+
// java-bench-extras.js finding when every real caller (checked project-
|
|
9188
|
+
// wide within the finding's own directory) agrees the concatenated/
|
|
9189
|
+
// redirected value is a literal. See java-structural-cross-file.js's own
|
|
9190
|
+
// header comment for the full design and W5.40's EXECUTION_STATUS.md
|
|
9191
|
+
// entry for the root-cause investigation.
|
|
9192
|
+
{
|
|
9193
|
+
const _jsCrossSuppress = runDetector(_detectorErrors,'<project>','computeJavaStructuralCrossFileSuppressions',()=>computeJavaStructuralCrossFileSuppressions(fc));
|
|
9194
|
+
if (_jsCrossSuppress && _jsCrossSuppress.size) {
|
|
9195
|
+
for (let _i = aF.length - 1; _i >= 0; _i--) {
|
|
9196
|
+
const _f = aF[_i];
|
|
9197
|
+
if (_f && typeof _f.id === 'string' && (_f.id.startsWith('java-struct-') || _f.id.startsWith('java-extras:open-redirect:')) && _jsCrossSuppress.has(`${_f.file}:${_f.line}`)) {
|
|
9198
|
+
aF.splice(_i, 1);
|
|
9199
|
+
}
|
|
9200
|
+
}
|
|
9201
|
+
}
|
|
9202
|
+
}
|
|
9119
9203
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanStoredPromptInjectionCrossFile',()=>scanStoredPromptInjectionCrossFile(fc))||[]));
|
|
9120
9204
|
// Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
|
|
9121
9205
|
// AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
|
|
@@ -9754,11 +9838,16 @@ function _deterministicFileTimings(timings) {
|
|
|
9754
9838
|
if (process.env.AGENTIC_SECURITY_FORMAL === '1') {
|
|
9755
9839
|
await _runAnnotator("annotateFormalVerification", async () => { await annotateFormalVerification(finalFindings, fc, {}); });
|
|
9756
9840
|
}
|
|
9757
|
-
// SMT path feasibility
|
|
9758
|
-
//
|
|
9759
|
-
|
|
9760
|
-
|
|
9761
|
-
|
|
9841
|
+
// SMT path feasibility (`AGENTIC_SECURITY_SMT_FEASIBILITY`) was retired:
|
|
9842
|
+
// its predicate encoder read `f.chain`/`f.taintPath` as a sequential
|
|
9843
|
+
// source-to-sink path, but those fields are actually a flat list of
|
|
9844
|
+
// SOURCES reaching the sink (`_sourcesReachingExpr`), never a path —
|
|
9845
|
+
// confirmed by direct code reading, never fixed by real data. It also
|
|
9846
|
+
// mutated `f.severity` directly on an 'unsat' verdict, violating this
|
|
9847
|
+
// codebase's own recall-preserving-demotion convention that every other
|
|
9848
|
+
// proof gate here follows (see `dataflow/CLAUDE.md`). Superseded by the
|
|
9849
|
+
// real backward-slice-driven mechanism: `dataflow/exploit-prover.js`'s
|
|
9850
|
+
// `proveExploits`, opt-in via `AGENTIC_SECURITY_SYMEXEC=1`.
|
|
9762
9851
|
// Privacy / PII taint: emits pii-exposure findings + DPIA artifact.
|
|
9763
9852
|
// FR-405 (assurance-hardening PRD): _privacyIrBacked is declared at
|
|
9764
9853
|
// function scope above the AGENTIC_SECURITY_NO_INTEGRATION block, not
|
|
@@ -10276,7 +10365,27 @@ function _deterministicFileTimings(timings) {
|
|
|
10276
10365
|
// SCA-SAST correlation: link SAST findings to SCA vulnerable packages
|
|
10277
10366
|
try{for(const f of finalFindings){if(!f.chain||!f.chain.length)continue;const src=f.chain[0]?.label||'';for(const sc of supplyChain){if(sc.type!=='vulnerable_dep')continue;if(src.includes(sc.name)||f.vuln?.toLowerCase().includes(sc.name)){f.scaCorrelation={osvId:sc.osvId,package:sc.name,version:sc.version,confirmed:true};sc.sastConfirmed=true;break;}}}}catch(_){}
|
|
10278
10367
|
// Multi-sink chain detection: group findings by source variable
|
|
10279
|
-
|
|
10368
|
+
// SARD_80_F1 W4.J22/J23: this aggregate previously ALWAYS pushed a brand
|
|
10369
|
+
// new finding hardcoding cwe:'CWE-20' with no `family`, regardless of what
|
|
10370
|
+
// the grouped sinks actually were. When every sink in the group shares one
|
|
10371
|
+
// real family/CWE — the common case: several sinks along the same
|
|
10372
|
+
// injection-class chain, e.g. the same tainted var reaching two
|
|
10373
|
+
// db.query() calls — that new finding duplicated a per-sink finding
|
|
10374
|
+
// ALREADY in `finalFindings` at the identical (file, line, family, cwe):
|
|
10375
|
+
// a first attempt here just relabeled the aggregate to the shared
|
|
10376
|
+
// family/CWE, expecting it to collapse via dedupeFindingsWithEvidence's
|
|
10377
|
+
// (file, sink-line, family) key — but this pass runs AFTER dedupe
|
|
10378
|
+
// (line ~9467), so the relabeled duplicate was never actually removed; a
|
|
10379
|
+
// real corpus A/B confirmed zero net fp movement, only a relabel. Fixed
|
|
10380
|
+
// properly: in the homogeneous case, don't push a second finding at all —
|
|
10381
|
+
// attach the chain metadata (source + full sink list) onto the existing
|
|
10382
|
+
// per-sink finding instead, so the "one source, N sinks" information
|
|
10383
|
+
// survives without a duplicate, separately-scored finding. Only a
|
|
10384
|
+
// genuinely mixed-CWE chain (a real, distinct "multiple vulnerability
|
|
10385
|
+
// classes from one source" finding, not a duplicate of any single
|
|
10386
|
+
// existing finding) still gets its own standalone finding, tagged with
|
|
10387
|
+
// the honest generic CWE-20/'multi-sink-taint-chain' label.
|
|
10388
|
+
try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;const sharedFamily=group.every(f=>f.family&&f.family===group[0].family)?group[0].family:null;const sharedCwe=group.every(f=>f.cwe&&f.cwe===group[0].cwe)?group[0].cwe:null;const sinks=group.map(f=>({file:f.file,line:f.line,vuln:f.vuln}));if(sharedFamily&&sharedCwe){for(const f of group)f.multiSinkChain={source:src,sinkCount:group.length,sinks};continue;}finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',family:'multi-sink-taint-chain',parser:'MULTI-SINK',confidence:0.85,sinks,_aggregated:true});}}catch(_){}
|
|
10280
10389
|
// SCA transitive dedup: collapse duplicate CVEs across dep chains
|
|
10281
10390
|
try{const osvGroups=new Map();for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||!sc.osvId)continue;if(!osvGroups.has(sc.osvId))osvGroups.set(sc.osvId,[]);osvGroups.get(sc.osvId).push(sc);}for(const[osvId,group]of osvGroups){if(group.length<=1)continue;const primary=group.find(s=>s.isDirect)||group[0];primary.dependents=group.filter(s=>s!==primary).map(s=>({name:s.name,version:s.version,depChain:s.depChain,isDirect:s.isDirect}));primary._transitiveDeduped=group.length-1;for(const dup of group){if(dup!==primary)dup._deduplicatedInto=primary.osvId;}supplyChain.splice(0,supplyChain.length,...supplyChain.filter(s=>!s._deduplicatedInto));}}catch(_){}
|
|
10282
10391
|
// ── World-class post-scan artifact emitters ──────────────────────────
|
package/src/ir/CLAUDE.md
CHANGED
|
@@ -31,7 +31,8 @@ consumed by `scanner/src/dataflow/` for taint analysis.
|
|
|
31
31
|
|
|
32
32
|
**Two further gaps found while building the corpus entry above, NOT fixed (out of scope for trailing-lambda body recursion, routed around instead — same pattern as Java's chained-call CFG bug):** (a) a call with NO args made on a tainted receiver via a dotted callee string — e.g. `it.toString()`, `tainted.trim()` — does not inherit the receiver's taint. `engine.js`'s `exprTaint` for `case 'call'` checks only `expr.args` and the resolved callee's own return-taint summary (`_nestedCallReturnTainted`); a callee string like `"it.toString"` is never parsed back into a member expression to ask whether its OWN receiver (`it`) is tainted. This is not Kotlin-specific (any language whose IR lowers a method call to a bare dotted-string callee has the same exposure), so a real fix belongs in `engine.js`, not here. (b) `call.parameters` (Ktor's own cataloged `member`-type source) does not propagate taint through a subsequent `.getAll(...)` call made on top of it — `call.parameters.getAll("id")` reads as untainted, because the catalog entry matches a bare property READ, not a call whose receiver is that property. Both gaps were discovered via a fixture that initially used these exact shapes and silently produced zero findings; each was routed around (by using a no-transform binding and a `call`-type source instead) rather than fixed, to keep this task scoped to CFG body recursion. **Taint-recall PRD (80%) Tier 5: `kt-file-readtext`/`kt-url-readtext` catalog cross-collision.** Found via `CVE-2022-22965-kt-ssrf`: both entries were unscoped bare `callee: 'readText'` (path-traversal and SSRF sinks sharing the identical last-segment name), so a single `URL(url).readText()` call fired BOTH — the correct SSRF finding and a spurious Path Traversal finding on the same line. Fixed with receiver scoping (`^File` / `^URL`), the same precision pattern `match.receiver` uses throughout this catalog. Guarded by `test/catalog-path-ssrf-p3.test.js`; required updating `test/phase2-scoping.test.js`'s existing `matchSinkOrSanitizer('readText', 'A.java')` call (a bare string with no receiver context, which the new requirement correctly rejects) to a realistic dotted-string shape (`'File.readText'`). |
|
|
33
33
|
| C / C++ | `parser-cpp.js` | Hand-rolled parser (functions, qualified names, CFG lowering). Dispatched by extension (`c/cc/cpp/cxx/h/hh/hpp/hxx`) in both `buildProjectIR` and `buildProjectIRAsync`. |
|
|
34
|
-
|
|
|
34
|
+
| Rust | `parser-rust.js` | Hand-rolled, the `parser-go.js`/`parser-cs.js` pattern (preprocess: comments blanked, `::` paths flattened to dots, turbofish/`?`/`.await` stripped; then statement-split + recursive CFG build). **SARD 80% F1 push, first-class from day one — no prior IR existed for Rust at all** (only the opt-in, WASM-based `tree-sitter-loader.js` long-tail path below, which nothing in the taint engine consumes). Models: `fn` items (free/impl/trait, qualifiers, generics, `impl Type` methods named `Type.method`), `let`/destructuring/compound-assign, method chains (args accumulated innermost-first, so `Command::new("sh").arg("-c").arg(x)` has the literal program name at arg 0 regardless of the chain's terminal method), macros (`format!`/`write!`/etc. lower to a template with inline `{ident}` and positional args; `vec!` to an array; every other macro keeps its own `name!` callee so `sqlx::query!` — the compile-checked, parameterized macro — never collides with the `query` sink); `if`/`if let`/`while`/`while let`/`loop`/`for`/`match` (pattern bindings assigned from the scrutinee) including expression-position control flow (`let x = match … {}` yields a `union` of the branch tails); trailing closures inlined with their parameter bound to the receiver; extractor-typed parameters (`Query(q): Query<P>`) and route-attributed handlers (`#[get("/<x>")]`) emit `fn.paramAnnotations`. **`.unwrap()`/`.expect(msg)` are popped from the chain, not just taint-transparent** — leaving them in shifts the terminal segment away from the real sink name (`sqlx::query(&sql).fetch_all(pool).await.unwrap()` would otherwise end in `.unwrap`, never `.fetch_all`, and no catalog entry could ever match it), the same "terminal segment shift" defect class documented for Go/Java/Kotlin above; a macro-only statement whose sole continuation is a popped `.unwrap()` (`write!(w, "…", x).unwrap();`) still counts as a call, tracked via a separate `_baseIsCall` flag so it doesn't fall through to "no call happened here" once the passthrough is gone. Three self-scan-flagged regexes (`(?:#\[[^\]]*\]\s*)+`, the attribute-skip pattern, appearing at three call sites) match the "optional/quantified group flanked by `\s*`" ReDoS heuristic shape but are linear by construction — each iteration is bounded by the mandatory literal `#[`…`]`, no shared character class exists for the engine to backtrack across — confirmed by direct timing (50 000 back-to-back attributes, 200 000 non-matching whitespace chars: both single-digit milliseconds), not merely asserted; see the comment at `_parseParams` in `parser-rust.js`. Guarded by `test/parser-rust.test.js` (CFG shape) and `test/rust-taint-flow.test.js` (end-to-end taint flow + precision cases). **Measured `bench/layer-recall` impact: 0 → 5/5 (100%) whole-corpus, 0 → 3/3 (100%) deep-tier** on first measurement — the corpus is small and every entry is annotation-sourced (axum/actix/rocket extractor parameters route directly into `paramAnnotations`), so 100% reflects the corpus's own composition, not general Rust ecosystem coverage; see `docs/METRICS.md`'s own disclosure. |
|
|
35
|
+
| Long-tail (solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. Rust's own tree-sitter grammar remains loadable here too (unaffected by `parser-rust.js` existing), but the hand-rolled parser above is what feeds the taint engine — this path is now redundant for Rust specifically, kept only because removing a language from `tree-sitter-sinks.js`'s opt-in surface is out of scope for this change. |
|
|
35
36
|
|
|
36
37
|
## Python parser — dual-path with auto fallback
|
|
37
38
|
|
package/src/ir/balanced-call.js
CHANGED
|
@@ -22,12 +22,44 @@
|
|
|
22
22
|
// simply left unconsumed rather than corrupting anything. Recovering the
|
|
23
23
|
// first call's real signature is a strict improvement over the prior
|
|
24
24
|
// choice between silent corruption and no match at all.
|
|
25
|
-
export function matchBalancedCall(s, calleeRe) {
|
|
25
|
+
export function matchBalancedCall(s, calleeRe, opts) {
|
|
26
26
|
if (typeof s !== 'string') return null;
|
|
27
27
|
const m = calleeRe.exec(s);
|
|
28
28
|
if (!m || m.index !== 0) return null;
|
|
29
29
|
let i = m[0].length;
|
|
30
30
|
while (s[i] === ' ' || s[i] === '\t') i++;
|
|
31
|
+
// SARD_80_F1 W2.3 follow-up (opt-in via `opts.skipGenerics`, used only by
|
|
32
|
+
// parser-cs.js): C#'s `new List<string>()` / `new Dictionary<string,
|
|
33
|
+
// int>()` has a generic type-argument list BETWEEN the class name and the
|
|
34
|
+
// constructor's own `(`. Without this, `new\s+([\w.]+)` matches only
|
|
35
|
+
// "List", the character right after it is `<` (not `(`), and the whole
|
|
36
|
+
// match fails — the constructor call silently lowers to `{kind:'unknown'}`
|
|
37
|
+
// instead of a proper `isNew:true` call, which is why `List<T>` (C#'s
|
|
38
|
+
// dominant MODERN collection idiom, unlike the non-generic `ArrayList`
|
|
39
|
+
// fixed alongside this) never even reached the collection-taint mutator
|
|
40
|
+
// rule at all. Deliberately opt-in, not the default: `<`/`>` are also
|
|
41
|
+
// comparison operators, and Go/PHP/Ruby (this helper's other three
|
|
42
|
+
// callers) have no equivalent "generic-args-before-a-call" shape, so
|
|
43
|
+
// widening the default here would be pure unvalidated risk for them.
|
|
44
|
+
// Scans for BALANCED angle brackets only (handles nested generics like
|
|
45
|
+
// `Dictionary<string, List<int>>`); if it doesn't find a clean balanced
|
|
46
|
+
// `<...>` immediately here, this leaves `i` untouched and falls through
|
|
47
|
+
// to the ordinary `(`-required check below, same as before this existed.
|
|
48
|
+
if (opts && opts.skipGenerics && s[i] === '<') {
|
|
49
|
+
let j = i + 1;
|
|
50
|
+
let depth = 1;
|
|
51
|
+
while (j < s.length && depth > 0) {
|
|
52
|
+
if (s[j] === '<') depth++;
|
|
53
|
+
else if (s[j] === '>') depth--;
|
|
54
|
+
else if (s[j] === '(' || s[j] === ')' || s[j] === ';') { depth = -1; break; } // not a generic-args list — bail
|
|
55
|
+
j++;
|
|
56
|
+
}
|
|
57
|
+
if (depth === 0) {
|
|
58
|
+
let k = j;
|
|
59
|
+
while (s[k] === ' ' || s[k] === '\t') k++;
|
|
60
|
+
if (s[k] === '(') i = k;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
31
63
|
if (s[i] !== '(') return null;
|
|
32
64
|
const openIdx = i;
|
|
33
65
|
let depth = 0;
|
package/src/ir/callgraph.js
CHANGED
|
@@ -59,16 +59,40 @@ export function buildCallGraph(perFileIR, fileContents) {
|
|
|
59
59
|
// missing one.
|
|
60
60
|
const bareTailInFile = new Map(); // file -> Map<bareTail, qid|null>
|
|
61
61
|
|
|
62
|
+
// PRD W1 (SARD_80_F1_EXECUTION_PRD.md) — `--scramble-identifiers` renames
|
|
63
|
+
// every class to a lowercase `case_<hash>` token, so the qid's middle
|
|
64
|
+
// `::ClassName::` segment no longer starts with an uppercase letter even
|
|
65
|
+
// though it IS still a real, structurally-declared class. Wherever a
|
|
66
|
+
// file's own parser emits real `ir.classes` facts (Java/C#, today —
|
|
67
|
+
// Python/PHP/Ruby/Go/Kotlin don't yet, tracked as W1.1), trust THAT
|
|
68
|
+
// instead of a capitalization guess: a name is a real class if and only
|
|
69
|
+
// if it was independently parsed as a class declaration, which is exactly
|
|
70
|
+
// as true under scrambled names as under real ones (the class-declaration
|
|
71
|
+
// parser doesn't care about capitalization either). Falls back to the old
|
|
72
|
+
// PascalCase heuristic for files whose language doesn't emit ir.classes
|
|
73
|
+
// yet, so nothing regresses ahead of W1.1 landing per-language.
|
|
74
|
+
const knownClassNamesByFile = new Map();
|
|
75
|
+
for (const file of Object.keys(perFileIR || {})) {
|
|
76
|
+
const ir = perFileIR[file];
|
|
77
|
+
if (ir && Array.isArray(ir.classes) && ir.classes.length) {
|
|
78
|
+
knownClassNamesByFile.set(file, new Set(ir.classes.map(c => c.name)));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
for (const file of Object.keys(perFileIR || {})) {
|
|
63
83
|
const ir = perFileIR[file];
|
|
64
84
|
if (!ir || !ir.functions) continue;
|
|
65
85
|
byNameInFile.set(file, new Map());
|
|
66
86
|
bareTailInFile.set(file, new Map());
|
|
87
|
+
const knownClasses = knownClassNamesByFile.get(file);
|
|
67
88
|
for (const fn of ir.functions) {
|
|
68
89
|
functions.set(fn.qid, fn);
|
|
69
90
|
byNameInFile.get(file).set(fn.name, fn.qid);
|
|
70
|
-
const m = fn.qid.match(/::(
|
|
71
|
-
if (m)
|
|
91
|
+
const m = fn.qid.match(/::(\w+)::(\w+)@/);
|
|
92
|
+
if (m) {
|
|
93
|
+
const isRealClass = knownClasses ? knownClasses.has(m[1]) : /^[A-Z]/.test(m[1]);
|
|
94
|
+
if (isRealClass) classMethods.set(`${m[1]}.${m[2]}`, fn.qid);
|
|
95
|
+
}
|
|
72
96
|
if (fn.name && fn.name.includes('.')) {
|
|
73
97
|
const tail = fn.name.split('.').pop();
|
|
74
98
|
const fileMap = bareTailInFile.get(file);
|