circle-ir 3.128.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.
- package/dist/analysis/passes/language-sources-pass.d.ts +133 -0
- package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
- package/dist/analysis/passes/language-sources-pass.js +1017 -0
- package/dist/analysis/passes/language-sources-pass.js.map +1 -1
- package/dist/browser/circle-ir.js +672 -0
- package/package.json +1 -1
|
@@ -206,6 +206,15 @@ export class LanguageSourcesPass {
|
|
|
206
206
|
if (language === 'go') {
|
|
207
207
|
additionalSanitizers.push(...findGoMapAllowlistGuardSanitizers(code));
|
|
208
208
|
additionalSanitizers.push(...findGoHtmlTemplateImportSanitizers(code));
|
|
209
|
+
// Sprint 78 (#190): Go ECB-mode weak-crypto detection.
|
|
210
|
+
const goMisconfigFindings = findGoPatternFindings(code, graph.ir.meta.file);
|
|
211
|
+
for (const finding of goMisconfigFindings) {
|
|
212
|
+
ctx.addFinding(finding);
|
|
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
|
+
}
|
|
209
218
|
}
|
|
210
219
|
// -- Python: safe-handler sanitizer detectors (cognium-dev #114 Sprint 31) --
|
|
211
220
|
if (language === 'python') {
|
|
@@ -227,6 +236,14 @@ export class LanguageSourcesPass {
|
|
|
227
236
|
for (const finding of pyMisconfigFindings) {
|
|
228
237
|
ctx.addFinding(finding);
|
|
229
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
|
+
}
|
|
230
247
|
}
|
|
231
248
|
// -- Rust: safe-handler sanitizer detectors (cognium-dev #115 Sprint 31) --
|
|
232
249
|
if (language === 'rust') {
|
|
@@ -241,6 +258,21 @@ export class LanguageSourcesPass {
|
|
|
241
258
|
for (const finding of rustMisconfigFindings) {
|
|
242
259
|
ctx.addFinding(finding);
|
|
243
260
|
}
|
|
261
|
+
// Sprint 78 (#190): additional Rust misconfig pattern detectors —
|
|
262
|
+
// hardcoded-credential, insecure-cookie (builder chain),
|
|
263
|
+
// jwt-verify-disabled, weak-crypto (raw ECB block ops).
|
|
264
|
+
for (const finding of findRustHardcodedCredentialFindings(code, graph.ir.meta.file)) {
|
|
265
|
+
ctx.addFinding(finding);
|
|
266
|
+
}
|
|
267
|
+
for (const finding of findRustInsecureCookieFindings(code, graph.ir.meta.file)) {
|
|
268
|
+
ctx.addFinding(finding);
|
|
269
|
+
}
|
|
270
|
+
for (const finding of findRustJwtVerifyDisabledFindings(code, graph.ir.meta.file)) {
|
|
271
|
+
ctx.addFinding(finding);
|
|
272
|
+
}
|
|
273
|
+
for (const finding of findRustWeakCryptoEcbFindings(code, graph.ir.meta.file)) {
|
|
274
|
+
ctx.addFinding(finding);
|
|
275
|
+
}
|
|
244
276
|
}
|
|
245
277
|
// -- JavaScript/TypeScript: Sprint 73 (#216 Pattern A + B) — ETE
|
|
246
278
|
// sanitizer-chain recognition (JSON.parse / bcrypt.hash / csv
|
|
@@ -252,6 +284,22 @@ export class LanguageSourcesPass {
|
|
|
252
284
|
additionalSanitizers.push(...findJsWrapperFunctionSanitizers(code));
|
|
253
285
|
// Sprint 75 (#216 Pattern D): JS SSRF allow-list guard (var-aware).
|
|
254
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));
|
|
291
|
+
// Sprint 78 (#190): JS misconfig pattern findings — libxmljs noent:true.
|
|
292
|
+
for (const finding of findJsPatternFindings(code, graph.ir.meta.file)) {
|
|
293
|
+
ctx.addFinding(finding);
|
|
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
|
+
}
|
|
255
303
|
}
|
|
256
304
|
// -- Java: Sprint 73 (#216 Pattern A) — Jackson readValue / Gson
|
|
257
305
|
// fromJson recognized as ETE terminator (does not affect
|
|
@@ -263,6 +311,18 @@ export class LanguageSourcesPass {
|
|
|
263
311
|
additionalSanitizers.push(...findJavaInlineCrlfStripLogSanitizers(code));
|
|
264
312
|
// Sprint 77a (#216 Pattern X): argv-form exec sanitizer.
|
|
265
313
|
additionalSanitizers.push(...findJavaArgvFormExecSanitizers(code));
|
|
314
|
+
// Sprint 78 (#190): Java misconfig pattern findings —
|
|
315
|
+
// jwt-verify-disabled (auth0 JWT.decode bare) +
|
|
316
|
+
// tls-verify-disabled (empty-body X509TrustManager).
|
|
317
|
+
for (const finding of findJavaPatternFindings(code, graph.ir.meta.file)) {
|
|
318
|
+
ctx.addFinding(finding);
|
|
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
|
+
}
|
|
266
326
|
}
|
|
267
327
|
// Sprint 70 (#151): cross-language env-secret → external-network exfiltration.
|
|
268
328
|
// Pattern findings only — no taint flow required (composed-flow shape that
|
|
@@ -2939,6 +2999,121 @@ function findJsSsrfAllowlistGuardSanitizers(code) {
|
|
|
2939
2999
|
}
|
|
2940
3000
|
return sanitizers;
|
|
2941
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
|
+
}
|
|
2942
3117
|
/**
|
|
2943
3118
|
* Java: Path.resolve(...).normalize() + startsWith(ROOT) guard
|
|
2944
3119
|
* sanitizer (cognium-dev #216 Sprint 76 Pattern B).
|
|
@@ -3710,4 +3885,846 @@ export function findRustPatternFindings(code, file) {
|
|
|
3710
3885
|
}
|
|
3711
3886
|
return out;
|
|
3712
3887
|
}
|
|
3888
|
+
// ---------------------------------------------------------------------------
|
|
3889
|
+
// Sprint 78 (#190) — Tier-2 misconfig pattern extensions for Rust, Java, Go,
|
|
3890
|
+
// and JS that the dedicated misconfig passes don't yet recognize.
|
|
3891
|
+
// ---------------------------------------------------------------------------
|
|
3892
|
+
/**
|
|
3893
|
+
* Rust `hardcoded-credential` (CWE-798). Pattern:
|
|
3894
|
+
* `(pub|const|static) <NAME>: &str = "literal";` where NAME matches
|
|
3895
|
+
* /api[_]?key|secret|token|password|passwd|pwd|auth/i and the literal is
|
|
3896
|
+
* non-trivial (length > 8 and not a placeholder).
|
|
3897
|
+
*/
|
|
3898
|
+
export function findRustHardcodedCredentialFindings(code, file) {
|
|
3899
|
+
const out = [];
|
|
3900
|
+
const lines = code.split('\n');
|
|
3901
|
+
const re = /\b(?:pub\s+)?(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:\s*&\s*'?[a-z_]*\s*str\s*=\s*"([^"]+)"/;
|
|
3902
|
+
const nameRe = /(?:^|_)(?:API[_]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD|AUTH)(?:_|$)/i;
|
|
3903
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3904
|
+
const raw = lines[i];
|
|
3905
|
+
const trimmed = raw.trim();
|
|
3906
|
+
if (!trimmed || trimmed.startsWith('//'))
|
|
3907
|
+
continue;
|
|
3908
|
+
const m = trimmed.match(re);
|
|
3909
|
+
if (!m)
|
|
3910
|
+
continue;
|
|
3911
|
+
const name = m[1];
|
|
3912
|
+
const value = m[2];
|
|
3913
|
+
if (!nameRe.test(name))
|
|
3914
|
+
continue;
|
|
3915
|
+
if (value.length < 8)
|
|
3916
|
+
continue;
|
|
3917
|
+
if (/^(?:xxx|todo|fixme|placeholder|changeme)/i.test(value))
|
|
3918
|
+
continue;
|
|
3919
|
+
out.push({
|
|
3920
|
+
id: `hardcoded-credential-${file}-${i + 1}`,
|
|
3921
|
+
pass: 'language-sources',
|
|
3922
|
+
category: 'security',
|
|
3923
|
+
rule_id: 'hardcoded-credential',
|
|
3924
|
+
cwe: 'CWE-798',
|
|
3925
|
+
severity: 'high',
|
|
3926
|
+
level: 'error',
|
|
3927
|
+
message: `Hardcoded credential: const ${name} contains a literal secret value`,
|
|
3928
|
+
file,
|
|
3929
|
+
line: i + 1,
|
|
3930
|
+
snippet: trimmed.substring(0, 100),
|
|
3931
|
+
});
|
|
3932
|
+
}
|
|
3933
|
+
return out;
|
|
3934
|
+
}
|
|
3935
|
+
/**
|
|
3936
|
+
* Rust `insecure-cookie` (CWE-1004 / CWE-614). Pattern:
|
|
3937
|
+
* `Cookie::build(...)` chain that explicitly calls `.secure(false)` or
|
|
3938
|
+
* `.http_only(false)` (or both). The dedicated `insecure-cookie-pass.ts`
|
|
3939
|
+
* handles the `format!("Set-Cookie: ...")` shape but not the actix-web
|
|
3940
|
+
* builder chain.
|
|
3941
|
+
*/
|
|
3942
|
+
export function findRustInsecureCookieFindings(code, file) {
|
|
3943
|
+
const out = [];
|
|
3944
|
+
const lines = code.split('\n');
|
|
3945
|
+
const builderRe = /\bCookie\s*::\s*build\s*\(/;
|
|
3946
|
+
const insecureFlagRe = /\.\s*(?:secure|http_only)\s*\(\s*false\s*\)/;
|
|
3947
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3948
|
+
const raw = lines[i];
|
|
3949
|
+
const trimmed = raw.trim();
|
|
3950
|
+
if (!trimmed || trimmed.startsWith('//'))
|
|
3951
|
+
continue;
|
|
3952
|
+
if (!builderRe.test(trimmed))
|
|
3953
|
+
continue;
|
|
3954
|
+
if (!insecureFlagRe.test(trimmed))
|
|
3955
|
+
continue;
|
|
3956
|
+
out.push({
|
|
3957
|
+
id: `insecure-cookie-${file}-${i + 1}`,
|
|
3958
|
+
pass: 'language-sources',
|
|
3959
|
+
category: 'security',
|
|
3960
|
+
rule_id: 'insecure-cookie',
|
|
3961
|
+
cwe: 'CWE-1004',
|
|
3962
|
+
severity: 'medium',
|
|
3963
|
+
level: 'warning',
|
|
3964
|
+
message: 'Insecure cookie: Cookie::build chain disables Secure / HttpOnly flag(s)',
|
|
3965
|
+
file,
|
|
3966
|
+
line: i + 1,
|
|
3967
|
+
snippet: trimmed.substring(0, 100),
|
|
3968
|
+
});
|
|
3969
|
+
}
|
|
3970
|
+
return out;
|
|
3971
|
+
}
|
|
3972
|
+
/**
|
|
3973
|
+
* Rust `jwt-verify-disabled` (CWE-347). Pattern:
|
|
3974
|
+
* `.insecure_disable_signature_validation()` method call on a
|
|
3975
|
+
* jsonwebtoken `Validation` value. Any presence of this method
|
|
3976
|
+
* disables the signature check.
|
|
3977
|
+
*/
|
|
3978
|
+
export function findRustJwtVerifyDisabledFindings(code, file) {
|
|
3979
|
+
const out = [];
|
|
3980
|
+
const lines = code.split('\n');
|
|
3981
|
+
const re = /\.\s*insecure_disable_signature_validation\s*\(/;
|
|
3982
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3983
|
+
const raw = lines[i];
|
|
3984
|
+
const trimmed = raw.trim();
|
|
3985
|
+
if (!trimmed || trimmed.startsWith('//'))
|
|
3986
|
+
continue;
|
|
3987
|
+
if (!re.test(trimmed))
|
|
3988
|
+
continue;
|
|
3989
|
+
out.push({
|
|
3990
|
+
id: `jwt-verify-disabled-${file}-${i + 1}`,
|
|
3991
|
+
pass: 'language-sources',
|
|
3992
|
+
category: 'security',
|
|
3993
|
+
rule_id: 'jwt-verify-disabled',
|
|
3994
|
+
cwe: 'CWE-347',
|
|
3995
|
+
severity: 'critical',
|
|
3996
|
+
level: 'error',
|
|
3997
|
+
message: 'JWT signature verification disabled: ' +
|
|
3998
|
+
'Validation::insecure_disable_signature_validation() forfeits signature enforcement',
|
|
3999
|
+
file,
|
|
4000
|
+
line: i + 1,
|
|
4001
|
+
snippet: trimmed.substring(0, 100),
|
|
4002
|
+
});
|
|
4003
|
+
}
|
|
4004
|
+
return out;
|
|
4005
|
+
}
|
|
4006
|
+
/**
|
|
4007
|
+
* Rust `weak-crypto` (CWE-327). Pattern (raw ECB via `aes` crate):
|
|
4008
|
+
* any line that calls `.encrypt_block(` or `.decrypt_block(` on a
|
|
4009
|
+
* block-cipher receiver constructed via `Aes128::new` / `Aes192::new` /
|
|
4010
|
+
* `Aes256::new` / `Aes128Ecb` / `Aes256Ecb`. We collect the cipher
|
|
4011
|
+
* constructor lines in a first pass (variable name → seen) and emit
|
|
4012
|
+
* on every `.encrypt_block(` / `.decrypt_block(` line in the same file.
|
|
4013
|
+
*
|
|
4014
|
+
* The wrapped CBC/GCM/CTR forms go through `Cbc::<Aes128, ...>::new`
|
|
4015
|
+
* or `Aes128Gcm::new` — those do NOT call `.encrypt_block` directly
|
|
4016
|
+
* and so are not matched.
|
|
4017
|
+
*/
|
|
4018
|
+
export function findRustWeakCryptoEcbFindings(code, file) {
|
|
4019
|
+
const out = [];
|
|
4020
|
+
const lines = code.split('\n');
|
|
4021
|
+
const ctorRe = /\bAes(?:128|192|256)(?:Ecb)?\s*::\s*new\s*\(/;
|
|
4022
|
+
const blockOpRe = /\.\s*(encrypt_block|decrypt_block)\s*\(/;
|
|
4023
|
+
// First: confirm the file uses raw block-cipher construction (Aes*::new)
|
|
4024
|
+
// — without that, a stray `.encrypt_block(` on some other type isn't
|
|
4025
|
+
// necessarily ECB.
|
|
4026
|
+
let sawCtor = false;
|
|
4027
|
+
for (const line of lines) {
|
|
4028
|
+
if (ctorRe.test(line)) {
|
|
4029
|
+
sawCtor = true;
|
|
4030
|
+
break;
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
if (!sawCtor)
|
|
4034
|
+
return out;
|
|
4035
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4036
|
+
const raw = lines[i];
|
|
4037
|
+
const trimmed = raw.trim();
|
|
4038
|
+
if (!trimmed || trimmed.startsWith('//'))
|
|
4039
|
+
continue;
|
|
4040
|
+
if (!blockOpRe.test(trimmed))
|
|
4041
|
+
continue;
|
|
4042
|
+
out.push({
|
|
4043
|
+
id: `weak-crypto-${file}-${i + 1}`,
|
|
4044
|
+
pass: 'language-sources',
|
|
4045
|
+
category: 'security',
|
|
4046
|
+
rule_id: 'weak-crypto',
|
|
4047
|
+
cwe: 'CWE-327',
|
|
4048
|
+
severity: 'high',
|
|
4049
|
+
level: 'error',
|
|
4050
|
+
message: 'Weak crypto (ECB mode): raw Aes::encrypt_block/decrypt_block ' +
|
|
4051
|
+
'leaks repeating-block patterns. Use AES-GCM, AES-CTR, or ' +
|
|
4052
|
+
'AES-CBC with an HMAC.',
|
|
4053
|
+
file,
|
|
4054
|
+
line: i + 1,
|
|
4055
|
+
snippet: trimmed.substring(0, 100),
|
|
4056
|
+
});
|
|
4057
|
+
}
|
|
4058
|
+
return out;
|
|
4059
|
+
}
|
|
4060
|
+
/**
|
|
4061
|
+
* Java pattern findings — Sprint 78 (#190):
|
|
4062
|
+
* - `jwt-verify-disabled` (CWE-347): bare `JWT.decode(<token>)` on the
|
|
4063
|
+
* auth0 `com.auth0.jwt.JWT` class. `decode` is documented as
|
|
4064
|
+
* "decode the token without performing any verification"; only
|
|
4065
|
+
* `JWT.require(...).build().verify(token)` enforces the signature.
|
|
4066
|
+
* - `tls-verify-disabled` (CWE-295): anonymous `X509TrustManager`
|
|
4067
|
+
* implementation whose `checkServerTrusted` body is empty
|
|
4068
|
+
* (returns void without raising). This trust-nothing implementation
|
|
4069
|
+
* accepts every certificate.
|
|
4070
|
+
*/
|
|
4071
|
+
export function findJavaPatternFindings(code, file) {
|
|
4072
|
+
const out = [];
|
|
4073
|
+
const lines = code.split('\n');
|
|
4074
|
+
// jwt-verify-disabled: `JWT.decode(<expr>)`. Anchored to the `JWT.`
|
|
4075
|
+
// receiver to avoid matching unrelated `decode(` calls on Base64,
|
|
4076
|
+
// URLDecoder, etc. Guarded against the safer `JWT.require(...).build()
|
|
4077
|
+
// .verify(...)` chain by requiring `decode` to appear without
|
|
4078
|
+
// `.verify(` later on the same line.
|
|
4079
|
+
const jwtDecodeRe = /\bJWT\s*\.\s*decode\s*\(/;
|
|
4080
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4081
|
+
const raw = lines[i];
|
|
4082
|
+
const trimmed = raw.trim();
|
|
4083
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
|
|
4084
|
+
continue;
|
|
4085
|
+
if (!jwtDecodeRe.test(trimmed))
|
|
4086
|
+
continue;
|
|
4087
|
+
if (/\.\s*verify\s*\(/.test(trimmed))
|
|
4088
|
+
continue;
|
|
4089
|
+
out.push({
|
|
4090
|
+
id: `jwt-verify-disabled-${file}-${i + 1}-decode`,
|
|
4091
|
+
pass: 'language-sources',
|
|
4092
|
+
category: 'security',
|
|
4093
|
+
rule_id: 'jwt-verify-disabled',
|
|
4094
|
+
cwe: 'CWE-347',
|
|
4095
|
+
severity: 'critical',
|
|
4096
|
+
level: 'error',
|
|
4097
|
+
message: 'JWT signature not verified: auth0 `JWT.decode(token)` parses ' +
|
|
4098
|
+
'without checking the signature. Use `JWT.require(<algorithm>)' +
|
|
4099
|
+
'.build().verify(token)` to enforce verification.',
|
|
4100
|
+
file,
|
|
4101
|
+
line: i + 1,
|
|
4102
|
+
snippet: trimmed.substring(0, 100),
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
// tls-verify-disabled: anonymous X509TrustManager with empty
|
|
4106
|
+
// checkServerTrusted body. Two-pass: locate the anonymous-class start
|
|
4107
|
+
// line (`new X509TrustManager() {`), then scan ahead for the
|
|
4108
|
+
// `checkServerTrusted(...)` method signature whose `{...}` body is
|
|
4109
|
+
// empty (no `throw`, no `if`).
|
|
4110
|
+
const anonStartRe = /\bnew\s+X509TrustManager\s*\(\s*\)\s*\{/;
|
|
4111
|
+
const checkServerSig = /\bcheckServerTrusted\s*\([^)]*\)\s*(?:throws\s+[^\{]*)?\{\s*\}/;
|
|
4112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4113
|
+
const raw = lines[i];
|
|
4114
|
+
if (!anonStartRe.test(raw))
|
|
4115
|
+
continue;
|
|
4116
|
+
// Scan up to 15 lines ahead for the empty-body checkServerTrusted.
|
|
4117
|
+
const end = Math.min(lines.length, i + 16);
|
|
4118
|
+
let foundAt = -1;
|
|
4119
|
+
for (let j = i; j < end; j++) {
|
|
4120
|
+
if (checkServerSig.test(lines[j])) {
|
|
4121
|
+
foundAt = j;
|
|
4122
|
+
break;
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
if (foundAt < 0)
|
|
4126
|
+
continue;
|
|
4127
|
+
out.push({
|
|
4128
|
+
id: `tls-verify-disabled-${file}-${foundAt + 1}`,
|
|
4129
|
+
pass: 'language-sources',
|
|
4130
|
+
category: 'security',
|
|
4131
|
+
rule_id: 'tls-verify-disabled',
|
|
4132
|
+
cwe: 'CWE-295',
|
|
4133
|
+
severity: 'high',
|
|
4134
|
+
level: 'error',
|
|
4135
|
+
message: 'TLS certificate verification disabled: anonymous X509TrustManager ' +
|
|
4136
|
+
'with empty checkServerTrusted body accepts every certificate.',
|
|
4137
|
+
file,
|
|
4138
|
+
line: foundAt + 1,
|
|
4139
|
+
snippet: lines[foundAt].trim().substring(0, 100),
|
|
4140
|
+
});
|
|
4141
|
+
}
|
|
4142
|
+
return out;
|
|
4143
|
+
}
|
|
4144
|
+
/**
|
|
4145
|
+
* Go pattern findings — Sprint 78 (#190):
|
|
4146
|
+
* - `weak-crypto` (CWE-327): raw ECB usage via `aes.NewCipher(...)`
|
|
4147
|
+
* followed by a direct `<cipher>.Encrypt(` / `.Decrypt(` call on the
|
|
4148
|
+
* constructed value (no `cipher.NewCBCEncrypter` / `NewGCM` / `NewCTR`
|
|
4149
|
+
* wrapper). The Go stdlib `aes.Cipher` exposes `Encrypt` / `Decrypt`
|
|
4150
|
+
* that operate on a single 16-byte block — calling these directly is
|
|
4151
|
+
* ECB mode.
|
|
4152
|
+
*
|
|
4153
|
+
* Algorithm:
|
|
4154
|
+
* 1. Collect every `<v>, _ := aes.NewCipher(...)` cipher variable.
|
|
4155
|
+
* 2. If the file contains a `cipher.NewGCM(<v>)` / `cipher.NewCBC*(<v>)`
|
|
4156
|
+
* / `cipher.NewCTR(<v>)` wrapping line for that variable, skip it
|
|
4157
|
+
* (wrapped mode is not ECB).
|
|
4158
|
+
* 3. Otherwise emit on every `<v>.Encrypt(` / `<v>.Decrypt(` line.
|
|
4159
|
+
*/
|
|
4160
|
+
export function findGoPatternFindings(code, file) {
|
|
4161
|
+
const out = [];
|
|
4162
|
+
const lines = code.split('\n');
|
|
4163
|
+
const cipherVars = new Set();
|
|
4164
|
+
const ctorRe = /\b([a-zA-Z_]\w*)\s*(?:,\s*[a-zA-Z_]\w*)?\s*:?=\s*aes\.NewCipher\s*\(/;
|
|
4165
|
+
for (const line of lines) {
|
|
4166
|
+
const m = line.match(ctorRe);
|
|
4167
|
+
if (m)
|
|
4168
|
+
cipherVars.add(m[1]);
|
|
4169
|
+
}
|
|
4170
|
+
if (cipherVars.size === 0)
|
|
4171
|
+
return out;
|
|
4172
|
+
// Drop wrapped ciphers (CBC/GCM/CTR/OFB/CFB) — those are not ECB.
|
|
4173
|
+
for (const v of Array.from(cipherVars)) {
|
|
4174
|
+
const wrapRe = new RegExp(`\\bcipher\\.New(?:GCM|CBCEncrypter|CBCDecrypter|CTR|OFB|CFBEncrypter|CFBDecrypter)\\s*\\(\\s*${v}\\b`);
|
|
4175
|
+
for (const line of lines) {
|
|
4176
|
+
if (wrapRe.test(line)) {
|
|
4177
|
+
cipherVars.delete(v);
|
|
4178
|
+
break;
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
if (cipherVars.size === 0)
|
|
4183
|
+
return out;
|
|
4184
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4185
|
+
const raw = lines[i];
|
|
4186
|
+
const trimmed = raw.trim();
|
|
4187
|
+
if (!trimmed || trimmed.startsWith('//'))
|
|
4188
|
+
continue;
|
|
4189
|
+
for (const v of cipherVars) {
|
|
4190
|
+
const opRe = new RegExp(`\\b${v}\\s*\\.\\s*(?:Encrypt|Decrypt)\\s*\\(`);
|
|
4191
|
+
if (!opRe.test(trimmed))
|
|
4192
|
+
continue;
|
|
4193
|
+
out.push({
|
|
4194
|
+
id: `weak-crypto-${file}-${i + 1}`,
|
|
4195
|
+
pass: 'language-sources',
|
|
4196
|
+
category: 'security',
|
|
4197
|
+
rule_id: 'weak-crypto',
|
|
4198
|
+
cwe: 'CWE-327',
|
|
4199
|
+
severity: 'high',
|
|
4200
|
+
level: 'error',
|
|
4201
|
+
message: 'Weak crypto (ECB mode): raw aes.Cipher.Encrypt/Decrypt on a ' +
|
|
4202
|
+
'block leaks repeating-block patterns. Wrap with cipher.NewGCM, ' +
|
|
4203
|
+
'cipher.NewCTR, or cipher.NewCBCEncrypter + HMAC.',
|
|
4204
|
+
file,
|
|
4205
|
+
line: i + 1,
|
|
4206
|
+
snippet: trimmed.substring(0, 100),
|
|
4207
|
+
});
|
|
4208
|
+
break;
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
return out;
|
|
4212
|
+
}
|
|
4213
|
+
/**
|
|
4214
|
+
* JS pattern findings — Sprint 78 (#190):
|
|
4215
|
+
* - `xml-entity-expansion` (CWE-611 / CWE-776): libxmljs `parseXml`
|
|
4216
|
+
* (and `parseXmlString`) called with `{ noent: true }` resolves
|
|
4217
|
+
* external entities, enabling XXE / billion-laughs. The default is
|
|
4218
|
+
* `noent: false`; only the explicit-true form is unsafe.
|
|
4219
|
+
*/
|
|
4220
|
+
export function findJsPatternFindings(code, file) {
|
|
4221
|
+
const out = [];
|
|
4222
|
+
const lines = code.split('\n');
|
|
4223
|
+
const parseRe = /\blibxml(?:js)?\s*\.\s*parseXml(?:String)?\s*\(/;
|
|
4224
|
+
const noentTrueRe = /\bnoent\s*:\s*true\b/;
|
|
4225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4226
|
+
const raw = lines[i];
|
|
4227
|
+
const trimmed = raw.trim();
|
|
4228
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*'))
|
|
4229
|
+
continue;
|
|
4230
|
+
if (!parseRe.test(trimmed))
|
|
4231
|
+
continue;
|
|
4232
|
+
if (!noentTrueRe.test(trimmed))
|
|
4233
|
+
continue;
|
|
4234
|
+
out.push({
|
|
4235
|
+
id: `xml-entity-expansion-${file}-${i + 1}`,
|
|
4236
|
+
pass: 'language-sources',
|
|
4237
|
+
category: 'security',
|
|
4238
|
+
rule_id: 'xml-entity-expansion',
|
|
4239
|
+
cwe: 'CWE-611',
|
|
4240
|
+
severity: 'high',
|
|
4241
|
+
level: 'error',
|
|
4242
|
+
message: 'XML external entity resolution enabled: libxmljs parseXml ' +
|
|
4243
|
+
'called with `noent: true` resolves external entities (XXE / ' +
|
|
4244
|
+
'billion-laughs). Omit the flag or set `noent: false`.',
|
|
4245
|
+
file,
|
|
4246
|
+
line: i + 1,
|
|
4247
|
+
snippet: trimmed.substring(0, 100),
|
|
4248
|
+
});
|
|
4249
|
+
}
|
|
4250
|
+
return out;
|
|
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
|
+
}
|
|
3713
4730
|
//# sourceMappingURL=language-sources-pass.js.map
|