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.
- package/dist/analysis/interprocedural.d.ts.map +1 -1
- package/dist/analysis/interprocedural.js +61 -16
- package/dist/analysis/interprocedural.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts +30 -0
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +940 -0
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/taint-matcher.d.ts.map +1 -1
- package/dist/analysis/taint-matcher.js +4 -0
- package/dist/analysis/taint-matcher.js.map +1 -1
- package/dist/browser/circle-ir.js +672 -3
- package/dist/core/circle-ir-core.cjs +5 -1
- package/dist/core/circle-ir-core.js +5 -1
- package/package.json +1 -1
|
@@ -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
|