circle-ir 3.129.0 → 3.132.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.
@@ -211,6 +211,14 @@ export class LanguageSourcesPass {
211
211
  for (const finding of goMisconfigFindings) {
212
212
  ctx.addFinding(finding);
213
213
  }
214
+ // Sprint 81 (#189): Go xss — fmt.Fprint(f|ln) to http.ResponseWriter.
215
+ for (const finding of findGoXssFindings(code, graph.ir.meta.file)) {
216
+ ctx.addFinding(finding);
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
+ }
214
222
  }
215
223
  // -- Python: safe-handler sanitizer detectors (cognium-dev #114 Sprint 31) --
216
224
  if (language === 'python') {
@@ -232,6 +240,18 @@ export class LanguageSourcesPass {
232
240
  for (const finding of pyMisconfigFindings) {
233
241
  ctx.addFinding(finding);
234
242
  }
243
+ // Sprint 81 (#189): Python xss — Flask string-concat / f-string returns
244
+ // and Jinja Markup wrap bypass.
245
+ for (const finding of findPythonFlaskStringConcatXssFindings(code, graph.ir.meta.file)) {
246
+ ctx.addFinding(finding);
247
+ }
248
+ for (const finding of findPythonJinjaMarkupXssFindings(code, graph.ir.meta.file)) {
249
+ ctx.addFinding(finding);
250
+ }
251
+ // Sprint 82 (#189): Python open_redirect — resp.headers["Location"] = taint.
252
+ for (const finding of findPythonHeadersSubscriptOpenRedirectFindings(code, graph.ir.meta.file)) {
253
+ ctx.addFinding(finding);
254
+ }
235
255
  }
236
256
  // -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
237
257
  if (language === 'rust') {
@@ -261,6 +281,10 @@ export class LanguageSourcesPass {
261
281
  for (const finding of findRustWeakCryptoEcbFindings(code, graph.ir.meta.file)) {
262
282
  ctx.addFinding(finding);
263
283
  }
284
+ // Sprint 82 (#189): Rust open_redirect — append_header(("Location", taint)).
285
+ for (const finding of findRustAppendHeaderTupleOpenRedirectFindings(code, graph.ir.meta.file)) {
286
+ ctx.addFinding(finding);
287
+ }
264
288
  }
265
289
  // -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
266
290
  // sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
@@ -272,10 +296,28 @@ export class LanguageSourcesPass {
272
296
  additionalSanitizers.push(...findJsWrapperFunctionSanitizers(code));
273
297
  // Sprint 75 (#216 Pattern D): JS SSRF allow-list guard (var-aware).
274
298
  additionalSanitizers.push(...findJsSsrfAllowlistGuardSanitizers(code));
299
+ // Sprint 77b (#216 Pattern X): JS/TS argv-form execFile/spawn and
300
+ // parameterized SQL placeholder sanitizers.
301
+ additionalSanitizers.push(...findJsArgvFormExecSanitizers(code));
302
+ additionalSanitizers.push(...findJsParameterizedSqlSanitizers(code));
275
303
  // Sprint 78 (#190): JS misconfig pattern findings — libxmljs noent:true.
276
304
  for (const finding of findJsPatternFindings(code, graph.ir.meta.file)) {
277
305
  ctx.addFinding(finding);
278
306
  }
307
+ // Sprint 81 (#189): JS/TS xss — Vue v-html directive tied to tainted
308
+ // template binding, and Angular DomSanitizer.bypassSecurityTrust*.
309
+ for (const finding of findJsVueVHtmlXssFindings(code, graph.ir.meta.file)) {
310
+ ctx.addFinding(finding);
311
+ }
312
+ for (const finding of findTsAngularBypassXssFindings(code, graph.ir.meta.file)) {
313
+ ctx.addFinding(finding);
314
+ }
315
+ // Sprint 82 (#189): JS/HTML DOM open_redirect — location.href /
316
+ // window.location / location.assign|replace() / <meta>.content shapes
317
+ // sourced from URLSearchParams / location.search|hash.
318
+ for (const finding of findJsDomOpenRedirectFindings(code, graph.ir.meta.file)) {
319
+ ctx.addFinding(finding);
320
+ }
279
321
  }
280
322
  // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
281
323
  // fromJson recognized as ETE terminator (does not affect
@@ -293,6 +335,12 @@ export class LanguageSourcesPass {
293
335
  for (const finding of findJavaPatternFindings(code, graph.ir.meta.file)) {
294
336
  ctx.addFinding(finding);
295
337
  }
338
+ // Sprint 81 (#189): Java xss — HttpServletResponse.getWriter().{print,
339
+ // println,write} receiver-chain that the configured PrintWriter sink
340
+ // doesn't resolve.
341
+ for (const finding of findJavaResponseWriterXssFindings(code, graph.ir.meta.file)) {
342
+ ctx.addFinding(finding);
343
+ }
296
344
  }
297
345
  // Sprint 70 (#151): cross-language env-secret → external-network exfiltration.
298
346
  // Pattern findings only — no taint flow required (composed-flow shape that
@@ -2969,6 +3017,121 @@ function findJsSsrfAllowlistGuardSanitizers(code) {
2969
3017
  }
2970
3018
  return sanitizers;
2971
3019
  }
3020
+ /**
3021
+ * JS/TS: argv-form `execFile`/`spawn`/`execFileSync`/`spawnSync` with
3022
+ * a string-literal program and an array literal argv (cognium-dev #216
3023
+ * Sprint 77b Pattern X).
3024
+ *
3025
+ * Pattern recognized:
3026
+ *
3027
+ * execFile('echo', ['--', arg], () => {});
3028
+ * spawn('grep', ['--', pattern, '/var/log/app.log']);
3029
+ * execFileSync('cat', [path]);
3030
+ *
3031
+ * Argv-form exec with a string-literal program splits arguments into
3032
+ * the argv slot with no shell interpretation, so a tainted argv element
3033
+ * cannot smuggle shell metacharacters into a separate command.
3034
+ *
3035
+ * The shell-via-argv form `execFile('sh', ['-c', tainted])` is excluded
3036
+ * since `-c` re-enables shell parsing of the subsequent argv slot. A
3037
+ * tainted-program slot `execFile(prog, [arg])` is NOT matched (TP-2
3038
+ * control), nor is the single-string `exec("cmd " + arg)` form (TP-1
3039
+ * control — `exec` itself spawns a shell regardless).
3040
+ *
3041
+ * Emits a `command_injection` + `external_taint_escape` sanitizer at
3042
+ * that line.
3043
+ */
3044
+ function findJsArgvFormExecSanitizers(code) {
3045
+ const sanitizers = [];
3046
+ const lines = code.split('\n');
3047
+ // execFile/spawn (+Sync) with a string-literal program and array argv.
3048
+ // Quoted program slot, then comma, then `[`.
3049
+ const argvExecRe = /\b(?:execFile|spawn)(?:Sync)?\s*\(\s*(?:'[^']*'|"[^"]*"|`[^`]*`)\s*,\s*\[/;
3050
+ // Shell-via-argv exclusion: program is sh/bash/etc. (possibly with a
3051
+ // leading path like /bin/ or /usr/bin/) AND first argv is "-c".
3052
+ const shellArgvRe = /\b(?:execFile|spawn)(?:Sync)?\s*\(\s*['"`](?:[\w./-]*\/)?(?:sh|bash|zsh|ksh|dash|cmd(?:\.exe)?|powershell|pwsh)['"`]\s*,\s*\[\s*['"`]-c['"`]/;
3053
+ for (let i = 0; i < lines.length; i++) {
3054
+ const text = lines[i];
3055
+ if (!argvExecRe.test(text))
3056
+ continue;
3057
+ if (shellArgvRe.test(text))
3058
+ continue;
3059
+ sanitizers.push({
3060
+ type: 'js_argv_form_exec',
3061
+ method: 'execFile',
3062
+ line: i + 1,
3063
+ sanitizes: ['command_injection', 'external_taint_escape'],
3064
+ });
3065
+ }
3066
+ return sanitizers;
3067
+ }
3068
+ /**
3069
+ * JS/TS: parameterized SQL query sanitizer (cognium-dev #216 Sprint 77b
3070
+ * Pattern X).
3071
+ *
3072
+ * Pattern recognized:
3073
+ *
3074
+ * await pool.query('SELECT * FROM users WHERE name = $1', [name]);
3075
+ * await conn.execute('SELECT * FROM users WHERE id = ?', [id]);
3076
+ * await client.query(`UPDATE u SET name = $1 WHERE id = $2`, [name, id]);
3077
+ *
3078
+ * The query string is a STRING-LITERAL (`'...'` / `"..."` / `` `...` ``
3079
+ * with no `${}` interpolation) that contains positional placeholders
3080
+ * (`$1`-style PostgreSQL or `?` MySQL/SQLite), AND a second argument
3081
+ * that begins with `[` (array literal of bound parameters). The driver
3082
+ * binds those parameters at the protocol layer rather than splicing
3083
+ * them into the SQL text, so the tainted values cannot become SQL.
3084
+ *
3085
+ * The concat form `pool.query("SELECT * FROM u WHERE n = '" + name + "'")`
3086
+ * and the interpolated template literal
3087
+ * `` pool.query(`SELECT * FROM u WHERE n = '${name}'`) `` are NOT
3088
+ * matched and continue to fire `sql_injection` (TP-1 / TP-2 controls).
3089
+ *
3090
+ * Emits a `sql_injection` + `external_taint_escape` sanitizer at that
3091
+ * line.
3092
+ */
3093
+ function findJsParameterizedSqlSanitizers(code) {
3094
+ const sanitizers = [];
3095
+ const lines = code.split('\n');
3096
+ // `.query(...)` / `.execute(...)` with a non-interpolating string
3097
+ // literal containing $N or ? placeholders, followed by `, [`.
3098
+ // Single quotes:
3099
+ const singleRe = /\.\s*(?:query|execute)\s*\(\s*'([^'\\]*(?:\\.[^'\\]*)*)'\s*,\s*\[/;
3100
+ // Double quotes:
3101
+ const doubleRe = /\.\s*(?:query|execute)\s*\(\s*"([^"\\]*(?:\\.[^"\\]*)*)"\s*,\s*\[/;
3102
+ // Backticks WITHOUT `${...}` interpolation:
3103
+ const tickRe = /\.\s*(?:query|execute)\s*\(\s*`([^`]*)`\s*,\s*\[/;
3104
+ const placeholderRe = /(?:\$\d+|\?)/;
3105
+ for (let i = 0; i < lines.length; i++) {
3106
+ const text = lines[i];
3107
+ let sql = null;
3108
+ const s = singleRe.exec(text);
3109
+ if (s)
3110
+ sql = s[1];
3111
+ if (sql == null) {
3112
+ const d = doubleRe.exec(text);
3113
+ if (d)
3114
+ sql = d[1];
3115
+ }
3116
+ if (sql == null) {
3117
+ const t = tickRe.exec(text);
3118
+ // Reject template literals that interpolate values.
3119
+ if (t && !/\$\{/.test(t[1]))
3120
+ sql = t[1];
3121
+ }
3122
+ if (sql == null)
3123
+ continue;
3124
+ if (!placeholderRe.test(sql))
3125
+ continue;
3126
+ sanitizers.push({
3127
+ type: 'js_parameterized_sql',
3128
+ method: 'query',
3129
+ line: i + 1,
3130
+ sanitizes: ['sql_injection', 'external_taint_escape'],
3131
+ });
3132
+ }
3133
+ return sanitizers;
3134
+ }
2972
3135
  /**
2973
3136
  * Java: Path.resolve(...).normalize() + startsWith(ROOT) guard
2974
3137
  * sanitizer (cognium-dev #216 Sprint 76 Pattern B).
@@ -4104,4 +4267,872 @@ export function findJsPatternFindings(code, file) {
4104
4267
  }
4105
4268
  return out;
4106
4269
  }
4270
+ // ---------------------------------------------------------------------------
4271
+ // Sprint 81 (#189) — xss-cluster FN coverage. Six per-language pattern
4272
+ // detectors that emit SastFinding{rule_id:'xss'} directly, bypassing the
4273
+ // source→sink→flow construction where the engine can't yet build a flow
4274
+ // (e.g. Python string-concat / f-string, Java receiver-chain typing).
4275
+ // ---------------------------------------------------------------------------
4276
+ /**
4277
+ * Go xss — `fmt.Fprint(f|ln)?(w, ...)` writing tainted data directly to an
4278
+ * `http.ResponseWriter`. Two-pass:
4279
+ *
4280
+ * 1. Discover ResponseWriter parameter names by scanning function
4281
+ * signatures `(<name> http.ResponseWriter, ...)`.
4282
+ * 2. For every `fmt.Fprint(f|ln)?(<name>, <format>, <args...>)` whose
4283
+ * first argument matches a discovered name, emit xss UNLESS one of
4284
+ * the args is wrapped in a recognized HTML escaper
4285
+ * (`html.EscapeString` / `template.HTMLEscapeString` /
4286
+ * `template.HTMLEscaper`).
4287
+ */
4288
+ export function findGoXssFindings(code, file) {
4289
+ const out = [];
4290
+ const lines = code.split('\n');
4291
+ const rwNames = new Set();
4292
+ const sigRe = /\(\s*([A-Za-z_]\w*)\s+http\.ResponseWriter\b/g;
4293
+ for (const line of lines) {
4294
+ let m;
4295
+ sigRe.lastIndex = 0;
4296
+ while ((m = sigRe.exec(line)) !== null)
4297
+ rwNames.add(m[1]);
4298
+ }
4299
+ if (rwNames.size === 0)
4300
+ return out;
4301
+ const escaperRe = /\b(?:html|template)\.(?:EscapeString|HTMLEscapeString|HTMLEscaper|JSEscapeString|URLQueryEscaper)\s*\(/;
4302
+ for (let i = 0; i < lines.length; i++) {
4303
+ const raw = lines[i];
4304
+ const trimmed = raw.trim();
4305
+ if (!trimmed || trimmed.startsWith('//'))
4306
+ continue;
4307
+ const callRe = /\bfmt\.Fprint(?:f|ln)?\s*\(\s*([A-Za-z_]\w*)\s*,\s*([\s\S]*)\)\s*(?:\/\/.*)?$/;
4308
+ const m = trimmed.match(callRe);
4309
+ if (!m)
4310
+ continue;
4311
+ if (!rwNames.has(m[1]))
4312
+ continue;
4313
+ const tail = m[2];
4314
+ if (escaperRe.test(tail))
4315
+ continue;
4316
+ // Require at least one identifier in tail outside string literals.
4317
+ if (!/[A-Za-z_]\w*/.test(tail.replace(/"[^"]*"|`[^`]*`/g, '')))
4318
+ continue;
4319
+ out.push({
4320
+ id: `xss-${file}-${i + 1}`,
4321
+ pass: 'language-sources',
4322
+ category: 'security',
4323
+ rule_id: 'xss',
4324
+ cwe: 'CWE-79',
4325
+ severity: 'high',
4326
+ level: 'error',
4327
+ message: 'Reflected XSS: fmt.Fprint writes data to http.ResponseWriter ' +
4328
+ 'without HTML escaping. Wrap user-controlled args with ' +
4329
+ 'html.EscapeString / template.HTMLEscapeString.',
4330
+ file,
4331
+ line: i + 1,
4332
+ snippet: trimmed.substring(0, 100),
4333
+ });
4334
+ }
4335
+ return out;
4336
+ }
4337
+ /**
4338
+ * Java xss — `<recv>.getWriter().{print,println,write,printf,format,append}
4339
+ * (<arg>)` chained call where `<recv>` is typed `HttpServletResponse`.
4340
+ * The receiver-chain form bypasses the configured `PrintWriter.print`
4341
+ * sink because the engine doesn't yet trace `HttpServletResponse
4342
+ * .getWriter()` → `PrintWriter` type resolution.
4343
+ *
4344
+ * Conservative: only fires when the receiver token matches a parameter
4345
+ * named with the `HttpServletResponse` type AND no recognized HTML
4346
+ * encoder wraps the argument.
4347
+ */
4348
+ export function findJavaResponseWriterXssFindings(code, file) {
4349
+ const out = [];
4350
+ const lines = code.split('\n');
4351
+ const respNames = new Set();
4352
+ const sigRe = /\bHttpServletResponse\s+([A-Za-z_]\w*)\b/g;
4353
+ for (const line of lines) {
4354
+ let m;
4355
+ sigRe.lastIndex = 0;
4356
+ while ((m = sigRe.exec(line)) !== null)
4357
+ respNames.add(m[1]);
4358
+ }
4359
+ if (respNames.size === 0)
4360
+ return out;
4361
+ const safeWrapRe = /\b(?:Encode\.(?:forHtml|forHtmlAttribute|forHtmlContent|forJavaScript)|StringEscapeUtils\.escape(?:Html3|Html4|EcmaScript|Xml)|HtmlUtils\.htmlEscape(?:Decimal|Hex)?|Escaper\.escapeHtml|HtmlEscapers\.(?:escapeHtml|htmlEscaper)|Encoder\.encodeForHTML(?:Attribute)?|Jsoup\.clean)\s*\(/;
4362
+ for (let i = 0; i < lines.length; i++) {
4363
+ const raw = lines[i];
4364
+ const trimmed = raw.trim();
4365
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
4366
+ continue;
4367
+ const chainRe = /\b([A-Za-z_]\w*)\s*\.\s*getWriter\s*\(\s*\)\s*\.\s*(print|println|write|printf|format|append)\s*\(([\s\S]*)\)\s*;?\s*(?:\/\/.*)?$/;
4368
+ const m = trimmed.match(chainRe);
4369
+ if (!m)
4370
+ continue;
4371
+ const recv = m[1];
4372
+ if (!respNames.has(recv))
4373
+ continue;
4374
+ const args = m[3];
4375
+ if (safeWrapRe.test(args))
4376
+ continue;
4377
+ // Skip when args is a pure string-literal call like `print("hello")`.
4378
+ // Anything else (`+`-concat, bare identifier, method call) is a write
4379
+ // of dynamic content that should have gone through an HTML encoder.
4380
+ const argsTrim = args.trim();
4381
+ if (/^"(?:\\.|[^"\\])*"$/.test(argsTrim))
4382
+ continue;
4383
+ if (!/[A-Za-z_]\w*/.test(argsTrim))
4384
+ continue;
4385
+ out.push({
4386
+ id: `xss-${file}-${i + 1}`,
4387
+ pass: 'language-sources',
4388
+ category: 'security',
4389
+ rule_id: 'xss',
4390
+ cwe: 'CWE-79',
4391
+ severity: 'high',
4392
+ level: 'error',
4393
+ message: 'Reflected XSS: HttpServletResponse.getWriter().' +
4394
+ m[2] +
4395
+ '(...) writes data to the response without HTML escaping. ' +
4396
+ 'Wrap user-controlled values with OWASP Encode.forHtml / ' +
4397
+ 'StringEscapeUtils.escapeHtml4 / Jsoup.clean.',
4398
+ file,
4399
+ line: i + 1,
4400
+ snippet: trimmed.substring(0, 100),
4401
+ });
4402
+ }
4403
+ return out;
4404
+ }
4405
+ /**
4406
+ * Vue xss — `template: '<...v-html="<var>"...>'` directive binding to a
4407
+ * variable that is sourced from a tainted location (URLSearchParams,
4408
+ * location.search/hash, route.query/params, fetch, etc.).
4409
+ *
4410
+ * Conservative: when the bound variable is a literal initializer
4411
+ * (`<var>: 'static'`) or sourced from a non-recognized location, no
4412
+ * finding is emitted.
4413
+ */
4414
+ export function findJsVueVHtmlXssFindings(code, file) {
4415
+ const out = [];
4416
+ const lines = code.split('\n');
4417
+ const sourceRe = /\b(?:URLSearchParams|location\s*\.\s*(?:search|hash|href|pathname)|window\s*\.\s*location|route\s*\.\s*(?:query|params)|router\s*\.\s*(?:currentRoute|query)|\$route\s*\.\s*(?:query|params)|fetch\s*\(|axios\s*\.\s*(?:get|post)|XMLHttpRequest|document\s*\.\s*location)\b/;
4418
+ const tplRe = /\btemplate\s*:\s*(['"`])([\s\S]*?)\1/g;
4419
+ let tm;
4420
+ const boundVars = new Set();
4421
+ while ((tm = tplRe.exec(code)) !== null) {
4422
+ const tpl = tm[2];
4423
+ const vhRe = /\bv-html\s*=\s*"([^"]+)"/g;
4424
+ let vm;
4425
+ while ((vm = vhRe.exec(tpl)) !== null) {
4426
+ const expr = vm[1].trim();
4427
+ const idMatch = expr.match(/^([A-Za-z_$][\w$]*)/);
4428
+ if (idMatch)
4429
+ boundVars.add(idMatch[1]);
4430
+ }
4431
+ }
4432
+ if (boundVars.size === 0)
4433
+ return out;
4434
+ // Build a set of variables transitively tainted by a recognized source
4435
+ // pattern: `const params = new URLSearchParams(...)` taints `params`,
4436
+ // then `const q = params.get('q')` taints `q` (one-hop).
4437
+ const taintedSet = new Set();
4438
+ const assignRe = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g;
4439
+ let am;
4440
+ // First pass: direct source-pattern assignments.
4441
+ assignRe.lastIndex = 0;
4442
+ while ((am = assignRe.exec(code)) !== null) {
4443
+ if (sourceRe.test(am[2]))
4444
+ taintedSet.add(am[1]);
4445
+ }
4446
+ // Second pass: one-hop through tainted vars (limit 3 iterations).
4447
+ for (let pass = 0; pass < 3; pass++) {
4448
+ assignRe.lastIndex = 0;
4449
+ const before = taintedSet.size;
4450
+ while ((am = assignRe.exec(code)) !== null) {
4451
+ if (taintedSet.has(am[1]))
4452
+ continue;
4453
+ for (const tv of taintedSet) {
4454
+ if (new RegExp(`\\b${tv}\\b`).test(am[2])) {
4455
+ taintedSet.add(am[1]);
4456
+ break;
4457
+ }
4458
+ }
4459
+ }
4460
+ if (taintedSet.size === before)
4461
+ break;
4462
+ }
4463
+ const taintedBindings = new Set();
4464
+ for (const v of boundVars) {
4465
+ const bindRe = new RegExp(`\\b${v}\\s*[:=]\\s*([^,;\\n]+)`);
4466
+ for (const line of lines) {
4467
+ const m = line.match(bindRe);
4468
+ if (!m)
4469
+ continue;
4470
+ const val = m[1];
4471
+ if (sourceRe.test(val)) {
4472
+ taintedBindings.add(v);
4473
+ break;
4474
+ }
4475
+ let found = false;
4476
+ for (const tv of taintedSet) {
4477
+ if (new RegExp(`\\b${tv}\\b`).test(val)) {
4478
+ taintedBindings.add(v);
4479
+ found = true;
4480
+ break;
4481
+ }
4482
+ }
4483
+ if (found)
4484
+ break;
4485
+ }
4486
+ const propsRe = new RegExp(`\\bprops\\s*:\\s*\\[[^\\]]*['"\`]${v}['"\`][^\\]]*\\]`);
4487
+ if (propsRe.test(code))
4488
+ taintedBindings.add(v);
4489
+ }
4490
+ if (taintedBindings.size === 0)
4491
+ return out;
4492
+ for (let i = 0; i < lines.length; i++) {
4493
+ const raw = lines[i];
4494
+ if (!/v-html\s*=/.test(raw))
4495
+ continue;
4496
+ const vm = raw.match(/v-html\s*=\s*"([^"]+)"/);
4497
+ if (!vm)
4498
+ continue;
4499
+ const idMatch = vm[1].trim().match(/^([A-Za-z_$][\w$]*)/);
4500
+ if (!idMatch || !taintedBindings.has(idMatch[1]))
4501
+ continue;
4502
+ out.push({
4503
+ id: `xss-${file}-${i + 1}`,
4504
+ pass: 'language-sources',
4505
+ category: 'security',
4506
+ rule_id: 'xss',
4507
+ cwe: 'CWE-79',
4508
+ severity: 'high',
4509
+ level: 'error',
4510
+ message: 'Vue v-html XSS: directive binds to "' +
4511
+ idMatch[1] +
4512
+ '" which is sourced from user-controlled input. Use {{ }} ' +
4513
+ 'interpolation (auto-escapes) or sanitize the HTML with ' +
4514
+ 'DOMPurify before binding.',
4515
+ file,
4516
+ line: i + 1,
4517
+ snippet: raw.trim().substring(0, 100),
4518
+ });
4519
+ }
4520
+ return out;
4521
+ }
4522
+ /**
4523
+ * Angular xss — `<recv>.bypassSecurityTrust(Html|Script|Url|ResourceUrl|
4524
+ * Style)(<arg>)` where `<recv>` is typed `DomSanitizer`. Skip when
4525
+ * `<arg>` is a string literal (intentional safe-by-author escape hatch).
4526
+ */
4527
+ export function findTsAngularBypassXssFindings(code, file) {
4528
+ const out = [];
4529
+ const lines = code.split('\n');
4530
+ const sanitizerNames = new Set();
4531
+ const declRe = /\b(?:public|private|protected|readonly|\s)?\s*([A-Za-z_$][\w$]*)\s*:\s*DomSanitizer\b/g;
4532
+ let m;
4533
+ declRe.lastIndex = 0;
4534
+ while ((m = declRe.exec(code)) !== null)
4535
+ sanitizerNames.add(m[1]);
4536
+ if (sanitizerNames.size === 0)
4537
+ return out;
4538
+ const assignRe = /\bthis\s*\.\s*([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\b/g;
4539
+ let am;
4540
+ assignRe.lastIndex = 0;
4541
+ while ((am = assignRe.exec(code)) !== null) {
4542
+ if (sanitizerNames.has(am[2]))
4543
+ sanitizerNames.add(am[1]);
4544
+ }
4545
+ for (let i = 0; i < lines.length; i++) {
4546
+ const raw = lines[i];
4547
+ const trimmed = raw.trim();
4548
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
4549
+ continue;
4550
+ const callRe = /\b(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\.\s*bypassSecurityTrust(Html|Script|Url|ResourceUrl|Style)\s*\(([\s\S]*?)\)/;
4551
+ const cm = trimmed.match(callRe);
4552
+ if (!cm)
4553
+ continue;
4554
+ const recv = cm[1];
4555
+ if (!sanitizerNames.has(recv))
4556
+ continue;
4557
+ const arg = cm[3].trim();
4558
+ if (/^(['"`])[^'"`]*\1$/.test(arg))
4559
+ continue;
4560
+ out.push({
4561
+ id: `xss-${file}-${i + 1}`,
4562
+ pass: 'language-sources',
4563
+ category: 'security',
4564
+ rule_id: 'xss',
4565
+ cwe: 'CWE-79',
4566
+ severity: 'high',
4567
+ level: 'error',
4568
+ message: 'Angular XSS: DomSanitizer.bypassSecurityTrust' +
4569
+ cm[2] +
4570
+ '() opts out of Angular\'s built-in sanitization for a ' +
4571
+ 'non-literal argument. Prefer leaving Angular\'s default ' +
4572
+ 'sanitizer in place, or sanitize the input with DOMPurify ' +
4573
+ 'before bypassing.',
4574
+ file,
4575
+ line: i + 1,
4576
+ snippet: trimmed.substring(0, 100),
4577
+ });
4578
+ }
4579
+ return out;
4580
+ }
4581
+ /**
4582
+ * Python Flask xss — route function returning HTML built from a
4583
+ * `request.<args|form|values|files>.get(...)` value via string concat,
4584
+ * f-string, %, or .format(). Bypasses the engine's flow construction
4585
+ * gap for these string ops.
4586
+ *
4587
+ * Conservative: requires a Flask-style route decorator (`@<x>.route(`)
4588
+ * above the function; skip when the variable is escape()-wrapped.
4589
+ */
4590
+ export function findPythonFlaskStringConcatXssFindings(code, file) {
4591
+ const out = [];
4592
+ const lines = code.split('\n');
4593
+ const fns = [];
4594
+ const defRe = /^(\s*)def\s+([A-Za-z_]\w*)\s*\(/;
4595
+ for (let i = 0; i < lines.length; i++) {
4596
+ const raw = lines[i];
4597
+ const dm = raw.match(defRe);
4598
+ if (!dm)
4599
+ continue;
4600
+ const indent = dm[1].length;
4601
+ let hasRoute = false;
4602
+ for (let j = i - 1; j >= 0; j--) {
4603
+ const prev = lines[j].trim();
4604
+ if (prev === '' || prev.startsWith('#'))
4605
+ continue;
4606
+ if (!prev.startsWith('@'))
4607
+ break;
4608
+ if (/@\s*[A-Za-z_]\w*\s*\.\s*route\s*\(/.test(prev) ||
4609
+ /@\s*route\s*\(/.test(prev)) {
4610
+ hasRoute = true;
4611
+ }
4612
+ }
4613
+ if (!hasRoute)
4614
+ continue;
4615
+ let end = lines.length;
4616
+ for (let k = i + 1; k < lines.length; k++) {
4617
+ const ln = lines[k];
4618
+ if (!ln.trim())
4619
+ continue;
4620
+ const ind = ln.match(/^(\s*)/)?.[1].length ?? 0;
4621
+ if (ind <= indent) {
4622
+ end = k;
4623
+ break;
4624
+ }
4625
+ }
4626
+ fns.push({ name: dm[2], startLine: i + 1, endLine: end });
4627
+ }
4628
+ if (fns.length === 0)
4629
+ return out;
4630
+ const requestSourceRe = /\b([A-Za-z_]\w*)\s*=\s*request\s*\.\s*(?:args|form|values|files|json|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/;
4631
+ const escapeWrapRe = /\b(?:html\.escape|markupsafe\.escape|escape|markupsafe\.Markup\s*\.\s*escape|werkzeug\.utils\.escape)\s*\(/;
4632
+ for (const fn of fns) {
4633
+ const taintedVars = new Set();
4634
+ for (let li = fn.startLine; li < fn.endLine; li++) {
4635
+ const raw = lines[li];
4636
+ const rm = raw.match(requestSourceRe);
4637
+ if (rm)
4638
+ taintedVars.add(rm[1]);
4639
+ }
4640
+ for (let li = fn.startLine; li < fn.endLine; li++) {
4641
+ const raw = lines[li];
4642
+ const trimmed = raw.trim();
4643
+ if (!trimmed || trimmed.startsWith('#'))
4644
+ continue;
4645
+ const retMatch = trimmed.match(/^(?:return|yield)\s+(.+)$/);
4646
+ if (!retMatch)
4647
+ continue;
4648
+ const expr = retMatch[1];
4649
+ const inlineSource = /\brequest\s*\.\s*(?:args|form|values|files|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/.test(expr);
4650
+ const hasTaintedVar = [...taintedVars].some(v => new RegExp(`\\b${v}\\b`).test(expr));
4651
+ if (!hasTaintedVar && !inlineSource)
4652
+ continue;
4653
+ if (escapeWrapRe.test(expr))
4654
+ continue;
4655
+ const buildsHtml = (/['"`]\s*\+/.test(expr) || /\+\s*['"`]/.test(expr)) ||
4656
+ // f-string with interpolation. Allow any non-`{` char between the
4657
+ // opening quote and the first `{` (Python f-strings may embed `"`
4658
+ // when single-quoted and vice-versa, e.g. f'<a href="{u}">').
4659
+ /\bf['"`][^{\n]*\{[^}]+\}/.test(expr) ||
4660
+ /['"`]\s*%\s*[^=]/.test(expr) ||
4661
+ /['"`]\.\s*format\s*\(/.test(expr);
4662
+ if (!buildsHtml)
4663
+ continue;
4664
+ out.push({
4665
+ id: `xss-${file}-${li + 1}`,
4666
+ pass: 'language-sources',
4667
+ category: 'security',
4668
+ rule_id: 'xss',
4669
+ cwe: 'CWE-79',
4670
+ severity: 'high',
4671
+ level: 'error',
4672
+ message: 'Reflected XSS: Flask route returns HTML built from request ' +
4673
+ 'input via string concatenation / f-string / format. Wrap ' +
4674
+ 'user input with markupsafe.escape() or render via a ' +
4675
+ 'Jinja2 template (autoescape on by default).',
4676
+ file,
4677
+ line: li + 1,
4678
+ snippet: trimmed.substring(0, 100),
4679
+ });
4680
+ break;
4681
+ }
4682
+ }
4683
+ return out;
4684
+ }
4685
+ /**
4686
+ * Python Jinja2 Markup-bypass xss — `Markup(<var>)` where `<var>` is
4687
+ * request-sourced, then passed into a `<X>.render(...)` call. The
4688
+ * `Markup` wrap deliberately disables Jinja autoescape for the value.
4689
+ *
4690
+ * Namespace-scoped alias tracking (Sprint 74 lesson): only treats
4691
+ * `Markup` as dangerous when imported from `markupsafe` or `flask`,
4692
+ * and only when no user-defined `class Markup` shadows the import.
4693
+ */
4694
+ export function findPythonJinjaMarkupXssFindings(code, file) {
4695
+ const out = [];
4696
+ const lines = code.split('\n');
4697
+ const importRe = /^\s*from\s+(?:markupsafe|flask)\s+import\s+(?:[^#\n]*\b)?Markup\b/m;
4698
+ const classDefRe = /^\s*class\s+Markup\b/m;
4699
+ if (!importRe.test(code))
4700
+ return out;
4701
+ if (classDefRe.test(code))
4702
+ return out;
4703
+ const requestSourceRe = /\b([A-Za-z_]\w*)\s*=\s*request\s*\.\s*(?:args|form|values|files|json|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/;
4704
+ const taintedVars = new Set();
4705
+ for (const line of lines) {
4706
+ const m = line.match(requestSourceRe);
4707
+ if (m)
4708
+ taintedVars.add(m[1]);
4709
+ }
4710
+ if (taintedVars.size === 0)
4711
+ return out;
4712
+ if (!/\.\s*render\s*\(/.test(code))
4713
+ return out;
4714
+ const markupCallRe = /\bMarkup\s*\(\s*([A-Za-z_]\w*)\s*[,)]/g;
4715
+ for (let i = 0; i < lines.length; i++) {
4716
+ const raw = lines[i];
4717
+ const trimmed = raw.trim();
4718
+ if (!trimmed || trimmed.startsWith('#'))
4719
+ continue;
4720
+ let mm;
4721
+ markupCallRe.lastIndex = 0;
4722
+ while ((mm = markupCallRe.exec(trimmed)) !== null) {
4723
+ const argName = mm[1];
4724
+ if (!taintedVars.has(argName))
4725
+ continue;
4726
+ out.push({
4727
+ id: `xss-${file}-${i + 1}`,
4728
+ pass: 'language-sources',
4729
+ category: 'security',
4730
+ rule_id: 'xss',
4731
+ cwe: 'CWE-79',
4732
+ severity: 'high',
4733
+ level: 'error',
4734
+ message: 'Jinja2 autoescape bypass: Markup(' +
4735
+ argName +
4736
+ ') wraps a request-derived value, disabling autoescape when ' +
4737
+ 'rendered. Pass the raw value to render() and let Jinja2 ' +
4738
+ 'auto-escape, or sanitize with bleach.clean first.',
4739
+ file,
4740
+ line: i + 1,
4741
+ snippet: trimmed.substring(0, 100),
4742
+ });
4743
+ break;
4744
+ }
4745
+ }
4746
+ return out;
4747
+ }
4748
+ /**
4749
+ * Go open_redirect — `<rw>.Header().Set("Location", <expr>)` where
4750
+ * `<rw>` is bound to `http.ResponseWriter`. The configured sink rows
4751
+ * for `Header.Set` are typed `crlf` (CWE-113); the same line is also a
4752
+ * CWE-601 open_redirect when the header key is exactly `"Location"`
4753
+ * (case-insensitive). This pattern detector emits the open_redirect
4754
+ * finding directly, complementing the engine's crlf classification.
4755
+ *
4756
+ * Sprint 82 (#189): closes go__v02_location_header.
4757
+ *
4758
+ * Conservative: only fires when the receiver matches a parameter typed
4759
+ * `http.ResponseWriter` AND the literal key value is `Location` AND no
4760
+ * recognized URL-allowlist sanitizer wraps the value argument.
4761
+ */
4762
+ export function findGoLocationHeaderOpenRedirectFindings(code, file) {
4763
+ const out = [];
4764
+ const lines = code.split('\n');
4765
+ const rwNames = new Set();
4766
+ const sigRe = /\(\s*([A-Za-z_]\w*)\s+http\.ResponseWriter\b/g;
4767
+ for (const line of lines) {
4768
+ let m;
4769
+ sigRe.lastIndex = 0;
4770
+ while ((m = sigRe.exec(line)) !== null)
4771
+ rwNames.add(m[1]);
4772
+ }
4773
+ if (rwNames.size === 0)
4774
+ return out;
4775
+ // Recognized URL-allowlist / strict-equality sanitizers. Conservative
4776
+ // — keeps the FP set tight (Sprint 74 lesson). `url.Parse` alone is
4777
+ // not a sanitizer; we require an `IsAbs`/`Host`/`==` check.
4778
+ const safeWrapRe = /\b(?:net\/url|url)\.Parse\b[\s\S]{0,120}?\b(?:IsAbs|Host)\b|\bstrings\.HasPrefix\s*\(/;
4779
+ // Match: `<rw>.Header().Set("Location", <expr>)` — also accept
4780
+ // `Add` (header-stacking variant) which has the same redirect effect.
4781
+ const callRe = /\b([A-Za-z_]\w*)\s*\.\s*Header\s*\(\s*\)\s*\.\s*(?:Set|Add)\s*\(\s*(['"])([^'"]+)\2\s*,\s*([\s\S]*)\)\s*(?:\/\/.*)?$/;
4782
+ for (let i = 0; i < lines.length; i++) {
4783
+ const raw = lines[i];
4784
+ const trimmed = raw.trim();
4785
+ if (!trimmed || trimmed.startsWith('//'))
4786
+ continue;
4787
+ const m = trimmed.match(callRe);
4788
+ if (!m)
4789
+ continue;
4790
+ const recv = m[1];
4791
+ if (!rwNames.has(recv))
4792
+ continue;
4793
+ const key = m[3];
4794
+ if (key.toLowerCase() !== 'location')
4795
+ continue;
4796
+ const valExpr = m[4];
4797
+ // Skip when the value is a pure string literal (no taint shape).
4798
+ const valTrim = valExpr.trim();
4799
+ if (/^"(?:\\.|[^"\\])*"$/.test(valTrim))
4800
+ continue;
4801
+ if (safeWrapRe.test(valExpr))
4802
+ continue;
4803
+ out.push({
4804
+ id: `open_redirect-${file}-${i + 1}`,
4805
+ pass: 'language-sources',
4806
+ category: 'security',
4807
+ rule_id: 'open_redirect',
4808
+ cwe: 'CWE-601',
4809
+ severity: 'high',
4810
+ level: 'error',
4811
+ message: 'Open redirect: ResponseWriter.Header().Set("Location", ...) ' +
4812
+ 'writes a user-controlled value into the Location header without ' +
4813
+ 'validating the target. Restrict to an allow-list of hosts/paths ' +
4814
+ 'or compare against a known-safe set before issuing the redirect.',
4815
+ file,
4816
+ line: i + 1,
4817
+ snippet: trimmed.substring(0, 100),
4818
+ });
4819
+ }
4820
+ return out;
4821
+ }
4822
+ /**
4823
+ * Python Flask open_redirect — `<resp>.headers["Location"] = <expr>`
4824
+ * subscript-assignment shape. The engine's configured sinks for
4825
+ * Flask `Response.headers` model the dict-add form but not subscript
4826
+ * assignment, so the open_redirect signal is missed even though the
4827
+ * source (`request.args.get`) is detected.
4828
+ *
4829
+ * Sprint 82 (#189): closes py__v02_location_header.
4830
+ *
4831
+ * Conservative: requires the rhs to be a recognized request-source
4832
+ * variable OR an inline `request.<x>.get/[]` expression. Skips when
4833
+ * the value is wrapped in a recognized URL-allowlist call.
4834
+ */
4835
+ export function findPythonHeadersSubscriptOpenRedirectFindings(code, file) {
4836
+ const out = [];
4837
+ const lines = code.split('\n');
4838
+ const requestSourceRe = /\b([A-Za-z_]\w*)\s*=\s*request\s*\.\s*(?:args|form|values|files|json|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/;
4839
+ const taintedVars = new Set();
4840
+ for (const line of lines) {
4841
+ const m = line.match(requestSourceRe);
4842
+ if (m)
4843
+ taintedVars.add(m[1]);
4844
+ }
4845
+ // Recognized URL-allowlist sanitizers (tight, Sprint 74 lesson).
4846
+ const safeWrapRe = /\b(?:urllib\.parse\.urlparse|urlparse)\s*\([\s\S]{0,120}?\bnetloc\b|\b(?:startswith)\s*\(/;
4847
+ const subscriptRe = /\b([A-Za-z_]\w*)\s*\.\s*headers\s*\[\s*(['"])([^'"]+)\2\s*\]\s*=\s*(.+)$/;
4848
+ for (let i = 0; i < lines.length; i++) {
4849
+ const raw = lines[i];
4850
+ const trimmed = raw.trim();
4851
+ if (!trimmed || trimmed.startsWith('#'))
4852
+ continue;
4853
+ const m = trimmed.match(subscriptRe);
4854
+ if (!m)
4855
+ continue;
4856
+ const key = m[3];
4857
+ if (key.toLowerCase() !== 'location')
4858
+ continue;
4859
+ const rhs = m[4].replace(/\s*(?:#.*)?$/, '').trim();
4860
+ // Skip pure string literal.
4861
+ if (/^['"](?:\\.|[^'"\\])*['"]$/.test(rhs))
4862
+ continue;
4863
+ // Skip when wrapped in a recognized allowlist check.
4864
+ if (safeWrapRe.test(rhs))
4865
+ continue;
4866
+ const hasTaintedVar = [...taintedVars].some(v => new RegExp(`\\b${v}\\b`).test(rhs));
4867
+ const inlineSource = /\brequest\s*\.\s*(?:args|form|values|files|cookies|headers|json)(?:\s*\.\s*get\s*\(|\s*\[)/.test(rhs);
4868
+ if (!hasTaintedVar && !inlineSource)
4869
+ continue;
4870
+ out.push({
4871
+ id: `open_redirect-${file}-${i + 1}`,
4872
+ pass: 'language-sources',
4873
+ category: 'security',
4874
+ rule_id: 'open_redirect',
4875
+ cwe: 'CWE-601',
4876
+ severity: 'high',
4877
+ level: 'error',
4878
+ message: 'Open redirect: response.headers["Location"] = ... assigns a ' +
4879
+ 'user-controlled value to the Location header. Validate the URL ' +
4880
+ 'against an allow-list of trusted hosts before sending.',
4881
+ file,
4882
+ line: i + 1,
4883
+ snippet: trimmed.substring(0, 100),
4884
+ });
4885
+ }
4886
+ return out;
4887
+ }
4888
+ /**
4889
+ * Rust open_redirect — Actix/Rocket builder pattern
4890
+ * `<builder>.append_header(("Location", <expr>))` or
4891
+ * `.insert_header(("Location", <expr>))` where the value traces back
4892
+ * to a `web::Query` / `web::Path` / `Form` extractor. The tuple-arg
4893
+ * form is the idiomatic Actix HeaderName→HeaderValue API; the
4894
+ * engine's configured sinks model single-arg `set_header(value)` but
4895
+ * not the tuple builder shape.
4896
+ *
4897
+ * Sprint 82 (#189): closes rust__v01_redirect_param.
4898
+ *
4899
+ * Conservative: requires the function to accept a recognized
4900
+ * `web::Query` / `web::Path` / `web::Form` / `HttpRequest` parameter
4901
+ * and the value expression to reference it (directly, or transitively
4902
+ * via a `.get(...)` / `.cloned()` / `.unwrap_or_default()` chain).
4903
+ */
4904
+ export function findRustAppendHeaderTupleOpenRedirectFindings(code, file) {
4905
+ const out = [];
4906
+ const lines = code.split('\n');
4907
+ // Discover request-extractor parameter names.
4908
+ 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;
4909
+ const extractorParams = new Set();
4910
+ for (const line of lines) {
4911
+ let m;
4912
+ extractorRe.lastIndex = 0;
4913
+ while ((m = extractorRe.exec(line)) !== null) {
4914
+ const name = m[1] ?? m[2];
4915
+ if (name)
4916
+ extractorParams.add(name);
4917
+ }
4918
+ }
4919
+ if (extractorParams.size === 0)
4920
+ return out;
4921
+ // Build transitive tainted-let set: `let v = <expr>` where <expr>
4922
+ // references an extractor param or a previously tainted var.
4923
+ const taintedVars = new Set(extractorParams);
4924
+ const letRe = /\blet\s+(?:mut\s+)?([A-Za-z_]\w*)\s*(?::[^=]+)?=\s*([^;]+);/g;
4925
+ for (let pass = 0; pass < 4; pass++) {
4926
+ const before = taintedVars.size;
4927
+ let lm;
4928
+ letRe.lastIndex = 0;
4929
+ while ((lm = letRe.exec(code)) !== null) {
4930
+ const name = lm[1];
4931
+ const rhs = lm[2];
4932
+ if (taintedVars.has(name))
4933
+ continue;
4934
+ for (const tv of taintedVars) {
4935
+ if (new RegExp(`\\b${tv}\\b`).test(rhs)) {
4936
+ taintedVars.add(name);
4937
+ break;
4938
+ }
4939
+ }
4940
+ }
4941
+ if (taintedVars.size === before)
4942
+ break;
4943
+ }
4944
+ 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/;
4945
+ const tupleCallRe = /\.\s*(?:append_header|insert_header)\s*\(\s*\(\s*(['"])([^'"]+)\1\s*,\s*([^)]*?)\s*\)\s*\)/g;
4946
+ for (let i = 0; i < lines.length; i++) {
4947
+ const raw = lines[i];
4948
+ const trimmed = raw.trim();
4949
+ if (!trimmed || trimmed.startsWith('//'))
4950
+ continue;
4951
+ let cm;
4952
+ tupleCallRe.lastIndex = 0;
4953
+ while ((cm = tupleCallRe.exec(raw)) !== null) {
4954
+ const key = cm[2];
4955
+ if (key.toLowerCase() !== 'location')
4956
+ continue;
4957
+ const valExpr = cm[3];
4958
+ if (/^['"](?:\\.|[^'"\\])*['"]$/.test(valExpr.trim()))
4959
+ continue;
4960
+ if (safeWrapRe.test(valExpr))
4961
+ continue;
4962
+ const hasTainted = [...taintedVars].some(v => new RegExp(`\\b${v}\\b`).test(valExpr));
4963
+ if (!hasTainted)
4964
+ continue;
4965
+ out.push({
4966
+ id: `open_redirect-${file}-${i + 1}`,
4967
+ pass: 'language-sources',
4968
+ category: 'security',
4969
+ rule_id: 'open_redirect',
4970
+ cwe: 'CWE-601',
4971
+ severity: 'high',
4972
+ level: 'error',
4973
+ message: 'Open redirect: HttpResponse builder ' +
4974
+ '.append_header(("Location", ...)) sets the Location header from ' +
4975
+ 'a request-derived value without allow-list validation. Parse ' +
4976
+ 'with url::Url::parse and compare host_str against an allow-list.',
4977
+ file,
4978
+ line: i + 1,
4979
+ snippet: trimmed.substring(0, 100),
4980
+ });
4981
+ break;
4982
+ }
4983
+ }
4984
+ return out;
4985
+ }
4986
+ /**
4987
+ * JS/HTML DOM open_redirect — assignment / call patterns that drive
4988
+ * `location.href`, `window.location`, `document.location`,
4989
+ * `location.assign(...)`, `location.replace(...)`, or `<elem>.content
4990
+ * = '...;url=' + ...` (meta-refresh DOM shape) where the right-hand
4991
+ * side traces back to a DOM source (`location.search`,
4992
+ * `location.hash`, `URLSearchParams.get(...)`, `document.referrer`,
4993
+ * `window.name`).
4994
+ *
4995
+ * Sprint 82 (#189): closes html__v01_redirect_param,
4996
+ * html__v03_meta_refresh, htmljs__v03_meta_refresh.
4997
+ *
4998
+ * Conservative: only fires when the rhs expression contains (or
4999
+ * references a var that contains) a recognized DOM source token;
5000
+ * never fires on literal-only assignments.
5001
+ */
5002
+ export function findJsDomOpenRedirectFindings(code, file) {
5003
+ const out = [];
5004
+ const lines = code.split('\n');
5005
+ // DOM source pattern — direct references to user-controlled URL
5006
+ // pieces. `document.referrer` / `window.name` round out the common
5007
+ // taint sources beyond URLSearchParams.
5008
+ 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/;
5009
+ // Transitive tainted variable set: `const x = <DOM source ...>`,
5010
+ // then `const y = x.get(...)`, etc.
5011
+ const taintedVars = new Set();
5012
+ const assignRe = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g;
5013
+ let am;
5014
+ assignRe.lastIndex = 0;
5015
+ while ((am = assignRe.exec(code)) !== null) {
5016
+ if (domSourceRe.test(am[2]))
5017
+ taintedVars.add(am[1]);
5018
+ }
5019
+ for (let pass = 0; pass < 4; pass++) {
5020
+ const before = taintedVars.size;
5021
+ assignRe.lastIndex = 0;
5022
+ while ((am = assignRe.exec(code)) !== null) {
5023
+ if (taintedVars.has(am[1]))
5024
+ continue;
5025
+ for (const tv of taintedVars) {
5026
+ if (new RegExp(`\\b${tv}\\b`).test(am[2])) {
5027
+ taintedVars.add(am[1]);
5028
+ break;
5029
+ }
5030
+ }
5031
+ }
5032
+ if (taintedVars.size === before)
5033
+ break;
5034
+ }
5035
+ // Recognized sanitizers (tight): explicit allow-list check against a
5036
+ // known set / startsWith on a same-origin literal prefix.
5037
+ const safeWrapRe = /\b(?:startsWith|includes)\s*\(\s*['"`]\/[^'"`]*['"`]\s*\)|\bnew\s+URL\s*\([\s\S]{0,80}?\)\s*\.\s*(?:origin|hostname)\s*===/;
5038
+ const containsTaint = (expr) => {
5039
+ if (domSourceRe.test(expr))
5040
+ return true;
5041
+ for (const tv of taintedVars) {
5042
+ if (new RegExp(`\\b${tv}\\b`).test(expr))
5043
+ return true;
5044
+ }
5045
+ return false;
5046
+ };
5047
+ // Sink shape (a1): `[<X>.]location.href = <expr>`.
5048
+ const hrefSinkRe = /\b(?:(?:window|document|self|top|parent)\s*\.\s*)?location\s*\.\s*href\s*=\s*([^;\n]+?)\s*;?\s*(?:\/\/.*)?$/;
5049
+ // Sink shape (a2): `window.location = <expr>` etc.
5050
+ const locSinkRe = /\b(?:window|document|self|top|parent)\s*\.\s*location\s*=\s*([^;\n]+?)\s*;?\s*(?:\/\/.*)?$/;
5051
+ // Sink shape (a3): `<elem>.content = <expr>` — only counts when the
5052
+ // rhs looks like a meta-refresh string (`'...url=' + ...`). Allow `;`
5053
+ // inside string literals on the rhs (meta-refresh format is
5054
+ // `'<delay>;url=<...>'`), so match up to newline then trim trailing
5055
+ // semicolon/comment manually.
5056
+ const contentSinkRe = /\.\s*content\s*=\s*([^\n]+?)\s*$/;
5057
+ // Sink shape (b): `[<X>.]location.assign(<expr>)` /
5058
+ // `[<X>.]location.replace(<expr>)`.
5059
+ const callSinkRe = /\b(?:(?:window|document|self|top|parent)\s*\.\s*)?location\s*\.\s*(?:assign|replace)\s*\(\s*([^)]+)\)/;
5060
+ const emit = (line, msg, snippet) => {
5061
+ out.push({
5062
+ id: `open_redirect-${file}-${line}`,
5063
+ pass: 'language-sources',
5064
+ category: 'security',
5065
+ rule_id: 'open_redirect',
5066
+ cwe: 'CWE-601',
5067
+ severity: 'high',
5068
+ level: 'error',
5069
+ message: msg,
5070
+ file,
5071
+ line,
5072
+ snippet: snippet.substring(0, 100),
5073
+ });
5074
+ };
5075
+ const isLiteralOnly = (expr) => /^['"`](?:\\.|[^'"`\\])*['"`]$/.test(expr.trim());
5076
+ for (let i = 0; i < lines.length; i++) {
5077
+ const raw = lines[i];
5078
+ const trimmed = raw.trim();
5079
+ if (!trimmed || trimmed.startsWith('//'))
5080
+ continue;
5081
+ let matched = false;
5082
+ // (a1) location.href = ...
5083
+ const hm = trimmed.match(hrefSinkRe);
5084
+ if (hm) {
5085
+ const rhs = hm[1].trim();
5086
+ if (!isLiteralOnly(rhs) && containsTaint(rhs) && !safeWrapRe.test(rhs)) {
5087
+ emit(i + 1, 'Open redirect: assignment to location.href uses a value ' +
5088
+ 'derived from a URL query/hash without allow-list validation. ' +
5089
+ 'Compare the target origin to a known-safe set before navigating.', trimmed);
5090
+ matched = true;
5091
+ }
5092
+ }
5093
+ // (a2) window.location = ... (skip if already matched as href)
5094
+ if (!matched) {
5095
+ const lm = trimmed.match(locSinkRe);
5096
+ if (lm) {
5097
+ const rhs = lm[1].trim();
5098
+ if (!isLiteralOnly(rhs) && containsTaint(rhs) && !safeWrapRe.test(rhs)) {
5099
+ emit(i + 1, 'Open redirect: assignment to window.location uses a value ' +
5100
+ 'derived from a URL query/hash without allow-list validation. ' +
5101
+ 'Compare the target origin to a known-safe set before navigating.', trimmed);
5102
+ matched = true;
5103
+ }
5104
+ }
5105
+ }
5106
+ // (a3) <elem>.content = '...url=' + ... (meta-refresh DOM shape)
5107
+ if (!matched) {
5108
+ const cm = trimmed.match(contentSinkRe);
5109
+ if (cm) {
5110
+ // Strip trailing `;` and comment from captured rhs.
5111
+ const rhs = cm[1].replace(/\s*\/\/.*$/, '').replace(/\s*;\s*$/, '').trim();
5112
+ if (!isLiteralOnly(rhs) &&
5113
+ /['"`][^'"`]*\burl\s*=/i.test(rhs) &&
5114
+ containsTaint(rhs) &&
5115
+ !safeWrapRe.test(rhs)) {
5116
+ emit(i + 1, 'Open redirect: DOM assignment to <meta>.content with a ' +
5117
+ 'meta-refresh URL built from a URL query/hash without ' +
5118
+ 'allow-list validation. Validate origin before navigating.', trimmed);
5119
+ matched = true;
5120
+ }
5121
+ }
5122
+ }
5123
+ // (b) location.assign(...) / location.replace(...)
5124
+ if (!matched) {
5125
+ const callm = trimmed.match(callSinkRe);
5126
+ if (callm) {
5127
+ const arg = callm[1].trim();
5128
+ if (!isLiteralOnly(arg) && containsTaint(arg) && !safeWrapRe.test(arg)) {
5129
+ emit(i + 1, 'Open redirect: location.assign / location.replace invoked ' +
5130
+ 'with a value derived from a URL query/hash without allow-' +
5131
+ 'list validation. Validate origin before navigating.', trimmed);
5132
+ }
5133
+ }
5134
+ }
5135
+ }
5136
+ return out;
5137
+ }
4107
5138
  //# sourceMappingURL=language-sources-pass.js.map