From acde89a38f4a0c47bb69e98225693f4f8f744c3d Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Wed, 19 Aug 2026 15:56:01 +0300 Subject: [PATCH 01/10] CWE-95 + frontend/template XSS precision --- rules/backend_javascript/backend_security.ron | 15 +- rules/html/django.ron | 37 +++++ rules/html/xss.ron | 5 + rules/javascript/backend_code_injection.ron | 5 +- rules/javascript/frontend_security.ron | 29 +++- rules/javascript/frontend_taint_security.ron | 52 ++---- tests/strictness/cwe95_xss_sink_precision.rs | 154 ++++++++++++++++++ tests/strictness/main.rs | 1 + .../django/fixtures/template_xss.html | 16 ++ .../javascript/cwe95_xss_sink_precision.js | 63 +++++++ 10 files changed, 319 insertions(+), 58 deletions(-) create mode 100644 rules/html/django.ron create mode 100644 tests/strictness/cwe95_xss_sink_precision.rs create mode 100644 tests/test_files/django/fixtures/template_xss.html create mode 100644 tests/test_files/javascript/cwe95_xss_sink_precision.js diff --git a/rules/backend_javascript/backend_security.ron b/rules/backend_javascript/backend_security.ron index 5e9375a..beb0cb8 100644 --- a/rules/backend_javascript/backend_security.ron +++ b/rules/backend_javascript/backend_security.ron @@ -474,25 +474,14 @@ ]), sinks: Some([ - // Direct code execution + // Eval injection only. Template engines / dynamic require are not CWE-95. "eval", "Function", "setTimeout", "setInterval", - - // VM module "vm.runInThisContext", "vm.runInNewContext", - "vm.runInContext", - - // Dynamic requires - "require", - "import", - - // Template engines (unsafe usage) - "ejs.render", - "handlebars.compile", - "mustache.render" + "vm.runInContext" ]), sanitizers: Some([ diff --git a/rules/html/django.ron b/rules/html/django.ron new file mode 100644 index 0000000..38695b5 --- /dev/null +++ b/rules/html/django.ron @@ -0,0 +1,37 @@ +( + rules: [ + // Autoescaped `{{ var }}` is not XSS. Flag `|safe`/`|mark_safe` only on + // request data. Gate `=` forces full-text match. Spaced filters (`| safe`) + // resolve as `{{`, so those patterns are `{{`-prefixed. + ( + id: Some("html-django-safe-request-xss"), + name: Some("Django |safe on request data"), + category: Some("xss"), + mode: "search", + patterns: Some([ + "django-safe-filter-sink=", + "*request.GET*|safe*", + "*request.POST*|safe*", + "*request.COOKIES*|safe*", + "*request.GET*|mark_safe*", + "*request.POST*|mark_safe*", + "*request.COOKIES*|mark_safe*", + "{{*request.GET*| safe*", + "{{*request.POST*| safe*", + "{{*request.COOKIES*| safe*", + "{{*request.GET*| mark_safe*", + "{{*request.POST*| mark_safe*", + "{{*request.COOKIES*| mark_safe*" + ]), + finding_type: Some("Cross-Site Scripting"), + severity: Some("High"), + confidence: Some("High"), + cwe_id: Some("cwe-79"), + description: Some("Request data passed through |safe / |mark_safe disables autoescape and is reflected XSS"), + file_types: Some(( + extensions: Some([".html", ".htm"]) + )), + tags: Some(["xss", "django", "cwe-79"]) + ) + ] +) diff --git a/rules/html/xss.ron b/rules/html/xss.ron index a100fbc..981197c 100644 --- a/rules/html/xss.ron +++ b/rules/html/xss.ron @@ -20,6 +20,11 @@ // All real sinks require a template literal (backtick) sink target, i.e. // dynamic/interpolated HTML, which avoids flagging assignments of static // string constants. + // + // Not sinks: HTMX `hx-swap="innerHTML"` (swap strategy, not reflected XSS), + // Django/Jinja default `{{ title }}` autoescape, or `json_script` / + // ` + +
{{ request.GET.q|safe }}
+ +
{{ request.COOKIES.sid| mark_safe }}
+ + diff --git a/tests/test_files/javascript/cwe95_xss_sink_precision.js b/tests/test_files/javascript/cwe95_xss_sink_precision.js new file mode 100644 index 0000000..8b92a5c --- /dev/null +++ b/tests/test_files/javascript/cwe95_xss_sink_precision.js @@ -0,0 +1,63 @@ +// CWE-95 vs CWE-79 sink precision. Eval-family sinks are CWE-95. +// HTML writes, HTMX, DOMPurify, and parse-only template helpers are not. + +// TP: eval / Function / setTimeout(string) with user input → CWE-95 +function evalUser(userInput) { + eval(userInput); +} + +function functionUser(userInput) { + const fn = new Function(userInput); + return fn(); +} + +function timeoutUser(userInput) { + setTimeout(userInput, 1000); +} + +function evalFromHash() { + eval(location.hash); +} + +// TP XSS, not CWE-95: tainted HTML write +function xssFromHash() { + document.body.innerHTML = location.hash; +} + +// TN CWE-95: sanitized HTML write +function sanitizedInnerHtml(userInput) { + const el = document.createElement('div'); + el.innerHTML = DOMPurify.sanitize(userInput); +} + +// TN CWE-95: parse-only helper (doghouse issues_table.js shape) +function htmlToElement(html) { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content.firstElementChild; +} + +// TN CWE-95: textContent → escaped innerHTML helper +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// TN CWE-95: HTMX trigger is not eval +function applyFilters() { + htmx.trigger('#applyFilterButton', 'click'); +} + +// TN CWE-95: Bootstrap / jQuery HTML without eval +function renderPanel(html) { + $('.collapse').html(html); + new bootstrap.Collapse(document.getElementById('panel')); +} + +// TN CWE-95: setTimeout function callback is not eval +function delayedPaint(userInput) { + setTimeout(function () { + document.getElementById('out').textContent = userInput; + }, 0); +} From 03d8a4ff9bf270201f685c472d035074c759cdb8 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Wed, 19 Aug 2026 19:11:54 +0300 Subject: [PATCH 02/10] Fix HTML-only scans so Django |safe rules actually run. Skip the taint pass when a pack has no taint rules, and match template text on auto-detected HTML so CLI/Fusion no longer drop search findings. Co-authored-by: Cursor --- src/language.rs | 48 +++++++++++--------- src/scanner/modes.rs | 14 ++++-- tests/integration/integration_tests.rs | 35 ++++++++++++++ tests/strictness/cwe95_xss_sink_precision.rs | 31 +++++++++++++ 4 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/language.rs b/src/language.rs index beb00b3..5b7e611 100644 --- a/src/language.rs +++ b/src/language.rs @@ -93,6 +93,27 @@ pub fn get_language_support_for_path( get_language_support(language_name) } +/// Match Django/Jinja filter text so search rules can key off `|safe` even +/// when auto-detect classifies the file as `html` (`.html` → html, not django). +#[cfg(any(feature = "html", feature = "django"))] +fn django_template_name_from_text(text: &str) -> Option<&str> { + if text.contains("|safe") { + Some("|safe") + } else if text.contains("|mark_safe") { + Some("|mark_safe") + } else if text.contains("{% autoescape off %}") { + Some("{% autoescape off %}") + } else if text.contains("{{") && text.contains("}}") { + Some("{{") + } else if text.contains("{% include") { + Some("{% include") + } else if text.contains("{{") || text.contains("{%") { + Some("django_template") + } else { + None + } +} + fn direct_named_child_of_kind<'a>(node: &Node<'a>, kind: &str) -> Option> { let mut cursor = node.walk(); let child = node.named_children(&mut cursor).find(|child| child.kind() == kind); @@ -538,7 +559,7 @@ impl LanguageSupport for HTMLLanguage { tree_sitter_html::LANGUAGE.into() } fn call_node_types(&self) -> &[&'static str] { - &["attribute", "start_tag", "script_element", "element"] + &["attribute", "start_tag", "script_element", "element", "text"] } fn get_function_name<'a>(&self, node: &Node, source: &'a [u8]) -> Option<&'a str> { @@ -548,6 +569,7 @@ impl LanguageSupport for HTMLLanguage { // names like `th:utext`, `th:replace`, or tag names like `textarea` // resolve as the matchable "function" name for search rules. match node.kind() { + "text" => django_template_name_from_text(get_node_text_slice(node, source)), "attribute" => node .child_by_field_name("name") .or_else(|| { @@ -566,6 +588,9 @@ impl LanguageSupport for HTMLLanguage { } fn get_arguments_node<'a>(&self, node: &'a Node) -> Option> { + if node.kind() == "text" { + return Some(*node); + } if node.kind() == "attribute" { if let Some(value_node) = node.child_by_field_name("value") { return Some(value_node); @@ -609,26 +634,7 @@ impl LanguageSupport for DjangoTemplateLanguage { fn get_function_name<'a>(&self, node: &Node, source: &'a [u8]) -> Option<&'a str> { match node.kind() { - "text" => { - let text = get_node_text_slice(node, source); - - // Check for Django template patterns (return static strings for consistent lifetimes) - if text.contains("|safe") { - Some("|safe") - } else if text.contains("|mark_safe") { - Some("|mark_safe") - } else if text.contains("{% autoescape off %}") { - Some("{% autoescape off %}") - } else if text.contains("{{") && text.contains("}}") { - Some("{{") - } else if text.contains("{% include") { - Some("{% include") - } else if text.contains("{{") || text.contains("{%") { - Some("django_template") - } else { - None - } - } + "text" => django_template_name_from_text(get_node_text_slice(node, source)), "attribute" => { node.child_by_field_name("name").map(|child| get_node_text_slice(&child, source)) } diff --git a/src/scanner/modes.rs b/src/scanner/modes.rs index b0f8a2b..2afd54a 100644 --- a/src/scanner/modes.rs +++ b/src/scanner/modes.rs @@ -542,9 +542,17 @@ pub fn run_taint_analysis_with_verbosity( let taint_rules_count = rules.rules.iter().filter(|r| r.is_taint_rule()).count(); if taint_rules_count == 0 { - return Err(anyhow::anyhow!( - "No taint flow rules found. Please ensure your rules contain rules with mode='taint'." - )); + // Combined default mode (simple + taint) visits this as a silent second + // pass. HTML/Django packs are search-only; aborting here discarded the + // search findings and made `sighthound file.html` exit non-zero. + // `--taint-analysis` (verbose_mode) still errors so the exclusive flag + // stays honest. + if verbose_mode { + return Err(anyhow::anyhow!( + "No taint flow rules found. Please ensure your rules contain rules with mode='taint'." + )); + } + return Ok(Vec::new()); } if show_progress && verbose_mode { print_taint_analysis_intro(root_dir, taint_rules_count, context.total_files); diff --git a/tests/integration/integration_tests.rs b/tests/integration/integration_tests.rs index 9c4dcec..7595cad 100644 --- a/tests/integration/integration_tests.rs +++ b/tests/integration/integration_tests.rs @@ -573,4 +573,39 @@ def run(data): let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Invalid severity level: 'invalid_level'"), "got: {stderr}"); } + + #[test] + fn html_only_default_scan_succeeds_and_flags_django_safe() { + let bin_path = if let Ok(path) = std::env::var("CARGO_BIN_EXE_sighthound") { + std::path::PathBuf::from(path) + } else { + std::path::PathBuf::from("target/debug/sighthound") + }; + if !bin_path.exists() { + return; + } + + let mut html_file = NamedTempFile::with_suffix(".html").expect("temp html"); + write!(html_file, "{}", include_str!("../test_files/django/fixtures/template_xss.html")) + .expect("write html"); + + let output = std::process::Command::new(&bin_path) + .args([html_file.path().to_str().unwrap(), "--output-format", "json"]) + .output() + .expect("failed to run sighthound"); + assert!( + output.status.success(), + "default scan of an HTML-only file must not fail when the pack has no taint rules: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("|safe") || stdout.contains("mark_safe"), + "django |safe TP must survive default (simple+taint) HTML scan, got: {stdout}" + ); + assert!( + !stdout.contains("No taint flow rules found"), + "taint-empty HTML packs must skip taint, not error: {stdout}" + ); + } } diff --git a/tests/strictness/cwe95_xss_sink_precision.rs b/tests/strictness/cwe95_xss_sink_precision.rs index b427bbb..1565914 100644 --- a/tests/strictness/cwe95_xss_sink_precision.rs +++ b/tests/strictness/cwe95_xss_sink_precision.rs @@ -152,3 +152,34 @@ fn django_autoescape_and_htmx_are_not_xss_but_safe_filter_is() { .collect::>() ); } + +#[test] +#[cfg(feature = "html")] +fn html_language_still_flags_django_safe_filter() { + // CLI auto-detect maps `.html` → `html`, not `django`. Search rules must + // still see `|safe` on text nodes. + let staging = stage_dir(); + stage_file( + staging.path(), + "tests/test_files/django/fixtures/template_xss.html", + "page.html", + &[], + ); + + let findings = scan_language_simple_with_rules( + staging.path(), + "html", + Rules::load_from_directory("rules/html/").expect("load html rules"), + ); + let xss: Vec<_> = findings.iter().filter(|f| is_xss(f)).cloned().collect(); + + assert!( + xss.iter().any(|f| f.snippet.contains("request.GET") && f.snippet.contains("|safe")), + "|safe on request.GET must be XSS when scanned as html, got: {:?}", + findings + .iter() + .map(|f| (f.line, f.finding_type.as_str(), f.snippet.as_str())) + .collect::>() + ); + assert_no_findings_in_range(&xss, 4, 10, "autoescape, json_script, hx-swap are not XSS"); +} From 258b9c0068ce438d9285e72ee0f77ea220715ebf Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Thu, 20 Aug 2026 10:56:25 +0300 Subject: [PATCH 03/10] Flag vm.runIn* and string timers as CWE-95, not escapeHtml helpers. Search-mode vm patterns and setInterval(string) TPs; treat textContent then innerHTML as a sanitizer. Identifier setTimeout callbacks stay quiet. Co-authored-by: Cursor --- .gitignore | 3 + build.rs | 21 +++++ rules/javascript/backend_code_injection.ron | 6 ++ rules/javascript/frontend_security.ron | 35 +++++-- rules/javascript/frontend_taint_security.ron | 16 ++-- src/common.rs | 90 ++++++++++++++++-- src/scanner/scanning_logic.rs | 27 +++++- src/scanner/taint_utils.rs | 27 +++++- src/scanner/utils.rs | 94 +++++++++++++++++++ tests/strictness/cwe95_xss_sink_precision.rs | 19 ++-- .../javascript/cwe95_xss_sink_precision.js | 44 ++++++++- 11 files changed, 343 insertions(+), 39 deletions(-) create mode 100644 build.rs diff --git a/.gitignore b/.gitignore index cd8b71e..24e5e5f 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,9 @@ release/ # External benchmark corpus (separate/large repo, cloned locally) /fusion-benchmarks/ +# Local linux/mac export from `./build_all_platforms.sh` (binary + copied rules) +/sighthound_release/ + # Generated benchmark scan artifacts (reproducible via bench/run_bench.py) /bench/results/ /bench/*.json diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0af8161 --- /dev/null +++ b/build.rs @@ -0,0 +1,21 @@ +fn main() { + emit_rerun("rules"); +} + +fn emit_rerun(path: &str) { + println!("cargo:rerun-if-changed={path}"); + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let child = entry.path(); + let Some(child_str) = child.to_str() else { + continue; + }; + if child.is_dir() { + emit_rerun(child_str); + } else { + println!("cargo:rerun-if-changed={child_str}"); + } + } +} diff --git a/rules/javascript/backend_code_injection.ron b/rules/javascript/backend_code_injection.ron index f355956..bf3bd2a 100644 --- a/rules/javascript/backend_code_injection.ron +++ b/rules/javascript/backend_code_injection.ron @@ -9,7 +9,13 @@ patterns: Some([ "regex:eval\\(\\s*[A-Za-z_$](?:key)?", "regex:new Function\\(\\s*[A-Za-z_$](?:key)?", + // Dummy `=` names pass the call-name prefilter (escaped regex + // dots are not a substring of `vm.runInNewContext`). + "vm.runInNewContext=", + "vm.runInThisContext=", + "vm.runInContext=", "regex:vm\\.runIn(?:New|This)?Context\\(\\s*[A-Za-z_$]", + "regex:vm\\.runIn(?:New|This)?Context\\(\\s*['\"]", ]), finding_type: Some("Code Injection"), severity: Some("Critical"), diff --git a/rules/javascript/frontend_security.ron b/rules/javascript/frontend_security.ron index 15ec049..5901039 100644 --- a/rules/javascript/frontend_security.ron +++ b/rules/javascript/frontend_security.ron @@ -135,7 +135,9 @@ mode: "search", patterns: Some([ "*Function(*user*)", "*Function(*input*)", "*Function(*data*)", - "new Function(*+*)", " Function(*+*)" + "new Function(*+*)", " Function(*+*)", + // Word boundary so `Function('return '+x)` matches but `myFunction` does not. + "regex:\\bFunction\\(.*\\+" ]), finding_type: Some("Code Injection"), severity: Some("Critical"), @@ -150,14 +152,13 @@ name: Some("Code injection via setTimeout/setInterval string"), category: Some("code-injection"), mode: "search", - // Gate `=` forces full-call matching. First arg must be a string or - // `ident,` — not `function` / `async` / `(() =>` callbacks. + // Gate `=` forces full-call matching. String first arg only — + // `setTimeout(handler, n)` is a function reference, not eval. + // Taint still flags `setTimeout(userInput)` when the arg is tainted. patterns: Some([ "settimeout-eval-sink=", "regex:setTimeout\\(\\s*['\"]", - "regex:setTimeout\\(\\s*[A-Za-z_$][\\w$]*\\s*,", - "regex:setInterval\\(\\s*['\"]", - "regex:setInterval\\(\\s*[A-Za-z_$][\\w$]*\\s*," + "regex:setInterval\\(\\s*['\"]" ]), finding_type: Some("Code Injection"), severity: Some("Critical"), @@ -167,6 +168,28 @@ file_types: Some((extensions: Some([".js", ".jsx", ".ts", ".tsx"]))), tags: Some(["code-injection", "frontend", "cwe-95"]) ), + ( + id: Some("js-code-injection-004"), + name: Some("Code injection via vm.runIn*"), + category: Some("code-injection"), + mode: "search", + // Dummy `=` names pass the call-name prefilter (escaped regex dots + // are not a substring of `vm.runInNewContext`). + patterns: Some([ + "vm.runInNewContext=", + "vm.runInThisContext=", + "vm.runInContext=", + "regex:vm\\.runIn(?:New|This)?Context\\(\\s*[A-Za-z_$]", + "regex:vm\\.runIn(?:New|This)?Context\\(\\s*['\"]" + ]), + finding_type: Some("Code Injection"), + severity: Some("Critical"), + confidence: Some("High"), + cwe_id: Some("cwe-95"), + description: Some("User-controlled input is evaluated as JavaScript via vm.runIn*"), + file_types: Some((extensions: Some([".js", ".jsx", ".ts", ".tsx"]))), + tags: Some(["code-injection", "frontend", "cwe-95"]) + ), ( id: Some("js-postmessage-001"), name: Some("postMessage with wildcard origin"), diff --git a/rules/javascript/frontend_taint_security.ron b/rules/javascript/frontend_taint_security.ron index b265b25..50858d2 100644 --- a/rules/javascript/frontend_taint_security.ron +++ b/rules/javascript/frontend_taint_security.ron @@ -1302,15 +1302,13 @@ "URLSearchParams(*innerHTML*", "new URLSearchParams(*innerHTML*", - // DOM element content patterns to innerHTML - "*.textContent*innerHTML*", - "*.innerText*innerHTML*", - "*.text*innerHTML*", - "option.text*innerHTML*", - "element.textContent*innerHTML*", - "firstMember.querySelector(*).textContent*innerHTML*", - "document.querySelector(*).textContent*innerHTML*", - "select.previousElementSibling.textContent*innerHTML*", + // DOM element content assigned to innerHTML (write). Reading + // innerHTML after a textContent write is the escapeHtml helper. + "*.innerHTML*=*.textContent*", + "*.innerHTML*=*textContent*", + "*.textContent*innerHTML*=*", + "*.innerText*innerHTML*=*", + "*.text*innerHTML*=*", // Template literal patterns with innerHTML "`*${*searchParams.get(*}*`*innerHTML*", diff --git a/src/common.rs b/src/common.rs index c3bd8f9..4149cb3 100644 --- a/src/common.rs +++ b/src/common.rs @@ -66,22 +66,58 @@ impl CommonUtils { return false; } - // Special handling for DOM property patterns like *.innerHTML + // `*.innerHTML` matches assignment to that property only (not any `=` + // in the snippet). Optional TS `as` cast: `(el.innerHTML as T) = x`. if let Some(property) = pattern.strip_prefix("*.") { - // Remove "*." - - // Handle TypeScript casting syntax: (element.innerHTML as Type) = value - if text.contains(&format!(".{}", property)) { - // Check for assignment context - if text.contains('=') && !text.contains("==") && !text.contains("!=") { - return true; - } + if Self::assignment_follows_dom_property(text, property) { + return true; } } Self::matches_unified_pattern(pattern, text) } + pub(crate) fn is_html_write_source_property(property: &str) -> bool { + property == "innerHTML" || property == "outerHTML" + } + + /// `.innerHTML = x`, `.innerHTML+=x`, or `(el.innerHTML as T) = x`. + pub(crate) fn assignment_follows_dom_property(text: &str, property: &str) -> bool { + Self::any_dom_property(text, property, Self::suffix_looks_like_dom_assignment) + } + + /// `.prop` used as a read, not the left-hand side of an assignment. + pub(crate) fn dom_property_has_unassigned_use(text: &str, property: &str) -> bool { + Self::any_dom_property(text, property, |after| { + !Self::suffix_looks_like_dom_assignment(after) + }) + } + + fn any_dom_property(text: &str, property: &str, pred: impl Fn(&str) -> bool) -> bool { + let needle = format!(".{}", property); + let mut search = 0; + while let Some(rel) = text.get(search..).and_then(|rest| rest.find(&needle)) { + let idx = search + rel + needle.len(); + if pred(&text[idx..]) { + return true; + } + search = idx; + } + false + } + + fn suffix_looks_like_dom_assignment(after: &str) -> bool { + let after = after.trim_start(); + if after.starts_with("+=") || (after.starts_with('=') && !after.starts_with("==")) { + return true; + } + let Some(rest) = after.strip_prefix("as ") else { + return false; + }; + rest.find(')') + .is_some_and(|paren| Self::suffix_looks_like_dom_assignment(&rest[paren + 1..])) + } + /// Context-aware taint pattern matching with additional filtering pub fn matches_taint_pattern_in_context( pattern: &str, @@ -752,3 +788,39 @@ impl CommonUtils { } } } + +#[cfg(test)] +mod tests { + use super::CommonUtils; + + #[test] + fn assignment_follows_dom_property_requires_this_property() { + assert!(CommonUtils::assignment_follows_dom_property( + "el.innerHTML = location.hash", + "innerHTML" + )); + assert!(!CommonUtils::assignment_follows_dom_property( + "el.textContent = x; return el.innerHTML", + "innerHTML" + )); + assert!(CommonUtils::assignment_follows_dom_property( + "el.textContent = x; return el.innerHTML", + "textContent" + )); + assert!(CommonUtils::assignment_follows_dom_property( + "(el.innerHTML as string) = x", + "innerHTML" + )); + assert!(!CommonUtils::assignment_follows_dom_property( + "(el.innerHTML as string) == x", + "innerHTML" + )); + } + + #[test] + fn html_write_source_property_is_inner_or_outer_html() { + assert!(CommonUtils::is_html_write_source_property("innerHTML")); + assert!(CommonUtils::is_html_write_source_property("outerHTML")); + assert!(!CommonUtils::is_html_write_source_property("textContent")); + } +} diff --git a/src/scanner/scanning_logic.rs b/src/scanner/scanning_logic.rs index 5b79cc8..12b349e 100644 --- a/src/scanner/scanning_logic.rs +++ b/src/scanner/scanning_logic.rs @@ -840,7 +840,10 @@ impl ScanningLogic { }, ); } else { - log::debug!("[FUNCTION_PARAM_ANALYSIS] Function parameter '{}' does not match any source pattern", param); + log::debug!( + "[FUNCTION_PARAM_ANALYSIS] Function parameter '{}' does not match any source pattern", + param + ); } } } @@ -1250,6 +1253,22 @@ impl ScanningLogic { let Some(sink_pattern) = ctx.rule_deduplicator.matches_sink_pattern(&node_text) else { return; }; + if sink_pattern.contains("innerHTML") + && crate::scanner::utils::AstUtils::is_textcontent_escape_innerhtml_read(&node_text) + { + log::debug!( + "[SINK_ANALYSIS] Skipping innerHTML read after textContent write: '{}'", + node_text + ); + return; + } + if crate::scanner::utils::AstUtils::is_timer_callback_eval_sink(&sink_pattern, &node_text) { + log::debug!( + "[SINK_ANALYSIS] Skipping setTimeout/setInterval function callback: '{}'", + node_text + ); + return; + } log::debug!( "[SINK_ANALYSIS] Found sink '{}' with pattern '{}' at line {}", node_text, @@ -2134,6 +2153,12 @@ impl ScanningLogic { if !Self::rule_pattern_matches_node(rule, &node_text) { return None; } + let finding_type = rule.get_finding_type().to_lowercase(); + if (finding_type.contains("xss") || rule.cwe_id.as_deref() == Some("cwe-79")) + && crate::scanner::utils::AstUtils::is_textcontent_escape_innerhtml_read(&node_text) + { + return None; + } if let Some(conditions) = &rule.conditions { if !crate::scanner::conditions::check_ast_conditions( diff --git a/src/scanner/taint_utils.rs b/src/scanner/taint_utils.rs index 8e20a34..b535996 100644 --- a/src/scanner/taint_utils.rs +++ b/src/scanner/taint_utils.rs @@ -55,11 +55,19 @@ impl TaintRuleDeduplicator { let result = self.rule_mapping.get(&key); if let Some(rule) = result { - log::debug!("[RULE_SELECTION] Found rule for source='{}' + sink='{}' -> rule_id={:?}, finding_type={:?}", - source_pattern, sink_pattern, rule.id, rule.finding_type); + log::debug!( + "[RULE_SELECTION] Found rule for source='{}' + sink='{}' -> rule_id={:?}, finding_type={:?}", + source_pattern, + sink_pattern, + rule.id, + rule.finding_type + ); } else { - log::debug!("[RULE_SELECTION] No rule found for source='{}' + sink='{}'. Showing up to 5 mappings", - source_pattern, sink_pattern); + log::debug!( + "[RULE_SELECTION] No rule found for source='{}' + sink='{}'. Showing up to 5 mappings", + source_pattern, + sink_pattern + ); for ((src, snk), rule) in self.rule_mapping.iter().take(5) { log::debug!(" - ('{}', '{}') -> {:?}", src, snk, rule.finding_type); } @@ -80,6 +88,17 @@ impl TaintRuleDeduplicator { { continue; } + if let Some(property) = pattern.strip_prefix("*.") { + // Skip textContent/innerText writes (sanitizers). Keep + // innerHTML/outerHTML writes so `el.innerHTML = location.hash` + // still pairs as a same-node source+sink. + if !CommonUtils::is_html_write_source_property(property) + && CommonUtils::assignment_follows_dom_property(text, property) + && !CommonUtils::dom_property_has_unassigned_use(text, property) + { + continue; + } + } if CommonUtils::matches_taint_pattern(pattern, text) { log::debug!("[SOURCE_MATCH] Matched pattern: '{}' in text: '{}'", pattern, text); diff --git a/src/scanner/utils.rs b/src/scanner/utils.rs index 922ebc8..38211ef 100644 --- a/src/scanner/utils.rs +++ b/src/scanner/utils.rs @@ -609,6 +609,71 @@ impl AstUtils { let html_sanitizers = ["DOMPurify.sanitize(", "validator.escape(", "xss(", "escapeHtml(", "encodeHTML("]; html_sanitizers.iter().any(|pat| code.contains(pat)) + || Self::is_textcontent_escape_innerhtml_read(code) + } + + /// `el.textContent = x; return el.innerHTML` encodes HTML. Not XSS. + pub fn is_textcontent_escape_innerhtml_read(code: &str) -> bool { + CommonUtils::assignment_follows_dom_property(code, "textContent") + && code.contains(".innerHTML") + && !CommonUtils::assignment_follows_dom_property(code, "innerHTML") + } + + /// Function/arrow timer args are not CWE-95. Parentheses around a value + /// (`(userInput)`) are not a callback. + pub fn is_timer_callback_eval_sink(sink_pattern: &str, node_text: &str) -> bool { + if !sink_pattern.contains("setTimeout") && !sink_pattern.contains("setInterval") { + return false; + } + let lower = node_text.to_ascii_lowercase(); + ["settimeout", "setinterval"].iter().any(|name| { + lower.find(name).is_some_and(|idx| { + let after_name = lower[idx + name.len()..].trim_start(); + after_name + .strip_prefix('(') + .is_some_and(|args| Self::starts_with_timer_callback_arg(args.trim_start())) + }) + }) + } + + fn starts_with_timer_callback_arg(args: &str) -> bool { + if args.starts_with('(') { + return Self::paren_group_is_timer_callback(args); + } + for keyword in ["function", "async"] { + if let Some(rest) = args.strip_prefix(keyword) { + if rest.is_empty() + || rest.starts_with('(') + || rest.starts_with(|c: char| c.is_whitespace()) + { + return true; + } + } + } + let ident_end = args + .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '$') + .unwrap_or(args.len()); + ident_end > 0 && args[ident_end..].trim_start().starts_with("=>") + } + + fn paren_group_is_timer_callback(args: &str) -> bool { + let mut depth = 0; + for (i, c) in args.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + let inner = args.get(1..i).unwrap_or("").trim(); + let after = args.get(i + 1..).unwrap_or("").trim_start(); + return after.starts_with("=>") + || Self::starts_with_timer_callback_arg(inner); + } + } + _ => {} + } + } + false } fn check_python_sanitization(code: &str) -> bool { @@ -791,4 +856,33 @@ mod tests { assert!(result.is_empty()); } + + #[test] + fn textcontent_write_then_innerhtml_read_is_escape() { + assert!(AstUtils::is_textcontent_escape_innerhtml_read( + "div.textContent = text; return div.innerHTML" + )); + assert!(!AstUtils::is_textcontent_escape_innerhtml_read("el.innerHTML = location.hash")); + } + + #[test] + fn timer_callback_skip_keeps_ident_and_string_eval() { + let sink = "setTimeout("; + assert!(AstUtils::is_timer_callback_eval_sink( + sink, + "setTimeout(function () { paint(); }, 0)" + )); + assert!(AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(() => paint(), 0)")); + assert!(AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(x => paint(x), 0)")); + assert!(AstUtils::is_timer_callback_eval_sink( + sink, + "setTimeout((function () { paint(); }), 0)" + )); + assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(functionName, 1000)")); + assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(userInput, 1000)")); + assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(userInput)")); + assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout((userInput), 1000)")); + assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout((event.data), 1000)")); + assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(event.data, 1000)")); + } } diff --git a/tests/strictness/cwe95_xss_sink_precision.rs b/tests/strictness/cwe95_xss_sink_precision.rs index 1565914..2d91fa5 100644 --- a/tests/strictness/cwe95_xss_sink_precision.rs +++ b/tests/strictness/cwe95_xss_sink_precision.rs @@ -38,14 +38,20 @@ fn innerhtml_helpers_are_not_cwe95_but_eval_is() { assert_findings_in_range(&cwe95, 6, 6, 1, "eval(userInput) is CWE-95"); assert_findings_in_range(&cwe95, 10, 10, 1, "new Function(userInput) is CWE-95"); - assert_findings_in_range(&cwe95, 15, 15, 1, "setTimeout(userInput) string form is CWE-95"); - assert_no_findings_in_range(&cwe95, 23, 63, "DOM/HTMX/callback helpers are not CWE-95"); + assert_findings_in_range(&cwe95, 15, 15, 1, "setTimeout(string concat) is CWE-95"); + assert_findings_in_range(&cwe95, 19, 19, 1, "setInterval(string concat) is CWE-95"); + assert_findings_in_range(&cwe95, 23, 23, 1, "eval(location.hash) is CWE-95"); + assert_findings_in_range(&cwe95, 27, 27, 1, "vm.runInNewContext(userInput) is CWE-95"); + assert_findings_in_range(&cwe95, 98, 98, 1, "Function('return '+userInput) is CWE-95"); + assert_no_findings_in_range(&cwe95, 30, 94, "DOM/HTMX/callback helpers are not CWE-95"); let xss: Vec<_> = findings.iter().filter(|f| is_xss(f)).cloned().collect(); - assert!( - xss.iter().any(|f| f.snippet.contains("innerHTML") && f.snippet.contains("location.hash")), - "innerHTML = location.hash must remain XSS, got: {:?}", - xss.iter().map(|f| (f.line, f.snippet.as_str())).collect::>() + assert_findings_in_range(&xss, 32, 32, 1, "innerHTML = location.hash must remain XSS"); + assert_no_findings_in_range( + &xss, + 36, + 94, + "escapeHtml/DOMPurify/template/htmx helpers are not XSS", ); assert!( xss.iter().all(|f| f.cwe_id.as_deref() != Some("cwe-95")), @@ -101,6 +107,7 @@ fn existing_js_xss_true_positives_still_fire() { || f.snippet.contains("Function(") || f.snippet.contains("setTimeout(") || f.snippet.contains("setInterval(") + || f.snippet.contains("vm.runIn") }), "CWE-95 on XSS fixtures must be eval-family only, got: {:?}", cwe95_findings(&findings) diff --git a/tests/test_files/javascript/cwe95_xss_sink_precision.js b/tests/test_files/javascript/cwe95_xss_sink_precision.js index 8b92a5c..697e3c0 100644 --- a/tests/test_files/javascript/cwe95_xss_sink_precision.js +++ b/tests/test_files/javascript/cwe95_xss_sink_precision.js @@ -1,7 +1,7 @@ // CWE-95 vs CWE-79 sink precision. Eval-family sinks are CWE-95. // HTML writes, HTMX, DOMPurify, and parse-only template helpers are not. -// TP: eval / Function / setTimeout(string) with user input → CWE-95 +// TP: eval / Function / setTimeout(string) / setInterval(string) / vm.runIn* → CWE-95 function evalUser(userInput) { eval(userInput); } @@ -12,13 +12,21 @@ function functionUser(userInput) { } function timeoutUser(userInput) { - setTimeout(userInput, 1000); + setTimeout('alert(' + userInput, 1000); +} + +function intervalUser(userInput) { + setInterval('alert(' + userInput, 1000); } function evalFromHash() { eval(location.hash); } +function vmUser(userInput) { + vm.runInNewContext(userInput); +} + // TP XSS, not CWE-95: tainted HTML write function xssFromHash() { document.body.innerHTML = location.hash; @@ -37,7 +45,7 @@ function htmlToElement(html) { return template.content.firstElementChild; } -// TN CWE-95: textContent → escaped innerHTML helper +// TN CWE-79 / CWE-95: textContent → escaped innerHTML helper function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; @@ -55,9 +63,37 @@ function renderPanel(html) { new bootstrap.Collapse(document.getElementById('panel')); } -// TN CWE-95: setTimeout function callback is not eval +// TN CWE-95: setTimeout/setInterval function callbacks are not eval function delayedPaint(userInput) { setTimeout(function () { document.getElementById('out').textContent = userInput; }, 0); } + +function delayedInterval(userInput) { + setInterval(function () { + document.getElementById('out').textContent = userInput; + }, 0); +} + +function delayedArrow(userInput) { + setTimeout(x => { + document.getElementById('out').textContent = userInput; + }, 0); +} + +function delayedParenArrow(userInput) { + setTimeout(() => { + document.getElementById('out').textContent = userInput; + }, 0); +} + +// TN CWE-95: identifier callback is a function reference, not string eval +function timeoutHandler(handler) { + setTimeout(handler, 1000); +} + +// TP CWE-95: bare Function() concatenation (no `new`) +function functionConcat(userInput) { + Function('return ' + userInput); +} From c2290eb99c4780f8796789b58964a41205be58ab Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Thu, 20 Aug 2026 15:20:02 +0300 Subject: [PATCH 04/10] address comment --- build.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/build.rs b/build.rs index 0af8161..2879790 100644 --- a/build.rs +++ b/build.rs @@ -4,10 +4,11 @@ fn main() { fn emit_rerun(path: &str) { println!("cargo:rerun-if-changed={path}"); - let Ok(entries) = std::fs::read_dir(path) else { - return; - }; - for entry in entries.flatten() { + let entries = std::fs::read_dir(path) + .unwrap_or_else(|error| panic!("failed to read directory '{path}': {error}")); + for entry in entries { + let entry = entry + .unwrap_or_else(|error| panic!("failed to read directory entry in '{path}': {error}")); let child = entry.path(); let Some(child_str) = child.to_str() else { continue; From 78045cb83262583a6a3587c9bb47393cf3c04e43 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Mon, 24 Aug 2026 12:35:38 +0300 Subject: [PATCH 05/10] handle iife and quality comment --- build.rs | 34 +++++++++++++++++++--------------- src/scanner/utils.rs | 9 +++++++++ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/build.rs b/build.rs index 2879790..37ec913 100644 --- a/build.rs +++ b/build.rs @@ -2,21 +2,25 @@ fn main() { emit_rerun("rules"); } -fn emit_rerun(path: &str) { - println!("cargo:rerun-if-changed={path}"); - let entries = std::fs::read_dir(path) - .unwrap_or_else(|error| panic!("failed to read directory '{path}': {error}")); - for entry in entries { - let entry = entry - .unwrap_or_else(|error| panic!("failed to read directory entry in '{path}': {error}")); - let child = entry.path(); - let Some(child_str) = child.to_str() else { - continue; - }; - if child.is_dir() { - emit_rerun(child_str); - } else { - println!("cargo:rerun-if-changed={child_str}"); +fn emit_rerun(root: &str) { + let mut pending = vec![root.to_string()]; + while let Some(path) = pending.pop() { + println!("cargo:rerun-if-changed={path}"); + let entries = std::fs::read_dir(&path) + .unwrap_or_else(|error| panic!("failed to read directory '{path}': {error}")); + for entry in entries { + let entry = entry.unwrap_or_else(|error| { + panic!("failed to read directory entry in '{path}': {error}") + }); + let child = entry.path(); + let Some(child_str) = child.to_str() else { + continue; + }; + if child.is_dir() { + pending.push(child_str.to_string()); + } else { + println!("cargo:rerun-if-changed={child_str}"); + } } } } diff --git a/src/scanner/utils.rs b/src/scanner/utils.rs index 38211ef..671e4d4 100644 --- a/src/scanner/utils.rs +++ b/src/scanner/utils.rs @@ -666,6 +666,10 @@ impl AstUtils { if depth == 0 { let inner = args.get(1..i).unwrap_or("").trim(); let after = args.get(i + 1..).unwrap_or("").trim_start(); + // `(fn)()` / `(() => x)()` is an IIFE result, not a callback. + if after.starts_with('(') { + return false; + } return after.starts_with("=>") || Self::starts_with_timer_callback_arg(inner); } @@ -878,6 +882,11 @@ mod tests { sink, "setTimeout((function () { paint(); }), 0)" )); + assert!(!AstUtils::is_timer_callback_eval_sink( + sink, + "setTimeout((function () { return userInput; })(), 0)" + )); + assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout((() => userInput)(), 0)")); assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(functionName, 1000)")); assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(userInput, 1000)")); assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(userInput)")); From c3383dc0a620a690bb2e0d4985800d6f4c0455e1 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Wed, 26 Aug 2026 17:11:21 +0300 Subject: [PATCH 06/10] address comments --- rules/backend_javascript/backend_security.ron | 43 +++++++++++++++ rules/javascript/frontend_security.ron | 12 +++-- src/language.rs | 22 +++++--- tests/strictness/cwe95_xss_sink_precision.rs | 52 +++++++++++++++++-- .../django/fixtures/template_xss.html | 2 + .../javascript/cwe95_xss_sink_precision.js | 5 ++ 6 files changed, 121 insertions(+), 15 deletions(-) diff --git a/rules/backend_javascript/backend_security.ron b/rules/backend_javascript/backend_security.ron index beb0cb8..5fbfedc 100644 --- a/rules/backend_javascript/backend_security.ron +++ b/rules/backend_javascript/backend_security.ron @@ -514,6 +514,49 @@ )), tags: Some(["code-injection", "cwe-94", "server-side", "backend"]) + ), + + ( + id: Some("js-server-template-injection-001"), + name: Some("Server-side template injection"), + category: Some("code-injection"), + mode: "taint", + sources: Some([ + "req.query.*", + "req.params.*", + "req.body.*", + "request.query.*", + "request.params.*", + "request.body.*", + "ctx.query.*", + "ctx.params.*", + "req.headers.*" + ]), + sinks: Some([ + "ejs.render", + "handlebars.compile", + "mustache.render" + ]), + sanitizers: Some([ + "validateCode", + "sanitizeCode", + "isValidCode" + ]), + finding_type: Some("Code Injection"), + severity: Some("Critical"), + confidence: Some("High"), + cwe_id: Some("cwe-94"), + description: Some("User-controlled request data is passed to a template engine"), + file_types: Some(( + extensions: Some([".js", ".jsx", ".ts", ".tsx"]), + exclude_patterns: Some([ + "*.min.js", "*.min.jsx", "*.min.ts", "*.min.tsx", + "*/public/*", "*/static/*", "*/assets/*", "*/dist/*", + "*/build/*", "*/frontend/*", "*/client/*", + "*/node_modules/*", "*/lib/*", "*/vendor/*" + ]) + )), + tags: Some(["code-injection", "cwe-94", "ssti", "server-side", "backend"]) ) ] ) \ No newline at end of file diff --git a/rules/javascript/frontend_security.ron b/rules/javascript/frontend_security.ron index 5901039..13cef8e 100644 --- a/rules/javascript/frontend_security.ron +++ b/rules/javascript/frontend_security.ron @@ -152,13 +152,15 @@ name: Some("Code injection via setTimeout/setInterval string"), category: Some("code-injection"), mode: "search", - // Gate `=` forces full-call matching. String first arg only — - // `setTimeout(handler, n)` is a function reference, not eval. - // Taint still flags `setTimeout(userInput)` when the arg is tainted. + // Gate `=` forces full-call matching. First arg must be a concatenated + // or interpolated string, not a constant (`setTimeout('tick')`) or + // identifier callback (`setTimeout(handler, n)`). Identifiers stay with taint. patterns: Some([ "settimeout-eval-sink=", - "regex:setTimeout\\(\\s*['\"]", - "regex:setInterval\\(\\s*['\"]" + "regex:setTimeout\\(\\s*['\"][^'\"]*['\"]\\s*\\+", + "regex:setTimeout\\(\\s*`[^`]*\\$\\{", + "regex:setInterval\\(\\s*['\"][^'\"]*['\"]\\s*\\+", + "regex:setInterval\\(\\s*`[^`]*\\$\\{" ]), finding_type: Some("Code Injection"), severity: Some("Critical"), diff --git a/src/language.rs b/src/language.rs index 91d3273..2120e35 100644 --- a/src/language.rs +++ b/src/language.rs @@ -593,12 +593,18 @@ impl LanguageSupport for HTMLLanguage { // resolve as the matchable "function" name for search rules. match node.kind() { "text" => django_template_name_from_text(get_node_text_slice(node, source)), - "attribute" => node - .child_by_field_name("name") - .or_else(|| { - crate::common::CommonUtils::find_child(node, |c| c.kind() == "attribute_name") + "attribute" => { + let attr_text = get_node_text_slice(node, source); + django_template_name_from_text(attr_text).or_else(|| { + node.child_by_field_name("name") + .or_else(|| { + crate::common::CommonUtils::find_child(node, |c| { + c.kind() == "attribute_name" + }) + }) + .map(|child| get_node_text_slice(&child, source)) }) - .map(|child| get_node_text_slice(&child, source)), + } "start_tag" | "element" => node .child_by_field_name("name") .or_else(|| { @@ -663,7 +669,11 @@ impl LanguageSupport for DjangoTemplateLanguage { match node.kind() { "text" => django_template_name_from_text(get_node_text_slice(node, source)), "attribute" => { - node.child_by_field_name("name").map(|child| get_node_text_slice(&child, source)) + let attr_text = get_node_text_slice(node, source); + django_template_name_from_text(attr_text).or_else(|| { + node.child_by_field_name("name") + .map(|child| get_node_text_slice(&child, source)) + }) } "script_element" => Some("script"), _ => None, diff --git a/tests/strictness/cwe95_xss_sink_precision.rs b/tests/strictness/cwe95_xss_sink_precision.rs index 2d91fa5..0c90ccf 100644 --- a/tests/strictness/cwe95_xss_sink_precision.rs +++ b/tests/strictness/cwe95_xss_sink_precision.rs @@ -42,15 +42,15 @@ fn innerhtml_helpers_are_not_cwe95_but_eval_is() { assert_findings_in_range(&cwe95, 19, 19, 1, "setInterval(string concat) is CWE-95"); assert_findings_in_range(&cwe95, 23, 23, 1, "eval(location.hash) is CWE-95"); assert_findings_in_range(&cwe95, 27, 27, 1, "vm.runInNewContext(userInput) is CWE-95"); - assert_findings_in_range(&cwe95, 98, 98, 1, "Function('return '+userInput) is CWE-95"); - assert_no_findings_in_range(&cwe95, 30, 94, "DOM/HTMX/callback helpers are not CWE-95"); + assert_findings_in_range(&cwe95, 103, 103, 1, "Function('return '+userInput) is CWE-95"); + assert_no_findings_in_range(&cwe95, 30, 98, "DOM/HTMX/callback helpers are not CWE-95"); let xss: Vec<_> = findings.iter().filter(|f| is_xss(f)).cloned().collect(); assert_findings_in_range(&xss, 32, 32, 1, "innerHTML = location.hash must remain XSS"); assert_no_findings_in_range( &xss, 36, - 94, + 98, "escapeHtml/DOMPurify/template/htmx helpers are not XSS", ); assert!( @@ -149,6 +149,11 @@ fn django_autoescape_and_htmx_are_not_xss_but_safe_filter_is() { "spaced | mark_safe on request.COOKIES must be XSS, got: {:?}", xss.iter().map(|f| (f.line, f.snippet.as_str())).collect::>() ); + assert!( + xss.iter().any(|f| f.snippet.contains("value=") && f.snippet.contains("|safe")), + "|safe on request.GET in an attribute must be XSS, got: {:?}", + xss.iter().map(|f| (f.line, f.snippet.as_str())).collect::>() + ); assert_no_findings_in_range(&xss, 4, 10, "autoescape, json_script, hx-swap are not XSS"); assert!( findings.iter().all(|f| !is_cwe95(f)), @@ -164,7 +169,7 @@ fn django_autoescape_and_htmx_are_not_xss_but_safe_filter_is() { #[cfg(feature = "html")] fn html_language_still_flags_django_safe_filter() { // CLI auto-detect maps `.html` → `html`, not `django`. Search rules must - // still see `|safe` on text nodes. + // still see `|safe` on text nodes and attribute values. let staging = stage_dir(); stage_file( staging.path(), @@ -188,5 +193,44 @@ fn html_language_still_flags_django_safe_filter() { .map(|f| (f.line, f.finding_type.as_str(), f.snippet.as_str())) .collect::>() ); + assert!( + xss.iter().any(|f| f.snippet.contains("value=") && f.snippet.contains("|safe")), + "|safe in an attribute must be XSS when scanned as html, got: {:?}", + xss.iter().map(|f| (f.line, f.snippet.as_str())).collect::>() + ); assert_no_findings_in_range(&xss, 4, 10, "autoescape, json_script, hx-swap are not XSS"); } + +#[test] +#[cfg(feature = "javascript")] +fn request_body_to_ejs_render_is_cwe94_not_cwe95() { + let staging = stage_dir(); + write_staged_file( + staging.path(), + "render.js", + "function render(req) {\n const template = req.body.template;\n ejs.render(template);\n}\n", + ); + let findings = scan_language_unified_with_rules( + staging.path(), + "javascript", + Rules::load_from_directory("rules/backend_javascript/").expect("load backend js rules"), + ); + assert!( + findings + .iter() + .any(|f| f.cwe_id.as_deref() == Some("cwe-94") && f.snippet.contains("ejs.render")), + "ejs.render(req.body) must be CWE-94 SSTI, got: {:?}", + findings + .iter() + .map(|f| (f.line, f.cwe_id.as_deref(), f.snippet.as_str())) + .collect::>() + ); + assert!( + findings.iter().all(|f| f.cwe_id.as_deref() != Some("cwe-95")), + "SSTI must not be labeled CWE-95, got: {:?}", + findings + .iter() + .map(|f| (f.line, f.cwe_id.as_deref(), f.snippet.as_str())) + .collect::>() + ); +} diff --git a/tests/test_files/django/fixtures/template_xss.html b/tests/test_files/django/fixtures/template_xss.html index 8b86640..533c134 100644 --- a/tests/test_files/django/fixtures/template_xss.html +++ b/tests/test_files/django/fixtures/template_xss.html @@ -12,5 +12,7 @@

{{ title }}

{{ request.GET.q|safe }}
{{ request.COOKIES.sid| mark_safe }}
+ + diff --git a/tests/test_files/javascript/cwe95_xss_sink_precision.js b/tests/test_files/javascript/cwe95_xss_sink_precision.js index 697e3c0..b5398f4 100644 --- a/tests/test_files/javascript/cwe95_xss_sink_precision.js +++ b/tests/test_files/javascript/cwe95_xss_sink_precision.js @@ -93,6 +93,11 @@ function timeoutHandler(handler) { setTimeout(handler, 1000); } +// TN CWE-95: constant timer string is not attacker-controlled +function timeoutConst() { + setTimeout('refreshUI', 100); +} + // TP CWE-95: bare Function() concatenation (no `new`) function functionConcat(userInput) { Function('return ' + userInput); From ee4e724ea016d240e5cdedbc4c8953de34d5f998 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Tue, 8 Sep 2026 16:20:20 +0300 Subject: [PATCH 07/10] update django rule pattern and regex and other updates --- rules/html/django.ron | 21 +++++++------------ rules/javascript/frontend_security.ron | 10 ++++++--- tests/strictness/cwe95_xss_sink_precision.rs | 14 +++++++++++++ .../django/fixtures/template_xss.html | 2 ++ .../javascript/cwe95_xss_sink_precision.js | 9 ++++++++ 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/rules/html/django.ron b/rules/html/django.ron index 38695b5..52ff0d4 100644 --- a/rules/html/django.ron +++ b/rules/html/django.ron @@ -1,8 +1,9 @@ ( rules: [ // Autoescaped `{{ var }}` is not XSS. Flag `|safe`/`|mark_safe` only on - // request data. Gate `=` forces full-text match. Spaced filters (`| safe`) - // resolve as `{{`, so those patterns are `{{`-prefixed. + // request data inside one `{{ ... }}` (`[^}]*` cannot join adjacent + // expressions). Dummy `=` names pass the `|safe` / `|mark_safe` / `{{` + // call-name prefilter. Spaced filters (`| safe`) resolve as `{{`. ( id: Some("html-django-safe-request-xss"), name: Some("Django |safe on request data"), @@ -10,18 +11,10 @@ mode: "search", patterns: Some([ "django-safe-filter-sink=", - "*request.GET*|safe*", - "*request.POST*|safe*", - "*request.COOKIES*|safe*", - "*request.GET*|mark_safe*", - "*request.POST*|mark_safe*", - "*request.COOKIES*|mark_safe*", - "{{*request.GET*| safe*", - "{{*request.POST*| safe*", - "{{*request.COOKIES*| safe*", - "{{*request.GET*| mark_safe*", - "{{*request.POST*| mark_safe*", - "{{*request.COOKIES*| mark_safe*" + "|safe=", + "|mark_safe=", + "{{=", + "regex:\\{\\{[^}]*request\\.(GET|POST|COOKIES)[^}]*\\|\\s*(mark_)?safe[^}]*\\}\\}" ]), finding_type: Some("Cross-Site Scripting"), severity: Some("High"), diff --git a/rules/javascript/frontend_security.ron b/rules/javascript/frontend_security.ron index 13cef8e..b7b3528 100644 --- a/rules/javascript/frontend_security.ron +++ b/rules/javascript/frontend_security.ron @@ -152,11 +152,15 @@ name: Some("Code injection via setTimeout/setInterval string"), category: Some("code-injection"), mode: "search", - // Gate `=` forces full-call matching. First arg must be a concatenated - // or interpolated string, not a constant (`setTimeout('tick')`) or - // identifier callback (`setTimeout(handler, n)`). Identifiers stay with taint. + // Gate `=` forces full-call matching. Dummy names pass the call-name + // prefilter for qualified `window.setTimeout` / `window.setInterval` + // (`regex:setTimeout\\(` is not a substring of that callee). First arg + // must be a concatenated or interpolated string, not a constant + // (`setTimeout('tick')`) or identifier callback (`setTimeout(handler, n)`). patterns: Some([ "settimeout-eval-sink=", + "window.setTimeout=", + "window.setInterval=", "regex:setTimeout\\(\\s*['\"][^'\"]*['\"]\\s*\\+", "regex:setTimeout\\(\\s*`[^`]*\\$\\{", "regex:setInterval\\(\\s*['\"][^'\"]*['\"]\\s*\\+", diff --git a/tests/strictness/cwe95_xss_sink_precision.rs b/tests/strictness/cwe95_xss_sink_precision.rs index 0c90ccf..4daacf4 100644 --- a/tests/strictness/cwe95_xss_sink_precision.rs +++ b/tests/strictness/cwe95_xss_sink_precision.rs @@ -43,6 +43,8 @@ fn innerhtml_helpers_are_not_cwe95_but_eval_is() { assert_findings_in_range(&cwe95, 23, 23, 1, "eval(location.hash) is CWE-95"); assert_findings_in_range(&cwe95, 27, 27, 1, "vm.runInNewContext(userInput) is CWE-95"); assert_findings_in_range(&cwe95, 103, 103, 1, "Function('return '+userInput) is CWE-95"); + assert_findings_in_range(&cwe95, 108, 108, 1, "window.setTimeout(string concat) is CWE-95"); + assert_findings_in_range(&cwe95, 112, 112, 1, "window.setInterval(string concat) is CWE-95"); assert_no_findings_in_range(&cwe95, 30, 98, "DOM/HTMX/callback helpers are not CWE-95"); let xss: Vec<_> = findings.iter().filter(|f| is_xss(f)).cloned().collect(); @@ -155,6 +157,12 @@ fn django_autoescape_and_htmx_are_not_xss_but_safe_filter_is() { xss.iter().map(|f| (f.line, f.snippet.as_str())).collect::>() ); assert_no_findings_in_range(&xss, 4, 10, "autoescape, json_script, hx-swap are not XSS"); + assert_no_findings_in_range( + &xss, + 18, + 18, + "request.GET and |safe in adjacent expressions are not XSS", + ); assert!( findings.iter().all(|f| !is_cwe95(f)), "django templates must not produce CWE-95, got: {:?}", @@ -199,6 +207,12 @@ fn html_language_still_flags_django_safe_filter() { xss.iter().map(|f| (f.line, f.snippet.as_str())).collect::>() ); assert_no_findings_in_range(&xss, 4, 10, "autoescape, json_script, hx-swap are not XSS"); + assert_no_findings_in_range( + &xss, + 18, + 18, + "request.GET and |safe in adjacent expressions are not XSS", + ); } #[test] diff --git a/tests/test_files/django/fixtures/template_xss.html b/tests/test_files/django/fixtures/template_xss.html index 533c134..edeb646 100644 --- a/tests/test_files/django/fixtures/template_xss.html +++ b/tests/test_files/django/fixtures/template_xss.html @@ -14,5 +14,7 @@

{{ title }}

{{ request.COOKIES.sid| mark_safe }}
+ +
{{ request.GET.q }} {{ title|safe }}
diff --git a/tests/test_files/javascript/cwe95_xss_sink_precision.js b/tests/test_files/javascript/cwe95_xss_sink_precision.js index b5398f4..7bee7f1 100644 --- a/tests/test_files/javascript/cwe95_xss_sink_precision.js +++ b/tests/test_files/javascript/cwe95_xss_sink_precision.js @@ -102,3 +102,12 @@ function timeoutConst() { function functionConcat(userInput) { Function('return ' + userInput); } + +// TP CWE-95: qualified window timers with string concat +function windowTimeoutUser(userInput) { + window.setTimeout('alert(' + userInput, 1000); +} + +function windowIntervalUser(userInput) { + window.setInterval('alert(' + userInput, 1000); +} From 0f006af3dc625025df055a7656c04ba26c20e23a Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Wed, 9 Sep 2026 15:40:24 +0300 Subject: [PATCH 08/10] fix: keep PathBuf rebuild walk and pass clippy Co-authored-by: Cursor --- build.rs | 18 ++++++++---------- src/common.rs | 9 +++++---- src/scanner/utils.rs | 9 ++++----- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/build.rs b/build.rs index 37ec913..0f6452a 100644 --- a/build.rs +++ b/build.rs @@ -3,23 +3,21 @@ fn main() { } fn emit_rerun(root: &str) { - let mut pending = vec![root.to_string()]; + let mut pending = vec![std::path::PathBuf::from(root)]; while let Some(path) = pending.pop() { - println!("cargo:rerun-if-changed={path}"); - let entries = std::fs::read_dir(&path) - .unwrap_or_else(|error| panic!("failed to read directory '{path}': {error}")); + println!("cargo:rerun-if-changed={}", path.display()); + let entries = std::fs::read_dir(&path).unwrap_or_else(|error| { + panic!("failed to read directory '{}': {error}", path.display()) + }); for entry in entries { let entry = entry.unwrap_or_else(|error| { - panic!("failed to read directory entry in '{path}': {error}") + panic!("failed to read directory entry in '{}': {error}", path.display()) }); let child = entry.path(); - let Some(child_str) = child.to_str() else { - continue; - }; if child.is_dir() { - pending.push(child_str.to_string()); + pending.push(child); } else { - println!("cargo:rerun-if-changed={child_str}"); + println!("cargo:rerun-if-changed={}", child.display()); } } } diff --git a/src/common.rs b/src/common.rs index 4149cb3..bb623d5 100644 --- a/src/common.rs +++ b/src/common.rs @@ -68,10 +68,11 @@ impl CommonUtils { // `*.innerHTML` matches assignment to that property only (not any `=` // in the snippet). Optional TS `as` cast: `(el.innerHTML as T) = x`. - if let Some(property) = pattern.strip_prefix("*.") { - if Self::assignment_follows_dom_property(text, property) { - return true; - } + if pattern + .strip_prefix("*.") + .is_some_and(|property| Self::assignment_follows_dom_property(text, property)) + { + return true; } Self::matches_unified_pattern(pattern, text) diff --git a/src/scanner/utils.rs b/src/scanner/utils.rs index e6cc560..b30bbab 100644 --- a/src/scanner/utils.rs +++ b/src/scanner/utils.rs @@ -644,13 +644,12 @@ impl AstUtils { return Self::paren_group_is_timer_callback(args); } for keyword in ["function", "async"] { - if let Some(rest) = args.strip_prefix(keyword) { - if rest.is_empty() + if args.strip_prefix(keyword).is_some_and(|rest| { + rest.is_empty() || rest.starts_with('(') || rest.starts_with(|c: char| c.is_whitespace()) - { - return true; - } + }) { + return true; } } let ident_end = args From 1ef0541769c77eead197ddbd9850dd1770a03b3f Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Sun, 13 Sep 2026 11:19:32 +0300 Subject: [PATCH 09/10] add language scopes --- src/scanner/scanning_logic.rs | 5 +++- src/scanner/utils.rs | 5 ++++ tests/strictness/cwe95_xss_sink_precision.rs | 25 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/scanner/scanning_logic.rs b/src/scanner/scanning_logic.rs index b135a8c..e3bc18b 100644 --- a/src/scanner/scanning_logic.rs +++ b/src/scanner/scanning_logic.rs @@ -2240,7 +2240,10 @@ impl ScanningLogic { return None; } let finding_type = rule.get_finding_type().to_lowercase(); - if (finding_type.contains("xss") || rule.cwe_id.as_deref() == Some("cwe-79")) + // JS/TS only: HTML `script_element` is the whole script, so this + // helper must not suppress sibling sinks (`outerHTML`, `document.write`). + if matches!(language_support.name(), "javascript" | "typescript") + && (finding_type.contains("xss") || rule.cwe_id.as_deref() == Some("cwe-79")) && crate::scanner::utils::AstUtils::is_textcontent_escape_innerhtml_read(&node_text) { return None; diff --git a/src/scanner/utils.rs b/src/scanner/utils.rs index b30bbab..2b97ea5 100644 --- a/src/scanner/utils.rs +++ b/src/scanner/utils.rs @@ -616,10 +616,12 @@ impl AstUtils { } /// `el.textContent = x; return el.innerHTML` encodes HTML. Not XSS. + /// An `outerHTML` write in the same snippet is a separate sink. pub fn is_textcontent_escape_innerhtml_read(code: &str) -> bool { CommonUtils::assignment_follows_dom_property(code, "textContent") && code.contains(".innerHTML") && !CommonUtils::assignment_follows_dom_property(code, "innerHTML") + && !CommonUtils::assignment_follows_dom_property(code, "outerHTML") } /// Function/arrow timer args are not CWE-95. Parentheses around a value @@ -869,6 +871,9 @@ mod tests { "div.textContent = text; return div.innerHTML" )); assert!(!AstUtils::is_textcontent_escape_innerhtml_read("el.innerHTML = location.hash")); + assert!(!AstUtils::is_textcontent_escape_innerhtml_read( + "div.textContent = text; return div.innerHTML; el.outerHTML = user" + )); } #[test] diff --git a/tests/strictness/cwe95_xss_sink_precision.rs b/tests/strictness/cwe95_xss_sink_precision.rs index 4daacf4..89bd921 100644 --- a/tests/strictness/cwe95_xss_sink_precision.rs +++ b/tests/strictness/cwe95_xss_sink_precision.rs @@ -215,6 +215,31 @@ fn html_language_still_flags_django_safe_filter() { ); } +#[test] +#[cfg(feature = "html")] +fn textcontent_escape_in_script_does_not_hide_outerhtml() { + let staging = stage_dir(); + write_staged_file( + staging.path(), + "page.html", + "\n", + ); + let findings = scan_language_unified_with_rules( + staging.path(), + "html", + Rules::load_from_directory("rules/html/").expect("load html rules"), + ); + let xss: Vec<_> = findings.iter().filter(|f| is_xss(f)).cloned().collect(); + assert!( + xss.iter().any(|f| f.snippet.contains("outerHTML")), + "outerHTML write next to textContent escape must stay XSS, got: {:?}", + findings + .iter() + .map(|f| (f.line, f.finding_type.as_str(), f.snippet.as_str())) + .collect::>() + ); +} + #[test] #[cfg(feature = "javascript")] fn request_body_to_ejs_render_is_cwe94_not_cwe95() { From ff72b24800fa81b1cba56a45b077d568302bf239 Mon Sep 17 00:00:00 2001 From: Leen Kilani Date: Thu, 17 Sep 2026 11:56:48 +0300 Subject: [PATCH 10/10] address comments --- rules/html/django.ron | 5 ++-- src/common.rs | 12 ++++++-- src/scanner/utils.rs | 29 ++++++++++++++----- tests/strictness/cwe95_xss_sink_precision.rs | 20 +++++++++++++ .../django/fixtures/template_xss.html | 2 ++ .../javascript/cwe95_xss_sink_precision.js | 12 ++++++++ 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/rules/html/django.ron b/rules/html/django.ron index 52ff0d4..492c616 100644 --- a/rules/html/django.ron +++ b/rules/html/django.ron @@ -3,7 +3,8 @@ // Autoescaped `{{ var }}` is not XSS. Flag `|safe`/`|mark_safe` only on // request data inside one `{{ ... }}` (`[^}]*` cannot join adjacent // expressions). Dummy `=` names pass the `|safe` / `|mark_safe` / `{{` - // call-name prefilter. Spaced filters (`| safe`) resolve as `{{`. + // call-name prefilter (`django-{{=` contains `{{` but is not template + // syntax). Spaced filters (`| safe`) resolve as `{{`. ( id: Some("html-django-safe-request-xss"), name: Some("Django |safe on request data"), @@ -13,7 +14,7 @@ "django-safe-filter-sink=", "|safe=", "|mark_safe=", - "{{=", + "django-{{=", "regex:\\{\\{[^}]*request\\.(GET|POST|COOKIES)[^}]*\\|\\s*(mark_)?safe[^}]*\\}\\}" ]), finding_type: Some("Cross-Site Scripting"), diff --git a/src/common.rs b/src/common.rs index bb623d5..9bfe8ea 100644 --- a/src/common.rs +++ b/src/common.rs @@ -82,7 +82,7 @@ impl CommonUtils { property == "innerHTML" || property == "outerHTML" } - /// `.innerHTML = x`, `.innerHTML+=x`, or `(el.innerHTML as T) = x`. + /// `.innerHTML = x`, `.innerHTML+=x`, `.innerHTML||=x`, or `(el.innerHTML as T) = x`. pub(crate) fn assignment_follows_dom_property(text: &str, property: &str) -> bool { Self::any_dom_property(text, property, Self::suffix_looks_like_dom_assignment) } @@ -109,7 +109,12 @@ impl CommonUtils { fn suffix_looks_like_dom_assignment(after: &str) -> bool { let after = after.trim_start(); - if after.starts_with("+=") || (after.starts_with('=') && !after.starts_with("==")) { + if after.starts_with("+=") + || after.starts_with("||=") + || after.starts_with("&&=") + || after.starts_with("??=") + || (after.starts_with('=') && !after.starts_with("==")) + { return true; } let Some(rest) = after.strip_prefix("as ") else { @@ -812,6 +817,9 @@ mod tests { "(el.innerHTML as string) = x", "innerHTML" )); + assert!(CommonUtils::assignment_follows_dom_property("el.innerHTML ||= html", "innerHTML")); + assert!(CommonUtils::assignment_follows_dom_property("el.innerHTML &&= html", "innerHTML")); + assert!(CommonUtils::assignment_follows_dom_property("el.innerHTML ??= html", "innerHTML")); assert!(!CommonUtils::assignment_follows_dom_property( "(el.innerHTML as string) == x", "innerHTML" diff --git a/src/scanner/utils.rs b/src/scanner/utils.rs index 2b97ea5..92dd689 100644 --- a/src/scanner/utils.rs +++ b/src/scanner/utils.rs @@ -625,20 +625,29 @@ impl AstUtils { } /// Function/arrow timer args are not CWE-95. Parentheses around a value - /// (`(userInput)`) are not a callback. + /// (`(userInput)`) are not a callback. A later non-callback timer in the + /// same snippet is still a sink. pub fn is_timer_callback_eval_sink(sink_pattern: &str, node_text: &str) -> bool { if !sink_pattern.contains("setTimeout") && !sink_pattern.contains("setInterval") { return false; } let lower = node_text.to_ascii_lowercase(); - ["settimeout", "setinterval"].iter().any(|name| { - lower.find(name).is_some_and(|idx| { + let mut saw_callback = false; + for name in ["settimeout", "setinterval"] { + let mut search = 0; + while let Some(rel) = lower.get(search..).and_then(|rest| rest.find(name)) { + let idx = search + rel; let after_name = lower[idx + name.len()..].trim_start(); - after_name - .strip_prefix('(') - .is_some_and(|args| Self::starts_with_timer_callback_arg(args.trim_start())) - }) - }) + if let Some(args) = after_name.strip_prefix('(') { + if !Self::starts_with_timer_callback_arg(args.trim_start()) { + return false; + } + saw_callback = true; + } + search = idx + name.len(); + } + } + saw_callback } fn starts_with_timer_callback_arg(args: &str) -> bool { @@ -900,5 +909,9 @@ mod tests { assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout((userInput), 1000)")); assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout((event.data), 1000)")); assert!(!AstUtils::is_timer_callback_eval_sink(sink, "setTimeout(event.data, 1000)")); + assert!(!AstUtils::is_timer_callback_eval_sink( + sink, + "setTimeout(function () { paint(); }, 0); setTimeout('alert(' + userInput, 1000)" + )); } } diff --git a/tests/strictness/cwe95_xss_sink_precision.rs b/tests/strictness/cwe95_xss_sink_precision.rs index 89bd921..9b9aca0 100644 --- a/tests/strictness/cwe95_xss_sink_precision.rs +++ b/tests/strictness/cwe95_xss_sink_precision.rs @@ -45,10 +45,28 @@ fn innerhtml_helpers_are_not_cwe95_but_eval_is() { assert_findings_in_range(&cwe95, 103, 103, 1, "Function('return '+userInput) is CWE-95"); assert_findings_in_range(&cwe95, 108, 108, 1, "window.setTimeout(string concat) is CWE-95"); assert_findings_in_range(&cwe95, 112, 112, 1, "window.setInterval(string concat) is CWE-95"); + assert_findings_in_range(&cwe95, 124, 124, 1, "later string-eval timer is CWE-95"); assert_no_findings_in_range(&cwe95, 30, 98, "DOM/HTMX/callback helpers are not CWE-95"); let xss: Vec<_> = findings.iter().filter(|f| is_xss(f)).cloned().collect(); assert_findings_in_range(&xss, 32, 32, 1, "innerHTML = location.hash must remain XSS"); + let logical: Vec<_> = + findings.iter().filter(|f| f.snippet.contains("innerHTML ||=")).cloned().collect(); + assert_findings_in_range( + &logical, + 116, + 118, + 1, + "innerHTML ||= tainted intermediate is a DOM sink", + ); + assert!( + logical.iter().any(|f| f.cwe_id.as_deref() == Some("cwe-116") || is_xss(f)), + "innerHTML ||= must be a DOM/encoding sink, got: {:?}", + logical + .iter() + .map(|f| (f.line, f.cwe_id.as_deref(), f.finding_type.as_str())) + .collect::>() + ); assert_no_findings_in_range( &xss, 36, @@ -163,6 +181,7 @@ fn django_autoescape_and_htmx_are_not_xss_but_safe_filter_is() { 18, "request.GET and |safe in adjacent expressions are not XSS", ); + assert_no_findings_in_range(&xss, 20, 20, "{{= without request+|safe is not XSS"); assert!( findings.iter().all(|f| !is_cwe95(f)), "django templates must not produce CWE-95, got: {:?}", @@ -213,6 +232,7 @@ fn html_language_still_flags_django_safe_filter() { 18, "request.GET and |safe in adjacent expressions are not XSS", ); + assert_no_findings_in_range(&xss, 20, 20, "{{= without request+|safe is not XSS"); } #[test] diff --git a/tests/test_files/django/fixtures/template_xss.html b/tests/test_files/django/fixtures/template_xss.html index edeb646..8df17f0 100644 --- a/tests/test_files/django/fixtures/template_xss.html +++ b/tests/test_files/django/fixtures/template_xss.html @@ -16,5 +16,7 @@

{{ title }}

{{ request.GET.q }} {{ title|safe }}
+ +

{{= heading }}

diff --git a/tests/test_files/javascript/cwe95_xss_sink_precision.js b/tests/test_files/javascript/cwe95_xss_sink_precision.js index 7bee7f1..5e6b377 100644 --- a/tests/test_files/javascript/cwe95_xss_sink_precision.js +++ b/tests/test_files/javascript/cwe95_xss_sink_precision.js @@ -111,3 +111,15 @@ function windowTimeoutUser(userInput) { function windowIntervalUser(userInput) { window.setInterval('alert(' + userInput, 1000); } + +// TP: logical assign of a tainted intermediate (DOM sink) +function innerHtmlLogicalAssign() { + const html = window.location.hash; + document.body.innerHTML ||= html; +} + +// TP CWE-95: callback timer must not hide a later string-eval timer +function mixedTimers(userInput) { + setTimeout(function () { paint(); }, 0); + setTimeout('alert(' + userInput, 1000); +}