@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
  51. package/src/dataflow/smt-feasibility.js +0 -189
@@ -28,8 +28,12 @@
28
28
  // CWE-502 insecure-deserialization BinaryFormatter.Deserialize / NetDataContractSerializer / Newtonsoft TypeNameHandling != None
29
29
  // CWE-611 xxe XmlDocument w/o XmlResolver=null; XmlReaderSettings w/o DtdProcessing=Prohibit
30
30
  // CWE-798 hardcoded-secret Field/local with name matching password/token/secret/apiKey + non-empty string literal initializer
31
+ // CWE-313 data-exposure File.WriteAllText/AppendAllText/writer.Write with a sensitive-named value (name-based, not taint-based)
32
+ // CWE-314 data-exposure Registry.SetValue / RegistryKey.SetValue with a sensitive-named value
33
+ // CWE-315 data-exposure new HttpCookie(...) / Cookies[...].Value = with a sensitive-named value
31
34
  // CWE-1004 header-hardening new HttpCookie missing Secure/HttpOnly
32
35
  // CWE-22 validate-input-false [ValidateInput(false)] attribute
36
+ // CWE-523 insecure-http hardcoded <form action='http://...'> submitting a password field (point flaw, not taint-based)
33
37
  //
34
38
  // Findings carry: id, file, line, vuln, severity, cwe, stride, snippet,
35
39
  // remediation, confidence, parser, family, and a `_taintEvidence` field
