@clear-capabilities/agentic-security-scanner 0.151.2 → 0.152.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 +184 -0
- package/dist/1905.index.js +30 -2
- package/dist/3499.index.js +20 -1
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -4
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +842 -17
- package/src/dataflow/engine.js +865 -90
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +18 -5
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +132 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/discovery/THREAT_MODEL.md +122 -0
- package/src/discovery/disprove.js +10 -1
- package/src/discovery/lenses.js +20 -1
- package/src/engine.js +108 -3
- 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 +119 -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 +527 -24
- 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/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
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
// are stable enough (Request.Query / Request.Form / Request.Headers /
|
|
11
11
|
// HttpContext.Request.* / IFormCollection / BinaryReader / etc.) that a
|
|
12
12
|
// simple forward-pass catches the vast majority. Misses on:
|
|
13
|
-
// - Aliased sources via method indirection (caller-supplied taint)
|
|
13
|
+
// - Aliased sources via method indirection (caller-supplied taint) —
|
|
14
|
+
// SARD_80_F1 W4.C41 closed the SAME-FILE, SINGLE-HOP case (a value
|
|
15
|
+
// tainted in a caller passed as an argument to a private/same-class
|
|
16
|
+
// helper — Juliet's own "data passed as an argument from one method
|
|
17
|
+
// to another in the same class" flow-variant idiom): see
|
|
18
|
+
// `analyzeCSharpIR`'s post-pass below. Still deliberately NOT
|
|
19
|
+
// modeled: a second hop (the helper passes it on to a THIRD method)
|
|
20
|
+
// and any cross-FILE call, both out of scope for this lightweight
|
|
21
|
+
// analyzer (the real, general interprocedural case is what the deep
|
|
22
|
+
// taint engine in `dataflow/` exists for).
|
|
14
23
|
// - Inheritance-resolved property reads
|
|
15
24
|
// - Generic constraints
|
|
16
25
|
// The Layer 4 LLM validator stage covers the residue when enabled.
|
|
@@ -101,6 +110,53 @@ function isSanitizedExpr(text) {
|
|
|
101
110
|
return false;
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
// SARD_80_F1 W3.x — `TAINT_SOURCE_PATTERNS`'s `StreamReader.ReadLine` /
|
|
114
|
+
// `BinaryReader.ReadString` entries require the LITERAL type name as the
|
|
115
|
+
// receiver, which real code never writes (`StreamReader.ReadLine()` isn't
|
|
116
|
+
// even a static method) — idiomatic C# always calls through an INSTANCE
|
|
117
|
+
// variable (`using (StreamReader sr = …) { sr.ReadLine() }`), so those
|
|
118
|
+
// patterns were effectively dead against this dominant shape. Exposed (not
|
|
119
|
+
// created) by the `resolveCalleeReturnTaint` precision fix above: the old
|
|
120
|
+
// identifier-name fallback used to accidentally re-taint the result via an
|
|
121
|
+
// unrelated already-tainted variable merely appearing in the call text
|
|
122
|
+
// (the exact over-tainting bug this fix removes), which happened to produce
|
|
123
|
+
// the right verdict here for the wrong reason. Mirrors
|
|
124
|
+
// `HTTP_TAINTED_PARAM_TYPES`'s type-based approach: a call through a
|
|
125
|
+
// variable whose DECLARED type is a reader class is a source regardless of
|
|
126
|
+
// the variable's name.
|
|
127
|
+
const READER_SOURCE_TYPES = /^(?:StreamReader|BinaryReader|TextReader)$/;
|
|
128
|
+
const READER_SOURCE_CALL_RE = /\b([A-Za-z_]\w*)\s*\.\s*(?:ReadLine|ReadToEnd|ReadString|ReadAllText|ReadAllLines)\s*\(/g;
|
|
129
|
+
// SARD_80_F1 W4.C34 — a second reader-shaped type/method pair, same class of
|
|
130
|
+
// gap as the StreamReader one above: `SqlDataReader dr = command.ExecuteReader();
|
|
131
|
+
// data = dr.GetString(1);` (Juliet's own idiomatic ADO.NET shape, confirmed
|
|
132
|
+
// via the public C# mirror, `CWE23_Relative_Path_Traversal__Database_01.cs`)
|
|
133
|
+
// is a real database-row read every bit as untrusted as a file/stream read,
|
|
134
|
+
// but `dataflow/catalog.js`'s OWN separate `cs-datareader-*` source entries
|
|
135
|
+
// (a completely different detector, the deep taint engine, not this file's
|
|
136
|
+
// lexical type-flow) require the LITERAL variable-name substring "reader" —
|
|
137
|
+
// investigated and found genuinely inert there too (W4.C32/W4.C33: giving
|
|
138
|
+
// THAT engine receiver-type confirmation is real but doesn't help here,
|
|
139
|
+
// since `classOfVar`'s CHA only resolves `new Foo()` allocations, never a
|
|
140
|
+
// factory-method return like `ExecuteReader()` — a separate, deeper
|
|
141
|
+
// limitation, not this file's concern). THIS detector already has exactly
|
|
142
|
+
// the right mechanism (declared-type lookup via `typeMap`, independent of
|
|
143
|
+
// variable name) — it just never had a DataReader entry.
|
|
144
|
+
const DATAREADER_SOURCE_TYPES = /^(?:Sql|OleDb|Odbc|MySql|Npgsql|Sqlite)DataReader$/;
|
|
145
|
+
const DATAREADER_SOURCE_CALL_RE = /\b([A-Za-z_]\w*)\s*\.\s*(?:GetString|GetValue|GetInt16|GetInt32|GetInt64|GetDateTime|GetDecimal|GetDouble|GetFloat|GetBoolean|GetGuid|GetChar|GetByte)\s*\(/g;
|
|
146
|
+
function _typedReaderSourceCall(text, typeMap) {
|
|
147
|
+
if (!text) return false;
|
|
148
|
+
READER_SOURCE_CALL_RE.lastIndex = 0;
|
|
149
|
+
let m;
|
|
150
|
+
while ((m = READER_SOURCE_CALL_RE.exec(text))) {
|
|
151
|
+
if (READER_SOURCE_TYPES.test(typeMap.get(m[1]) || '')) return true;
|
|
152
|
+
}
|
|
153
|
+
DATAREADER_SOURCE_CALL_RE.lastIndex = 0;
|
|
154
|
+
while ((m = DATAREADER_SOURCE_CALL_RE.exec(text))) {
|
|
155
|
+
if (DATAREADER_SOURCE_TYPES.test(typeMap.get(m[1]) || '')) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
104
160
|
// For each SANITIZER_PATTERNS match in `text`, find the nearest `(...)` call
|
|
105
161
|
// span after the match and return its [start, end) bounds. Used to tell
|
|
106
162
|
// "the sanitizer call actually wraps the tainted value" (HtmlEncode(x)) apart
|
|
@@ -154,6 +210,43 @@ function _refEscapesSanitizers(text, ref, spans) {
|
|
|
154
210
|
// HttpRequest, the data inside it is by definition user-controlled.
|
|
155
211
|
const HTTP_TAINTED_PARAM_TYPES = /^(?:HttpRequest(?:Base|Message)?|HttpListenerRequest|HttpResponseBase|HttpResponse|HttpResponseMessage|HttpContext(?:Base)?|IPrincipal|HttpListenerContext|HttpServletRequest|HttpServletResponse|IFormCollection|IFormFile|IFormFileCollection|Stream|StreamReader|BinaryReader|TextReader|HttpListener)$/;
|
|
156
212
|
|
|
213
|
+
// SARD_80_F1 W3.x — is `text` (the ENTIRE trimmed expression, not merely a
|
|
214
|
+
// substring) exactly one call `Name(args…)`, with nothing before the name
|
|
215
|
+
// (besides an optional `this.`) and nothing after the matching close-paren?
|
|
216
|
+
// Used to decide when a call-shaped RHS/return can be resolved against its
|
|
217
|
+
// OWN callee's return-taint verdict instead of the identifier-name fallback
|
|
218
|
+
// below (which cannot distinguish "the callee's return derives from this
|
|
219
|
+
// argument" from "this argument's tainted NAME merely appears in the call
|
|
220
|
+
// text", the latter being exactly W4.J17's documented `argIsTainted`
|
|
221
|
+
// over-tainting bug: `data = GoodG2BSource(req, resp);` taints `data`
|
|
222
|
+
// purely because `req`/`resp` are unconditionally-tainted HTTP-typed
|
|
223
|
+
// params, even though `GoodG2BSource` never reads them).
|
|
224
|
+
function _asBareCall(text) {
|
|
225
|
+
if (!text) return null;
|
|
226
|
+
const t = text.trim();
|
|
227
|
+
const m = t.match(/^(?:this\s*\.\s*)?([A-Za-z_]\w*)\s*\(/);
|
|
228
|
+
if (!m) return null;
|
|
229
|
+
const openIdx = t.indexOf('(', m[0].length - 1);
|
|
230
|
+
let depth = 0;
|
|
231
|
+
for (let i = openIdx; i < t.length; i++) {
|
|
232
|
+
if (t[i] === '(') depth++;
|
|
233
|
+
else if (t[i] === ')') {
|
|
234
|
+
depth--;
|
|
235
|
+
if (depth === 0) return i === t.length - 1 ? m[1] : null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The pre-existing "any tainted identifier's name appears in the text"
|
|
242
|
+
// fallback, factored out so both the propagation loop and the return-taint
|
|
243
|
+
// computation below share one definition.
|
|
244
|
+
function _identRefTainted(text, taintMap) {
|
|
245
|
+
const refs = (text.match(/\b[A-Za-z_]\w*\b/g) || []);
|
|
246
|
+
for (const ref of refs) if (taintMap.get(ref)) return true;
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
157
250
|
function analyzeMethodFlow(method, opts = {}) {
|
|
158
251
|
const typeMap = new Map();
|
|
159
252
|
const taintMap = new Map();
|
|
@@ -167,62 +260,109 @@ function analyzeMethodFlow(method, opts = {}) {
|
|
|
167
260
|
// ADDITIONALLY: any parameter whose TYPE is an HTTP context type
|
|
168
261
|
// (HttpRequest, HttpResponse, IFormCollection, …) is tainted regardless
|
|
169
262
|
// of opts — the data IN those types is by definition user-controlled.
|
|
263
|
+
// `opts.taintedParamNames` (SARD_80_F1 W4.C41, optional Set<string>) is
|
|
264
|
+
// the SAME-FILE, single-hop caller-argument-taint case referenced above:
|
|
265
|
+
// `analyzeCSharpIR`'s post-pass computes, from an ALREADY-computed
|
|
266
|
+
// caller flow, which of THIS method's own parameter names were passed a
|
|
267
|
+
// tainted argument at some real call site, and re-invokes this function
|
|
268
|
+
// with that set so the normal forward pass below propagates it through
|
|
269
|
+
// this method's body exactly as it would a route-handler-tainted param.
|
|
170
270
|
const paramsTainted = !!opts.treatParamsAsTainted;
|
|
271
|
+
const taintedParamNames = opts.taintedParamNames || null;
|
|
171
272
|
for (const p of method.params || []) {
|
|
172
273
|
typeMap.set(p.name, p.type);
|
|
173
274
|
const typeBase = String(p.type || '').replace(/\?$/, '').replace(/<.*$/, '');
|
|
174
275
|
const isHttpTaintedType = HTTP_TAINTED_PARAM_TYPES.test(typeBase);
|
|
175
|
-
|
|
276
|
+
const isCallerArgTainted = !!(taintedParamNames && taintedParamNames.has(p.name));
|
|
277
|
+
if (paramsTainted || isHttpTaintedType || isCallerArgTainted) {
|
|
176
278
|
taintMap.set(p.name, true);
|
|
177
279
|
sourceLines.set(p.name, method.line);
|
|
178
280
|
}
|
|
179
281
|
}
|
|
180
282
|
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
283
|
+
// Single forward pass, in TRUE source-line order, over decls AND
|
|
284
|
+
// assignments merged together.
|
|
285
|
+
//
|
|
286
|
+
// Previously this ran as two SEPARATE forward passes — all of
|
|
287
|
+
// method.decls, then all of method.assignments — each individually in
|
|
288
|
+
// source order, but with no ordering between the two lists. Juliet's own
|
|
289
|
+
// universal C# convention (confirmed via the public Juliet C# mirror this
|
|
290
|
+
// project's SARD manifest pins) declares a source variable bare, then
|
|
291
|
+
// assigns it on the next line: `string data; data = Environment.
|
|
292
|
+
// GetEnvironmentVariable("ADD");` — a plain decl (no rhsText, doesn't
|
|
293
|
+
// taint) followed by a SEPARATE ASSIGNMENT (which does). Any LATER
|
|
294
|
+
// declare-with-initializer that reads that variable (`string[] tokens =
|
|
295
|
+
// data.Split(...);`) is a decl, processed in the FIRST loop — before the
|
|
296
|
+
// assignment that actually taints `data` had run in the SECOND loop — so
|
|
297
|
+
// the propagation check saw `data` as not-yet-tainted and the dependent
|
|
298
|
+
// variable silently stayed clean, regardless of true source order.
|
|
299
|
+
// Reproduced down to a minimal 100%-failing case (SARD_80_F1 W4.C8) and
|
|
300
|
+
// confirmed real (not Juliet-specific): any C# code that separately
|
|
301
|
+
// assigns a source, then derives a value from it in a later declared
|
|
302
|
+
// variable, hits this. Fixing it can only ADD correctly-propagated taint
|
|
303
|
+
// (this model has no un-taint step — a value that starts tainted stays
|
|
304
|
+
// tainted — so re-ordering never removes a propagation that used to fire).
|
|
305
|
+
const decls = (method.decls || []).map(d => ({ kind: 'decl', line: d.line, item: d }));
|
|
306
|
+
const assigns = (method.assignments || []).map(a => ({ kind: 'assign', line: a.line, item: a }));
|
|
307
|
+
const merged = decls.concat(assigns).sort((x, y) => x.line - y.line);
|
|
308
|
+
for (const { kind, item } of merged) {
|
|
309
|
+
const targetKey = kind === 'decl' ? item.name : item.fullTarget;
|
|
310
|
+
const rhsText = item.rhsText;
|
|
311
|
+
if (kind === 'decl') {
|
|
312
|
+
if (item.type && item.type !== 'var') typeMap.set(item.name, item.type);
|
|
313
|
+
else if (item.isVar && rhsText) {
|
|
314
|
+
// Best-effort type inference for `var x = new T(...)`.
|
|
315
|
+
const m = rhsText.match(/^\s*new\s+([\w.<>?\[\],\s]+?)\s*\(/);
|
|
316
|
+
if (m) typeMap.set(item.name, m[1].trim());
|
|
203
317
|
}
|
|
204
318
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// Then assignments — same forward propagation rules.
|
|
208
|
-
for (const a of method.assignments || []) {
|
|
209
|
-
if (!a.rhsText) continue;
|
|
210
|
-
const targetKey = a.fullTarget;
|
|
211
|
-
if (isSourceExpr(a.rhsText) && !isSanitizedExpr(a.rhsText)) {
|
|
319
|
+
if (!rhsText) continue;
|
|
320
|
+
if ((isSourceExpr(rhsText) || _typedReaderSourceCall(rhsText, typeMap)) && !isSanitizedExpr(rhsText)) {
|
|
212
321
|
taintMap.set(targetKey, true);
|
|
213
|
-
sourceLines.set(targetKey,
|
|
322
|
+
sourceLines.set(targetKey, item.line);
|
|
214
323
|
continue;
|
|
215
324
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
325
|
+
// Propagation via a resolvable same-file call: consult the callee's OWN
|
|
326
|
+
// return-taint verdict (SARD_80_F1 W3.x) instead of the identifier-name
|
|
327
|
+
// fallback, which cannot tell "the callee's return actually derives
|
|
328
|
+
// from this argument" apart from "this argument's tainted NAME merely
|
|
329
|
+
// appears in the call text" — see `_asBareCall`'s header comment.
|
|
330
|
+
const bareCallName = _asBareCall(rhsText);
|
|
331
|
+
let resolved = null;
|
|
332
|
+
if (bareCallName && opts.resolveCalleeReturnTaint) {
|
|
333
|
+
resolved = opts.resolveCalleeReturnTaint(bareCallName);
|
|
334
|
+
}
|
|
335
|
+
if (resolved !== null) {
|
|
336
|
+
if (resolved) { taintMap.set(targetKey, true); sourceLines.set(targetKey, item.line); }
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// Fallback: rhs references a tainted var → lhs becomes tainted. Reached
|
|
340
|
+
// for non-call RHS shapes, and for calls the resolver couldn't resolve
|
|
341
|
+
// (external/library callee, overload ambiguity, or a dependency cycle)
|
|
342
|
+
// — recall-preserving: an unresolved call keeps the old, more liberal
|
|
343
|
+
// behavior rather than silently going quiet.
|
|
344
|
+
if (_identRefTainted(rhsText, taintMap)) {
|
|
345
|
+
taintMap.set(targetKey, true);
|
|
346
|
+
sourceLines.set(targetKey, item.line);
|
|
223
347
|
}
|
|
224
348
|
}
|
|
225
|
-
return
|
|
349
|
+
// Per-return taint verdict, so a CALLER can resolve a call to THIS method
|
|
350
|
+
// via the same mechanism (see `resolveCalleeReturnTaint` in
|
|
351
|
+
// `analyzeCSharpIR`). Same three-way resolution as the propagation loop
|
|
352
|
+
// above: an explicit source wins, then a resolvable nested call, then the
|
|
353
|
+
// identifier-name fallback.
|
|
354
|
+
const returns = (method.returns || []).map((r) => {
|
|
355
|
+
let tainted;
|
|
356
|
+
if ((isSourceExpr(r.exprText) || _typedReaderSourceCall(r.exprText, typeMap)) && !isSanitizedExpr(r.exprText)) {
|
|
357
|
+
tainted = true;
|
|
358
|
+
} else {
|
|
359
|
+
const bareCallName = _asBareCall(r.exprText);
|
|
360
|
+
const resolved = bareCallName && opts.resolveCalleeReturnTaint ? opts.resolveCalleeReturnTaint(bareCallName) : null;
|
|
361
|
+
tainted = resolved !== null ? resolved : _identRefTainted(r.exprText, taintMap);
|
|
362
|
+
}
|
|
363
|
+
return { line: r.line, tainted };
|
|
364
|
+
});
|
|
365
|
+
return { typeMap, taintMap, sourceLines, returns };
|
|
226
366
|
}
|
|
227
367
|
|
|
228
368
|
// Attribute → route classifier. Each entry maps an attribute name to
|
|
@@ -250,7 +390,7 @@ function extractPath(argsRaw, argIdx) {
|
|
|
250
390
|
return null;
|
|
251
391
|
}
|
|
252
392
|
|
|
253
|
-
export function analyzeCSharpIR(ir) {
|
|
393
|
+
export function analyzeCSharpIR(ir, opts = {}) {
|
|
254
394
|
// Class-level attribute roll-up.
|
|
255
395
|
const classAuth = new Map(); // class-ref → { authedAtClass, anonymousAtClass, isController }
|
|
256
396
|
for (const c of ir.classes) {
|
|
@@ -278,6 +418,21 @@ export function analyzeCSharpIR(ir) {
|
|
|
278
418
|
const methodFlow = new Map();
|
|
279
419
|
const methodToClass = new Map();
|
|
280
420
|
for (const c of ir.classes) for (const m of c.methods) methodToClass.set(m, c);
|
|
421
|
+
const classesByName = new Map();
|
|
422
|
+
for (const c of ir.classes) classesByName.set(c.name, c);
|
|
423
|
+
// SARD_80_F1 W3.x — resolve a same-file call's return-taint verdict
|
|
424
|
+
// instead of letting `analyzeMethodFlow`'s identifier-name fallback treat
|
|
425
|
+
// ANY tainted identifier appearing in the call text (including an
|
|
426
|
+
// argument the callee never reads) as tainting the result. Bare-name
|
|
427
|
+
// keyed (this codebase's call sites don't carry receiver/overload info to
|
|
428
|
+
// disambiguate further); a name with 0 or 2+ candidates is unresolvable
|
|
429
|
+
// (`null`, meaning "fall back to the old heuristic") rather than guessed.
|
|
430
|
+
const methodsByName = new Map();
|
|
431
|
+
for (const m of ir.methods) {
|
|
432
|
+
if (!methodsByName.has(m.name)) methodsByName.set(m.name, []);
|
|
433
|
+
methodsByName.get(m.name).push(m);
|
|
434
|
+
}
|
|
435
|
+
const treatParamsAsTaintedByMethod = new Map();
|
|
281
436
|
for (const m of ir.methods) {
|
|
282
437
|
const attrNames = (m.attrs || []).map(x => x.name);
|
|
283
438
|
const isRouteAttr = attrNames.some(n => ROUTE_ATTRS[n]);
|
|
@@ -285,8 +440,151 @@ export function analyzeCSharpIR(ir) {
|
|
|
285
440
|
const classIsController = cls ? !!classAuth.get(cls)?.isController : false;
|
|
286
441
|
const classHasApiAttr = cls && (cls.attrs || []).some(a => a.name === 'ApiController' || a.name === 'Route');
|
|
287
442
|
const isPublic = !m.modifiers || m.modifiers.includes('public') || (!m.modifiers.includes('private') && !m.modifiers.includes('protected') && !m.modifiers.includes('internal'));
|
|
288
|
-
|
|
289
|
-
|
|
443
|
+
treatParamsAsTaintedByMethod.set(m, (isRouteAttr || classHasApiAttr || classIsController) && isPublic);
|
|
444
|
+
}
|
|
445
|
+
const calleeFlowCache = new Map();
|
|
446
|
+
const calleeReturnTaintMemo = new Map();
|
|
447
|
+
const resolvingStack = new Set();
|
|
448
|
+
function computeFlow(m) {
|
|
449
|
+
let flow = calleeFlowCache.get(m);
|
|
450
|
+
if (!flow) {
|
|
451
|
+
flow = analyzeMethodFlow(m, { treatParamsAsTainted: treatParamsAsTaintedByMethod.get(m), resolveCalleeReturnTaint });
|
|
452
|
+
calleeFlowCache.set(m, flow);
|
|
453
|
+
}
|
|
454
|
+
return flow;
|
|
455
|
+
}
|
|
456
|
+
function resolveCalleeReturnTaint(name) {
|
|
457
|
+
if (calleeReturnTaintMemo.has(name)) return calleeReturnTaintMemo.get(name);
|
|
458
|
+
const candidates = methodsByName.get(name);
|
|
459
|
+
if (!candidates || candidates.length !== 1) { calleeReturnTaintMemo.set(name, null); return null; }
|
|
460
|
+
const callee = candidates[0];
|
|
461
|
+
// Cycle guard (self- or mutually-recursive calls): unresolvable for
|
|
462
|
+
// THIS resolution, deliberately not memoized so a later, non-cyclic
|
|
463
|
+
// call to the same name can still resolve normally.
|
|
464
|
+
if (resolvingStack.has(callee)) return null;
|
|
465
|
+
resolvingStack.add(callee);
|
|
466
|
+
const flow = computeFlow(callee);
|
|
467
|
+
resolvingStack.delete(callee);
|
|
468
|
+
const tainted = flow.returns && flow.returns.length ? flow.returns.some(r => r.tainted) : null;
|
|
469
|
+
calleeReturnTaintMemo.set(name, tainted);
|
|
470
|
+
return tainted;
|
|
471
|
+
}
|
|
472
|
+
for (const m of ir.methods) methodFlow.set(m, computeFlow(m));
|
|
473
|
+
|
|
474
|
+
// SARD_80_F1 W4.C41 — same-file, single-hop caller-argument taint.
|
|
475
|
+
// Every detector in csharp.js reads `analysis.methodFlow.get(m)` and
|
|
476
|
+
// that flow's own `taintMap` only ever seeds from THIS method's own
|
|
477
|
+
// params (route-handler convention / HTTP-typed param) — never from
|
|
478
|
+
// what a CALLER actually passed it. Juliet's own "Flow Variant NN: data
|
|
479
|
+
// passed as an argument from one method to another in the same class"
|
|
480
|
+
// idiom (a route handler reads a source, then calls a private static
|
|
481
|
+
// sink-wrapper helper with it) was therefore invisible to every
|
|
482
|
+
// per-method detector here, confirmed via a standalone `runScan`
|
|
483
|
+
// reproduction of the exact real-corpus shape (CWE36's own
|
|
484
|
+
// `Params_Get_Web_41.cs`): the DIRECT single-method form of the same
|
|
485
|
+
// sink already fired correctly; the moment the tainted value crossed a
|
|
486
|
+
// same-file method-call boundary, it vanished with zero findings from
|
|
487
|
+
// any detector, structural or deep-engine.
|
|
488
|
+
//
|
|
489
|
+
// Deliberately conservative, matching this analyzer's own "single
|
|
490
|
+
// forward pass" simplicity rather than a full fixed point: (1) SINGLE
|
|
491
|
+
// HOP only — a callee that itself forwards the value to a THIRD method
|
|
492
|
+
// is not covered (mirrors `resolveCalleeReturnTaint`'s own scope, which
|
|
493
|
+
// is the established precedent for interprocedural resolution in this
|
|
494
|
+
// file); (2) SAME-FILE, unambiguous-name resolution only, via the same
|
|
495
|
+
// `methodsByName` index `resolveCalleeReturnTaint` already uses — a
|
|
496
|
+
// callee name with zero or 2+ same-named candidates project-wide is
|
|
497
|
+
// left unresolved (permissive: the finding is simply not gained, never
|
|
498
|
+
// a false suppression); (3) self-recursive calls are skipped (a
|
|
499
|
+
// parameter's own taint state within its own body is already handled
|
|
500
|
+
// by the normal forward pass, and re-seeding it from itself adds
|
|
501
|
+
// nothing); (4) this recomputes the DIRECT callee's flow only — it does
|
|
502
|
+
// NOT retroactively fix up `resolveCalleeReturnTaint`'s memo for any
|
|
503
|
+
// caller further up a chain that already resolved this callee's
|
|
504
|
+
// pre-this-pass return-taint verdict, an accepted, narrow gap given the
|
|
505
|
+
// single-hop scope above.
|
|
506
|
+
// SARD_80_F1 W5.31 — genuine cross-FILE extension of W5.30's same-file
|
|
507
|
+
// cross-class fix. This function has no visibility beyond its own `ir`
|
|
508
|
+
// (one file), so it cannot itself discover that `Sink.BadSink(data, …)`
|
|
509
|
+
// resolves to a static method declared in a DIFFERENT file — that
|
|
510
|
+
// project-wide resolution now happens one layer up, in
|
|
511
|
+
// `sast/csharp-cross-file.js`, which parses every `.cs` file in the
|
|
512
|
+
// project, finds a caller's tainted argument passed to a receiver-
|
|
513
|
+
// qualified call whose class lives in ANOTHER file, and re-invokes this
|
|
514
|
+
// function on the CALLEE's own file with the result seeded here via
|
|
515
|
+
// `opts.externalTaintedParams` (a `Map<methodObject, Set<paramName>>`,
|
|
516
|
+
// keyed by an object reference into THIS SAME `ir.methods` array — the
|
|
517
|
+
// caller resolves the callee from its own freshly-parsed copy of this
|
|
518
|
+
// exact `ir`, so the reference always matches). Seeding happens BEFORE
|
|
519
|
+
// the same-file loop below runs, and the two never conflict: the
|
|
520
|
+
// same-file loop only ever ADDS to whatever Set is already present for a
|
|
521
|
+
// callee (`if (!taintedParamNamesByMethod.has(callee)) …set(callee, new
|
|
522
|
+
// Set())`), so a param already seeded from across files is simply
|
|
523
|
+
// preserved, and a param seeded here that the same-file loop separately
|
|
524
|
+
// also proves tainted collapses harmlessly into the same Set entry.
|
|
525
|
+
const taintedParamNamesByMethod = new Map();
|
|
526
|
+
if (opts.externalTaintedParams) {
|
|
527
|
+
for (const [m, names] of opts.externalTaintedParams) {
|
|
528
|
+
taintedParamNamesByMethod.set(m, new Set(names));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
for (const caller of ir.methods) {
|
|
532
|
+
const callerFlow = methodFlow.get(caller);
|
|
533
|
+
if (!callerFlow) continue;
|
|
534
|
+
for (const call of caller.calls || []) {
|
|
535
|
+
let candidates;
|
|
536
|
+
if (call.receiver) {
|
|
537
|
+
// SARD_80_F1 W5.30 — a receiver-qualified call (`Sink.BadSink(data,
|
|
538
|
+
// …)`) was unconditionally skipped here, same as any OTHER
|
|
539
|
+
// receiver-bearing call — Juliet's own cross-class "Flow Variant 51:
|
|
540
|
+
// data passed as an argument from one method to another in
|
|
541
|
+
// different classes in the same package" idiom (confirmed via the
|
|
542
|
+
// public C# mirror's own
|
|
543
|
+
// CWE36_..._Params_Get_Web_51a.cs/_51b.cs pair) was therefore
|
|
544
|
+
// invisible here even in the SAME FILE, let alone across files —
|
|
545
|
+
// confirmed by direct `runScan` reproduction showing ZERO findings
|
|
546
|
+
// from any detector for this shape, structural or deep-engine,
|
|
547
|
+
// before this fix. Resolvable ONLY when `call.receiver` is a REAL
|
|
548
|
+
// class declared in THIS file (`classesByName`, never an instance
|
|
549
|
+
// variable this analyzer has no type-tracking for) AND the matched
|
|
550
|
+
// candidate is `static` — the second check is load-bearing, not
|
|
551
|
+
// redundant: C# cannot compile `ClassName.InstanceMethod()` without
|
|
552
|
+
// an object reference, so if the resolved candidate is NOT static,
|
|
553
|
+
// `call.receiver`'s text is far more likely a coincidentally
|
|
554
|
+
// same-named INSTANCE VARIABLE than a genuine static-class
|
|
555
|
+
// qualifier, and treating it as one would silently invent a data-flow
|
|
556
|
+
// edge that doesn't exist — exactly the risk `byQname`'s own header
|
|
557
|
+
// comment in `callgraph.js` warns against for a structurally
|
|
558
|
+
// analogous ambiguity. Deliberately single-hop and same-file only,
|
|
559
|
+
// matching every other interprocedural mechanism in this file —
|
|
560
|
+
// cross-FILE resolution is a separate, larger undertaking (the deep
|
|
561
|
+
// taint engine's own `callgraph.js` already resolves cross-file
|
|
562
|
+
// static calls project-wide; this structural analyzer does not).
|
|
563
|
+
const cls = classesByName.get(call.receiver);
|
|
564
|
+
candidates = cls
|
|
565
|
+
? (methodsByName.get(call.method) || []).filter(m => methodToClass.get(m) === cls && (m.modifiers || []).includes('static'))
|
|
566
|
+
: null;
|
|
567
|
+
} else {
|
|
568
|
+
candidates = methodsByName.get(call.method);
|
|
569
|
+
}
|
|
570
|
+
if (!candidates || candidates.length !== 1) continue;
|
|
571
|
+
const callee = candidates[0];
|
|
572
|
+
if (callee === caller) continue;
|
|
573
|
+
for (let i = 0; i < (call.args || []).length; i++) {
|
|
574
|
+
const param = (callee.params || [])[i];
|
|
575
|
+
if (!param) continue;
|
|
576
|
+
if (!argIsTainted(callerFlow, call.args[i])) continue;
|
|
577
|
+
if (!taintedParamNamesByMethod.has(callee)) taintedParamNamesByMethod.set(callee, new Set());
|
|
578
|
+
taintedParamNamesByMethod.get(callee).add(param.name);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
for (const [callee, taintedParamNames] of taintedParamNamesByMethod) {
|
|
583
|
+
methodFlow.set(callee, analyzeMethodFlow(callee, {
|
|
584
|
+
treatParamsAsTainted: treatParamsAsTaintedByMethod.get(callee),
|
|
585
|
+
resolveCalleeReturnTaint,
|
|
586
|
+
taintedParamNames,
|
|
587
|
+
}));
|
|
290
588
|
}
|
|
291
589
|
// Route detection.
|
|
292
590
|
const routes = [];
|
|
@@ -19,11 +19,14 @@ const _CWE_FAMILY = {
|
|
|
19
19
|
'CWE-90': 'ldap-injection',
|
|
20
20
|
'CWE-91': 'xpath-injection',
|
|
21
21
|
'CWE-94': 'code-injection',
|
|
22
|
+
'CWE-95': 'code-injection',
|
|
23
|
+
'CWE-98': 'code-injection',
|
|
22
24
|
'CWE-22': 'path-traversal',
|
|
23
25
|
'CWE-23': 'path-traversal',
|
|
24
26
|
'CWE-36': 'path-traversal',
|
|
25
27
|
'CWE-200': 'info-disclosure',
|
|
26
28
|
'CWE-209': 'info-disclosure',
|
|
29
|
+
'CWE-134': 'format-string',
|
|
27
30
|
'CWE-256': 'hardcoded-secret',
|
|
28
31
|
'CWE-259': 'hardcoded-secret',
|
|
29
32
|
'CWE-287': 'broken-auth',
|
|
@@ -40,6 +43,7 @@ const _CWE_FAMILY = {
|
|
|
40
43
|
'CWE-352': 'csrf',
|
|
41
44
|
'CWE-384': 'session-fixation',
|
|
42
45
|
'CWE-434': 'unrestricted-upload',
|
|
46
|
+
'CWE-470': 'code-injection',
|
|
43
47
|
'CWE-502': 'insecure-deserialization',
|
|
44
48
|
'CWE-601': 'open-redirect',
|
|
45
49
|
'CWE-611': 'xxe',
|
|
@@ -50,7 +54,7 @@ const _CWE_FAMILY = {
|
|
|
50
54
|
'CWE-776': 'xxe',
|
|
51
55
|
'CWE-798': 'hardcoded-secret',
|
|
52
56
|
'CWE-829': 'supply-chain',
|
|
53
|
-
'CWE-862': '
|
|
57
|
+
'CWE-862': 'missing-authz',
|
|
54
58
|
'CWE-863': 'broken-authz',
|
|
55
59
|
'CWE-915': 'mass-assignment',
|
|
56
60
|
'CWE-918': 'ssrf',
|
|
@@ -30,6 +30,7 @@ export const DECLINED_TO_FIX = Object.freeze({
|
|
|
30
30
|
'broken-access-control': 'the correct authorisation rule is a product decision — a scanner that invents one is guessing at intent, and a wrong authz patch fails open.',
|
|
31
31
|
'idor': 'same as broken-access-control: which identity may read which record is not recoverable from the code.',
|
|
32
32
|
'broken-authz': 'the rule that was checked wrongly is a product decision; patching it from the code alone guesses at which roles may do what, and guessing fails open.',
|
|
33
|
+
'missing-authz': 'the rule that was checked wrongly is a product decision; patching it from the code alone guesses at which roles may do what, and guessing fails open.',
|
|
33
34
|
'business-logic': 'by definition the defect is a mismatch with intent, and intent is not in the file.',
|
|
34
35
|
'concurrency-bug': 'the correct lock discipline depends on the whole call graph; a local patch can deadlock rather than fix.',
|
|
35
36
|
'license-graph': 'a licence conflict is resolved by a policy or a dependency decision, not by editing code.',
|
|
@@ -41,6 +41,7 @@ export const INDETERMINATE_BY_CLASS = Object.freeze({
|
|
|
41
41
|
'idor': 'proving it means showing user A read user B\'s record, which needs two authenticated identities and a populated data store. A single-shot harness would invent both, and a PoC built on invented state proves something about the invention.',
|
|
42
42
|
'broken-access-control': 'same as idor — requires two identities and real state.',
|
|
43
43
|
'broken-authz': 'same as idor — requires two identities and real state.',
|
|
44
|
+
'missing-authz': 'same as idor — requires two identities and real state.',
|
|
44
45
|
'ssrf': 'the proof is that the server fetched an attacker-named host. The sandbox denies egress by design, so a failed fetch is confinement talking, not the finding.',
|
|
45
46
|
'xss': 'needs a browser to say whether the payload executed; a marker file cannot observe a DOM.',
|
|
46
47
|
'mutation-xss': 'needs a DOM and a parser round-trip — same limit as xss.',
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -13,7 +13,7 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
|
|
|
13
13
|
|
|
14
14
|
## What lives here, by category
|
|
15
15
|
|
|
16
|
-
**Language-specific** — `cpp.js`, `csharp.js`, `csharp-structural.js` (regex structural: hardcoded-secret incl. split-concat + guarded SSRF — complements the flow-based `csharp.js`), `dart-flutter.js`, `go-extended.js`, `java-deserialization.js`, `java-structural.js` (regex structural SQLi/cmdi/path/SSRF via concat, with path/SSRF guards — complements the AST/flow Java modules), `kotlin.js` (Kotlin idioms + **taint-independent structural injection** detectors: SQLi/cmdi/path via string template/concat, SSRF (guarded), XXE (insecure XML config), ObjectInputStream deser — closes the corpus Kotlin FNs where a tainted-by-convention param has no in-file source), `php.js` (+ structural SQLi/cmdi via concat/`$`-interp: DB::raw/whereRaw, shell_exec/exec; structural path traversal: readfile/file_get_contents/fopen with concat), `python-sinks.js`, `ruby.js` (+ structural ActiveRecord SQLi via `#{}`/concat, backtick/system cmdi, and File/IO path traversal via interpolation/concat; **plus `scanRubyPathJoin` (PRD F1.3)** — `File.join(<root>, …, <variable>)` reaching a filesystem operation with no traversal guard, which the interpolation rule cannot reach because it requires a string LITERAL as the first component while the real advisories join variables. Refuses a constant root (`__dir__`/`Rails.root`/`Dir.pwd`), a literal last component, and a join that never reaches the filesystem; any containment guard in the window silences it, since that guard is the fix. FP budget measured at 41 findings across 3,782 real `.rb` files in 128 packages), `rust.js
|
|
16
|
+
**Language-specific** — `cpp.js`, `csharp.js`, `csharp-cross-file.js` (SARD_80_F1 W5.31 — project-wide pass, run once after the main per-file loop like `db-taint.js`'s own `scanDbTaintCrossFile`: parses every `.cs` file, builds a project-wide class registry, and resolves a receiver-qualified call whose class lives in a DIFFERENT file by re-running `csharp.js`'s detector suite — via the newly-exported `runCSharpDetectors` — against that file with the parameter seeded as externally tainted via `csharp-analysis.js`'s `analyzeCSharpIR(ir, {externalTaintedParams})` option; only genuinely NEW findings (absent from that file's own ordinary, un-seeded scan) are reported, so nothing is ever double-counted. Single-hop, unambiguous-class/unambiguous-static-method only — the cross-file counterpart of `csharp-analysis.js`'s own same-file cross-class fix), `csharp-structural.js` (regex structural: hardcoded-secret incl. split-concat + guarded SSRF — complements the flow-based `csharp.js`), `dart-flutter.js`, `go-extended.js`, `java-deserialization.js`, `java-structural.js` (regex structural SQLi/cmdi/path/SSRF via concat, with path/SSRF guards — complements the AST/flow Java modules), `kotlin.js` (Kotlin idioms + **taint-independent structural injection** detectors: SQLi/cmdi/path via string template/concat, SSRF (guarded), XXE (insecure XML config), ObjectInputStream deser — closes the corpus Kotlin FNs where a tainted-by-convention param has no in-file source), `php.js` (+ structural SQLi/cmdi via concat/`$`-interp: DB::raw/whereRaw, shell_exec/exec; structural path traversal: readfile/file_get_contents/fopen with concat), `python-sinks.js`, `ruby.js` (+ structural ActiveRecord SQLi via `#{}`/concat, backtick/system cmdi, and File/IO path traversal via interpolation/concat; **plus `scanRubyPathJoin` (PRD F1.3)** — `File.join(<root>, …, <variable>)` reaching a filesystem operation with no traversal guard, which the interpolation rule cannot reach because it requires a string LITERAL as the first component while the real advisories join variables. Refuses a constant root (`__dir__`/`Rails.root`/`Dir.pwd`), a literal last component, and a join that never reaches the filesystem; any containment guard in the window silences it, since that guard is the fix. FP budget measured at 41 findings across 3,782 real `.rb` files in 128 packages), `rust.js` (TLS verification disabled, weak hash/cipher, weak RNG feeding a token/secret identifier, unsafe raw-memory ops (`transmute`/`from_raw_parts`/`get_unchecked`) — complements the flow-based Rust taint coverage in `dataflow/catalog.js`/`ir/parser-rust.js` for SQLi/cmdi/path/SSRF/XSS/log-injection/deserialization, which Rust gets in full since its SARD 80% F1 push addition as a first-class language), `solidity.js`, `swift.js`, `xxe.js` (CWE-611 for Java/Python **and** PHP/Go/Ruby — each non-JVM stack is XXE-safe by default, so it flags the explicit external-entity opt-in: PHP `LIBXML_NOENT`/`LIBXML_DTDLOAD`, Go `xml.Decoder` `Strict=false`/custom `Entity`, Ruby Nokogiri `noent`/`dtdload`/`replace_entities`; default-safe parses don't match).
|
|
17
17
|
|
|
18
18
|
> **Structural-detector pattern (Tier 1 recall).** `kotlin.js`/`ruby.js`/`php.js` carry taint-independent rules: a dangerous sink built with string interpolation/concat is the injection shape regardless of variable names. This closes corpus FNs where a value is routed through a local var (`params[:x]` → `where("…#{x}")`) so the taint engine sees no source. Keep them high-precision: parameterized / array-form / literal variants must NOT match. Verify on the cve-replay `pre/`+`post/` pairs.
|
|
19
19
|
|