@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/dist/178.index.js +1 -1
  3. package/dist/384.index.js +1 -1
  4. package/dist/637.index.js +1 -1
  5. package/dist/838.index.js +1 -1
  6. package/dist/agentic-security.mjs +84 -84
  7. package/dist/agentic-security.mjs.sha256 +1 -1
  8. package/package.json +12 -6
  9. package/src/.agentic-security/dpia.md +26 -0
  10. package/src/.agentic-security/pqc-migration-plan.json +80 -0
  11. package/src/.agentic-security/pqc-migration-plan.md +32 -0
  12. package/src/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
  13. package/src/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
  14. package/src/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
  15. package/src/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
  16. package/src/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
  17. package/src/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
  18. package/src/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
  19. package/src/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
  20. package/src/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
  21. package/src/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
  22. package/src/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
  23. package/src/.agentic-security/threat-model.json +3702 -0
  24. package/src/.agentic-security/threat-model.md +118 -0
  25. package/src/dataflow/.agentic-security/dpia.md +26 -0
  26. package/src/dataflow/.agentic-security/sbom-history/15d7c4d20f9d968ebec29445c9e9b3fc6f74e6a1.json +6 -0
  27. package/src/dataflow/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
  28. package/src/dataflow/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
  29. package/src/dataflow/.agentic-security/sbom-history/8869fe64035bec0152d92ece961e9369d8637076.json +6 -0
  30. package/src/dataflow/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
  31. package/src/dataflow/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
  32. package/src/dataflow/.agentic-security/threat-model.json +161 -0
  33. package/src/dataflow/.agentic-security/threat-model.md +43 -0
  34. package/src/dataflow/CLAUDE.md +17 -5
  35. package/src/dataflow/catalog.js +9 -0
  36. package/src/dataflow/engine.js +30 -4
  37. package/src/dataflow/index.js +8 -0
  38. package/src/dataflow/proof-gate.js +95 -0
  39. package/src/dataflow/summaries.js +44 -5
  40. package/src/engine.js +199 -5
  41. package/src/ir/.agentic-security/dpia.md +26 -0
  42. package/src/ir/.agentic-security/exploit-bundles.json +77 -0
  43. package/src/ir/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
  44. package/src/ir/.agentic-security/sbom-history/b5f468a158f6a145aedd8680841c106b9809f16b.json +6 -0
  45. package/src/ir/.agentic-security/threat-model.json +143 -0
  46. package/src/ir/.agentic-security/threat-model.md +39 -0
  47. package/src/ir/CLAUDE.md +1 -0
  48. package/src/ir/callgraph.js +12 -1
  49. package/src/ir/tree-sitter-loader.js +108 -0
  50. package/src/posture/.agentic-security/dpia.md +1 -1
  51. package/src/posture/.agentic-security/pqc-migration-plan.json +1 -1
  52. package/src/posture/.agentic-security/pqc-migration-plan.md +1 -1
  53. package/src/posture/.agentic-security/sbom-history/0724c133d2943c87555695b5b2adcfcfc3373a2d.json +6 -0
  54. package/src/posture/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
  55. package/src/posture/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
  56. package/src/posture/.agentic-security/threat-model.json +3 -3
  57. package/src/posture/.agentic-security/threat-model.md +1 -1
  58. package/src/posture/corpus-status.js +74 -0
  59. package/src/posture/coverage-report.js +113 -0
  60. package/src/posture/holdout-eval.js +57 -0
  61. package/src/posture/router.js +33 -3
  62. package/src/report/.agentic-security/dpia.md +26 -0
  63. package/src/report/.agentic-security/sbom-history/f94be839aa6a1011f53da016416a561698b8f338.json +6 -0
  64. package/src/report/.agentic-security/threat-model.md +1 -1
  65. package/src/report/index.js +17 -0
  66. package/src/sast/.agentic-security/dpia.md +26 -0
  67. package/src/sast/.agentic-security/pqc-migration-plan.json +65 -0
  68. package/src/sast/.agentic-security/pqc-migration-plan.md +30 -0
  69. package/src/sast/.agentic-security/sbom-history/043a7b401c68f5a298c83d4c42e94f118ff2a89a.json +6 -0
  70. package/src/sast/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
  71. package/src/sast/.agentic-security/sbom-history/20d77ad7363a82777d506dedbbb1ecfed52f094a.json +6 -0
  72. package/src/sast/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
  73. package/src/sast/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
  74. package/src/sast/.agentic-security/sbom-history/3c6b3205f04e26f7654943d465281d00e0fa395e.json +6 -0
  75. package/src/sast/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
  76. package/src/sast/.agentic-security/sbom-history/5282502e090013219eb006f7e02d8c983bc29c8e.json +6 -0
  77. package/src/sast/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
  78. package/src/sast/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
  79. package/src/sast/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
  80. package/src/sast/.agentic-security/sbom-history/73514b485d4770f3c5ee57d87338bccd1c61d8b7.json +6 -0
  81. package/src/sast/.agentic-security/sbom-history/96fa584fc3134512237d58aac63d6f3778b0cbcb.json +6 -0
  82. package/src/sast/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
  83. package/src/sast/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
  84. package/src/sast/.agentic-security/sbom-history/a4a34ab8b3d758de2adc8c3dc3e42d4cb0db00a3.json +6 -0
  85. package/src/sast/.agentic-security/sbom-history/ac236ec3602cfc8fb133f389ec3687e409084673.json +6 -0
  86. package/src/sast/.agentic-security/sbom-history/b57b3e1292f1f9f0de6c5a34a436e330c78008a3.json +6 -0
  87. package/src/sast/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
  88. package/src/sast/.agentic-security/sbom-history/dcbfd4935922dd165093c08a1003c338eed937c5.json +6 -0
  89. package/src/sast/.agentic-security/sbom-history/fa017f725615b0293814344c8ef7dfeb06775ca0.json +6 -0
  90. package/src/sast/.agentic-security/threat-model.json +816 -0
  91. package/src/sast/.agentic-security/threat-model.md +91 -0
  92. package/src/sast/CLAUDE.md +9 -3
  93. package/src/sast/code-injection-multilang.js +117 -0
  94. package/src/sast/crypto-protocol.js +43 -1
  95. package/src/sast/csharp-structural.js +65 -0
  96. package/src/sast/csharp.js +3 -0
  97. package/src/sast/csrf.js +24 -3
  98. package/src/sast/csv-injection.js +84 -0
  99. package/src/sast/frontend-hygiene.js +106 -0
  100. package/src/sast/go-structural.js +60 -0
  101. package/src/sast/java-structural.js +81 -0
  102. package/src/sast/js-framework-structural.js +115 -0
  103. package/src/sast/kotlin.js +72 -0
  104. package/src/sast/ldap-injection.js +73 -17
  105. package/src/sast/open-redirect.js +47 -5
  106. package/src/sast/php.js +35 -0
  107. package/src/sast/python-structural.js +83 -0
  108. package/src/sast/redos-nfa.js +75 -0
  109. package/src/sast/response-splitting.js +48 -19
  110. package/src/sast/ruby.js +38 -0
  111. package/src/sast/secret-concat.js +60 -0
  112. package/src/sast/ssrf-cloud-metadata.js +13 -0
  113. package/src/sast/stored-taint.js +88 -0
  114. package/src/sast/tree-sitter-sinks.js +62 -0
  115. package/src/sast/weak-password-hash.js +28 -0
  116. package/src/sast/weak-randomness.js +46 -5
  117. package/src/sast/wrong-context-sanitizer.js +94 -0
  118. package/src/sast/xpath-injection.js +30 -6
  119. package/src/sast/xss-reflected-multilang.js +116 -0
  120. 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\b|RedirectView|HttpResponseRedirect|Location\s*:/i.test(code)) return [];
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
- if (!TAINT_HINT_RE.test(target)) continue;
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
+ }
@@ -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,