circle-ir 3.129.0 → 3.131.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,10 @@ 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
+ }
214
218
  }
215
219
  // -- Python: safe-handler sanitizer detectors (cognium-dev #114 Sprint 31) --
216
220
  if (language === 'python') {
@@ -232,6 +236,14 @@ export class LanguageSourcesPass {
232
236
  for (const finding of pyMisconfigFindings) {
233
237
  ctx.addFinding(finding);
234
238
  }
239
+ // Sprint 81 (#189): Python xss — Flask string-concat / f-string returns
240
+ // and Jinja Markup wrap bypass.
241
+ for (const finding of findPythonFlaskStringConcatXssFindings(code, graph.ir.meta.file)) {
242
+ ctx.addFinding(finding);
243
+ }
244
+ for (const finding of findPythonJinjaMarkupXssFindings(code, graph.ir.meta.file)) {
245
+ ctx.addFinding(finding);
246
+ }
235
247
  }
236
248
  // -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
237
249
  if (language === 'rust') {
@@ -272,10 +284,22 @@ export class LanguageSourcesPass {
272
284
  additionalSanitizers.push(...findJsWrapperFunctionSanitizers(code));
273
285
  // Sprint 75 (#216 Pattern D): JS SSRF allow-list guard (var-aware).
274
286
  additionalSanitizers.push(...findJsSsrfAllowlistGuardSanitizers(code));
287
+ // Sprint 77b (#216 Pattern X): JS/TS argv-form execFile/spawn and
288
+ // parameterized SQL placeholder sanitizers.
289
+ additionalSanitizers.push(...findJsArgvFormExecSanitizers(code));
290
+ additionalSanitizers.push(...findJsParameterizedSqlSanitizers(code));
275
291
  // Sprint 78 (#190): JS misconfig pattern findings — libxmljs noent:true.
276
292
  for (const finding of findJsPatternFindings(code, graph.ir.meta.file)) {
277
293
  ctx.addFinding(finding);
278
294
  }
295
+ // Sprint 81 (#189): JS/TS xss — Vue v-html directive tied to tainted
296
+ // template binding, and Angular DomSanitizer.bypassSecurityTrust*.
297
+ for (const finding of findJsVueVHtmlXssFindings(code, graph.ir.meta.file)) {
298
+ ctx.addFinding(finding);
299
+ }
300
+ for (const finding of findTsAngularBypassXssFindings(code, graph.ir.meta.file)) {
301
+ ctx.addFinding(finding);
302
+ }
279
303
  }
280
304
  // -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
281
305
  // fromJson recognized as ETE terminator (does not affect
@@ -293,6 +317,12 @@ export class LanguageSourcesPass {
293
317
  for (const finding of findJavaPatternFindings(code, graph.ir.meta.file)) {
294
318
  ctx.addFinding(finding);
295
319
  }
320
+ // Sprint 81 (#189): Java xss — HttpServletResponse.getWriter().{print,
321
+ // println,write} receiver-chain that the configured PrintWriter sink
322
+ // doesn't resolve.
323
+ for (const finding of findJavaResponseWriterXssFindings(code, graph.ir.meta.file)) {
324
+ ctx.addFinding(finding);
325
+ }
296
326
  }
297
327
  // Sprint 70 (#151): cross-language env-secret → external-network exfiltration.
298
328
  // Pattern findings only — no taint flow required (composed-flow shape that
@@ -2969,6 +2999,121 @@ function findJsSsrfAllowlistGuardSanitizers(code) {
2969
2999
  }
2970
3000
  return sanitizers;
2971
3001
  }
3002
+ /**
3003
+ * JS/TS: argv-form `execFile`/`spawn`/`execFileSync`/`spawnSync` with
3004
+ * a string-literal program and an array literal argv (cognium-dev #216
3005
+ * Sprint 77b Pattern X).
3006
+ *
3007
+ * Pattern recognized:
3008
+ *
3009
+ * execFile('echo', ['--', arg], () => {});
3010
+ * spawn('grep', ['--', pattern, '/var/log/app.log']);
3011
+ * execFileSync('cat', [path]);
3012
+ *
3013
+ * Argv-form exec with a string-literal program splits arguments into
3014
+ * the argv slot with no shell interpretation, so a tainted argv element
3015
+ * cannot smuggle shell metacharacters into a separate command.
3016
+ *
3017
+ * The shell-via-argv form `execFile('sh', ['-c', tainted])` is excluded
3018
+ * since `-c` re-enables shell parsing of the subsequent argv slot. A
3019
+ * tainted-program slot `execFile(prog, [arg])` is NOT matched (TP-2
3020
+ * control), nor is the single-string `exec("cmd " + arg)` form (TP-1
3021
+ * control — `exec` itself spawns a shell regardless).
3022
+ *
3023
+ * Emits a `command_injection` + `external_taint_escape` sanitizer at
3024
+ * that line.
3025
+ */
3026
+ function findJsArgvFormExecSanitizers(code) {
3027
+ const sanitizers = [];
3028
+ const lines = code.split('\n');
3029
+ // execFile/spawn (+Sync) with a string-literal program and array argv.
3030
+ // Quoted program slot, then comma, then `[`.
3031
+ const argvExecRe = /\b(?:execFile|spawn)(?:Sync)?\s*\(\s*(?:'[^']*'|"[^"]*"|`[^`]*`)\s*,\s*\[/;
3032
+ // Shell-via-argv exclusion: program is sh/bash/etc. (possibly with a
3033
+ // leading path like /bin/ or /usr/bin/) AND first argv is "-c".
3034
+ const shellArgvRe = /\b(?:execFile|spawn)(?:Sync)?\s*\(\s*['"`](?:[\w./-]*\/)?(?:sh|bash|zsh|ksh|dash|cmd(?:\.exe)?|powershell|pwsh)['"`]\s*,\s*\[\s*['"`]-c['"`]/;
3035
+ for (let i = 0; i < lines.length; i++) {
3036
+ const text = lines[i];
3037
+ if (!argvExecRe.test(text))
3038
+ continue;
3039
+ if (shellArgvRe.test(text))
3040
+ continue;
3041
+ sanitizers.push({
3042
+ type: 'js_argv_form_exec',
3043
+ method: 'execFile',
3044
+ line: i + 1,
3045
+ sanitizes: ['command_injection', 'external_taint_escape'],
3046
+ });
3047
+ }
3048
+ return sanitizers;
3049
+ }
3050
+ /**
3051
+ * JS/TS: parameterized SQL query sanitizer (cognium-dev #216 Sprint 77b
3052
+ * Pattern X).
3053
+ *
3054
+ * Pattern recognized:
3055
+ *
3056
+ * await pool.query('SELECT * FROM users WHERE name = $1', [name]);
3057
+ * await conn.execute('SELECT * FROM users WHERE id = ?', [id]);
3058
+ * await client.query(`UPDATE u SET name = $1 WHERE id = $2`, [name, id]);
3059
+ *
3060
+ * The query string is a STRING-LITERAL (`'...'` / `"..."` / `` `...` ``
3061
+ * with no `${}` interpolation) that contains positional placeholders
3062
+ * (`$1`-style PostgreSQL or `?` MySQL/SQLite), AND a second argument
3063
+ * that begins with `[` (array literal of bound parameters). The driver
3064
+ * binds those parameters at the protocol layer rather than splicing
3065
+ * them into the SQL text, so the tainted values cannot become SQL.
3066
+ *
3067
+ * The concat form `pool.query("SELECT * FROM u WHERE n = '" + name + "'")`
3068
+ * and the interpolated template literal
3069
+ * `` pool.query(`SELECT * FROM u WHERE n = '${name}'`) `` are NOT
3070
+ * matched and continue to fire `sql_injection` (TP-1 / TP-2 controls).
3071
+ *
3072
+ * Emits a `sql_injection` + `external_taint_escape` sanitizer at that
3073
+ * line.
3074
+ */
3075
+ function findJsParameterizedSqlSanitizers(code) {
3076
+ const sanitizers = [];
3077
+ const lines = code.split('\n');
3078
+ // `.query(...)` / `.execute(...)` with a non-interpolating string
3079
+ // literal containing $N or ? placeholders, followed by `, [`.
3080
+ // Single quotes:
3081
+ const singleRe = /\.\s*(?:query|execute)\s*\(\s*'([^'\\]*(?:\\.[^'\\]*)*)'\s*,\s*\[/;
3082
+ // Double quotes:
3083
+ const doubleRe = /\.\s*(?:query|execute)\s*\(\s*"([^"\\]*(?:\\.[^"\\]*)*)"\s*,\s*\[/;
3084
+ // Backticks WITHOUT `${...}` interpolation:
3085
+ const tickRe = /\.\s*(?:query|execute)\s*\(\s*`([^`]*)`\s*,\s*\[/;
3086
+ const placeholderRe = /(?:\$\d+|\?)/;
3087
+ for (let i = 0; i < lines.length; i++) {
3088
+ const text = lines[i];
3089
+ let sql = null;
3090
+ const s = singleRe.exec(text);
3091
+ if (s)
3092
+ sql = s[1];
3093
+ if (sql == null) {
3094
+ const d = doubleRe.exec(text);
3095
+ if (d)
3096
+ sql = d[1];
3097
+ }
3098
+ if (sql == null) {
3099
+ const t = tickRe.exec(text);
3100
+ // Reject template literals that interpolate values.
3101
+ if (t && !/\$\{/.test(t[1]))
3102
+ sql = t[1];
3103
+ }
3104
+ if (sql == null)
3105
+ continue;
3106
+ if (!placeholderRe.test(sql))
3107
+ continue;
3108
+ sanitizers.push({
3109
+ type: 'js_parameterized_sql',
3110
+ method: 'query',
3111
+ line: i + 1,
3112
+ sanitizes: ['sql_injection', 'external_taint_escape'],
3113
+ });
3114
+ }
3115
+ return sanitizers;
3116
+ }
2972
3117
  /**
2973
3118
  * Java: Path.resolve(...).normalize() + startsWith(ROOT) guard
2974
3119
  * sanitizer (cognium-dev #216 Sprint 76 Pattern B).
@@ -4104,4 +4249,482 @@ export function findJsPatternFindings(code, file) {
4104
4249
  }
4105
4250
  return out;
4106
4251
  }
4252
+ // ---------------------------------------------------------------------------
4253
+ // Sprint 81 (#189) — xss-cluster FN coverage. Six per-language pattern
4254
+ // detectors that emit SastFinding{rule_id:'xss'} directly, bypassing the
4255
+ // source→sink→flow construction where the engine can't yet build a flow
4256
+ // (e.g. Python string-concat / f-string, Java receiver-chain typing).
4257
+ // ---------------------------------------------------------------------------
4258
+ /**
4259
+ * Go xss — `fmt.Fprint(f|ln)?(w, ...)` writing tainted data directly to an
4260
+ * `http.ResponseWriter`. Two-pass:
4261
+ *
4262
+ * 1. Discover ResponseWriter parameter names by scanning function
4263
+ * signatures `(<name> http.ResponseWriter, ...)`.
4264
+ * 2. For every `fmt.Fprint(f|ln)?(<name>, <format>, <args...>)` whose
4265
+ * first argument matches a discovered name, emit xss UNLESS one of
4266
+ * the args is wrapped in a recognized HTML escaper
4267
+ * (`html.EscapeString` / `template.HTMLEscapeString` /
4268
+ * `template.HTMLEscaper`).
4269
+ */
4270
+ export function findGoXssFindings(code, file) {
4271
+ const out = [];
4272
+ const lines = code.split('\n');
4273
+ const rwNames = new Set();
4274
+ const sigRe = /\(\s*([A-Za-z_]\w*)\s+http\.ResponseWriter\b/g;
4275
+ for (const line of lines) {
4276
+ let m;
4277
+ sigRe.lastIndex = 0;
4278
+ while ((m = sigRe.exec(line)) !== null)
4279
+ rwNames.add(m[1]);
4280
+ }
4281
+ if (rwNames.size === 0)
4282
+ return out;
4283
+ const escaperRe = /\b(?:html|template)\.(?:EscapeString|HTMLEscapeString|HTMLEscaper|JSEscapeString|URLQueryEscaper)\s*\(/;
4284
+ for (let i = 0; i < lines.length; i++) {
4285
+ const raw = lines[i];
4286
+ const trimmed = raw.trim();
4287
+ if (!trimmed || trimmed.startsWith('//'))
4288
+ continue;
4289
+ const callRe = /\bfmt\.Fprint(?:f|ln)?\s*\(\s*([A-Za-z_]\w*)\s*,\s*([\s\S]*)\)\s*(?:\/\/.*)?$/;
4290
+ const m = trimmed.match(callRe);
4291
+ if (!m)
4292
+ continue;
4293
+ if (!rwNames.has(m[1]))
4294
+ continue;
4295
+ const tail = m[2];
4296
+ if (escaperRe.test(tail))
4297
+ continue;
4298
+ // Require at least one identifier in tail outside string literals.
4299
+ if (!/[A-Za-z_]\w*/.test(tail.replace(/"[^"]*"|`[^`]*`/g, '')))
4300
+ continue;
4301
+ out.push({
4302
+ id: `xss-${file}-${i + 1}`,
4303
+ pass: 'language-sources',
4304
+ category: 'security',
4305
+ rule_id: 'xss',
4306
+ cwe: 'CWE-79',
4307
+ severity: 'high',
4308
+ level: 'error',
4309
+ message: 'Reflected XSS: fmt.Fprint writes data to http.ResponseWriter ' +
4310
+ 'without HTML escaping. Wrap user-controlled args with ' +
4311
+ 'html.EscapeString / template.HTMLEscapeString.',
4312
+ file,
4313
+ line: i + 1,
4314
+ snippet: trimmed.substring(0, 100),
4315
+ });
4316
+ }
4317
+ return out;
4318
+ }
4319
+ /**
4320
+ * Java xss — `<recv>.getWriter().{print,println,write,printf,format,append}
4321
+ * (<arg>)` chained call where `<recv>` is typed `HttpServletResponse`.
4322
+ * The receiver-chain form bypasses the configured `PrintWriter.print`
4323
+ * sink because the engine doesn't yet trace `HttpServletResponse
4324
+ * .getWriter()` → `PrintWriter` type resolution.
4325
+ *
4326
+ * Conservative: only fires when the receiver token matches a parameter
4327
+ * named with the `HttpServletResponse` type AND no recognized HTML
4328
+ * encoder wraps the argument.
4329
+ */
4330
+ export function findJavaResponseWriterXssFindings(code, file) {
4331
+ const out = [];
4332
+ const lines = code.split('\n');
4333
+ const respNames = new Set();
4334
+ const sigRe = /\bHttpServletResponse\s+([A-Za-z_]\w*)\b/g;
4335
+ for (const line of lines) {
4336
+ let m;
4337
+ sigRe.lastIndex = 0;
4338
+ while ((m = sigRe.exec(line)) !== null)
4339
+ respNames.add(m[1]);
4340
+ }
4341
+ if (respNames.size === 0)
4342
+ return out;
4343
+ 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*\(/;
4344
+ for (let i = 0; i < lines.length; i++) {
4345
+ const raw = lines[i];
4346
+ const trimmed = raw.trim();
4347
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
4348
+ continue;
4349
+ 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*(?:\/\/.*)?$/;
4350
+ const m = trimmed.match(chainRe);
4351
+ if (!m)
4352
+ continue;
4353
+ const recv = m[1];
4354
+ if (!respNames.has(recv))
4355
+ continue;
4356
+ const args = m[3];
4357
+ if (safeWrapRe.test(args))
4358
+ continue;
4359
+ // Skip when args is a pure string-literal call like `print("hello")`.
4360
+ // Anything else (`+`-concat, bare identifier, method call) is a write
4361
+ // of dynamic content that should have gone through an HTML encoder.
4362
+ const argsTrim = args.trim();
4363
+ if (/^"(?:\\.|[^"\\])*"$/.test(argsTrim))
4364
+ continue;
4365
+ if (!/[A-Za-z_]\w*/.test(argsTrim))
4366
+ continue;
4367
+ out.push({
4368
+ id: `xss-${file}-${i + 1}`,
4369
+ pass: 'language-sources',
4370
+ category: 'security',
4371
+ rule_id: 'xss',
4372
+ cwe: 'CWE-79',
4373
+ severity: 'high',
4374
+ level: 'error',
4375
+ message: 'Reflected XSS: HttpServletResponse.getWriter().' +
4376
+ m[2] +
4377
+ '(...) writes data to the response without HTML escaping. ' +
4378
+ 'Wrap user-controlled values with OWASP Encode.forHtml / ' +
4379
+ 'StringEscapeUtils.escapeHtml4 / Jsoup.clean.',
4380
+ file,
4381
+ line: i + 1,
4382
+ snippet: trimmed.substring(0, 100),
4383
+ });
4384
+ }
4385
+ return out;
4386
+ }
4387
+ /**
4388
+ * Vue xss — `template: '<...v-html="<var>"...>'` directive binding to a
4389
+ * variable that is sourced from a tainted location (URLSearchParams,
4390
+ * location.search/hash, route.query/params, fetch, etc.).
4391
+ *
4392
+ * Conservative: when the bound variable is a literal initializer
4393
+ * (`<var>: 'static'`) or sourced from a non-recognized location, no
4394
+ * finding is emitted.
4395
+ */
4396
+ export function findJsVueVHtmlXssFindings(code, file) {
4397
+ const out = [];
4398
+ const lines = code.split('\n');
4399
+ 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/;
4400
+ const tplRe = /\btemplate\s*:\s*(['"`])([\s\S]*?)\1/g;
4401
+ let tm;
4402
+ const boundVars = new Set();
4403
+ while ((tm = tplRe.exec(code)) !== null) {
4404
+ const tpl = tm[2];
4405
+ const vhRe = /\bv-html\s*=\s*"([^"]+)"/g;
4406
+ let vm;
4407
+ while ((vm = vhRe.exec(tpl)) !== null) {
4408
+ const expr = vm[1].trim();
4409
+ const idMatch = expr.match(/^([A-Za-z_$][\w$]*)/);
4410
+ if (idMatch)
4411
+ boundVars.add(idMatch[1]);
4412
+ }
4413
+ }
4414
+ if (boundVars.size === 0)
4415
+ return out;
4416
+ // Build a set of variables transitively tainted by a recognized source
4417
+ // pattern: `const params = new URLSearchParams(...)` taints `params`,
4418
+ // then `const q = params.get('q')` taints `q` (one-hop).
4419
+ const taintedSet = new Set();
4420
+ const assignRe = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g;
4421
+ let am;
4422
+ // First pass: direct source-pattern assignments.
4423
+ assignRe.lastIndex = 0;
4424
+ while ((am = assignRe.exec(code)) !== null) {
4425
+ if (sourceRe.test(am[2]))
4426
+ taintedSet.add(am[1]);
4427
+ }
4428
+ // Second pass: one-hop through tainted vars (limit 3 iterations).
4429
+ for (let pass = 0; pass < 3; pass++) {
4430
+ assignRe.lastIndex = 0;
4431
+ const before = taintedSet.size;
4432
+ while ((am = assignRe.exec(code)) !== null) {
4433
+ if (taintedSet.has(am[1]))
4434
+ continue;
4435
+ for (const tv of taintedSet) {
4436
+ if (new RegExp(`\\b${tv}\\b`).test(am[2])) {
4437
+ taintedSet.add(am[1]);
4438
+ break;
4439
+ }
4440
+ }
4441
+ }
4442
+ if (taintedSet.size === before)
4443
+ break;
4444
+ }
4445
+ const taintedBindings = new Set();
4446
+ for (const v of boundVars) {
4447
+ const bindRe = new RegExp(`\\b${v}\\s*[:=]\\s*([^,;\\n]+)`);
4448
+ for (const line of lines) {
4449
+ const m = line.match(bindRe);
4450
+ if (!m)
4451
+ continue;
4452
+ const val = m[1];
4453
+ if (sourceRe.test(val)) {
4454
+ taintedBindings.add(v);
4455
+ break;
4456
+ }
4457
+ let found = false;
4458
+ for (const tv of taintedSet) {
4459
+ if (new RegExp(`\\b${tv}\\b`).test(val)) {
4460
+ taintedBindings.add(v);
4461
+ found = true;
4462
+ break;
4463
+ }
4464
+ }
4465
+ if (found)
4466
+ break;
4467
+ }
4468
+ const propsRe = new RegExp(`\\bprops\\s*:\\s*\\[[^\\]]*['"\`]${v}['"\`][^\\]]*\\]`);
4469
+ if (propsRe.test(code))
4470
+ taintedBindings.add(v);
4471
+ }
4472
+ if (taintedBindings.size === 0)
4473
+ return out;
4474
+ for (let i = 0; i < lines.length; i++) {
4475
+ const raw = lines[i];
4476
+ if (!/v-html\s*=/.test(raw))
4477
+ continue;
4478
+ const vm = raw.match(/v-html\s*=\s*"([^"]+)"/);
4479
+ if (!vm)
4480
+ continue;
4481
+ const idMatch = vm[1].trim().match(/^([A-Za-z_$][\w$]*)/);
4482
+ if (!idMatch || !taintedBindings.has(idMatch[1]))
4483
+ continue;
4484
+ out.push({
4485
+ id: `xss-${file}-${i + 1}`,
4486
+ pass: 'language-sources',
4487
+ category: 'security',
4488
+ rule_id: 'xss',
4489
+ cwe: 'CWE-79',
4490
+ severity: 'high',
4491
+ level: 'error',
4492
+ message: 'Vue v-html XSS: directive binds to "' +
4493
+ idMatch[1] +
4494
+ '" which is sourced from user-controlled input. Use {{ }} ' +
4495
+ 'interpolation (auto-escapes) or sanitize the HTML with ' +
4496
+ 'DOMPurify before binding.',
4497
+ file,
4498
+ line: i + 1,
4499
+ snippet: raw.trim().substring(0, 100),
4500
+ });
4501
+ }
4502
+ return out;
4503
+ }
4504
+ /**
4505
+ * Angular xss — `<recv>.bypassSecurityTrust(Html|Script|Url|ResourceUrl|
4506
+ * Style)(<arg>)` where `<recv>` is typed `DomSanitizer`. Skip when
4507
+ * `<arg>` is a string literal (intentional safe-by-author escape hatch).
4508
+ */
4509
+ export function findTsAngularBypassXssFindings(code, file) {
4510
+ const out = [];
4511
+ const lines = code.split('\n');
4512
+ const sanitizerNames = new Set();
4513
+ const declRe = /\b(?:public|private|protected|readonly|\s)?\s*([A-Za-z_$][\w$]*)\s*:\s*DomSanitizer\b/g;
4514
+ let m;
4515
+ declRe.lastIndex = 0;
4516
+ while ((m = declRe.exec(code)) !== null)
4517
+ sanitizerNames.add(m[1]);
4518
+ if (sanitizerNames.size === 0)
4519
+ return out;
4520
+ const assignRe = /\bthis\s*\.\s*([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\b/g;
4521
+ let am;
4522
+ assignRe.lastIndex = 0;
4523
+ while ((am = assignRe.exec(code)) !== null) {
4524
+ if (sanitizerNames.has(am[2]))
4525
+ sanitizerNames.add(am[1]);
4526
+ }
4527
+ for (let i = 0; i < lines.length; i++) {
4528
+ const raw = lines[i];
4529
+ const trimmed = raw.trim();
4530
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
4531
+ continue;
4532
+ const callRe = /\b(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\.\s*bypassSecurityTrust(Html|Script|Url|ResourceUrl|Style)\s*\(([\s\S]*?)\)/;
4533
+ const cm = trimmed.match(callRe);
4534
+ if (!cm)
4535
+ continue;
4536
+ const recv = cm[1];
4537
+ if (!sanitizerNames.has(recv))
4538
+ continue;
4539
+ const arg = cm[3].trim();
4540
+ if (/^(['"`])[^'"`]*\1$/.test(arg))
4541
+ continue;
4542
+ out.push({
4543
+ id: `xss-${file}-${i + 1}`,
4544
+ pass: 'language-sources',
4545
+ category: 'security',
4546
+ rule_id: 'xss',
4547
+ cwe: 'CWE-79',
4548
+ severity: 'high',
4549
+ level: 'error',
4550
+ message: 'Angular XSS: DomSanitizer.bypassSecurityTrust' +
4551
+ cm[2] +
4552
+ '() opts out of Angular\'s built-in sanitization for a ' +
4553
+ 'non-literal argument. Prefer leaving Angular\'s default ' +
4554
+ 'sanitizer in place, or sanitize the input with DOMPurify ' +
4555
+ 'before bypassing.',
4556
+ file,
4557
+ line: i + 1,
4558
+ snippet: trimmed.substring(0, 100),
4559
+ });
4560
+ }
4561
+ return out;
4562
+ }
4563
+ /**
4564
+ * Python Flask xss — route function returning HTML built from a
4565
+ * `request.<args|form|values|files>.get(...)` value via string concat,
4566
+ * f-string, %, or .format(). Bypasses the engine's flow construction
4567
+ * gap for these string ops.
4568
+ *
4569
+ * Conservative: requires a Flask-style route decorator (`@<x>.route(`)
4570
+ * above the function; skip when the variable is escape()-wrapped.
4571
+ */
4572
+ export function findPythonFlaskStringConcatXssFindings(code, file) {
4573
+ const out = [];
4574
+ const lines = code.split('\n');
4575
+ const fns = [];
4576
+ const defRe = /^(\s*)def\s+([A-Za-z_]\w*)\s*\(/;
4577
+ for (let i = 0; i < lines.length; i++) {
4578
+ const raw = lines[i];
4579
+ const dm = raw.match(defRe);
4580
+ if (!dm)
4581
+ continue;
4582
+ const indent = dm[1].length;
4583
+ let hasRoute = false;
4584
+ for (let j = i - 1; j >= 0; j--) {
4585
+ const prev = lines[j].trim();
4586
+ if (prev === '' || prev.startsWith('#'))
4587
+ continue;
4588
+ if (!prev.startsWith('@'))
4589
+ break;
4590
+ if (/@\s*[A-Za-z_]\w*\s*\.\s*route\s*\(/.test(prev) ||
4591
+ /@\s*route\s*\(/.test(prev)) {
4592
+ hasRoute = true;
4593
+ }
4594
+ }
4595
+ if (!hasRoute)
4596
+ continue;
4597
+ let end = lines.length;
4598
+ for (let k = i + 1; k < lines.length; k++) {
4599
+ const ln = lines[k];
4600
+ if (!ln.trim())
4601
+ continue;
4602
+ const ind = ln.match(/^(\s*)/)?.[1].length ?? 0;
4603
+ if (ind <= indent) {
4604
+ end = k;
4605
+ break;
4606
+ }
4607
+ }
4608
+ fns.push({ name: dm[2], startLine: i + 1, endLine: end });
4609
+ }
4610
+ if (fns.length === 0)
4611
+ return out;
4612
+ const requestSourceRe = /\b([A-Za-z_]\w*)\s*=\s*request\s*\.\s*(?:args|form|values|files|json|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/;
4613
+ const escapeWrapRe = /\b(?:html\.escape|markupsafe\.escape|escape|markupsafe\.Markup\s*\.\s*escape|werkzeug\.utils\.escape)\s*\(/;
4614
+ for (const fn of fns) {
4615
+ const taintedVars = new Set();
4616
+ for (let li = fn.startLine; li < fn.endLine; li++) {
4617
+ const raw = lines[li];
4618
+ const rm = raw.match(requestSourceRe);
4619
+ if (rm)
4620
+ taintedVars.add(rm[1]);
4621
+ }
4622
+ for (let li = fn.startLine; li < fn.endLine; li++) {
4623
+ const raw = lines[li];
4624
+ const trimmed = raw.trim();
4625
+ if (!trimmed || trimmed.startsWith('#'))
4626
+ continue;
4627
+ const retMatch = trimmed.match(/^(?:return|yield)\s+(.+)$/);
4628
+ if (!retMatch)
4629
+ continue;
4630
+ const expr = retMatch[1];
4631
+ const inlineSource = /\brequest\s*\.\s*(?:args|form|values|files|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/.test(expr);
4632
+ const hasTaintedVar = [...taintedVars].some(v => new RegExp(`\\b${v}\\b`).test(expr));
4633
+ if (!hasTaintedVar && !inlineSource)
4634
+ continue;
4635
+ if (escapeWrapRe.test(expr))
4636
+ continue;
4637
+ const buildsHtml = (/['"`]\s*\+/.test(expr) || /\+\s*['"`]/.test(expr)) ||
4638
+ // f-string with interpolation. Allow any non-`{` char between the
4639
+ // opening quote and the first `{` (Python f-strings may embed `"`
4640
+ // when single-quoted and vice-versa, e.g. f'<a href="{u}">').
4641
+ /\bf['"`][^{\n]*\{[^}]+\}/.test(expr) ||
4642
+ /['"`]\s*%\s*[^=]/.test(expr) ||
4643
+ /['"`]\.\s*format\s*\(/.test(expr);
4644
+ if (!buildsHtml)
4645
+ continue;
4646
+ out.push({
4647
+ id: `xss-${file}-${li + 1}`,
4648
+ pass: 'language-sources',
4649
+ category: 'security',
4650
+ rule_id: 'xss',
4651
+ cwe: 'CWE-79',
4652
+ severity: 'high',
4653
+ level: 'error',
4654
+ message: 'Reflected XSS: Flask route returns HTML built from request ' +
4655
+ 'input via string concatenation / f-string / format. Wrap ' +
4656
+ 'user input with markupsafe.escape() or render via a ' +
4657
+ 'Jinja2 template (autoescape on by default).',
4658
+ file,
4659
+ line: li + 1,
4660
+ snippet: trimmed.substring(0, 100),
4661
+ });
4662
+ break;
4663
+ }
4664
+ }
4665
+ return out;
4666
+ }
4667
+ /**
4668
+ * Python Jinja2 Markup-bypass xss — `Markup(<var>)` where `<var>` is
4669
+ * request-sourced, then passed into a `<X>.render(...)` call. The
4670
+ * `Markup` wrap deliberately disables Jinja autoescape for the value.
4671
+ *
4672
+ * Namespace-scoped alias tracking (Sprint 74 lesson): only treats
4673
+ * `Markup` as dangerous when imported from `markupsafe` or `flask`,
4674
+ * and only when no user-defined `class Markup` shadows the import.
4675
+ */
4676
+ export function findPythonJinjaMarkupXssFindings(code, file) {
4677
+ const out = [];
4678
+ const lines = code.split('\n');
4679
+ const importRe = /^\s*from\s+(?:markupsafe|flask)\s+import\s+(?:[^#\n]*\b)?Markup\b/m;
4680
+ const classDefRe = /^\s*class\s+Markup\b/m;
4681
+ if (!importRe.test(code))
4682
+ return out;
4683
+ if (classDefRe.test(code))
4684
+ return out;
4685
+ const requestSourceRe = /\b([A-Za-z_]\w*)\s*=\s*request\s*\.\s*(?:args|form|values|files|json|cookies|headers)(?:\s*\.\s*get\s*\(|\s*\[)/;
4686
+ const taintedVars = new Set();
4687
+ for (const line of lines) {
4688
+ const m = line.match(requestSourceRe);
4689
+ if (m)
4690
+ taintedVars.add(m[1]);
4691
+ }
4692
+ if (taintedVars.size === 0)
4693
+ return out;
4694
+ if (!/\.\s*render\s*\(/.test(code))
4695
+ return out;
4696
+ const markupCallRe = /\bMarkup\s*\(\s*([A-Za-z_]\w*)\s*[,)]/g;
4697
+ for (let i = 0; i < lines.length; i++) {
4698
+ const raw = lines[i];
4699
+ const trimmed = raw.trim();
4700
+ if (!trimmed || trimmed.startsWith('#'))
4701
+ continue;
4702
+ let mm;
4703
+ markupCallRe.lastIndex = 0;
4704
+ while ((mm = markupCallRe.exec(trimmed)) !== null) {
4705
+ const argName = mm[1];
4706
+ if (!taintedVars.has(argName))
4707
+ continue;
4708
+ out.push({
4709
+ id: `xss-${file}-${i + 1}`,
4710
+ pass: 'language-sources',
4711
+ category: 'security',
4712
+ rule_id: 'xss',
4713
+ cwe: 'CWE-79',
4714
+ severity: 'high',
4715
+ level: 'error',
4716
+ message: 'Jinja2 autoescape bypass: Markup(' +
4717
+ argName +
4718
+ ') wraps a request-derived value, disabling autoescape when ' +
4719
+ 'rendered. Pass the raw value to render() and let Jinja2 ' +
4720
+ 'auto-escape, or sanitize with bleach.clean first.',
4721
+ file,
4722
+ line: i + 1,
4723
+ snippet: trimmed.substring(0, 100),
4724
+ });
4725
+ break;
4726
+ }
4727
+ }
4728
+ return out;
4729
+ }
4107
4730
  //# sourceMappingURL=language-sources-pass.js.map