use std::collections::{HashMap, HashSet};
use std::path::Path;
use aho_corasick::AhoCorasick;
use once_cell::sync::Lazy;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use streaming_iterator::StreamingIterator;
use tree_sitter::{Node, Query, QueryCursor, Tree};
static HTML_PATTERNS: Lazy<AhoCorasick> = Lazy::new(|| {
AhoCorasick::new([
"<",
">",
"<div",
"<span",
"<p>",
"<a ",
"<script",
"<img",
"<iframe",
"<svg",
"<style",
"<form",
"<input",
"<button",
"<table",
"<li>",
"<ul>",
"<ol>",
"<h1",
"<h2",
"<br",
"<hr",
])
.expect("Invalid HTML patterns")
});
use crate::callgraph::scanner::{ProjectScanner, ScanConfig};
use crate::error::{Result, BrrrError};
use crate::lang::LanguageRegistry;
use crate::util::format_query_error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => write!(f, "INFO"),
Self::Low => write!(f, "LOW"),
Self::Medium => write!(f, "MEDIUM"),
Self::High => write!(f, "HIGH"),
Self::Critical => write!(f, "CRITICAL"),
}
}
}
impl std::str::FromStr for Severity {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"info" => Ok(Self::Info),
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
"critical" => Ok(Self::Critical),
_ => Err(format!("Unknown severity: {}", s)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
Low,
Medium,
High,
}
impl std::fmt::Display for Confidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Low => write!(f, "LOW"),
Self::Medium => write!(f, "MEDIUM"),
Self::High => write!(f, "HIGH"),
}
}
}
impl std::str::FromStr for Confidence {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
_ => Err(format!("Unknown confidence: {}", s)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum XSSSinkType {
Dom,
DocumentWrite,
InsertAdjacentHtml,
ReactDangerouslySetInnerHtml,
JQuery,
VueVHtml,
AngularInnerHtml,
CodeExecution,
TimerCodeExecution,
TemplateLiteralHtml,
Other,
}
impl std::fmt::Display for XSSSinkType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Dom => write!(f, "dom"),
Self::DocumentWrite => write!(f, "document_write"),
Self::InsertAdjacentHtml => write!(f, "insert_adjacent_html"),
Self::ReactDangerouslySetInnerHtml => write!(f, "react_dangerously_set_inner_html"),
Self::JQuery => write!(f, "jquery"),
Self::VueVHtml => write!(f, "vue_v_html"),
Self::AngularInnerHtml => write!(f, "angular_inner_html"),
Self::CodeExecution => write!(f, "code_execution"),
Self::TimerCodeExecution => write!(f, "timer_code_execution"),
Self::TemplateLiteralHtml => write!(f, "template_literal_html"),
Self::Other => write!(f, "other"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum XSSContext {
HtmlContent,
HtmlAttribute,
JavaScript,
Url,
Css,
}
impl std::fmt::Display for XSSContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::HtmlContent => write!(f, "html_content"),
Self::HtmlAttribute => write!(f, "html_attribute"),
Self::JavaScript => write!(f, "javascript"),
Self::Url => write!(f, "url"),
Self::Css => write!(f, "css"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Location {
pub file: String,
pub line: usize,
pub column: usize,
pub end_line: usize,
pub end_column: usize,
}
impl std::fmt::Display for Location {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}:{}", self.file, self.line, self.column)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XSSFinding {
pub location: Location,
pub severity: Severity,
pub sink_type: XSSSinkType,
pub context: XSSContext,
pub sink_expression: String,
pub tainted_value: String,
pub tainted_variables: Vec<String>,
pub confidence: Confidence,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_snippet: Option<String>,
pub description: String,
pub remediation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XSSScanResult {
pub findings: Vec<XSSFinding>,
pub files_scanned: usize,
pub sinks_found: usize,
pub severity_counts: HashMap<String, usize>,
pub language: String,
}
struct XSSSink {
name: &'static str,
sink_type: XSSSinkType,
severity: Severity,
context: XSSContext,
description: &'static str,
}
const DOM_PROPERTY_SINKS: &[XSSSink] = &[
XSSSink {
name: "innerHTML",
sink_type: XSSSinkType::Dom,
severity: Severity::Critical,
context: XSSContext::HtmlContent,
description: "Directly injects HTML content, enabling script execution",
},
XSSSink {
name: "outerHTML",
sink_type: XSSSinkType::Dom,
severity: Severity::Critical,
context: XSSContext::HtmlContent,
description: "Replaces element with HTML content, enabling script execution",
},
];
const DOM_METHOD_SINKS: &[XSSSink] = &[
XSSSink {
name: "write",
sink_type: XSSSinkType::DocumentWrite,
severity: Severity::Critical,
context: XSSContext::HtmlContent,
description: "document.write injects arbitrary HTML into the document",
},
XSSSink {
name: "writeln",
sink_type: XSSSinkType::DocumentWrite,
severity: Severity::Critical,
context: XSSContext::HtmlContent,
description: "document.writeln injects arbitrary HTML into the document",
},
XSSSink {
name: "insertAdjacentHTML",
sink_type: XSSSinkType::InsertAdjacentHtml,
severity: Severity::Critical,
context: XSSContext::HtmlContent,
description: "Inserts HTML at specified position, enabling script execution",
},
];
const JQUERY_METHOD_SINKS: &[XSSSink] = &[
XSSSink {
name: "html",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .html() sets HTML content, enabling script execution",
},
XSSSink {
name: "append",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .append() with HTML string enables script execution",
},
XSSSink {
name: "prepend",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .prepend() with HTML string enables script execution",
},
XSSSink {
name: "after",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .after() with HTML string enables script execution",
},
XSSSink {
name: "before",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .before() with HTML string enables script execution",
},
XSSSink {
name: "replaceWith",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .replaceWith() with HTML string enables script execution",
},
XSSSink {
name: "wrapAll",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .wrapAll() with HTML string enables script execution",
},
XSSSink {
name: "wrapInner",
sink_type: XSSSinkType::JQuery,
severity: Severity::High,
context: XSSContext::HtmlContent,
description: "jQuery .wrapInner() with HTML string enables script execution",
},
];
const CODE_EXECUTION_SINKS: &[XSSSink] = &[
XSSSink {
name: "eval",
sink_type: XSSSinkType::CodeExecution,
severity: Severity::Critical,
context: XSSContext::JavaScript,
description: "eval() executes arbitrary JavaScript code",
},
XSSSink {
name: "Function",
sink_type: XSSSinkType::CodeExecution,
severity: Severity::Critical,
context: XSSContext::JavaScript,
description: "Function constructor creates executable code from strings",
},
];
const TIMER_SINKS: &[XSSSink] = &[
XSSSink {
name: "setTimeout",
sink_type: XSSSinkType::TimerCodeExecution,
severity: Severity::High,
context: XSSContext::JavaScript,
description: "setTimeout with string argument executes code",
},
XSSSink {
name: "setInterval",
sink_type: XSSSinkType::TimerCodeExecution,
severity: Severity::High,
context: XSSContext::JavaScript,
description: "setInterval with string argument executes code",
},
];
const SAFE_PROPERTIES: &[&str] = &[
"textContent",
"innerText",
"nodeValue",
"value", "className",
"id",
"name",
"placeholder",
"title",
"alt",
"data", ];
const SANITIZATION_FUNCTIONS: &[&str] = &[
"sanitize",
"sanitizeHtml",
"DOMPurify",
"escape",
"escapeHtml",
"encodeURIComponent",
"encodeURI",
"htmlEncode",
"htmlEscape",
"xss",
"purify",
"clean",
"sanitizeContent",
"stripTags",
"htmlEntities",
];
const TYPESCRIPT_XSS_SINK_QUERY: &str = r#"
; innerHTML/outerHTML assignment: element.innerHTML = value
(assignment_expression
left: (member_expression
object: (_) @obj
property: (property_identifier) @prop)
right: (_) @value
(#any-of? @prop "innerHTML" "outerHTML")) @sink
; document.write/writeln: document.write(value)
(call_expression
function: (member_expression
object: (identifier) @obj
property: (property_identifier) @method)
arguments: (arguments) @args
(#eq? @obj "document")
(#any-of? @method "write" "writeln")) @sink
; insertAdjacentHTML: element.insertAdjacentHTML(position, html)
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @method)
arguments: (arguments) @args
(#eq? @method "insertAdjacentHTML")) @sink
; jQuery .html() with argument: $(selector).html(value)
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @method)
arguments: (arguments (_) @first_arg)
(#eq? @method "html")) @sink
; jQuery .append/.prepend/.after/.before/.replaceWith with HTML
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @method)
arguments: (arguments) @args
(#any-of? @method "append" "prepend" "after" "before" "replaceWith" "wrapAll" "wrapInner")) @sink
; eval(code)
(call_expression
function: (identifier) @func
arguments: (arguments) @args
(#eq? @func "eval")) @sink
; new Function(code)
(new_expression
constructor: (identifier) @ctor
arguments: (arguments) @args
(#eq? @ctor "Function")) @sink
; setTimeout/setInterval with string first argument
(call_expression
function: (identifier) @func
arguments: (arguments (string) @str_arg)
(#any-of? @func "setTimeout" "setInterval")) @sink
; Template literal with HTML tags: `<div>${value}</div>`
(template_string
(template_substitution) @subst) @potential_html_template
"#;
const TSX_XSS_SINK_QUERY: &str = r#"
; innerHTML/outerHTML assignment: element.innerHTML = value
(assignment_expression
left: (member_expression
object: (_) @obj
property: (property_identifier) @prop)
right: (_) @value
(#any-of? @prop "innerHTML" "outerHTML")) @sink
; document.write/writeln: document.write(value)
(call_expression
function: (member_expression
object: (identifier) @obj
property: (property_identifier) @method)
arguments: (arguments) @args
(#eq? @obj "document")
(#any-of? @method "write" "writeln")) @sink
; insertAdjacentHTML: element.insertAdjacentHTML(position, html)
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @method)
arguments: (arguments) @args
(#eq? @method "insertAdjacentHTML")) @sink
; jQuery .html() with argument: $(selector).html(value)
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @method)
arguments: (arguments (_) @first_arg)
(#eq? @method "html")) @sink
; jQuery .append/.prepend/.after/.before/.replaceWith with HTML
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @method)
arguments: (arguments) @args
(#any-of? @method "append" "prepend" "after" "before" "replaceWith" "wrapAll" "wrapInner")) @sink
; eval(code)
(call_expression
function: (identifier) @func
arguments: (arguments) @args
(#eq? @func "eval")) @sink
; new Function(code)
(new_expression
constructor: (identifier) @ctor
arguments: (arguments) @args
(#eq? @ctor "Function")) @sink
; setTimeout/setInterval with string first argument
(call_expression
function: (identifier) @func
arguments: (arguments (string) @str_arg)
(#any-of? @func "setTimeout" "setInterval")) @sink
; Template literal with HTML tags: `<div>${value}</div>`
(template_string
(template_substitution) @subst) @potential_html_template
; React dangerouslySetInnerHTML in JSX attribute
(jsx_attribute
(property_identifier) @attr_name
(#eq? @attr_name "dangerouslySetInnerHTML")) @sink
"#;
const VUE_XSS_SINK_QUERY: &str = r#"
; v-html directive in template
(attribute
(attribute_name) @attr_name
(quoted_attribute_value) @attr_value
(#eq? @attr_name "v-html")) @sink
; v-html without quotes
(attribute
(attribute_name) @attr_name
(#eq? @attr_name "v-html")) @sink
"#;
const USER_INPUT_SOURCE_QUERY: &str = r#"
; URL parameters: location.search, location.hash, location.href
(member_expression
object: (identifier) @obj
property: (property_identifier) @prop
(#eq? @obj "location")
(#any-of? @prop "search" "hash" "href" "pathname")) @source
; URLSearchParams
(new_expression
constructor: (identifier) @ctor
(#eq? @ctor "URLSearchParams")) @source
; document.URL, document.documentURI, document.referrer
(member_expression
object: (identifier) @obj
property: (property_identifier) @prop
(#eq? @obj "document")
(#any-of? @prop "URL" "documentURI" "referrer" "cookie" "domain")) @source
; window.name
(member_expression
object: (identifier) @obj
property: (property_identifier) @prop
(#eq? @obj "window")
(#eq? @prop "name")) @source
; localStorage/sessionStorage
(call_expression
function: (member_expression
object: (identifier) @obj
property: (property_identifier) @method)
(#any-of? @obj "localStorage" "sessionStorage")
(#eq? @method "getItem")) @source
; postMessage event.data
(member_expression
object: (identifier) @obj
property: (property_identifier) @prop
(#any-of? @obj "event" "e" "evt" "msg" "message")
(#eq? @prop "data")) @source
; fetch/XMLHttpRequest response
(call_expression
function: (member_expression
property: (property_identifier) @method)
(#any-of? @method "json" "text" "responseText" "responseXML")) @source
; User input elements
(call_expression
function: (member_expression
object: (identifier) @obj
property: (property_identifier) @method)
(#eq? @obj "document")
(#any-of? @method "getElementById" "querySelector" "querySelectorAll" "getElementsByName" "getElementsByClassName" "getElementsByTagName")) @source
"#;
pub struct XSSDetector {
dom_property_sinks: HashMap<String, &'static XSSSink>,
dom_method_sinks: HashMap<String, &'static XSSSink>,
jquery_sinks: HashMap<String, &'static XSSSink>,
code_sinks: HashMap<String, &'static XSSSink>,
timer_sinks: HashMap<String, &'static XSSSink>,
safe_properties: HashSet<String>,
sanitization_functions: HashSet<String>,
}
impl Default for XSSDetector {
fn default() -> Self {
Self::new()
}
}
impl XSSDetector {
pub fn new() -> Self {
let mut dom_property_sinks = HashMap::new();
for sink in DOM_PROPERTY_SINKS {
dom_property_sinks.insert(sink.name.to_string(), sink);
}
let mut dom_method_sinks = HashMap::new();
for sink in DOM_METHOD_SINKS {
dom_method_sinks.insert(sink.name.to_string(), sink);
}
let mut jquery_sinks = HashMap::new();
for sink in JQUERY_METHOD_SINKS {
jquery_sinks.insert(sink.name.to_string(), sink);
}
let mut code_sinks = HashMap::new();
for sink in CODE_EXECUTION_SINKS {
code_sinks.insert(sink.name.to_string(), sink);
}
let mut timer_sinks = HashMap::new();
for sink in TIMER_SINKS {
timer_sinks.insert(sink.name.to_string(), sink);
}
let safe_properties: HashSet<String> = SAFE_PROPERTIES.iter().map(|s| (*s).to_string()).collect();
let sanitization_functions: HashSet<String> = SANITIZATION_FUNCTIONS.iter().map(|s| (*s).to_string()).collect();
Self {
dom_property_sinks,
dom_method_sinks,
jquery_sinks,
code_sinks,
timer_sinks,
safe_properties,
sanitization_functions,
}
}
pub fn scan_file(&self, file_path: &Path) -> Result<Vec<XSSFinding>> {
let registry = LanguageRegistry::global();
let lang = registry.detect_language(file_path).ok_or_else(|| {
BrrrError::UnsupportedLanguage(
file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown")
.to_string(),
)
})?;
let lang_name = lang.name();
if !matches!(lang_name, "typescript" | "javascript" | "tsx" | "jsx") {
return Ok(vec![]);
}
let source = std::fs::read(file_path).map_err(|e| BrrrError::io_with_path(e, file_path))?;
let mut parser = lang.parser_for_path(file_path)?;
let tree = parser.parse(&source, None).ok_or_else(|| BrrrError::Parse {
file: file_path.display().to_string(),
message: "Failed to parse file".to_string(),
})?;
let file_path_str = file_path.display().to_string();
self.scan_typescript(&tree, &source, &file_path_str)
}
pub fn scan_directory(&self, dir_path: &Path, language: Option<&str>) -> Result<XSSScanResult> {
if !dir_path.is_dir() {
return Err(BrrrError::InvalidArgument(format!(
"Not a directory: {}",
dir_path.display()
)));
}
let path_str = dir_path.to_str().ok_or_else(|| {
BrrrError::InvalidArgument("Invalid path encoding".to_string())
})?;
let scanner = ProjectScanner::new(path_str)?;
let config = match language {
Some(lang) => ScanConfig::for_language(lang),
None => ScanConfig::default(),
};
let scan_result = scanner.scan_with_config(&config)?;
let js_ts_extensions: HashSet<&str> = ["js", "jsx", "ts", "tsx", "mjs", "cjs", "vue"]
.iter()
.copied()
.collect();
let files: Vec<_> = scan_result
.files
.into_iter()
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|ext| js_ts_extensions.contains(ext))
.unwrap_or(false)
})
.collect();
let files_scanned = files.len();
let all_findings: Vec<XSSFinding> = files
.par_iter()
.filter_map(|file| self.scan_file(file).ok())
.flatten()
.collect();
let sinks_found = all_findings.len();
let mut severity_counts: HashMap<String, usize> = HashMap::new();
for finding in &all_findings {
*severity_counts.entry(finding.severity.to_string()).or_insert(0) += 1;
}
let detected_lang = language.unwrap_or("javascript/typescript").to_string();
Ok(XSSScanResult {
findings: all_findings,
files_scanned,
sinks_found,
severity_counts,
language: detected_lang,
})
}
fn scan_typescript(
&self,
tree: &Tree,
source: &[u8],
file_path: &str,
) -> Result<Vec<XSSFinding>> {
let mut findings = Vec::new();
let ts_lang = tree.language();
let is_jsx = file_path.ends_with(".tsx") || file_path.ends_with(".jsx");
let query_str = if is_jsx { TSX_XSS_SINK_QUERY } else { TYPESCRIPT_XSS_SINK_QUERY };
let query = Query::new(&ts_lang, query_str)
.map_err(|e| BrrrError::TreeSitter(format_query_error("typescript", "xss_sink", query_str, &e)))?;
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, tree.root_node(), source);
let sink_idx = query.capture_index_for_name("sink");
let prop_idx = query.capture_index_for_name("prop");
let method_idx = query.capture_index_for_name("method");
let value_idx = query.capture_index_for_name("value");
let args_idx = query.capture_index_for_name("args");
let func_idx = query.capture_index_for_name("func");
let ctor_idx = query.capture_index_for_name("ctor");
let attr_name_idx = query.capture_index_for_name("attr_name");
let potential_html_idx = query.capture_index_for_name("potential_html_template");
while let Some(match_) = matches.next() {
let sink_node = sink_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| c.node);
let prop_name = prop_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| node_text(c.node, source));
let method_name = method_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| node_text(c.node, source));
let value_node = value_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| c.node);
let args_node = args_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| c.node);
let func_name = func_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| node_text(c.node, source));
let ctor_name = ctor_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| node_text(c.node, source));
let attr_name = attr_name_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| node_text(c.node, source));
let is_potential_html_template = potential_html_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.is_some();
if let Some(sink_node) = sink_node {
if let Some(prop) = prop_name {
if self.dom_property_sinks.contains_key(prop) {
if let Some(finding) = self.analyze_dom_property_sink(
sink_node,
prop,
value_node,
source,
file_path,
) {
findings.push(finding);
}
}
}
if let Some(method) = method_name {
if self.dom_method_sinks.contains_key(method) {
if let Some(finding) = self.analyze_dom_method_sink(
sink_node,
method,
args_node,
source,
file_path,
) {
findings.push(finding);
}
} else if self.jquery_sinks.contains_key(method) {
if let Some(finding) = self.analyze_jquery_sink(
sink_node,
method,
args_node,
source,
file_path,
) {
findings.push(finding);
}
}
}
if let Some(func) = func_name {
if self.code_sinks.contains_key(func) {
if let Some(finding) = self.analyze_code_execution_sink(
sink_node,
func,
args_node,
source,
file_path,
) {
findings.push(finding);
}
} else if self.timer_sinks.contains_key(func) {
if let Some(finding) = self.analyze_timer_sink(
sink_node,
func,
args_node,
source,
file_path,
) {
findings.push(finding);
}
}
}
if let Some(ctor) = ctor_name {
if self.code_sinks.contains_key(ctor) {
if let Some(finding) = self.analyze_code_execution_sink(
sink_node,
ctor,
args_node,
source,
file_path,
) {
findings.push(finding);
}
}
}
if let Some(attr) = attr_name {
if attr == "dangerouslySetInnerHTML" {
if let Some(finding) = self.analyze_react_dangerous_html(
sink_node,
source,
file_path,
) {
findings.push(finding);
}
}
}
}
if is_potential_html_template {
if let Some(sink_node) = potential_html_idx
.and_then(|idx| match_.captures.iter().find(|c| c.index == idx))
.map(|c| c.node)
{
if let Some(finding) = self.analyze_template_literal_html(
sink_node,
source,
file_path,
) {
findings.push(finding);
}
}
}
}
if file_path.ends_with(".vue") {
let vue_findings = self.scan_vue_template(tree, source, file_path)?;
findings.extend(vue_findings);
}
Ok(findings)
}
fn analyze_dom_property_sink(
&self,
sink_node: Node,
prop_name: &str,
value_node: Option<Node>,
source: &[u8],
file_path: &str,
) -> Option<XSSFinding> {
let sink_def = self.dom_property_sinks.get(prop_name)?;
let value_text = value_node.map(|n| node_text(n, source)).unwrap_or("");
if self.is_sanitized(value_text) {
return None;
}
if value_node.map(|n| self.is_safe_literal(n, source)).unwrap_or(false) {
return None;
}
let (confidence, tainted_vars) = self.analyze_taint(value_text, source);
if confidence == Confidence::Low && tainted_vars.is_empty() {
if value_node.map(|n| n.kind() == "string" || n.kind() == "template_string").unwrap_or(true) {
return None;
}
}
let location = Location {
file: file_path.to_string(),
line: sink_node.start_position().row + 1,
column: sink_node.start_position().column + 1,
end_line: sink_node.end_position().row + 1,
end_column: sink_node.end_position().column + 1,
};
let code_snippet = extract_code_snippet(source, &location);
let sink_expr = node_text(sink_node, source).to_string();
let severity = self.compute_severity(sink_def.severity, confidence, &tainted_vars);
let description = self.generate_description(prop_name, sink_def.description, &tainted_vars);
let remediation = self.generate_remediation(sink_def.sink_type);
Some(XSSFinding {
location,
severity,
sink_type: sink_def.sink_type,
context: sink_def.context,
sink_expression: sink_expr,
tainted_value: value_text.to_string(),
tainted_variables: tainted_vars,
confidence,
code_snippet,
description,
remediation,
})
}
fn analyze_dom_method_sink(
&self,
sink_node: Node,
method_name: &str,
args_node: Option<Node>,
source: &[u8],
file_path: &str,
) -> Option<XSSFinding> {
let sink_def = self.dom_method_sinks.get(method_name)?;
let args_node = args_node?;
let arg_index = if method_name == "insertAdjacentHTML" { 1 } else { 0 };
let arg_text = self.get_argument_text(args_node, arg_index, source)?;
if self.is_sanitized(&arg_text) {
return None;
}
let (confidence, tainted_vars) = self.analyze_taint(&arg_text, source);
let location = Location {
file: file_path.to_string(),
line: sink_node.start_position().row + 1,
column: sink_node.start_position().column + 1,
end_line: sink_node.end_position().row + 1,
end_column: sink_node.end_position().column + 1,
};
let code_snippet = extract_code_snippet(source, &location);
let sink_expr = node_text(sink_node, source).to_string();
let severity = self.compute_severity(sink_def.severity, confidence, &tainted_vars);
let description = self.generate_description(method_name, sink_def.description, &tainted_vars);
let remediation = self.generate_remediation(sink_def.sink_type);
Some(XSSFinding {
location,
severity,
sink_type: sink_def.sink_type,
context: sink_def.context,
sink_expression: sink_expr,
tainted_value: arg_text,
tainted_variables: tainted_vars,
confidence,
code_snippet,
description,
remediation,
})
}
fn analyze_jquery_sink(
&self,
sink_node: Node,
method_name: &str,
args_node: Option<Node>,
source: &[u8],
file_path: &str,
) -> Option<XSSFinding> {
let sink_def = self.jquery_sinks.get(method_name)?;
let args_node = args_node?;
let arg_text = self.get_argument_text(args_node, 0, source)?;
if arg_text.is_empty() {
return None;
}
if self.is_sanitized(&arg_text) {
return None;
}
if matches!(method_name, "append" | "prepend" | "after" | "before") {
if !self.looks_like_html(&arg_text) {
return None;
}
}
let (confidence, tainted_vars) = self.analyze_taint(&arg_text, source);
let location = Location {
file: file_path.to_string(),
line: sink_node.start_position().row + 1,
column: sink_node.start_position().column + 1,
end_line: sink_node.end_position().row + 1,
end_column: sink_node.end_position().column + 1,
};
let code_snippet = extract_code_snippet(source, &location);
let sink_expr = node_text(sink_node, source).to_string();
let severity = self.compute_severity(sink_def.severity, confidence, &tainted_vars);
let description = self.generate_description(method_name, sink_def.description, &tainted_vars);
let remediation = self.generate_remediation(sink_def.sink_type);
Some(XSSFinding {
location,
severity,
sink_type: sink_def.sink_type,
context: sink_def.context,
sink_expression: sink_expr,
tainted_value: arg_text,
tainted_variables: tainted_vars,
confidence,
code_snippet,
description,
remediation,
})
}
fn analyze_code_execution_sink(
&self,
sink_node: Node,
func_name: &str,
args_node: Option<Node>,
source: &[u8],
file_path: &str,
) -> Option<XSSFinding> {
let sink_def = self.code_sinks.get(func_name)?;
let args_node = args_node?;
let arg_text = self.get_argument_text(args_node, 0, source)?;
let is_literal = arg_text.starts_with('"') || arg_text.starts_with('\'') || arg_text.starts_with('`');
let (mut confidence, tainted_vars) = self.analyze_taint(&arg_text, source);
if is_literal && tainted_vars.is_empty() {
confidence = Confidence::Low;
}
let location = Location {
file: file_path.to_string(),
line: sink_node.start_position().row + 1,
column: sink_node.start_position().column + 1,
end_line: sink_node.end_position().row + 1,
end_column: sink_node.end_position().column + 1,
};
let code_snippet = extract_code_snippet(source, &location);
let sink_expr = node_text(sink_node, source).to_string();
let severity = self.compute_severity(sink_def.severity, confidence, &tainted_vars);
let description = self.generate_description(func_name, sink_def.description, &tainted_vars);
let remediation = self.generate_remediation(sink_def.sink_type);
Some(XSSFinding {
location,
severity,
sink_type: sink_def.sink_type,
context: sink_def.context,
sink_expression: sink_expr,
tainted_value: arg_text,
tainted_variables: tainted_vars,
confidence,
code_snippet,
description,
remediation,
})
}
fn analyze_timer_sink(
&self,
sink_node: Node,
func_name: &str,
args_node: Option<Node>,
source: &[u8],
file_path: &str,
) -> Option<XSSFinding> {
let sink_def = self.timer_sinks.get(func_name)?;
let args_node = args_node?;
let arg_text = self.get_argument_text(args_node, 0, source)?;
if !arg_text.starts_with('"') && !arg_text.starts_with('\'') && !arg_text.starts_with('`') {
let args_text = node_text(args_node, source);
if !args_text.contains('"') && !args_text.contains('\'') && !args_text.contains('`') {
return None;
}
}
let (confidence, tainted_vars) = self.analyze_taint(&arg_text, source);
let location = Location {
file: file_path.to_string(),
line: sink_node.start_position().row + 1,
column: sink_node.start_position().column + 1,
end_line: sink_node.end_position().row + 1,
end_column: sink_node.end_position().column + 1,
};
let code_snippet = extract_code_snippet(source, &location);
let sink_expr = node_text(sink_node, source).to_string();
let severity = self.compute_severity(sink_def.severity, confidence, &tainted_vars);
let description = self.generate_description(func_name, sink_def.description, &tainted_vars);
let remediation = self.generate_remediation(sink_def.sink_type);
Some(XSSFinding {
location,
severity,
sink_type: sink_def.sink_type,
context: sink_def.context,
sink_expression: sink_expr,
tainted_value: arg_text,
tainted_variables: tainted_vars,
confidence,
code_snippet,
description,
remediation,
})
}
fn analyze_react_dangerous_html(
&self,
sink_node: Node,
source: &[u8],
file_path: &str,
) -> Option<XSSFinding> {
let sink_text = node_text(sink_node, source);
let value_text = if sink_text.contains("__html") {
sink_text
.split("__html")
.nth(1)
.and_then(|s| s.split(':').nth(1))
.map(|s| s.trim().trim_end_matches('}').trim_end_matches(')').trim())
.unwrap_or(sink_text)
} else {
sink_text
};
if self.is_sanitized(value_text) {
return None;
}
let (confidence, tainted_vars) = self.analyze_taint(value_text, source);
let location = Location {
file: file_path.to_string(),
line: sink_node.start_position().row + 1,
column: sink_node.start_position().column + 1,
end_line: sink_node.end_position().row + 1,
end_column: sink_node.end_position().column + 1,
};
let code_snippet = extract_code_snippet(source, &location);
let severity = self.compute_severity(Severity::Critical, confidence, &tainted_vars);
let description = format!(
"React dangerouslySetInnerHTML bypasses XSS protection. {}",
if tainted_vars.is_empty() {
"Value should be sanitized before use.".to_string()
} else {
format!("Variables {} may contain user-controlled data.", tainted_vars.join(", "))
}
);
Some(XSSFinding {
location,
severity,
sink_type: XSSSinkType::ReactDangerouslySetInnerHtml,
context: XSSContext::HtmlContent,
sink_expression: sink_text.to_string(),
tainted_value: value_text.to_string(),
tainted_variables: tainted_vars,
confidence,
code_snippet,
description,
remediation: self.generate_remediation(XSSSinkType::ReactDangerouslySetInnerHtml),
})
}
fn analyze_template_literal_html(
&self,
template_node: Node,
source: &[u8],
file_path: &str,
) -> Option<XSSFinding> {
let template_text = node_text(template_node, source);
if !self.looks_like_html(template_text) {
return None;
}
if !template_text.contains("${") {
return None;
}
if self.is_sanitized(template_text) {
return None;
}
let (confidence, tainted_vars) = self.analyze_taint(template_text, source);
if tainted_vars.is_empty() {
return None;
}
let location = Location {
file: file_path.to_string(),
line: template_node.start_position().row + 1,
column: template_node.start_position().column + 1,
end_line: template_node.end_position().row + 1,
end_column: template_node.end_position().column + 1,
};
let code_snippet = extract_code_snippet(source, &location);
let severity = self.compute_severity(Severity::High, confidence, &tainted_vars);
let description = format!(
"Template literal with HTML content and interpolated variables {}. \
If assigned to innerHTML or rendered as HTML, this enables XSS.",
tainted_vars.join(", ")
);
Some(XSSFinding {
location,
severity,
sink_type: XSSSinkType::TemplateLiteralHtml,
context: XSSContext::HtmlContent,
sink_expression: template_text.to_string(),
tainted_value: template_text.to_string(),
tainted_variables: tainted_vars,
confidence,
code_snippet,
description,
remediation: self.generate_remediation(XSSSinkType::TemplateLiteralHtml),
})
}
fn scan_vue_template(
&self,
_tree: &Tree,
source: &[u8],
file_path: &str,
) -> Result<Vec<XSSFinding>> {
let mut findings = Vec::new();
let source_str = std::str::from_utf8(source).unwrap_or("");
for (line_idx, line) in source_str.lines().enumerate() {
if let Some(vh_pos) = line.find("v-html") {
let value_start = line[vh_pos..].find('=');
if let Some(start) = value_start {
let remaining = &line[vh_pos + start + 1..];
let value = remaining
.trim()
.trim_start_matches('"')
.trim_start_matches('\'')
.split(|c| c == '"' || c == '\'')
.next()
.unwrap_or("");
if !value.is_empty() && !self.is_sanitized(value) {
let (confidence, tainted_vars) = self.analyze_taint(value, source);
let location = Location {
file: file_path.to_string(),
line: line_idx + 1,
column: vh_pos + 1,
end_line: line_idx + 1,
end_column: vh_pos + 6 + start + value.len(),
};
let code_snippet = Some(format!("{:4} | {}", line_idx + 1, line));
findings.push(XSSFinding {
location,
severity: self.compute_severity(Severity::High, confidence, &tainted_vars),
sink_type: XSSSinkType::VueVHtml,
context: XSSContext::HtmlContent,
sink_expression: format!("v-html=\"{}\"", value),
tainted_value: value.to_string(),
tainted_variables: tainted_vars,
confidence,
code_snippet,
description: format!(
"Vue v-html directive renders raw HTML. \
Value '{}' may contain user-controlled data.",
value
),
remediation: self.generate_remediation(XSSSinkType::VueVHtml),
});
}
}
}
if let Some(ang_pos) = line.find("[innerHTML]") {
let value_start = line[ang_pos..].find('=');
if let Some(start) = value_start {
let remaining = &line[ang_pos + start + 1..];
let value = remaining
.trim()
.trim_start_matches('"')
.trim_start_matches('\'')
.split(|c| c == '"' || c == '\'')
.next()
.unwrap_or("");
if !value.is_empty() && !self.is_sanitized(value) {
let (confidence, tainted_vars) = self.analyze_taint(value, source);
let location = Location {
file: file_path.to_string(),
line: line_idx + 1,
column: ang_pos + 1,
end_line: line_idx + 1,
end_column: ang_pos + 11 + start + value.len(),
};
let code_snippet = Some(format!("{:4} | {}", line_idx + 1, line));
findings.push(XSSFinding {
location,
severity: self.compute_severity(Severity::High, confidence, &tainted_vars),
sink_type: XSSSinkType::AngularInnerHtml,
context: XSSContext::HtmlContent,
sink_expression: format!("[innerHTML]=\"{}\"", value),
tainted_value: value.to_string(),
tainted_variables: tainted_vars,
confidence,
code_snippet,
description: format!(
"Angular [innerHTML] binding renders raw HTML. \
Value '{}' may contain user-controlled data. \
Consider using DomSanitizer.",
value
),
remediation: self.generate_remediation(XSSSinkType::AngularInnerHtml),
});
}
}
}
}
Ok(findings)
}
fn is_sanitized(&self, value: &str) -> bool {
let lower = value.to_lowercase();
self.sanitization_functions.iter().any(|func| {
lower.contains(&func.to_lowercase())
})
}
fn is_safe_literal(&self, node: Node, source: &[u8]) -> bool {
match node.kind() {
"string" => {
let text = node_text(node, source);
!text.to_lowercase().contains("<script")
}
"template_string" => {
let mut cursor = node.walk();
let has_substitution = node.children(&mut cursor).any(|c| c.kind() == "template_substitution");
!has_substitution
}
_ => false,
}
}
fn looks_like_html(&self, text: &str) -> bool {
let lower = text.to_lowercase();
let has_angle_brackets = lower.contains('<') && lower.contains('>');
has_angle_brackets || HTML_PATTERNS.is_match(&lower)
}
fn get_argument_text(&self, args_node: Node, index: usize, source: &[u8]) -> Option<String> {
let mut cursor = args_node.walk();
let mut arg_index = 0;
for child in args_node.children(&mut cursor) {
if child.kind() == "(" || child.kind() == ")" || child.kind() == "," {
continue;
}
if arg_index == index {
return Some(node_text(child, source).to_string());
}
arg_index += 1;
}
None
}
fn analyze_taint(&self, value: &str, _source: &[u8]) -> (Confidence, Vec<String>) {
let mut tainted_vars = Vec::new();
let mut confidence = Confidence::Low;
let user_input_patterns = [
"location.search", "location.hash", "location.href", "location.pathname",
"document.URL", "document.documentURI", "document.referrer", "document.cookie",
"window.name", "window.location",
"localStorage", "sessionStorage",
"URLSearchParams",
"event.data", "postMessage",
"req.body", "req.query", "req.params", "request.body", "request.query",
"params.", "query.", "body.",
"fetch", "XMLHttpRequest", "axios",
"getElementById", "querySelector", "querySelectorAll",
"input", "textarea", "select",
".value", ".innerHTML", ".outerHTML",
"user", "userData", "userInput", "data", "content", "html", "markup",
];
let lower = value.to_lowercase();
for pattern in user_input_patterns {
if lower.contains(&pattern.to_lowercase()) {
confidence = Confidence::High;
tainted_vars.push(pattern.to_string());
}
}
if value.contains("${") {
let mut in_interp = false;
let mut current_var = String::new();
for ch in value.chars() {
if ch == '$' {
continue;
}
if ch == '{' && !in_interp {
in_interp = true;
current_var.clear();
} else if ch == '}' && in_interp {
in_interp = false;
let var_name = current_var
.split(['(', '[', '.', ' ', '+', '-', '*', '/', '?', ':'])
.next()
.unwrap_or(¤t_var)
.trim();
if !var_name.is_empty() && !tainted_vars.contains(&var_name.to_string()) {
tainted_vars.push(var_name.to_string());
if confidence < Confidence::Medium {
confidence = Confidence::Medium;
}
}
} else if in_interp {
current_var.push(ch);
}
}
}
if value.contains('+') && confidence < Confidence::Medium {
confidence = Confidence::Medium;
}
if tainted_vars.is_empty() && !value.starts_with('"') && !value.starts_with('\'') {
let identifier: String = value
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !identifier.is_empty() && identifier != "true" && identifier != "false" && identifier != "null" && identifier != "undefined" {
tainted_vars.push(identifier);
}
}
(confidence, tainted_vars)
}
fn compute_severity(&self, base_severity: Severity, confidence: Confidence, tainted_vars: &[String]) -> Severity {
let mut severity = base_severity;
if confidence == Confidence::High {
if severity < Severity::High {
severity = Severity::High;
}
} else if confidence == Confidence::Low {
severity = match severity {
Severity::Critical => Severity::High,
Severity::High => Severity::Medium,
s => s,
};
}
if !tainted_vars.is_empty() {
let has_direct_user_input = tainted_vars.iter().any(|v| {
let lower = v.to_lowercase();
lower.contains("location") ||
lower.contains("document.url") ||
lower.contains("cookie") ||
lower.contains("postmessage") ||
lower.contains("localstorage") ||
lower.contains("sessionstorage") ||
lower.contains("req.") ||
lower.contains("request.") ||
lower.contains("params") ||
lower.contains("query")
});
if has_direct_user_input && severity < Severity::Critical {
severity = Severity::Critical;
}
}
severity
}
fn generate_description(&self, sink_name: &str, sink_desc: &str, tainted_vars: &[String]) -> String {
if tainted_vars.is_empty() {
format!(
"{} sink detected. {}",
sink_name,
sink_desc
)
} else {
format!(
"{} sink with potentially tainted variables: {}. {}",
sink_name,
tainted_vars.join(", "),
sink_desc
)
}
}
fn generate_remediation(&self, sink_type: XSSSinkType) -> String {
match sink_type {
XSSSinkType::Dom => {
"REMEDIATION:\n\
1. Use textContent instead of innerHTML for text: element.textContent = userInput\n\
2. If HTML is required, sanitize with DOMPurify: element.innerHTML = DOMPurify.sanitize(userInput)\n\
3. Use DOM APIs to create elements: document.createElement(), appendChild()\n\
4. Never trust user input - validate and encode appropriately".to_string()
}
XSSSinkType::DocumentWrite => {
"REMEDIATION:\n\
1. Avoid document.write() entirely - it's deprecated and dangerous\n\
2. Use DOM manipulation instead: document.getElementById().textContent = value\n\
3. If dynamic content is needed, use document.createElement() and appendChild()\n\
4. For scripts, use proper <script> tags in the document".to_string()
}
XSSSinkType::InsertAdjacentHtml => {
"REMEDIATION:\n\
1. Sanitize HTML before insertion: element.insertAdjacentHTML(pos, DOMPurify.sanitize(html))\n\
2. Consider using insertAdjacentText() for plain text\n\
3. Use DOM APIs to create and insert elements safely".to_string()
}
XSSSinkType::ReactDangerouslySetInnerHtml => {
"REMEDIATION:\n\
1. Avoid dangerouslySetInnerHTML when possible\n\
2. If required, ALWAYS sanitize: dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(html)}}\n\
3. Consider using a React-specific sanitization library\n\
4. For rich text, use a library like react-quill or slate with built-in XSS protection".to_string()
}
XSSSinkType::JQuery => {
"REMEDIATION:\n\
1. Use .text() instead of .html() for plain text: $(selector).text(userInput)\n\
2. If HTML is required, sanitize first: $(selector).html(DOMPurify.sanitize(html))\n\
3. For DOM elements, use $('<div>').text(value) then append\n\
4. Migrate from jQuery to modern DOM APIs where possible".to_string()
}
XSSSinkType::VueVHtml => {
"REMEDIATION:\n\
1. Avoid v-html when possible - use {{ }} interpolation for text\n\
2. If HTML is required, sanitize: v-html=\"sanitize(userInput)\"\n\
3. Use a sanitization library like vue-sanitize or DOMPurify\n\
4. Consider using a rich text component with built-in XSS protection".to_string()
}
XSSSinkType::AngularInnerHtml => {
"REMEDIATION:\n\
1. Use Angular's DomSanitizer: this.sanitizer.bypassSecurityTrustHtml()\n\
2. Better: sanitize untrusted content before binding\n\
3. Use [textContent] for plain text binding\n\
4. Consider Angular's built-in sanitization in templates".to_string()
}
XSSSinkType::CodeExecution => {
"CRITICAL REMEDIATION:\n\
1. NEVER use eval() with user input - find an alternative approach\n\
2. For JSON parsing, use JSON.parse() instead of eval()\n\
3. For dynamic property access, use bracket notation: obj[key]\n\
4. For math expressions, use a safe expression parser library\n\
5. For templates, use a template engine with auto-escaping".to_string()
}
XSSSinkType::TimerCodeExecution => {
"REMEDIATION:\n\
1. Pass a function reference instead of a string: setTimeout(myFunc, delay)\n\
2. Use arrow functions: setTimeout(() => doSomething(), delay)\n\
3. NEVER pass user-controlled strings to setTimeout/setInterval\n\
4. If dynamic code is absolutely required, use a sandboxed environment".to_string()
}
XSSSinkType::TemplateLiteralHtml => {
"REMEDIATION:\n\
1. Escape HTML entities in interpolated values before creating HTML strings\n\
2. Use a template engine with auto-escaping (e.g., lit-html, htm)\n\
3. Better: use DOM APIs to create elements programmatically\n\
4. If using innerHTML, sanitize the complete string with DOMPurify".to_string()
}
XSSSinkType::Other => {
"REMEDIATION:\n\
1. Identify the exact sink and apply appropriate encoding/sanitization\n\
2. Use context-appropriate encoding (HTML, JS, URL, CSS)\n\
3. Implement Content Security Policy (CSP) as defense in depth\n\
4. Review OWASP XSS Prevention Cheat Sheet for detailed guidance".to_string()
}
}
}
}
pub fn scan_xss(path: &Path, language: Option<&str>) -> Result<XSSScanResult> {
let detector = XSSDetector::new();
if path.is_file() {
let findings = detector.scan_file(path)?;
let sinks_found = findings.len();
let mut severity_counts: HashMap<String, usize> = HashMap::new();
for finding in &findings {
*severity_counts.entry(finding.severity.to_string()).or_insert(0) += 1;
}
Ok(XSSScanResult {
findings,
files_scanned: 1,
sinks_found,
severity_counts,
language: language.unwrap_or("auto").to_string(),
})
} else {
detector.scan_directory(path, language)
}
}
pub fn scan_file_xss(file_path: &Path) -> Result<Vec<XSSFinding>> {
let detector = XSSDetector::new();
detector.scan_file(file_path)
}
fn node_text<'a>(node: Node<'a>, source: &'a [u8]) -> &'a str {
std::str::from_utf8(&source[node.start_byte()..node.end_byte()]).unwrap_or("")
}
fn extract_code_snippet(source: &[u8], location: &Location) -> Option<String> {
let source_str = std::str::from_utf8(source).ok()?;
let lines: Vec<&str> = source_str.lines().collect();
let start = location.line.saturating_sub(2);
let end = (location.end_line + 1).min(lines.len());
let snippet: Vec<String> = lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| format!("{:4} | {}", start + i + 1, line))
.collect();
Some(snippet.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
let mut file = tempfile::Builder::new()
.suffix(extension)
.tempfile()
.expect("Failed to create temp file");
file.write_all(content.as_bytes()).expect("Failed to write");
file
}
#[test]
fn test_innerhtml_xss() {
let source = r#"
const userInput = location.search;
document.getElementById('output').innerHTML = userInput;
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect innerHTML XSS");
let finding = &findings[0];
assert_eq!(finding.sink_type, XSSSinkType::Dom);
assert!(finding.severity >= Severity::High);
}
#[test]
fn test_outerhtml_xss() {
let source = r#"
function render(data) {
element.outerHTML = data;
}
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect outerHTML XSS");
assert_eq!(findings[0].sink_type, XSSSinkType::Dom);
}
#[test]
fn test_innerhtml_sanitized_safe() {
let source = r#"
const userInput = location.search;
document.getElementById('output').innerHTML = DOMPurify.sanitize(userInput);
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(findings.is_empty(), "Should NOT detect sanitized innerHTML");
}
#[test]
fn test_textcontent_safe() {
let source = r#"
const userInput = location.search;
document.getElementById('output').textContent = userInput;
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(findings.is_empty(), "Should NOT detect textContent (safe)");
}
#[test]
fn test_document_write_xss() {
let source = r#"
const param = new URLSearchParams(location.search).get('name');
document.write('<h1>Hello ' + param + '</h1>');
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect document.write XSS");
let finding = &findings[0];
assert_eq!(finding.sink_type, XSSSinkType::DocumentWrite);
}
#[test]
fn test_insert_adjacent_html_xss() {
let source = r#"
function insertContent(html) {
element.insertAdjacentHTML('beforeend', html);
}
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect insertAdjacentHTML XSS");
assert_eq!(findings[0].sink_type, XSSSinkType::InsertAdjacentHtml);
}
#[test]
fn test_jquery_html_xss() {
let detector = XSSDetector::new();
assert!(detector.jquery_sinks.contains_key("html"), "Should have html in jQuery sinks");
assert!(detector.jquery_sinks.contains_key("append"), "Should have append in jQuery sinks");
assert!(detector.jquery_sinks.contains_key("prepend"), "Should have prepend in jQuery sinks");
}
#[test]
fn test_jquery_append_html_xss() {
let source = r#"
function appendContent(userHtml) {
$('#list').append('<li>' + userHtml + '</li>');
}
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect jQuery .append() with HTML XSS");
}
#[test]
fn test_eval_xss() {
let source = r#"
function calculate(expression) {
return eval(expression);
}
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect eval() XSS");
assert_eq!(findings[0].sink_type, XSSSinkType::CodeExecution);
assert_eq!(findings[0].context, XSSContext::JavaScript);
}
#[test]
fn test_function_constructor_xss() {
let source = r#"
function createFunction(body) {
return new Function('x', body);
}
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect Function constructor XSS");
assert_eq!(findings[0].sink_type, XSSSinkType::CodeExecution);
}
#[test]
fn test_settimeout_string_xss() {
let detector = XSSDetector::new();
assert!(detector.timer_sinks.contains_key("setTimeout"), "Should have setTimeout in timer sinks");
assert!(detector.timer_sinks.contains_key("setInterval"), "Should have setInterval in timer sinks");
let timeout_sink = detector.timer_sinks.get("setTimeout").unwrap();
assert_eq!(timeout_sink.sink_type, XSSSinkType::TimerCodeExecution);
assert_eq!(timeout_sink.severity, Severity::High);
}
#[test]
fn test_settimeout_function_safe() {
let source = r#"
function delayedAction() {
setTimeout(() => console.log('hello'), 1000);
}
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(findings.is_empty(), "Should NOT detect setTimeout with function (safe)");
}
#[test]
fn test_react_dangerous_innerhtml_xss() {
let source = r#"
function Component({ userContent }) {
return <div dangerouslySetInnerHTML={{ __html: userContent }} />;
}
"#;
let file = create_temp_file(source, ".tsx");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect dangerouslySetInnerHTML XSS");
assert_eq!(findings[0].sink_type, XSSSinkType::ReactDangerouslySetInnerHtml);
}
#[test]
fn test_react_dangerous_innerhtml_sanitized_safe() {
let source = r#"
function Component({ userContent }) {
return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />;
}
"#;
let file = create_temp_file(source, ".tsx");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(findings.is_empty(), "Should NOT detect sanitized dangerouslySetInnerHTML");
}
#[test]
fn test_template_literal_html_xss() {
let source = r#"
function render(userName) {
const html = `<div class="user">${userName}</div>`;
element.innerHTML = html;
}
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect template literal HTML XSS");
}
#[test]
fn test_vue_vhtml_detection() {
let detector = XSSDetector::new();
assert!(detector.is_sanitized("DOMPurify.sanitize(input)"));
assert!(!detector.is_sanitized("userContent"));
}
#[test]
fn test_angular_innerhtml_pattern() {
let detector = XSSDetector::new();
assert!(detector.looks_like_html("<div>content</div>"));
assert!(detector.looks_like_html("<div [innerHTML]=\"data\"></div>"));
}
#[test]
fn test_high_confidence_user_input() {
let source = r#"
const search = location.search;
document.body.innerHTML = search;
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(!findings.is_empty(), "Should detect XSS");
assert!(findings[0].sink_type == XSSSinkType::Dom, "Should be DOM XSS");
}
#[test]
fn test_severity_display() {
assert_eq!(Severity::Critical.to_string(), "CRITICAL");
assert_eq!(Severity::High.to_string(), "HIGH");
assert_eq!(Severity::Medium.to_string(), "MEDIUM");
assert_eq!(Severity::Low.to_string(), "LOW");
assert_eq!(Severity::Info.to_string(), "INFO");
}
#[test]
fn test_severity_ordering() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
assert!(Severity::Low > Severity::Info);
}
#[test]
fn test_confidence_ordering() {
assert!(Confidence::High > Confidence::Medium);
assert!(Confidence::Medium > Confidence::Low);
}
#[test]
fn test_sink_type_display() {
assert_eq!(XSSSinkType::Dom.to_string(), "dom");
assert_eq!(XSSSinkType::DocumentWrite.to_string(), "document_write");
assert_eq!(XSSSinkType::ReactDangerouslySetInnerHtml.to_string(), "react_dangerously_set_inner_html");
assert_eq!(XSSSinkType::CodeExecution.to_string(), "code_execution");
}
#[test]
fn test_context_display() {
assert_eq!(XSSContext::HtmlContent.to_string(), "html_content");
assert_eq!(XSSContext::JavaScript.to_string(), "javascript");
assert_eq!(XSSContext::Url.to_string(), "url");
}
#[test]
fn test_sanitization_detection() {
let detector = XSSDetector::new();
assert!(detector.is_sanitized("DOMPurify.sanitize(input)"));
assert!(detector.is_sanitized("escape(userInput)"));
assert!(detector.is_sanitized("escapeHtml(content)"));
assert!(detector.is_sanitized("sanitizeHtml(html)"));
assert!(detector.is_sanitized("encodeURIComponent(url)"));
assert!(!detector.is_sanitized("userInput"));
assert!(!detector.is_sanitized("rawContent"));
}
#[test]
fn test_html_detection() {
let detector = XSSDetector::new();
assert!(detector.looks_like_html("<div>content</div>"));
assert!(detector.looks_like_html("<script>alert(1)</script>"));
assert!(detector.looks_like_html("<img src='x'>"));
assert!(detector.looks_like_html("<a href='#'>link</a>"));
assert!(!detector.looks_like_html("plain text"));
assert!(!detector.looks_like_html("x + y"));
}
#[test]
fn test_location_display() {
let loc = Location {
file: "test.js".to_string(),
line: 10,
column: 5,
end_line: 10,
end_column: 30,
};
assert_eq!(format!("{}", loc), "test.js:10:5");
}
#[test]
fn test_multiple_vulnerabilities() {
let source = r#"
const userInput = location.search;
// Multiple XSS vulnerabilities
document.getElementById('out1').innerHTML = userInput;
document.write(userInput);
$('#out2').html(userInput);
eval(userInput);
"#;
let file = create_temp_file(source, ".js");
let findings = scan_file_xss(file.path()).expect("Scan should succeed");
assert!(findings.len() >= 3, "Should detect multiple XSS vulnerabilities");
let sink_types: HashSet<_> = findings.iter().map(|f| f.sink_type).collect();
assert!(sink_types.contains(&XSSSinkType::Dom), "Should have DOM sink");
assert!(sink_types.contains(&XSSSinkType::DocumentWrite), "Should have document.write sink");
}
#[test]
fn test_scan_directory() {
let dir = tempfile::tempdir().expect("Failed to create temp dir");
let vuln_path = dir.path().join("vulnerable.js");
std::fs::write(&vuln_path, r#"
const x = location.search;
document.body.innerHTML = x;
"#).expect("Failed to write file");
let safe_path = dir.path().join("safe.js");
std::fs::write(&safe_path, r#"
const x = location.search;
document.body.textContent = x;
"#).expect("Failed to write file");
let result = scan_xss(dir.path(), None).expect("Scan should succeed");
assert!(result.files_scanned >= 1, "Should scan at least one file");
assert!(!result.findings.is_empty(), "Should find vulnerability in vulnerable.js");
assert!(result.findings.iter().any(|f| f.sink_type == XSSSinkType::Dom),
"Should detect DOM XSS in vulnerable.js");
}
}