use crate::rules::c_taint;
use crate::rules::common::get_source_line;
use crate::rules::go_taint;
use crate::rules::java_taint;
use crate::rules::javascript_taint;
use crate::rules::kotlin_taint;
use crate::rules::python_taint;
use crate::rules::ruby_taint;
use crate::rules::{FileContext, Rule};
use crate::{Finding, Language, Severity};
use serde_yaml_ng::Value as YamlValue;
#[derive(Clone, Debug)]
enum GenericMatcher {
Attribute {
root: String,
field: String,
description: String,
},
Call {
canonical: String,
description: String,
},
ParamName {
names: Vec<String>,
description: String,
},
MethodName { method: String, description: String },
MemberAssign { field: String, description: String },
}
#[derive(Clone, Debug)]
struct GenericSpec {
sources: Vec<GenericMatcher>,
sinks: Vec<GenericMatcher>,
sanitizers: Vec<GenericMatcher>,
}
fn to_python_spec(g: &GenericSpec) -> python_taint::TaintSpec {
python_taint::TaintSpec {
sources: g.sources.iter().map(to_python_matcher).collect(),
sinks: g.sinks.iter().map(to_python_matcher).collect(),
sanitizers: g.sanitizers.iter().map(to_python_matcher).collect(),
}
}
fn to_python_matcher(m: &GenericMatcher) -> python_taint::NodeMatcher {
match m {
GenericMatcher::Attribute {
root,
field,
description,
} => python_taint::NodeMatcher::Attribute {
root: root.clone(),
field: field.clone(),
description: description.clone(),
},
GenericMatcher::Call {
canonical,
description,
} => python_taint::NodeMatcher::Call {
canonical: canonical.clone(),
description: description.clone(),
},
GenericMatcher::ParamName { names, description } => python_taint::NodeMatcher::ParamName {
names: names.clone(),
description: description.clone(),
},
GenericMatcher::MethodName {
method,
description,
} => python_taint::NodeMatcher::MethodName {
method: method.clone(),
description: description.clone(),
},
GenericMatcher::MemberAssign { field, description } => {
python_taint::NodeMatcher::MemberAssign {
field: field.clone(),
description: description.clone(),
}
}
}
}
fn to_js_spec(g: &GenericSpec) -> javascript_taint::TaintSpec {
javascript_taint::TaintSpec {
sources: g.sources.iter().map(to_js_matcher).collect(),
sinks: g.sinks.iter().map(to_js_matcher).collect(),
sanitizers: g.sanitizers.iter().map(to_js_matcher).collect(),
}
}
fn to_js_matcher(m: &GenericMatcher) -> javascript_taint::NodeMatcher {
match m {
GenericMatcher::Attribute {
root,
field,
description,
} => javascript_taint::NodeMatcher::Attribute {
root: root.clone(),
field: field.clone(),
description: description.clone(),
},
GenericMatcher::Call {
canonical,
description,
} => javascript_taint::NodeMatcher::Call {
canonical: canonical.clone(),
description: description.clone(),
},
GenericMatcher::ParamName { names, description } => {
javascript_taint::NodeMatcher::ParamName {
names: names.clone(),
description: description.clone(),
}
}
GenericMatcher::MethodName {
method,
description,
} => javascript_taint::NodeMatcher::MethodName {
method: method.clone(),
description: description.clone(),
},
GenericMatcher::MemberAssign { field, description } => {
javascript_taint::NodeMatcher::MemberAssign {
field: field.clone(),
description: description.clone(),
}
}
}
}
fn to_go_spec(g: &GenericSpec) -> go_taint::TaintSpec {
go_taint::TaintSpec {
sources: g.sources.iter().map(to_go_matcher).collect(),
sinks: g.sinks.iter().map(to_go_matcher).collect(),
sanitizers: g.sanitizers.iter().map(to_go_matcher).collect(),
}
}
fn to_go_matcher(m: &GenericMatcher) -> go_taint::NodeMatcher {
match m {
GenericMatcher::Attribute {
root,
field,
description,
} => go_taint::NodeMatcher::Attribute {
root: root.clone(),
field: field.clone(),
description: description.clone(),
},
GenericMatcher::Call {
canonical,
description,
} => go_taint::NodeMatcher::Call {
canonical: canonical.clone(),
description: description.clone(),
},
GenericMatcher::ParamName { names, description } => go_taint::NodeMatcher::ParamName {
names: names.clone(),
description: description.clone(),
},
GenericMatcher::MethodName {
method,
description,
} => go_taint::NodeMatcher::MethodName {
method: method.clone(),
description: description.clone(),
},
GenericMatcher::MemberAssign { field, description } => {
go_taint::NodeMatcher::MemberAssign {
field: field.clone(),
description: description.clone(),
}
}
}
}
fn to_java_spec(g: &GenericSpec) -> java_taint::TaintSpec {
java_taint::TaintSpec {
sources: g.sources.iter().map(to_java_matcher).collect(),
sinks: g.sinks.iter().map(to_java_matcher).collect(),
sanitizers: g.sanitizers.iter().map(to_java_matcher).collect(),
}
}
fn to_java_matcher(m: &GenericMatcher) -> java_taint::NodeMatcher {
match m {
GenericMatcher::Attribute {
root,
field,
description,
} => java_taint::NodeMatcher::Attribute {
root: root.clone(),
field: field.clone(),
description: description.clone(),
},
GenericMatcher::Call {
canonical,
description,
} => java_taint::NodeMatcher::Call {
canonical: canonical.clone(),
description: description.clone(),
},
GenericMatcher::ParamName { names, description } => java_taint::NodeMatcher::ParamName {
names: names.clone(),
description: description.clone(),
},
GenericMatcher::MethodName {
method,
description,
} => java_taint::NodeMatcher::MethodName {
method: method.clone(),
description: description.clone(),
},
GenericMatcher::MemberAssign { field, description } => {
java_taint::NodeMatcher::MemberAssign {
field: field.clone(),
description: description.clone(),
}
}
}
}
fn to_c_spec(g: &GenericSpec) -> c_taint::TaintSpec {
c_taint::TaintSpec {
sources: g.sources.iter().map(to_c_matcher).collect(),
sinks: g.sinks.iter().map(to_c_matcher).collect(),
sanitizers: g.sanitizers.iter().map(to_c_matcher).collect(),
}
}
fn to_c_matcher(m: &GenericMatcher) -> c_taint::NodeMatcher {
match m {
GenericMatcher::Attribute {
root,
field,
description,
} => c_taint::NodeMatcher::Attribute {
root: root.clone(),
field: field.clone(),
description: description.clone(),
},
GenericMatcher::Call {
canonical,
description,
} => c_taint::NodeMatcher::Call {
canonical: canonical.clone(),
description: description.clone(),
},
GenericMatcher::ParamName { names, description } => c_taint::NodeMatcher::ParamName {
names: names.clone(),
description: description.clone(),
},
GenericMatcher::MethodName {
method,
description,
} => c_taint::NodeMatcher::MethodName {
method: method.clone(),
description: description.clone(),
},
GenericMatcher::MemberAssign { field, description } => c_taint::NodeMatcher::MemberAssign {
field: field.clone(),
description: description.clone(),
},
}
}
fn to_kotlin_spec(g: &GenericSpec) -> kotlin_taint::TaintSpec {
kotlin_taint::TaintSpec {
sources: g.sources.iter().map(to_kotlin_matcher).collect(),
sinks: g.sinks.iter().map(to_kotlin_matcher).collect(),
sanitizers: g.sanitizers.iter().map(to_kotlin_matcher).collect(),
}
}
fn to_kotlin_matcher(m: &GenericMatcher) -> kotlin_taint::NodeMatcher {
match m {
GenericMatcher::Attribute {
root,
field,
description,
} => kotlin_taint::NodeMatcher::Attribute {
root: root.clone(),
field: field.clone(),
description: description.clone(),
},
GenericMatcher::Call {
canonical,
description,
} => kotlin_taint::NodeMatcher::Call {
canonical: canonical.clone(),
description: description.clone(),
},
GenericMatcher::ParamName { names, description } => kotlin_taint::NodeMatcher::ParamName {
names: names.clone(),
description: description.clone(),
},
GenericMatcher::MethodName {
method,
description,
} => kotlin_taint::NodeMatcher::MethodName {
method: method.clone(),
description: description.clone(),
},
GenericMatcher::MemberAssign { field, description } => {
kotlin_taint::NodeMatcher::MemberAssign {
field: field.clone(),
description: description.clone(),
}
}
}
}
fn to_ruby_spec(g: &GenericSpec) -> ruby_taint::TaintSpec {
ruby_taint::TaintSpec {
sources: g.sources.iter().map(to_ruby_matcher).collect(),
sinks: g.sinks.iter().map(to_ruby_matcher).collect(),
sanitizers: g.sanitizers.iter().map(to_ruby_matcher).collect(),
}
}
fn to_ruby_matcher(m: &GenericMatcher) -> ruby_taint::NodeMatcher {
match m {
GenericMatcher::Attribute {
root,
field,
description,
} => ruby_taint::NodeMatcher::Attribute {
root: root.clone(),
field: field.clone(),
description: description.clone(),
},
GenericMatcher::Call {
canonical,
description,
} => ruby_taint::NodeMatcher::Call {
canonical: canonical.clone(),
description: description.clone(),
},
GenericMatcher::ParamName { names, description } => ruby_taint::NodeMatcher::ParamName {
names: names.clone(),
description: description.clone(),
},
GenericMatcher::MethodName {
method,
description,
} => ruby_taint::NodeMatcher::MethodName {
method: method.clone(),
description: description.clone(),
},
GenericMatcher::MemberAssign { field, description } => {
ruby_taint::NodeMatcher::MemberAssign {
field: field.clone(),
description: description.clone(),
}
}
}
}
pub struct SemgrepTaintRule {
pub id: String,
pub message: String,
pub severity: Severity,
pub cwe: Option<String>,
pub lang: Language,
spec: GenericSpec,
}
struct TaintFindingView {
sink_start_byte: usize,
sink_line: usize,
sink_column: usize,
sink_end_line: usize,
sink_end_column: usize,
source_description: String,
sink_description: String,
source_line: usize,
hops: u8,
}
impl TaintFindingView {
fn from_python(f: python_taint::TaintFinding) -> Self {
Self {
sink_start_byte: f.sink_start_byte,
sink_line: f.sink_line,
sink_column: f.sink_column,
sink_end_line: f.sink_end_line,
sink_end_column: f.sink_end_column,
source_description: f.source_description,
sink_description: f.sink_description,
source_line: f.source_line,
hops: f.hops,
}
}
fn from_js(f: javascript_taint::TaintFinding) -> Self {
Self {
sink_start_byte: f.sink_start_byte,
sink_line: f.sink_line,
sink_column: f.sink_column,
sink_end_line: f.sink_end_line,
sink_end_column: f.sink_end_column,
source_description: f.source_description,
sink_description: f.sink_description,
source_line: f.source_line,
hops: f.hops,
}
}
fn from_go(f: go_taint::TaintFinding) -> Self {
Self {
sink_start_byte: f.sink_start_byte,
sink_line: f.sink_line,
sink_column: f.sink_column,
sink_end_line: f.sink_end_line,
sink_end_column: f.sink_end_column,
source_description: f.source_description,
sink_description: f.sink_description,
source_line: f.source_line,
hops: f.hops,
}
}
fn from_java(f: java_taint::TaintFinding) -> Self {
Self {
sink_start_byte: f.sink_start_byte,
sink_line: f.sink_line,
sink_column: f.sink_column,
sink_end_line: f.sink_end_line,
sink_end_column: f.sink_end_column,
source_description: f.source_description,
sink_description: f.sink_description,
source_line: f.source_line,
hops: f.hops,
}
}
fn from_c(f: c_taint::TaintFinding) -> Self {
Self {
sink_start_byte: f.sink_start_byte,
sink_line: f.sink_line,
sink_column: f.sink_column,
sink_end_line: f.sink_end_line,
sink_end_column: f.sink_end_column,
source_description: f.source_description,
sink_description: f.sink_description,
source_line: f.source_line,
hops: f.hops,
}
}
fn from_kotlin(f: kotlin_taint::TaintFinding) -> Self {
Self {
sink_start_byte: f.sink_start_byte,
sink_line: f.sink_line,
sink_column: f.sink_column,
sink_end_line: f.sink_end_line,
sink_end_column: f.sink_end_column,
source_description: f.source_description,
sink_description: f.sink_description,
source_line: f.source_line,
hops: f.hops,
}
}
fn from_ruby(f: ruby_taint::TaintFinding) -> Self {
Self {
sink_start_byte: f.sink_start_byte,
sink_line: f.sink_line,
sink_column: f.sink_column,
sink_end_line: f.sink_end_line,
sink_end_column: f.sink_end_column,
source_description: f.source_description,
sink_description: f.sink_description,
source_line: f.source_line,
hops: f.hops,
}
}
}
impl Rule for SemgrepTaintRule {
fn id(&self) -> &str {
&self.id
}
fn severity(&self) -> Severity {
self.severity
}
fn cwe(&self) -> Option<&str> {
self.cwe.as_deref()
}
fn description(&self) -> &str {
&self.message
}
fn language(&self) -> Language {
self.lang
}
fn check(&self, source: &str, tree: &tree_sitter::Tree) -> Vec<Finding> {
self.check_with_context(source, tree, &FileContext::default())
}
fn ast_analysis_requirement(&self) -> crate::rules::AstAnalysisRequirement {
crate::rules::AstAnalysisRequirement::FileContext
}
fn check_with_context(
&self,
source: &str,
tree: &tree_sitter::Tree,
ctx: &FileContext<'_>,
) -> Vec<Finding> {
let raw: Vec<TaintFindingView> = match self.lang {
Language::Python => {
let spec = to_python_spec(&self.spec);
python_taint::analyze_tree(tree.root_node(), source, &spec, ctx.python_aliases)
.into_iter()
.map(TaintFindingView::from_python)
.collect()
}
Language::JavaScript => {
let spec = to_js_spec(&self.spec);
javascript_taint::analyze_tree(
tree.root_node(),
source,
&spec,
ctx.javascript_aliases,
)
.into_iter()
.map(TaintFindingView::from_js)
.collect()
}
Language::Go => {
let spec = to_go_spec(&self.spec);
go_taint::analyze_tree(tree.root_node(), source, &spec, ctx.go_aliases)
.into_iter()
.map(TaintFindingView::from_go)
.collect()
}
Language::Java => {
let spec = to_java_spec(&self.spec);
java_taint::analyze_tree(tree.root_node(), source, &spec, None)
.into_iter()
.map(TaintFindingView::from_java)
.collect()
}
Language::C => {
let spec = to_c_spec(&self.spec);
c_taint::analyze_tree(tree.root_node(), source, &spec, None)
.into_iter()
.map(TaintFindingView::from_c)
.collect()
}
Language::Kotlin => {
let spec = to_kotlin_spec(&self.spec);
kotlin_taint::analyze_tree(tree.root_node(), source, &spec, None)
.into_iter()
.map(TaintFindingView::from_kotlin)
.collect()
}
Language::Ruby => {
let spec = to_ruby_spec(&self.spec);
ruby_taint::analyze_tree(tree.root_node(), source, &spec, None)
.into_iter()
.map(TaintFindingView::from_ruby)
.collect()
}
_ => Vec::new(),
};
raw.into_iter()
.map(|t| Finding {
rule_id: self.id.clone(),
severity: self.severity,
cwe: self.cwe.clone(),
description: format!(
"{} — {} reaches {}",
self.message, t.source_description, t.sink_description
),
file: String::new(),
line: t.sink_line,
column: t.sink_column,
end_line: t.sink_end_line,
end_column: t.sink_end_column,
snippet: get_source_line(source, t.sink_start_byte),
source_line: Some(t.source_line),
source_description: Some(t.source_description),
sink_line: Some(t.sink_line),
sink_description: Some(t.sink_description),
fix_suggestion: None,
sink_start_byte: None,
sink_end_byte: None,
confidence: crate::rules::common::confidence_for_hops(t.hops),
taint_hops: Some(t.hops),
tags: vec![],
crypto_algorithm: None,
cnsa2_deadline: None,
dep_name: None,
dep_version: None,
dep_ecosystem: None,
dep_purl: None,
dep_vulnerability_id: None,
dep_fixed_version: None,
dep_source: None,
dep_vulnerability_severity: None,
dep_path: vec![],
})
.collect()
}
}
pub enum TaintRuleParse {
Compiled(SemgrepTaintRule),
Skip(String),
NotTaint,
}
pub fn parse_taint_rule(yaml: &YamlValue) -> TaintRuleParse {
let mode = yaml.get("mode").and_then(YamlValue::as_str);
if mode != Some("taint") {
return TaintRuleParse::NotTaint;
}
let id = match yaml.get("id").and_then(YamlValue::as_str) {
Some(s) => s.to_string(),
None => return TaintRuleParse::Skip("taint rule missing `id`".into()),
};
let lang = match yaml.get("languages").and_then(YamlValue::as_sequence) {
Some(langs) => {
let mut detected: Option<Language> = None;
for s in langs.iter().filter_map(YamlValue::as_str) {
match s.to_lowercase().as_str() {
"python" | "py" => {
detected = Some(Language::Python);
break;
}
"javascript" | "js" | "typescript" | "ts" => {
detected = Some(Language::JavaScript);
break;
}
"go" | "golang" => {
detected = Some(Language::Go);
break;
}
"java" => {
detected = Some(Language::Java);
break;
}
"c" => {
detected = Some(Language::C);
break;
}
"kotlin" | "kt" => {
detected = Some(Language::Kotlin);
break;
}
"ruby" | "rb" => {
detected = Some(Language::Ruby);
break;
}
_ => {}
}
}
match detected {
Some(l) => l,
None => {
return TaintRuleParse::Skip(format!(
"taint rule `{}` targets unsupported languages; Python, JavaScript/TypeScript, Go, Java, C, Kotlin, and Ruby are supported",
id
));
}
}
}
None => return TaintRuleParse::Skip(format!("taint rule `{}` missing `languages`", id)),
};
let message = yaml
.get("message")
.and_then(YamlValue::as_str)
.unwrap_or("")
.to_string();
let severity_str = yaml
.get("severity")
.and_then(YamlValue::as_str)
.unwrap_or("WARNING");
let severity = map_severity(severity_str);
let cwe = extract_cwe(yaml);
let sources = match compile_matcher_list(yaml.get("pattern-sources"), MatcherRole::Source, &id)
{
Ok(v) => v,
Err(e) => return TaintRuleParse::Skip(format!("taint rule `{}` skipped: {}", id, e)),
};
if sources.is_empty() {
return TaintRuleParse::Skip(format!(
"taint rule `{}` has no valid `pattern-sources`",
id
));
}
let sinks = match compile_matcher_list(yaml.get("pattern-sinks"), MatcherRole::Sink, &id) {
Ok(v) => v,
Err(e) => return TaintRuleParse::Skip(format!("taint rule `{}` skipped: {}", id, e)),
};
if sinks.is_empty() {
return TaintRuleParse::Skip(format!("taint rule `{}` has no valid `pattern-sinks`", id));
}
let sanitizers =
match compile_matcher_list(yaml.get("pattern-sanitizers"), MatcherRole::Sanitizer, &id) {
Ok(v) => v,
Err(e) => return TaintRuleParse::Skip(format!("taint rule `{}` skipped: {}", id, e)),
};
TaintRuleParse::Compiled(SemgrepTaintRule {
id: format!("semgrep/{}", id),
message,
severity,
cwe,
lang,
spec: GenericSpec {
sources,
sinks,
sanitizers,
},
})
}
#[derive(Copy, Clone)]
enum MatcherRole {
Source,
Sink,
Sanitizer,
}
impl MatcherRole {
fn label(self) -> &'static str {
match self {
MatcherRole::Source => "pattern-sources",
MatcherRole::Sink => "pattern-sinks",
MatcherRole::Sanitizer => "pattern-sanitizers",
}
}
}
fn compile_matcher_list(
node: Option<&YamlValue>,
role: MatcherRole,
rule_id: &str,
) -> Result<Vec<GenericMatcher>, String> {
let Some(node) = node else {
return Ok(Vec::new());
};
let Some(entries) = node.as_sequence() else {
return Err(format!("{} must be a list", role.label()));
};
let mut out = Vec::new();
for entry in entries {
compile_entry(entry, role, rule_id, &mut out);
}
Ok(out)
}
fn compile_entry(
entry: &YamlValue,
role: MatcherRole,
rule_id: &str,
out: &mut Vec<GenericMatcher>,
) {
let Some(map) = entry.as_mapping() else {
eprintln!(
"Warning: taint rule `{}` {} entry is not a mapping; skipping",
rule_id,
role.label()
);
return;
};
if map.len() != 1 {
eprintln!(
"Warning: taint rule `{}` {} entry has {} keys (expected a single `pattern:`, `pattern-either:`, or `patterns:`); skipping entry",
rule_id,
role.label(),
map.len(),
);
return;
}
let (k, v) = map.iter().next().expect("map.len() == 1");
match k.as_str() {
Some("pattern") => {
let Some(pattern) = v.as_str() else {
eprintln!(
"Warning: taint rule `{}` {} `pattern:` value must be a string; skipping entry",
rule_id,
role.label()
);
return;
};
match compile_pattern(pattern, role) {
Some(m) => out.push(m),
None => eprintln!(
"Warning: taint rule `{}` {} unsupported pattern shape `{}`; skipping entry",
rule_id,
role.label(),
pattern
),
}
}
Some("pattern-either") => {
let Some(inner) = v.as_sequence() else {
eprintln!(
"Warning: taint rule `{}` {} `pattern-either:` must be a list; skipping",
rule_id,
role.label()
);
return;
};
if inner.is_empty() {
eprintln!(
"Warning: taint rule `{}` {} `pattern-either:` is empty; producing no matchers",
rule_id,
role.label()
);
return;
}
for nested in inner {
compile_entry(nested, role, rule_id, out);
}
}
Some("patterns") => {
compile_patterns_block(v, role, rule_id, out);
}
Some(other) => {
eprintln!(
"Warning: taint rule `{}` {} uses unsupported key `{}` (only `pattern:`, `pattern-either:`, and `patterns:` are supported); skipping entry",
rule_id,
role.label(),
other
);
}
None => {
eprintln!(
"Warning: taint rule `{}` {} entry has a non-string key; skipping",
rule_id,
role.label()
);
}
}
}
const PATTERNS_CONSTRAINT_KEYS: &[&str] = &[
"pattern-inside",
"pattern-not-inside",
"pattern-not",
"pattern-not-regex",
"focus-metavariable",
"metavariable-regex",
"metavariable-comparison",
"metavariable-pattern",
"metavariable-analysis",
"metavariable-type",
];
fn compile_patterns_block(
v: &YamlValue,
role: MatcherRole,
rule_id: &str,
out: &mut Vec<GenericMatcher>,
) {
let Some(inner) = v.as_sequence() else {
eprintln!(
"Warning: taint rule `{}` {} `patterns:` value must be a list; skipping entry",
rule_id,
role.label()
);
return;
};
if inner.is_empty() {
eprintln!(
"Warning: taint rule `{}` {} `patterns:` block is empty; skipping entry",
rule_id,
role.label()
);
return;
}
let before = out.len();
for sub in inner {
let Some(sub_map) = sub.as_mapping() else {
continue;
};
if sub_map.len() != 1 {
continue;
}
let (sk, sv) = sub_map.iter().next().expect("len == 1");
match sk.as_str() {
Some("pattern") | Some("pattern-either") => {
compile_entry(sub, role, rule_id, out);
}
Some(constraint_key) if PATTERNS_CONSTRAINT_KEYS.contains(&constraint_key) => {
eprintln!(
"Warning: taint rule `{}` {} `patterns:` block contains `{}` \
which foxguard cannot enforce inside a taint source/sink entry; \
dropping constraint (matcher will be broader than the original rule)",
rule_id,
role.label(),
constraint_key
);
let _ = sv; }
Some(other) => {
eprintln!(
"Warning: taint rule `{}` {} `patterns:` block contains unknown key `{}`; \
skipping sub-item",
rule_id,
role.label(),
other
);
}
None => {}
}
}
if out.len() == before {
eprintln!(
"Warning: taint rule `{}` {} `patterns:` block produced no expressible matchers; \
skipping entry",
rule_id,
role.label()
);
}
}
fn compile_pattern(pattern: &str, role: MatcherRole) -> Option<GenericMatcher> {
let pat = pattern.trim();
if pat.is_empty() {
return None;
}
if let Some(field) = parse_member_assign_pattern(pat) {
return match role {
MatcherRole::Sink | MatcherRole::Sanitizer => Some(GenericMatcher::MemberAssign {
field: field.to_string(),
description: describe(field, role),
}),
MatcherRole::Source => None,
};
}
if let Some(open_paren) = pat.find('(') {
if !pat.ends_with(')') {
return None;
}
let callee = pat[..open_paren].trim();
if callee.is_empty() {
return None;
}
if let Some(method) = parse_metavar_dot_method(callee) {
return match role {
MatcherRole::Sink | MatcherRole::Sanitizer => Some(GenericMatcher::MethodName {
method: method.to_string(),
description: describe(method, role),
}),
MatcherRole::Source => None,
};
}
if !is_dotted_identifier(callee) {
return None;
}
let canonical = callee.to_string();
return Some(GenericMatcher::Call {
canonical: canonical.clone(),
description: describe(&canonical, role),
});
}
if !is_dotted_identifier(pat) {
return None;
}
if let Some(dot) = pat.rfind('.') {
let root = pat[..pat.find('.').expect("rfind guarantees at least one dot")].to_string();
let field = pat[dot + 1..].to_string();
if root.is_empty() || field.is_empty() {
return None;
}
let desc = describe(pat, role);
return Some(GenericMatcher::Attribute {
root,
field,
description: desc,
});
}
match role {
MatcherRole::Source => Some(GenericMatcher::ParamName {
names: vec![pat.to_string()],
description: format!("untrusted `{}` parameter", pat),
}),
MatcherRole::Sink | MatcherRole::Sanitizer => None,
}
}
fn parse_member_assign_pattern(pat: &str) -> Option<&str> {
let eq_pos = find_single_assignment(pat)?;
let lhs = pat[..eq_pos].trim();
if lhs.contains('(') || lhs.contains(')') {
return None;
}
parse_metavar_dot_method(lhs)
}
fn find_single_assignment(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if b != b'=' {
continue;
}
if bytes.get(i + 1) == Some(&b'=') {
continue;
}
if i > 0 && matches!(bytes[i - 1], b'!' | b'<' | b'>') {
continue;
}
return Some(i);
}
None
}
fn parse_metavar_dot_method(callee: &str) -> Option<&str> {
let dot = callee.find('.')?;
let receiver = &callee[..dot];
let rest = &callee[dot + 1..];
if !is_metavariable(receiver) {
return None;
}
if rest.contains('.') {
return None;
}
if !is_identifier(rest) {
return None;
}
Some(rest)
}
fn is_metavariable(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some('$') => {}
_ => return false,
}
let rest: String = chars.collect();
!rest.is_empty()
&& rest
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
}
fn describe(canonical: &str, role: MatcherRole) -> String {
match role {
MatcherRole::Source => format!("semgrep source `{}`", canonical),
MatcherRole::Sink => format!("semgrep sink `{}`", canonical),
MatcherRole::Sanitizer => format!("semgrep sanitizer `{}`", canonical),
}
}
fn is_dotted_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.split('.').all(is_identifier)
}
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn map_severity(s: &str) -> Severity {
match s.to_ascii_uppercase().as_str() {
"ERROR" => Severity::Critical,
"WARNING" => Severity::High,
"INFO" => Severity::Medium,
_ => Severity::Medium,
}
}
fn extract_cwe(yaml: &YamlValue) -> Option<String> {
let meta = yaml.get("metadata")?;
let cwe = meta.get("cwe")?;
match cwe {
YamlValue::String(s) => Some(s.clone()),
YamlValue::Sequence(v) => v.first().and_then(|x| x.as_str()).map(|s| s.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn compile(pattern: &str, role: MatcherRole) -> Option<GenericMatcher> {
compile_pattern(pattern, role)
}
#[test]
fn compile_attribute_source() {
let m = compile("request.data", MatcherRole::Source).expect("attribute");
match m {
GenericMatcher::Attribute { root, field, .. } => {
assert_eq!(root, "request");
assert_eq!(field, "data");
}
_ => panic!("expected Attribute"),
}
}
#[test]
fn compile_nested_attribute_takes_leftmost_root_and_outermost_field() {
let m = compile("request.session.user_id", MatcherRole::Source).expect("attribute");
match m {
GenericMatcher::Attribute { root, field, .. } => {
assert_eq!(root, "request");
assert_eq!(field, "user_id");
}
_ => panic!("expected Attribute"),
}
}
#[test]
fn compile_call_with_metavar() {
let m = compile("pickle.loads($X)", MatcherRole::Sink).expect("call");
match m {
GenericMatcher::Call { canonical, .. } => assert_eq!(canonical, "pickle.loads"),
_ => panic!("expected Call"),
}
}
#[test]
fn compile_call_with_ellipsis() {
let m = compile("pickle.loads(...)", MatcherRole::Sink).expect("call");
match m {
GenericMatcher::Call { canonical, .. } => assert_eq!(canonical, "pickle.loads"),
_ => panic!("expected Call"),
}
}
#[test]
fn compile_bare_func_call() {
let m = compile("eval($X)", MatcherRole::Sink).expect("call");
match m {
GenericMatcher::Call { canonical, .. } => assert_eq!(canonical, "eval"),
_ => panic!("expected Call"),
}
}
#[test]
fn compile_bare_identifier_source() {
let m = compile("request", MatcherRole::Source).expect("paramname");
match m {
GenericMatcher::ParamName { names, .. } => {
assert_eq!(names, vec!["request".to_string()])
}
_ => panic!("expected ParamName"),
}
}
#[test]
fn bare_identifier_rejected_as_sink() {
assert!(compile("request", MatcherRole::Sink).is_none());
}
#[test]
fn weird_shapes_rejected() {
assert!(compile("$X + $Y", MatcherRole::Source).is_none());
assert!(compile("a.b.c(d", MatcherRole::Sink).is_none());
assert!(compile("", MatcherRole::Source).is_none());
}
#[test]
fn parse_full_taint_rule() {
let yaml = r#"
id: semgrep-pickle-taint
mode: taint
languages: [python]
severity: ERROR
message: "Untrusted input reaches pickle.loads"
metadata:
cwe: "CWE-502"
pattern-sources:
- pattern: request.data
- pattern: request
pattern-sinks:
- pattern: pickle.loads($X)
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => {
assert_eq!(r.id, "semgrep/semgrep-pickle-taint");
assert_eq!(r.lang, Language::Python);
assert_eq!(r.cwe.as_deref(), Some("CWE-502"));
assert_eq!(r.spec.sources.len(), 2);
assert_eq!(r.spec.sinks.len(), 1);
}
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn non_taint_rule_falls_through() {
let yaml = r#"
id: classic
pattern: eval(...)
message: x
severity: ERROR
languages: [python]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
assert!(matches!(parse_taint_rule(&v), TaintRuleParse::NotTaint));
}
#[test]
fn taint_rule_with_unsupported_language_is_skipped() {
let yaml = r#"
id: x
mode: taint
languages: [elixir]
severity: ERROR
message: m
pattern-sources: [{pattern: req}]
pattern-sinks: [{pattern: eval($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
assert!(matches!(parse_taint_rule(&v), TaintRuleParse::Skip(_)));
}
#[test]
fn taint_rule_with_ruby_language_compiles() {
let yaml = r#"
id: ruby-taint
mode: taint
languages: [ruby]
severity: ERROR
message: m
pattern-sources: [{pattern: gets($X)}]
pattern-sinks: [{pattern: system($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => {
assert_eq!(r.lang, Language::Ruby);
assert_eq!(r.spec.sources.len(), 1);
assert_eq!(r.spec.sinks.len(), 1);
}
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn taint_rule_with_rb_alias_compiles_as_ruby() {
let yaml = r#"
id: rb-taint
mode: taint
languages: [rb]
severity: ERROR
message: m
pattern-sources: [{pattern: gets($X)}]
pattern-sinks: [{pattern: eval($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => assert_eq!(r.lang, Language::Ruby),
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn ruby_taint_rule_produces_finding_for_source_to_sink_flow() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: ruby-cmd-injection
mode: taint
languages: [ruby]
severity: ERROR
message: "Tainted input reaches system"
metadata:
cwe: "CWE-78"
pattern-sources:
- pattern: gets($X)
pattern-sinks:
- pattern: system($X)
"#,
);
let src = r#"
def run
cmd = gets
system(cmd)
end
"#;
let tree = parse_file(src, Language::Ruby).expect("Ruby fixture should parse");
let findings = rule.check(src, &tree);
assert!(
!findings.is_empty(),
"expected a finding for gets -> system flow, got none"
);
assert!(
findings[0].description.contains("gets"),
"description should mention source"
);
}
#[test]
fn ruby_bridge_bare_params_source_to_system_sink_fires() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: ruby-params-cmdi
mode: taint
languages: [ruby]
severity: ERROR
message: "Tainted params reaches system"
pattern-sources:
- pattern: params
pattern-sinks:
- pattern: system($X)
"#,
);
let src = r#"
def handler
cmd = params[:cmd]
system(cmd)
end
"#;
let tree = parse_file(src, Language::Ruby).expect("Ruby fixture should parse");
let findings = rule.check(src, &tree);
assert_eq!(
findings.len(),
1,
"expected 1 finding for params[:cmd] -> system, got {:?}",
findings
);
assert_eq!(
findings[0].line, 4,
"finding should be at the system() sink line"
);
}
#[test]
fn ruby_bridge_bare_gets_source_to_system_sink_fires() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: ruby-gets-cmdi
mode: taint
languages: [ruby]
severity: ERROR
message: "Tainted gets reaches system"
pattern-sources:
- pattern: gets
pattern-sinks:
- pattern: system($X)
"#,
);
let src = r#"
def handler
cmd = gets
system(cmd)
end
"#;
let tree = parse_file(src, Language::Ruby).expect("Ruby fixture should parse");
let findings = rule.check(src, &tree);
assert_eq!(
findings.len(),
1,
"expected 1 finding for bare gets -> system, got {:?}",
findings
);
assert_eq!(
findings[0].line, 4,
"finding should be at the system() sink line"
);
}
#[test]
fn ruby_bridge_bare_params_source_sanitized_produces_no_finding() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: ruby-params-cmdi-sanitized
mode: taint
languages: [ruby]
severity: ERROR
message: "Tainted params reaches system"
pattern-sources:
- pattern: params
pattern-sanitizers:
- pattern: Shellwords.escape($X)
pattern-sinks:
- pattern: system($X)
"#,
);
let src = r#"
def handler
cmd = Shellwords.escape(params[:cmd])
system(cmd)
end
"#;
let tree = parse_file(src, Language::Ruby).expect("Ruby fixture should parse");
let findings = rule.check(src, &tree);
assert!(
findings.is_empty(),
"sanitized params flow must produce no finding, got {:?}",
findings
);
}
#[test]
fn ruby_bridge_dotted_request_params_source_still_fires() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: ruby-request-params-cmdi
mode: taint
languages: [ruby]
severity: ERROR
message: "Tainted request.params reaches system"
pattern-sources:
- pattern: request.params
pattern-sinks:
- pattern: system($X)
"#,
);
let src = r#"
def handler
val = request.params[:q]
system(val)
end
"#;
let tree = parse_file(src, Language::Ruby).expect("Ruby fixture should parse");
let findings = rule.check(src, &tree);
assert_eq!(
findings.len(),
1,
"dotted request.params -> system must still fire, got {:?}",
findings
);
}
#[test]
fn ruby_bridge_bare_params_source_near_miss_no_finding() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: ruby-params-cmdi-nearmiss
mode: taint
languages: [ruby]
severity: ERROR
message: "Tainted params reaches system"
pattern-sources:
- pattern: params
pattern-sinks:
- pattern: system($X)
"#,
);
let src = r#"
def handler
cmd = params[:cmd]
safe = "ls"
system(safe)
end
"#;
let tree = parse_file(src, Language::Ruby).expect("Ruby fixture should parse");
let findings = rule.check(src, &tree);
assert!(
findings.is_empty(),
"untainted argument must not fire, got {:?}",
findings
);
}
#[test]
fn taint_rule_with_c_language_compiles() {
let yaml = r#"
id: c-taint
mode: taint
languages: [c]
severity: ERROR
message: m
pattern-sources: [{pattern: getenv($X)}]
pattern-sinks: [{pattern: system($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => {
assert_eq!(r.lang, Language::C);
assert_eq!(r.spec.sources.len(), 1);
assert_eq!(r.spec.sinks.len(), 1);
}
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn taint_rule_with_kotlin_language_compiles() {
let yaml = r#"
id: kotlin-taint
mode: taint
languages: [kotlin]
severity: ERROR
message: m
pattern-sources: [{pattern: request.getParameter($X)}]
pattern-sinks: [{pattern: Runtime.exec($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => {
assert_eq!(r.lang, Language::Kotlin);
assert_eq!(r.spec.sources.len(), 1);
assert_eq!(r.spec.sinks.len(), 1);
}
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn taint_rule_with_kt_alias_compiles_as_kotlin() {
let yaml = r#"
id: kt-taint
mode: taint
languages: [kt]
severity: ERROR
message: m
pattern-sources: [{pattern: call.receiveText($X)}]
pattern-sinks: [{pattern: Runtime.exec($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => assert_eq!(r.lang, Language::Kotlin),
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn taint_rule_with_javascript_language_compiles() {
let yaml = r#"
id: js-taint
mode: taint
languages: [javascript]
severity: ERROR
message: m
pattern-sources: [{pattern: req.query}]
pattern-sinks: [{pattern: eval($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => {
assert_eq!(r.lang, Language::JavaScript);
assert_eq!(r.spec.sources.len(), 1);
assert_eq!(r.spec.sinks.len(), 1);
}
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn taint_rule_with_typescript_language_compiles_as_javascript() {
let yaml = r#"
id: ts-taint
mode: taint
languages: [typescript]
severity: ERROR
message: m
pattern-sources: [{pattern: req.body}]
pattern-sinks: [{pattern: eval($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => assert_eq!(r.lang, Language::JavaScript),
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn taint_rule_with_go_language_compiles() {
let yaml = r#"
id: go-taint
mode: taint
languages: [go]
severity: ERROR
message: m
pattern-sources: [{pattern: c.Query($X)}]
pattern-sinks: [{pattern: exec.Command($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => {
assert_eq!(r.lang, Language::Go);
assert_eq!(r.spec.sources.len(), 1);
assert_eq!(r.spec.sinks.len(), 1);
}
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn taint_rule_with_java_language_compiles() {
let yaml = r#"
id: java-taint
mode: taint
languages: [java]
severity: ERROR
message: m
pattern-sources: [{pattern: request.getParameter($X)}]
pattern-sinks: [{pattern: Runtime.exec($X)}]
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => {
assert_eq!(r.lang, Language::Java);
assert_eq!(r.spec.sources.len(), 1);
assert_eq!(r.spec.sinks.len(), 1);
}
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn java_taint_rule_produces_finding_for_source_to_sink_flow() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: java-cmd-injection
mode: taint
languages: [java]
severity: ERROR
message: "Tainted input reaches Runtime.exec"
metadata:
cwe: "CWE-78"
pattern-sources:
- pattern: request.getParameter($X)
pattern-sinks:
- pattern: Runtime.exec($X)
"#,
);
let src = r#"
class Controller {
void run(HttpServletRequest request) throws Exception {
String cmd = request.getParameter("cmd");
Runtime.getRuntime().exec(cmd);
}
}
"#;
let tree = parse_file(src, Language::Java).expect("Java fixture should parse");
let findings = rule.check(src, &tree);
assert!(
!findings.is_empty(),
"expected a finding for request.getParameter -> Runtime.exec flow, got none"
);
assert!(
findings[0].description.contains("Runtime.exec")
|| findings[0]
.sink_description
.as_deref()
.is_some_and(|d| d.contains("exec")),
"sink description should mention exec: {:?}",
findings[0]
);
}
#[test]
fn java_taint_sanitizer_blocks_finding() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: java-cmd-sanitized
mode: taint
languages: [java]
severity: ERROR
message: "Tainted input reaches Runtime.exec"
pattern-sources:
- pattern: request.getParameter($X)
pattern-sinks:
- pattern: Runtime.exec($X)
pattern-sanitizers:
- pattern: validate($X)
"#,
);
let src = r#"
class Controller {
void run(HttpServletRequest request) throws Exception {
String cmd = request.getParameter("cmd");
String safe = validate(cmd);
Runtime.getRuntime().exec(safe);
}
}
"#;
assert_eq!(
rule.spec.sanitizers.len(),
1,
"sanitizer spec should compile"
);
let tree = parse_file(src, Language::Java).expect("Java fixture should parse");
let _ = rule.check(src, &tree); }
#[test]
fn c_taint_rule_produces_finding_for_getenv_to_system() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: c-cmd-injection
mode: taint
languages: [c]
severity: ERROR
message: "Tainted env var reaches system()"
metadata:
cwe: "CWE-78"
pattern-sources:
- pattern: getenv($X)
pattern-sinks:
- pattern: system($X)
"#,
);
let src = r#"
#include <stdlib.h>
void handler() {
char *cmd = getenv("CMD");
system(cmd);
}
"#;
let tree = parse_file(src, Language::C).expect("C fixture should parse");
let findings = rule.check(src, &tree);
assert!(
!findings.is_empty(),
"expected a finding for getenv -> system flow, got none"
);
assert!(
findings[0].description.contains("system")
|| findings[0]
.sink_description
.as_deref()
.is_some_and(|d| d.contains("system")),
"sink description should mention system: {:?}",
findings[0]
);
}
#[test]
fn c_taint_sanitizer_no_panic() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: c-cmd-sanitized
mode: taint
languages: [c]
severity: ERROR
message: "Tainted input reaches system()"
pattern-sources:
- pattern: getenv($X)
pattern-sinks:
- pattern: system($X)
pattern-sanitizers:
- pattern: strlcpy($X)
"#,
);
assert_eq!(
rule.spec.sanitizers.len(),
1,
"sanitizer spec should compile"
);
let src = r#"
#include <stdlib.h>
#include <string.h>
void handler() {
char *input = getenv("CMD");
char safe[64];
strlcpy(safe, input, sizeof(safe));
system(safe);
}
"#;
let tree = parse_file(src, Language::C).expect("C fixture should parse");
let _ = rule.check(src, &tree); }
#[test]
fn kotlin_taint_rule_produces_finding_for_receive_to_exec() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: kotlin-cmd-injection
mode: taint
languages: [kotlin]
severity: ERROR
message: "Tainted request body reaches Runtime.exec"
metadata:
cwe: "CWE-78"
pattern-sources:
- pattern: call.receiveText($X)
pattern-sinks:
- pattern: Runtime.exec($X)
"#,
);
let src = r#"
fun handler(call: ApplicationCall) {
val cmd = call.receiveText()
Runtime.getRuntime().exec(cmd)
}
"#;
let tree = parse_file(src, Language::Kotlin).expect("Kotlin fixture should parse");
let findings = rule.check(src, &tree);
assert!(
!findings.is_empty(),
"expected a finding for call.receiveText -> Runtime.exec flow, got none"
);
assert!(
findings[0].description.contains("exec")
|| findings[0]
.sink_description
.as_deref()
.is_some_and(|d| d.contains("exec")),
"sink description should mention exec: {:?}",
findings[0]
);
}
#[test]
fn kotlin_taint_sanitizer_no_panic() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: kotlin-cmd-sanitized
mode: taint
languages: [kotlin]
severity: ERROR
message: "Tainted request body reaches Runtime.exec"
pattern-sources:
- pattern: call.receiveText($X)
pattern-sinks:
- pattern: Runtime.exec($X)
pattern-sanitizers:
- pattern: validate($X)
"#,
);
assert_eq!(
rule.spec.sanitizers.len(),
1,
"sanitizer spec should compile"
);
let src = r#"
fun handler(call: ApplicationCall) {
val body = call.receiveText()
val safe = validate(body)
Runtime.getRuntime().exec(safe)
}
"#;
let tree = parse_file(src, Language::Kotlin).expect("Kotlin fixture should parse");
let _ = rule.check(src, &tree); }
fn compiled(yaml: &str) -> SemgrepTaintRule {
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Compiled(r) => r,
TaintRuleParse::Skip(msg) => panic!("unexpected skip: {}", msg),
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
#[test]
fn pattern_either_flattens_into_multiple_matchers() {
let r = compiled(
r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern-either:
- pattern: request.data
- pattern: request.form
- pattern: request.args
pattern-sinks:
- pattern: pickle.loads($X)
"#,
);
assert_eq!(r.spec.sources.len(), 3);
assert_eq!(r.spec.sinks.len(), 1);
}
#[test]
fn nested_pattern_either_flattens_recursively() {
let r = compiled(
r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern-either:
- pattern-either:
- pattern: request.data
- pattern: request.form
- pattern: request.args
pattern-sinks:
- pattern: pickle.loads($X)
"#,
);
assert_eq!(r.spec.sources.len(), 3);
}
#[test]
fn pattern_either_in_sinks_flattens() {
let r = compiled(
r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern: request.data
pattern-sinks:
- pattern-either:
- pattern: pickle.loads($X)
- pattern: pickle.load($X)
"#,
);
assert_eq!(r.spec.sinks.len(), 2);
}
#[test]
fn pattern_either_in_sanitizers_flattens() {
let r = compiled(
r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern: request.data
pattern-sinks:
- pattern: pickle.loads($X)
pattern-sanitizers:
- pattern-either:
- pattern: sanitize($X)
- pattern: escape($X)
"#,
);
assert_eq!(r.spec.sanitizers.len(), 2);
}
#[test]
fn mixed_pattern_and_pattern_either_work_together() {
let r = compiled(
r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern-either:
- pattern: request.data
- pattern: request.form
- pattern: request
pattern-sinks:
- pattern: pickle.loads($X)
- pattern-either:
- pattern: pickle.load($X)
"#,
);
assert_eq!(r.spec.sources.len(), 3);
assert_eq!(r.spec.sinks.len(), 2);
}
#[test]
fn empty_pattern_either_warns_and_produces_no_matcher() {
let yaml = r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern-either: []
pattern-sinks:
- pattern: pickle.loads($X)
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Skip(msg) => assert!(msg.contains("pattern-sources")),
other => panic!(
"expected Skip because empty pattern-either produced no sources, got {:?}",
match other {
TaintRuleParse::Compiled(_) => "Compiled",
TaintRuleParse::NotTaint => "NotTaint",
TaintRuleParse::Skip(_) => unreachable!(),
}
),
}
let r = compiled(
r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern: request.data
pattern-sinks:
- pattern: pickle.loads($X)
pattern-sanitizers:
- pattern-either: []
"#,
);
assert!(r.spec.sanitizers.is_empty());
}
#[test]
fn unknown_composite_still_rejected() {
let yaml2 = r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern-inside: |
def $F(...):
...
pattern-sinks:
- pattern: pickle.loads($X)
"#;
let v2: YamlValue = serde_yaml_ng::from_str(yaml2).unwrap();
assert!(matches!(parse_taint_rule(&v2), TaintRuleParse::Skip(_)));
let r = compiled(
r#"
id: x
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern-inside: |
def $F(...):
...
- pattern: request.form
pattern-sinks:
- pattern: pickle.loads($X)
"#,
);
assert_eq!(r.spec.sources.len(), 1);
}
#[test]
fn compile_metavar_receiver_sink_produces_method_name() {
let m = compile("$CONN.executeQuery($X)", MatcherRole::Sink).expect("MethodName");
match m {
GenericMatcher::MethodName { method, .. } => assert_eq!(method, "executeQuery"),
_ => panic!("expected MethodName"),
}
}
#[test]
fn compile_metavar_receiver_sanitizer_produces_method_name() {
let m = compile("$OBJ.escape($X)", MatcherRole::Sanitizer).expect("MethodName sanitizer");
match m {
GenericMatcher::MethodName { method, .. } => assert_eq!(method, "escape"),
_ => panic!("expected MethodName"),
}
}
#[test]
fn metavar_receiver_as_source_is_rejected() {
assert!(compile("$OBJ.getInput($X)", MatcherRole::Source).is_none());
}
#[test]
fn metavar_without_dot_is_rejected() {
assert!(compile("$X", MatcherRole::Sink).is_none());
assert!(compile("$X($Y)", MatcherRole::Sink).is_none());
}
#[test]
fn metavar_with_multi_segment_rest_is_rejected() {
assert!(compile("$OBJ.a.b($X)", MatcherRole::Sink).is_none());
}
#[test]
fn metavar_lowercase_is_rejected() {
assert!(compile("$obj.method($X)", MatcherRole::Sink).is_none());
}
#[test]
fn metavar_with_trailing_digits_is_accepted() {
assert!(is_metavariable("$ARG1"));
assert!(is_metavariable("$CONN_2"));
assert!(!is_metavariable("$obj"));
assert!(!is_metavariable("$"));
assert!(compile("$X1.executeQuery($P)", MatcherRole::Sink).is_some());
}
#[test]
fn taint_rule_with_metavar_receiver_sink_compiles() {
let r = compiled(
r#"
id: java-sql-injection
mode: taint
languages: [java]
severity: ERROR
message: "Tainted input reaches executeQuery"
metadata:
cwe: "CWE-89"
pattern-sources:
- pattern: request.getParameter($X)
pattern-sinks:
- pattern: $CONN.executeQuery($X)
"#,
);
assert_eq!(r.spec.sinks.len(), 1);
match &r.spec.sinks[0] {
GenericMatcher::MethodName { method, .. } => assert_eq!(method, "executeQuery"),
other => panic!("expected MethodName sink, got {:?}", other),
}
}
#[test]
fn java_taint_method_name_sink_produces_finding() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: java-sql-metavar
mode: taint
languages: [java]
severity: ERROR
message: "SQL injection via executeQuery"
metadata:
cwe: "CWE-89"
pattern-sources:
- pattern: request.getParameter($X)
pattern-sinks:
- pattern: $CONN.executeQuery($X)
"#,
);
let src = r#"
class Dao {
void query(HttpServletRequest request, Connection conn) throws Exception {
String input = request.getParameter("id");
conn.executeQuery(input);
}
}
"#;
let tree = parse_file(src, Language::Java).expect("Java fixture should parse");
let findings = rule.check(src, &tree);
assert!(
!findings.is_empty(),
"expected a finding for request.getParameter -> conn.executeQuery flow, got none"
);
assert!(
findings[0]
.sink_description
.as_deref()
.is_some_and(|d| d.contains("executeQuery")),
"sink description should mention executeQuery: {:?}",
findings[0]
);
}
#[test]
fn java_taint_method_name_non_matching_method_does_not_fire() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: java-sql-metavar-negative
mode: taint
languages: [java]
severity: ERROR
message: "SQL injection via executeQuery"
pattern-sources:
- pattern: request.getParameter($X)
pattern-sinks:
- pattern: $CONN.executeQuery($X)
"#,
);
let src = r#"
class Dao {
void update(HttpServletRequest request, Connection conn) throws Exception {
String input = request.getParameter("id");
conn.executeUpdate(input);
}
}
"#;
let tree = parse_file(src, Language::Java).expect("Java fixture should parse");
let findings = rule.check(src, &tree);
assert!(
findings.is_empty(),
"executeUpdate should NOT trigger the executeQuery rule, got {:?}",
findings
);
}
#[test]
fn compile_member_assign_sink_produces_member_assign() {
let m = compile("$EL.innerHTML = $X", MatcherRole::Sink).expect("MemberAssign");
match m {
GenericMatcher::MemberAssign { field, .. } => assert_eq!(field, "innerHTML"),
_ => panic!("expected MemberAssign"),
}
}
#[test]
fn compile_member_assign_sanitizer_produces_member_assign() {
let m =
compile("$EL.outerHTML = $X", MatcherRole::Sanitizer).expect("MemberAssign sanitizer");
match m {
GenericMatcher::MemberAssign { field, .. } => assert_eq!(field, "outerHTML"),
_ => panic!("expected MemberAssign"),
}
}
#[test]
fn compile_member_assign_source_is_rejected() {
assert!(compile("$EL.innerHTML = $X", MatcherRole::Source).is_none());
}
#[test]
fn member_assign_equality_operator_is_rejected() {
assert!(compile("$EL.innerHTML == $X", MatcherRole::Sink).is_none());
}
#[test]
fn member_assign_plain_receiver_not_compiled_as_member_assign() {
assert!(compile("el.innerHTML = $X", MatcherRole::Sink).is_none());
}
#[test]
fn member_assign_multi_segment_lhs_is_rejected() {
assert!(compile("$EL.a.b = $X", MatcherRole::Sink).is_none());
}
#[test]
fn taint_rule_with_member_assign_sink_compiles() {
let r = compiled(
r#"
id: js-dom-xss-innerhtml
mode: taint
languages: [javascript]
severity: ERROR
message: "Tainted input reaches innerHTML"
metadata:
cwe: "CWE-79"
pattern-sources:
- pattern: req.query
pattern-sinks:
- pattern: $EL.innerHTML = $X
"#,
);
assert_eq!(r.spec.sinks.len(), 1);
match &r.spec.sinks[0] {
GenericMatcher::MemberAssign { field, .. } => assert_eq!(field, "innerHTML"),
other => panic!("expected MemberAssign sink, got {:?}", other),
}
}
#[test]
fn js_taint_member_assign_sink_produces_finding() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: js-dom-xss-innerhtml-e2e
mode: taint
languages: [javascript]
severity: ERROR
message: "DOM XSS via innerHTML"
metadata:
cwe: "CWE-79"
pattern-sources:
- pattern: req.query
pattern-sinks:
- pattern: $EL.innerHTML = $X
"#,
);
let src = r#"
function handler(req) {
var data = req.query.name;
document.getElementById("target").innerHTML = data;
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("JS fixture should parse");
let findings = rule.check(src, &tree);
assert!(
!findings.is_empty(),
"expected a finding for req.query -> innerHTML flow, got none"
);
assert!(
findings[0]
.sink_description
.as_deref()
.is_some_and(|d| d.contains("innerHTML")),
"sink description should mention innerHTML: {:?}",
findings[0]
);
}
#[test]
fn js_taint_member_assign_non_matching_field_does_not_fire() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: js-dom-xss-innerhtml-neg
mode: taint
languages: [javascript]
severity: ERROR
message: "DOM XSS via innerHTML"
pattern-sources:
- pattern: req.query
pattern-sinks:
- pattern: $EL.innerHTML = $X
"#,
);
let src = r#"
function handler(req) {
var data = req.query.name;
document.getElementById("target").textContent = data;
}
"#;
let tree = parse_file(src, Language::JavaScript).expect("JS fixture should parse");
let findings = rule.check(src, &tree);
assert!(
findings.is_empty(),
"textContent assignment should NOT trigger the innerHTML rule, got {:?}",
findings
);
}
#[test]
fn patterns_block_single_pattern_source_to_sink_finding() {
use crate::engine::parser::parse_file;
let rule = compiled(
r#"
id: patterns-block-single-pattern
mode: taint
languages: [python]
severity: ERROR
message: "Tainted data reaches pickle.loads"
pattern-sources:
- patterns:
- pattern: request.data
pattern-sinks:
- patterns:
- pattern: pickle.loads($X)
"#,
);
assert_eq!(rule.spec.sources.len(), 1, "source from patterns: block");
assert_eq!(rule.spec.sinks.len(), 1, "sink from patterns: block");
let src = r#"
import pickle
def view(request):
data = request.data
result = pickle.loads(data)
return result
"#;
let tree = parse_file(src, Language::Python).expect("Python fixture should parse");
let findings = rule.check(src, &tree);
assert!(
!findings.is_empty(),
"expected a finding for request.data -> pickle.loads flow, got none"
);
}
#[test]
fn patterns_block_with_pattern_either_compiles_all_alternatives() {
let r = compiled(
r#"
id: patterns-block-pattern-either
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- pattern: request.data
pattern-sinks:
- patterns:
- pattern-either:
- pattern: pickle.loads($X)
- pattern: pickle.load($X)
- pattern: eval($X)
"#,
);
assert_eq!(
r.spec.sinks.len(),
3,
"expected 3 sink matchers from patterns: {{ pattern-either: [3] }}"
);
}
#[test]
fn patterns_block_with_unsupported_constraint_compiles_with_broadening() {
let r = compiled(
r#"
id: patterns-block-broadening
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- patterns:
- pattern-inside: |
def handler(event, context):
...
- pattern: event
pattern-sinks:
- patterns:
- focus-metavariable: $CMD
- pattern-either:
- pattern: os.system($CMD)
- pattern: os.popen($CMD)
"#,
);
assert_eq!(
r.spec.sources.len(),
1,
"source compiled despite dropped pattern-inside"
);
assert_eq!(
r.spec.sinks.len(),
2,
"sinks compiled despite dropped focus-metavariable"
);
}
#[test]
fn patterns_block_no_expressible_matcher_warn_skips_gracefully() {
let yaml = r#"
id: patterns-block-no-expressible
mode: taint
languages: [python]
severity: ERROR
message: m
pattern-sources:
- patterns:
- pattern-inside: |
def handler(event, context):
...
- focus-metavariable: $X
pattern-sinks:
- pattern: pickle.loads($X)
"#;
let v: YamlValue = serde_yaml_ng::from_str(yaml).unwrap();
match parse_taint_rule(&v) {
TaintRuleParse::Skip(msg) => {
assert!(
msg.contains("pattern-sources"),
"skip message should mention pattern-sources: {msg}"
);
}
TaintRuleParse::Compiled(_) => {
panic!("expected Skip when patterns: block has no expressible matchers")
}
TaintRuleParse::NotTaint => panic!("expected taint rule"),
}
}
}