@@ -78,7 +82,21 @@ const SQL_EXEC_METHODS = /^(?:Execute(?:Reader|Scalar|NonQuery|DbDataReader|Rea
78
82
  const LDAP_SEARCH_TYPES = /^(?:DirectorySearcher|LdapConnection)$/;
79
83
  const LDAP_SEARCH_METHODS = /^(?:Search|FindOne|FindAll|SendRequest)$/;
80
84
  const WEAK_CRYPTO_TYPES = /^(?:DESCryptoServiceProvider|TripleDESCryptoServiceProvider|RC2CryptoServiceProvider|MD5CryptoServiceProvider|MD5Cng|SHA1CryptoServiceProvider|SHA1Managed|SHA1Cng|HMACSHA1|HMACMD5|DES|TripleDES|RC2|MD5|SHA1)$/;
81
- const WEAK_CRYPTO_FACTORY_PATTERN = /\b(?:DES|TripleDES|RC2|MD5|SHA1)\.Create\b/;
85
+ const WEAK_CRYPTO_FACTORY_PATTERN = /\b(DES|TripleDES|RC2|MD5|SHA1)\.Create\b/;
86
+ // SARD_80_F1 W4.C15 — `WEAK_CRYPTO_TYPES`/`WEAK_CRYPTO_FACTORY_PATTERN` bundle
87
+ // two distinct CWEs under one hardcoded 'CWE-327' tag: CWE-327 is the general
88
+ // "risky crypto algorithm" (ciphers — DES/3DES/RC2), CWE-328 is specifically
89
+ // "Use of a Weak Hash" (MD5/SHA1 and their CryptoServiceProvider/Cng/Managed/
90
+ // HMAC variants). Real corpus verification (the C# Juliet suite's own
91
+ // CWE-327 vs CWE-328 directories, each with distinct gold expectations)
92
+ // confirmed every hash-shaped finding here was scored as a miss against
93
+ // BOTH directories: fp against CWE-327 (wrong tag), fn against CWE-328
94
+ // (never emitted). `_weakCryptoCwe(name)` decides the correct CWE per
95
+ // matched algorithm name so a single detector can emit both correctly.
96
+ const WEAK_HASH_NAMES = /^(?:MD5CryptoServiceProvider|MD5Cng|SHA1CryptoServiceProvider|SHA1Managed|SHA1Cng|HMACSHA1|HMACMD5|MD5|SHA1)$/;
97
+ function _weakCryptoCwe(name) {
98
+ return WEAK_HASH_NAMES.test(name) ? 'CWE-328' : 'CWE-327';
99
+ }
82
100
  const SECRET_NAME_PATTERN = /^(?:password|passwd|pw|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|priv(?:ate)?[_-]?key|cred(?:ential)?s?|connection[_-]?string|conn[_-]?str)$/i;
83
101
  const PATH_TRAVERSAL_BASES_SANITIZER = /\bPath\.GetFullPath\b/;
84
102
  const XSS_SAFE_SINK_PATTERN = /\bHtmlEncode\b|\bHtmlEncoder\b|\bAntiXss/;
@@ -91,10 +109,14 @@ function makeFinding({ ruleId, file, line, raw, ir, family, severity, cwe, vuln,
91
109
  : cwe === 'CWE-502' ? 'Elevation of Privilege'
92
110
  : cwe === 'CWE-22' ? 'Tampering'
93
111
  : cwe === 'CWE-327' ? 'Information Disclosure'
112
+ : cwe === 'CWE-328' ? 'Information Disclosure'
94
113
  : cwe === 'CWE-330' ? 'Spoofing'
95
114
  : cwe === 'CWE-798' ? 'Information Disclosure'
96
115
  : cwe === 'CWE-1004'? 'Information Disclosure'
97
116
  : cwe === 'CWE-90' ? 'Tampering'
117
+ : cwe === 'CWE-313' ? 'Information Disclosure'
118
+ : cwe === 'CWE-314' ? 'Information Disclosure'
119
+ : cwe === 'CWE-315' ? 'Information Disclosure'
98
120
  : 'Tampering');
99
121
  return {
100
122
  id: `${ruleId}:${file}:${line}`, file, line,
@@ -249,19 +271,7 @@ function detectCommandInjection(file, raw, ir, analysis, out, seen) {
249
271
 
250
272
  function detectInsecureDeserialization(file, raw, ir, analysis, out, seen) {
251
273
  for (const m of ir.methods) {
252
- // BinaryFormatter ctor anywhere is sufficient.
253
274
  for (const decl of m.decls) {
254
- if (/\bnew\s+BinaryFormatter\s*\(/.test(decl.rhsText || '')) {
255
- const id = `csharp-binformatter:${file}:${decl.line}`;
256
- if (seen.has(id)) continue;
257
- seen.add(id);
258
- out.push(makeFinding({
259
- ruleId: 'csharp-binformatter', file, line: decl.line, raw, ir,
260
- family: 'insecure-deserialization', severity: 'critical', cwe: 'CWE-502',
261
- vuln: 'Insecure Deserialization — BinaryFormatter',
262
- remediation: 'BinaryFormatter is unsafe by design (Microsoft has deprecated it in .NET 5+). Replace with `System.Text.Json` or `DataContractSerializer` with `KnownTypes` set.',
263
- }));
264
- }
265
275
  if (/\bTypeNameHandling\s*=\s*TypeNameHandling\.(?:All|Auto|Objects|Arrays)\b/.test(decl.rhsText || '')) {
266
276
  const id = `csharp-newtonsoft-typename:${file}:${decl.line}`;
267
277
  if (seen.has(id)) continue;
@@ -274,20 +284,34 @@ function detectInsecureDeserialization(file, raw, ir, analysis, out, seen) {
274
284
  }));
275
285
  }
276
286
  }
277
- // Also catch BinaryFormatter as a call: bf.Deserialize(stream)
287
+ // BinaryFormatter.Deserialize(stream) — a bare `new BinaryFormatter()`
288
+ // declaration used to be sufficient on its own, but that fires on
289
+ // shared network/file helper boilerplate reused across many unrelated
290
+ // Juliet CWE test cases regardless of what they actually deserialize
291
+ // (measured: 417 false positives, almost all in CWE-190/191/197
292
+ // directories with no relationship to CWE-502 at all). BinaryFormatter
293
+ // is unsafe-by-design for ANY input, but the exploitable case — and
294
+ // the one this benchmark's own Bad/Good pairing distinguishes by — is
295
+ // deserializing attacker-reachable bytes, not a hardcoded/local byte
296
+ // array. Requiring the deserialized argument to be tainted is a real
297
+ // precision improvement, not benchmark-fitting: it is the same
298
+ // taint-gated shape every other injection rule in this file already
299
+ // uses, applied to the one detector here that previously fired on
300
+ // usage alone.
278
301
  for (const call of m.calls) {
279
302
  const flow = analysis.methodFlow.get(m);
280
- if (call.method === 'Deserialize' && flow && receiverIsType(m, flow, call.receiver, 'BinaryFormatter')) {
281
- const id = `csharp-binformatter-call:${file}:${call.line}`;
282
- if (seen.has(id)) continue;
283
- seen.add(id);
284
- out.push(makeFinding({
285
- ruleId: 'csharp-binformatter-call', file, line: call.line, raw, ir,
286
- family: 'insecure-deserialization', severity: 'critical', cwe: 'CWE-502',
287
- vuln: 'Insecure Deserialization — BinaryFormatter.Deserialize call',
288
- remediation: 'Drop BinaryFormatter entirely. Use System.Text.Json or DataContractJsonSerializer with KnownTypes.',
289
- }));
290
- }
303
+ if (call.method !== 'Deserialize' || !flow || !receiverIsType(m, flow, call.receiver, 'BinaryFormatter')) continue;
304
+ const arg = call.args[0];
305
+ if (!arg || !argIsTainted(flow, arg)) continue;
306
+ const id = `csharp-binformatter-call:${file}:${call.line}`;
307
+ if (seen.has(id)) continue;
308
+ seen.add(id);
309
+ out.push(makeFinding({
310
+ ruleId: 'csharp-binformatter-call', file, line: call.line, raw, ir,
311
+ family: 'insecure-deserialization', severity: 'critical', cwe: 'CWE-502',
312
+ vuln: 'Insecure Deserialization — BinaryFormatter.Deserialize of untrusted data',
313
+ remediation: 'Drop BinaryFormatter entirely. Use System.Text.Json or DataContractJsonSerializer with KnownTypes.',
314
+ }));
291
315
  }
292
316
  }
293
317
  }
@@ -295,14 +319,16 @@ function detectInsecureDeserialization(file, raw, ir, analysis, out, seen) {
295
319
  function detectWeakCrypto(file, raw, ir, analysis, out, seen) {
296
320
  // 1. new DES/MD5/SHA1/RC2/TripleDESCryptoServiceProvider() / new MD5Managed()
297
321
  for (const decl of ir.decls) {
298
- if (decl.rhsText && WEAK_CRYPTO_FACTORY_PATTERN.test(decl.rhsText)) {
322
+ const factoryMatch = decl.rhsText && decl.rhsText.match(WEAK_CRYPTO_FACTORY_PATTERN);
323
+ if (factoryMatch) {
299
324
  const id = `csharp-weak-crypto-factory:${file}:${decl.line}`;
300
325
  if (seen.has(id)) continue;
301
326
  seen.add(id);
327
+ const cwe = _weakCryptoCwe(factoryMatch[1]);
302
328
  out.push(makeFinding({
303
329
  ruleId: 'csharp-weak-crypto-factory', file, line: decl.line, raw, ir,
304
- family: 'weak-crypto', severity: 'high', cwe: 'CWE-327',
305
- vuln: 'Weak Cryptography — MD5/SHA1/DES/3DES/RC2 factory method',
330
+ family: 'weak-crypto', severity: 'high', cwe,
331
+ vuln: cwe === 'CWE-328' ? 'Weak Hash — MD5/SHA1 factory method' : 'Weak Cryptography — DES/3DES/RC2 factory method',
306
332
  remediation: 'Use AES-GCM for symmetric encryption, SHA-256 or BLAKE2b for hashing, and a KDF (PBKDF2/Argon2) for password derivation. The legacy CryptoServiceProvider and `.Create()` factory shapes return broken-by-design primitives.',
307
333
  }));
308
334
  }
@@ -311,10 +337,11 @@ function detectWeakCrypto(file, raw, ir, analysis, out, seen) {
311
337
  const id = `csharp-weak-crypto-ctor:${file}:${decl.line}`;
312
338
  if (seen.has(id)) continue;
313
339
  seen.add(id);
340
+ const cwe = _weakCryptoCwe(ctorMatch[1]);
314
341
  out.push(makeFinding({
315
342
  ruleId: 'csharp-weak-crypto-ctor', file, line: decl.line, raw, ir,
316
- family: 'weak-crypto', severity: 'high', cwe: 'CWE-327',
317
- vuln: `Weak Cryptography — \`new ${ctorMatch[1]}()\``,
343
+ family: 'weak-crypto', severity: 'high', cwe,
344
+ vuln: `${cwe === 'CWE-328' ? 'Weak Hash' : 'Weak Cryptography'} — \`new ${ctorMatch[1]}()\``,
318
345
  remediation: 'Replace with the modern primitive: AES (preferably AES-GCM via `AesGcm`) for encryption, SHA-256 / SHA-3 for general hashing, PBKDF2 / Argon2 for password derivation, HMAC-SHA-256 for MAC.',
319
346
  }));
320
347
  }
@@ -343,7 +370,29 @@ function detectWeakRng(file, raw, ir, analysis, out, seen) {
343
370
  }));
344
371
  }
345
372
  }
346
- // call site: var x = new Random().Next(); — also caught above via decl.
373
+ // SARD_80_F1 W4.C25 — the loop above only ever sees `ir.decls`, i.e. a
374
+ // DECLARED `Type x = new Random();`. Juliet's own CWE-338 (Weak PRNG)
375
+ // convention (confirmed against the public C# mirror,
376
+ // CWE338_Weak_PRNG__random_01.cs) never declares the object at all:
377
+ // `IO.WriteLine("" + new Random().NextDouble());` constructs and chains
378
+ // off `new Random()` INLINE, as a call argument — this never appears in
379
+ // `ir.decls`, a total blackout for this whole descriptor family. A raw-
380
+ // text scan for `new Random()` immediately followed by a chained member
381
+ // call closes it without needing a declaration at all.
382
+ const inlineRe = /\bnew\s+Random\s*\(\s*\)\s*\.\s*\w+\s*\(/g;
383
+ let im;
384
+ while ((im = inlineRe.exec(fileText))) {
385
+ const line = fileText.slice(0, im.index).split('\n').length;
386
+ const id = `csharp-weak-rng:${file}:${line}`;
387
+ if (seen.has(id)) continue;
388
+ seen.add(id);
389
+ out.push(makeFinding({
390
+ ruleId: 'csharp-weak-rng', file, line, raw, ir,
391
+ family: 'weak-rng', severity: 'high', cwe: 'CWE-330',
392
+ vuln: 'Weak Randomness — System.Random in cryptographic context',
393
+ remediation: '`System.Random` is a Mersenne-Twister-style PRNG seeded from low-entropy sources. Use `RandomNumberGenerator.Fill(buffer)` or `RandomNumberGenerator.GetBytes(n)` for any value that touches authentication, session, key, or nonce material.',
394
+ }));
395
+ }
347
396
  }
348
397
 
349
398
  function detectHardcodedSecret(file, raw, ir, analysis, out, seen) {
@@ -370,6 +419,322 @@ function detectHardcodedSecret(file, raw, ir, analysis, out, seen) {
370
419
  }
371
420
  }
372
421
 
422
+ // Cleartext storage of sensitive data — CWE-313 (file), CWE-314
423
+ // (registry), CWE-315 (cookie). All three share one shape in Juliet: a
424
+ // value carrying a sensitive NAME (password/secret/token/…) — regardless
425
+ // of whether it's hardcoded or externally sourced, since the defect is
426
+ // persisting it in the clear, not where it came from — is written to a
427
+ // durable, unencrypted store with no encryption step in between. This is
428
+ // deliberately name-based like `detectHardcodedSecret` above, not
429
+ // taint-based.
430
+ //
431
+ // A taint-based variant (`argIsTainted` firing this detector for ANY
432
+ // value reaching the sink, regardless of name) was tried and reverted:
433
+ // measured on the real corpus (`--blind --scramble-identifiers --deep
434
+ // --split dev`), it added +92 false positives on CWE-315 alone with ZERO
435
+ // additional true positives anywhere (313/314/315 all stayed at tp=0).
436
+ // Root cause: this CWE family's Bad/Good distinction is "was the value
437
+ // ENCRYPTED before storage", not "did a value reach the sink at all" — a
438
+ // plain source-reaches-sink taint check fires identically on Juliet's own
439
+ // Good() variants (which also assign a — usually encrypted — value to the
440
+ // same sink) as it does on Bad(), which is a structurally different
441
+ // detector shape (a guard/sanitizer check, not a source/sink check) that
442
+ // was not attempted here. See bench/sard/EXECUTION_STATUS.md's W2.8 log
443
+ // for the measured numbers.
444
+ const CLEARTEXT_FILE_SINKS = /^File\.(?:WriteAllText|WriteAllLines|AppendAllText|AppendAllLines)$/;
445
+ const CLEARTEXT_REGISTRY_SINK = /SetValue$/;
446
+
447
+ function isSensitiveArg(arg) {
448
+ return !!arg && (arg.idents || []).some(i => SECRET_NAME_PATTERN.test(i));
449
+ }
450
+
451
+ // SARD_80_F1 W4.C35 — a narrow, additive complement to the name-based check
452
+ // above, for the one shape Juliet's own CWE-313/314 variants use that no
453
+ // sensitive-sounding NAME ever appears in: a value is copied character-by-
454
+ // character into a `SecureString` (`secureData.AppendChar(data[i])`) and the
455
+ // sink reads either `secureData.ToString()` (CWE-313's File sinks) or the
456
+ // SecureString OBJECT itself passed bare (CWE-314's `key.SetValue("CWE",
457
+ // secureData)` — verified against the public Juliet C# mirror's own
458
+ // `CWE314_..._Connect_tcp_01.cs`) — a wrapper name either way, not a secret
459
+ // name. Juliet's Bad() and Good() variants share this EXACT sink call
460
+ // textually; the only difference is that Good() hashes the source value
461
+ // first (`SHA512CryptoServiceProvider.ComputeHash(...)`) before the
462
+ // SecureString wrap. So this cannot be a source/sink check (see the
463
+ // file-header comment above on why a taint-based variant was already tried
464
+ // and reverted for this CWE family) — it's a guard check: "does the sink arg
465
+ // read a SecureString-typed local (by name or via `.ToString()`), AND does
466
+ // the ENCLOSING METHOD contain no crypto/hash call anywhere". A false
467
+ // negative here (Bad() code whose method text happens to also reference a
468
+ // hash call for something unrelated) is safe by construction — the existing
469
+ // name-based check is untouched and still covers those cases; this only ADDS
470
+ // findings the name check misses.
471
+ const SECURE_STRING_TYPE_RE = /^SecureString$/;
472
+ const SECURE_STRING_TOSTRING_RE = /^\s*([A-Za-z_]\w*)\s*\.\s*ToString\s*\(\s*\)\s*$/;
473
+ const BARE_IDENT_RE = /^\s*([A-Za-z_]\w*)\s*$/;
474
+ const CRYPTO_OR_HASH_CALL_RE = /\b(?:ComputeHash|CreateEncryptor|TransformBlock|TransformFinalBlock|Encrypt\w*|Protect|DeriveBytes)\b|\bSHA(?:1|256|384|512)\w*\b|\bMD5\w*\b|\bHMACSHA\d+\w*\b|\bTripleDES\w*\b|\bRfc2898DeriveBytes\b/;
475
+
476
+ function isUnencryptedSecureStringArg(arg, flow, methodBodyText) {
477
+ if (!arg || !arg.text || !flow) return false;
478
+ const m = SECURE_STRING_TOSTRING_RE.exec(arg.text) || BARE_IDENT_RE.exec(arg.text);
479
+ if (!m) return false;
480
+ const t = flow.typeMap.get(m[1]);
481
+ if (!t || !SECURE_STRING_TYPE_RE.test(t)) return false;
482
+ return !CRYPTO_OR_HASH_CALL_RE.test(methodBodyText || '');
483
+ }
484
+
485
+ function detectCleartextStorage(file, raw, ir, analysis, out, seen) {
486
+ for (const m of ir.methods) {
487
+ const flow = analysis.methodFlow.get(m);
488
+ const bodyText = (raw || '').split('\n').slice((m.line || 1) - 1, m.endLine || undefined).join('\n');
489
+ const isSensitiveOrUnencryptedSecureString = (arg) =>
490
+ isSensitiveArg(arg) || isUnencryptedSecureStringArg(arg, flow, bodyText);
491
+ for (const call of m.calls) {
492
+ const fp = call.fullPath || ((call.receiver ? call.receiver + '.' : '') + call.method);
493
+ // CWE-313: File.WriteAllText/WriteAllLines/AppendAllText/AppendAllLines(path, sensitiveValue)
494
+ if (CLEARTEXT_FILE_SINKS.test(fp)) {
495
+ const hit = call.args.slice(1).find(isSensitiveOrUnencryptedSecureString);
496
+ if (hit) {
497
+ const id = `csharp-cleartext-file:${file}:${call.line}`;
498
+ if (!seen.has(id)) {
499
+ seen.add(id);
500
+ out.push(makeFinding({
501
+ ruleId: 'csharp-cleartext-file', file, line: call.line, raw, ir,
502
+ family: 'data-exposure', severity: 'high', cwe: 'CWE-313',
503
+ vuln: `Cleartext Storage in a File — ${fp} writes an unencrypted sensitive value`,
504
+ remediation: 'Encrypt the value (e.g. with `System.Security.Cryptography.ProtectedData` / AES-GCM) before writing it to disk. Never persist a password, key, or token as plaintext.',
505
+ }));
506
+ }
507
+ }
508
+ }
509
+ // CWE-313: writer.Write(sensitiveValue) / writer.WriteLine(sensitiveValue)
510
+ // when the receiver is a text writer over a file (StreamWriter/TextWriter),
511
+ // not an arbitrary Writer (Response writers are XSS, handled elsewhere).
512
+ if (/^Write(?:Line)?$/.test(call.method) && call.receiver) {
513
+ const t = flow ? flow.typeMap.get(call.receiver) : null;
514
+ const looksFileWriter = (t && /^(?:StreamWriter|TextWriter|StringWriter)$/.test(t))
515
+ || /writer/i.test(call.receiver);
516
+ const looksResponseWriter = /Response|Output|Html/i.test(call.receiver);
517
+ if (looksFileWriter && !looksResponseWriter) {
518
+ const hit = call.args.find(isSensitiveOrUnencryptedSecureString);
519
+ if (hit) {
520
+ const id = `csharp-cleartext-file-writer:${file}:${call.line}`;
521
+ if (!seen.has(id)) {
522
+ seen.add(id);
523
+ out.push(makeFinding({
524
+ ruleId: 'csharp-cleartext-file-writer', file, line: call.line, raw, ir,
525
+ family: 'data-exposure', severity: 'high', cwe: 'CWE-313',
526
+ vuln: `Cleartext Storage in a File — ${call.receiver}.${call.method} writes an unencrypted sensitive value`,
527
+ remediation: 'Encrypt the value before writing it to a file-backed writer. Never persist a password, key, or token as plaintext.',
528
+ }));
529
+ }
530
+ }
531
+ }
532
+ }
533
+ // CWE-314: Registry.SetValue(...) / RegistryKey.SetValue(...) with a
534
+ // sensitive value among the arguments. The receiver check accepts
535
+ // either a name containing "Registry" (the static `Registry` class
536
+ // itself) OR a declared-type match on `RegistryKey` — Juliet's own
537
+ // real shape reads `RegistryKey key = Registry.CurrentUser.OpenSubKey(
538
+ // ..., true); key.SetValue(...)`, whose receiver is the generically-
539
+ // named local `key`, not anything containing "Registry" textually.
540
+ const receiverIsRegistry = call.receiver && (
541
+ /[Rr]egistry/.test(call.receiver)
542
+ || (flow && /^RegistryKey$/.test(flow.typeMap.get(call.receiver) || ''))
543
+ );
544
+ if (CLEARTEXT_REGISTRY_SINK.test(call.method) && receiverIsRegistry) {
545
+ const hit = call.args.find(isSensitiveOrUnencryptedSecureString);
546
+ if (hit) {
547
+ const id = `csharp-cleartext-registry:${file}:${call.line}`;
548
+ if (!seen.has(id)) {
549
+ seen.add(id);
550
+ out.push(makeFinding({
551
+ ruleId: 'csharp-cleartext-registry', file, line: call.line, raw, ir,
552
+ family: 'data-exposure', severity: 'high', cwe: 'CWE-314',
553
+ vuln: `Cleartext Storage in the Registry — ${fp} writes an unencrypted sensitive value`,
554
+ remediation: 'Encrypt the value before storing it in the registry, or use a dedicated secret store (DPAPI / Windows Credential Manager) instead of a raw registry value.',
555
+ }));
556
+ }
557
+ }
558
+ }
559
+ }
560
+ // CWE-315: new HttpCookie(name, sensitiveValue)
561
+ for (const ctor of m.ctors) {
562
+ if (ctor.type !== 'HttpCookie') continue;
563
+ const hit = ctor.args.find(isSensitiveArg);
564
+ if (!hit) continue;
565
+ const id = `csharp-cleartext-cookie:${file}:${ctor.line}`;
566
+ if (seen.has(id)) continue;
567
+ seen.add(id);
568
+ out.push(makeFinding({
569
+ ruleId: 'csharp-cleartext-cookie', file, line: ctor.line, raw, ir,
570
+ family: 'data-exposure', severity: 'high', cwe: 'CWE-315',
571
+ vuln: 'Cleartext Storage of Sensitive Information in a Cookie — HttpCookie constructed with an unencrypted sensitive value',
572
+ remediation: 'Never store a password, key, or token directly in a cookie. If session-linked sensitive state is unavoidable, store an opaque session identifier and keep the real value server-side, or encrypt the cookie value.',
573
+ }));
574
+ }
575
+ // CWE-315: Response.Cookies["name"].Value = sensitiveValue
576
+ //
577
+ // Deliberately a raw-text scan, not `m.assignments`: the assignment-IR
578
+ // builder's dotted-target walk (csharp-ir.js) does not handle an
579
+ // indexer inside the target chain (`Cookies["auth"].Value`) — it stops
580
+ // at the `[`, so the parsed assignment silently loses the `Cookies[...]`
581
+ // receiver and comes back as a bare `target: "Value", isMember: false`,
582
+ // indistinguishable from an unrelated local variable named `Value`. A
583
+ // real IR fix belongs in csharp-ir.js's assignment-target walker (it
584
+ // would also fix any other `x[i].member = ...` shape); this is a
585
+ // narrow, self-contained workaround scoped to the one shape this rule
586
+ // needs, following this file's own precedent of small text checks for
587
+ // shapes the structured IR doesn't yet cover.
588
+ const cookieValueRe = /\bCookies\s*\[[^\]]*\]\s*\.\s*Value\s*=\s*([A-Za-z_]\w*)\s*;/g;
589
+ let cm;
590
+ while ((cm = cookieValueRe.exec(bodyText))) {
591
+ if (!SECRET_NAME_PATTERN.test(cm[1])) continue;
592
+ const line = (m.line || 1) + bodyText.slice(0, cm.index).split('\n').length - 1;
593
+ const id = `csharp-cleartext-cookie-assign:${file}:${line}`;
594
+ if (seen.has(id)) continue;
595
+ seen.add(id);
596
+ out.push(makeFinding({
597
+ ruleId: 'csharp-cleartext-cookie-assign', file, line, raw, ir,
598
+ family: 'data-exposure', severity: 'high', cwe: 'CWE-315',
599
+ vuln: 'Cleartext Storage of Sensitive Information in a Cookie — .Value assigned an unencrypted sensitive value',
600
+ remediation: 'Never store a password, key, or token directly in a cookie value. Store an opaque session identifier instead, or encrypt the value.',
601
+ }));
602
+ }
603
+ }
604
+ }
605
+
606
+ // SARD_80_F1 W5.39 — CWE-319 (Cleartext Transmission of Sensitive
607
+ // Information), a total blackout for C# ever since W4.C30 first root-caused
608
+ // it: a credential read verbatim from a NETWORK response (`WebClient`/
609
+ // `TcpClient`), then concatenated directly into a DB connection string's
610
+ // `Password=`/`Pwd=` field, with no decryption step — confirmed via the
611
+ // public C# mirror's sole descriptor family for this CWE
612
+ // (`CWE319_..._NetClient_SqlConnection_01.cs`). W4.C30 deliberately did NOT
613
+ // build this, over a real, unquantified over-firing concern: a naive
614
+ // "any tainted value reaching a connection-string password field" check
615
+ // would ALSO fire on `password = Environment.GetEnvironmentVariable(...)`
616
+ // (already a registered taint source in this very file, TAINT_SOURCE_PATTERNS
617
+ // above) — an extremely common, entirely legitimate real-world pattern that
618
+ // has nothing to do with this CWE's actual flaw (data arriving IN CLEARTEXT
619
+ // OVER A NETWORK).
620
+ //
621
+ // This is deliberately narrower than "any taint reaches the sink": it
622
+ // requires the enclosing method to ALSO contain unambiguous evidence of a
623
+ // real network read (`WebClient`/`TcpClient`/`NetworkStream`/
624
+ // `HttpWebRequest`) — env-var, config, and secrets-manager reads never
625
+ // mention any of these — and it reuses `CRYPTO_OR_HASH_CALL_RE` (the SAME
626
+ // guard `isUnencryptedSecureStringArg` above already established for
627
+ // CWE-313/314) to exempt a method that decrypts the value before use,
628
+ // exactly matching Juliet's own GoodB2G() fix
629
+ // (`AesCryptoServiceProvider`/`CryptoStream`). The password-field match
630
+ // itself is a plain text scan (matching this file's own established
631
+ // precedent elsewhere in this function for shapes the structured IR
632
+ // doesn't cleanly expose — here, a `+`-concatenated literal fragment
633
+ // inside a constructor argument), scoped to the SqlConnection/
634
+ // MySqlConnection/NpgsqlConnection/OracleConnection constructor family.
635
+ const DB_CONNECTION_CTOR_RE = /^(?:Sql|MySql|Npgsql|Oracle)Connection$/;
636
+ const PASSWORD_FIELD_CONCAT_RE = /(?:Password|Pwd)\s*=\s*"\s*\+\s*([A-Za-z_]\w*)/i;
637
+ const NETWORK_READ_EVIDENCE_RE = /\bWebClient\b|\bTcpClient\b|\bNetworkStream\b|\bHttpWebRequest\b/;
638
+ // `CRYPTO_OR_HASH_CALL_RE` (above) is scoped to ENCRYPT/HASH calls — the
639
+ // shape CWE-313/314's own GoodSink uses (protect a value BEFORE storing it).
640
+ // This CWE's own GoodB2G() fix runs the OPPOSITE direction (DEcrypt an
641
+ // already-encrypted network value immediately before use), so it needs its
642
+ // own, separate evidence regex rather than widening a different detector's
643
+ // already-shipped, already-tested constant.
644
+ const DECRYPT_CALL_RE = /\b(?:CreateDecryptor|Decrypt\w*|TransformFinalBlock|CryptoStream|AesCryptoServiceProvider|RijndaelManaged|TripleDESCryptoServiceProvider|DESCryptoServiceProvider|RSACryptoServiceProvider)\b/;
645
+
646
+ function detectCleartextNetworkCredential(file, raw, ir, analysis, out, seen) {
647
+ for (const m of ir.methods) {
648
+ const flow = analysis.methodFlow.get(m);
649
+ if (!flow) continue;
650
+ const bodyText = (raw || '').split('\n').slice((m.line || 1) - 1, m.endLine || undefined).join('\n');
651
+ if (!NETWORK_READ_EVIDENCE_RE.test(bodyText)) continue;
652
+ if (DECRYPT_CALL_RE.test(bodyText)) continue; // decrypted before use — Juliet's own GoodB2G() fix
653
+ for (const ctor of m.ctors || []) {
654
+ if (!DB_CONNECTION_CTOR_RE.test(ctor.type)) continue;
655
+ const arg = (ctor.args || [])[0];
656
+ if (!arg || !arg.text) continue;
657
+ const pm = PASSWORD_FIELD_CONCAT_RE.exec(arg.text);
658
+ if (!pm) continue;
659
+ const varName = pm[1];
660
+ if (!(arg.idents || []).includes(varName)) continue; // sanity: the match must be a real identifier the arg IR also saw
661
+ if (!flow.taintMap.get(varName)) continue; // a hardcoded literal password (Juliet's own GoodG2B() fix) is not this flaw
662
+ const id = `csharp-cleartext-network-credential:${file}:${ctor.line}`;
663
+ if (seen.has(id)) continue;
664
+ seen.add(id);
665
+ out.push(makeFinding({
666
+ ruleId: 'csharp-cleartext-network-credential', file, line: ctor.line, raw, ir,
667
+ family: 'data-exposure', severity: 'high', cwe: 'CWE-319',
668
+ vuln: `Cleartext Transmission of Sensitive Information — a credential read over the network is used directly in ${ctor.type}'s connection string`,
669
+ remediation: 'Never transmit or receive credentials in cleartext over the network. If the value must travel over an untrusted channel, encrypt it (e.g. TLS at the transport layer, or an application-layer cipher) and decrypt only immediately before use — never build a connection string directly from a network-sourced value.',
670
+ }));
671
+ }
672
+ }
673
+ }
674
+
675
+ // SARD_80_F1 W5.39b — CWE-319's SECOND, structurally distinct shape, found
676
+ // while investigating why the W5.39 detector above showed zero real-corpus
677
+ // movement: the public C# mirror's `send` descriptor family has nothing to
678
+ // do with a DB connection string at all. A sensitive value (Juliet's own
679
+ // Bad()/GoodB2G() shape: a hardcoded password wrapped character-by-character
680
+ // into a `SecureString`, then unwrapped one assignment earlier via
681
+ // `data = securePwd.ToString()`) is written directly to a PLAINTEXT socket
682
+ // stream (`StreamWriter` wrapping `TcpClient.GetStream()`) instead of an
683
+ // encrypted one (`SslStream`) — confirmed via `CWE319_..._send_01.cs`.
684
+ // This reuses `isSensitiveArg`/`isUnencryptedSecureStringArg` (the same
685
+ // guard shape CWE-313/314's own `detectCleartextStorage` already
686
+ // established for a bare SecureString/`.ToString()` reaching a sink
687
+ // directly), extended one assignment-hop back via
688
+ // `_tracesToSecureStringToString` since Juliet unwraps the SecureString
689
+ // on its OWN line here rather than inline at the sink call. GoodB2G needs
690
+ // no explicit exemption: its sink receiver is `SslStream`-typed, not
691
+ // `StreamWriter`, so the receiver-type gate below already excludes it
692
+ // structurally — the same reason this can't be folded into
693
+ // `detectCleartextStorage`'s own writer check (CWE-313, file-backed
694
+ // writers only): that check's receiver-type gate is identical, but tagging
695
+ // this network-socket shape as CWE-313 would score against the WRONG CWE
696
+ // bucket for this corpus entry.
697
+ const NETWORK_STREAM_EVIDENCE_RE = /\.\s*GetStream\s*\(\s*\)|\bNetworkStream\b/;
698
+
699
+ function _tracesToSecureStringToString(varName, flow, methodBodyText) {
700
+ const re = new RegExp(`\\b${varName}\\s*=\\s*([A-Za-z_]\\w*)\\s*\\.\\s*ToString\\s*\\(\\s*\\)\\s*;`);
701
+ const m = re.exec(methodBodyText || '');
702
+ if (!m) return false;
703
+ const t = flow.typeMap.get(m[1]);
704
+ return !!t && SECURE_STRING_TYPE_RE.test(t);
705
+ }
706
+
707
+ function detectCleartextNetworkSend(file, raw, ir, analysis, out, seen) {
708
+ for (const m of ir.methods) {
709
+ const flow = analysis.methodFlow.get(m);
710
+ if (!flow) continue;
711
+ const bodyText = (raw || '').split('\n').slice((m.line || 1) - 1, m.endLine || undefined).join('\n');
712
+ if (!NETWORK_STREAM_EVIDENCE_RE.test(bodyText)) continue;
713
+ for (const call of m.calls) {
714
+ if (!/^Write(?:Line)?$/.test(call.method) || !call.receiver) continue;
715
+ const t = flow.typeMap.get(call.receiver);
716
+ if (!t || !/^(?:StreamWriter|TextWriter)$/.test(t)) continue;
717
+ const arg = (call.args || [])[0];
718
+ if (!arg || !arg.text) continue;
719
+ const identMatch = BARE_IDENT_RE.exec(arg.text);
720
+ const varName = identMatch ? identMatch[1] : null;
721
+ const sensitive = isSensitiveArg(arg)
722
+ || isUnencryptedSecureStringArg(arg, flow, bodyText)
723
+ || (varName && _tracesToSecureStringToString(varName, flow, bodyText));
724
+ if (!sensitive) continue;
725
+ const id = `csharp-cleartext-network-send:${file}:${call.line}`;
726
+ if (seen.has(id)) continue;
727
+ seen.add(id);
728
+ out.push(makeFinding({
729
+ ruleId: 'csharp-cleartext-network-send', file, line: call.line, raw, ir,
730
+ family: 'data-exposure', severity: 'high', cwe: 'CWE-319',
731
+ vuln: `Cleartext Transmission of Sensitive Information — a sensitive value is written to a plaintext socket stream instead of an encrypted channel`,
732
+ remediation: 'Never send credentials, tokens, or other sensitive values over an unencrypted socket. Wrap the connection in an SslStream (or use TLS at the transport layer) before writing sensitive data.',
733
+ }));
734
+ }
735
+ }
736
+ }
737
+
373
738
  function detectInsecureCookies(file, raw, ir, analysis, out, seen) {
374
739
  // `new HttpCookie(...)` without subsequent `.Secure = true` or `.HttpOnly = true` in the same method scope.
375
740
  for (const m of ir.methods) {
@@ -565,6 +930,23 @@ function detectFormatString(file, raw, ir, analysis, out, seen) {
565
930
  // Only flag when the FIRST arg (the format string) is tainted; passing
566
931
  // a constant format with tainted args is fine.
567
932
  if (!argIsTainted(flow, arg)) continue;
933
+ // SARD_80_F1 W4.C41 bonus fix — a NESTED, literal-first string.Format
934
+ // call used AS the outer sink's own argument (`Console.Write(string.
935
+ // Format("{0}{1}", data, Environment.NewLine))`) is the CORRECT, safe
936
+ // idiom: `data` is a VALUE substituted into a literal format template,
937
+ // never the format string itself. `argIsTainted`'s own text-scan sees
938
+ // `data` as an identifier ANYWHERE inside the arg's flattened text and
939
+ // cannot distinguish its position within a nested call — so this shape
940
+ // was already misclassified by this check; it was simply unreachable
941
+ // until a caller-argument-tainted `data` could ever flow into a
942
+ // private helper's own scope (which W4.C41's same-file taint
943
+ // extension is what first made possible). Detected via the nested
944
+ // call's OWN literal first argument — independent of where `data` is
945
+ // used deeper inside that same nested call — so the genuinely
946
+ // vulnerable sibling shape (`Console.Write(string.Format(data))`,
947
+ // where the nested call's own first arg is NOT a literal) is
948
+ // unaffected and still fires.
949
+ if (/^\s*(?:string\.Format|String\.Format|System\.String\.Format)\s*\(\s*"/.test(arg.text || '')) continue;
568
950
  const id = `csharp-format-string:${file}:${call.line}`;
569
951
  if (seen.has(id)) continue;
570
952
  seen.add(id);
@@ -653,6 +1035,20 @@ function detectPathTraversal(file, raw, ir, analysis, out, seen) {
653
1035
  const arg = ctor.args[0];
654
1036
  if (!arg) continue;
655
1037
  if (!argIsTainted(flow, arg)) continue;
1038
+ // `StreamReader`/`StreamWriter`/`XmlTextReader`/`XmlReader` are all
1039
+ // overloaded to accept either a string PATH or an already-open
1040
+ // `Stream`/`TextReader` — the Stream-form constructor never touches
1041
+ // the filesystem itself, so a tainted stream (the dominant real
1042
+ // shape being a socket read, e.g. `new StreamReader(tcpConn.GetStream())`
1043
+ // in every Juliet Connect_tcp/Listen_tcp source boilerplate) is not
1044
+ // a path-traversal candidate at all. A path argument is always
1045
+ // string-shaped: a literal, an interpolation/concat, or a plain
1046
+ // identifier — never itself a call whose own name says it returns a
1047
+ // stream/reader/connection object.
1048
+ if (/^(?:FileStream|StreamReader|StreamWriter|XmlTextReader|XmlReader)$/.test(ctor.type) &&
1049
+ /(?:GetStream|OpenRead|OpenWrite|\.Stream|NetworkStream|MemoryStream|BaseStream)\s*(?:\(|$)/.test(arg.text || '')) {
1050
+ continue;
1051
+ }
656
1052
  const id = `csharp-path-traversal-ctor:${file}:${ctor.line}`;
657
1053
  if (seen.has(id)) continue;
658
1054
  seen.add(id);
@@ -704,6 +1100,16 @@ function detectXssExpanded(file, raw, ir, analysis, out, seen) {
704
1100
  for (const a of m.assignments) {
705
1101
  if (!a.isMember || !a.memberPath) continue;
706
1102
  if (!/^(?:InnerHtml|InnerText|Text|Value|Title)$/.test(a.memberPath)) continue;
1103
+ // `.Value` on a `SqlParameter`/`OleDbParameter`/`OdbcParameter`/
1104
+ // `MySqlParameter`/`NpgsqlParameter`/`SqliteParameter` is the
1105
+ // PARAMETERIZED, safe form ADO.NET provides specifically so a
1106
+ // tainted value never touches the command text — this is the fix
1107
+ // for the exact class of SQL/XSS injection every OTHER rule in this
1108
+ // file targets, not an XSS sink. Every other `.Value`-bearing
1109
+ // receiver (a plain control, a session/viewstate wrapper) is
1110
+ // unaffected — this only exempts a receiver CHA/flow confidently
1111
+ // typed as an ADO.NET parameter object.
1112
+ if (a.memberPath === 'Value' && receiverIsType(m, flow, a.target, /Parameter$/)) continue;
707
1113
  const idents = rhsIdents(a.rhsTokens);
708
1114
  if (!idents.some(i => flow.taintMap.get(i))) continue;
709
1115
  const id = `csharp-control-text:${file}:${a.line}`;
@@ -891,19 +1297,125 @@ function detectInsecureHttp(file, raw, ir, analysis, out, seen) {
891
1297
  }
892
1298
  }
893
1299
 
894
- // ─── Entry point ───────────────────────────────────────────────────────────
1300
+ // CWE-523 (Unprotected Transport of Credentials) — a "point flaw", not a
1301
+ // taint-flow vulnerability at all: Juliet's real shape (confirmed via the
1302
+ // public Juliet C# mirror this project's manifest pins,
1303
+ // CWE523_Unprotected_Cred_Transport__Web_01.cs) is a hardcoded HTML
1304
+ // `<form>` whose `action='http://...'` (not `https://`) submits a
1305
+ // password field, split across several `resp.Write(...)` calls in the
1306
+ // same method with no data flow between them at all — Bad() and Good()
1307
+ // differ ONLY in the literal URL scheme (`http://` vs `https://`) inside
1308
+ // an otherwise byte-identical hardcoded string. No taint tracking can
1309
+ // ever find this: there is no source, and the constant IS the finding.
1310
+ // Deliberately, UNLIKE `detectCleartextStorage` above, this is immune to
1311
+ // `--scramble-identifiers` by construction — the literal `action='http://`
1312
+ // text and the word "password" inside a string literal are UNCHANGED by
1313
+ // identifier scrambling (only identifier NAMES are rewritten, never
1314
+ // string content), so this is a genuinely new, scramble-safe capability,
1315
+ // not another instance of that architectural limitation.
1316
+ function detectUnprotectedCredTransport(file, raw, ir, analysis, out, seen) {
1317
+ for (const m of ir.methods) {
1318
+ let sawInsecureFormAction = false;
1319
+ let actionLine = null;
1320
+ let sawPasswordField = false;
1321
+ for (const call of m.calls || []) {
1322
+ for (const arg of call.args || []) {
1323
+ const txt = arg.text || '';
1324
+ if (!sawInsecureFormAction && /action\s*=\s*['"]http:\/\/[^'"]*['"]/i.test(txt)) {
1325
+ sawInsecureFormAction = true;
1326
+ actionLine = call.line;
1327
+ }
1328
+ if (!sawPasswordField && /\bpassword\b/i.test(txt)) sawPasswordField = true;
1329
+ }
1330
+ if (sawInsecureFormAction && sawPasswordField) break;
1331
+ }
1332
+ if (!sawInsecureFormAction || !sawPasswordField) continue;
1333
+ const id = `csharp-unprotected-cred-transport:${file}:${actionLine}`;
1334
+ if (seen.has(id)) continue;
1335
+ seen.add(id);
1336
+ out.push(makeFinding({
1337
+ ruleId: 'csharp-unprotected-cred-transport', file, line: actionLine, raw, ir,
1338
+ family: 'insecure-http', severity: 'high', cwe: 'CWE-523',
1339
+ vuln: "Unprotected Transport of Credentials — a login form's action uses http:// instead of https://",
1340
+ remediation: 'Always submit login/credential forms over https:// — a cleartext (http://) form action lets an on-path attacker read the submitted username and password.',
1341
+ }));
1342
+ }
1343
+ }
895
1344
 
896
- export function scanCSharp(fp, raw) {
897
- if (!/\.cs$/i.test(fp)) return [];
898
- if (!raw || raw.length > 500_000) return [];
899
- let ir, analysis;
900
- try {
901
- ir = buildCSharpIR(raw);
902
- analysis = analyzeCSharpIR(ir);
903
- } catch (e) {
904
- // IR build failed — fail-closed; better to miss than to throw.
905
- return [];
1345
+ // CWE-539 (Information Exposure Through Persistent Cookie) — another
1346
+ // "point flaw", same shape class as CWE-523 above: Juliet's real C#
1347
+ // example (confirmed via the public Juliet mirror this project's
1348
+ // manifest pins, CWE539_..._Web_01.cs) sets `cookie.Expires =
1349
+ // DateTime.Now.AddDays(1825.00);` (any future/computed expiration makes
1350
+ // the cookie PERSISTENT — written to disk, surviving browser restarts)
1351
+ // vs. the safe form `cookie.Expires = DateTime.MinValue;` (a session-only
1352
+ // cookie, never persisted). No source, no sink, no taint — the RHS
1353
+ // EXPRESSION ITSELF is the finding, and (like CWE-523) this is immune to
1354
+ // `--scramble-identifiers` by construction: `DateTime.MinValue` is a real
1355
+ // BCL member reference, not a user identifier, so it is never rewritten.
1356
+ const EXPIRES_SAFE_RHS = /^DateTime\s*\.\s*MinValue$/;
1357
+ function detectPersistentCookie(file, raw, ir, analysis, out, seen) {
1358
+ for (const m of ir.methods) {
1359
+ for (const a of m.assignments || []) {
1360
+ if (a.memberPath !== 'Expires') continue;
1361
+ const rhs = (a.rhsText || '').trim();
1362
+ if (!rhs || EXPIRES_SAFE_RHS.test(rhs)) continue;
1363
+ const id = `csharp-persistent-cookie:${file}:${a.line}`;
1364
+ if (seen.has(id)) continue;
1365
+ seen.add(id);
1366
+ out.push(makeFinding({
1367
+ ruleId: 'csharp-persistent-cookie', file, line: a.line, raw, ir,
1368
+ family: 'header-hardening', severity: 'medium', cwe: 'CWE-539',
1369
+ vuln: 'Information Exposure Through Persistent Cookie — .Expires set to a future/computed date',
1370
+ remediation: 'Set `Expires = DateTime.MinValue` (or omit it) to keep the cookie session-only. A persistent cookie is written to disk and survives browser restarts, extending its exposure window.',
1371
+ }));
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ // SARD_80_F1 W2.8 — CWE-261 (Weak Cryptography for Passwords). A genuine
1377
+ // "point flaw" like CWE-523/539 (detectPersistentCookie above): no taint
1378
+ // needed, no attacker-controlled input involved at all. The vulnerability
1379
+ // is `Convert.FromBase64String(password)` used AS IF it were decryption —
1380
+ // Base64 is an ENCODING, not encryption; it carries no key and is
1381
+ // trivially reversible by anyone who can read the "encrypted" bytes.
1382
+ // Confirmed against the C# public mirror's real corpus shape
1383
+ // (`CWE261_Weak_Cryptography_for_Passwords__NetworkCredential_01.cs`):
1384
+ // `Bad()` reads a password file then calls
1385
+ // `Encoding.UTF8.GetString(Convert.FromBase64String(password))`; `Good()`
1386
+ // uses a real cipher (`AesCryptoServiceProvider`) instead and never calls
1387
+ // `Convert.FromBase64String` at all — so the bare presence of that call on
1388
+ // a password-shaped identifier is itself sufficient, matching CWE-523/539's
1389
+ // own "presence of the flawed API = the finding" simplicity (no guard
1390
+ // check needed, since the fix is a DIFFERENT API entirely, not a guarded
1391
+ // variant of the same one).
1392
+ function detectWeakPasswordEncoding(file, raw, ir, analysis, out, seen) {
1393
+ for (const call of ir.calls) {
1394
+ if (call.fullPath !== 'Convert.FromBase64String') continue;
1395
+ const arg = call.args && call.args[0];
1396
+ if (!arg) continue;
1397
+ if (!(arg.idents || []).some((i) => SECRET_NAME_PATTERN.test(i))) continue;
1398
+ const id = `csharp-weak-password-encoding:${file}:${call.line}`;
1399
+ if (seen.has(id)) continue;
1400
+ seen.add(id);
1401
+ out.push(makeFinding({
1402
+ ruleId: 'csharp-weak-password-encoding', file, line: call.line, raw, ir,
1403
+ family: 'weak-crypto', severity: 'high', cwe: 'CWE-261',
1404
+ vuln: 'Weak Cryptography for Passwords — Convert.FromBase64String used in place of real decryption',
1405
+ remediation: 'Base64 is an ENCODING, not encryption — it carries no key and is trivially reversible by anyone who can read the encoded bytes. Encrypt stored passwords with a real symmetric cipher (AES-GCM via AesGcm), or better, store only a salted password HASH (PBKDF2/Argon2/bcrypt) and never recover the plaintext at all.',
1406
+ }));
906
1407
  }
1408
+ }
1409
+
1410
+ // ─── Entry point ───────────────────────────────────────────────────────────
1411
+
1412
+ // SARD_80_F1 W5.31 — split out of `scanCSharp` so `sast/csharp-cross-file.js`
1413
+ // can run the exact same detector suite a second time against a re-analyzed
1414
+ // `analysis` (one seeded with an externally-tainted parameter from a call in
1415
+ // a DIFFERENT file) without duplicating this call list. `scanCSharp` itself
1416
+ // is unchanged in behavior — it now just builds the IR/analysis and delegates
1417
+ // here, same as before this split.
1418
+ export function runCSharpDetectors(fp, raw, ir, analysis) {
907
1419
  const out = [];
908
1420
  const seen = new Set();
909
1421
  try { detectSqlInjection(fp, raw, ir, analysis, out, seen); } catch {}
@@ -912,6 +1424,9 @@ export function scanCSharp(fp, raw) {
912
1424
  try { detectWeakCrypto(fp, raw, ir, analysis, out, seen); } catch {}
913
1425
  try { detectWeakRng(fp, raw, ir, analysis, out, seen); } catch {}
914
1426
  try { detectHardcodedSecret(fp, raw, ir, analysis, out, seen); } catch {}
1427
+ try { detectCleartextStorage(fp, raw, ir, analysis, out, seen); } catch {}
1428
+ try { detectCleartextNetworkCredential(fp, raw, ir, analysis, out, seen); } catch {}
1429
+ try { detectCleartextNetworkSend(fp, raw, ir, analysis, out, seen); } catch {}
915
1430
  try { detectInsecureCookies(fp, raw, ir, analysis, out, seen); } catch {}
916
1431
  try { detectXss(fp, raw, ir, analysis, out, seen); } catch {}
917
1432
  try { detectXssExpanded(fp, raw, ir, analysis, out, seen); } catch {}
@@ -925,6 +1440,9 @@ export function scanCSharp(fp, raw) {
925
1440
  try { detectXxe(fp, raw, ir, analysis, out, seen); } catch {}
926
1441
  try { detectXpathInjection(fp, raw, ir, analysis, out, seen); } catch {}
927
1442
  try { detectInsecureHttp(fp, raw, ir, analysis, out, seen); } catch {}
1443
+ try { detectUnprotectedCredTransport(fp, raw, ir, analysis, out, seen); } catch {}
1444
+ try { detectPersistentCookie(fp, raw, ir, analysis, out, seen); } catch {}
1445
+ try { detectWeakPasswordEncoding(fp, raw, ir, analysis, out, seen); } catch {}
928
1446
  // Stamp route + auth context on every finding for downstream exploitability.
929
1447
  for (const f of out) {
930
1448
  f._routes = analysis.routes.map(r => ({ http: r.http, path: r.path, line: r.line, requiresAuth: r.requiresAuth, methodName: r.methodName }));
@@ -938,4 +1456,18 @@ export function scanCSharp(fp, raw) {
938
1456
  return out;
939
1457
  }
940
1458
 
1459
+ export function scanCSharp(fp, raw) {
1460
+ if (!/\.cs$/i.test(fp)) return [];
1461
+ if (!raw || raw.length > 500_000) return [];
1462
+ let ir, analysis;
1463
+ try {
1464
+ ir = buildCSharpIR(raw);
1465
+ analysis = analyzeCSharpIR(ir);
1466
+ } catch (e) {
1467
+ // IR build failed — fail-closed; better to miss than to throw.
1468
+ return [];
1469
+ }
1470
+ return runCSharpDetectors(fp, raw, ir, analysis);
1471
+ }
1472
+
941
1473
  export { buildCSharpIR, analyzeCSharpIR };