circle-ir 3.121.0 → 3.124.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.
@@ -91,6 +91,11 @@ const PYTHON_TAINTED_PATTERNS = [
91
91
  { pattern: /\bget_query_parameter\s*\(/, type: 'http_param' },
92
92
  { pattern: /\bget_header_value\s*\(/, type: 'http_header' },
93
93
  { pattern: /\bget_cookie_value\s*\(/, type: 'http_cookie' },
94
+ // Sprint 72 (#183 residual): `input()` is registered in
95
+ // configs/sources/python.json but was not in the forward-taint regex
96
+ // registry, so `name = input()` was not added to pyTaintedVars. Closes
97
+ // the deferred `getattr(obj, input())()` reflection-invocation shape.
98
+ { pattern: /\binput\s*\(/, type: 'io_input' },
94
99
  ];
95
100
  // ---------------------------------------------------------------------------
96
101
  // Pass
@@ -166,6 +171,25 @@ export class LanguageSourcesPass {
166
171
  });
167
172
  }
168
173
  }
174
+ // Sprint 68 — #183: reflection-invocation sinks for
175
+ // (a) direct `getattr(obj, taint)()` (single-line two-call)
176
+ // (b) aliased `fn = getattr(obj, taint); ...; fn()` (two-stmt)
177
+ // Conservative: requires the 2nd `getattr` arg to be in pyTaintedVars
178
+ // AND the result to be invoked (gates against the benign data-access
179
+ // shape `value = getattr(obj, name)` or 3-arg `getattr(o, n, default)`).
180
+ for (const r of findPythonReflectionInvocationSinks(code, pyTaintedVars)) {
181
+ const alreadyExists = additionalSinks.some(s => s.line === r.sinkLine && s.type === 'code_injection' && s.method === r.method);
182
+ if (!alreadyExists) {
183
+ additionalSinks.push({
184
+ type: 'code_injection',
185
+ cwe: 'CWE-94',
186
+ line: r.sinkLine,
187
+ location: `reflection invocation (getattr result called) with tainted attribute name at line ${r.sinkLine}`,
188
+ method: r.method,
189
+ confidence: 0.85,
190
+ });
191
+ }
192
+ }
169
193
  }
170
194
  const jsTaintedVars = buildJavaScriptTaintedVars(code, language);
171
195
  // -- Bash/Shell: taint sources + pattern-based findings --
@@ -187,11 +211,53 @@ export class LanguageSourcesPass {
187
211
  if (language === 'python') {
188
212
  additionalSanitizers.push(...findPythonNetlocAllowlistGuardSanitizers(code));
189
213
  additionalSanitizers.push(...findPythonRangeCheckGuardSanitizers(code));
214
+ // Sprint 71 (#190): pattern-based misconfig findings for subscript/context
215
+ // assignment shapes (cors-wildcard-origin, xfo-csp-mismatch, tls-verify-
216
+ // disabled) that the language-agnostic detectors miss in Python.
217
+ const pyMisconfigFindings = findPythonPatternFindings(code, graph.ir.meta.file);
218
+ for (const finding of pyMisconfigFindings) {
219
+ ctx.addFinding(finding);
220
+ }
190
221
  }
191
222
  // -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
192
223
  if (language === 'rust') {
193
224
  additionalSanitizers.push(...findRustSetAllowlistGuardSanitizers(code));
194
225
  additionalSanitizers.push(...findRustCanonicalizeGuardSanitizers(code));
226
+ // Sprint 71 (#190): Rust reqwest builder `danger_accept_invalid_*(true)`
227
+ // is `tls-verify-disabled` — same rule as the Python/JS shapes.
228
+ const rustMisconfigFindings = findRustPatternFindings(code, graph.ir.meta.file);
229
+ for (const finding of rustMisconfigFindings) {
230
+ ctx.addFinding(finding);
231
+ }
232
+ }
233
+ // -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
234
+ // sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
235
+ // '-prefix) plus wrapper-function recognition for xss / log_injection.
236
+ if (language === 'javascript' || language === 'typescript' || language === 'htmljs') {
237
+ additionalSanitizers.push(...findJsSafeJsonParseSanitizers(code));
238
+ additionalSanitizers.push(...findJsCryptoHashSanitizers(code));
239
+ additionalSanitizers.push(...findJsCsvFormulaPrefixSanitizers(code));
240
+ additionalSanitizers.push(...findJsWrapperFunctionSanitizers(code));
241
+ }
242
+ // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
243
+ // fromJson recognized as ETE terminator (does not affect
244
+ // configured `deserialization` sinks).
245
+ if (language === 'java') {
246
+ additionalSanitizers.push(...findJavaSafeJsonParseSanitizers(code));
247
+ }
248
+ // Sprint 70 (#151): cross-language env-secret → external-network exfiltration.
249
+ // Pattern findings only — no taint flow required (composed-flow shape that
250
+ // the engine misses because the env source and the egress sink are
251
+ // co-located in one file but the taint propagator doesn't classify
252
+ // env-reads as taint sources for this exfil sink).
253
+ if (language === 'python' ||
254
+ language === 'javascript' ||
255
+ language === 'typescript' ||
256
+ language === 'go') {
257
+ const exfilFindings = findExternalSecretExfiltrationFindings(code, graph.ir.meta.file, language);
258
+ for (const finding of exfilFindings) {
259
+ ctx.addFinding(finding);
260
+ }
195
261
  }
196
262
  // Attach trimmed source-line text to each emitted source/sink so consumers
197
263
  // (LLM enrichment, SARIF reporters) can render the offending line without
@@ -970,6 +1036,82 @@ function findPythonReturnXSSSinks(sourceCode, taintedVars) {
970
1036
  }
971
1037
  return sinks;
972
1038
  }
