@clear-capabilities/agentic-security-scanner 0.86.0 → 0.118.2
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 +404 -0
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/agentic-security.mjs +84 -84
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +12 -6
- package/src/.agentic-security/dpia.md +26 -0
- package/src/.agentic-security/pqc-migration-plan.json +80 -0
- package/src/.agentic-security/pqc-migration-plan.md +32 -0
- package/src/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
- package/src/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
- package/src/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
- package/src/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
- package/src/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
- package/src/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
- package/src/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
- package/src/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
- package/src/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
- package/src/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
- package/src/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
- package/src/.agentic-security/threat-model.json +3702 -0
- package/src/.agentic-security/threat-model.md +118 -0
- package/src/dataflow/.agentic-security/dpia.md +26 -0
- package/src/dataflow/.agentic-security/sbom-history/15d7c4d20f9d968ebec29445c9e9b3fc6f74e6a1.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/8869fe64035bec0152d92ece961e9369d8637076.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
- package/src/dataflow/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
- package/src/dataflow/.agentic-security/threat-model.json +161 -0
- package/src/dataflow/.agentic-security/threat-model.md +43 -0
- package/src/dataflow/CLAUDE.md +17 -5
- package/src/dataflow/catalog.js +9 -0
- package/src/dataflow/engine.js +30 -4
- package/src/dataflow/index.js +8 -0
- package/src/dataflow/proof-gate.js +95 -0
- package/src/dataflow/summaries.js +44 -5
- package/src/engine.js +199 -5
- package/src/ir/.agentic-security/dpia.md +26 -0
- package/src/ir/.agentic-security/exploit-bundles.json +77 -0
- package/src/ir/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
- package/src/ir/.agentic-security/sbom-history/b5f468a158f6a145aedd8680841c106b9809f16b.json +6 -0
- package/src/ir/.agentic-security/threat-model.json +143 -0
- package/src/ir/.agentic-security/threat-model.md +39 -0
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/callgraph.js +12 -1
- package/src/ir/tree-sitter-loader.js +108 -0
- package/src/posture/.agentic-security/dpia.md +1 -1
- package/src/posture/.agentic-security/pqc-migration-plan.json +1 -1
- package/src/posture/.agentic-security/pqc-migration-plan.md +1 -1
- package/src/posture/.agentic-security/sbom-history/0724c133d2943c87555695b5b2adcfcfc3373a2d.json +6 -0
- package/src/posture/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
- package/src/posture/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
- package/src/posture/.agentic-security/threat-model.json +3 -3
- package/src/posture/.agentic-security/threat-model.md +1 -1
- package/src/posture/corpus-status.js +74 -0
- package/src/posture/coverage-report.js +113 -0
- package/src/posture/holdout-eval.js +57 -0
- package/src/posture/router.js +33 -3
- package/src/report/.agentic-security/dpia.md +26 -0
- package/src/report/.agentic-security/sbom-history/f94be839aa6a1011f53da016416a561698b8f338.json +6 -0
- package/src/report/.agentic-security/threat-model.md +1 -1
- package/src/report/index.js +17 -0
- package/src/sast/.agentic-security/dpia.md +26 -0
- package/src/sast/.agentic-security/pqc-migration-plan.json +65 -0
- package/src/sast/.agentic-security/pqc-migration-plan.md +30 -0
- package/src/sast/.agentic-security/sbom-history/043a7b401c68f5a298c83d4c42e94f118ff2a89a.json +6 -0
- package/src/sast/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
- package/src/sast/.agentic-security/sbom-history/20d77ad7363a82777d506dedbbb1ecfed52f094a.json +6 -0
- package/src/sast/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
- package/src/sast/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
- package/src/sast/.agentic-security/sbom-history/3c6b3205f04e26f7654943d465281d00e0fa395e.json +6 -0
- package/src/sast/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
- package/src/sast/.agentic-security/sbom-history/5282502e090013219eb006f7e02d8c983bc29c8e.json +6 -0
- package/src/sast/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
- package/src/sast/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
- package/src/sast/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
- package/src/sast/.agentic-security/sbom-history/73514b485d4770f3c5ee57d87338bccd1c61d8b7.json +6 -0
- package/src/sast/.agentic-security/sbom-history/96fa584fc3134512237d58aac63d6f3778b0cbcb.json +6 -0
- package/src/sast/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
- package/src/sast/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
- package/src/sast/.agentic-security/sbom-history/a4a34ab8b3d758de2adc8c3dc3e42d4cb0db00a3.json +6 -0
- package/src/sast/.agentic-security/sbom-history/ac236ec3602cfc8fb133f389ec3687e409084673.json +6 -0
- package/src/sast/.agentic-security/sbom-history/b57b3e1292f1f9f0de6c5a34a436e330c78008a3.json +6 -0
- package/src/sast/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
- package/src/sast/.agentic-security/sbom-history/dcbfd4935922dd165093c08a1003c338eed937c5.json +6 -0
- package/src/sast/.agentic-security/sbom-history/fa017f725615b0293814344c8ef7dfeb06775ca0.json +6 -0
- package/src/sast/.agentic-security/threat-model.json +816 -0
- package/src/sast/.agentic-security/threat-model.md +91 -0
- package/src/sast/CLAUDE.md +9 -3
- package/src/sast/code-injection-multilang.js +117 -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/frontend-hygiene.js +106 -0
- package/src/sast/go-structural.js +60 -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/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 +94 -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
|
@@ -33,11 +33,20 @@ const PATTERNS = [
|
|
|
33
33
|
['java', /\bnew\s+RedirectView\s*\(\s*(\w[\w.]*)\s*\)/g, 'Spring RedirectView'],
|
|
34
34
|
// PHP header("Location: " . $...)
|
|
35
35
|
['php', /\bheader\s*\(\s*['"]\s*Location\s*:\s*['"]\s*\.\s*(\$\w[\w\[\]'"]*)/g, 'PHP Location'],
|
|
36
|
+
// Go net/http: http.Redirect(w, r, <target>, code) — target is the 3rd arg
|
|
37
|
+
// (may contain calls/parens); status is a digit or an http.Status* constant.
|
|
38
|
+
['go', /\bhttp\.Redirect\s*\(\s*[^,]+,\s*[^,]+,\s*([\s\S]+?)\s*,\s*(?:\d|http\.Status)/g, 'Go net/http'],
|
|
39
|
+
// Ruby on Rails: redirect_to <target> (params/var).
|
|
40
|
+
['rb', /\bredirect_to\s+(?!.*\bonly_path:\s*true)([^\n,]+?)(?:\s+status:|\s*$|\n)/gm, 'Rails redirect_to'],
|
|
41
|
+
// C#: Response.Redirect(<target>) / return Redirect(<target>) / RedirectPermanent.
|
|
42
|
+
['cs', /\b(?:Response\.Redirect|return\s+Redirect|return\s+RedirectPermanent|RedirectPermanent)\s*\(\s*([^)]+?)\s*\)/g, 'ASP.NET Redirect'],
|
|
43
|
+
// Kotlin/Servlet: resp.sendRedirect(<target>).
|
|
44
|
+
['kt', /\.\s*sendRedirect\s*\(\s*([^)]+?)\s*\)/g, 'Servlet sendRedirect'],
|
|
36
45
|
];
|
|
37
46
|
|
|
38
47
|
// What counts as "user-derived" inside the captured target expression.
|
|
39
48
|
const TAINT_HINT_RE =
|
|
40
|
-
/\b(?:req\.|request\.|params\.|query\.|body\.|ctx\.query|ctx\.request|ctx\.params|reply\.query|r\.URL\.Query|c\.Query|next\b|_GET|_POST|_REQUEST|getParameter|getHeader)\b/;
|
|
49
|
+
/\b(?:req\.|request\.|params\.|params\s*\[|query\.|body\.|ctx\.query|ctx\.request|ctx\.params|reply\.query|r\.URL\.Query|c\.Query|c\.Param|next\b|_GET|_POST|_REQUEST|getParameter|getHeader|Request\.(?:Query|Form|Params|QueryString))\b|\b(?:params|query|cookies)\s*\[/;
|
|
41
50
|
|
|
42
51
|
// What counts as an allow-list check earlier in the function. We look back
|
|
43
52
|
// up to 30 lines before the redirect call for any of these patterns.
|
|
@@ -54,6 +63,12 @@ const ALLOWLIST_PATTERNS = [
|
|
|
54
63
|
/allowedRedirectTargets/i,
|
|
55
64
|
/\babort\s*\(\s*4\d\d/, // any abort(4xx) earlier
|
|
56
65
|
/\bres\s*\.\s*status\s*\(\s*4\d\d\b/,
|
|
66
|
+
/\bin_array\s*\(/i, // PHP in_array(...) allow-list check
|
|
67
|
+
/\bonly_path:\s*true/, // Rails redirect_to …, only_path: true
|
|
68
|
+
/\bUrl\.IsLocalUrl\b|\bLocalRedirect\b/, // ASP.NET local-only redirect
|
|
69
|
+
/\bin\s+(?:allowed|allow|ALLOWED)\b/, // Kotlin/Ruby `next in allowed`
|
|
70
|
+
/\bstrings\.HasPrefix\s*\(\s*\w+\s*,\s*"\/"/, // Go relative-path check
|
|
71
|
+
/\bset(?:Of)?\s*\([^)]*\)\.contains\b/, // Kotlin allowed.contains(next)
|
|
57
72
|
];
|
|
58
73
|
|
|
59
74
|
function _lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -61,10 +76,34 @@ function _lang(fp) {
|
|
|
61
76
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
|
|
62
77
|
if (/\.py$/i.test(fp)) return 'py';
|
|
63
78
|
if (/\.java$/i.test(fp)) return 'java';
|
|
64
|
-
if (/\.php$/i.test(fp)) return 'php';
|
|
79
|
+
if (/\.(?:php|phtml)$/i.test(fp)) return 'php';
|
|
80
|
+
if (/\.go$/i.test(fp)) return 'go';
|
|
81
|
+
if (/\.rb$/i.test(fp)) return 'rb';
|
|
82
|
+
if (/\.cs$/i.test(fp)) return 'cs';
|
|
83
|
+
if (/\.kt$/i.test(fp)) return 'kt';
|
|
65
84
|
return null;
|
|
66
85
|
}
|
|
67
86
|
|
|
87
|
+
// For C#/Kotlin/Go a bare-identifier redirect target is user-derived when it is
|
|
88
|
+
// a parameter of the enclosing method/handler (the single-file handler shape).
|
|
89
|
+
const _PARAM_LANGS = new Set(['cs', 'kt', 'go']);
|
|
90
|
+
function _isEnclosingParam(code, callIdx, target) {
|
|
91
|
+
if (!/^[A-Za-z_]\w*$/.test(target)) return false;
|
|
92
|
+
const before = code.slice(0, callIdx);
|
|
93
|
+
const sigRe = /\b(?:fun\s+\w+|func\s+\w*|[A-Za-z_][\w<>\[\],.\s]*\b\w+)\s*\(([^)]*)\)\s*(?::[^={]+|[A-Za-z_][\w.<>\[\]* ]*)?\{/g;
|
|
94
|
+
let sig = null, sm;
|
|
95
|
+
while ((sm = sigRe.exec(before)) !== null) sig = sm;
|
|
96
|
+
if (!sig) return false;
|
|
97
|
+
const params = sig[1].split(',').map((p) => {
|
|
98
|
+
const t = p.trim(); if (!t) return '';
|
|
99
|
+
if (t.includes(':')) return t.split(':')[0].trim(); // Kotlin name: Type
|
|
100
|
+
return (t.split(/\s+/)[t.includes(' ') ? (t.trim().split(/\s+/).length - 1) : 0] || '').trim();
|
|
101
|
+
});
|
|
102
|
+
// Go params are `name Type` (name first); JVM/C# are `Type name` (name last).
|
|
103
|
+
const goParams = sig[1].split(',').map((p) => (p.trim().split(/\s+/)[0] || '').trim());
|
|
104
|
+
return params.includes(target) || goParams.includes(target);
|
|
105
|
+
}
|
|
106
|
+
|
|
68
107
|
function _allowListedPrior(raw, callLine, target) {
|
|
69
108
|
const lines = raw.split('\n');
|
|
70
109
|
const lo = Math.max(0, callLine - 30);
|
|
@@ -79,8 +118,8 @@ export function scanOpenRedirect(fp, raw) {
|
|
|
79
118
|
if (!raw || raw.length > 500_000) return [];
|
|
80
119
|
const lang = _lang(fp);
|
|
81
120
|
if (!lang) return [];
|
|
82
|
-
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
83
|
-
if (!/\bredirect
|
|
121
|
+
const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
|
|
122
|
+
if (!/\bredirect|RedirectView|HttpResponseRedirect|Location\s*:|sendRedirect|http\.Redirect/i.test(code)) return [];
|
|
84
123
|
const findings = [];
|
|
85
124
|
const seen = new Set();
|
|
86
125
|
for (const [plang, pat, framework] of PATTERNS) {
|
|
@@ -90,7 +129,10 @@ export function scanOpenRedirect(fp, raw) {
|
|
|
90
129
|
while ((m = re.exec(code))) {
|
|
91
130
|
const target = (m[1] || '').trim();
|
|
92
131
|
if (!target) continue;
|
|
93
|
-
|
|
132
|
+
// Literal-string target ("/home", "https://fixed") is never user-derived.
|
|
133
|
+
if (/^["'`][^"'`]*["'`]$/.test(target)) continue;
|
|
134
|
+
if (!TAINT_HINT_RE.test(target) &&
|
|
135
|
+
!(_PARAM_LANGS.has(plang) && _isEnclosingParam(code, m.index, target))) continue;
|
|
94
136
|
const line = _lineOf(raw, m.index);
|
|
95
137
|
// Suppress if an allow-list check appears in the preceding window.
|
|
96
138
|
if (_allowListedPrior(raw, line, target)) continue;
|
package/src/sast/php.js
CHANGED
|
@@ -18,6 +18,21 @@ const RE = {
|
|
|
18
18
|
extractRequest: /\bextract\s*\(\s*\$(?:_REQUEST|_GET|_POST|HTTP_GET_VARS|HTTP_POST_VARS)\s*[,)]/g,
|
|
19
19
|
passwordHashMd5: /\b(?:md5|sha1)\s*\(\s*\$(?:_(?:REQUEST|GET|POST)|password|passwd|pwd|hash_input)/gi,
|
|
20
20
|
phpinfo: /\bphpinfo\s*\(/g,
|
|
21
|
+
// Structural (taint-independent): a shell or SQL sink built with string
|
|
22
|
+
// concat (`'…' .`) or double-quote `$var` interpolation is the injection
|
|
23
|
+
// shape regardless of the source token — the rules above require the literal
|
|
24
|
+
// $_REQUEST/$_GET/$_POST on the sink line, which misses framework idioms
|
|
25
|
+
// (`$f = $r->query->get('x'); shell_exec('gzip ' . $f)`).
|
|
26
|
+
cmdInjectionStructural: /\b(?:shell_exec|exec|system|passthru|popen|proc_open|pcntl_exec)\s*\(\s*(?:"[^"\n]*\$|'[^'\n]*'\s*\.|"[^"\n]*"\s*\.)/g,
|
|
27
|
+
sqlInjectionStructural: /\b(?:DB::raw|DB::select|DB::statement|DB::insert|DB::update|DB::delete|whereRaw|orWhereRaw|havingRaw|selectRaw|orderByRaw|mysqli_query|mysql_query|pg_query|pg_send_query|sqlite_query|->query|->rawQuery)\s*\(\s*(?:DB::raw\s*\(\s*)?(?:"[^"\n]*\$|"[^"\n]*"\s*\.|'[^'\n]*'\s*\.)/g,
|
|
28
|
+
// Structural path traversal: a filesystem read/write built by concatenating
|
|
29
|
+
// or interpolating into the path. Containment-guarded forms (basename) are
|
|
30
|
+
// dropped centrally by engine.js dropGuardedFindings (CWE-22).
|
|
31
|
+
pathTraversalStructural: /\b(?:readfile|file_get_contents|file_put_contents|fopen|fpassthru|fgets|file|copy|unlink|highlight_file|show_source)\s*\(\s*(?:"[^"\n]*"\s*\.|'[^'\n]*'\s*\.|"[^"\n]*\$(?:_(?:REQUEST|GET|POST|COOKIE)|[A-Za-z_]))/g,
|
|
32
|
+
// SSRF: a network fetch whose URL is user-controlled. cURL is unambiguously
|
|
33
|
+
// network; file_get_contents/fopen with a $_GET/$_POST value direct (not a
|
|
34
|
+
// path-base concat) is the PHP fopen-wrapper SSRF (http:// / ftp:// / phar://).
|
|
35
|
+
ssrfFetch: /\bcurl_init\s*\(\s*\$(?:_(?:REQUEST|GET|POST|COOKIE)|\w*url\w*)|\bcurl_setopt\s*\([^,]+,\s*CURLOPT_URL\s*,\s*\$(?:_(?:REQUEST|GET|POST|COOKIE)|\w*url\w*)|\b(?:file_get_contents|fopen)\s*\(\s*\$_(?:REQUEST|GET|POST|COOKIE)\b/g,
|
|
21
36
|
};
|
|
22
37
|
|
|
23
38
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -75,6 +90,26 @@ export function scanPhp(fp, raw) {
|
|
|
75
90
|
severity: 'high', cwe: 'CWE-200',
|
|
76
91
|
remediation: 'Delete phpinfo() before deploy. It leaks the PHP version, loaded extensions, environment variables, document root, and request headers — a one-shot recon page for an attacker.',
|
|
77
92
|
},
|
|
93
|
+
cmdInjectionStructural: {
|
|
94
|
+
vuln: 'Command Injection: shell function built with string concat / interpolation',
|
|
95
|
+
severity: 'critical', cwe: 'CWE-78',
|
|
96
|
+
remediation: 'Use the array form of proc_open(["gzip", $f], ...) so no shell parses the string, and validate inputs. Never concatenate or interpolate values into shell_exec/exec/system/passthru.',
|
|
97
|
+
},
|
|
98
|
+
sqlInjectionStructural: {
|
|
99
|
+
vuln: 'SQL Injection: raw query built with string concat / interpolation',
|
|
100
|
+
severity: 'critical', cwe: 'CWE-89',
|
|
101
|
+
remediation: 'Use parameter bindings: DB::select("SELECT … WHERE name = ?", [$name]) or the query builder with bindings. Never concatenate/interpolate into DB::raw / whereRaw / mysqli_query.',
|
|
102
|
+
},
|
|
103
|
+
pathTraversalStructural: {
|
|
104
|
+
vuln: 'Path Traversal: filesystem operation built with concatenated/interpolated input',
|
|
105
|
+
severity: 'high', cwe: 'CWE-22', family: 'path-traversal',
|
|
106
|
+
remediation: 'Strip directory components with basename() and resolve against a fixed root: $full = realpath($base . "/" . basename($name)); if (strpos($full, $base) !== 0) abort(). Never concatenate request input straight into a file path.',
|
|
107
|
+
},
|
|
108
|
+
ssrfFetch: {
|
|
109
|
+
vuln: 'SSRF: network fetch (cURL / fopen-wrapper) to a user-controlled URL',
|
|
110
|
+
severity: 'high', cwe: 'CWE-918', family: 'ssrf',
|
|
111
|
+
remediation: 'Validate the URL host against an allow-list and reject RFC1918 / link-local / metadata (169.254.169.254) addresses before fetching. Disable fopen URL wrappers (allow_url_fopen=0) and only allow https:// to known hosts.',
|
|
112
|
+
},
|
|
78
113
|
}[key];
|
|
79
114
|
push({
|
|
80
115
|
id: `php-${key}:${fp}:${line}`,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Python framework structural detectors — PRD Tier 1 (JS/Python recall).
|
|
2
|
+
//
|
|
3
|
+
// Closes Flask/Django handler FNs where user input is concatenated into a
|
|
4
|
+
// template or raw SQL. Taint-independent: the concat / f-string / %-format is
|
|
5
|
+
// the injection shape. Crucially, a Jinja `{{ name }}` placeholder is SAFE
|
|
6
|
+
// (auto-escaped), so `{` alone is NOT a signal — only `+`, f-strings, or
|
|
7
|
+
// `.format()`/`%`.
|
|
8
|
+
|
|
9
|
+
import { blankComments } from './_comment-strip.js';
|
|
10
|
+
|
|
11
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
12
|
+
|
|
13
|
+
// A string literal that may contain the *other* quote char (a SQL string like
|
|
14
|
+
// `"… name = '"` embeds a single quote) — the naive `["'][^"'\n]*["']` breaks
|
|
15
|
+
// on the embedded quote and misses the concat. Match each quote style with its
|
|
16
|
+
// own body so embedded quotes are allowed.
|
|
17
|
+
const STR = String.raw`(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')`;
|
|
18
|
+
// Dynamic-string shape inside a sink call: concat (`"…" +`), f-string (`f"…"`),
|
|
19
|
+
// `.format(`, or `%`-format via the `%` OPERATOR after the string (`"…%s" % v`).
|
|
20
|
+
// NOT a plain literal, and NOT a `%s` placeholder followed by a params list —
|
|
21
|
+
// `execute("… %s", [v])` is the SAFE parameterized DB-API form.
|
|
22
|
+
const DYN = String.raw`(?:[fF]["']|${STR}\s*\+|${STR}\s*%\s*[(\w]|${STR}\s*\.\s*format\s*\()`;
|
|
23
|
+
|
|
24
|
+
export function scanPythonStructural(fp, raw) {
|
|
25
|
+
if (!/\.py$/i.test(fp)) return [];
|
|
26
|
+
if (!raw || raw.length > 500_000) return [];
|
|
27
|
+
const code = blankComments(raw, 'py');
|
|
28
|
+
const findings = [];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const emit = (key, line, meta) => {
|
|
31
|
+
const id = `py-struct-${key}:${fp}:${line}`;
|
|
32
|
+
if (seen.has(id)) return;
|
|
33
|
+
seen.add(id);
|
|
34
|
+
findings.push({
|
|
35
|
+
id, file: fp, line, vuln: meta.vuln, severity: meta.severity, cwe: meta.cwe,
|
|
36
|
+
family: meta.family, parser: 'PYTHON', confidence: meta.confidence ?? 0.7,
|
|
37
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200), remediation: meta.remediation,
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Flask SSTI: render_template_string built from a dynamic string. A static
|
|
42
|
+
// template with Jinja `{{ }}` placeholders is safe and must not match.
|
|
43
|
+
const SSTI_RE = new RegExp(String.raw`\brender_template_string\s*\(\s*` + DYN, 'g');
|
|
44
|
+
let m;
|
|
45
|
+
while ((m = SSTI_RE.exec(code))) emit('flask-ssti', lineOf(code, m.index), {
|
|
46
|
+
// render_template_string with a built string is both reflected XSS
|
|
47
|
+
// (unescaped user input in the rendered HTML) and template injection
|
|
48
|
+
// (`{{7*7}}` executes). Classified CWE-79 (the dominant, ground-truth
|
|
49
|
+
// taxonomy); the remediation covers the SSTI aspect too.
|
|
50
|
+
vuln: 'Reflected XSS / template injection (SSTI) via render_template_string (Flask)',
|
|
51
|
+
severity: 'critical', cwe: 'CWE-79', family: 'xss', confidence: 0.7,
|
|
52
|
+
remediation: 'Use a static template with `{{ }}` placeholders and pass values as named context (`render_template_string("Hi {{ name }}", name=name)`). Never build the template body from input.',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Django raw SQL: .raw() / .extra() built from a dynamic string.
|
|
56
|
+
const DJ_RE = new RegExp(String.raw`\.\s*(?:raw|extra)\s*\(\s*` + DYN, 'g');
|
|
57
|
+
while ((m = DJ_RE.exec(code))) emit('django-sqli', lineOf(code, m.index), {
|
|
58
|
+
vuln: 'SQL Injection — Django raw()/extra() built with string concat / format (Python)',
|
|
59
|
+
severity: 'critical', cwe: 'CWE-89', family: 'sql-injection', confidence: 0.7,
|
|
60
|
+
remediation: 'Use the ORM with an allow-list for column/order fields, or parameterize: `User.objects.raw("… WHERE name = %s", [name])`. Never concatenate into raw SQL.',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// DB-API cursor.execute built from a dynamic string.
|
|
64
|
+
const EXEC_RE = new RegExp(String.raw`\.\s*execute(?:script)?\s*\(\s*` + DYN, 'g');
|
|
65
|
+
while ((m = EXEC_RE.exec(code))) emit('cursor-sqli', lineOf(code, m.index), {
|
|
66
|
+
vuln: 'SQL Injection — cursor.execute built with string concat / format (Python)',
|
|
67
|
+
severity: 'critical', cwe: 'CWE-89', family: 'sql-injection', confidence: 0.7,
|
|
68
|
+
remediation: 'Pass parameters as the second argument with `%s` placeholders: `cursor.execute("… WHERE id = %s", [id])`. Never concatenate or %-format values into the SQL string.',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Path traversal: a file-open / send_file built from a dynamic string
|
|
72
|
+
// (concat / f-string / format). `open('/var/data/' + name)` is the shape;
|
|
73
|
+
// a bare literal path is not. Hardened forms (os.path.basename, secure
|
|
74
|
+
// containment) are removed by engine.js dropGuardedFindings (CWE-22).
|
|
75
|
+
const PATH_RE = new RegExp(String.raw`\b(?:open|os\.open|io\.open|codecs\.open|send_file|send_from_directory)\s*\(\s*` + DYN, 'g');
|
|
76
|
+
while ((m = PATH_RE.exec(code))) emit('path-traversal', lineOf(code, m.index), {
|
|
77
|
+
vuln: 'Path Traversal — file path built with string concat / f-string / format (Python)',
|
|
78
|
+
severity: 'high', cwe: 'CWE-22', family: 'path-traversal', confidence: 0.62,
|
|
79
|
+
remediation: 'Strip directory components and assert containment: `safe = os.path.basename(name); full = os.path.join(BASE, safe); assert os.path.realpath(full).startswith(BASE)`. Never concatenate user input straight into a file path.',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
package/src/sast/redos-nfa.js
CHANGED
|
@@ -305,6 +305,73 @@ export function extractJavaRegexBodies(code) {
|
|
|
305
305
|
return out;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// Go: regexp.MustCompile / Compile / MatchString("…") — Go string literals use
|
|
309
|
+
// "…" (interpreted) or `…` (raw). Both carry the pattern verbatim.
|
|
310
|
+
export function extractGoRegexBodies(code) {
|
|
311
|
+
const out = [];
|
|
312
|
+
for (const m of code.matchAll(/\bregexp\.(?:MustCompile|Compile|CompilePOSIX|MatchString|Match)\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
313
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
314
|
+
}
|
|
315
|
+
for (const m of code.matchAll(/\bregexp\.(?:MustCompile|Compile|CompilePOSIX|MatchString|Match)\s*\(\s*`([^`]+)`/g)) {
|
|
316
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// PHP: preg_match / preg_match_all / preg_replace("/…/flags", …) — the regex is
|
|
322
|
+
// a delimited string; strip the delimiter + trailing flags.
|
|
323
|
+
export function extractPhpRegexBodies(code) {
|
|
324
|
+
const out = [];
|
|
325
|
+
for (const m of code.matchAll(/\bpreg_(?:match|match_all|replace|replace_callback|split)\s*\(\s*(['"])(.+?)\1/g)) {
|
|
326
|
+
let lit = m[2];
|
|
327
|
+
// delimited: first char is the delimiter (e.g. / # ~), trailing flags after it
|
|
328
|
+
const delim = lit[0];
|
|
329
|
+
const close = lit.lastIndexOf(delim);
|
|
330
|
+
if (close > 0) lit = lit.slice(1, close);
|
|
331
|
+
out.push({ body: lit, line: code.slice(0, m.index).split('\n').length });
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Ruby: a regex literal /…/ used with =~ / .match / .match? / Regexp.new("…").
|
|
337
|
+
export function extractRubyRegexBodies(code) {
|
|
338
|
+
const out = [];
|
|
339
|
+
for (const m of code.matchAll(/(?:=~|\.match\??|\.scan|\.gsub|\.sub|grep)\s*\(?\s*\/((?:\\.|[^\/\n])+)\//g)) {
|
|
340
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
341
|
+
}
|
|
342
|
+
for (const m of code.matchAll(/\bRegexp\.new\s*\(\s*['"]((?:\\.|[^'"\n])+)['"]/g)) {
|
|
343
|
+
out.push({ body: m[1], line: code.slice(0, m.index).split('\n').length });
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// C#: new Regex("…") / Regex.IsMatch(s, "…") / Regex.Match / Regex.Replace.
|
|
349
|
+
export function extractCsharpRegexBodies(code) {
|
|
350
|
+
const out = [];
|
|
351
|
+
for (const m of code.matchAll(/\bnew\s+Regex\s*\(\s*@?"((?:\\.|""|[^"\n])+)"/g)) {
|
|
352
|
+
out.push({ body: m[1].replace(/""/g, '"'), line: code.slice(0, m.index).split('\n').length });
|
|
353
|
+
}
|
|
354
|
+
for (const m of code.matchAll(/\bRegex\.(?:IsMatch|Match|Matches|Replace|Split)\s*\(\s*[^,]+,\s*@?"((?:\\.|""|[^"\n])+)"/g)) {
|
|
355
|
+
out.push({ body: m[1].replace(/""/g, '"'), line: code.slice(0, m.index).split('\n').length });
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Kotlin: Regex("…") / "…".toRegex() / Pattern.compile("…").
|
|
361
|
+
export function extractKotlinRegexBodies(code) {
|
|
362
|
+
const out = [];
|
|
363
|
+
for (const m of code.matchAll(/\bRegex\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
364
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
365
|
+
}
|
|
366
|
+
for (const m of code.matchAll(/"((?:\\.|[^"\n])+)"\s*\.\s*toRegex\s*\(/g)) {
|
|
367
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
368
|
+
}
|
|
369
|
+
for (const m of code.matchAll(/\bPattern\.compile\s*\(\s*"((?:\\.|[^"\n])+)"/g)) {
|
|
370
|
+
out.push({ body: m[1].replace(/\\\\/g, '\\'), line: code.slice(0, m.index).split('\n').length });
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
308
375
|
export function scanRegexReDoS(file, raw) {
|
|
309
376
|
if (!file || !raw || typeof raw !== 'string') return [];
|
|
310
377
|
if (raw.length > 500_000) return [];
|
|
@@ -313,6 +380,14 @@ export function scanRegexReDoS(file, raw) {
|
|
|
313
380
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(file)) bodies = extractJsRegexBodies(raw);
|
|
314
381
|
else if (/\.py$/i.test(file)) bodies = extractPyRegexBodies(raw);
|
|
315
382
|
else if (/\.java$/i.test(file)) bodies = extractJavaRegexBodies(raw);
|
|
383
|
+
// NOTE: Go's regexp package uses RE2 (guaranteed linear time, no catastrophic
|
|
384
|
+
// backtracking), so Go is intentionally NOT scanned for ReDoS — a "vulnerable"
|
|
385
|
+
// pattern there is not actually exploitable. (extractGoRegexBodies is exported
|
|
386
|
+
// for completeness / third-party regexp libs but not wired into the default.)
|
|
387
|
+
else if (/\.(?:php|phtml)$/i.test(file)) bodies = extractPhpRegexBodies(raw);
|
|
388
|
+
else if (/\.rb$/i.test(file)) bodies = extractRubyRegexBodies(raw);
|
|
389
|
+
else if (/\.cs$/i.test(file)) bodies = extractCsharpRegexBodies(raw);
|
|
390
|
+
else if (/\.kt$/i.test(file)) bodies = extractKotlinRegexBodies(raw);
|
|
316
391
|
else return [];
|
|
317
392
|
|
|
318
393
|
for (const { body, line } of bodies) {
|
|
@@ -24,25 +24,43 @@
|
|
|
24
24
|
|
|
25
25
|
import { blankComments } from './_comment-strip.js';
|
|
26
26
|
|
|
27
|
+
// Each pattern captures the header VALUE expression in its LAST group (the
|
|
28
|
+
// code reads the last defined group, so header-name groups can come first).
|
|
27
29
|
const PATTERNS = [
|
|
28
30
|
// res.setHeader('name', val) | res.set('name', val) | ctx.response.set(…)
|
|
29
31
|
['js', /\b(?:res|reply|response|ctx\.response)\s*\.\s*(?:setHeader|set|append)\s*\(\s*[`"'][^`"']+[`"']\s*,\s*([^)]+?)\s*\)/g, 'res.setHeader'],
|
|
30
32
|
// response.setHeader / response.addHeader (Java Servlet API). Accept any
|
|
31
33
|
// identifier as the receiver — `resp`, `response`, `httpResponse`, etc.
|
|
32
|
-
['java', /\b(?:[A-Za-z_][\w]*)\s*\.\s*(?:setHeader|addHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'setHeader'],
|
|
34
|
+
['java', /\b(?:[A-Za-z_][\w]*)\s*\.\s*(?:setHeader|addHeader|addIntHeader|setDateHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'setHeader'],
|
|
33
35
|
// Django/Flask response.headers['X-…'] = userValue
|
|
34
36
|
['py', /\b(?:response|resp|r)\s*(?:\.\s*headers)?\s*\[\s*['"][^'"]+['"]\s*\]\s*=\s*([^\n;]+)/g, 'response.headers[…] = …'],
|
|
35
37
|
// PHP header("X-Foo: " . $_GET[…]) (Location: variants handled by open-redirect)
|
|
36
38
|
['php', /\bheader\s*\(\s*['"](?!Location)([^'"]+):\s*['"]\s*\.\s*(\$\w[\w\[\]'"]*)/g, 'PHP header()'],
|
|
39
|
+
// Go net/http: w.Header().Set/Add("X-…", value) | w.Header()["X-…"] = …
|
|
40
|
+
['go', /\.\s*Header\s*\(\s*\)\s*\.\s*(?:Set|Add)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'Header().Set'],
|
|
41
|
+
// Ruby Rack/Rails: response.headers["X-…"] = … | headers["X-…"] = … | response["X-…"] = …
|
|
42
|
+
['rb', /\b(?:response\s*(?:\.\s*headers)?|headers)\s*\[\s*['"][^'"]+['"]\s*\]\s*=\s*([^\n;]+)/g, 'response headers[…] = …'],
|
|
43
|
+
// C#: Response.Headers.Add/Append("X-…", value) | Response.AddHeader(…) | Response.AppendHeader(…)
|
|
44
|
+
['cs', /\bResponse\s*\.\s*(?:Headers\s*\.\s*(?:Add|Append)|AddHeader|AppendHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'Response.Headers.Add'],
|
|
45
|
+
// Kotlin: resp.setHeader("X-…", value) | resp.addHeader(…) — JVM servlet API
|
|
46
|
+
['kt', /\b(?:[A-Za-z_][\w]*)\s*\.\s*(?:setHeader|addHeader)\s*\(\s*"[^"]+"\s*,\s*([^)]+?)\s*\)/g, 'setHeader'],
|
|
37
47
|
];
|
|
38
48
|
|
|
39
49
|
const TAINT_HINT_RE =
|
|
40
|
-
/\b(?:req\.|request\.|params\.|query\.|body\.|ctx\.query|ctx\.request|reply\.query|c\.Query|r\.URL\.Query|_GET|_POST|_REQUEST|getParameter|getHeader)\b/;
|
|
50
|
+
/\b(?:req\.|request\.|params\.|params\s*\[|query\.|body\.|ctx\.query|ctx\.request|reply\.query|c\.Query|r\.URL\.Query|_GET|_POST|_REQUEST|getParameter|getHeader)\b|\b(?:params|query|cookies|session)\s*\[/;
|
|
41
51
|
|
|
42
52
|
const SANITIZER_PATTERNS = [
|
|
43
53
|
/\.replace\s*\(\s*\/\\r\?\\?n/i, // .replace(/\r?\n/, '')
|
|
44
54
|
/\.replace\s*\(\s*\/[\[]\\r\\n[\]]/i, // .replace(/[\r\n]/, '')
|
|
55
|
+
/\.replace\s*\(\s*['"]\\r['"]/, // .replace("\r", "") (Python/JS chain)
|
|
56
|
+
/\.replace\s*\(\s*['"]\\n['"]/, // .replace("\n", "")
|
|
45
57
|
/\.replaceAll\s*\(\s*['"]\\r?\\?n?['"]/,
|
|
58
|
+
/\bgsub\s*\(\s*\/[\[]?\\r/i, // Ruby .gsub(/[\r\n]/, '') or /\r\n/
|
|
59
|
+
/\bgsub\s*\(\s*\/[\[]\\r\\n[\]]/i,
|
|
60
|
+
/\bdelete\s*\(\s*['"]\\r\\n['"]\s*\)/, // Ruby .delete("\r\n")
|
|
61
|
+
/\bstrings\.NewReplacer\s*\([^)]*\\r/, // Go strings.NewReplacer("\r","","\n","")
|
|
62
|
+
/\bstrings\.ReplaceAll\s*\([^,]+,\s*['"]\\[rn]['"]/,
|
|
63
|
+
/\bstr_replace\s*\(\s*\[[^\]]*\\[rn]/, // PHP str_replace(["\r","\n"], …)
|
|
46
64
|
/\bstripNewlines\b/i,
|
|
47
65
|
/\bsanitizeHeader\b/i,
|
|
48
66
|
/\bescapeCRLF\b/i,
|
|
@@ -63,7 +81,11 @@ function _lang(fp) {
|
|
|
63
81
|
if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
|
|
64
82
|
if (/\.py$/i.test(fp)) return 'py';
|
|
65
83
|
if (/\.java$/i.test(fp)) return 'java';
|
|
66
|
-
if (/\.php$/i.test(fp)) return 'php';
|
|
84
|
+
if (/\.(?:php|phtml)$/i.test(fp)) return 'php';
|
|
85
|
+
if (/\.go$/i.test(fp)) return 'go';
|
|
86
|
+
if (/\.rb$/i.test(fp)) return 'rb';
|
|
87
|
+
if (/\.cs$/i.test(fp)) return 'cs';
|
|
88
|
+
if (/\.kt$/i.test(fp)) return 'kt';
|
|
67
89
|
return null;
|
|
68
90
|
}
|
|
69
91
|
|
|
@@ -79,8 +101,8 @@ export function scanResponseSplitting(fp, raw) {
|
|
|
79
101
|
if (!raw || raw.length > 500_000) return [];
|
|
80
102
|
const lang = _lang(fp);
|
|
81
103
|
if (!lang) return [];
|
|
82
|
-
const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
|
|
83
|
-
if (!/\b(?:setHeader|addHeader|response\s*\.\s*headers|response\s*\[|reply\s*\.\s*set|\bheader\s*\()/i.test(code)) return [];
|
|
104
|
+
const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
|
|
105
|
+
if (!/\b(?:setHeader|addHeader|addIntHeader|setDateHeader|AddHeader|AppendHeader|response\s*\.\s*headers|response\s*\[|headers\s*\[|reply\s*\.\s*set|\.\s*Header\s*\(\s*\)\s*\.\s*(?:Set|Add)|Response\s*\.\s*Headers|\bheader\s*\()/i.test(code)) return [];
|
|
84
106
|
const findings = [];
|
|
85
107
|
const seen = new Set();
|
|
86
108
|
for (const [plang, pat, label] of PATTERNS) {
|
|
@@ -88,23 +110,30 @@ export function scanResponseSplitting(fp, raw) {
|
|
|
88
110
|
const re = new RegExp(pat.source, pat.flags);
|
|
89
111
|
let m;
|
|
90
112
|
while ((m = re.exec(code))) {
|
|
91
|
-
|
|
113
|
+
// Use the LAST defined capture group as the header VALUE (php's pattern
|
|
114
|
+
// captures the header NAME first, the value second).
|
|
115
|
+
const value = ((m[m.length - 1] ?? m[1]) || '').trim();
|
|
92
116
|
let tainted = TAINT_HINT_RE.test(value);
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
const sigRe = /\b(?:
|
|
103
|
-
let sig = null;
|
|
104
|
-
let sm;
|
|
117
|
+
// Handler-context heuristic: when `value` is a plain identifier that is a
|
|
118
|
+
// parameter of the enclosing method/function, treat it as user-derived
|
|
119
|
+
// (same "request-scope param" rule a real SAST tool applies). Covers the
|
|
120
|
+
// single-file `void h(String v, … resp){ resp.setHeader("X", v); }` shape
|
|
121
|
+
// for Java / Kotlin / C#.
|
|
122
|
+
if (!tainted && (plang === 'java' || plang === 'kt' || plang === 'cs') && /^[A-Za-z_][\w]*$/.test(value)) {
|
|
123
|
+
const before = code.slice(0, m.index);
|
|
124
|
+
// Most recent signature `(...)` immediately followed by `{` — matches
|
|
125
|
+
// Java/C# (`… name(params) {`) and Kotlin (`fun name(params) … {`).
|
|
126
|
+
const sigRe = /\b(?:fun\s+\w+|[A-Za-z_][\w<>\[\],.\s]*\b\w+)\s*\(([^)]*)\)\s*(?::[^={]+)?(?:throws[^{]+)?\{/g;
|
|
127
|
+
let sig = null, sm;
|
|
105
128
|
while ((sm = sigRe.exec(before)) !== null) sig = sm;
|
|
106
129
|
if (sig) {
|
|
107
|
-
|
|
130
|
+
// Param name is the last token of each `Type name` / first of Kotlin `name: Type`.
|
|
131
|
+
const params = sig[1].split(',').map((p) => {
|
|
132
|
+
const t = p.trim();
|
|
133
|
+
if (!t) return '';
|
|
134
|
+
if (t.includes(':')) return t.split(':')[0].trim(); // Kotlin `name: Type`
|
|
135
|
+
return (t.split(/\s+/).pop() || '').trim(); // Java/C# `Type name`
|
|
136
|
+
});
|
|
108
137
|
if (params.includes(value)) tainted = true;
|
|
109
138
|
}
|
|
110
139
|
}
|
package/src/sast/ruby.js
CHANGED
|
@@ -23,6 +23,24 @@ const RE = {
|
|
|
23
23
|
attributesEq: /\.\s*attributes\s*=\s*params\b(?!\s*\.permit)/g,
|
|
24
24
|
openSsrf: /\b(?:open|URI\.open|URI\.parse\([^)]*\)\.read)\s*\(\s*(?:params|request|@\w+\.params)\b/g,
|
|
25
25
|
fileRead: /\bFile\s*\.\s*(?:read|open|new|readlines)\s*\(\s*params\s*\[/g,
|
|
26
|
+
// Structural (taint-independent): an ActiveRecord query or shell command
|
|
27
|
+
// built with string interpolation (#{...}) or concat is the injection shape
|
|
28
|
+
// regardless of whether the value is `params` or a local variable that came
|
|
29
|
+
// from params — the existing rules above require the literal `params` token
|
|
30
|
+
// on the sink line, which misses `name = params[:x]; where("... #{name}")`.
|
|
31
|
+
sqlInjectionStructural: /\.(?:where|find_by_sql|having|order|group|joins|select|from|pluck|update_all|delete_all|exec_query|execute|select_all|select_value|find_by)\s*\(\s*(?:"[^"\n]*#\{|['"][^'"\n]*['"]\s*\+)/g,
|
|
32
|
+
cmdInjectionStructural: /(?:`[^`\n]*#\{|\b(?:system|exec)\s*\(\s*"[^"\n]*#\{|\bIO\.popen\s*\(\s*"[^"\n]*#\{|\b(?:system|exec)\s*\(\s*['"][^'"\n]*['"]\s*\+)/g,
|
|
33
|
+
// Structural path traversal: File/IO op whose path is built by interpolation
|
|
34
|
+
// (#{...}) or concat. `File.read("/data/" + name)` routes through a local var
|
|
35
|
+
// so the `params[`-on-sink fileRead rule misses it. Containment-guarded forms
|
|
36
|
+
// (File.expand_path + start_with?) are dropped by engine.js dropGuardedFindings.
|
|
37
|
+
pathTraversalStructural: /\b(?:File|IO)\s*\.\s*(?:read|open|new|readlines|binread|write|foreach)\s*\(\s*(?:"[^"\n]*#\{|['"][^'"\n]*['"]\s*\+)/g,
|
|
38
|
+
// Structural deserialization (taint-independent): Marshal.load / YAML.load /
|
|
39
|
+
// YAML.unsafe_load are unsafe on ANY non-trivial argument — the danger is the
|
|
40
|
+
// sink, not the source. The `params`-on-sink marshalLoad/yamlUnsafe rules
|
|
41
|
+
// above miss `data = request.body.read; Marshal.load(data)`. A bare string
|
|
42
|
+
// literal arg (Marshal.load("...")) won't match (`\w` requires an identifier).
|
|
43
|
+
deserStructural: /\bMarshal\s*\.\s*load\s*\(\s*[A-Za-z_@]\w*|\bYAML\s*\.\s*(?:load|unsafe_load)\s*\(\s*[A-Za-z_@]\w*/g,
|
|
26
44
|
};
|
|
27
45
|
|
|
28
46
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
@@ -92,6 +110,26 @@ export function scanRuby(fp, raw) {
|
|
|
92
110
|
severity: 'high', cwe: 'CWE-22',
|
|
93
111
|
remediation: 'Canonicalize the path and verify it stays under an allowed base: `path = File.expand_path(File.join(base, name)); raise unless path.start_with?(base)`.',
|
|
94
112
|
},
|
|
113
|
+
sqlInjectionStructural: {
|
|
114
|
+
vuln: 'SQL Injection: ActiveRecord query built with string interpolation / concat',
|
|
115
|
+
severity: 'critical', cwe: 'CWE-89',
|
|
116
|
+
remediation: 'Use the parameterized form: `User.where("name = ?", name)` or `where(name: name)`. Never interpolate (`#{...}`) or concatenate values into a SQL fragment.',
|
|
117
|
+
},
|
|
118
|
+
cmdInjectionStructural: {
|
|
119
|
+
vuln: 'Command Injection: shell command built with string interpolation / concat',
|
|
120
|
+
severity: 'critical', cwe: 'CWE-78',
|
|
121
|
+
remediation: 'Use `Open3.capture2("cmd", arg1, arg2)` with separate arguments (no shell). Backticks and `system("... #{x}")` run through the shell.',
|
|
122
|
+
},
|
|
123
|
+
pathTraversalStructural: {
|
|
124
|
+
vuln: 'Path Traversal: File/IO operation built with interpolated/concatenated path',
|
|
125
|
+
severity: 'high', cwe: 'CWE-22',
|
|
126
|
+
remediation: 'Canonicalize and assert containment: `path = File.expand_path(File.join(base, name)); raise unless path.start_with?(base)`. Never interpolate/concatenate request input straight into a file path.',
|
|
127
|
+
},
|
|
128
|
+
deserStructural: {
|
|
129
|
+
vuln: 'Insecure Deserialization: Marshal.load / YAML.load on a non-literal value',
|
|
130
|
+
severity: 'critical', cwe: 'CWE-502', family: 'insecure-deserialization',
|
|
131
|
+
remediation: 'Marshal and YAML.load instantiate arbitrary Ruby objects (RCE gadget chains). Use JSON for data crossing a trust boundary, or YAML.safe_load(input, permitted_classes: [Symbol]). Never Marshal.load attacker-controlled bytes.',
|
|
132
|
+
},
|
|
95
133
|
}[key];
|
|
96
134
|
push({
|
|
97
135
|
id: `ruby-${key}:${fp}:${line}`,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Split-concatenation secret detector (language-agnostic) — PRD Tier 1.
|
|
2
|
+
//
|
|
3
|
+
// A hardcoded credential is frequently SPLIT across concatenated string
|
|
4
|
+
// literals specifically to slip past secret regexes that anchor on a
|
|
5
|
+
// contiguous token: `AWS_KEY = 'AKIA' + 'IOSFODNN7EXAMPLE'`,
|
|
6
|
+
// `GITHUB_TOKEN = 'ghp' + '_…'`, `STRIPE_KEY = 'sk' + '_live_…' + '…'`.
|
|
7
|
+
//
|
|
8
|
+
// We join the literals back together and match the *reassembled* value against
|
|
9
|
+
// known provider prefixes (high precision — these markers don't occur by
|
|
10
|
+
// accident) or, for a credential-named LHS, a long joined literal. The flow
|
|
11
|
+
// engine and the contiguous-token secrets scanner both miss the split shape;
|
|
12
|
+
// csharp-structural.js handles it for C# only. This covers JS/TS/Py/Rb/Go/
|
|
13
|
+
// Java/PHP via the `+` (and PHP `.`) concat operators.
|
|
14
|
+
|
|
15
|
+
import { blankComments } from './_comment-strip.js';
|
|
16
|
+
|
|
17
|
+
// Real provider markers — anchored, so a generic `'foo' + 'bar'` won't match.
|
|
18
|
+
const SECRET_PREFIX = /(?:sk_|sk-|AKIA|ASIA|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[abprs]-|AIza|ya29\.|eyJ[A-Za-z0-9_-]{8,}|-----BEGIN|glpat-|shpat_|shpss_|npm_|dop_v1_|SG\.[A-Za-z0-9_-]{10,})/;
|
|
19
|
+
// Credential-signalling LHS identifier.
|
|
20
|
+
const SECRET_NAME = /(?:api[_-]?key|secret|token|passwd|password|pwd|credential|private[_-]?key|access[_-]?key|client[_-]?secret|auth[_-]?token)/i;
|
|
21
|
+
|
|
22
|
+
// LHS ident, then `=`/`:=`/`:`, then a concat of >=2 string literals joined by
|
|
23
|
+
// `+` (most langs) or `.` (PHP). Single or double quoted.
|
|
24
|
+
const ASSIGN_CONCAT = /([A-Za-z_$][\w$]*)\s*(?::?=|:)\s*((?:(?:'[^'\n]*'|"[^"\n]*")\s*[+.]\s*)+(?:'[^'\n]*'|"[^"\n]*"))/g;
|
|
25
|
+
|
|
26
|
+
const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
|
|
27
|
+
function joinLiterals(expr) {
|
|
28
|
+
const parts = expr.match(/'[^'\n]*'|"[^"\n]*"/g) || [];
|
|
29
|
+
return parts.map((p) => p.slice(1, -1)).join('');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function scanSecretConcat(fp, raw) {
|
|
33
|
+
if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py|rb|go|java|kt|php|phtml|cs|scala|groovy)$/i.test(fp)) return [];
|
|
34
|
+
if (!raw || raw.length > 500_000) return [];
|
|
35
|
+
const code = blankComments(raw);
|
|
36
|
+
const findings = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
|
|
39
|
+
const re = new RegExp(ASSIGN_CONCAT.source, ASSIGN_CONCAT.flags);
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = re.exec(code))) {
|
|
42
|
+
const ident = m[1];
|
|
43
|
+
const value = joinLiterals(m[2]);
|
|
44
|
+
const byPrefix = SECRET_PREFIX.test(value);
|
|
45
|
+
const byName = SECRET_NAME.test(ident) && value.length >= 24;
|
|
46
|
+
if (!byPrefix && !byName) continue;
|
|
47
|
+
const line = lineOf(code, m.index);
|
|
48
|
+
const id = `secret-concat:${fp}:${line}`;
|
|
49
|
+
if (seen.has(id)) continue;
|
|
50
|
+
seen.add(id);
|
|
51
|
+
findings.push({
|
|
52
|
+
id, file: fp, line,
|
|
53
|
+
vuln: 'Hardcoded credential — secret split across concatenated literals to evade detection',
|
|
54
|
+
severity: 'high', cwe: 'CWE-798', family: 'secret', parser: 'SECRET-CONCAT', confidence: 0.78,
|
|
55
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
56
|
+
remediation: 'Load the secret from the environment or a secrets manager (process.env / os.environ / Vault / AWS Secrets Manager). Splitting the literal across a concatenation does not protect it — rotate the exposed value, it must be considered compromised.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
@@ -21,6 +21,13 @@ const SSRF_CLIENT_RE = /\b(?:fetch|axios\.\w+|requests\.\w+|http\.get|http\.requ
|
|
|
21
21
|
|
|
22
22
|
const METADATA_GUARD_RE = /(?:169\.254\.169\.254|169\.254\.|metadata\.google\.internal|metadata\.azure\.com|fd00:ec2|metadata\.aws\.amazon)/i;
|
|
23
23
|
|
|
24
|
+
// A metadata literal that appears inside an allow/deny-list or a host
|
|
25
|
+
// comparison is a GUARD (the remediation), not an SSRF — e.g.
|
|
26
|
+
// `DENY = Set.of("169.254.169.254", …)`, `if (u.Host == "169.254.169.254")
|
|
27
|
+
// throw`, `host !in setOf("169.254.169.254")`, `["169.254.169.254"].includes`.
|
|
28
|
+
// Flagging it makes the scanner cry wolf on correctly-hardened code.
|
|
29
|
+
const METADATA_GUARD_CONTEXT_RE = /\b(?:deny|denylist|blocklist|blocked?|allow(?:list|ed)?|forbidden|reject(?:ed)?|disallow|banned?)\b|[!=]==?\s*['"]|\.equals\s*\(|\.contains\b|\.includes\b|\.indexOf\b|\bin\s+\w|\bnot\s+in\b|!in\b|Set\s*\.\s*of|setOf|new\s+HashSet|\bthrow\b|SecurityException|\babort\b|getHost\s*\(|\.host\b|\.Host\b/i;
|
|
30
|
+
|
|
24
31
|
function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
25
32
|
|
|
26
33
|
export function scanSSRFCloudMetadata(fp, raw) {
|
|
@@ -37,6 +44,12 @@ export function scanSSRFCloudMetadata(fp, raw) {
|
|
|
37
44
|
let m;
|
|
38
45
|
while ((m = r.exec(code))) {
|
|
39
46
|
const line = lineOf(raw, m.index);
|
|
47
|
+
// Guard recognition: if the metadata literal sits in an allow/deny-list
|
|
48
|
+
// or host-comparison (this line ± 1), it's blocking the endpoint, not
|
|
49
|
+
// calling it — suppress the false positive on hardened code.
|
|
50
|
+
const lines = raw.split('\n');
|
|
51
|
+
const ctx = lines.slice(Math.max(0, line - 2), line + 1).join('\n');
|
|
52
|
+
if (METADATA_GUARD_CONTEXT_RE.test(ctx)) continue;
|
|
40
53
|
push({
|
|
41
54
|
id: `ssrf-meta-hardcoded:${fp}:${line}`,
|
|
42
55
|
file: fp, line,
|