circle-ir 3.131.0 → 3.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -215,6 +215,14 @@ export class LanguageSourcesPass {
215
215
  for (const finding of findGoXssFindings(code, graph.ir.meta.file)) {
216
216
  ctx.addFinding(finding);
217
217
  }
218
+ // Sprint 82 (#189): Go open_redirect — Header().Set("Location", taint).
219
+ for (const finding of findGoLocationHeaderOpenRedirectFindings(code, graph.ir.meta.file)) {
220
+ ctx.addFinding(finding);
221
+ }
222
+ // Sprint 83 (#189): Go code_injection — plugin.Open / plugin.Lookup.
223
+ for (const finding of findGoPluginOpenCodeInjectionFindings(code, graph.ir.meta.file)) {
224
+ ctx.addFinding(finding);
225
+ }
218
226
  }
219
227
  // -- Python: safe-handler sanitizer detectors (cognium-dev #114 Sprint 31) --
220
228
  if (language === 'python') {
@@ -244,6 +252,15 @@ export class LanguageSourcesPass {
244
252
  for (const finding of findPythonJinjaMarkupXssFindings(code, graph.ir.meta.file)) {
245
253
  ctx.addFinding(finding);
246
254
  }
255
+ // Sprint 82 (#189): Python open_redirect — resp.headers["Location"] = taint.
256
+ for (const finding of findPythonHeadersSubscriptOpenRedirectFindings(code, graph.ir.meta.file)) {
257
+ ctx.addFinding(finding);
258
+ }
259
+ // Sprint 83 (#189): Python code_injection — code.InteractiveInterpreter
260
+ // / InteractiveConsole / compile_command on Flask request input.
261
+ for (const finding of findPythonInteractiveInterpreterCodeInjectionFindings(code, graph.ir.meta.file)) {
262
+ ctx.addFinding(finding);
263
+ }
247
264
  }
248
265
  // -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
249
266
  if (language === 'rust') {
@@ -273,6 +290,15 @@ export class LanguageSourcesPass {
273
290
  for (const finding of findRustWeakCryptoEcbFindings(code, graph.ir.meta.file)) {
274
291
  ctx.addFinding(finding);
275
292
  }
293
+ // Sprint 82 (#189): Rust open_redirect — append_header(("Location", taint)).
294
+ for (const finding of findRustAppendHeaderTupleOpenRedirectFindings(code, graph.ir.meta.file)) {
295
+ ctx.addFinding(finding);
296
+ }
297
+ // Sprint 83 (#189): Rust code_injection — evalexpr crate, libloading,
298
+ // mlua/rlua dynamic-load shapes on Actix/Axum request bodies.
299
+ for (const finding of findRustEvalCrateCodeInjectionFindings(code, graph.ir.meta.file)) {
300
+ ctx.addFinding(finding);
301
+ }
276
302
  }
277
303
  // -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
278
304
  // sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
@@ -300,6 +326,17 @@ export class LanguageSourcesPass {
300
326
  for (const finding of findTsAngularBypassXssFindings(code, graph.ir.meta.file)) {
301
327
  ctx.addFinding(finding);
302
328
  }
329
+ // Sprint 82 (#189): JS/HTML DOM open_redirect — location.href /
330
+ // window.location / location.assign|replace() / <meta>.content shapes
331
+ // sourced from URLSearchParams / location.search|hash.
332
+ for (const finding of findJsDomOpenRedirectFindings(code, graph.ir.meta.file)) {
333
+ ctx.addFinding(finding);
334
+ }
335
+ // Sprint 83 (#189): JS code_injection — indirect eval forms
336
+ // ((0, eval)(x), globalThis.eval(x), aliased eval).
337
+ for (const finding of findJsIndirectEvalCodeInjectionFindings(code, graph.ir.meta.file)) {
338
+ ctx.addFinding(finding);
339
+ }
303
340
  }
304
341
  // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
305
342
  // fromJson recognized as ETE terminator (does not affect
@@ -4727,4 +4764,941 @@ export function findPythonJinjaMarkupXssFindings(code, file) {
4727
4764
  }
4728
4765
  return out;
4729
4766
  }
4767
+ /**
4768
+ * Go open_redirect — `<rw>.Header().Set("Location", <expr>)` where
4769
+ * `<rw>` is bound to `http.ResponseWriter`. The configured sink rows
4770
+ * for `Header.Set` are typed `crlf` (CWE-113); the same line is also a
4771
+ * CWE-601 open_redirect when the header key is exactly `"Location"`
4772
+ * (case-insensitive). This pattern detector emits the open_redirect
4773
+ * finding directly, complementing the engine's crlf classification.
4774
+ *
4775
+ * Sprint 82 (#189): closes go__v02_location_header.
4776
+ *
4777
+ * Conservative: only fires when the receiver matches a parameter typed
4778
+ * `http.ResponseWriter` AND the literal key value is `Location` AND no
4779
+ * recognized URL-allowlist sanitizer wraps the value argument.
4780
+ */
4781
+ export function findGoLocationHeaderOpenRedirectFindings(code, file) {
4782
+ const out = [];
4783
+ const lines = code.split('\n');
4784
+ const rwNames = new Set();
4785
+ const sigRe = /\(\s*([A-Za-z_]\w*)\s+http\.ResponseWriter\b/g;
4786
+ for (const line of lines) {
4787
+ let m;
4788
+ sigRe.lastIndex = 0;
4789
+ while ((m = sigRe.exec(line)) !== null)
4790
+ rwNames.add(m[1]);
4791
+ }
4792
+ if (rwNames.size === 0)
4793
+ return out;
4794
+ // Recognized URL-allowlist / strict-equality sanitizers. Conservative
4795
+ // — keeps the FP set tight (Sprint 74 lesson). `url.Parse` alone is
4796
+ // not a sanitizer; we require an `IsAbs`/`Host`/`==` check.
4797
+ const safeWrapRe = /\b(?:net\/url|url)\.Parse\b[\s\S]{0,120}?\b(?:IsAbs|Host)\b|\bstrings\.HasPrefix\s*\(/;
4798
+ // Match: `<rw>.Header().Set("Location", <expr>)` — also accept
4799
+ // `Add` (header-stacking variant) which has the same redirect effect.
4800
+ const callRe = /\b([A-Za-z_]\w*)\s*\.\s*Header\s*\(\s*\)\s*\.\s*(?:Set|Add)\s*\(\s*(['"])([^'"]+)\2\s*,\s*([\s\S]*)\)\s*(?:\/\/.*)?$/;
4801
+ for (let i = 0; i < lines.length; i++) {
4802
+ const raw = lines[i];
4803
+ const trimmed = raw.trim();
4804
+ if (!trimmed || trimmed.startsWith('//'))
4805
+ continue;
4806
+ const m = trimmed.match(callRe);
4807
+ if (!m)
4808
+ continue;
4809
+ const recv = m[1];
4810
+ if (!rwNames.has(recv))
4811
+ continue;
4812
+ const key = m[3];
4813
+ if (key.toLowerCase() !== 'location')
4814
+ continue;
4815
+ const valExpr = m[4];
4816
+ // Skip when the value is a pure string literal (no taint shape).
4817
+ const valTrim = valExpr.trim();
4818
+ if (/^"(?:\\.|[^"\\])*"$/.test(valTrim))
4819
+ continue;
4820
+ if (safeWrapRe.test(valExpr))
4821
+ continue;
4822
+ out.push({
4823
+ id: `open_redirect-${file}-${i + 1}`,
4824
+ pass: 'language-sources',
4825
+ category: 'security',
4826
+ rule_id: 'open_redirect',
4827
+ cwe: 'CWE-601',
4828
+ severity: 'high',
4829
+ level: 'error',
4830
+ message: 'Open redirect: ResponseWriter.Header().Set("Location", ...) ' +
4831
+ 'writes a user-controlled value into the Location header without ' +
4832
+ 'validating the target. Restrict to an allow-list of hosts/paths ' +
4833
+ 'or compare against a known-safe set before issuing the redirect.',
4834
+ file,
4835
+ line: i + 1,
4836
+ snippet: trimmed.substring(0, 100),
4837
+ });
4838
+ }
4839
+ return out;
4840
+ }
4841
+ /**
4842
+ * Python Flask open_redirect — `<resp>.headers["Location"] = <expr>`
4843
+ * subscript-assignment shape. The engine's configured sinks for
4844
+ * Flask `Response.headers` model the dict-add form but not subscript
4845
+ * assignment, so the open_redirect signal is missed even though the
4846
+ * source (`request.args.get`) is detected.
4847
+ *
4848
+ * Sprint 82 (#189): closes py__v02_location_header.
4849
+ *
4850
+ * Conservative: requires the rhs to be a recognized request-source
4851
+ * variable OR an inline `request.<x>.get/[]` expression. Skips when
4852
+ * the value is wrapped in a recognized URL-allowlist call.
4853
+ */
4854
+ export function findPythonHeadersSubscriptOpenRedirectFindings(code, file) {
4855
+ const out = [];
4856
+ const lines = code.split('\n');
4857
+ const requestSourceRe = /\b([A-Za-z_]\w*)\s*=\s*request\s*\.\s*(?:args|form|values|files|json|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/;
4858
+ const taintedVars = new Set();
4859
+ for (const line of lines) {
4860
+ const m = line.match(requestSourceRe);
4861
+ if (m)
4862
+ taintedVars.add(m[1]);
4863
+ }
4864
+ // Recognized URL-allowlist sanitizers (tight, Sprint 74 lesson).
4865
+ const safeWrapRe = /\b(?:urllib\.parse\.urlparse|urlparse)\s*\([\s\S]{0,120}?\bnetloc\b|\b(?:startswith)\s*\(/;
4866
+ const subscriptRe = /\b([A-Za-z_]\w*)\s*\.\s*headers\s*\[\s*(['"])([^'"]+)\2\s*\]\s*=\s*(.+)$/;
4867
+ for (let i = 0; i < lines.length; i++) {
4868
+ const raw = lines[i];
4869
+ const trimmed = raw.trim();
4870
+ if (!trimmed || trimmed.startsWith('#'))
4871
+ continue;
4872
+ const m = trimmed.match(subscriptRe);
4873
+ if (!m)
4874
+ continue;
4875
+ const key = m[3];
4876
+ if (key.toLowerCase() !== 'location')
4877
+ continue;
4878
+ const rhs = m[4].replace(/\s*(?:#.*)?$/, '').trim();
4879
+ // Skip pure string literal.
4880
+ if (/^['"](?:\\.|[^'"\\])*['"]$/.test(rhs))
4881
+ continue;
4882
+ // Skip when wrapped in a recognized allowlist check.
4883
+ if (safeWrapRe.test(rhs))
4884
+ continue;
4885
+ const hasTaintedVar = [...taintedVars].some(v => new RegExp(`\\b${v}\\b`).test(rhs));
4886
+ const inlineSource = /\brequest\s*\.\s*(?:args|form|values|files|cookies|headers|json)(?:\s*\.\s*get\s*\(|\s*\[)/.test(rhs);
4887
+ if (!hasTaintedVar && !inlineSource)
4888
+ continue;
4889
+ out.push({
4890
+ id: `open_redirect-${file}-${i + 1}`,
4891
+ pass: 'language-sources',
4892
+ category: 'security',
4893
+ rule_id: 'open_redirect',
4894
+ cwe: 'CWE-601',
4895
+ severity: 'high',
4896
+ level: 'error',
4897
+ message: 'Open redirect: response.headers["Location"] = ... assigns a ' +
4898
+ 'user-controlled value to the Location header. Validate the URL ' +
4899
+ 'against an allow-list of trusted hosts before sending.',
4900
+ file,
4901
+ line: i + 1,
4902
+ snippet: trimmed.substring(0, 100),
4903
+ });
4904
+ }
4905
+ return out;
4906
+ }
4907
+ /**
4908
+ * Rust open_redirect — Actix/Rocket builder pattern
4909
+ * `<builder>.append_header(("Location", <expr>))` or
4910
+ * `.insert_header(("Location", <expr>))` where the value traces back
4911
+ * to a `web::Query` / `web::Path` / `Form` extractor. The tuple-arg
4912
+ * form is the idiomatic Actix HeaderName→HeaderValue API; the
4913
+ * engine's configured sinks model single-arg `set_header(value)` but
4914
+ * not the tuple builder shape.
4915
+ *
4916
+ * Sprint 82 (#189): closes rust__v01_redirect_param.
4917
+ *
4918
+ * Conservative: requires the function to accept a recognized
4919
+ * `web::Query` / `web::Path` / `web::Form` / `HttpRequest` parameter
4920
+ * and the value expression to reference it (directly, or transitively
4921
+ * via a `.get(...)` / `.cloned()` / `.unwrap_or_default()` chain).
4922
+ */
4923
+ export function findRustAppendHeaderTupleOpenRedirectFindings(code, file) {
4924
+ const out = [];
4925
+ const lines = code.split('\n');
4926
+ // Discover request-extractor parameter names.
4927
+ const extractorRe = /\b([A-Za-z_]\w*)\s*:\s*(?:web\s*::\s*)?(?:Query|Path|Form|Json)\s*<|\b([A-Za-z_]\w*)\s*:\s*&?\s*HttpRequest\b/g;
4928
+ const extractorParams = new Set();
4929
+ for (const line of lines) {
4930
+ let m;
4931
+ extractorRe.lastIndex = 0;
4932
+ while ((m = extractorRe.exec(line)) !== null) {
4933
+ const name = m[1] ?? m[2];
4934
+ if (name)
4935
+ extractorParams.add(name);
4936
+ }
4937
+ }
4938
+ if (extractorParams.size === 0)
4939
+ return out;
4940
+ // Build transitive tainted-let set: `let v = <expr>` where <expr>
4941
+ // references an extractor param or a previously tainted var.
4942
+ const taintedVars = new Set(extractorParams);
4943
+ const letRe = /\blet\s+(?:mut\s+)?([A-Za-z_]\w*)\s*(?::[^=]+)?=\s*([^;]+);/g;
4944
+ for (let pass = 0; pass < 4; pass++) {
4945
+ const before = taintedVars.size;
4946
+ let lm;
4947
+ letRe.lastIndex = 0;
4948
+ while ((lm = letRe.exec(code)) !== null) {
4949
+ const name = lm[1];
4950
+ const rhs = lm[2];
4951
+ if (taintedVars.has(name))
4952
+ continue;
4953
+ for (const tv of taintedVars) {
4954
+ if (new RegExp(`\\b${tv}\\b`).test(rhs)) {
4955
+ taintedVars.add(name);
4956
+ break;
4957
+ }
4958
+ }
4959
+ }
4960
+ if (taintedVars.size === before)
4961
+ break;
4962
+ }
4963
+ const safeWrapRe = /\b(?:Url\s*::\s*parse|url\s*::\s*Url\s*::\s*parse)\s*\([\s\S]{0,160}?\b(?:host_str|host|origin)\b/;
4964
+ const tupleCallRe = /\.\s*(?:append_header|insert_header)\s*\(\s*\(\s*(['"])([^'"]+)\1\s*,\s*([^)]*?)\s*\)\s*\)/g;
4965
+ for (let i = 0; i < lines.length; i++) {
4966
+ const raw = lines[i];
4967
+ const trimmed = raw.trim();
4968
+ if (!trimmed || trimmed.startsWith('//'))
4969
+ continue;
4970
+ let cm;
4971
+ tupleCallRe.lastIndex = 0;
4972
+ while ((cm = tupleCallRe.exec(raw)) !== null) {
4973
+ const key = cm[2];
4974
+ if (key.toLowerCase() !== 'location')
4975
+ continue;
4976
+ const valExpr = cm[3];
4977
+ if (/^['"](?:\\.|[^'"\\])*['"]$/.test(valExpr.trim()))
4978
+ continue;
4979
+ if (safeWrapRe.test(valExpr))
4980
+ continue;
4981
+ const hasTainted = [...taintedVars].some(v => new RegExp(`\\b${v}\\b`).test(valExpr));
4982
+ if (!hasTainted)
4983
+ continue;
4984
+ out.push({
4985
+ id: `open_redirect-${file}-${i + 1}`,
4986
+ pass: 'language-sources',
4987
+ category: 'security',
4988
+ rule_id: 'open_redirect',
4989
+ cwe: 'CWE-601',
4990
+ severity: 'high',
4991
+ level: 'error',
4992
+ message: 'Open redirect: HttpResponse builder ' +
4993
+ '.append_header(("Location", ...)) sets the Location header from ' +
4994
+ 'a request-derived value without allow-list validation. Parse ' +
4995
+ 'with url::Url::parse and compare host_str against an allow-list.',
4996
+ file,
4997
+ line: i + 1,
4998
+ snippet: trimmed.substring(0, 100),
4999
+ });
5000
+ break;
5001
+ }
5002
+ }
5003
+ return out;
5004
+ }
5005
+ /**
5006
+ * JS/HTML DOM open_redirect — assignment / call patterns that drive
5007
+ * `location.href`, `window.location`, `document.location`,
5008
+ * `location.assign(...)`, `location.replace(...)`, or `<elem>.content
5009
+ * = '...;url=' + ...` (meta-refresh DOM shape) where the right-hand
5010
+ * side traces back to a DOM source (`location.search`,
5011
+ * `location.hash`, `URLSearchParams.get(...)`, `document.referrer`,
5012
+ * `window.name`).
5013
+ *
5014
+ * Sprint 82 (#189): closes html__v01_redirect_param,
5015
+ * html__v03_meta_refresh, htmljs__v03_meta_refresh.
5016
+ *
5017
+ * Conservative: only fires when the rhs expression contains (or
5018
+ * references a var that contains) a recognized DOM source token;
5019
+ * never fires on literal-only assignments.
5020
+ */
5021
+ export function findJsDomOpenRedirectFindings(code, file) {
5022
+ const out = [];
5023
+ const lines = code.split('\n');
5024
+ // DOM source pattern — direct references to user-controlled URL
5025
+ // pieces. `document.referrer` / `window.name` round out the common
5026
+ // taint sources beyond URLSearchParams.
5027
+ const domSourceRe = /\blocation\s*\.\s*(?:search|hash|href|pathname)\b|\bwindow\s*\.\s*location\s*\.\s*(?:search|hash|href|pathname)\b|\bURLSearchParams\b|\bdocument\s*\.\s*(?:referrer|URL|location\s*\.\s*(?:search|hash|href|pathname))\b|\bwindow\s*\.\s*name\b/;
5028
+ // Transitive tainted variable set: `const x = <DOM source ...>`,
5029
+ // then `const y = x.get(...)`, etc.
5030
+ const taintedVars = new Set();
5031
+ const assignRe = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g;
5032
+ let am;
5033
+ assignRe.lastIndex = 0;
5034
+ while ((am = assignRe.exec(code)) !== null) {
5035
+ if (domSourceRe.test(am[2]))
5036
+ taintedVars.add(am[1]);
5037
+ }
5038
+ for (let pass = 0; pass < 4; pass++) {
5039
+ const before = taintedVars.size;
5040
+ assignRe.lastIndex = 0;
5041
+ while ((am = assignRe.exec(code)) !== null) {
5042
+ if (taintedVars.has(am[1]))
5043
+ continue;
5044
+ for (const tv of taintedVars) {
5045
+ if (new RegExp(`\\b${tv}\\b`).test(am[2])) {
5046
+ taintedVars.add(am[1]);
5047
+ break;
5048
+ }
5049
+ }
5050
+ }
5051
+ if (taintedVars.size === before)
5052
+ break;
5053
+ }
5054
+ // Recognized sanitizers (tight): explicit allow-list check against a
5055
+ // known set / startsWith on a same-origin literal prefix.
5056
+ const safeWrapRe = /\b(?:startsWith|includes)\s*\(\s*['"`]\/[^'"`]*['"`]\s*\)|\bnew\s+URL\s*\([\s\S]{0,80}?\)\s*\.\s*(?:origin|hostname)\s*===/;
5057
+ const containsTaint = (expr) => {
5058
+ if (domSourceRe.test(expr))
5059
+ return true;
5060
+ for (const tv of taintedVars) {
5061
+ if (new RegExp(`\\b${tv}\\b`).test(expr))
5062
+ return true;
5063
+ }
5064
+ return false;
5065
+ };
5066
+ // Sink shape (a1): `[<X>.]location.href = <expr>`.
5067
+ const hrefSinkRe = /\b(?:(?:window|document|self|top|parent)\s*\.\s*)?location\s*\.\s*href\s*=\s*([^;\n]+?)\s*;?\s*(?:\/\/.*)?$/;
5068
+ // Sink shape (a2): `window.location = <expr>` etc.
5069
+ const locSinkRe = /\b(?:window|document|self|top|parent)\s*\.\s*location\s*=\s*([^;\n]+?)\s*;?\s*(?:\/\/.*)?$/;
5070
+ // Sink shape (a3): `<elem>.content = <expr>` — only counts when the
5071
+ // rhs looks like a meta-refresh string (`'...url=' + ...`). Allow `;`
5072
+ // inside string literals on the rhs (meta-refresh format is
5073
+ // `'<delay>;url=<...>'`), so match up to newline then trim trailing
5074
+ // semicolon/comment manually.
5075
+ const contentSinkRe = /\.\s*content\s*=\s*([^\n]+?)\s*$/;
5076
+ // Sink shape (b): `[<X>.]location.assign(<expr>)` /
5077
+ // `[<X>.]location.replace(<expr>)`.
5078
+ const callSinkRe = /\b(?:(?:window|document|self|top|parent)\s*\.\s*)?location\s*\.\s*(?:assign|replace)\s*\(\s*([^)]+)\)/;
5079
+ const emit = (line, msg, snippet) => {
5080
+ out.push({
5081
+ id: `open_redirect-${file}-${line}`,
5082
+ pass: 'language-sources',
5083
+ category: 'security',
5084
+ rule_id: 'open_redirect',
5085
+ cwe: 'CWE-601',
5086
+ severity: 'high',
5087
+ level: 'error',
5088
+ message: msg,
5089
+ file,
5090
+ line,
5091
+ snippet: snippet.substring(0, 100),
5092
+ });
5093
+ };
5094
+ const isLiteralOnly = (expr) => /^['"`](?:\\.|[^'"`\\])*['"`]$/.test(expr.trim());
5095
+ for (let i = 0; i < lines.length; i++) {
5096
+ const raw = lines[i];
5097
+ const trimmed = raw.trim();
5098
+ if (!trimmed || trimmed.startsWith('//'))
5099
+ continue;
5100
+ let matched = false;
5101
+ // (a1) location.href = ...
5102
+ const hm = trimmed.match(hrefSinkRe);
5103
+ if (hm) {
5104
+ const rhs = hm[1].trim();
5105
+ if (!isLiteralOnly(rhs) && containsTaint(rhs) && !safeWrapRe.test(rhs)) {
5106
+ emit(i + 1, 'Open redirect: assignment to location.href uses a value ' +
5107
+ 'derived from a URL query/hash without allow-list validation. ' +
5108
+ 'Compare the target origin to a known-safe set before navigating.', trimmed);
5109
+ matched = true;
5110
+ }
5111
+ }
5112
+ // (a2) window.location = ... (skip if already matched as href)
5113
+ if (!matched) {
5114
+ const lm = trimmed.match(locSinkRe);
5115
+ if (lm) {
5116
+ const rhs = lm[1].trim();
5117
+ if (!isLiteralOnly(rhs) && containsTaint(rhs) && !safeWrapRe.test(rhs)) {
5118
+ emit(i + 1, 'Open redirect: assignment to window.location uses a value ' +
5119
+ 'derived from a URL query/hash without allow-list validation. ' +
5120
+ 'Compare the target origin to a known-safe set before navigating.', trimmed);
5121
+ matched = true;
5122
+ }
5123
+ }
5124
+ }
5125
+ // (a3) <elem>.content = '...url=' + ... (meta-refresh DOM shape)
5126
+ if (!matched) {
5127
+ const cm = trimmed.match(contentSinkRe);
5128
+ if (cm) {
5129
+ // Strip trailing `;` and comment from captured rhs.
5130
+ const rhs = cm[1].replace(/\s*\/\/.*$/, '').replace(/\s*;\s*$/, '').trim();
5131
+ if (!isLiteralOnly(rhs) &&
5132
+ /['"`][^'"`]*\burl\s*=/i.test(rhs) &&
5133
+ containsTaint(rhs) &&
5134
+ !safeWrapRe.test(rhs)) {
5135
+ emit(i + 1, 'Open redirect: DOM assignment to <meta>.content with a ' +
5136
+ 'meta-refresh URL built from a URL query/hash without ' +
5137
+ 'allow-list validation. Validate origin before navigating.', trimmed);
5138
+ matched = true;
5139
+ }
5140
+ }
5141
+ }
5142
+ // (b) location.assign(...) / location.replace(...)
5143
+ if (!matched) {
5144
+ const callm = trimmed.match(callSinkRe);
5145
+ if (callm) {
5146
+ const arg = callm[1].trim();
5147
+ if (!isLiteralOnly(arg) && containsTaint(arg) && !safeWrapRe.test(arg)) {
5148
+ emit(i + 1, 'Open redirect: location.assign / location.replace invoked ' +
5149
+ 'with a value derived from a URL query/hash without allow-' +
5150
+ 'list validation. Validate origin before navigating.', trimmed);
5151
+ }
5152
+ }
5153
+ }
5154
+ }
5155
+ return out;
5156
+ }
5157
+ // ---------------------------------------------------------------------------
5158
+ // Sprint 83 (issue #189 — code_injection cluster, 8 FN cells)
5159
+ // ---------------------------------------------------------------------------
5160
+ // Per-language pattern detectors that close 4 remaining code_injection FN
5161
+ // shapes that the configured-sink path does not cover:
5162
+ // - Go plugin.Open / plugin.Lookup with *http.Request-derived path
5163
+ // - JS indirect eval forms: (0, eval)(x), globalThis.eval(x), aliased eval
5164
+ // - Python code.InteractiveInterpreter / InteractiveConsole / compile_command
5165
+ // - Rust evalexpr crate, libloading dynamic load, mlua/rlua .load().exec
5166
+ // ---------------------------------------------------------------------------
5167
+ /**
5168
+ * Sprint 83 detector A — Go `plugin.Open(<tainted>)` / `plugin.Lookup(...)`.
5169
+ * Loading a Go plugin executes the loaded module's init() functions and
5170
+ * makes its exported symbols callable, which is a code-injection sink
5171
+ * equivalent to dynamic library loading. Fires when the path argument
5172
+ * traces back to an `*http.Request` extractor (FormValue / URL.Query /
5173
+ * PostFormValue / Header.Get / Cookie) within the same function.
5174
+ */
5175
+ export function findGoPluginOpenCodeInjectionFindings(code, file) {
5176
+ const findings = [];
5177
+ if (typeof code !== 'string' || code.length === 0)
5178
+ return findings;
5179
+ if (!/\bplugin\s*\.\s*(?:Open|Lookup)\s*\(/.test(code))
5180
+ return findings;
5181
+ const lines = code.split('\n');
5182
+ const reqExtractRe = /\b\w+\s*\.\s*(?:FormValue|PostFormValue|URL\.Query\(\)\.Get|Header\.Get|Cookie)\s*\(/;
5183
+ const httpReqParamRe = /\*\s*http\.Request\b/;
5184
+ const callRe = /\bplugin\s*\.\s*(?:Open|Lookup)\s*\(\s*([^)]*)\s*\)/;
5185
+ const sinkLabel = (op) => op === 'Lookup'
5186
+ ? 'Go plugin.Lookup'
5187
+ : 'Go plugin.Open';
5188
+ const funcs = [];
5189
+ let cur = null;
5190
+ for (let i = 0; i < lines.length; i++) {
5191
+ const t = lines[i].trim();
5192
+ if (/^func\b/.test(t)) {
5193
+ if (cur) {
5194
+ cur.end = i - 1;
5195
+ funcs.push(cur);
5196
+ }
5197
+ cur = { start: i, end: lines.length - 1 };
5198
+ }
5199
+ }
5200
+ if (cur)
5201
+ funcs.push(cur);
5202
+ for (const fn of funcs) {
5203
+ const header = lines[fn.start];
5204
+ if (!httpReqParamRe.test(header))
5205
+ continue;
5206
+ const taintedVars = new Set();
5207
+ // Up to 3 passes: discover transitively-tainted vars from request extractors.
5208
+ for (let pass = 0; pass < 3; pass++) {
5209
+ const before = taintedVars.size;
5210
+ for (let i = fn.start; i <= fn.end; i++) {
5211
+ const line = lines[i];
5212
+ const trimmed = line.trim();
5213
+ const assignMatch = trimmed.match(/^(\w+)\s*(?::=|=)\s*(.+?)(?:\s*\/\/.*)?$/);
5214
+ if (!assignMatch)
5215
+ continue;
5216
+ const lhs = assignMatch[1];
5217
+ const rhs = assignMatch[2];
5218
+ if (taintedVars.has(lhs))
5219
+ continue;
5220
+ if (reqExtractRe.test(rhs)) {
5221
+ taintedVars.add(lhs);
5222
+ continue;
5223
+ }
5224
+ // Aliasing existing taint
5225
+ for (const v of taintedVars) {
5226
+ const re = new RegExp(`\\b${v}\\b`);
5227
+ if (re.test(rhs)) {
5228
+ taintedVars.add(lhs);
5229
+ break;
5230
+ }
5231
+ }
5232
+ }
5233
+ if (taintedVars.size === before)
5234
+ break;
5235
+ }
5236
+ if (taintedVars.size === 0)
5237
+ continue;
5238
+ for (let i = fn.start; i <= fn.end; i++) {
5239
+ const line = lines[i];
5240
+ const m = line.match(callRe);
5241
+ if (!m)
5242
+ continue;
5243
+ const arg = m[1].trim();
5244
+ if (arg.length === 0)
5245
+ continue;
5246
+ // skip clear literal-only constants
5247
+ if (/^"[^"]*"$/.test(arg))
5248
+ continue;
5249
+ let tainted = false;
5250
+ // direct extractor call as argument
5251
+ if (reqExtractRe.test(arg))
5252
+ tainted = true;
5253
+ else {
5254
+ for (const v of taintedVars) {
5255
+ const re = new RegExp(`\\b${v}\\b`);
5256
+ if (re.test(arg)) {
5257
+ tainted = true;
5258
+ break;
5259
+ }
5260
+ }
5261
+ }
5262
+ if (!tainted)
5263
+ continue;
5264
+ const op = /\bplugin\s*\.\s*Lookup\b/.test(line) ? 'Lookup' : 'Open';
5265
+ findings.push({
5266
+ id: `code_injection-${file}-${i + 1}-go-plugin-${op.toLowerCase()}`,
5267
+ pass: 'language-sources',
5268
+ category: 'security',
5269
+ rule_id: 'code_injection',
5270
+ cwe: 'CWE-94',
5271
+ severity: 'critical',
5272
+ level: 'error',
5273
+ message: `Code injection: ${sinkLabel(op)} called with a path/symbol derived ` +
5274
+ 'from an *http.Request without an allow-list. Loading a plugin ' +
5275
+ 'runs its init() and exposes arbitrary exported symbols. Restrict ' +
5276
+ 'the path to a trusted directory or use a fixed allow-list.',
5277
+ file,
5278
+ line: i + 1,
5279
+ snippet: line.trim(),
5280
+ });
5281
+ }
5282
+ }
5283
+ return findings;
5284
+ }
5285
+ /**
5286
+ * Sprint 83 detector B — JS indirect eval forms.
5287
+ * Configured `eval` sink matches direct `eval(x)` but misses:
5288
+ * - `(0, eval)(x)` comma-operator indirect call
5289
+ * - `globalThis.eval(x)` / `global.eval(x)` / `window.eval(x)` / `self.eval(x)`
5290
+ * - aliased eval: `const f = eval; f(x)` then `f(taint)`
5291
+ * Fires when the argument traces back to req.body / req.query / req.params /
5292
+ * req.headers / req.cookies (Express/Koa-style request extractor).
5293
+ */
5294
+ export function findJsIndirectEvalCodeInjectionFindings(code, file) {
5295
+ const findings = [];
5296
+ if (typeof code !== 'string' || code.length === 0)
5297
+ return findings;
5298
+ if (!/\beval\b/.test(code))
5299
+ return findings;
5300
+ const lines = code.split('\n');
5301
+ // request extractor (assignment rhs)
5302
+ const reqExtractRe = /\breq(?:uest)?\s*\.\s*(?:body|query|params|headers|cookies)\b/;
5303
+ // First: discover indirect-eval aliases: `const f = eval;`, `let f = eval`
5304
+ const aliasRe = /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:globalThis\s*\.\s*eval|global\s*\.\s*eval|window\s*\.\s*eval|self\s*\.\s*eval|eval)\s*;?\s*$/;
5305
+ const aliases = new Set();
5306
+ for (const line of lines) {
5307
+ const m = line.match(aliasRe);
5308
+ if (m)
5309
+ aliases.add(m[1]);
5310
+ }
5311
+ // Discover transitively-tainted vars
5312
+ const taintedVars = new Set();
5313
+ const assignRe = /^\s*(?:const|let|var)\s+(\w+)\s*=\s*(.+?);?\s*$/;
5314
+ const reassignRe = /^\s*(\w+)\s*=\s*(.+?);?\s*$/;
5315
+ for (let pass = 0; pass < 3; pass++) {
5316
+ const before = taintedVars.size;
5317
+ for (const line of lines) {
5318
+ const m = line.match(assignRe) || line.match(reassignRe);
5319
+ if (!m)
5320
+ continue;
5321
+ const lhs = m[1];
5322
+ const rhs = m[2];
5323
+ if (taintedVars.has(lhs))
5324
+ continue;
5325
+ if (lhs === 'const' || lhs === 'let' || lhs === 'var')
5326
+ continue;
5327
+ if (reqExtractRe.test(rhs)) {
5328
+ taintedVars.add(lhs);
5329
+ continue;
5330
+ }
5331
+ for (const v of taintedVars) {
5332
+ const re = new RegExp(`\\b${v}\\b`);
5333
+ if (re.test(rhs)) {
5334
+ taintedVars.add(lhs);
5335
+ break;
5336
+ }
5337
+ }
5338
+ }
5339
+ if (taintedVars.size === before)
5340
+ break;
5341
+ }
5342
+ // Patterns: (0, eval)(x); globalThis.eval(x); aliased f(x)
5343
+ const indirectCommaRe = /\(\s*0\s*,\s*eval\s*\)\s*\(\s*([^)]*)\s*\)/;
5344
+ const indirectMemberRe = /\b(?:globalThis|global|window|self)\s*\.\s*eval\s*\(\s*([^)]*)\s*\)/;
5345
+ for (let i = 0; i < lines.length; i++) {
5346
+ const line = lines[i];
5347
+ const trimmed = line.trim();
5348
+ // skip comment lines and alias-declaration lines themselves
5349
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
5350
+ continue;
5351
+ if (aliasRe.test(line))
5352
+ continue;
5353
+ let arg = null;
5354
+ let formLabel = '';
5355
+ let m = trimmed.match(indirectCommaRe);
5356
+ if (m) {
5357
+ arg = m[1].trim();
5358
+ formLabel = '(0, eval)(...) indirect eval';
5359
+ }
5360
+ if (!arg) {
5361
+ m = trimmed.match(indirectMemberRe);
5362
+ if (m) {
5363
+ arg = m[1].trim();
5364
+ formLabel = 'globalThis.eval / window.eval / self.eval indirect eval';
5365
+ }
5366
+ }
5367
+ if (!arg && aliases.size > 0) {
5368
+ for (const a of aliases) {
5369
+ const aliasCallRe = new RegExp(`\\b${a}\\s*\\(\\s*([^)]*)\\s*\\)`);
5370
+ const mm = trimmed.match(aliasCallRe);
5371
+ if (mm) {
5372
+ arg = mm[1].trim();
5373
+ formLabel = `aliased eval reference \`${a}(...)\``;
5374
+ break;
5375
+ }
5376
+ }
5377
+ }
5378
+ if (arg === null)
5379
+ continue;
5380
+ if (arg.length === 0)
5381
+ continue;
5382
+ // skip literal-only string args
5383
+ if (/^['"`][^'"`]*['"`]$/.test(arg))
5384
+ continue;
5385
+ let tainted = false;
5386
+ if (reqExtractRe.test(arg))
5387
+ tainted = true;
5388
+ else {
5389
+ for (const v of taintedVars) {
5390
+ const re = new RegExp(`\\b${v}\\b`);
5391
+ if (re.test(arg)) {
5392
+ tainted = true;
5393
+ break;
5394
+ }
5395
+ }
5396
+ }
5397
+ if (!tainted)
5398
+ continue;
5399
+ findings.push({
5400
+ id: `code_injection-${file}-${i + 1}-js-indirect-eval`,
5401
+ pass: 'language-sources',
5402
+ category: 'security',
5403
+ rule_id: 'code_injection',
5404
+ cwe: 'CWE-94',
5405
+ severity: 'critical',
5406
+ level: 'error',
5407
+ message: `Code injection: ${formLabel} called with a value derived from ` +
5408
+ 'an HTTP request body/query/headers. Indirect eval forms still ' +
5409
+ 'execute arbitrary code in the global scope. Remove the eval and ' +
5410
+ 'parse the input with a safe data parser instead.',
5411
+ file,
5412
+ line: i + 1,
5413
+ snippet: trimmed,
5414
+ });
5415
+ }
5416
+ return findings;
5417
+ }
5418
+ /**
5419
+ * Sprint 83 detector C — Python `code` stdlib REPL / compile_command.
5420
+ * `code.InteractiveInterpreter().runsource(s)`, `runcode(c)`, `push(line)` and
5421
+ * `code.InteractiveConsole().push(line)`, plus `code.compile_command(s)` —
5422
+ * all execute or compile arbitrary Python source. Fires when the argument
5423
+ * traces back to a Flask `request.*` extractor; gated on `import code`
5424
+ * to avoid colliding with user-defined `code` variables.
5425
+ */
5426
+ export function findPythonInteractiveInterpreterCodeInjectionFindings(code, file) {
5427
+ const findings = [];
5428
+ if (typeof code !== 'string' || code.length === 0)
5429
+ return findings;
5430
+ // Require `import code` namespace gate to avoid clashing with user-defined
5431
+ // identifiers named `code`.
5432
+ if (!/^\s*import\s+code\b/m.test(code))
5433
+ return findings;
5434
+ if (!/\bcode\s*\.\s*(?:InteractiveInterpreter|InteractiveConsole|compile_command)\b/.test(code)) {
5435
+ return findings;
5436
+ }
5437
+ const lines = code.split('\n');
5438
+ const reqExtractRe = /\brequest\s*\.\s*(?:args|form|values|files|json|cookies|headers|get_data|get_json)\b/;
5439
+ // Discover transitively-tainted vars
5440
+ const taintedVars = new Set();
5441
+ const assignRe = /^\s*(\w+)\s*=\s*(.+?)\s*(?:#.*)?$/;
5442
+ for (let pass = 0; pass < 3; pass++) {
5443
+ const before = taintedVars.size;
5444
+ for (const line of lines) {
5445
+ const m = line.match(assignRe);
5446
+ if (!m)
5447
+ continue;
5448
+ const lhs = m[1];
5449
+ const rhs = m[2];
5450
+ if (taintedVars.has(lhs))
5451
+ continue;
5452
+ if (reqExtractRe.test(rhs)) {
5453
+ taintedVars.add(lhs);
5454
+ continue;
5455
+ }
5456
+ for (const v of taintedVars) {
5457
+ const re = new RegExp(`\\b${v}\\b`);
5458
+ if (re.test(rhs)) {
5459
+ taintedVars.add(lhs);
5460
+ break;
5461
+ }
5462
+ }
5463
+ }
5464
+ if (taintedVars.size === before)
5465
+ break;
5466
+ }
5467
+ // Sink call patterns
5468
+ // a) code.InteractiveInterpreter(...).{runsource,runcode,push}(arg)
5469
+ // b) code.InteractiveConsole(...).{runsource,runcode,push,interact}(arg)
5470
+ // c) code.compile_command(arg, ...)
5471
+ const callRe = /\bcode\s*\.\s*(?:InteractiveInterpreter|InteractiveConsole)\s*\([^)]*\)\s*\.\s*(runsource|runcode|push|interact)\s*\(\s*([^),]+)/;
5472
+ const compileRe = /\bcode\s*\.\s*compile_command\s*\(\s*([^),]+)/;
5473
+ for (let i = 0; i < lines.length; i++) {
5474
+ const line = lines[i];
5475
+ const trimmed = line.trim();
5476
+ let arg = null;
5477
+ let formLabel = '';
5478
+ const m1 = trimmed.match(callRe);
5479
+ if (m1) {
5480
+ arg = m1[2].trim();
5481
+ formLabel = `code.${/Interpreter/.test(trimmed) ? 'InteractiveInterpreter' : 'InteractiveConsole'}().${m1[1]}`;
5482
+ }
5483
+ if (!arg) {
5484
+ const m2 = trimmed.match(compileRe);
5485
+ if (m2) {
5486
+ arg = m2[1].trim();
5487
+ formLabel = 'code.compile_command';
5488
+ }
5489
+ }
5490
+ if (arg === null)
5491
+ continue;
5492
+ if (arg.length === 0)
5493
+ continue;
5494
+ if (/^['"][^'"]*['"]$/.test(arg))
5495
+ continue;
5496
+ let tainted = false;
5497
+ if (reqExtractRe.test(arg))
5498
+ tainted = true;
5499
+ else {
5500
+ for (const v of taintedVars) {
5501
+ const re = new RegExp(`\\b${v}\\b`);
5502
+ if (re.test(arg)) {
5503
+ tainted = true;
5504
+ break;
5505
+ }
5506
+ }
5507
+ }
5508
+ if (!tainted)
5509
+ continue;
5510
+ findings.push({
5511
+ id: `code_injection-${file}-${i + 1}-py-interactive-interpreter`,
5512
+ pass: 'language-sources',
5513
+ category: 'security',
5514
+ rule_id: 'code_injection',
5515
+ cwe: 'CWE-94',
5516
+ severity: 'critical',
5517
+ level: 'error',
5518
+ message: `Code injection: ${formLabel}(...) called with a value derived from ` +
5519
+ 'a Flask request extractor. The Python `code` module compiles and ' +
5520
+ 'executes arbitrary source. Remove the call and validate input ' +
5521
+ 'against a fixed allow-list instead.',
5522
+ file,
5523
+ line: i + 1,
5524
+ snippet: trimmed,
5525
+ });
5526
+ }
5527
+ return findings;
5528
+ }
5529
+ /**
5530
+ * Sprint 83 detector D — Rust eval-crate / dynamic-load sinks.
5531
+ * Rust has no language-level eval; the canonical sinks are:
5532
+ * - `evalexpr::eval(...)` (and `_with_context|_boolean|_int|_float|_string|_tuple|_empty`)
5533
+ * - `libloading::Library::new(...)` (dynamic library load)
5534
+ * - `mlua::Lua::new().load(<src>).{exec,eval,call}(...)` / rlua equivalent
5535
+ * Fires when the argument traces back to an Actix-web extractor: a plain
5536
+ * `body: String|Bytes`, a `web::Query<T>` / `web::Path<T>` / `web::Form<T>` /
5537
+ * `web::Json<T>` / `HttpRequest` param, or `axum::body::Bytes`/`String` etc.
5538
+ */
5539
+ export function findRustEvalCrateCodeInjectionFindings(code, file) {
5540
+ const findings = [];
5541
+ if (typeof code !== 'string' || code.length === 0)
5542
+ return findings;
5543
+ if (!/\b(?:evalexpr\s*::\s*eval|libloading\s*::\s*Library\s*::\s*new|\.\s*load\s*\([^)]*\)\s*\.\s*(?:exec|eval|call))/.test(code)) {
5544
+ return findings;
5545
+ }
5546
+ const lines = code.split('\n');
5547
+ // Discover per-function tainted params: scan `fn ...(params)` headers and
5548
+ // mark params with extractor types as tainted.
5549
+ const extractorTypeRe = /:\s*(?:String|Bytes|bytes::Bytes|axum::body::Bytes|web::Query\b|web::Path\b|web::Form\b|web::Json\b|HttpRequest\b|actix_web::HttpRequest\b)/;
5550
+ const fns = [];
5551
+ let cur = null;
5552
+ for (let i = 0; i < lines.length; i++) {
5553
+ const t = lines[i];
5554
+ if (/^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+\s*\(/.test(t)) {
5555
+ if (cur) {
5556
+ cur.end = i - 1;
5557
+ fns.push(cur);
5558
+ }
5559
+ cur = { start: i, end: lines.length - 1, tainted: new Set() };
5560
+ // collect param idents whose type matches extractor
5561
+ const headerJoined = (() => {
5562
+ let j = i;
5563
+ let s = '';
5564
+ while (j < lines.length && !/\{\s*$/.test(s)) {
5565
+ s += lines[j];
5566
+ if (/\{\s*$/.test(lines[j]))
5567
+ break;
5568
+ j++;
5569
+ if (j - i > 12)
5570
+ break;
5571
+ }
5572
+ return s;
5573
+ })();
5574
+ // params are between first '(' and matching ')'
5575
+ const open = headerJoined.indexOf('(');
5576
+ const close = headerJoined.lastIndexOf(')');
5577
+ if (open !== -1 && close > open) {
5578
+ const params = headerJoined.substring(open + 1, close);
5579
+ // Split by top-level commas
5580
+ let depth = 0;
5581
+ let buf = '';
5582
+ const parts = [];
5583
+ for (const ch of params) {
5584
+ if (ch === '<' || ch === '(')
5585
+ depth++;
5586
+ else if (ch === '>' || ch === ')')
5587
+ depth--;
5588
+ if (ch === ',' && depth === 0) {
5589
+ parts.push(buf);
5590
+ buf = '';
5591
+ continue;
5592
+ }
5593
+ buf += ch;
5594
+ }
5595
+ if (buf.trim().length > 0)
5596
+ parts.push(buf);
5597
+ for (const p of parts) {
5598
+ const pm = p.match(/(?:mut\s+)?(\w+)\s*:/);
5599
+ if (!pm)
5600
+ continue;
5601
+ if (extractorTypeRe.test(p))
5602
+ cur.tainted.add(pm[1]);
5603
+ }
5604
+ }
5605
+ }
5606
+ }
5607
+ if (cur)
5608
+ fns.push(cur);
5609
+ // Inside each function, scan assignments (let / let mut) to propagate
5610
+ // taint from existing tainted vars into new bindings.
5611
+ for (const fn of fns) {
5612
+ for (let pass = 0; pass < 3; pass++) {
5613
+ const before = fn.tainted.size;
5614
+ for (let i = fn.start; i <= fn.end; i++) {
5615
+ const t = lines[i].trim();
5616
+ const m = t.match(/^let\s+(?:mut\s+)?(\w+)\s*(?::\s*[^=]+)?=\s*(.+?);?$/);
5617
+ if (!m)
5618
+ continue;
5619
+ const lhs = m[1];
5620
+ const rhs = m[2];
5621
+ if (fn.tainted.has(lhs))
5622
+ continue;
5623
+ for (const v of fn.tainted) {
5624
+ const re = new RegExp(`\\b${v}\\b`);
5625
+ if (re.test(rhs)) {
5626
+ fn.tainted.add(lhs);
5627
+ break;
5628
+ }
5629
+ }
5630
+ }
5631
+ if (fn.tainted.size === before)
5632
+ break;
5633
+ }
5634
+ }
5635
+ const evalExprRe = /\bevalexpr\s*::\s*eval(?:_with_context|_boolean|_int|_float|_string|_tuple|_empty)?\s*\(\s*([^)]+)\s*\)/;
5636
+ const libloadingRe = /\blibloading\s*::\s*Library\s*::\s*new\s*\(\s*([^)]+)\s*\)/;
5637
+ const luaLoadRe = /\.\s*load\s*\(\s*([^)]+)\s*\)\s*\.\s*(?:exec|eval|call)\b/;
5638
+ for (const fn of fns) {
5639
+ if (fn.tainted.size === 0)
5640
+ continue;
5641
+ for (let i = fn.start; i <= fn.end; i++) {
5642
+ const line = lines[i];
5643
+ const trimmed = line.trim();
5644
+ let arg = null;
5645
+ let formLabel = '';
5646
+ let m = trimmed.match(evalExprRe);
5647
+ if (m) {
5648
+ arg = m[1].trim();
5649
+ formLabel = 'evalexpr::eval';
5650
+ }
5651
+ if (!arg) {
5652
+ m = trimmed.match(libloadingRe);
5653
+ if (m) {
5654
+ arg = m[1].trim();
5655
+ formLabel = 'libloading::Library::new';
5656
+ }
5657
+ }
5658
+ if (!arg) {
5659
+ m = trimmed.match(luaLoadRe);
5660
+ if (m) {
5661
+ arg = m[1].trim();
5662
+ formLabel = 'mlua/rlua Lua::load().{exec|eval|call}';
5663
+ }
5664
+ }
5665
+ if (arg === null)
5666
+ continue;
5667
+ if (arg.length === 0)
5668
+ continue;
5669
+ // unwrap leading '&' borrow
5670
+ let unwrapped = arg.replace(/^&\s*/, '').trim();
5671
+ if (/^"[^"]*"$/.test(unwrapped))
5672
+ continue;
5673
+ let tainted = false;
5674
+ for (const v of fn.tainted) {
5675
+ const re = new RegExp(`\\b${v}\\b`);
5676
+ if (re.test(unwrapped)) {
5677
+ tainted = true;
5678
+ break;
5679
+ }
5680
+ }
5681
+ if (!tainted)
5682
+ continue;
5683
+ findings.push({
5684
+ id: `code_injection-${file}-${i + 1}-rust-eval-crate`,
5685
+ pass: 'language-sources',
5686
+ category: 'security',
5687
+ rule_id: 'code_injection',
5688
+ cwe: 'CWE-94',
5689
+ severity: 'critical',
5690
+ level: 'error',
5691
+ message: `Code injection: ${formLabel}(...) called with a value derived ` +
5692
+ 'from an HTTP request extractor (body / Query / Path / Form / ' +
5693
+ 'Json / HttpRequest). The expression / library / Lua chunk is ' +
5694
+ 'executed as code. Remove the dynamic-eval path or restrict ' +
5695
+ 'input to a fixed allow-list.',
5696
+ file,
5697
+ line: i + 1,
5698
+ snippet: trimmed,
5699
+ });
5700
+ }
5701
+ }
5702
+ return findings;
5703
+ }
4730
5704
  //# sourceMappingURL=language-sources-pass.js.map