1039
+ /**
1040
+ * Sprint 68 — #183: Python `getattr(obj, taint)()` reflection invocation.
1041
+ *
1042
+ * Detects two shapes that arrive at an attribute look-up by tainted name
1043
+ * and then invoke the result as a callable:
1044
+ *
1045
+ * 1. DIRECT: `... getattr(obj, name)(...) ...` (single-line two-call)
1046
+ * 2. ALIASED: `fn = getattr(obj, name)` then later `fn(...)` (two-stmt)
1047
+ *
1048
+ * `name` must be in `taintedVars` (i.e. flowed from a known Python source).
1049
+ * 3-arg `getattr(obj, name, default)` is excluded — the default-value form
1050
+ * is the idiomatic Python "safe attribute read" pattern and almost always
1051
+ * indicates the caller treats the result as a value, not a callable.
1052
+ *
1053
+ * Sink placement:
1054
+ * - DIRECT: sink at the line containing both the `getattr` call and the
1055
+ * trailing `(...)`. The engine's flow logic connects via the `name`
1056
+ * argument on that call site.
1057
+ * - ALIASED: sink at the BIND line (where `getattr(obj, name)` actually
1058
+ * references `name` as a call argument). Required because the
1059
+ * downstream invocation line (`fn()`) has zero args, and the engine's
1060
+ * `flows.push` path keys off tainted call arguments, not callees. The
1061
+ * aliased branch is still gated on detecting the subsequent invocation,
1062
+ * so a bare `value = getattr(obj, name)` with no later call does NOT
1063
+ * fire.
1064
+ */
1065
+ function findPythonReflectionInvocationSinks(sourceCode, taintedVars) {
1066
+ if (taintedVars.size === 0)
1067
+ return [];
1068
+ const sinks = [];
1069
+ const lines = sourceCode.split('\n');
1070
+ // 2-arg only: reject `getattr(obj, name, default)`. The 2nd group MUST
1071
+ // be a bare identifier (so we can check membership in taintedVars).
1072
+ const directRe = /\bgetattr\s*\(\s*[^,()]+\s*,\s*([A-Za-z_][\w]*)\s*\)\s*\(/;
1073
+ // Aliased binding: `alias = getattr(obj, name)` with strictly 2 args.
1074
+ const bindRe = /^\s*([A-Za-z_][\w]*)\s*=\s*getattr\s*\(\s*[^,()]+\s*,\s*([A-Za-z_][\w]*)\s*\)\s*$/;
1075
+ const aliases = [];
1076
+ for (let i = 0; i < lines.length; i++) {
1077
+ const line = lines[i];
1078
+ if (line.trimStart().startsWith('#'))
1079
+ continue;
1080
+ const dm = line.match(directRe);
1081
+ if (dm && taintedVars.has(dm[1])) {
1082
+ sinks.push({ sinkLine: i + 1, method: 'getattr' });
1083
+ continue;
1084
+ }
1085
+ const bm = line.match(bindRe);
1086
+ if (bm && taintedVars.has(bm[2])) {
1087
+ aliases.push({ name: bm[1], bindLine: i + 1 });
1088
+ }
1089
+ }
1090
+ if (aliases.length === 0)
1091
+ return sinks;
1092
+ // Second pass: for each alias, look for an invocation `<alias>(...)` on a
1093
+ // later line. If found, emit the sink at the BIND line (see header note).
1094
+ const firedBindLines = new Set();
1095
+ for (let i = 0; i < lines.length; i++) {
1096
+ const line = lines[i];
1097
+ if (line.trimStart().startsWith('#'))
1098
+ continue;
1099
+ const lineNum = i + 1;
1100
+ for (const a of aliases) {
1101
+ if (lineNum <= a.bindLine)
1102
+ continue;
1103
+ if (firedBindLines.has(a.bindLine))
1104
+ continue;
1105
+ // Plain invocation: `<alias>(...)`. Reject method-style `o.alias(...)`.
1106
+ const invokeRe = new RegExp(`(?<![\\w.])${a.name}\\s*\\(`);
1107
+ if (invokeRe.test(line)) {
1108
+ sinks.push({ sinkLine: a.bindLine, method: 'getattr' });
1109
+ firedBindLines.add(a.bindLine);
1110
+ }
1111
+ }
1112
+ }
1113
+ return sinks;
1114
+ }
973
1115
  function findJavaScriptDOMSinks(sourceCode, language) {
974
1116
  if (!['javascript', 'typescript'].includes(language))
975
1117
  return [];
@@ -1283,6 +1425,14 @@ export function findBashPatternFindings(sourceCode, file) {
1283
1425
  const findings = [];
1284
1426
  const lines = sourceCode.split('\n');
1285
1427
  const checksumVerifiedTmpPaths = collectChecksumVerifiedTmpPaths(lines);
1428
+ // Sprint 69 (#199): unverified-package-install — `dpkg -i`, `rpm -i/-U`,
1429
+ // `apt(-get|itude) install <.deb>`, `yum|dnf|zypper install <.rpm>` of a
1430
+ // file path that was not verified by a signature (gpg --verify / rpm
1431
+ // --checksig / dpkg --verify) or checksum (sha{1,256,512}sum -c / b2sum
1432
+ // -c) earlier in the script. The shape covers the daemon-pkg-install
1433
+ // CVE class (FN-CVE-B03) where a tainted URL is downloaded then
1434
+ // installed without integrity check.
1435
+ const scriptHasVerifier = hasIntegrityVerifierAnywhere(lines);
1286
1436
  for (let i = 0; i < lines.length; i++) {
1287
1437
  const line = lines[i];
1288
1438
  const trimmed = line.trim();
@@ -1385,9 +1535,124 @@ export function findBashPatternFindings(sourceCode, file) {
1385
1535
  snippet: trimmed.substring(0, 80),
1386
1536
  });
1387
1537
  }
1538
+ // 6. Unverified package install (Sprint 69, #199)
1539
+ if (!scriptHasVerifier) {
1540
+ const installer = matchUnverifiedPackageInstall(trimmed);
1541
+ if (installer) {
1542
+ findings.push({
1543
+ id: `unverified-package-install-${file}-${lineNumber}`,
1544
+ pass: 'language-sources',
1545
+ category: 'security',
1546
+ rule_id: 'unverified-package-install',
1547
+ cwe: 'CWE-494',
1548
+ severity: 'high',
1549
+ level: 'error',
1550
+ message: `Unverified package install via ${installer}: package contents are not integrity-checked (no gpg/sha256sum verify in script)`,
1551
+ file,
1552
+ line: lineNumber,
1553
+ snippet: trimmed.substring(0, 80),
1554
+ });
1555
+ }
1556
+ }
1557
+ // 7. Weak hash command (Sprint 71, #190): `md5`, `sha1`, `md5sum`,
1558
+ // `sha1sum` invoked as a command (pipeline or standalone). Modern
1559
+ // best-practice is `sha256sum`/`sha512sum`/`b2sum`. The verify form
1560
+ // (`-c`/`--check`) is informational, not a fresh hash emission, but
1561
+ // the algorithm is still broken — we still fire.
1562
+ const weakHashAlg = matchBashWeakHashCommand(trimmed);
1563
+ if (weakHashAlg) {
1564
+ findings.push({
1565
+ id: `weak-hash-${file}-${lineNumber}`,
1566
+ pass: 'language-sources',
1567
+ category: 'security',
1568
+ rule_id: 'weak-hash',
1569
+ cwe: 'CWE-328',
1570
+ severity: 'medium',
1571
+ level: 'warning',
1572
+ message: `Weak hash algorithm: ${weakHashAlg} is cryptographically broken. Use sha256sum or sha512sum`,
1573
+ file,
1574
+ line: lineNumber,
1575
+ snippet: trimmed.substring(0, 80),
1576
+ });
1577
+ }
1388
1578
  }
1389
1579
  return findings;
1390
1580
  }
