use crate::detectors::ast_fingerprint::parse_root_ext;
use crate::detectors::ast_walk::AstWalkCtx;
use crate::detectors::base::{Detector, DetectorConfig};
use crate::detectors::fast_search::*;
use crate::detectors::security::ast_helpers::{
collect_named_args, node_text, receiver_chain_label, unwrap_callee,
};
use crate::detectors::security::dangerous_sinks::{classify_sink, Lang, SinkKind};
use crate::detectors::security::scan_inputs::{ScanAstInputs, ScanInputs};
use crate::detectors::taint::{TaintAnalyzer, TaintCategory};
use crate::graph::GraphQueryExt;
use crate::models::{Evidence, Finding, Severity, SourceSpan, Tier};
use crate::parsers::lightweight::Language;
use anyhow::Result;
use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tracing::{debug, info};
const SUPPORTED_EXTS: &[&str] = &[
"py", "js", "ts", "jsx", "tsx", "rb", "php",
];
const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"tests/",
"test_",
"_test.py",
"migrations/",
"__pycache__/",
".git/",
"node_modules/",
"venv/",
".venv/",
"management/commands/",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EvalArgKind {
StaticLiteral,
Interpolated,
Variable,
FunctionLike,
Unknown,
}
impl EvalArgKind {
fn fires_for_timer(self) -> bool {
matches!(self, EvalArgKind::StaticLiteral | EvalArgKind::Interpolated)
}
}
pub struct EvalDetector {
config: DetectorConfig,
repository_path: PathBuf,
max_findings: usize,
exclude_patterns: Vec<String>,
compiled_globs: Vec<Regex>,
taint_analyzer: TaintAnalyzer,
precomputed_cross: std::sync::OnceLock<Vec<crate::detectors::taint::TaintPath>>,
precomputed_intra: std::sync::OnceLock<Vec<crate::detectors::taint::TaintPath>>,
}
impl EvalDetector {
pub fn new() -> Self {
Self::with_config(DetectorConfig::new(), PathBuf::from("."))
}
pub fn with_repository_path(repository_path: PathBuf) -> Self {
Self::with_config(DetectorConfig::new(), repository_path)
}
pub fn with_config(config: DetectorConfig, repository_path: PathBuf) -> Self {
let max_findings = config.get_option_or("max_findings", 100);
let exclude_patterns = config
.get_option::<Vec<String>>("exclude_patterns")
.unwrap_or_else(|| {
DEFAULT_EXCLUDE_PATTERNS
.iter()
.map(|s| s.to_string())
.collect()
});
let compiled_globs: Vec<Regex> = exclude_patterns
.iter()
.filter(|p| p.contains('*'))
.filter_map(|p| {
let re_str = format!("^{}$", p.replace('*', ".*"));
Regex::new(&re_str).ok()
})
.collect();
Self {
config,
repository_path,
max_findings,
exclude_patterns,
compiled_globs,
taint_analyzer: TaintAnalyzer::new(),
precomputed_cross: std::sync::OnceLock::new(),
precomputed_intra: std::sync::OnceLock::new(),
}
}
fn should_exclude(&self, path: &str) -> bool {
for pattern in &self.exclude_patterns {
if pattern.ends_with('/') {
let dir = pattern.trim_end_matches('/');
if dir.contains('/') {
if path.contains(pattern.as_str()) || path.contains(dir) {
return true;
}
} else if path.split('/').any(|p| p == dir) {
return true;
}
} else if pattern.contains('*') {
continue;
} else if path.contains(pattern) {
return true;
}
}
let filename = Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
for re in &self.compiled_globs {
if re.is_match(path) || re.is_match(filename) {
return true;
}
}
false
}
fn scan_file_ast(&self, inputs: &ScanAstInputs<'_>) -> Vec<Finding> {
let path = inputs.path();
let content = inputs.content();
let ext = inputs.ext();
let lang = inputs.lang;
let cached_tree = inputs.cached_tree;
let mut findings = vec![];
if content.contains('\0') {
return findings;
}
let owned;
let root = match cached_tree {
Some(tree) => tree.root_node(),
None => match parse_root_ext(content, lang, ext) {
Some(t) => {
owned = t;
owned.root_node()
}
None => return findings,
},
};
let bytes = content.as_bytes();
let lines: Vec<&str> = content.lines().collect();
let alias_map = if matches!(lang, Language::Python) {
super::python_imports::collect_python_from_imports(root, bytes)
} else {
HashMap::new()
};
let module_aliases = if matches!(lang, Language::Python) {
super::python_imports::collect_python_module_aliases(root, bytes)
} else {
HashMap::new()
};
let mut sites: Vec<EvalSite> = Vec::new();
let ctx = AstWalkCtx {
lang,
source: bytes,
};
let aliases = super::python_imports::PythonAliases::new(&alias_map, &module_aliases);
collect_eval_sites(&ctx, root, &aliases, &mut sites);
for site in sites {
if findings.len() >= self.max_findings {
break;
}
let line_idx = site.call_node.start_position().row;
if let Some(line) = lines.get(line_idx) {
let prev = if line_idx > 0 {
Some(lines[line_idx - 1])
} else {
None
};
if crate::detectors::is_line_suppressed(line, prev) {
continue;
}
}
let snippet = lines.get(line_idx).map(|s| s.trim()).unwrap_or("");
let line_num = (line_idx + 1) as u32;
let severity = match site.api {
EvalApi::Eval | EvalApi::Exec | EvalApi::NewFunction | EvalApi::Compile => {
match site.arg_kind {
EvalArgKind::StaticLiteral => Severity::Low,
EvalArgKind::Interpolated | EvalArgKind::Variable => Severity::Critical,
EvalArgKind::FunctionLike => Severity::Low,
EvalArgKind::Unknown => Severity::High,
}
}
EvalApi::SetTimeout | EvalApi::SetInterval => {
if !site.arg_kind.fires_for_timer() {
continue;
}
match site.arg_kind {
EvalArgKind::StaticLiteral => Severity::High,
EvalArgKind::Interpolated | EvalArgKind::Variable => Severity::Critical,
_ => Severity::High,
}
}
EvalApi::VmRun => match site.arg_kind {
EvalArgKind::StaticLiteral => Severity::High,
EvalArgKind::Interpolated | EvalArgKind::Variable => Severity::Critical,
_ => Severity::High,
},
EvalApi::InstanceEval | EvalApi::Assert => match site.arg_kind {
EvalArgKind::StaticLiteral => Severity::Low,
EvalArgKind::Interpolated | EvalArgKind::Variable => Severity::Critical,
_ => Severity::High,
},
};
findings.push(self.build_finding(
path,
line_num,
site.api,
site.arg_kind,
severity,
snippet,
ext,
));
}
findings
}
fn scan_file_line(&self, inputs: &ScanInputs<'_>) -> Vec<Finding> {
let path = inputs.path;
let content = inputs.content;
let ext = inputs.ext;
let mut findings = vec![];
if content.len() > 500_000 {
return findings;
}
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
if findings.len() >= self.max_findings {
break;
}
let prev = if i > 0 { Some(lines[i - 1]) } else { None };
if crate::detectors::is_line_suppressed(line, prev) {
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('#') || trimmed.starts_with("//") {
continue;
}
if let Some((api, arg_kind)) = match_line_eval(line, ext) {
let line_num = (i + 1) as u32;
let severity = match arg_kind {
EvalArgKind::StaticLiteral => Severity::Low,
EvalArgKind::Interpolated | EvalArgKind::Variable => Severity::Critical,
_ => Severity::High,
};
findings.push(self.build_finding(
path,
line_num,
api,
arg_kind,
severity,
line.trim(),
ext,
));
}
}
findings
}
fn build_finding(
&self,
path: &Path,
line_num: u32,
api: EvalApi,
arg_kind: EvalArgKind,
severity: Severity,
snippet: &str,
ext: &str,
) -> Finding {
let api_name = api.callee_label();
let title = format!("Code Injection via {}", api_name);
let arg_desc = match arg_kind {
EvalArgKind::StaticLiteral => "static string literal (low risk)",
EvalArgKind::Interpolated => "string with variable interpolation (RCE risk)",
EvalArgKind::Variable => "non-literal expression (RCE risk)",
EvalArgKind::FunctionLike => "function value (still risky for eval)",
EvalArgKind::Unknown => "non-static argument",
};
let lang_label = match ext {
"py" => "python",
"js" | "jsx" => "javascript",
"ts" | "tsx" => "typescript",
"rb" => "ruby",
"php" => "php",
_ => "",
};
let description = format!(
"**Potential Code Injection (CWE-94)**\n\n\
**API**: `{}`\n\n\
**Argument shape**: {}\n\n\
**Location**: {}:{}\n\n\
**Code snippet**:\n```{}\n{}\n```\n\n\
Dynamic code execution APIs run their argument as program text. \
When that argument is anything other than a constant the caller controls \
at write time, attackers who can influence the value get arbitrary \
code execution.",
api_name,
arg_desc,
path.display(),
line_num,
lang_label,
snippet,
);
let suggested_fix = self.recommend(api, ext);
Finding {
id: String::new(),
detector: "EvalDetector".to_string(),
severity,
title,
description,
affected_files: vec![path.to_path_buf()],
line_start: Some(line_num),
line_end: Some(line_num),
suggested_fix: Some(suggested_fix),
estimated_effort: Some("Medium (1-4 hours)".to_string()),
category: Some("security".to_string()),
cwe_id: Some("CWE-94".to_string()),
why_it_matters: Some(
"Code execution vulnerabilities allow attackers to run arbitrary code on the server, \
potentially leading to complete system compromise."
.to_string(),
),
..Default::default()
}
}
fn recommend(&self, api: EvalApi, ext: &str) -> String {
match (api, ext) {
(EvalApi::Eval | EvalApi::Exec | EvalApi::Compile, "py") => {
"Avoid `eval`/`exec`/`compile` on user-controlled input.\n\n\
- For parsing literal data structures: use `ast.literal_eval`.\n\
- For JSON: use `json.loads`.\n\
- For dispatch tables: use a dict mapping name → handler.\n\
- If `compile` is required, ensure the source argument is a \
program-author-controlled constant, not user input."
.to_string()
}
(
EvalApi::Eval
| EvalApi::NewFunction
| EvalApi::SetTimeout
| EvalApi::SetInterval
| EvalApi::VmRun,
"js" | "ts" | "jsx" | "tsx",
) => "Avoid dynamic code-execution APIs on user-controlled input.\n\n\
- Replace `eval` / `new Function(str)` with explicit dispatch.\n\
- Replace `setTimeout(stringArg, ms)` with `setTimeout(() => fn(), ms)`.\n\
- For sandboxed evaluation, prefer a worker / iframe / WASM \
isolate with no host-API access — `vm.runInNewContext` is NOT \
a real sandbox."
.to_string(),
(_, "rb") => "Avoid `eval` / `instance_eval` / `class_eval` on user input. \
Use `public_send` with an allowlist of method names instead."
.to_string(),
(EvalApi::Assert, "php") | (_, "php") => {
"Avoid `eval` and `assert` with string arguments. PHP's \
`assert` evaluates string args as code; pass a boolean expression \
instead, or remove the assertion."
.to_string()
}
_ => "Avoid dynamic code execution on user-controlled input.".to_string(),
}
}
}
impl Default for EvalDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for EvalDetector {
fn name(&self) -> &'static str {
"EvalDetector"
}
fn description(&self) -> &'static str {
"Detects dangerous code execution patterns (eval, exec, new Function, setTimeout-string, ...)"
}
fn bypass_postprocessor(&self) -> bool {
true
}
fn category(&self) -> &'static str {
"security"
}
fn config(&self) -> Option<&DetectorConfig> {
Some(&self.config)
}
crate::detectors::impl_taint_precompute!();
fn taint_category(&self) -> Option<crate::detectors::taint::TaintCategory> {
Some(TaintCategory::CodeInjection)
}
fn file_extensions(&self) -> &'static [&'static str] {
SUPPORTED_EXTS
}
fn content_requirements(&self) -> crate::detectors::detector_context::ContentFlags {
crate::detectors::detector_context::ContentFlags::HAS_EVAL
}
fn detect(
&self,
ctx: &crate::detectors::analysis_context::AnalysisContext,
) -> Result<Vec<Finding>> {
let graph = ctx.graph;
debug!("Starting eval/exec detection (AST-first)");
let files = ctx.as_file_provider();
let mut findings: Vec<Finding> = Vec::new();
for path in files.files_with_extensions(SUPPORTED_EXTS) {
if findings.len() >= self.max_findings {
break;
}
let path_str = path.to_string_lossy().to_string();
if self.should_exclude(&path_str) {
continue;
}
let content = match files.content(path) {
Some(c) => c,
None => continue,
};
if !contains_any_eval_keyword(&content) {
continue;
}
if content.len() > 500_000 {
continue;
}
let scan_source = content;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let lang = Language::from_path(path);
let has_ast_grammar = matches!(
lang,
Language::Python | Language::JavaScript | Language::TypeScript
);
let new_findings = if has_ast_grammar {
let cached = files.tree(path);
let scan = ScanInputs::new(path, &scan_source, ext);
let ast_inputs = ScanAstInputs::new(scan, lang, cached.as_deref());
self.scan_file_ast(&ast_inputs)
} else {
let scan = ScanInputs::new(path, &scan_source, ext);
self.scan_file_line(&scan)
};
findings.extend(new_findings);
}
let mut taint_results = if let Some(cross) = self.precomputed_cross.get() {
cross.clone()
} else {
self.taint_analyzer
.trace_taint(graph, TaintCategory::CodeInjection)
};
let intra_paths = if let Some(intra) = self.precomputed_intra.get() {
intra.clone()
} else {
crate::detectors::taint::run_intra_function_taint(
&self.taint_analyzer,
graph,
TaintCategory::CodeInjection,
&self.repository_path,
)
};
taint_results.extend(intra_paths);
for finding in &mut findings {
let file_path = finding
.affected_files
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let line = finding.line_start.unwrap_or(0);
for taint in &taint_results {
if taint.sink_file == file_path && taint.sink_line == line {
if taint.is_sanitized {
finding.severity = Severity::Low;
} else {
finding.severity = Severity::Critical;
finding.description = format!(
"{}\n\n**Taint Analysis:** Unsanitized data flow from {} (line {}) to sink.",
finding.description, taint.source_function, taint.source_line
);
}
break;
}
}
}
for finding in &mut findings {
let file_path = finding
.affected_files
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let line = finding.line_start.unwrap_or(0);
let ext = std::path::Path::new(&file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let lang = match ext {
"py" => Some(Lang::Python),
"js" | "jsx" => Some(Lang::Js),
"ts" | "tsx" => Some(Lang::Ts),
_ => None,
};
for taint in &taint_results {
if taint.sink_file != file_path || taint.sink_line != line {
continue;
}
if taint.is_sanitized {
break;
}
let (receiver, name) = parse_sink_callee_text(&taint.sink_callee_text);
if let Some(lang) = lang {
if let Some(SinkKind::Eval) = classify_sink(lang, receiver, name) {
finding.tier = Tier::Blocking;
finding.deterministic = true;
finding.confidence = Some(0.95);
finding.evidence = Some(Evidence::TaintPath {
source: SourceSpan {
file: std::path::PathBuf::from(&taint.source_file),
line_start: taint.source_line,
line_end: taint.source_line,
snippet: None,
},
sink: SourceSpan {
file: std::path::PathBuf::from(&taint.sink_file),
line_start: taint.sink_line,
line_end: taint.sink_line,
snippet: None,
},
sink_kind: "eval".to_string(),
flow: vec![],
sanitizers_seen: taint.sanitizers_on_path.clone(),
});
}
}
break;
}
}
static HANDLER_VERB_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(get|post|put|delete|patch|head|options)[A-Z]").expect("valid regex")
});
for finding in &mut findings {
if !matches!(finding.severity, Severity::High | Severity::Medium) {
continue;
}
if let (Some(file_path), Some(line)) =
(finding.affected_files.first(), finding.line_start)
{
let path_str = file_path.to_string_lossy().to_string();
let i = graph.interner();
if let Some(func) = graph.find_function_at(&path_str, line) {
let raw_name = func.node_name(i);
let name_lower = raw_name.to_lowercase();
let is_route = name_lower.contains("handler")
|| name_lower.contains("route")
|| name_lower.contains("endpoint")
|| name_lower.contains("view")
|| name_lower.contains("controller")
|| name_lower.contains("middleware")
|| name_lower.contains("request")
|| name_lower.contains("response")
|| HANDLER_VERB_RE.is_match(raw_name);
if is_route {
finding.severity = Severity::Critical;
}
}
}
}
findings.retain(|f| f.severity != Severity::Low);
info!(
"EvalDetector found {} potential code-injection sites",
findings.len()
);
Ok(findings)
}
}
impl crate::detectors::RegisteredDetector for EvalDetector {
fn create(init: &crate::detectors::DetectorInit) -> std::sync::Arc<dyn Detector> {
std::sync::Arc::new(Self::with_repository_path(init.repo_path.to_path_buf()))
}
fn max_tier() -> crate::models::Tier {
crate::models::Tier::Blocking
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EvalApi {
Eval,
Exec,
Compile,
NewFunction,
SetTimeout,
SetInterval,
VmRun,
InstanceEval,
Assert,
}
impl EvalApi {
fn callee_label(self) -> &'static str {
match self {
EvalApi::Eval => "eval",
EvalApi::Exec => "exec",
EvalApi::Compile => "compile",
EvalApi::NewFunction => "new Function",
EvalApi::SetTimeout => "setTimeout",
EvalApi::SetInterval => "setInterval",
EvalApi::VmRun => "vm.runInThisContext",
EvalApi::InstanceEval => "instance_eval",
EvalApi::Assert => "assert",
}
}
}
struct EvalSite<'a> {
call_node: tree_sitter::Node<'a>,
api: EvalApi,
arg_kind: EvalArgKind,
}
fn collect_eval_sites<'a>(
ctx: &AstWalkCtx<'a>,
node: tree_sitter::Node<'a>,
aliases: &super::python_imports::PythonAliases<'_>,
out: &mut Vec<EvalSite<'a>>,
) {
if let Some(site) = match_eval_site(node, ctx.source, ctx.lang, aliases) {
out.push(site);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_eval_sites(ctx, child, aliases, out);
}
}
fn match_eval_site<'a>(
node: tree_sitter::Node<'a>,
source: &'a [u8],
lang: Language,
aliases: &super::python_imports::PythonAliases<'_>,
) -> Option<EvalSite<'a>> {
match (node.kind(), lang) {
("call", Language::Python) => {
let func = node.child_by_field_name("function")?;
let _ = aliases.imports;
let name: &str = match func.kind() {
"identifier" => node_text(func, source)?,
"attribute" => {
let obj = func.child_by_field_name("object")?;
let attr = func.child_by_field_name("attribute")?;
let obj_text = node_text(obj, source).unwrap_or("");
let resolved = aliases.modules.get(obj_text).map(|s| s.as_str());
if resolved != Some("builtins") {
return None;
}
node_text(attr, source)?
}
_ => return None,
};
let api = match name {
"eval" => EvalApi::Eval,
"exec" => EvalApi::Exec,
"compile" => EvalApi::Compile,
_ => return None,
};
let args = node.child_by_field_name("arguments")?;
let arg_nodes = collect_named_args(args);
if api == EvalApi::Compile && !python_compile_mode_is_dangerous(&arg_nodes, source) {
return None;
}
let first = arg_nodes.first().copied()?;
let arg_kind = classify_eval_arg_python(first, source);
Some(EvalSite {
call_node: node,
api,
arg_kind,
})
}
("call_expression", Language::JavaScript | Language::TypeScript) => {
let func = node.child_by_field_name("function")?;
let args = node.child_by_field_name("arguments")?;
let arg_nodes = collect_named_args(args);
let func = unwrap_callee(func);
let api = match func.kind() {
"identifier" => match node_text(func, source)? {
"eval" => EvalApi::Eval,
"setTimeout" => EvalApi::SetTimeout,
"setInterval" => EvalApi::SetInterval,
_ => return None,
},
"member_expression" => {
let obj = func.child_by_field_name("object")?;
let prop = func.child_by_field_name("property")?;
let prop_text = node_text(prop, source)?;
let recv = receiver_chain_label(obj, source, None);
if recv == "eval" && matches!(prop_text, "call" | "apply" | "bind") {
let code_arg = arg_nodes.get(1).copied()?;
let arg_kind = classify_eval_arg_js(code_arg, source);
return Some(EvalSite {
call_node: node,
api: EvalApi::Eval,
arg_kind,
});
}
if prop_text == "eval"
&& matches!(
recv.as_str(),
"globalthis" | "window" | "self" | "globalobject" | "global"
)
{
EvalApi::Eval
} else if matches!(
prop_text,
"runInThisContext" | "runInNewContext" | "runInContext"
) && matches!(
recv.as_str(),
"vm" | "globalthis" | "window" | "self" | "globalobject"
) {
EvalApi::VmRun
} else {
return None;
}
}
_ => return None,
};
let first = arg_nodes.first().copied()?;
let arg_kind = classify_eval_arg_js(first, source);
Some(EvalSite {
call_node: node,
api,
arg_kind,
})
}
("new_expression", Language::JavaScript | Language::TypeScript) => {
let ctor = node.child_by_field_name("constructor")?;
let args = node.child_by_field_name("arguments")?;
let arg_nodes = collect_named_args(args);
if ctor.kind() == "identifier" {
let name = node_text(ctor, source)?;
if name != "Function" {
return None;
}
let first = arg_nodes.first().copied()?;
let arg_kind = classify_eval_arg_js(first, source);
return Some(EvalSite {
call_node: node,
api: EvalApi::NewFunction,
arg_kind,
});
}
if ctor.kind() == "member_expression" {
let prop = ctor.child_by_field_name("property")?;
let prop_text = node_text(prop, source)?;
if matches!(prop_text, "Script" | "SourceTextModule" | "Module") {
let first = arg_nodes.first().copied()?;
let arg_kind = classify_eval_arg_js(first, source);
return Some(EvalSite {
call_node: node,
api: EvalApi::VmRun,
arg_kind,
});
}
}
None
}
_ => None,
}
}
fn python_compile_mode_is_dangerous(args: &[tree_sitter::Node<'_>], source: &[u8]) -> bool {
for a in args {
if a.kind() == "keyword_argument" {
let name = a
.child_by_field_name("name")
.and_then(|n| node_text(n, source));
if name == Some("mode") {
if let Some(value) = a.child_by_field_name("value") {
return string_node_value(value, source)
.map(|s| matches!(s.as_str(), "exec" | "eval" | "single"))
.unwrap_or(true); }
}
}
}
let positional: Vec<_> = args
.iter()
.filter(|a| a.kind() != "keyword_argument")
.collect();
if let Some(third) = positional.get(2) {
if let Some(s) = string_node_value(**third, source) {
return matches!(s.as_str(), "exec" | "eval" | "single");
}
return true;
}
false
}
fn string_node_value(node: tree_sitter::Node<'_>, source: &[u8]) -> Option<String> {
if node.kind() != "string" {
return None;
}
let mut cursor = node.walk();
let mut content = String::new();
for child in node.children(&mut cursor) {
match child.kind() {
"string_start" | "string_end" => {}
"string_content" => {
if let Some(t) = node_text(child, source) {
content.push_str(t);
}
}
"interpolation" => return None,
_ => {}
}
}
Some(content)
}
#[allow(clippy::only_used_in_recursion)]
fn classify_eval_arg_python(node: tree_sitter::Node<'_>, source: &[u8]) -> EvalArgKind {
match node.kind() {
"string" => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "interpolation" {
return EvalArgKind::Interpolated;
}
}
EvalArgKind::StaticLiteral
}
"concatenated_string" => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if classify_eval_arg_python(child, source) == EvalArgKind::Interpolated {
return EvalArgKind::Interpolated;
}
}
EvalArgKind::StaticLiteral
}
"binary_operator" => {
let mut found_var = false;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if !child.is_named() {
continue;
}
match classify_eval_arg_python(child, source) {
EvalArgKind::Variable | EvalArgKind::Interpolated | EvalArgKind::Unknown => {
found_var = true;
}
_ => {}
}
}
if found_var {
EvalArgKind::Interpolated
} else {
EvalArgKind::StaticLiteral
}
}
"identifier" | "attribute" | "subscript" | "call" => EvalArgKind::Variable,
"lambda" => EvalArgKind::FunctionLike,
"parenthesized_expression" => {
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
return classify_eval_arg_python(c, source);
}
}
EvalArgKind::Unknown
}
"await" => {
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
return classify_eval_arg_python(c, source);
}
}
EvalArgKind::Unknown
}
"conditional_expression" => {
let mut strongest = EvalArgKind::StaticLiteral;
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
let k = classify_eval_arg_python(c, source);
strongest = strongest_arg_kind(strongest, k);
}
}
strongest
}
_ => EvalArgKind::Unknown,
}
}
fn strongest_arg_kind(a: EvalArgKind, b: EvalArgKind) -> EvalArgKind {
fn rank(k: EvalArgKind) -> u8 {
match k {
EvalArgKind::Variable => 4,
EvalArgKind::Interpolated => 3,
EvalArgKind::Unknown => 2,
EvalArgKind::FunctionLike => 1,
EvalArgKind::StaticLiteral => 0,
}
}
if rank(a) >= rank(b) {
a
} else {
b
}
}
#[allow(clippy::only_used_in_recursion)]
fn classify_eval_arg_js(node: tree_sitter::Node<'_>, source: &[u8]) -> EvalArgKind {
match node.kind() {
"string" => EvalArgKind::StaticLiteral,
"template_string" => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "template_substitution" {
return EvalArgKind::Interpolated;
}
}
EvalArgKind::StaticLiteral
}
"binary_expression" => {
let left = node.child_by_field_name("left");
let right = node.child_by_field_name("right");
let mut found_var = false;
for opt in [left, right].iter().flatten() {
match classify_eval_arg_js(*opt, source) {
EvalArgKind::Variable | EvalArgKind::Interpolated | EvalArgKind::Unknown => {
found_var = true;
}
_ => {}
}
}
if found_var {
EvalArgKind::Interpolated
} else {
EvalArgKind::StaticLiteral
}
}
"identifier" | "member_expression" | "subscript_expression" | "call_expression" => {
EvalArgKind::Variable
}
"arrow_function" | "function_expression" | "function" | "function_declaration" => {
EvalArgKind::FunctionLike
}
"parenthesized_expression" => {
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
return classify_eval_arg_js(c, source);
}
}
EvalArgKind::Unknown
}
"await_expression"
| "as_expression"
| "type_assertion_expression"
| "non_null_expression"
| "satisfies_expression" => {
for i in 0..node.named_child_count() {
if let Some(c) = node.named_child(i) {
return classify_eval_arg_js(c, source);
}
}
EvalArgKind::Unknown
}
"ternary_expression" => {
let consequence = node.child_by_field_name("consequence");
let alternative = node.child_by_field_name("alternative");
let mut strongest = EvalArgKind::StaticLiteral;
for opt in [consequence, alternative].iter().flatten() {
let k = classify_eval_arg_js(*opt, source);
strongest = strongest_arg_kind(strongest, k);
}
strongest
}
_ => EvalArgKind::Unknown,
}
}
fn contains_any_eval_keyword(content: &str) -> bool {
find_in(&FIND_EVAL_PAREN, content)
|| find_in(&FIND_EXEC_PAREN, content)
|| content.contains("new Function")
|| content.contains("setTimeout(")
|| content.contains("setInterval(")
|| content.contains("vm.runIn")
|| content.contains("compile(")
|| content.contains("instance_eval")
|| content.contains("class_eval")
|| content.contains("module_eval")
|| content.contains("assert(")
|| content.contains("(eval)")
|| content.contains("eval.")
|| content.contains("eval,")
|| content.contains(", eval")
|| content.contains(",eval")
|| content.contains("vm.Script")
|| content.contains("vm.SourceTextModule")
|| content.contains(".eval(")
}
fn match_line_eval(line: &str, ext: &str) -> Option<(EvalApi, EvalArgKind)> {
static RUBY_EVAL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\b(eval|instance_eval|class_eval|module_eval)\s*\(").expect("valid regex")
});
static PHP_EVAL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(eval|assert)\s*\(").expect("valid regex"));
let re = match ext {
"rb" => &*RUBY_EVAL_RE,
"php" => &*PHP_EVAL_RE,
_ => return None,
};
let m = re.find(line)?;
let after = &line[m.end()..];
let arg_kind = classify_line_arg(after);
let api_name = re
.captures(line)?
.get(1)
.map(|m| m.as_str())
.unwrap_or("eval");
let api = match api_name {
"eval" => EvalApi::Eval,
"instance_eval" | "class_eval" | "module_eval" => EvalApi::InstanceEval,
"assert" => {
if !matches!(
arg_kind,
EvalArgKind::StaticLiteral | EvalArgKind::Interpolated
) {
return None;
}
EvalApi::Assert
}
_ => EvalApi::Eval,
};
Some((api, arg_kind))
}
fn classify_line_arg(after_paren: &str) -> EvalArgKind {
let trimmed = after_paren.trim_start();
if trimmed.starts_with('"') || trimmed.starts_with('\'') {
let quote = trimmed.as_bytes()[0];
let mut i = 1;
let bytes = trimmed.as_bytes();
let mut had_interp = false;
while i < bytes.len() {
let c = bytes[i];
if c == b'\\' {
i += 2;
continue;
}
if c == quote {
break;
}
if quote == b'"' && c == b'#' && bytes.get(i + 1) == Some(&b'{') {
had_interp = true;
}
if quote == b'"' && c == b'$' {
had_interp = true;
}
i += 1;
}
if had_interp {
EvalArgKind::Interpolated
} else {
EvalArgKind::StaticLiteral
}
} else if trimmed.starts_with(')') {
EvalArgKind::Unknown
} else {
EvalArgKind::Variable
}
}
fn parse_sink_callee_text(text: &str) -> (&str, &str) {
let text = text.trim_end_matches('(');
if let Some(dot) = text.rfind('.') {
(&text[..dot], &text[dot + 1..])
} else {
("", text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::builder::GraphBuilder;
#[test]
fn test_detects_eval_with_variable() {
let content =
"def process(user_input):\n result = eval(user_input)\n return result\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("handler.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect eval() with variable argument"
);
assert!(findings.iter().any(|f| f.detector == "EvalDetector"));
}
#[test]
fn test_no_finding_for_management_command() {
let content = "def handle(self, **options):\n code = compile(source, '<shell>', 'exec')\n exec(code)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("management/commands/shell.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Should not flag exec() in management/commands/. Found: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_no_finding_for_method_eval() {
let content = "class Operator:\n def eval(self, context):\n return self.value\n\nresult = op.eval(context)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("smartif.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let eval_findings: Vec<_> = findings
.iter()
.filter(|f| f.title.contains("eval"))
.collect();
assert!(
eval_findings.is_empty(),
"Should not flag .eval() method call. Found: {:?}",
eval_findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_no_finding_for_safe_subprocess() {
let content = "import subprocess\n\ndef run_command(args):\n result = subprocess.run(args, capture_output=True)\n return result.stdout\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("runner.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let subprocess_findings: Vec<_> = findings
.iter()
.filter(|f| {
f.title.contains("subprocess")
|| f.title.contains("command")
|| f.title.contains("Shell")
|| f.title.contains("shell")
})
.collect();
assert!(
subprocess_findings.is_empty(),
"Should not flag subprocess.run without shell=True. Found: {:?}",
subprocess_findings
.iter()
.map(|f| &f.title)
.collect::<Vec<_>>()
);
}
#[test]
fn test_no_finding_for_literal_eval() {
let content = "import ast\ndata = ast.literal_eval(\"[1, 2, 3]\")\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("safe.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"Should not flag ast.literal_eval (safe), but got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_detects_eval_with_user_input_python() {
let content = "def handle(user_input):\n return eval(user_input)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("handler.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect eval(user_input). Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_detects_exec_with_variable_python() {
let content = "def run(code_var):\n exec(code_var)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("runner.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect exec(code_var). Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_detects_eval_in_javascript() {
let content = "function handle(somevar) {\n return eval(somevar);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("handler.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect JS eval(somevar). Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_detects_new_function_javascript() {
let content =
"function build(arg) {\n const f = new Function(arg);\n return f();\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("build.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"Should detect `new Function(arg)`. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_skips_eval_in_comment() {
let content = "def handler(x):\n # eval(x) was removed for safety\n return x\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("safe.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"eval inside a comment must not fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_skips_eval_in_string_literal() {
let content = "def doc():\n msg = \"use eval() for math\"\n return msg\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("doc.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"`eval(` inside a string literal must not fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
#[allow(non_snake_case)]
fn test_detects_setTimeout_with_string_arg() {
let content = "function go() {\n setTimeout(\"alert('xss')\", 100);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"setTimeout(stringArg, ...) should fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
#[allow(non_snake_case)]
fn test_skips_setTimeout_with_function_arg() {
let content = "function go() {\n setTimeout(() => doStuff(), 100);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"setTimeout(arrowFn, ms) must not fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
#[allow(non_snake_case)]
fn test_skips_setTimeout_with_identifier_arg() {
let content = "function go() {\n setTimeout(myCallback, 100);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"setTimeout(identifier, ms) must not fire — bare identifier is a fn ref. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
#[allow(non_snake_case)]
fn test_skips_setInterval_with_identifier_arg() {
let content = "function go() {\n setInterval(tick, 1000);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"setInterval(identifier, ms) must not fire — bare identifier is a fn ref. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
#[allow(non_snake_case)]
fn test_skips_setTimeout_with_short_identifier_arg() {
let content = "function go() {\n setTimeout(r, 200);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"setTimeout(r, ms) must not fire — single-char identifier is a fn ref. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
#[allow(non_snake_case)]
fn test_detects_setTimeout_with_string_arg_still_fires() {
let content = "function bad() {\n setTimeout(\"alert('xss')\", 100);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("bad.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"setTimeout(string, ms) must still fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
#[allow(non_snake_case)]
fn test_detects_setInterval_with_string_arg_still_fires() {
let content = "function bad() {\n setInterval(\"doStuff()\", 1000);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("bad.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"setInterval(string, ms) must still fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_skips_eval_as_method_name() {
let content = "class Operator:\n def eval(self, ctx):\n return ctx\n\nresult = op.eval(ctx)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("operator.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let eval_findings: Vec<_> = findings
.iter()
.filter(|f| f.title.to_lowercase().contains("eval"))
.collect();
assert!(
eval_findings.is_empty(),
"Method-name `eval` must not be confused with builtin. Got: {:?}",
eval_findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_detects_eval_in_template_literal_concat_js() {
let content = "function run(bar) {\n return eval(`foo ${bar}`);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("run.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"eval(`foo ${{bar}}`) should fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_severity_critical_for_user_input_low_for_literal() {
let content = "def both(user_input):\n a = eval(user_input)\n b = eval(\"1 + 1\")\n return (a, b)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("both.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let var_finding = findings.iter().find(|f| f.line_start == Some(2));
let lit_finding = findings.iter().find(|f| f.line_start == Some(3));
assert!(
var_finding.is_some(),
"Variable-arg eval on line 2 must produce a finding"
);
let var_sev = var_finding.expect("checked Some above").severity;
assert!(
matches!(var_sev, Severity::Critical | Severity::High),
"Variable-arg eval should be Critical/High; got {:?}",
var_sev
);
if let Some(lit) = lit_finding {
assert!(
matches!(lit.severity, Severity::Low),
"Literal-arg eval, if reported, must be Low; got {:?}",
lit.severity
);
}
}
#[test]
fn test_detects_python_compile_with_exec_mode() {
let content = "def build(user_code):\n code = compile(user_code, '<string>', 'exec')\n exec(code)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("compile_use.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"compile(user_code, '<string>', 'exec') must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b1_member_of_member_vm_runinthiscontext_detected() {
let content = "function go(userCode) {\n this.vm.runInThisContext(userCode);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B1: this.vm.runInThisContext(...) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b1_globalthis_eval_detected() {
let content = "function go(userCode) {\n globalThis.eval(userCode);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B1: globalThis.eval(...) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b1_window_eval_detected() {
let content = "function go(userCode) {\n window.eval(userCode);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B1: window.eval(...) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b1_self_vm_runinthiscontext_detected() {
let content = "function go(userCode) {\n self.vm.runInThisContext(userCode);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B1: self.vm.runInThisContext(...) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b4_python_ternary_arg_classified_as_variable() {
let content = "def go(cond, a, b):\n eval(a if cond else b)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings
.iter()
.find(|f| f.line_start == Some(2))
.expect("B4: eval(ternary) must fire");
assert!(
matches!(f.severity, Severity::Critical),
"B4: eval(a if cond else b) should be Critical (Variable), got {:?}",
f.severity
);
}
#[test]
fn test_b4_python_await_arg_classified_as_variable() {
let content = "async def go():\n exec(await get_code())\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings
.iter()
.find(|f| f.line_start == Some(2))
.expect("B4: exec(await ...) must fire");
assert!(
matches!(f.severity, Severity::Critical),
"B4: exec(await get_code()) should be Critical (Variable), got {:?}",
f.severity
);
}
#[test]
fn test_b4_js_ternary_arg_classified_as_variable() {
let content = "function go(cond, a, b) {\n eval(cond ? a : b);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings
.iter()
.find(|f| f.line_start == Some(2))
.expect("B4: eval(ternary) must fire");
assert!(
matches!(f.severity, Severity::Critical),
"B4: eval(cond ? a : b) should be Critical (Variable), got {:?}",
f.severity
);
}
#[test]
fn test_b4_ts_as_expression_arg_classified_as_variable() {
let content = "function go(x: any) {\n eval(x as string);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.ts", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings
.iter()
.find(|f| f.line_start == Some(2))
.expect("B4: eval(x as string) must fire");
assert!(
matches!(f.severity, Severity::Critical),
"B4: eval(x as string) should be Critical (Variable), got {:?}",
f.severity
);
}
#[test]
fn test_b7_indirect_eval_comma_zero_detected() {
let content = "function go(userCode) {\n (0, eval)(userCode);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B7: (0, eval)(code) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b7_parenthesized_eval_detected() {
let content = "function go(userCode) {\n (eval)(userCode);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B7: (eval)(code) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b11_eval_call_detected() {
let content = "function go(userCode) {\n eval.call(null, userCode);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B11: eval.call(null, code) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b11_eval_apply_detected() {
let content = "function go(userCode) {\n eval.apply(null, [userCode]);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B11: eval.apply(null, [code]) must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b13_new_vm_script_detected() {
let content =
"function go(userCode) {\n new vm.Script(userCode).runInThisContext();\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B13: new vm.Script(code).runInThisContext() must fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b8_camelcase_handler_name_boosts_severity() {
let content = "function getUser(req) {\n setTimeout(\"req.body.code\", 100);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("h.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B8: getUser handler with setTimeout(stringLiteral) must still fire."
);
}
#[test]
fn test_b17_php_assert_boolean_does_not_fire() {
let content = "<?php\nfunction check($x) {\n assert($x === 1);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("c.php", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.is_empty(),
"B17: PHP assert(boolean) must not fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b17_php_assert_string_still_fires() {
let content = "<?php\nfunction bad($x) {\n assert(\"$x === 1\");\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("b.php", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
!findings.is_empty(),
"B17: PHP assert(\"...\") with string must still fire. Got: {:?}",
findings.iter().map(|f| &f.title).collect::<Vec<_>>()
);
}
#[test]
fn test_b14_eval_arrow_function_not_critical() {
let content = "function go() {\n eval(() => doStuff());\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("g.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings
.iter()
.all(|f| !matches!(f.severity, Severity::Critical)),
"B14: eval(arrowFn) must NOT be Critical. Got: {:?}",
findings
.iter()
.map(|f| (&f.title, f.severity))
.collect::<Vec<_>>()
);
}
#[test]
fn test_b3_python_compile_concatenated_string_mode() {
let content = "def b(src):\n code = compile(src, '<x>', 'ex' 'ec')\n exec(code)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("c.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings.iter().any(|f| f.line_start == Some(2)),
"B3: compile(..., 'ex' 'ec') must fire on line 2. Got: {:?}",
findings
.iter()
.map(|f| (f.line_start, &f.title))
.collect::<Vec<_>>()
);
}
#[test]
fn test_python_bare_eval_after_from_import() {
let content =
"from builtins import eval\n\ndef handle(user_code):\n return eval(user_code)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("h.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings
.iter()
.any(|f| f.line_start == Some(4) && f.severity == Severity::Critical),
"Should fire Critical on `eval(user_code)` after `from builtins import eval`. Got: {:?}",
findings
.iter()
.map(|f| (f.line_start, f.severity, &f.title))
.collect::<Vec<_>>()
);
}
#[test]
fn test_python_aliased_module_eval_detected() {
let content =
"import builtins as bi\n\ndef handle(user_code):\n return bi.eval(user_code)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("h.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
assert!(
findings
.iter()
.any(|f| f.line_start == Some(4) && f.severity == Severity::Critical),
"Should fire Critical on `bi.eval(user_code)` after `import builtins as bi`. Got: {:?}",
findings
.iter()
.map(|f| (f.line_start, f.severity, &f.title))
.collect::<Vec<_>>()
);
}
#[test]
fn taint_to_eval_sink_is_blocking() {
use crate::detectors::taint::TaintPath;
use crate::models::{Evidence, Tier};
let content = "def handler(user_input):\n eval(user_input)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let taint_path = TaintPath {
source_function: "handler".to_string(),
source_file: "handler.py".to_string(),
source_line: 1,
sink_function: "eval".to_string(),
sink_file: "handler.py".to_string(),
sink_line: 2,
category: crate::detectors::taint::TaintCategory::CodeInjection,
call_chain: vec![],
is_sanitized: false,
sanitizer: None,
confidence: 0.95,
sink_callee_text: "eval(".to_string(),
sanitizers_on_path: vec![],
};
detector
.precomputed_intra
.set(vec![taint_path])
.expect("OnceLock must be unset");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("handler.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings
.iter()
.find(|f| f.line_start == Some(2))
.expect("eval(user_input) on line 2 must produce a finding");
assert_eq!(
f.tier,
Tier::Blocking,
"SSA taint-to-eval sink must be Tier::Blocking; got {:?}",
f.tier
);
assert!(
f.deterministic,
"Blocking taint finding must have deterministic = true"
);
assert!(
f.confidence.unwrap_or(0.0) >= 0.90,
"Blocking taint finding must have confidence >= 0.90; got {:?}",
f.confidence
);
assert!(
matches!(
f.evidence,
Some(Evidence::TaintPath { ref sink_kind, .. }) if sink_kind == "eval"
),
"Blocking finding must carry Evidence::TaintPath with sink_kind = \"eval\"; got {:?}",
f.evidence
);
}
#[test]
fn eval_of_string_literal_is_advisory() {
use crate::models::Tier;
let content = "def run(x):\n eval(x)\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("run.py", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings
.iter()
.find(|f| f.line_start == Some(2))
.expect("eval(x) on line 2 must produce a finding");
assert_eq!(
f.tier,
Tier::Advisory,
"eval(x) without a taint path must stay Advisory; got {:?}",
f.tier
);
assert!(
f.evidence.is_none(),
"Advisory finding must have no evidence; got {:?}",
f.evidence
);
}
#[test]
fn taint_to_benign_callee_is_advisory() {
use crate::detectors::taint::TaintPath;
use crate::models::Tier;
let content = "function go(x) {\n eval(x);\n}\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let benign_path = TaintPath {
source_function: "go".to_string(),
source_file: "go.js".to_string(),
source_line: 1,
sink_function: "console.log".to_string(),
sink_file: "go.js".to_string(),
sink_line: 2,
category: crate::detectors::taint::TaintCategory::CodeInjection,
call_chain: vec![],
is_sanitized: false,
sanitizer: None,
confidence: 0.90,
sink_callee_text: "console.log(".to_string(),
sanitizers_on_path: vec![],
};
detector
.precomputed_intra
.set(vec![benign_path])
.expect("OnceLock must be unset");
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.js", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings.iter().find(|f| f.line_start == Some(2));
if let Some(f) = f {
assert_eq!(
f.tier,
Tier::Advisory,
"Taint to benign callee must leave eval finding at Advisory; got {:?}",
f.tier
);
}
}
#[test]
fn line_heuristic_match_is_advisory() {
use crate::models::Tier;
let content = "def go(code)\n eval(code)\nend\n";
let store = GraphBuilder::new().freeze();
let detector = EvalDetector::with_repository_path(PathBuf::from("/mock/repo"));
let ctx = crate::detectors::analysis_context::AnalysisContext::test_with_mock_files(
&store,
vec![("go.rb", content)],
);
let findings = detector.detect(&ctx).expect("detection should succeed");
let f = findings
.iter()
.find(|f| f.detector == "EvalDetector")
.expect("Ruby eval(code) must produce a finding");
assert_eq!(
f.tier,
Tier::Advisory,
"Line-heuristic finding must be Advisory; got {:?}",
f.tier
);
assert!(
f.evidence.is_none(),
"Line-heuristic finding must have no evidence; got {:?}",
f.evidence
);
}
#[test]
fn test_parse_sink_callee_text_bare_call() {
assert_eq!(super::parse_sink_callee_text("eval("), ("", "eval"));
assert_eq!(super::parse_sink_callee_text("exec("), ("", "exec"));
assert_eq!(super::parse_sink_callee_text("Function("), ("", "Function"));
assert_eq!(
super::parse_sink_callee_text("setTimeout("),
("", "setTimeout")
);
}
#[test]
fn test_parse_sink_callee_text_dotted() {
assert_eq!(
super::parse_sink_callee_text("child_process.exec"),
("child_process", "exec")
);
assert_eq!(
super::parse_sink_callee_text("subprocess.run"),
("subprocess", "run")
);
}
}