@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +660 -0
- package/bin/agentic-security.js +78 -1
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +90 -0
- package/dist/444.index.js +89 -0
- package/dist/503.index.js +288 -0
- package/dist/637.index.js +15 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -1
- package/dist/agentic-security.mjs +84 -84
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +21 -10
- package/src/dataflow/CLAUDE.md +18 -6
- package/src/dataflow/catalog.js +59 -11
- package/src/dataflow/engine.js +106 -5
- package/src/dataflow/implicit-flow.js +4 -2
- package/src/dataflow/index.js +17 -4
- package/src/dataflow/proof-gate.js +100 -0
- package/src/dataflow/summaries.js +63 -6
- package/src/engine.js +292 -10
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/callgraph.js +12 -1
- package/src/ir/parser-go.js +8 -0
- package/src/ir/tree-sitter-loader.js +108 -0
- package/src/mcp/tools.js +6 -2
- package/src/posture/corpus-status.js +74 -0
- package/src/posture/coverage-report.js +113 -0
- package/src/posture/fix-history.js +24 -0
- package/src/posture/holdout-eval.js +57 -0
- package/src/posture/provenance.js +39 -0
- package/src/posture/router.js +33 -3
- package/src/posture/sca-verdict.js +130 -0
- package/src/posture/secret-history.js +72 -0
- package/src/pr-delta.js +13 -0
- package/src/report/index.js +67 -0
- package/src/sast/CLAUDE.md +9 -3
- package/src/sast/agent-untrusted-flow.js +107 -0
- package/src/sast/api-authz.js +71 -0
- package/src/sast/code-injection-multilang.js +117 -0
- package/src/sast/cross-service.js +98 -0
- package/src/sast/crypto-protocol.js +43 -1
- package/src/sast/csharp-structural.js +65 -0
- package/src/sast/csharp.js +3 -0
- package/src/sast/csrf.js +24 -3
- package/src/sast/csv-injection.js +84 -0
- package/src/sast/event-entrypoint.js +90 -0
- package/src/sast/frontend-hygiene.js +106 -0
- package/src/sast/go-structural.js +60 -0
- package/src/sast/iac-terraform.js +107 -0
- package/src/sast/java-structural.js +81 -0
- package/src/sast/js-framework-structural.js +115 -0
- package/src/sast/kotlin.js +72 -0
- package/src/sast/ldap-injection.js +73 -17
- package/src/sast/open-redirect.js +47 -5
- package/src/sast/php.js +35 -0
- package/src/sast/python-structural.js +83 -0
- package/src/sast/rbac-consistency.js +113 -0
- package/src/sast/redos-nfa.js +75 -0
- package/src/sast/response-splitting.js +48 -19
- package/src/sast/ruby.js +38 -0
- package/src/sast/secret-concat.js +60 -0
- package/src/sast/ssrf-cloud-metadata.js +13 -0
- package/src/sast/stored-taint.js +88 -0
- package/src/sast/tree-sitter-sinks.js +62 -0
- package/src/sast/weak-password-hash.js +28 -0
- package/src/sast/weak-randomness.js +46 -5
- package/src/sast/wrong-context-sanitizer.js +149 -0
- package/src/sast/xpath-injection.js +30 -6
- package/src/sast/xss-reflected-multilang.js +116 -0
- package/src/sast/xxe.js +67 -0
- package/src/sca/image-packages.js +74 -0
- package/src/sca/import-reachability.js +271 -0
- package/src/sca/install-script-analysis.js +82 -0
- package/bin/.agentic-security/findings.json +0 -1907
- package/bin/.agentic-security/last-scan.json +0 -1907
- package/bin/.agentic-security/last-scan.json.sig +0 -1
- package/bin/.agentic-security/scan-history.json +0 -166
- package/bin/.agentic-security/streak.json +0 -20
- package/src/.agentic-security/findings.json +0 -117732
- package/src/.agentic-security/last-scan.json +0 -117732
- package/src/.agentic-security/last-scan.json.sig +0 -1
- package/src/.agentic-security/scan-history.json +0 -12946
- package/src/.agentic-security/streak.json +0 -21
- package/src/dataflow/.agentic-security/findings.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json +0 -6086
- package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
- package/src/dataflow/.agentic-security/scan-history.json +0 -250
- package/src/dataflow/.agentic-security/streak.json +0 -21
- package/src/ir/.agentic-security/findings.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json +0 -4011
- package/src/ir/.agentic-security/last-scan.json.sig +0 -1
- package/src/ir/.agentic-security/scan-history.json +0 -193
- package/src/ir/.agentic-security/streak.json +0 -20
- package/src/mcp/.agentic-security/findings.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json +0 -8632
- package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
- package/src/mcp/.agentic-security/scan-history.json +0 -331
- package/src/mcp/.agentic-security/streak.json +0 -20
- package/src/posture/.agentic-security/dpia.md +0 -26
- package/src/posture/.agentic-security/findings.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json +0 -77181
- package/src/posture/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
- package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
- package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/posture/.agentic-security/scan-history.json +0 -8904
- package/src/posture/.agentic-security/streak.json +0 -21
- package/src/posture/.agentic-security/threat-model.json +0 -2038
- package/src/posture/.agentic-security/threat-model.md +0 -73
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
- package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
- package/src/report/.agentic-security/threat-model.json +0 -7
- package/src/report/.agentic-security/threat-model.md +0 -22
- package/src/sast/.agentic-security/findings.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json +0 -6154
- package/src/sast/.agentic-security/last-scan.json.sig +0 -1
- package/src/sast/.agentic-security/scan-history.json +0 -941
- package/src/sast/.agentic-security/streak.json +0 -22
- package/src/sca/.agentic-security/findings.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json +0 -7460
- package/src/sca/.agentic-security/last-scan.json.sig +0 -1
- package/src/sca/.agentic-security/scan-history.json +0 -113
- package/src/sca/.agentic-security/streak.json +0 -21
|
@@ -54,7 +54,7 @@ function _shape(file, line, ruleId, vuln, fam, sev, cwe, remediation, descriptio
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const _RELEVANCE = /\bTLS\b|\bSSL\b|\btls\b|tls_version|tls_minimum|min[_-]?version|min[_-]?protocol|verify|ciph(?:er|ersuite)|NODE_TLS|InsecureSkipVerify|rejectUnauthor|trust[_-]?all|allow[_-]?all|jwt\.|jsonwebtoken|jose|PyJWT|jwt\b|MD5|SHA1|sha-?1\b|md5\b|DES\b|3DES|RC4|Blowfish|ECB|pbkdf2|PBKDF2|bcrypt|scrypt|argon2|Math\.random|random\.random|java\.util\.Random|new Random/i;
|
|
57
|
+
const _RELEVANCE = /\bTLS\b|\bSSL\b|\btls\b|tls_version|tls_minimum|min[_-]?version|min[_-]?protocol|verify|ciph(?:er|ersuite)|NODE_TLS|InsecureSkipVerify|rejectUnauthor|trust[_-]?all|allow[_-]?all|jwt\.|jsonwebtoken|jose|PyJWT|jwt\b|MD5|SHA1|sha-?1\b|md5\b|DES\b|3DES|RC4|Blowfish|ECB|pbkdf2|PBKDF2|bcrypt|scrypt|argon2|Math\.random|random\.random|java\.util\.Random|new Random|IvParameterSpec|GCMParameterSpec|openssl_encrypt|createCipher|\bAES\b|\baes\b|\.iv\s*=|\bIV\b/i;
|
|
58
58
|
|
|
59
59
|
function _isCryptoRelevant(text) { return _RELEVANCE.test(text); }
|
|
60
60
|
|
|
@@ -148,6 +148,15 @@ function detectWeakCiphers(file, raw, code, out, seen) {
|
|
|
148
148
|
{ re: /\bciphers\s*:\s*['"`][^'"`]*(?:NULL|RC4|EXPORT|aNULL|eNULL|3DES|DES-CBC)/g },
|
|
149
149
|
// Go cipher.NewTripleDESCipher
|
|
150
150
|
{ re: /\bcipher\.New(?:TripleDESCipher|DESCipher)\b/g },
|
|
151
|
+
// Go crypto/des + crypto/rc4 package constructors
|
|
152
|
+
{ re: /\b(?:des\.New(?:TripleDESCipher|Cipher)|rc4\.NewCipher)\s*\(/g },
|
|
153
|
+
// PHP openssl_encrypt/openssl_decrypt with a weak algorithm string.
|
|
154
|
+
{ re: /\bopenssl_(?:encrypt|decrypt)\s*\([^;)]*,\s*["'](?:des|des-ede3?|3des|rc4|rc2|bf|blowfish|cast5|seed)\b[^"']*["']/gi },
|
|
155
|
+
// PHP legacy mcrypt with DES/3DES/RC4/Blowfish.
|
|
156
|
+
{ re: /\bmcrypt_(?:encrypt|decrypt|module_open)\s*\(\s*(?:MCRYPT_(?:DES|3DES|TRIPLEDES|ARCFOUR|RC2|RC4|BLOWFISH|CAST_128)|["'](?:des|tripledes|arcfour|rc2|rc4|blowfish)["'])/gi },
|
|
157
|
+
// Ruby OpenSSL::Cipher.new("DES-…"/"RC4"/"bf-…") or OpenSSL::Cipher::DES.new
|
|
158
|
+
{ re: /\bOpenSSL::Cipher\.new\s*\(\s*["'](?:des|des-ede3?|3des|rc4|rc2|bf|blowfish|cast5|seed)\b[^"']*["']/gi },
|
|
159
|
+
{ re: /\bOpenSSL::Cipher::(?:DES|RC4|RC2|Blowfish|CAST5)\b/g },
|
|
151
160
|
];
|
|
152
161
|
for (const p of patterns) {
|
|
153
162
|
let m;
|
|
@@ -196,8 +205,41 @@ function detectStaticIv(file, raw, code, out, seen) {
|
|
|
196
205
|
{ re: /\bcreateCipheriv\s*\([^,]+,\s*[^,]+,\s*['"`](?:0+|\\0+)['"`]/g },
|
|
197
206
|
{ re: /\bAES\.new\s*\([^,]+,[^,]+,\s*IV\s*=\s*b?['"](?:\\x00){8,}/g },
|
|
198
207
|
{ re: /\bcipher\.NewCBCEncrypter\s*\([^,]+,\s*make\s*\(\s*\[\]byte\s*,/g }, // make([]byte, blocksize)
|
|
208
|
+
// pyca/cryptography: a zero / repeated-byte literal IV — `iv = b'\x00' * 16`
|
|
209
|
+
// or `iv = b'\x00\x00…'` — fed to modes.CBC/CTR/CFB/OFB.
|
|
210
|
+
{ re: /\biv\s*=\s*b['"](?:\\x00|\\0|0)+['"]\s*\*\s*\d+/gi },
|
|
211
|
+
{ re: /\biv\s*=\s*b['"](?:\\x00|\\0){8,}['"]/gi },
|
|
212
|
+
{ re: /\bmodes\.(?:CBC|CTR|CFB|OFB|GCM)\s*\(\s*b['"]/g }, // modes.CBC(b'…') literal IV/nonce
|
|
213
|
+
// Java/Kotlin: IvParameterSpec / GCMParameterSpec built from a freshly
|
|
214
|
+
// zero-initialized array (`new byte[16]` / `ByteArray(16)`) — no CSPRNG.
|
|
215
|
+
{ re: /\b(?:Iv|GCM)ParameterSpec\s*\(\s*new\s+byte\s*\[\s*\d+\s*\]/g }, // Java
|
|
216
|
+
{ re: /\b(?:Iv|GCM)ParameterSpec\s*\(\s*ByteArray\s*\(\s*\d+\s*\)/g }, // Kotlin
|
|
217
|
+
// Go: an IV/nonce that is `iv := make([]byte, …)` (all-zero, never filled
|
|
218
|
+
// from a CSPRNG) used with a stream/block-mode constructor. The `make`
|
|
219
|
+
// assignment usually precedes the NewXxx call, so gate on both being
|
|
220
|
+
// present in the file (NewXxx hint) and flag the make line.
|
|
221
|
+
{ re: /\b\w*iv\w*\s*:?=\s*make\s*\(\s*\[\]byte\s*,/gi,
|
|
222
|
+
goGate: /\bcipher\.New(?:CBCEncrypter|CBCDecrypter|CTR|CFBEncrypter|CFBDecrypter|OFB)\b/,
|
|
223
|
+
// Safe if the file fills the IV from a CSPRNG — rand.Read(iv) /
|
|
224
|
+
// io.ReadFull(rand.Reader, iv) / crypto/rand usage.
|
|
225
|
+
goSafe: /\brand\.Read\s*\(|\bio\.ReadFull\s*\(\s*rand\.Reader|\bcrypto\/rand\b/ },
|
|
226
|
+
// C#: a zero/static IV assigned to .IV or passed to CreateEncryptor/Decryptor.
|
|
227
|
+
{ re: /\.\s*IV\s*=\s*new\s+byte\s*\[\s*\d+\s*\]/g }, // C# aes.IV = new byte[16]
|
|
228
|
+
{ re: /\bCreateEncryptor\s*\(\s*[^,]+,\s*new\s+byte\s*\[\s*\d+\s*\]/g }, // C# CreateEncryptor(key, new byte[16])
|
|
229
|
+
// PHP: openssl_encrypt with an empty-string IV or a str_repeat("\0",N) IV.
|
|
230
|
+
{ re: /\bopenssl_encrypt\s*\([^;]*,\s*(?:""|''|str_repeat\s*\(\s*["']\\?0["']\s*,)/g },
|
|
231
|
+
{ re: /\$iv\s*=\s*str_repeat\s*\(\s*["']\\?0["']\s*,\s*\d+\s*\)/g },
|
|
232
|
+
// Ruby: cipher.iv = a zero / fixed literal string.
|
|
233
|
+
{ re: /\.\s*iv\s*=\s*["']\\x?0(?:\\x?0|0)*["']\s*\*\s*\d+/gi }, // cipher.iv = "\x00" * 16
|
|
234
|
+
{ re: /\.\s*iv\s*=\s*["'](?:\\x00){4,}["']/gi }, // cipher.iv = "\x00\x00…"
|
|
235
|
+
{ re: /\.\s*iv\s*=\s*["'][0]{8,}["']/g }, // cipher.iv = "0000…"
|
|
199
236
|
];
|
|
200
237
|
for (const p of patterns) {
|
|
238
|
+
// Some patterns only make sense in the presence of a companion construct
|
|
239
|
+
// (e.g. a Go `iv := make([]byte,…)` is only suspicious when a CBC/CTR/…
|
|
240
|
+
// mode constructor uses it). Skip the pattern when its gate is absent.
|
|
241
|
+
if (p.goGate && !p.goGate.test(code)) continue;
|
|
242
|
+
if (p.goSafe && p.goSafe.test(code)) continue;
|
|
201
243
|
let m;
|
|
202
244
|
while ((m = p.re.exec(code))) {
|
|
203
245
|
const ln = _line(raw, m.index);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// C# structural detectors — PRD Tier 1.
|
|
2
|
+
//
|
|
3
|
+
// The main csharp.js is flow/taint-based and misses two corpus shapes:
|
|
4
|
+
// - a hardcoded secret in a const/static field, SPLIT across concatenated
|
|
5
|
+
// literals ("sk_" + "live_…") specifically to evade secret regexes;
|
|
6
|
+
// - SSRF via WebClient/HttpClient on a non-validated URL.
|
|
7
|
+
// Both are regex/structural and complement the flow engine.
|
|
8
|
+
|
|
9
|
+
import { blankComments } from './_comment-strip.js';
|
|
10
|
+
|
|
11
|
+
const SECRET_PREFIX = /\b(?:sk_|sk-|AKIA|ghp_|gho_|xox[abps]-|AIza|eyJ|-----BEGIN|glpat-)/;
|
|
12
|
+
// A field whose name signals a credential, assigned a string (or concat of
|
|
13
|
+
// string literals — the splitting trick).
|
|
14
|
+
const SECRET_FIELD = /\b\w*(?:apikey|api_key|secret|token|password|passwd|pwd|credential|privatekey|connectionstring|accesskey)\w*\s*=\s*((?:"[^"]*"\s*\+\s*)*"[^"]*")/ig;
|
|
15
|
+
|
|
16
|
+
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
17
|
+
// Join the contents of a concatenation of string literals.
|
|
18
|
+
function joinLiterals(expr) {
|
|
19
|
+
const parts = expr.match(/"([^"]*)"/g) || [];
|
|
20
|
+
return parts.map(p => p.slice(1, -1)).join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function scanCsharpStructural(fp, raw) {
|
|
24
|
+
if (!/\.cs$/i.test(fp)) return [];
|
|
25
|
+
if (!raw || raw.length > 500_000) return [];
|
|
26
|
+
const code = blankComments(raw);
|
|
27
|
+
const findings = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
|
30
|
+
|
|
31
|
+
// Hardcoded secret in a credential-named field. The joined literal value must
|
|
32
|
+
// look like a real secret (length or known prefix) so header-name constants
|
|
33
|
+
// (ApiKeyHeader = "X-Api-Key") are not flagged.
|
|
34
|
+
let m;
|
|
35
|
+
const sre = new RegExp(SECRET_FIELD.source, SECRET_FIELD.flags);
|
|
36
|
+
while ((m = sre.exec(code))) {
|
|
37
|
+
const value = joinLiterals(m[1]);
|
|
38
|
+
if (value.length < 16 && !SECRET_PREFIX.test(value)) continue;
|
|
39
|
+
const line = lineOf(code, m.index);
|
|
40
|
+
push({
|
|
41
|
+
id: `csharp-hardcoded-secret:${fp}:${line}`, file: fp, line,
|
|
42
|
+
vuln: 'Hardcoded credential in a const/static field (C#)',
|
|
43
|
+
severity: 'high', cwe: 'CWE-798', family: 'secret', parser: 'CSHARP', confidence: 0.7,
|
|
44
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
45
|
+
remediation: 'Load the secret from the environment / a secrets manager (Environment.GetEnvironmentVariable, Azure Key Vault). Concatenating the literal to split it does not help — rotate the exposed value.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// SSRF: WebClient/HttpClient fetch from a non-literal URL, unless a host
|
|
50
|
+
// allow/deny guard is present.
|
|
51
|
+
const SSRF_SINK = /\.(?:DownloadString|DownloadData|OpenRead|GetAsync|GetStringAsync|GetStreamAsync|GetByteArrayAsync)\s*\(\s*[A-Za-z_]\w*\s*\)/;
|
|
52
|
+
const SSRF_GUARD = /169\.254\.169\.254|\.Host\b|allow(?:ed)?Hosts?|IsLoopback|deny(?:list)?|block(?:list|ed)/i;
|
|
53
|
+
if (SSRF_SINK.test(code) && !SSRF_GUARD.test(code)) {
|
|
54
|
+
const line = lineOf(code, code.search(SSRF_SINK));
|
|
55
|
+
push({
|
|
56
|
+
id: `csharp-ssrf:${fp}:${line}`, file: fp, line,
|
|
57
|
+
vuln: 'SSRF — HTTP client fetch from a non-validated URL (C#)',
|
|
58
|
+
severity: 'high', cwe: 'CWE-918', family: 'ssrf', parser: 'CSHARP', confidence: 0.55,
|
|
59
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
60
|
+
remediation: 'Validate the URL host against an allow-list and reject RFC1918 / link-local / metadata (169.254.169.254) addresses before fetching.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
package/src/sast/csharp.js
CHANGED
|
@@ -531,6 +531,9 @@ function detectOpenRedirect(file, raw, ir, analysis, out, seen) {
|
|
|
531
531
|
// URL is non-local. We still flag it because Juliet expects the
|
|
532
532
|
// detection — but downgrade the severity.
|
|
533
533
|
const isLocalRedirect = call.method === 'LocalRedirect';
|
|
534
|
+
// A LocalRedirect / Redirect guarded by Url.IsLocalUrl is the documented
|
|
535
|
+
// safe pattern — don't flag it (avoids FP on hardened handlers).
|
|
536
|
+
if ((isLocalRedirect || /\bUrl\.IsLocalUrl\b/.test(raw)) && /\bUrl\.IsLocalUrl\b/.test(raw)) continue;
|
|
534
537
|
const id = `csharp-open-redirect:${file}:${call.line}`;
|
|
535
538
|
if (seen.has(id)) continue;
|
|
536
539
|
seen.add(id);
|
package/src/sast/csrf.js
CHANGED
|
@@ -21,11 +21,28 @@ const STATE_CHANGE_RE = {
|
|
|
21
21
|
django: /\b(?:require_POST|require_http_methods\s*\(\s*\[[^\]]*['"](POST|PUT|PATCH|DELETE)['"])/gi,
|
|
22
22
|
fastapi: /@\w+\s*\.\s*(post|put|patch|delete)\s*\(/gi,
|
|
23
23
|
spring: /@(PostMapping|PutMapping|PatchMapping|DeleteMapping)\b/g,
|
|
24
|
+
// Symfony: reading the POST body bag (`$request->request->get(...)`) is the
|
|
25
|
+
// state-change signal, plus the Route annotation method list.
|
|
26
|
+
symfony: /\$\w+\s*->\s*(?:request|getPayload\s*\(\s*\))\s*->\s*(?:get|getInt|getBoolean|getAlnum|getDigits|all|has)\s*\(|[#@]\[?\s*Route\s*\([^)]*methods\s*[:=]\s*[[{][^\]}]*['"](?:POST|PUT|PATCH|DELETE)['"]/gi,
|
|
27
|
+
// Go (gin/echo/fiber/chi/mux): a router POST/PUT/PATCH/DELETE registration,
|
|
28
|
+
// or net/http mux `.Methods("POST")`.
|
|
29
|
+
go: /\b\w+\s*\.\s*(?:POST|PUT|PATCH|DELETE)\s*\(\s*["'`]|\.\s*Methods\s*\(\s*["'](?:POST|PUT|PATCH|DELETE)["']/g,
|
|
30
|
+
// Ruby on Rails: a route declaration `post '/x'` / `patch` / `put` / `delete`
|
|
31
|
+
// (in routes.rb) or `resources :x` (full CRUD incl. state-changing verbs).
|
|
32
|
+
ruby: /^\s*(?:post|put|patch|delete)\s+['":]|\bresources\s+:/gim,
|
|
33
|
+
// C# ASP.NET MVC/Web API: a [HttpPost]/[HttpPut]/[HttpPatch]/[HttpDelete]
|
|
34
|
+
// action attribute.
|
|
35
|
+
csharp: /\[\s*Http(?:Post|Put|Patch|Delete)\b/g,
|
|
24
36
|
};
|
|
25
37
|
|
|
26
|
-
const CSRF_DEFENCE_RE = /\b(?:csurf|csrfProtection|csrf\(\)|express-csrf-token|lusca\.csrf|fastify-csrf|CSRFProtect|csrf_protect|CsrfViewMiddleware|CsrfFilter|@CsrfProtected|sameSite\s*[:=]\s*['"](?:Strict|Lax)['"]|origin\s*===|referer\s*===|Origin\s*===|Referer\s*===)/i;
|
|
38
|
+
const CSRF_DEFENCE_RE = /\b(?:csurf|csrfProtection|csrf\(\)|express-csrf-token|lusca\.csrf|fastify-csrf|CSRFProtect|csrf_protect|CsrfViewMiddleware|CsrfFilter|@CsrfProtected|isCsrfTokenValid|IsCsrfTokenValid|csrf_token|CsrfToken|ValidateAntiForgeryToken|AutoValidateAntiforgeryToken|IAntiforgery|protect_from_forgery|verify_authenticity_token|form_authenticity_token|gorilla\/csrf|csrf\.Protect|nosurf|sameSite\s*[:=]\s*['"](?:Strict|Lax)['"]|origin\s*===|referer\s*===|Origin\s*===|Referer\s*===)/i;
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
// Token-auth signals — these auth schemes are CSRF-safe by construction (the
|
|
41
|
+
// credential rides in a header the browser never auto-attaches). NOTE: bare
|
|
42
|
+
// `[Authorize]` is intentionally NOT here — ASP.NET cookie auth is the default
|
|
43
|
+
// and stays CSRF-vulnerable; only an explicit Bearer scheme or [ApiController]
|
|
44
|
+
// (which disables antiforgery + implies token auth) exempts.
|
|
45
|
+
const TOKEN_AUTH_RE = /\b(?:Authorization\s*:\s*Bearer|\.startsWith\s*\(\s*['"`]Bearer|req\.headers\.authorization|request\.headers\.get\(\s*['"`]authorization|bearer\s+token|x-api-key|verifyJWT|jwt\.verify|jsonwebtoken|lexik_jwt|JWTTokenAuthenticator|headers\s*->\s*get\s*\(\s*['"]Authorization|c\.GetHeader\s*\(\s*["']Authorization|request\.headers\[["']Authorization)|\[\s*ApiController\b|AuthenticationSchemes\s*=\s*["']?Bearer/i;
|
|
29
46
|
|
|
30
47
|
const TEST_FILE_RE = /(?:^|\/)(?:tests?|__tests__|specs?|test|fixtures)\//i;
|
|
31
48
|
|
|
@@ -39,9 +56,13 @@ export function scanCSRF(fp, raw) {
|
|
|
39
56
|
if (/^(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(ext)) langSel = ['express', 'fastify'];
|
|
40
57
|
else if (ext === 'py') langSel = ['flask', 'django', 'fastapi'];
|
|
41
58
|
else if (ext === 'java' || ext === 'kt') langSel = ['spring'];
|
|
59
|
+
else if (ext === 'php' || ext === 'phtml') langSel = ['symfony'];
|
|
60
|
+
else if (ext === 'go') langSel = ['go'];
|
|
61
|
+
else if (ext === 'rb') langSel = ['ruby'];
|
|
62
|
+
else if (ext === 'cs') langSel = ['csharp'];
|
|
42
63
|
if (!langSel) return [];
|
|
43
64
|
|
|
44
|
-
const code = blankComments(raw, ext === 'py' ? 'py' : undefined);
|
|
65
|
+
const code = blankComments(raw, (ext === 'py' || ext === 'rb') ? 'py' : undefined);
|
|
45
66
|
// Project-wide-ish: if the file shows CSRF defence anywhere or only handles
|
|
46
67
|
// token-authenticated routes, we suppress.
|
|
47
68
|
const csrfInScope = CSRF_DEFENCE_RE.test(code);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// CSV / formula injection (CWE-1236) — additive detector.
|
|
2
|
+
//
|
|
3
|
+
// When user-controlled text is written into a CSV/spreadsheet cell that begins
|
|
4
|
+
// with `=`, `+`, `-`, `@`, or a tab/CR, a spreadsheet app (Excel, Sheets,
|
|
5
|
+
// LibreOffice) interprets it as a FORMULA on open — enabling data exfiltration
|
|
6
|
+
// (`=IMPORTXML(...)`), command execution via DDE, or credential phishing. The
|
|
7
|
+
// fix is to neutralize the leading character (prefix with `'`), not HTML/SQL
|
|
8
|
+
// escaping.
|
|
9
|
+
//
|
|
10
|
+
// High precision by construction: we only fire when (a) a CSV/spreadsheet
|
|
11
|
+
// WRITE API is on the line, (b) a user-derived value is referenced on the same
|
|
12
|
+
// statement, and (c) no formula-neutralization helper is present nearby.
|
|
13
|
+
|
|
14
|
+
import { blankComments } from './_comment-strip.js';
|
|
15
|
+
|
|
16
|
+
const JS_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
17
|
+
const PY_RE = /\.py$/i;
|
|
18
|
+
|
|
19
|
+
// CSV / spreadsheet write APIs worth flagging (JS + Python).
|
|
20
|
+
const CSV_WRITE = [
|
|
21
|
+
/\bwriteRecords?\s*\(/, // csv-writer createObjectCsvWriter().writeRecords
|
|
22
|
+
/\bcsv\s*\.\s*stringify\s*\(/, // csv-stringify
|
|
23
|
+
/\bstringify\s*\(\s*[^)]*\bcolumns\b/, // csv-stringify with columns
|
|
24
|
+
/\bcsvStringify\s*\(/,
|
|
25
|
+
/\b(?:fastcsv|fast_csv|csvFormat)\s*\(/,
|
|
26
|
+
/\bXLSX\s*\.\s*utils\s*\.\s*(?:json_to_sheet|aoa_to_sheet|sheet_add)/,
|
|
27
|
+
/\bwriterow\s*\(|\bwriterows\s*\(|\bwriteheader\s*\(/, // python csv module
|
|
28
|
+
/\bcsv\s*\.\s*writer\s*\(/,
|
|
29
|
+
/\bDictWriter\s*\(/,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// User-derived value hints on the same statement.
|
|
33
|
+
const TAINT_HINT =
|
|
34
|
+
/\b(?:req\.|request\.|params\.|query\.|body\.|_GET|_POST|_REQUEST|getParameter|\.get\(|user\.|profile\.|\.fields\b|row\.|record\.)/;
|
|
35
|
+
|
|
36
|
+
// Formula-neutralization already applied → suppress.
|
|
37
|
+
const NEUTRALIZED =
|
|
38
|
+
/escapeFormula|sanitizeCsv|sanitiseCsv|csvEscape|formulaEscape|stripFormula|['"`]\s*'\s*['"`]\s*\+|startsWith\(\s*['"`][=+\-@]|replace\(\s*\/\^\[=/;
|
|
39
|
+
|
|
40
|
+
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
41
|
+
|
|
42
|
+
export function scanCsvInjection(fp, raw) {
|
|
43
|
+
if (!raw || raw.length > 500_000) return [];
|
|
44
|
+
const isJs = JS_RE.test(fp), isPy = PY_RE.test(fp);
|
|
45
|
+
if (!isJs && !isPy) return [];
|
|
46
|
+
// Cheap pre-filter.
|
|
47
|
+
if (!/csv|writeRecords|XLSX|writerow|DictWriter|stringify/i.test(raw)) return [];
|
|
48
|
+
|
|
49
|
+
const code = blankComments(raw, isPy ? 'py' : undefined);
|
|
50
|
+
const lines = code.split('\n');
|
|
51
|
+
const findings = [];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const line = lines[i];
|
|
56
|
+
if (!CSV_WRITE.some(re => re.test(line))) continue;
|
|
57
|
+
if (!TAINT_HINT.test(line)) continue;
|
|
58
|
+
// Look at the line plus a small window for an explicit neutralizer.
|
|
59
|
+
const lo = Math.max(0, i - 4);
|
|
60
|
+
const near = lines.slice(lo, i + 2).join('\n');
|
|
61
|
+
if (NEUTRALIZED.test(near)) continue;
|
|
62
|
+
if (seen.has(i + 1)) continue;
|
|
63
|
+
seen.add(i + 1);
|
|
64
|
+
findings.push({
|
|
65
|
+
id: `csv-injection:${fp}:${i + 1}`,
|
|
66
|
+
severity: 'medium',
|
|
67
|
+
file: fp,
|
|
68
|
+
line: i + 1,
|
|
69
|
+
vuln: 'CSV / formula injection (untrusted data written to a spreadsheet cell)',
|
|
70
|
+
cwe: 'CWE-1236',
|
|
71
|
+
family: 'injection',
|
|
72
|
+
parser: 'SAST',
|
|
73
|
+
confidence: 0.55,
|
|
74
|
+
description:
|
|
75
|
+
'User-controlled text is written to a CSV/spreadsheet without neutralizing leading formula ' +
|
|
76
|
+
'characters (= + - @ tab). A spreadsheet app will execute it as a formula on open, enabling ' +
|
|
77
|
+
'data exfiltration (e.g. =IMPORTXML), DDE command execution, or phishing.',
|
|
78
|
+
remediation:
|
|
79
|
+
"Prefix any cell value that starts with = + - @ or a tab/CR with a single quote (or strip it), " +
|
|
80
|
+
'using a dedicated CSV-escaping helper. HTML/SQL escaping does not address this.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return findings;
|
|
84
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// R6 (PRD §5) — non-HTTP entrypoint taint. Route-rooted analysis goes blind to
|
|
2
|
+
// message-queue consumers, scheduled tasks, and serverless handlers — exactly
|
|
3
|
+
// where high-severity backend bugs live. This recognizes those entrypoints,
|
|
4
|
+
// treats their payload parameter as an untrusted source, and flags when it
|
|
5
|
+
// reaches a code/command/SQL sink without sanitization.
|
|
6
|
+
//
|
|
7
|
+
// Precision-first: gated to files that register such an entrypoint; tracks the
|
|
8
|
+
// handler's payload parameter (+ common member accesses) into a dangerous sink.
|
|
9
|
+
|
|
10
|
+
import { blankComments } from './_comment-strip.js';
|
|
11
|
+
|
|
12
|
+
// Entrypoint registrations → the untrusted payload parameter name they bind.
|
|
13
|
+
const ENTRYPOINTS = [
|
|
14
|
+
// JS: queue/stream consumers — .on('message', (msg)=>…) / .subscribe((msg)=>…)
|
|
15
|
+
{ re: /\.(?:on|subscribe|consume|process)\s*\(\s*(?:['"][^'"]*['"]\s*,\s*)?(?:async\s*)?\(?\s*([A-Za-z_$][\w$]*)/g, lang: 'js' },
|
|
16
|
+
// JS serverless: exports.handler = async (event) => … / module.exports.handler = (event)
|
|
17
|
+
{ re: /(?:exports\.\w+|module\.exports(?:\.\w+)?)\s*=\s*(?:async\s*)?\(?\s*([A-Za-z_$][\w$]*)/g, lang: 'js' },
|
|
18
|
+
// Python Celery / serverless: @app.task / @shared_task / def handler(event, context)
|
|
19
|
+
{ re: /@(?:app|celery|shared)\w*\.task[^\n]*\n\s*def\s+\w+\s*\(\s*([A-Za-z_]\w*)/g, lang: 'py' },
|
|
20
|
+
{ re: /\bdef\s+(?:handler|lambda_handler|handle|consume|on_message|process)\s*\(\s*([A-Za-z_]\w*)/g, lang: 'py' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Context markers that indicate an event/queue/serverless file.
|
|
24
|
+
const CONTEXT = /(kafka|rabbit|amqp|sqs|sns|pubsub|kinesis|@app\.task|shared_task|lambda_handler|exports\.handler|celery|bull|bullmq|@SqsListener|@KafkaListener|@RabbitListener|@Scheduled|consumer|EventBridge|cloudevent)/i;
|
|
25
|
+
|
|
26
|
+
const SINKS = [
|
|
27
|
+
{ re: /\b(?:exec|execSync|spawn|spawnSync)\s*\(/, label: 'command exec', cwe: '78', fam: 'command-injection' },
|
|
28
|
+
{ re: /\beval\s*\(|\bnew\s+Function\s*\(/, label: 'eval', cwe: '94', fam: 'code-injection' },
|
|
29
|
+
{ re: /\bos\.system\s*\(|\bsubprocess\.(?:run|call|Popen|check_output)\s*\(/, label: 'command exec', cwe: '78', fam: 'command-injection' },
|
|
30
|
+
{ re: /\.(?:query|execute|raw)\s*\(\s*[`'"][^`'"]*\$?\{?\s*\+?/, label: 'SQL query (concatenated)', cwe: '89', fam: 'sql-injection' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const SANITIZER = /\b(sanitiz|validate|escape|allowlist|allow_list|whitelist|schema\.(?:parse|validate)|zod|joi|pydantic|is_safe|verify)/i;
|
|
34
|
+
|
|
35
|
+
const PAYLOAD_MEMBER = /\.(?:body|value|data|message|payload|Records|detail|content|text)\b/;
|
|
36
|
+
|
|
37
|
+
export function scanEventEntrypoints(fp, raw) {
|
|
38
|
+
if (typeof raw !== 'string' || !raw) return [];
|
|
39
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py)$/i.test(fp)) return [];
|
|
40
|
+
const code = blankComments(raw);
|
|
41
|
+
if (!CONTEXT.test(code)) return [];
|
|
42
|
+
const isPy = /\.py$/i.test(fp);
|
|
43
|
+
const lines = code.split('\n');
|
|
44
|
+
|
|
45
|
+
// Collect payload variable names from entrypoint registrations.
|
|
46
|
+
const payloads = new Set();
|
|
47
|
+
for (const ep of ENTRYPOINTS) {
|
|
48
|
+
if (ep.lang === 'py' !== isPy) continue;
|
|
49
|
+
const re = new RegExp(ep.re.source, ep.re.flags);
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = re.exec(code))) {
|
|
52
|
+
const name = m[1];
|
|
53
|
+
if (name && !['async', 'function', 'context', 'self', 'cls'].includes(name)) payloads.add(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!payloads.size) return [];
|
|
57
|
+
|
|
58
|
+
const findings = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const line = lines[i];
|
|
62
|
+
for (const sink of SINKS) {
|
|
63
|
+
if (!sink.re.test(line)) continue;
|
|
64
|
+
const arg = line.slice(line.search(sink.re));
|
|
65
|
+
const hit = [...payloads].some((p) => new RegExp(`\\b${p}\\b`).test(arg));
|
|
66
|
+
if (!hit) continue;
|
|
67
|
+
// Sanitizer anywhere in a small window above suppresses.
|
|
68
|
+
const near = lines.slice(Math.max(0, i - 6), i + 1).join('\n');
|
|
69
|
+
if (SANITIZER.test(near)) continue;
|
|
70
|
+
const key = `${i + 1}:${sink.fam}`;
|
|
71
|
+
if (seen.has(key)) continue;
|
|
72
|
+
seen.add(key);
|
|
73
|
+
findings.push({
|
|
74
|
+
id: `event-entrypoint:${fp}:${i + 1}`,
|
|
75
|
+
severity: 'high',
|
|
76
|
+
file: fp,
|
|
77
|
+
line: i + 1,
|
|
78
|
+
vuln: `Untrusted event/message payload reaches ${sink.label}`,
|
|
79
|
+
cwe: `CWE-${sink.cwe}`,
|
|
80
|
+
family: sink.fam,
|
|
81
|
+
parser: 'EVENT-FLOW',
|
|
82
|
+
description: `A non-HTTP entrypoint (queue consumer / scheduled task / serverless handler) passes its untrusted payload into ${sink.label} without validation. Message/event bodies are attacker-influenceable just like HTTP input.`,
|
|
83
|
+
remediation: 'Validate/parse the event payload against a schema before use, and avoid passing it to a command/code/SQL sink (parameterize, use argv-form exec).',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const _internals = { PAYLOAD_MEMBER };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Front-end security hygiene — additive, high-precision client-side checks
|
|
2
|
+
// that the engine did not previously cover. None of these change existing
|
|
3
|
+
// findings; each emits its own finding class.
|
|
4
|
+
//
|
|
5
|
+
// 1. Reverse tabnabbing (CWE-1022): `<a target="_blank">` without
|
|
6
|
+
// `rel="noopener"` — the opened page can rewrite `window.opener.location`
|
|
7
|
+
// and phish. (Modern browsers default to noopener, so: low.)
|
|
8
|
+
// 2. Missing Subresource Integrity (CWE-353): a cross-origin
|
|
9
|
+
// `<script src="https://cdn…">` / `<link rel=stylesheet href="//…">`
|
|
10
|
+
// with no `integrity=` — a compromised/MITM'd CDN executes in your origin.
|
|
11
|
+
// 3. Angular sanitizer bypass (CWE-79): `DomSanitizer.bypassSecurityTrust*`
|
|
12
|
+
// with a non-literal argument explicitly disables Angular's built-in XSS
|
|
13
|
+
// protection on attacker-influenced data.
|
|
14
|
+
|
|
15
|
+
import { blankComments } from './_comment-strip.js';
|
|
16
|
+
|
|
17
|
+
const MARKUP_RE = /\.(?:html?|jsx|tsx|vue|svelte|php|erb|ejs|hbs|handlebars|astro)$/i;
|
|
18
|
+
const SCRIPT_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/i;
|
|
19
|
+
|
|
20
|
+
const A_BLANK_RE = /<a\b[^>]*\btarget\s*=\s*["']_blank["'][^>]*>/gi;
|
|
21
|
+
const REL_NOOPENER = /\brel\s*=\s*["'][^"']*\bno(?:opener|referrer)\b/i;
|
|
22
|
+
|
|
23
|
+
const EXT_SCRIPT_RE = /<script\b[^>]*\bsrc\s*=\s*["'](?:https?:)?\/\/[^"']+["'][^>]*>/gi;
|
|
24
|
+
const EXT_LINK_RE = /<link\b[^>]*\bhref\s*=\s*["'](?:https?:)?\/\/[^"']+["'][^>]*>/gi;
|
|
25
|
+
const HAS_INTEGRITY = /\bintegrity\s*=/i;
|
|
26
|
+
const IS_STYLESHEET = /\brel\s*=\s*["'][^"']*\bstylesheet\b/i;
|
|
27
|
+
|
|
28
|
+
const NG_BYPASS_RE = /\.bypassSecurityTrust(?:Html|Script|Style|Url|ResourceUrl)\s*\(\s*([^)]*?)\)/g;
|
|
29
|
+
// A pure string literal argument is a developer-controlled constant — safe.
|
|
30
|
+
const PURE_LITERAL = /^\s*(['"`])(?:\\.|(?!\1).)*\1\s*$/;
|
|
31
|
+
|
|
32
|
+
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
33
|
+
|
|
34
|
+
export function scanFrontendHygiene(fp, raw) {
|
|
35
|
+
if (!raw || raw.length > 500_000) return [];
|
|
36
|
+
const isMarkup = MARKUP_RE.test(fp);
|
|
37
|
+
const isScript = SCRIPT_RE.test(fp);
|
|
38
|
+
if (!isMarkup && !isScript) return [];
|
|
39
|
+
|
|
40
|
+
const findings = [];
|
|
41
|
+
|
|
42
|
+
if (isMarkup) {
|
|
43
|
+
// 1. Reverse tabnabbing.
|
|
44
|
+
A_BLANK_RE.lastIndex = 0;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = A_BLANK_RE.exec(raw))) {
|
|
47
|
+
if (REL_NOOPENER.test(m[0])) continue;
|
|
48
|
+
findings.push({
|
|
49
|
+
id: `tabnabbing:${fp}:${lineOf(raw, m.index)}`,
|
|
50
|
+
severity: 'low', file: fp, line: lineOf(raw, m.index),
|
|
51
|
+
vuln: 'Reverse tabnabbing (target="_blank" without rel="noopener")',
|
|
52
|
+
cwe: 'CWE-1022', family: 'client-side', parser: 'SAST',
|
|
53
|
+
description: 'A link opens a new tab with target="_blank" but no rel="noopener". The destination page receives a reference to window.opener and can redirect this tab to a phishing page.',
|
|
54
|
+
remediation: 'Add rel="noopener noreferrer" (or rel="noopener") to every target="_blank" link.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// 2. Missing SRI on cross-origin <script>.
|
|
58
|
+
EXT_SCRIPT_RE.lastIndex = 0;
|
|
59
|
+
while ((m = EXT_SCRIPT_RE.exec(raw))) {
|
|
60
|
+
if (HAS_INTEGRITY.test(m[0])) continue;
|
|
61
|
+
findings.push({
|
|
62
|
+
id: `missing-sri-script:${fp}:${lineOf(raw, m.index)}`,
|
|
63
|
+
severity: 'medium', file: fp, line: lineOf(raw, m.index),
|
|
64
|
+
vuln: 'Missing Subresource Integrity on cross-origin <script>',
|
|
65
|
+
cwe: 'CWE-353', family: 'supply-chain', parser: 'SAST',
|
|
66
|
+
description: 'A script is loaded from a third-party origin without an integrity= hash. If that host (or a CDN in front of it) is compromised or MITM\'d, arbitrary code runs in your origin.',
|
|
67
|
+
remediation: 'Add an integrity="sha384-…" attribute and crossorigin="anonymous", or self-host the asset.',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// 2b. Missing SRI on cross-origin stylesheet <link>.
|
|
71
|
+
EXT_LINK_RE.lastIndex = 0;
|
|
72
|
+
while ((m = EXT_LINK_RE.exec(raw))) {
|
|
73
|
+
if (!IS_STYLESHEET.test(m[0])) continue;
|
|
74
|
+
if (HAS_INTEGRITY.test(m[0])) continue;
|
|
75
|
+
findings.push({
|
|
76
|
+
id: `missing-sri-link:${fp}:${lineOf(raw, m.index)}`,
|
|
77
|
+
severity: 'low', file: fp, line: lineOf(raw, m.index),
|
|
78
|
+
vuln: 'Missing Subresource Integrity on cross-origin stylesheet',
|
|
79
|
+
cwe: 'CWE-353', family: 'supply-chain', parser: 'SAST',
|
|
80
|
+
description: 'A stylesheet is loaded from a third-party origin without an integrity= hash, so a compromised CDN can inject CSS-based exfiltration / UI redress.',
|
|
81
|
+
remediation: 'Add integrity="sha384-…" + crossorigin="anonymous", or self-host the stylesheet.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isScript) {
|
|
87
|
+
// 3. Angular sanitizer bypass with a non-literal argument.
|
|
88
|
+
const code = blankComments(raw);
|
|
89
|
+
NG_BYPASS_RE.lastIndex = 0;
|
|
90
|
+
let m;
|
|
91
|
+
while ((m = NG_BYPASS_RE.exec(code))) {
|
|
92
|
+
const arg = (m[1] || '').trim();
|
|
93
|
+
if (!arg || PURE_LITERAL.test(arg)) continue; // constant → safe
|
|
94
|
+
findings.push({
|
|
95
|
+
id: `ng-sanitizer-bypass:${fp}:${lineOf(code, m.index)}`,
|
|
96
|
+
severity: 'high', file: fp, line: lineOf(code, m.index),
|
|
97
|
+
vuln: 'Angular sanitizer bypass on dynamic value (bypassSecurityTrust*)',
|
|
98
|
+
cwe: 'CWE-79', family: 'xss', parser: 'SAST',
|
|
99
|
+
description: 'DomSanitizer.bypassSecurityTrust* explicitly disables Angular\'s built-in XSS protection. Called on a non-constant value, attacker-influenced data reaches the DOM unsanitized.',
|
|
100
|
+
remediation: 'Avoid bypassSecurityTrust* on dynamic data; bind trusted values only, or sanitize with DomSanitizer.sanitize() / a strict allow-list first.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Go structural (taint-independent) injection detectors — PRD Tier 1 (Go recall).
|
|
2
|
+
//
|
|
3
|
+
// Go handler FNs: user input from c.Query / r.URL.Query().Get is built into a
|
|
4
|
+
// sink via fmt.Sprintf or string concat (Go has no string templates). That
|
|
5
|
+
// shape is the vulnerability regardless of variable names. Parameterized
|
|
6
|
+
// queries (`db.Query("… ?", x)`) and canonicalized paths have no Sprintf/concat
|
|
7
|
+
// in the sink argument, so they don't match.
|
|
8
|
+
|
|
9
|
+
import { blankComments } from './_comment-strip.js';
|
|
10
|
+
|
|
11
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
12
|
+
|
|
13
|
+
export function scanGoStructural(fp, raw) {
|
|
14
|
+
if (!/\.go$/i.test(fp)) return [];
|
|
15
|
+
if (!raw || raw.length > 500_000) return [];
|
|
16
|
+
const code = blankComments(raw);
|
|
17
|
+
const findings = [];
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const emit = (key, line, meta) => {
|
|
20
|
+
const id = `go-struct-${key}:${fp}:${line}`;
|
|
21
|
+
if (seen.has(id)) return;
|
|
22
|
+
seen.add(id);
|
|
23
|
+
findings.push({
|
|
24
|
+
id, file: fp, line, vuln: meta.vuln, severity: meta.severity, cwe: meta.cwe,
|
|
25
|
+
family: meta.family, parser: 'GO', confidence: meta.confidence ?? 0.72,
|
|
26
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200), remediation: meta.remediation,
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// SQL injection: a database/sql query method built with fmt.Sprintf or concat.
|
|
31
|
+
const SQL_RE = /\.(?:Query|QueryRow|QueryContext|QueryRowContext|Exec|ExecContext|Prepare|PrepareContext)\s*\(\s*(?:fmt\.Sprintf\s*\(|"[^"\n]*"\s*\+)/g;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = SQL_RE.exec(code))) emit('sqli', lineOf(code, m.index), {
|
|
34
|
+
vuln: 'SQL Injection — query built with fmt.Sprintf / concat (Go)',
|
|
35
|
+
severity: 'critical', cwe: 'CWE-89', family: 'sql-injection', confidence: 0.75,
|
|
36
|
+
remediation: 'Use parameter placeholders: db.Query("… WHERE name = ?", name). Never build SQL with fmt.Sprintf or string concatenation.',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Path traversal: an os/ioutil file op built with concat or fmt.Sprintf.
|
|
40
|
+
const PATH_RE = /\b(?:os\.Open|os\.OpenFile|os\.ReadFile|os\.Create|ioutil\.ReadFile|ioutil\.WriteFile)\s*\(\s*(?:"[^"\n]*"\s*\+|fmt\.Sprintf\s*\()/g;
|
|
41
|
+
while ((m = PATH_RE.exec(code))) emit('path', lineOf(code, m.index), {
|
|
42
|
+
vuln: 'Path Traversal — file path built with concat / fmt.Sprintf (Go)',
|
|
43
|
+
severity: 'high', cwe: 'CWE-22', family: 'path-traversal', confidence: 0.7,
|
|
44
|
+
remediation: 'Strip the path with filepath.Base and assert containment: want := filepath.Join(base, filepath.Base(name)); if !strings.HasPrefix(filepath.Clean(want), base) { reject }.',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Insecure deserialization: encoding/gob decoding attacker-controlled bytes
|
|
48
|
+
// into an interface{} / any target — gob will instantiate registered concrete
|
|
49
|
+
// types, a deserialization gadget surface. Gated on encoding/gob being used.
|
|
50
|
+
if (/\bencoding\/gob\b|\bgob\.NewDecoder\b/.test(code)) {
|
|
51
|
+
const GOB_RE = /\bgob\.NewDecoder\s*\([\s\S]*?\)\s*\.\s*Decode\s*\(|\b\w+\s*:?=\s*gob\.NewDecoder\s*\(/g;
|
|
52
|
+
while ((m = GOB_RE.exec(code))) emit('gob-deser', lineOf(code, m.index), {
|
|
53
|
+
vuln: 'Insecure Deserialization — encoding/gob decoding untrusted input (Go)',
|
|
54
|
+
severity: 'high', cwe: 'CWE-502', family: 'insecure-deserialization', confidence: 0.6,
|
|
55
|
+
remediation: 'Do not gob-decode data from an untrusted source — gob instantiates registered concrete types and is not designed for adversarial input. Use a schema-validated format (JSON with a fixed struct, protobuf) and validate before use.',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// R18 (PRD §5) — semantic IaC (Terraform), variable-resolving.
|
|
2
|
+
//
|
|
3
|
+
// Regex IaC (engine.js scanIaC) matches literals on one line. Its blind spot is
|
|
4
|
+
// indirection: `cidr_blocks = [var.ingress]` where `variable "ingress"` defaults
|
|
5
|
+
// to "0.0.0.0/0" is wide-open, but the literal never appears on the resource
|
|
6
|
+
// line. This resolves `variable` defaults and `local` values, then evaluates a
|
|
7
|
+
// few high-signal misconfigurations on the RESOLVED value — catching what the
|
|
8
|
+
// literal scan misses, and reporting when the exposure arrives via a variable.
|
|
9
|
+
//
|
|
10
|
+
// Regex-based HCL (no new dep, consistent with the repo); bounded to the
|
|
11
|
+
// highest-signal AWS checks. Real `terraform plan` ingestion is the remainder.
|
|
12
|
+
|
|
13
|
+
const VAR_DEFAULT = /variable\s+"([^"]+)"\s*\{[^{}]*?default\s*=\s*(?:"([^"]*)"|(\[[^\]]*\]|true|false|[\w.\-]+))/g;
|
|
14
|
+
const LOCAL_BLOCK = /locals\s*\{([\s\S]*?)\n\}/g;
|
|
15
|
+
|
|
16
|
+
function buildResolver(text) {
|
|
17
|
+
const vars = new Map();
|
|
18
|
+
let m;
|
|
19
|
+
VAR_DEFAULT.lastIndex = 0;
|
|
20
|
+
while ((m = VAR_DEFAULT.exec(text))) vars.set(m[1], (m[2] !== undefined ? m[2] : m[3] || '').trim());
|
|
21
|
+
const locals = new Map();
|
|
22
|
+
LOCAL_BLOCK.lastIndex = 0;
|
|
23
|
+
while ((m = LOCAL_BLOCK.exec(text))) {
|
|
24
|
+
for (const line of m[1].split('\n')) {
|
|
25
|
+
const lm = line.match(/^\s*([A-Za-z_][\w-]*)\s*=\s*(?:"([^"]*)"|([\w.\-]+))/);
|
|
26
|
+
if (lm) locals.set(lm[1], (lm[2] !== undefined ? lm[2] : lm[3] || '').trim());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Resolve a raw attribute value (possibly `var.x`, `local.y`, a list, or literal).
|
|
30
|
+
return function resolve(raw) {
|
|
31
|
+
if (raw == null) return { value: '', viaVar: false };
|
|
32
|
+
// Strip bracket/brace/quote noise anywhere (the attr capture can include a
|
|
33
|
+
// trailing `}` / `]`), so a `var.x` token matches the resolver exactly.
|
|
34
|
+
let v = String(raw).replace(/[[\]{}'"]/g, '').trim();
|
|
35
|
+
const vm = v.match(/^var\.([\w-]+)$/);
|
|
36
|
+
if (vm && vars.has(vm[1])) return { value: vars.get(vm[1]), viaVar: true, varName: vm[1] };
|
|
37
|
+
const lm = v.match(/^local\.([\w-]+)$/);
|
|
38
|
+
if (lm && locals.has(lm[1])) return { value: locals.get(lm[1]), viaVar: true, varName: `local.${lm[1]}` };
|
|
39
|
+
return { value: v, viaVar: false };
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lineOf(text, idx) { return text.slice(0, idx).split('\n').length; }
|
|
44
|
+
|
|
45
|
+
const CHECKS = [
|
|
46
|
+
{
|
|
47
|
+
id: 'open-ingress', cwe: 'CWE-284', sev: 'high',
|
|
48
|
+
attr: /\b(?:cidr_blocks|ipv6_cidr_blocks)\s*=\s*(\[?[^\n]+)/g,
|
|
49
|
+
bad: (val) => /\b0\.0\.0\.0\/0\b|::\/0\b/.test(val),
|
|
50
|
+
vuln: 'Terraform: security-group ingress open to the world (0.0.0.0/0)',
|
|
51
|
+
fix: 'Restrict cidr_blocks to known networks; never default an ingress CIDR to 0.0.0.0/0.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'public-bucket', cwe: 'CWE-732', sev: 'high',
|
|
55
|
+
attr: /\bacl\s*=\s*(\S+)/g,
|
|
56
|
+
bad: (val) => /^public-read(?:-write)?$/.test(val),
|
|
57
|
+
vuln: 'Terraform: S3 bucket ACL is public',
|
|
58
|
+
fix: 'Set acl = "private" and use bucket policies / least-privilege grants instead of a public ACL.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'public-db', cwe: 'CWE-668', sev: 'high',
|
|
62
|
+
attr: /\bpublicly_accessible\s*=\s*(\S+)/g,
|
|
63
|
+
bad: (val) => /^true$/i.test(val),
|
|
64
|
+
vuln: 'Terraform: database instance is publicly accessible',
|
|
65
|
+
fix: 'Set publicly_accessible = false; place the DB in a private subnet.',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
export function scanTerraform(fp, raw) {
|
|
70
|
+
if (typeof raw !== 'string' || !raw) return [];
|
|
71
|
+
if (!/\.tf$/i.test(fp)) return [];
|
|
72
|
+
const resolve = buildResolver(raw);
|
|
73
|
+
const findings = [];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
for (const check of CHECKS) {
|
|
76
|
+
const re = new RegExp(check.attr.source, check.attr.flags);
|
|
77
|
+
let m;
|
|
78
|
+
while ((m = re.exec(raw))) {
|
|
79
|
+
const rawVal = m[1];
|
|
80
|
+
// A literal list may hold several entries; resolve each token.
|
|
81
|
+
const tokens = rawVal.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
|
|
82
|
+
let hit = null;
|
|
83
|
+
for (const tok of tokens.length ? tokens : [rawVal]) {
|
|
84
|
+
const r = resolve(tok);
|
|
85
|
+
if (check.bad(r.value)) { hit = r; break; }
|
|
86
|
+
}
|
|
87
|
+
if (!hit) continue;
|
|
88
|
+
const line = lineOf(raw, m.index);
|
|
89
|
+
const key = `${check.id}:${line}`;
|
|
90
|
+
if (seen.has(key)) continue;
|
|
91
|
+
seen.add(key);
|
|
92
|
+
findings.push({
|
|
93
|
+
id: `iac-tf:${check.id}:${fp}:${line}`,
|
|
94
|
+
severity: check.sev,
|
|
95
|
+
file: fp,
|
|
96
|
+
line,
|
|
97
|
+
vuln: check.vuln,
|
|
98
|
+
cwe: check.cwe,
|
|
99
|
+
family: 'iac-misconfig',
|
|
100
|
+
parser: 'IAC-TF',
|
|
101
|
+
description: `${check.vuln}.${hit.viaVar ? ` The unsafe value arrives via \`${hit.varName}\` (resolved to "${hit.value}") — a literal-only scan would miss this.` : ''}`,
|
|
102
|
+
remediation: check.fix,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
}
|