1581
+ /**
1582
+ * Sprint 71 (#190): match a bare bash `md5` / `sha1` / `md5sum` / `sha1sum`
1583
+ * invocation. Returns the algorithm name when found, else null.
1584
+ *
1585
+ * Recognition rules:
1586
+ * - Word-boundary match on `md5`, `sha1`, `md5sum`, `sha1sum`.
1587
+ * - The token must appear at the start of the line OR immediately after
1588
+ * a pipeline operator (`|`) or shell separator (`;`, `&&`, `||`).
1589
+ * - Reject when the token appears inside an obvious algorithm-name
1590
+ * string literal (e.g. `"md5sum"` as an argv).
1591
+ */
1592
+ function matchBashWeakHashCommand(line) {
1593
+ // Algorithm name + boundary: either followed by whitespace, end-of-line,
1594
+ // a redirect, or another pipe.
1595
+ const re = /(?:^|[|;]|&&|\|\|)\s*(md5sum|sha1sum|md5|sha1)\b(?!\s*=)/;
1596
+ const m = line.match(re);
1597
+ if (!m)
1598
+ return null;
1599
+ // Reject `-c` / `--check` verify form? No — broken algorithm regardless.
1600
+ return m[1];
1601
+ }
1602
+ /**
1603
+ * Sprint 69 (#199): return the installer name when `line` is a package-install
1604
+ * command of a file PATH (not a registry package name).
1605
+ *
1606
+ * dpkg -i / -I / -U / --install → 'dpkg'
1607
+ * rpm -i / -U / --install / --upgrade → 'rpm'
1608
+ * apt-get|apt|aptitude install …/*.deb → 'apt-get'|'apt'|'aptitude'
1609
+ * yum|dnf|zypper install …/*.rpm → 'yum'|'dnf'|'zypper'
1610
+ *
1611
+ * For apt/yum/dnf/zypper the install target must be an explicit .deb/.rpm
1612
+ * file path (otherwise the call is a normal repository-managed install and
1613
+ * not the FN shape we model).
1614
+ */
1615
+ function matchUnverifiedPackageInstall(line) {
1616
+ // dpkg -i / -I / -U / --install
1617
+ if (/\bdpkg\b/.test(line) && /(?:^|\s)(?:-[a-zA-Z]*[iIU][a-zA-Z]*|--install)\b/.test(line)) {
1618
+ return 'dpkg';
1619
+ }
1620
+ // rpm -i / -U / --install / --upgrade (reject -e/--erase, -q/--query, -V/--verify)
1621
+ if (/\brpm\b/.test(line) && /(?:^|\s)(?:-[a-zA-Z]*[iU][a-zA-Z]*|--install|--upgrade)\b/.test(line)
1622
+ && !/--(?:verify|checksig|erase|query)\b/.test(line)) {
1623
+ return 'rpm';
1624
+ }
1625
+ // apt-get|apt|aptitude install <.deb path>
1626
+ const aptMatch = line.match(/\b(apt-get|apt|aptitude)\s+install\b/);
1627
+ if (aptMatch && /\.deb\b/.test(line)) {
1628
+ return aptMatch[1];
1629
+ }
1630
+ // yum|dnf|zypper install <.rpm path>
1631
+ const yumMatch = line.match(/\b(yum|dnf|zypper)\s+install\b/);
1632
+ if (yumMatch && /\.rpm\b/.test(line)) {
1633
+ return yumMatch[1];
1634
+ }
1635
+ return null;
1636
+ }
1637
+ /**
1638
+ * Sprint 69 (#199): True iff the script contains any integrity-verifier
1639
+ * invocation that would gate a subsequent install (signature or checksum).
1640
+ * Whole-script check, not per-path — matches the corpus's "verify-then-
1641
+ * install" idiom where the gpg/sha verify references a separate path or
1642
+ * inline data.
1643
+ */
1644
+ function hasIntegrityVerifierAnywhere(lines) {
1645
+ const sigRe = /\b(?:gpg(?:v|2)?|gpg)\s+(?:[^|]*\s)?--verify\b/;
1646
+ const rpmSigRe = /\brpm\s+(?:[^|]*\s)?--checksig\b/;
1647
+ const dpkgSigRe = /\bdpkg\s+(?:[^|]*\s)?--verify\b/;
1648
+ const sumRe = /\b(?:sha(?:1|224|256|384|512)sum|md5sum|cksum|b2sum)\s+(?:[^|]*\s)?(?:-c|--check)\b/;
1649
+ for (const line of lines) {
1650
+ if (sigRe.test(line) || rpmSigRe.test(line) || dpkgSigRe.test(line) || sumRe.test(line)) {
1651
+ return true;
1652
+ }
1653
+ }
1654
+ return false;
1655
+ }
1391
1656
  // ---------------------------------------------------------------------------
1392
1657
  // Bash regex-allowlist sanitizers (Sprint 11 — #73.2)
1393
1658
  // ---------------------------------------------------------------------------
@@ -1987,4 +2252,679 @@ function findRustCanonicalizeGuardSanitizers(code) {
1987
2252
  }
1988
2253
  return sanitizers;
1989
2254
  }
