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.
- package/dist/analysis/config-loader.d.ts.map +1 -1
- package/dist/analysis/config-loader.js +4 -2
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/findings.d.ts.map +1 -1
- package/dist/analysis/findings.js +14 -7
- package/dist/analysis/findings.js.map +1 -1
- package/dist/analysis/passes/language-sources-pass.d.ts +124 -0
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +1031 -0
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
- package/dist/analysis/passes/taint-propagation-pass.js +12 -4
- package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
- package/dist/browser/circle-ir.js +722 -14
- package/dist/core/circle-ir-core.cjs +4 -2
- package/dist/core/circle-ir-core.js +4 -2
- package/package.json +1 -1
|
@@ -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
|