use crate::commands::scan::Diag;
use crate::evidence::{Confidence, Evidence, SpanEvidence};
use crate::patterns::{FindingCategory, Severity};
use std::path::Path;
pub const RULE_ID: &str = "js.xss.ejs_unescaped";
pub fn scan_ejs_file(path: &Path, bytes: &[u8]) -> Vec<Diag> {
let Ok(text) = std::str::from_utf8(bytes) else {
return vec![];
};
let path_str = path.to_string_lossy().into_owned();
let mut out = Vec::new();
for (line_idx, line) in text.lines().enumerate() {
let line_no = line_idx + 1;
let mut search_from = 0;
while let Some(start) = line[search_from..].find("<%-") {
let abs_start = search_from + start;
let after_tag = abs_start + 3;
let Some(end) = line[after_tag..].find("%>") else {
break; };
let abs_end = after_tag + end;
let expr = &line[after_tag..abs_end];
search_from = abs_end + 2;
if is_include_call(expr) {
continue;
}
let col = abs_start + 1; let expr_trimmed = expr.trim();
let snippet = &line[abs_start..abs_end + 2];
out.push(Diag {
path: path_str.clone(),
line: line_no,
col,
severity: Severity::Medium,
id: RULE_ID.to_owned(),
category: FindingCategory::Security,
path_validated: false,
guard_kind: None,
message: Some(format!(
"Unescaped EJS output `<%- {expr_trimmed} %>` renders raw HTML. \
If the expression contains user-controlled data, this is an XSS \
sink. Use `<%= ... %>` for auto-escaped output."
)),
labels: vec![("expression".into(), expr_trimmed.to_owned())],
confidence: Some(Confidence::Medium),
evidence: Some(Evidence {
sink: Some(SpanEvidence {
path: path_str.clone(),
line: line_no as u32,
col: col as u32,
kind: "sink".into(),
snippet: Some(snippet.to_owned()),
}),
..Default::default()
}),
rank_score: None,
rank_reason: None,
suppressed: false,
suppression: None,
rollup: None,
finding_id: String::new(),
alternative_finding_ids: Vec::new(),
});
}
}
out
}
fn is_include_call(expr: &str) -> bool {
let trimmed = expr.trim_start();
if !trimmed.starts_with("include") {
return false;
}
let rest = trimmed["include".len()..].trim_start();
rest.starts_with('(')
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn detects_unescaped_variable() {
let src = b"<h1><%- query %></h1>";
let path = PathBuf::from("views/search.ejs");
let diags = scan_ejs_file(&path, src);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].id, RULE_ID);
assert_eq!(diags[0].line, 1);
assert_eq!(diags[0].severity, Severity::Medium);
assert!(diags[0].message.as_ref().unwrap().contains("query"));
}
#[test]
fn skips_escaped_output() {
let src = b"<h1><%= safe %></h1>";
let path = PathBuf::from("views/safe.ejs");
let diags = scan_ejs_file(&path, src);
assert!(diags.is_empty());
}
#[test]
fn skips_include_calls() {
let src = b"<%- include('header') %>\n<%- include(\"footer\") %>";
let path = PathBuf::from("views/layout.ejs");
let diags = scan_ejs_file(&path, src);
assert!(diags.is_empty());
}
#[test]
fn detects_multiple_on_same_line() {
let src = b"<%- first %> and <%- second %>";
let path = PathBuf::from("views/multi.ejs");
let diags = scan_ejs_file(&path, src);
assert_eq!(diags.len(), 2);
}
#[test]
fn detects_complex_expression() {
let src = b"<%- user.name.toUpperCase() %>";
let path = PathBuf::from("views/profile.ejs");
let diags = scan_ejs_file(&path, src);
assert_eq!(diags.len(), 1);
assert!(
diags[0]
.message
.as_ref()
.unwrap()
.contains("user.name.toUpperCase()")
);
}
#[test]
fn correct_line_numbers() {
let src = b"line 1\nline 2\n<%- danger %>\nline 4";
let path = PathBuf::from("views/lines.ejs");
let diags = scan_ejs_file(&path, src);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].line, 3);
}
#[test]
fn handles_non_utf8() {
let src = &[0xff, 0xfe, 0x00];
let path = PathBuf::from("views/binary.ejs");
let diags = scan_ejs_file(&path, src);
assert!(diags.is_empty());
}
#[test]
fn is_include_call_positive() {
assert!(is_include_call(" include('header') "));
assert!(is_include_call("include(\"footer\")"));
assert!(is_include_call(" include( 'partials/nav' )"));
}
#[test]
fn is_include_call_negative() {
assert!(!is_include_call(" query "));
assert!(!is_include_call(" includes.header "));
assert!(!is_include_call(" user.name "));
}
}