@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/bin/agentic-security.js +78 -1
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/415.index.js +90 -0
  6. package/dist/444.index.js +89 -0
  7. package/dist/503.index.js +288 -0
  8. package/dist/637.index.js +15 -1
  9. package/dist/838.index.js +1 -1
  10. package/dist/985.index.js +5 -1
  11. package/dist/agentic-security.mjs +84 -84
  12. package/dist/agentic-security.mjs.sha256 +1 -1
  13. package/package.json +21 -10
  14. package/src/dataflow/CLAUDE.md +18 -6
  15. package/src/dataflow/catalog.js +59 -11
  16. package/src/dataflow/engine.js +106 -5
  17. package/src/dataflow/implicit-flow.js +4 -2
  18. package/src/dataflow/index.js +17 -4
  19. package/src/dataflow/proof-gate.js +100 -0
  20. package/src/dataflow/summaries.js +63 -6
  21. package/src/engine.js +292 -10
  22. package/src/ir/CLAUDE.md +1 -0
  23. package/src/ir/callgraph.js +12 -1
  24. package/src/ir/parser-go.js +8 -0
  25. package/src/ir/tree-sitter-loader.js +108 -0
  26. package/src/mcp/tools.js +6 -2
  27. package/src/posture/corpus-status.js +74 -0
  28. package/src/posture/coverage-report.js +113 -0
  29. package/src/posture/fix-history.js +24 -0
  30. package/src/posture/holdout-eval.js +57 -0
  31. package/src/posture/provenance.js +39 -0
  32. package/src/posture/router.js +33 -3
  33. package/src/posture/sca-verdict.js +130 -0
  34. package/src/posture/secret-history.js +72 -0
  35. package/src/pr-delta.js +13 -0
  36. package/src/report/index.js +67 -0
  37. package/src/sast/CLAUDE.md +9 -3
  38. package/src/sast/agent-untrusted-flow.js +107 -0
  39. package/src/sast/api-authz.js +71 -0
  40. package/src/sast/code-injection-multilang.js +117 -0
  41. package/src/sast/cross-service.js +98 -0
  42. package/src/sast/crypto-protocol.js +43 -1
  43. package/src/sast/csharp-structural.js +65 -0
  44. package/src/sast/csharp.js +3 -0
  45. package/src/sast/csrf.js +24 -3
  46. package/src/sast/csv-injection.js +84 -0
  47. package/src/sast/event-entrypoint.js +90 -0
  48. package/src/sast/frontend-hygiene.js +106 -0
  49. package/src/sast/go-structural.js +60 -0
  50. package/src/sast/iac-terraform.js +107 -0
  51. package/src/sast/java-structural.js +81 -0
  52. package/src/sast/js-framework-structural.js +115 -0
  53. package/src/sast/kotlin.js +72 -0
  54. package/src/sast/ldap-injection.js +73 -17
  55. package/src/sast/open-redirect.js +47 -5
  56. package/src/sast/php.js +35 -0
  57. package/src/sast/python-structural.js +83 -0
  58. package/src/sast/rbac-consistency.js +113 -0
  59. package/src/sast/redos-nfa.js +75 -0
  60. package/src/sast/response-splitting.js +48 -19
  61. package/src/sast/ruby.js +38 -0
  62. package/src/sast/secret-concat.js +60 -0
  63. package/src/sast/ssrf-cloud-metadata.js +13 -0
  64. package/src/sast/stored-taint.js +88 -0
  65. package/src/sast/tree-sitter-sinks.js +62 -0
  66. package/src/sast/weak-password-hash.js +28 -0
  67. package/src/sast/weak-randomness.js +46 -5
  68. package/src/sast/wrong-context-sanitizer.js +149 -0
  69. package/src/sast/xpath-injection.js +30 -6
  70. package/src/sast/xss-reflected-multilang.js +116 -0
  71. package/src/sast/xxe.js +67 -0
  72. package/src/sca/image-packages.js +74 -0
  73. package/src/sca/import-reachability.js +271 -0
  74. package/src/sca/install-script-analysis.js +82 -0
  75. package/bin/.agentic-security/findings.json +0 -1907
  76. package/bin/.agentic-security/last-scan.json +0 -1907
  77. package/bin/.agentic-security/last-scan.json.sig +0 -1
  78. package/bin/.agentic-security/scan-history.json +0 -166
  79. package/bin/.agentic-security/streak.json +0 -20
  80. package/src/.agentic-security/findings.json +0 -117732
  81. package/src/.agentic-security/last-scan.json +0 -117732
  82. package/src/.agentic-security/last-scan.json.sig +0 -1
  83. package/src/.agentic-security/scan-history.json +0 -12946
  84. package/src/.agentic-security/streak.json +0 -21
  85. package/src/dataflow/.agentic-security/findings.json +0 -6086
  86. package/src/dataflow/.agentic-security/last-scan.json +0 -6086
  87. package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
  88. package/src/dataflow/.agentic-security/scan-history.json +0 -250
  89. package/src/dataflow/.agentic-security/streak.json +0 -21
  90. package/src/ir/.agentic-security/findings.json +0 -4011
  91. package/src/ir/.agentic-security/last-scan.json +0 -4011
  92. package/src/ir/.agentic-security/last-scan.json.sig +0 -1
  93. package/src/ir/.agentic-security/scan-history.json +0 -193
  94. package/src/ir/.agentic-security/streak.json +0 -20
  95. package/src/mcp/.agentic-security/findings.json +0 -8632
  96. package/src/mcp/.agentic-security/last-scan.json +0 -8632
  97. package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
  98. package/src/mcp/.agentic-security/scan-history.json +0 -331
  99. package/src/mcp/.agentic-security/streak.json +0 -20
  100. package/src/posture/.agentic-security/dpia.md +0 -26
  101. package/src/posture/.agentic-security/findings.json +0 -77181
  102. package/src/posture/.agentic-security/last-scan.json +0 -77181
  103. package/src/posture/.agentic-security/last-scan.json.sig +0 -1
  104. package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
  105. package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
  106. package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  107. package/src/posture/.agentic-security/scan-history.json +0 -8904
  108. package/src/posture/.agentic-security/streak.json +0 -21
  109. package/src/posture/.agentic-security/threat-model.json +0 -2038
  110. package/src/posture/.agentic-security/threat-model.md +0 -73
  111. package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
  112. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
  113. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
  114. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
  115. package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
  116. package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  117. package/src/report/.agentic-security/threat-model.json +0 -7
  118. package/src/report/.agentic-security/threat-model.md +0 -22
  119. package/src/sast/.agentic-security/findings.json +0 -6154
  120. package/src/sast/.agentic-security/last-scan.json +0 -6154
  121. package/src/sast/.agentic-security/last-scan.json.sig +0 -1
  122. package/src/sast/.agentic-security/scan-history.json +0 -941
  123. package/src/sast/.agentic-security/streak.json +0 -22
  124. package/src/sca/.agentic-security/findings.json +0 -7460
  125. package/src/sca/.agentic-security/last-scan.json +0 -7460
  126. package/src/sca/.agentic-security/last-scan.json.sig +0 -1
  127. package/src/sca/.agentic-security/scan-history.json +0 -113
  128. package/src/sca/.agentic-security/streak.json +0 -21
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ // R21 (PRD §5) — RBAC role-tier authorization model.
2
+ //
3
+ // R19 (api-authz.js) flags missing AUTHENTICATION among authenticated siblings.
4
+ // This goes one level deeper to AUTHORIZATION TIER: it extracts the role/
5
+ // permission a route requires (from middleware/annotations near the handler) and
6
+ // flags two broken-access-control shapes the route inventory alone can't see:
7
+ // 1. A state-changing route that enforces NO role while sibling routes on the
8
+ // same resource DO → missing function-level authorization (BFLA).
9
+ // 2. A state-changing route gated at a STRICTLY LOWER tier than a read sibling
10
+ // → privilege tier inversion (delete allowed below the tier that can read).
11
+ //
12
+ // Pure cross-route analysis (routes + file contents). Precision-first: fires
13
+ // only on inconsistency within a resource group that uses roles at all.
14
+
15
+ // Inline role checks — on the route's OWN registration line (middleware args).
16
+ const INLINE_PATTERNS = [
17
+ /\b(?:requireRole|hasRole|hasAnyRole|checkRole|ensureRole|restrictTo|requireScope|hasAuthority|hasPermission|requirePermission)\s*\(\s*\[?\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
18
+ /\brole\s*(?:===?|==)\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
19
+ ];
20
+ // Decorator/attribute role checks — on the line(s) directly ABOVE the handler.
21
+ const DECORATOR_PATTERNS = [
22
+ /@(?:PreAuthorize|Secured|RolesAllowed)\s*\(\s*[^)]*['"]([A-Za-z0-9_.:-]+)['"]/i,
23
+ /\[Authorize\s*\(\s*Roles\s*=\s*"([A-Za-z0-9_.,:-]+)"/i,
24
+ /@(?:roles_required|permission_required)\s*\(\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
25
+ ];
26
+
27
+ // Coarse tier ranking (higher = more privileged). Unknown roles → tier 1 (mid).
28
+ function tierOf(role) {
29
+ const r = String(role || '').toLowerCase();
30
+ if (/\b(super.?admin|root|owner|sysadmin)\b/.test(r) || r === 'superadmin') return 3;
31
+ if (/\b(admin|administrator|manager|staff|moderator|mod)\b/.test(r)) return 2;
32
+ if (/\b(user|member|authenticated|customer|editor|contributor)\b/.test(r)) return 1;
33
+ if (/\b(guest|public|anon|anonymous|viewer|reader)\b/.test(r)) return 0;
34
+ return 1;
35
+ }
36
+
37
+ const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
38
+
39
+ function roleOf(route, fileContents) {
40
+ const content = fileContents[route.file];
41
+ if (typeof content !== 'string') return { hasRole: false, role: null };
42
+ const lines = content.split('\n');
43
+ const idx = (route.line || 1) - 1;
44
+ // 1. Inline middleware / role check on the route's OWN line (don't look at
45
+ // siblings — that would leak an adjacent route's role into this one).
46
+ const own = lines[idx] || '';
47
+ for (const re of INLINE_PATTERNS) { const m = own.match(re); if (m) return { hasRole: true, role: m[1] }; }
48
+ // 2. Stacked decorators/attributes directly ABOVE the handler; stop at the
49
+ // first non-decorator line so we never cross into the previous route.
50
+ for (let j = idx - 1; j >= 0 && j >= idx - 4; j--) {
51
+ const l = (lines[j] || '').trim();
52
+ if (!l) continue;
53
+ if (/^@\w|^\[Authorize/i.test(l)) {
54
+ for (const re of DECORATOR_PATTERNS) { const m = l.match(re); if (m) return { hasRole: true, role: m[1] }; }
55
+ continue; // an unrecognized decorator — keep scanning the stack upward
56
+ }
57
+ break; // hit a non-decorator line → stop
58
+ }
59
+ return { hasRole: false, role: null };
60
+ }
61
+
62
+ // Resource key: path with dynamic segments collapsed (so /users and /users/:id
63
+ // group together as one resource).
64
+ function resourceKey(p) {
65
+ return String(p || '').split('/').map((s) => (/^(:|\{|<|\d)/.test(s) ? '*' : s)).join('/').replace(/\/\*$/, '') || '/';
66
+ }
67
+
68
+ function mk(route, kind, cwe, why) {
69
+ return {
70
+ id: `rbac:${kind}:${route.file}:${route.line}`,
71
+ severity: 'high',
72
+ file: route.file, line: route.line || 0,
73
+ vuln: kind === 'inversion'
74
+ ? 'Authorization tier inversion (state-change allowed below the read tier)'
75
+ : 'Missing role-based authorization on a state-changing route',
76
+ cwe, family: 'broken-access-control', parser: 'RBAC',
77
+ description: why,
78
+ remediation: 'Enforce a role/permission check on the route at least as strong as its sibling routes; gate state-changing actions at the appropriate privilege tier.',
79
+ };
80
+ }
81
+
82
+ export function scanRbacConsistency(routes, fileContents) {
83
+ if (!Array.isArray(routes) || routes.length < 2) return [];
84
+ const groups = new Map();
85
+ for (const r of routes) {
86
+ if (!r || r.path === '(file-based)' || !r.path) continue;
87
+ const key = `${r.file}::${resourceKey(r.path)}`;
88
+ if (!groups.has(key)) groups.set(key, []);
89
+ groups.get(key).push({ ...r, ...roleOf(r, fileContents) });
90
+ }
91
+ const findings = [];
92
+ const seen = new Set();
93
+ const push = (f) => { const k = `${f.file}:${f.line}`; if (!seen.has(k)) { seen.add(k); findings.push(f); } };
94
+ for (const group of groups.values()) {
95
+ if (group.length < 2) continue;
96
+ const roled = group.filter((r) => r.hasRole);
97
+ if (!roled.length) continue; // resource doesn't use roles at all → not our signal
98
+ const maxReadTier = Math.max(...group.filter((r) => !STATE_CHANGING.has(r.method) && r.hasRole).map((r) => tierOf(r.role)), -1);
99
+ for (const r of group) {
100
+ if (!STATE_CHANGING.has(r.method)) continue;
101
+ if (!r.hasRole) {
102
+ push(mk(r, 'missing', '285',
103
+ `${r.method} ${r.path} enforces no role check, while ${roled.length} sibling route(s) on this resource require one — a state-changing action left at a lower authorization tier (BFLA).`));
104
+ } else if (maxReadTier >= 0 && tierOf(r.role) < maxReadTier) {
105
+ push(mk(r, 'inversion', '269',
106
+ `${r.method} ${r.path} requires role "${r.role}" (tier ${tierOf(r.role)}), but reading this resource requires a higher tier (${maxReadTier}). A lower-privileged user can mutate what they may not even read.`));
107
+ }
108
+ }
109
+ }
110
+ return findings;
111
+ }
112
+
113
+ export const _internals = { tierOf, resourceKey };
@@ -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
- const value = (m[1] || '').trim();
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
- // Java-handler-context heuristic: if the receiver looks like an
94
- // HttpServletResponse and `value` is a plain ident that appears in
95
- // the enclosing method's param list, treat it as user-derived. Same
96
- // signature pattern as a real SAST tool's "request-scope param" rule.
97
- if (!tainted && plang === 'java' && /^[A-Za-z_][\w]*$/.test(value)) {
98
- const callIdx = m.index;
99
- // Find the start of the enclosing method by walking back to the
100
- // most recent `(...)`-style signature followed by `{`.
101
- const before = code.slice(0, callIdx);
102
- const sigRe = /\b(?:public|private|protected|static)[^;{]*\(([^)]*)\)\s*(?:throws[^{]+)?\{/g;
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
- const params = sig[1].split(',').map(p => (p.trim().split(/\s+/).pop() || '').trim());
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,
@@ -0,0 +1,88 @@
1
+ // Stored / second-order taint (roadmap #2) — OPT-IN.
2
+ //
3
+ // Classic SAST blind spot: data written to a persistence store (DB / cache /
4
+ // file) by one request and read back by another is implicitly trusted, but if
5
+ // any user input was ever stored there it is still attacker-controlled. The
6
+ // result is stored XSS, second-order SQLi, etc. The engine's forward taint
7
+ // can't see across the persistence boundary.
8
+ //
9
+ // This detector flags the second-order SHAPE: a value READ from a store that
10
+ // flows into an injection sink without re-validation. It is inherently lower
11
+ // confidence than first-order taint (stored data is not ALWAYS attacker-
12
+ // controlled), so it is OFF by default and enabled with:
13
+ //
14
+ // AGENTIC_SECURITY_STORED_TAINT=1
15
+ //
16
+ // Being opt-in, it cannot change default scan behavior (no default FPs/FNs).
17
+
18
+ import { blankComments } from './_comment-strip.js';
19
+
20
+ const JS_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
21
+ const PY_RE = /\.py$/i;
22
+
23
+ // A read from a persistence store, capturing the assigned variable.
24
+ // const row = await db.query(...) | row = collection.find_one(...)
25
+ const READ_ASSIGN = [
26
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[\w$.]*\b(?:query|find|findOne|findById|findAll|fetchRow|fetchAll|getItem|get|scan|select|aggregate|exec)\s*\(/g,
27
+ /\b([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[\w$.]*\b(?:query|find|find_one|fetchone|fetchall|first|all|get_object_or_404)\s*\(/g,
28
+ ];
29
+
30
+ // Injection sinks that, fed stored data, are second-order vulnerabilities.
31
+ function sinkPatternsFor(varName) {
32
+ const v = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ return [
34
+ [new RegExp(`\\b(?:eval|exec|execSync|Function)\\s*\\([^)]*\\b${v}\\b`), 'CWE-94', 'code-injection'],
35
+ [new RegExp(`\\.(?:innerHTML|outerHTML)\\s*=\\s*[^;\\n]*\\b${v}\\b`), 'CWE-79', 'xss'],
36
+ [new RegExp(`\\bres(?:ponse)?\\s*\\.\\s*(?:send|write|end)\\s*\\([^)]*\\b${v}\\b`), 'CWE-79', 'xss'],
37
+ [new RegExp(`\\b(?:query|execute|raw)\\s*\\([^)]*\\b${v}\\b[^)]*\\+`), 'CWE-89', 'sql-injection'],
38
+ [new RegExp(`\\b(?:exec|execSync|spawn|system|popen)\\s*\\([^)]*\\b${v}\\b`), 'CWE-78', 'command-injection'],
39
+ ];
40
+ }
41
+
42
+ const REVALIDATED = /\b(?:validate|sanitize|escape|encode|allow(?:list|ed)|schema\.\w*parse)\b/i;
43
+
44
+ function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
45
+
46
+ export function scanStoredTaint(fp, raw) {
47
+ if (process.env.AGENTIC_SECURITY_STORED_TAINT !== '1') return [];
48
+ if (!raw || raw.length > 500_000) return [];
49
+ if (!JS_RE.test(fp) && !PY_RE.test(fp)) return [];
50
+
51
+ const code = blankComments(raw, PY_RE.test(fp) ? 'py' : undefined);
52
+ const lines = code.split('\n');
53
+ const findings = [];
54
+ const seen = new Set();
55
+
56
+ for (const re of READ_ASSIGN) {
57
+ re.lastIndex = 0;
58
+ let m;
59
+ while ((m = re.exec(code))) {
60
+ const varName = m[1];
61
+ if (!varName) continue;
62
+ const readLine = lineOf(code, m.index);
63
+ // Search a forward window for the var reaching a sink, with no
64
+ // re-validation in between.
65
+ const winEnd = Math.min(lines.length, readLine + 20);
66
+ const window = lines.slice(readLine, winEnd).join('\n');
67
+ if (REVALIDATED.test(window)) continue;
68
+ for (const [sinkRe, cwe, family] of sinkPatternsFor(varName)) {
69
+ const sm = sinkRe.exec(window);
70
+ if (!sm) continue;
71
+ const sinkLine = readLine + window.substring(0, sm.index).split('\n').length;
72
+ const key = `${sinkLine}:${family}`;
73
+ if (seen.has(key)) continue;
74
+ seen.add(key);
75
+ findings.push({
76
+ id: `stored-taint:${fp}:${sinkLine}:${family}`,
77
+ severity: 'medium', file: fp, line: sinkLine,
78
+ vuln: `Second-order / stored taint candidate (${family})`,
79
+ cwe, family, parser: 'SAST', confidence: 0.4,
80
+ description: `A value read from a persistence store ('${varName}') flows into a ${family} sink without re-validation. If any user input is ever written to that store, this is a second-order injection (e.g. stored XSS / second-order SQLi).`,
81
+ remediation: 'Treat data read from a store as untrusted: validate/encode it at the sink exactly as you would direct request input. Do not rely on validation performed at write time.',
82
+ });
83
+ break; // one sink class per read is enough
84
+ }
85
+ }
86
+ }
87
+ return findings;
88
+ }