2255
+ // ---------------------------------------------------------------------------
2256
+ // Sprint 73 (#216 Pattern A + B) — JS/Java safe-handler sanitizer
2257
+ // recognition for the synthetic `external_taint_escape` (CWE-668) fallback
2258
+ // flow, plus JS user-defined wrapper-function recognition for `xss` /
2259
+ // `log_injection`.
2260
+ //
2261
+ // Suppression mechanism: `taint-propagation-pass.ts:198-232` already
2262
+ // filters ETE flows when ANY sanitizer between source and sink lists
2263
+ // `external_taint_escape` in its `sanitizes` array. These detectors emit
2264
+ // the sanitizer entries so the existing filter can do its job.
2265
+ //
2266
+ // Conservative scoping: each detector restricts `sanitizes` to the
2267
+ // minimum justifiable set so configured (real) sinks are unaffected.
2268
+ // ---------------------------------------------------------------------------
2269
+ /**
2270
+ * Java: Jackson `mapper.readValue(...)` and Gson `gson.fromJson(...)`
2271
+ * construct typed POJOs from JSON. Without `enableDefaultTyping` they
2272
+ * cannot instantiate attacker-chosen classes, so the synthetic ETE
2273
+ * fallback over-fires. The configured `deserialization` sink remains
2274
+ * unaffected (different sink_type, gated at the sink line).
2275
+ */
2276
+ function findJavaSafeJsonParseSanitizers(code) {
2277
+ const sanitizers = [];
2278
+ const lines = code.split('\n');
2279
+ const safeParse = /\.(?:readValue|fromJson)\s*\(/;
2280
+ for (let i = 0; i < lines.length; i++) {
2281
+ if (!safeParse.test(lines[i]))
2282
+ continue;
2283
+ sanitizers.push({
2284
+ type: 'java_safe_json_parse',
2285
+ method: 'readValue',
2286
+ line: i + 1,
2287
+ sanitizes: ['external_taint_escape'],
2288
+ });
2289
+ }
2290
+ return sanitizers;
2291
+ }
2292
+ /**
2293
+ * JS: `JSON.parse(...)` produces only primitives / plain objects. It
2294
+ * cannot execute code (unlike `eval` / `Function` / `vm.runInNewContext`,
2295
+ * which remain configured sinks).
2296
+ */
2297
+ function findJsSafeJsonParseSanitizers(code) {
2298
+ const sanitizers = [];
2299
+ const lines = code.split('\n');
2300
+ const safeParse = /\bJSON\.parse\s*\(/;
2301
+ for (let i = 0; i < lines.length; i++) {
2302
+ if (!safeParse.test(lines[i]))
2303
+ continue;
2304
+ sanitizers.push({
2305
+ type: 'js_safe_json_parse',
2306
+ method: 'JSON.parse',
2307
+ line: i + 1,
2308
+ sanitizes: ['external_taint_escape'],
2309
+ });
2310
+ }
2311
+ return sanitizers;
2312
+ }
2313
+ /**
2314
+ * JS: one-way crypto hashes destroy original content. Bcrypt/argon2/
2315
+ * scrypt/createHash...digest cannot be reversed. The `weak-password-hash`
2316
+ * rule fires independently for MD5/SHA1 algorithms.
2317
+ */
2318
+ function findJsCryptoHashSanitizers(code) {
2319
+ const sanitizers = [];
2320
+ const lines = code.split('\n');
2321
+ // bcrypt.hash(...) / bcrypt.hashSync(...)
2322
+ // argon2.hash(...)
2323
+ // crypto.scrypt(...) / crypto.scryptSync(...)
2324
+ // crypto.createHash(...) / hash.digest(...)
2325
+ const hashCall = /\b(?:bcrypt|argon2)\s*\.\s*hash(?:Sync)?\s*\(|\bcrypto\s*\.\s*(?:scrypt(?:Sync)?|createHash)\s*\(|\.digest\s*\(/;
2326
+ for (let i = 0; i < lines.length; i++) {
2327
+ if (!hashCall.test(lines[i]))
2328
+ continue;
2329
+ sanitizers.push({
2330
+ type: 'js_crypto_hash',
2331
+ method: 'hash',
2332
+ line: i + 1,
2333
+ sanitizes: ['external_taint_escape'],
2334
+ });
2335
+ }
2336
+ return sanitizers;
2337
+ }
2338
+ /**
2339
+ * JS: Excel-formula-injection sanitizer. Prepending a literal single
2340
+ * quote (`'`) to a CSV cell prevents Excel/LibreOffice from interpreting
2341
+ * the value as a formula. Scoped to ETE only — the `'` prefix does NOT
2342
+ * mitigate xss, command_injection, sql_injection, etc.
2343
+ */
2344
+ function findJsCsvFormulaPrefixSanitizers(code) {
2345
+ const sanitizers = [];
2346
+ const lines = code.split('\n');
2347
+ // Template literal `'${x}...` — leading `'` inside backticks.
2348
+ const tickPrefix = /`'\$\{/;
2349
+ // Concat form "'" + x or '\'' + x
2350
+ const concatPrefix = /["']\\?'["']\s*\+\s*[A-Za-z_]/;
2351
+ for (let i = 0; i < lines.length; i++) {
2352
+ if (tickPrefix.test(lines[i]) || concatPrefix.test(lines[i])) {
2353
+ sanitizers.push({
2354
+ type: 'js_csv_formula_prefix',
2355
+ method: "'-prefix",
2356
+ line: i + 1,
2357
+ sanitizes: ['external_taint_escape'],
2358
+ });
2359
+ }
2360
+ }
2361
+ return sanitizers;
2362
+ }
2363
+ /**
2364
+ * JS: user-defined wrapper functions that perform a `.replace(...)`
2365
+ * against a recognizable threat-character class. Two-pass:
2366
+ *
2367
+ * 1. Discover wrappers — `function NAME(...) { ... .replace(/.../...) }`
2368
+ * or arrow form `const NAME = (...) => { ... .replace(...) }`.
2369
+ * Classify by the character class:
2370
+ * - `[&<>"']` → xss + external_taint_escape
2371
+ * - `[\r\n\t]` → log_injection + external_taint_escape
2372
+ * 2. For every call site `NAME(...)`, emit a sanitizer at that line
2373
+ * listing the wrapper's kinds.
2374
+ *
2375
+ * Conservative: requires explicit `.replace` against a recognizable
2376
+ * threat-set. Custom wrappers using different escape strategies (e.g.
2377
+ * URL encode) are not yet covered.
2378
+ */
2379
+ function findJsWrapperFunctionSanitizers(code) {
2380
+ const sanitizers = [];
2381
+ const lines = code.split('\n');
2382
+ const wrappers = new Map();
2383
+ const fnDeclRe = /\bfunction\s+([A-Za-z_]\w*)\s*\(/;
2384
+ const arrowDeclRe = /\b(?:const|let|var)\s+([A-Za-z_]\w*)\s*=\s*(?:\([^)]*\)|[A-Za-z_]\w*)\s*=>/;
2385
+ // Threat-char classes — must appear inside a .replace(/.../, ...) call.
2386
+ const xssCharClass = /\.replace\s*\(\s*\/[^/]*[&<>"'][^/]*\//;
2387
+ const crlfCharClass = /\.replace\s*\(\s*\/[^/]*(?:\\r|\\n|\\t)[^/]*\//;
2388
+ for (let i = 0; i < lines.length; i++) {
2389
+ const m = fnDeclRe.exec(lines[i]) ?? arrowDeclRe.exec(lines[i]);
2390
+ if (!m)
2391
+ continue;
2392
+ const name = m[1];
2393
+ // Scan up to 8 lines for the body's .replace(...) call.
2394
+ const body = lines.slice(i, Math.min(lines.length, i + 8)).join('\n');
2395
+ const kinds = new Set();
2396
+ if (xssCharClass.test(body)) {
2397
+ kinds.add('xss');
2398
+ kinds.add('external_taint_escape');
2399
+ }
2400
+ if (crlfCharClass.test(body)) {
2401
+ kinds.add('log_injection');
2402
+ kinds.add('external_taint_escape');
2403
+ }
2404
+ if (kinds.size > 0)
2405
+ wrappers.set(name, kinds);
2406
+ }
2407
+ if (wrappers.size === 0)
2408
+ return sanitizers;
2409
+ for (let i = 0; i < lines.length; i++) {
2410
+ for (const [name, kinds] of wrappers) {
2411
+ // Skip the declaration line itself.
2412
+ const declSelf = new RegExp(`\\b(?:function|const|let|var)\\s+${name}\\b`);
2413
+ if (declSelf.test(lines[i]))
2414
+ continue;
2415
+ const callRe = new RegExp(`\\b${name}\\s*\\(`);
2416
+ if (!callRe.test(lines[i]))
2417
+ continue;
2418
+ sanitizers.push({
2419
+ type: 'js_wrapper_function',
2420
+ method: name,
2421
+ line: i + 1,
2422
+ sanitizes: [...kinds],
2423
+ });
2424
+ }
2425
+ }
2426
+ return sanitizers;
2427
+ }
2428
+ // ---------------------------------------------------------------------------
2429
+ // Sprint 70 (#151) — external-secret-exfiltration composed-flow detection.
2430
+ //
2431
+ // Models the trust-corpus FN-TQ-01 shape: an env-read secret variable that
2432
+ // is transmitted in the BODY of an outbound HTTPS request to a non-internal
2433
+ // host. The detector is intentionally narrow:
2434
+ //
2435
+ // - SOURCE: env read assigned to a local var (Python `os.environ` /
2436
+ // `os.getenv`, JS/TS `process.env.X`, Go `os.Getenv`).
2437
+ // - SINK: HTTP POST/PUT/PATCH/DELETE/request to a literal URL whose host
2438
+ // is NOT internal (loopback, RFC1918, `.internal.`, `.local`, `.lan`,
2439
+ // `.corp`, or single-label intranet).
2440
+ // - GATE: secret var (or a JS carrier var defined from a secret) appears
2441
+ // in the request body / form args, NOT exclusively in headers /
2442
+ // Authorization context.
2443
+ //
2444
+ // CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor).
2445
+ // ---------------------------------------------------------------------------
2446
+ function collectEnvSecretVars(lines, language) {
2447
+ const out = new Map();
2448
+ for (let i = 0; i < lines.length; i++) {
2449
+ const line = lines[i];
2450
+ if (language === 'python') {
2451
+ // var = os.environ["..."] | os.environ.get(...) | os.getenv(...)
2452
+ const m = line.match(/^\s*(\w+)\s*=\s*os\.(?:environ\s*[\[.]|getenv\b)/);
2453
+ if (m)
2454
+ out.set(m[1], i + 1);
2455
+ }
2456
+ else if (language === 'javascript' || language === 'typescript') {
2457
+ // const/let/var X = process.env.NAME | process.env["NAME"]
2458
+ const m = line.match(/(?:^|\s|;)(?:const|let|var)\s+(\w+)\s*=\s*process\.env\b/);
2459
+ if (m)
2460
+ out.set(m[1], i + 1);
2461
+ }
2462
+ else if (language === 'go') {
2463
+ // X := os.Getenv("...") or X = os.Getenv(...)
2464
+ const m = line.match(/^\s*(\w+)\s*:?=\s*os\.Getenv\s*\(/);
2465
+ if (m)
2466
+ out.set(m[1], i + 1);
2467
+ }
2468
+ }
2469
+ return out;
2470
+ }
2471
+ function isExternalHostUrl(url) {
2472
+ const lower = url.toLowerCase();
2473
+ if (!/^https?:\/\//.test(lower))
2474
+ return false;
2475
+ const host = lower.replace(/^https?:\/\//, '').split(/[/?#:]/)[0];
2476
+ if (!host)
2477
+ return false;
2478
+ if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1')
2479
+ return false;
2480
+ if (/^10\./.test(host))
2481
+ return false;
2482
+ if (/^192\.168\./.test(host))
2483
+ return false;
2484
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
2485
+ return false;
2486
+ // Common internal markers
2487
+ if (host.includes('.internal.') || host.endsWith('.internal') || host.startsWith('internal.'))
2488
+ return false;
2489
+ if (host.endsWith('.local') || host.endsWith('.lan') || host.endsWith('.corp'))
2490
+ return false;
2491
+ // Single-label hostnames (no dot) are intranet
2492
+ if (!host.includes('.'))
2493
+ return false;
2494
+ return true;
2495
+ }
2496
+ // Walk forward from `start` (first char inside open paren) until the matching
2497
+ // close paren is found. Honors string literals to avoid false matches.
2498
+ function findBalancedCallEnd(code, start) {
2499
+ let depth = 1;
2500
+ let i = start;
2501
+ let inStr = null;
2502
+ while (i < code.length) {
2503
+ const ch = code[i];
2504
+ if (inStr) {
2505
+ if (ch === '\\') {
2506
+ i += 2;
2507
+ continue;
2508
+ }
2509
+ if (ch === inStr)
2510
+ inStr = null;
2511
+ }
2512
+ else {
2513
+ if (ch === '"' || ch === "'" || ch === '`')
2514
+ inStr = ch;
2515
+ else if (ch === '(')
2516
+ depth++;
2517
+ else if (ch === ')') {
2518
+ depth--;
2519
+ if (depth === 0)
2520
+ return i;
2521
+ }
2522
+ }
2523
+ i++;
2524
+ }
2525
+ return -1;
2526
+ }
2527
+ // Walk forward from `start` until a top-level comma or end-of-args is found.
2528
+ // Used to extract a kwarg's value substring (Python `headers=…` etc.).
2529
+ function findKwargValueEnd(s, start) {
2530
+ let depth = 0;
2531
+ let inStr = null;
2532
+ for (let i = start; i < s.length; i++) {
2533
+ const ch = s[i];
2534
+ if (inStr) {
2535
+ if (ch === '\\') {
2536
+ i++;
2537
+ continue;
2538
+ }
2539
+ if (ch === inStr)
2540
+ inStr = null;
2541
+ continue;
2542
+ }
2543
+ if (ch === '"' || ch === "'" || ch === '`') {
2544
+ inStr = ch;
2545
+ continue;
2546
+ }
2547
+ if (ch === '(' || ch === '[' || ch === '{')
2548
+ depth++;
2549
+ else if (ch === ')' || ch === ']' || ch === '}') {
2550
+ if (depth === 0)
2551
+ return i;
2552
+ depth--;
2553
+ }
2554
+ else if (ch === ',' && depth === 0) {
2555
+ return i;
2556
+ }
2557
+ }
2558
+ return s.length;
2559
+ }
2560
+ function lineOfCharIndex(code, charIdx) {
2561
+ let line = 1;
2562
+ for (let i = 0; i < charIdx && i < code.length; i++) {
2563
+ if (code[i] === '\n')
2564
+ line++;
2565
+ }
2566
+ return line;
2567
+ }
2568
+ function makeExfilFinding(file, line, snippet, fired, receiver) {
2569
+ return {
2570
+ id: `external-secret-exfiltration-${file}-${line}`,
2571
+ pass: 'language-sources',
2572
+ category: 'security',
2573
+ rule_id: 'external-secret-exfiltration',
2574
+ cwe: 'CWE-200',
2575
+ severity: 'high',
2576
+ level: 'error',
2577
+ message: `Environment secret(s) ${fired.join(', ')} transmitted in request body to external host via ${receiver}`,
2578
+ file,
2579
+ line,
2580
+ snippet: snippet.substring(0, 120),
2581
+ };
2582
+ }
2583
+ export function findExternalSecretExfiltrationFindings(code, file, language) {
2584
+ const out = [];
2585
+ const lines = code.split('\n');
2586
+ const secretVars = collectEnvSecretVars(lines, language);
2587
+ if (secretVars.size === 0)
2588
+ return out;
2589
+ if (language === 'python') {
2590
+ out.push(...findPythonExfilCalls(code, file, secretVars));
2591
+ }
2592
+ else if (language === 'javascript' || language === 'typescript') {
2593
+ out.push(...findJavaScriptExfilCalls(code, file, secretVars));
2594
+ }
2595
+ else if (language === 'go') {
2596
+ out.push(...findGoExfilCalls(code, file, secretVars));
2597
+ }
2598
+ return out;
2599
+ }
2600
+ function findPythonExfilCalls(code, file, secretVars) {
2601
+ const out = [];
2602
+ const callRe = /\b(requests|httpx)\.(post|put|patch|delete|request)\s*\(/g;
2603
+ let m;
2604
+ while ((m = callRe.exec(code))) {
2605
+ const argStart = m.index + m[0].length;
2606
+ const argEnd = findBalancedCallEnd(code, argStart);
2607
+ if (argEnd < 0)
2608
+ continue;
2609
+ const args = code.slice(argStart, argEnd);
2610
+ // First arg is URL literal (skip leading whitespace/newlines)
2611
+ const urlMatch = args.match(/^\s*["']([^"']+)["']/);
2612
+ if (!urlMatch)
2613
+ continue;
2614
+ if (!isExternalHostUrl(urlMatch[1]))
2615
+ continue;
2616
+ // Split into body-context vs headers-context
2617
+ const headersIdx = args.search(/\bheaders\s*=/);
2618
+ let headersStr = '';
2619
+ let bodyStr = args;
2620
+ if (headersIdx >= 0) {
2621
+ const eqIdx = args.indexOf('=', headersIdx);
2622
+ const valueStart = eqIdx + 1;
2623
+ const headersEnd = findKwargValueEnd(args, valueStart);
2624
+ headersStr = args.slice(valueStart, headersEnd);
2625
+ bodyStr = args.slice(0, headersIdx) + ' ' + args.slice(headersEnd);
2626
+ }
2627
+ const fired = [];
2628
+ for (const v of secretVars.keys()) {
2629
+ const re = new RegExp(`\\b${v}\\b`);
2630
+ if (re.test(bodyStr) && !re.test(headersStr))
2631
+ fired.push(v);
2632
+ }
2633
+ if (fired.length > 0) {
2634
+ const line = lineOfCharIndex(code, m.index);
2635
+ out.push(makeExfilFinding(file, line, code.slice(m.index, Math.min(argEnd + 1, m.index + 120)), fired, `${m[1]}.${m[2]}`));
2636
+ }
2637
+ }
2638
+ return out;
2639
+ }
2640
+ function findJavaScriptExfilCalls(code, file, secretVars) {
2641
+ const out = [];
2642
+ const allLines = code.split('\n');
2643
+ // Build carrier vars: any `const|let|var NAME = …<secret-ref>…`
2644
+ const carriers = new Set();
2645
+ const carrierRe = /(?:^|[\s;{])(?:const|let|var)\s+(\w+)\s*=\s*([^;\n]+)/g;
2646
+ let cm;
2647
+ while ((cm = carrierRe.exec(code))) {
2648
+ const name = cm[1];
2649
+ const rhs = cm[2];
2650
+ for (const v of secretVars.keys()) {
2651
+ if (new RegExp(`\\b${v}\\b`).test(rhs)) {
2652
+ carriers.add(name);
2653
+ break;
2654
+ }
2655
+ }
2656
+ }
2657
+ const taintedRefs = new Set([...secretVars.keys(), ...carriers]);
2658
+ // Match common outbound network call shapes
2659
+ const networkRe = /\b(https?\.(?:request|get)|fetch|axios\.(?:post|put|patch|request|delete))\s*\(/g;
2660
+ let m;
2661
+ while ((m = networkRe.exec(code))) {
2662
+ const argStart = m.index + m[0].length;
2663
+ const argEnd = findBalancedCallEnd(code, argStart);
2664
+ if (argEnd < 0)
2665
+ continue;
2666
+ const args = code.slice(argStart, argEnd);
2667
+ // URL literal (string or template)
2668
+ const urlMatch = args.match(/^\s*(?:["']([^"']+)["']|`([^`$]+)`)/);
2669
+ if (!urlMatch)
2670
+ continue;
2671
+ const url = urlMatch[1] || urlMatch[2];
2672
+ if (!isExternalHostUrl(url))
2673
+ continue;
2674
+ // Split body vs headers within the call args. JS uses object literal
2675
+ // fields `headers: {…}` and (optionally) `body: …`.
2676
+ const headersIdx = args.search(/\bheaders\s*:/);
2677
+ let headersStr = '';
2678
+ let restStr = args;
2679
+ if (headersIdx >= 0) {
2680
+ const valueStart = args.indexOf(':', headersIdx) + 1;
2681
+ const headersEnd = findKwargValueEnd(args, valueStart);
2682
+ headersStr = args.slice(valueStart, headersEnd);
2683
+ restStr = args.slice(0, headersIdx) + ' ' + args.slice(headersEnd);
2684
+ }
2685
+ const fired = new Set();
2686
+ for (const v of taintedRefs) {
2687
+ const re = new RegExp(`\\b${v}\\b`);
2688
+ if (re.test(restStr) && !re.test(headersStr))
2689
+ fired.add(v);
2690
+ }
2691
+ // Forward scan: req.write(VAR) / req.end(VAR) within next 20 lines
2692
+ const callLine = lineOfCharIndex(code, m.index);
2693
+ const windowEnd = Math.min(allLines.length, callLine + 20);
2694
+ const writeWindow = allLines.slice(callLine, windowEnd).join('\n');
2695
+ const writeRe = /\b\w+\.(?:write|end)\s*\(\s*(\w+)/g;
2696
+ let wm;
2697
+ while ((wm = writeRe.exec(writeWindow))) {
2698
+ if (taintedRefs.has(wm[1]))
2699
+ fired.add(wm[1]);
2700
+ }
2701
+ if (fired.size > 0) {
2702
+ out.push(makeExfilFinding(file, callLine, code.slice(m.index, Math.min(argEnd + 1, m.index + 120)), [...fired], m[1]));
2703
+ }
2704
+ }
2705
+ return out;
2706
+ }
2707
+ function findGoExfilCalls(code, file, secretVars) {
2708
+ const out = [];
2709
+ const patterns = [
2710
+ { re: /\bhttp\.PostForm\s*\(/g, urlArgIndex: 0, receiver: 'http.PostForm' },
2711
+ { re: /\bhttp\.Post\s*\(/g, urlArgIndex: 0, receiver: 'http.Post' },
2712
+ { re: /\bhttp\.NewRequest\s*\(/g, urlArgIndex: 1, receiver: 'http.NewRequest' },
2713
+ ];
2714
+ for (const { re, urlArgIndex, receiver } of patterns) {
2715
+ let m;
2716
+ while ((m = re.exec(code))) {
2717
+ const argStart = m.index + m[0].length;
2718
+ const argEnd = findBalancedCallEnd(code, argStart);
2719
+ if (argEnd < 0)
2720
+ continue;
2721
+ const args = code.slice(argStart, argEnd);
2722
+ // Skip the leading non-URL arg(s) when needed
2723
+ let urlSearchSlice = args;
2724
+ if (urlArgIndex === 1) {
2725
+ const firstComma = findKwargValueEnd(args, 0);
2726
+ if (firstComma >= args.length)
2727
+ continue;
2728
+ urlSearchSlice = args.slice(firstComma + 1);
2729
+ }
2730
+ const urlMatch = urlSearchSlice.match(/^\s*["`]([^"`]+)["`]/);
2731
+ if (!urlMatch)
2732
+ continue;
2733
+ if (!isExternalHostUrl(urlMatch[1]))
2734
+ continue;
2735
+ const fired = [];
2736
+ for (const v of secretVars.keys()) {
2737
+ if (new RegExp(`\\b${v}\\b`).test(args))
2738
+ fired.push(v);
2739
+ }
2740
+ if (fired.length > 0) {
2741
+ const line = lineOfCharIndex(code, m.index);
2742
+ out.push(makeExfilFinding(file, line, code.slice(m.index, Math.min(argEnd + 1, m.index + 120)), fired, receiver));
2743
+ }
2744
+ }
2745
+ }
2746
+ return out;
2747
+ }
2748
+ // ---------------------------------------------------------------------------
2749
+ // Sprint 71 (#190) — Python pattern-finding extensions for cells where the
2750
+ // existing detectors only cover non-Python shapes.
2751
+ // ---------------------------------------------------------------------------
2752
+ /**
2753
+ * Emit Python `cors-wildcard-origin`, `xfo-csp-mismatch`, and
2754
+ * `tls-verify-disabled` findings for the subscript-assignment / context-
2755
+ * assignment shapes that the language-agnostic detectors miss.
2756
+ *
2757
+ * cors-wildcard-origin:
2758
+ * `resp.headers['Access-Control-Allow-Origin'] = '*'`
2759
+ *
2760
+ * xfo-csp-mismatch:
2761
+ * `resp.headers['X-Frame-Options'] = 'DENY' | 'SAMEORIGIN'`
2762
+ * correlated with
2763
+ * `resp.headers['Content-Security-Policy'] = '... frame-ancestors *|http* ...'`
2764
+ *
2765
+ * tls-verify-disabled:
2766
+ * `ctx.verify_mode = ssl.CERT_NONE` OR
2767
+ * `ctx.check_hostname = False`
2768
+ */
2769
+ export function findPythonPatternFindings(code, file) {
2770
+ const out = [];
2771
+ const lines = code.split('\n');
2772
+ // Subscript-style header assignment: `<recv>.headers['NAME'] = 'VAL'`
2773
+ // `NAME` is any quoted key (single or double). `VAL` is anything to EOL.
2774
+ const subscriptHeaderRe = /\.headers\s*\[\s*['"]([^'"]+)['"]\s*\]\s*=\s*(.+)$/;
2775
+ const xfoHits = [];
2776
+ const cspHits = [];
2777
+ for (let i = 0; i < lines.length; i++) {
2778
+ const raw = lines[i];
2779
+ const trimmed = raw.trim();
2780
+ if (!trimmed || trimmed.startsWith('#'))
2781
+ continue;
2782
+ const lineNumber = i + 1;
2783
+ const sm = trimmed.match(subscriptHeaderRe);
2784
+ if (sm) {
2785
+ const headerName = sm[1];
2786
+ const rhs = sm[2].trim();
2787
+ const headerLower = headerName.toLowerCase();
2788
+ // 1. cors-wildcard-origin
2789
+ if (headerLower === 'access-control-allow-origin') {
2790
+ // Wildcard literal — single, double, or echoed back any-origin var.
2791
+ // Conservative: only fire on a bare '*' literal.
2792
+ const valMatch = rhs.match(/^['"]\*['"]/);
2793
+ if (valMatch) {
2794
+ out.push({
2795
+ id: `cors-wildcard-origin-${file}-${lineNumber}`,
2796
+ pass: 'language-sources',
2797
+ category: 'security',
2798
+ rule_id: 'cors-wildcard-origin',
2799
+ cwe: 'CWE-942',
2800
+ severity: 'medium',
2801
+ level: 'warning',
2802
+ message: "CORS Access-Control-Allow-Origin set to wildcard '*': any origin may read responses",
2803
+ file,
2804
+ line: lineNumber,
2805
+ snippet: trimmed.substring(0, 100),
2806
+ });
2807
+ }
2808
+ }
2809
+ // Collect for xfo-csp correlation
2810
+ if (headerLower === 'x-frame-options') {
2811
+ xfoHits.push({ line: lineNumber, value: rhs });
2812
+ }
2813
+ else if (headerLower === 'content-security-policy') {
2814
+ cspHits.push({ line: lineNumber, value: rhs });
2815
+ }
2816
+ }
2817
+ // 3. tls-verify-disabled (ssl context post-create mutation)
2818
+ // `<x>.verify_mode = ssl.CERT_NONE` OR
2819
+ // `<x>.check_hostname = False`
2820
+ if (/\bverify_mode\s*=\s*ssl\.CERT_NONE\b/.test(trimmed)) {
2821
+ out.push({
2822
+ id: `tls-verify-disabled-${file}-${lineNumber}`,
2823
+ pass: 'language-sources',
2824
+ category: 'security',
2825
+ rule_id: 'tls-verify-disabled',
2826
+ cwe: 'CWE-295',
2827
+ severity: 'high',
2828
+ level: 'error',
2829
+ message: 'TLS certificate verification disabled: ssl context verify_mode set to CERT_NONE',
2830
+ file,
2831
+ line: lineNumber,
2832
+ snippet: trimmed.substring(0, 100),
2833
+ });
2834
+ }
2835
+ else if (/\bcheck_hostname\s*=\s*False\b/.test(trimmed)) {
2836
+ out.push({
2837
+ id: `tls-verify-disabled-${file}-${lineNumber}`,
2838
+ pass: 'language-sources',
2839
+ category: 'security',
2840
+ rule_id: 'tls-verify-disabled',
2841
+ cwe: 'CWE-295',
2842
+ severity: 'high',
2843
+ level: 'error',
2844
+ message: 'TLS hostname verification disabled: ssl context check_hostname set to False',
2845
+ file,
2846
+ line: lineNumber,
2847
+ snippet: trimmed.substring(0, 100),
2848
+ });
2849
+ }
2850
+ }
2851
+ // 2. xfo-csp-mismatch — fire iff at least one XFO=DENY|SAMEORIGIN and at
2852
+ // least one CSP with permissive `frame-ancestors` (wildcard or http*).
2853
+ // The mismatch indicates the dev set XFO to deny framing but a
2854
+ // permissive CSP `frame-ancestors` directive overrides XFO on modern
2855
+ // browsers (CSP wins).
2856
+ const restrictiveXfo = xfoHits.find(h => /['"](?:DENY|SAMEORIGIN)['"]/i.test(h.value));
2857
+ const permissiveCsp = cspHits.find(h => {
2858
+ // Extract the policy string body.
2859
+ const pm = h.value.match(/['"]([^'"]+)['"]/);
2860
+ if (!pm)
2861
+ return false;
2862
+ const policy = pm[1];
2863
+ const faMatch = policy.match(/frame-ancestors\s+([^;]+)/i);
2864
+ if (!faMatch)
2865
+ return false;
2866
+ const directive = faMatch[1].trim();
2867
+ // Permissive iff bare *, contains http://, or contains https://* wildcard host
2868
+ return /(^|\s)\*(\s|$)/.test(directive) || /\bhttps?:\/\//i.test(directive);
2869
+ });
2870
+ if (restrictiveXfo && permissiveCsp) {
2871
+ out.push({
2872
+ id: `xfo-csp-mismatch-${file}-${restrictiveXfo.line}`,
2873
+ pass: 'language-sources',
2874
+ category: 'security',
2875
+ rule_id: 'xfo-csp-mismatch',
2876
+ cwe: 'CWE-1021',
2877
+ severity: 'medium',
2878
+ level: 'warning',
2879
+ message: 'X-Frame-Options/CSP frame-ancestors mismatch: XFO restricts framing but CSP frame-ancestors is permissive (CSP overrides on modern browsers)',
2880
+ file,
2881
+ line: restrictiveXfo.line,
2882
+ snippet: lines[restrictiveXfo.line - 1].trim().substring(0, 100),
2883
+ });
2884
+ }
2885
+ return out;
2886
+ }
2887
+ // ---------------------------------------------------------------------------
2888
+ // Sprint 71 (#190) — Rust pattern-finding extensions.
2889
+ // ---------------------------------------------------------------------------
2890
+ /**
2891
+ * Emit Rust `tls-verify-disabled` for `reqwest`-style builders that opt out
2892
+ * of certificate / hostname validation:
2893
+ *
2894
+ * `.danger_accept_invalid_certs(true)`
2895
+ * `.danger_accept_invalid_hostnames(true)`
2896
+ *
2897
+ * Conservative: requires the literal `true` argument; `false` (re-enable) is
2898
+ * benign and is ignored.
2899
+ */
2900
+ export function findRustPatternFindings(code, file) {
2901
+ const out = [];
2902
+ const lines = code.split('\n');
2903
+ const re = /\.\s*(danger_accept_invalid_certs|danger_accept_invalid_hostnames)\s*\(\s*true\s*\)/;
2904
+ for (let i = 0; i < lines.length; i++) {
2905
+ const raw = lines[i];
2906
+ const trimmed = raw.trim();
2907
+ if (!trimmed || trimmed.startsWith('//'))
2908
+ continue;
2909
+ const m = trimmed.match(re);
2910
+ if (!m)
2911
+ continue;
2912
+ const method = m[1];
2913
+ const what = method === 'danger_accept_invalid_certs' ? 'certificate' : 'hostname';
2914
+ out.push({
2915
+ id: `tls-verify-disabled-${file}-${i + 1}`,
2916
+ pass: 'language-sources',
2917
+ category: 'security',
2918
+ rule_id: 'tls-verify-disabled',
2919
+ cwe: 'CWE-295',
2920
+ severity: 'high',
2921
+ level: 'error',
2922
+ message: `TLS ${what} verification disabled: reqwest builder ${method}(true)`,
2923
+ file,
2924
+ line: i + 1,
2925
+ snippet: trimmed.substring(0, 100),
2926
+ });
2927
+ }
2928
+ return out;
2929
+ }
1990
2930
  //# sourceMappingURL=language-sources-pass.js.map