use crate::engine::parser::parse_file;
use crate::rules::common::get_source_line;
use crate::rules::Rule;
use crate::{Finding, Language, Severity};
use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::sync::OnceLock;
const RESERVED_RULE_ID_NAMESPACES: &[&str] = &[
"py", "js", "go", "java", "php", "ruby", "cs", "csharp", "swift", "kotlin", "rs", "rust",
"config", "manifest",
];
#[derive(Debug, Clone)]
pub enum CompiledRegex {
Fast(Regex),
Fancy(fancy_regex::Regex),
}
impl CompiledRegex {
pub fn as_str(&self) -> &str {
match self {
CompiledRegex::Fast(re) => re.as_str(),
CompiledRegex::Fancy(re) => re.as_str(),
}
}
pub fn is_match(&self, text: &str) -> bool {
match self {
CompiledRegex::Fast(re) => re.is_match(text),
CompiledRegex::Fancy(re) => re.is_match(text).unwrap_or(false),
}
}
pub fn find_matches(&self, text: &str) -> Vec<(usize, usize)> {
match self {
CompiledRegex::Fast(re) => re.find_iter(text).map(|m| (m.start(), m.end())).collect(),
CompiledRegex::Fancy(re) => re
.find_iter(text)
.map_while(Result::ok)
.map(|m| (m.start(), m.end()))
.collect(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct SemgrepFile {
pub rules: Vec<SemgrepRuleYaml>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum PatternOrBlock {
Literal(String),
Block(serde_yaml_ng::Value),
}
impl PatternOrBlock {
pub fn into_pattern_string(self) -> Option<String> {
match self {
PatternOrBlock::Literal(s) => Some(s),
PatternOrBlock::Block(v) => {
if let Some(clauses) = v
.get("patterns")
.and_then(serde_yaml_ng::Value::as_sequence)
{
for clause in clauses {
if let Some(pat) =
clause.get("pattern").and_then(serde_yaml_ng::Value::as_str)
{
return Some(pat.to_string());
}
}
}
eprintln!(
"Warning: pattern-not-inside block has no extractable `pattern:` string; \
skipping constraint"
);
None
}
}
}
}
#[derive(Debug, Deserialize)]
pub struct SemgrepRuleYaml {
pub id: String,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default, rename = "pattern-regex")]
pub pattern_regex: Option<String>,
#[serde(default, rename = "pattern-either")]
pub pattern_either: Option<Vec<PatternEntry>>,
#[serde(default, rename = "pattern-not")]
pub pattern_not: Option<String>,
#[serde(default, rename = "pattern-not-regex")]
pub pattern_not_regex: Option<String>,
#[serde(default, rename = "pattern-inside")]
pub pattern_inside: Option<String>,
#[serde(default, rename = "pattern-not-inside")]
pub pattern_not_inside: Option<PatternOrBlock>,
#[serde(default)]
pub patterns: Option<Vec<PatternClause>>,
pub message: String,
pub severity: SemgrepSeverity,
pub languages: Vec<String>,
#[serde(default)]
pub metadata: Option<SemgrepMetadata>,
#[serde(default)]
pub paths: Option<SemgrepPaths>,
#[serde(default)]
pub fix: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PatternEntry {
#[serde(default)]
pub pattern: Option<String>,
#[serde(default, rename = "pattern-regex")]
pub pattern_regex: Option<String>,
#[serde(default)]
pub patterns: Option<serde_yaml_ng::Value>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PatternClause {
#[serde(default)]
pub pattern: Option<String>,
#[serde(default, rename = "pattern-regex")]
pub pattern_regex: Option<String>,
#[serde(default, rename = "pattern-not")]
pub pattern_not: Option<String>,
#[serde(default, rename = "pattern-not-regex")]
pub pattern_not_regex: Option<String>,
#[serde(default, rename = "pattern-inside")]
pub pattern_inside: Option<String>,
#[serde(default, rename = "pattern-not-inside")]
pub pattern_not_inside: Option<PatternOrBlock>,
#[serde(default, rename = "pattern-either")]
pub pattern_either: Option<Vec<PatternEntry>>,
#[serde(default, rename = "metavariable-regex")]
pub metavariable_regex: Option<SemgrepMetavariableRegexClause>,
#[serde(default, rename = "metavariable-comparison")]
pub metavariable_comparison: Option<SemgrepMetavariableComparisonClause>,
#[serde(default, rename = "metavariable-pattern")]
pub metavariable_pattern: Option<SemgrepMetavariablePatternClause>,
#[serde(default, rename = "metavariable-analysis")]
pub metavariable_analysis: Option<SemgrepMetavariableAnalysisClause>,
#[serde(default, rename = "focus-metavariable")]
pub focus_metavariable: Option<FocusMetavariableValue>,
#[serde(default, rename = "metavariable-type")]
pub metavariable_type: Option<SemgrepMetavariableTypeClause>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum SemgrepSeverity {
Error,
Warning,
Medium,
Info,
}
#[derive(Debug, Deserialize)]
pub struct SemgrepMetadata {
pub cwe: Option<CweValue>,
}
#[derive(Debug, Deserialize, Default)]
pub struct SemgrepPaths {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SemgrepMetavariableRegexClause {
pub metavariable: String,
pub regex: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SemgrepMetavariableComparisonClause {
#[serde(default)]
pub metavariable: Option<String>,
pub comparison: String,
#[serde(default)]
pub base: Option<u32>,
#[serde(default)]
pub strip: Option<bool>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum FocusMetavariableValue {
Single(String),
List(Vec<String>),
}
impl FocusMetavariableValue {
pub fn into_vec(self) -> Vec<String> {
match self {
FocusMetavariableValue::Single(s) => vec![s],
FocusMetavariableValue::List(v) => v,
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct SemgrepMetavariablePatternClause {
pub metavariable: String,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default, rename = "pattern-regex")]
pub pattern_regex: Option<String>,
#[serde(default, rename = "pattern-either")]
pub pattern_either: Option<Vec<PatternEntry>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SemgrepMetavariableAnalysisClause {
pub metavariable: String,
pub analyzer: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SemgrepMetavariableTypeClause {
pub metavariable: String,
#[serde(rename = "type")]
pub type_name: String,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum CweValue {
Single(String),
List(Vec<String>),
}
pub struct SemgrepRule {
pub id: String,
pub message: String,
pub severity: Severity,
pub lang: Language,
pub cwe: Option<String>,
pub matcher: PatternMatcher,
pub path_filter: Option<PathFilter>,
pub fix_template: Option<String>,
}
#[derive(Debug, Clone)]
pub enum PatternMatcher {
Single(CompiledAstPattern),
Regex(CompiledRegex),
Either(Vec<PatternMatcher>),
Combined {
positives: Vec<PatternMatcher>,
negatives: Vec<NegativeMatcher>,
inside: Option<CompiledAstPattern>,
not_inside: Option<CompiledAstPattern>,
metavariable_regexes: Vec<MetavariableRegexConstraint>,
metavariable_comparisons: Vec<MetavariableComparisonConstraint>,
metavariable_patterns: Vec<MetavariablePatternConstraint>,
metavariable_analyses: Vec<MetavariableAnalysisConstraint>,
metavariable_types: Vec<MetavariableTypeConstraint>,
focus_metavariables: Vec<String>,
},
}
#[derive(Debug, Clone)]
pub enum NegativeMatcher {
Pattern(CompiledAstPattern),
Regex(CompiledRegex),
}
#[derive(Clone)]
pub struct CompiledAstPattern {
source: String,
tree: Option<tree_sitter::Tree>,
selector_kind: Option<String>,
}
impl fmt::Debug for CompiledAstPattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CompiledAstPattern")
.field("source", &self.source)
.field("compiled", &self.tree.is_some())
.field("selector_kind", &self.selector_kind)
.finish()
}
}
impl CompiledAstPattern {
pub(crate) fn try_new(pattern: &str, lang: Language) -> Option<Self> {
let compiled = Self::new(pattern.to_string(), lang);
if compiled.pattern_node().is_some() {
Some(compiled)
} else {
None
}
}
pub(crate) fn overlaps_range(
&self,
root: tree_sitter::Node<'_>,
source: &str,
start_byte: usize,
end_byte: usize,
) -> bool {
match_single_pattern(self, root, source)
.iter()
.any(|m| m.start_byte < end_byte && start_byte < m.end_byte)
}
pub(crate) fn contains_range(
&self,
root: tree_sitter::Node<'_>,
source: &str,
start_byte: usize,
end_byte: usize,
) -> bool {
match_single_pattern(self, root, source)
.iter()
.any(|m| m.start_byte <= start_byte && end_byte <= m.end_byte)
}
}
#[derive(Debug, Clone)]
pub struct PathFilter {
include: Option<GlobSet>,
exclude: Option<GlobSet>,
}
#[derive(Debug, Clone)]
pub struct MetavariableRegexConstraint {
metavariable: String,
regex: CompiledRegex,
}
#[derive(Debug, Clone, PartialEq)]
enum CmpOp {
Lt,
Le,
Gt,
Ge,
Eq,
Ne,
}
#[derive(Debug, Clone)]
pub struct MetavariableComparisonConstraint {
metavariable: String,
op: CmpOp,
literal: f64,
literal_is_lhs: bool,
}
#[derive(Debug, Clone)]
pub struct MetavariablePatternConstraint {
metavariable: String,
sub_matcher: PatternMatcher,
lang: Language,
}
const ENTROPY_THRESHOLD: f64 = 3.5;
fn shannon_entropy(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let len = s.len() as f64;
let mut counts = [0u32; 256];
for b in s.bytes() {
counts[b as usize] += 1;
}
counts
.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}
#[derive(Debug, Clone)]
pub struct MetavariableAnalysisConstraint {
metavariable: String,
}
impl MetavariableAnalysisConstraint {
fn from_yaml(clause: &SemgrepMetavariableAnalysisClause) -> Option<Self> {
match clause.analyzer.as_str() {
"entropy" => Some(Self {
metavariable: clause.metavariable.clone(),
}),
"redos" => {
eprintln!(
"Warning: metavariable-analysis analyzer 'redos' for {} is not \
implemented (no sound cheap heuristic); skipping constraint",
clause.metavariable
);
None
}
other => {
eprintln!(
"Warning: metavariable-analysis analyzer '{}' for {} is unknown; \
skipping constraint",
other, clause.metavariable
);
None
}
}
}
fn matches(&self, bindings: &HashMap<String, String>) -> bool {
let Some(text) = bindings.get(&self.metavariable) else {
return false;
};
shannon_entropy(text) >= ENTROPY_THRESHOLD
}
}
#[derive(Debug, Clone)]
pub struct MetavariableTypeConstraint {
metavariable: String,
type_name: String,
lang: Language,
}
impl MetavariableTypeConstraint {
fn from_yaml(clause: &SemgrepMetavariableTypeClause, lang: Language) -> Self {
Self {
metavariable: clause.metavariable.clone(),
type_name: normalize_type_name(&clause.type_name),
lang,
}
}
fn matches(
&self,
root: tree_sitter::Node,
source: &str,
bindings: &HashMap<String, String>,
binding_ranges: &HashMap<String, MetavarRange>,
) -> bool {
let Some(text) = bindings.get(&self.metavariable) else {
return false;
};
let name = text.trim();
if !is_simple_identifier(name) {
return false;
}
let Some(&(line, col, _, _)) = binding_ranges.get(&self.metavariable) else {
return false;
};
let Some(offset) = position_to_byte_offset(source, line, col) else {
return false;
};
match resolve_declared_type(self.lang, root, source, name, offset) {
Some(declared) => normalize_type_name(&declared) == self.type_name,
None => false,
}
}
}
fn metavariable_type_enforceable(lang: Language) -> bool {
matches!(
lang,
Language::Java
| Language::CSharp
| Language::Go
| Language::Kotlin
| Language::JavaScript
)
}
fn normalize_type_name(raw: &str) -> String {
let mut s = raw.trim();
s = s.trim_start_matches(':').trim();
if let Some(idx) = s.find('<') {
s = s[..idx].trim_end();
}
if let Some(idx) = s.find('[') {
s = s[..idx].trim_end();
}
if let Some(idx) = s.rfind(['.', ':']) {
s = &s[idx + 1..];
}
s.trim().to_string()
}
fn is_simple_identifier(text: &str) -> bool {
let mut chars = text.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
fn parse_comparison(comparison: &str) -> Result<(String, CmpOp, f64, bool), String> {
let s = comparison.trim();
const OPS: &[(&str, CmpOp)] = &[
("<=", CmpOp::Le),
(">=", CmpOp::Ge),
("!=", CmpOp::Ne),
("==", CmpOp::Eq),
("<", CmpOp::Lt),
(">", CmpOp::Gt),
];
for (op_str, op) in OPS {
if let Some(idx) = s.find(op_str) {
let lhs = s[..idx].trim();
let rhs = s[idx + op_str.len()..].trim();
let (metavar, literal_str, literal_is_lhs) = if lhs.starts_with('$') {
(lhs, rhs, false)
} else if rhs.starts_with('$') {
(rhs, lhs, true)
} else {
return Err(format!("metavariable-comparison: no metavariable in '{s}'"));
};
if metavariable_key(metavar).is_none() {
return Err(format!(
"metavariable-comparison: invalid metavariable token '{metavar}' in '{s}'"
));
}
let literal_str = strip_numeric_suffixes(literal_str);
let literal: f64 = parse_numeric(&literal_str).ok_or_else(|| {
format!(
"metavariable-comparison: cannot parse numeric literal '{literal_str}' in '{s}'"
)
})?;
return Ok((metavar.to_string(), op.clone(), literal, literal_is_lhs));
}
}
Err(format!(
"metavariable-comparison: unsupported comparison expression '{s}'"
))
}
fn strip_numeric_suffixes(s: &str) -> String {
let upper = s.to_uppercase();
if upper.starts_with("0X") || upper.starts_with("0B") {
return upper;
}
upper.trim_end_matches(['L', 'U', 'F']).to_string()
}
fn parse_numeric(s: &str) -> Option<f64> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some(hex) = s.strip_prefix("0X").or_else(|| s.strip_prefix("0x")) {
return i64::from_str_radix(hex, 16).ok().map(|v| v as f64);
}
if let Some(bin) = s.strip_prefix("0B").or_else(|| s.strip_prefix("0b")) {
return i64::from_str_radix(bin, 2).ok().map(|v| v as f64);
}
s.parse::<f64>().ok()
}
impl Rule for SemgrepRule {
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 applies_to_path(&self, path: &Path) -> bool {
self.path_filter
.as_ref()
.is_none_or(|filter| filter.matches(path))
}
fn check(&self, source: &str, tree: &tree_sitter::Tree) -> Vec<Finding> {
let mut findings = Vec::new();
let root = tree.root_node();
let matches = match_pattern_in_tree(&self.matcher, root, source);
for matched_node_range in matches {
let fix_suggestion = self
.fix_template
.as_deref()
.map(|tmpl| apply_fix_template(tmpl, &matched_node_range.bindings));
findings.push(Finding {
rule_id: self.id.clone(),
severity: self.severity,
cwe: self.cwe.clone(),
description: self.message.clone(),
file: String::new(),
line: matched_node_range.line,
column: matched_node_range.column,
end_line: matched_node_range.end_line,
end_column: matched_node_range.end_column,
snippet: matched_node_range.snippet,
source_line: None,
source_description: None,
sink_line: None,
sink_description: None,
fix_suggestion,
sink_start_byte: None,
sink_end_byte: None,
confidence: 0.7,
taint_hops: None,
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![],
crypto_material: None,
});
}
findings
}
}
impl PathFilter {
pub(crate) fn from_yaml(paths: Option<&SemgrepPaths>) -> Result<Option<Self>, String> {
let Some(paths) = paths else {
return Ok(None);
};
let include = compile_globset(&paths.include)?;
let exclude = compile_globset(&paths.exclude)?;
Ok(Some(Self { include, exclude }))
}
pub(crate) fn matches(&self, path: &Path) -> bool {
let normalized = normalize_rule_path(path);
if let Some(include) = &self.include {
if !include.is_match(&normalized) {
return false;
}
}
if let Some(exclude) = &self.exclude {
if exclude.is_match(&normalized) {
return false;
}
}
true
}
}
impl MetavariableRegexConstraint {
fn from_yaml(clause: &SemgrepMetavariableRegexClause) -> Option<Self> {
match compile_regex(&clause.regex) {
Ok(regex) => Some(Self {
metavariable: clause.metavariable.clone(),
regex,
}),
Err(e) => {
eprintln!(
"Warning: metavariable-regex for {} uses an unsupported regex ({}); \
skipping constraint",
clause.metavariable, e
);
None
}
}
}
fn matches(&self, bindings: &HashMap<String, String>) -> bool {
bindings
.get(&self.metavariable)
.is_some_and(|value| self.regex.is_match(value))
}
}
impl MetavariableComparisonConstraint {
fn from_yaml(clause: &SemgrepMetavariableComparisonClause) -> Result<Self, String> {
if let Some(base) = clause.base {
if base != 10 {
return Err(format!(
"metavariable-comparison: base:{base} is not supported (only base:10); skipping constraint"
));
}
}
if clause.metavariable.is_none() {
return Err(format!(
"metavariable-comparison: no `metavariable:` key in clause '{}'; \
the comparison uses an unsupported expression form — skipping constraint",
clause.comparison
));
}
let (metavariable, op, literal, literal_is_lhs) = parse_comparison(&clause.comparison)?;
Ok(Self {
metavariable,
op,
literal,
literal_is_lhs,
})
}
fn matches(&self, bindings: &HashMap<String, String>) -> bool {
let Some(value_text) = bindings.get(&self.metavariable) else {
return false;
};
let stripped = strip_numeric_suffixes(value_text.trim());
let Some(value) = parse_numeric(&stripped) else {
return false;
};
let (lhs, rhs) = if self.literal_is_lhs {
(self.literal, value)
} else {
(value, self.literal)
};
match self.op {
CmpOp::Lt => lhs < rhs,
CmpOp::Le => lhs <= rhs,
CmpOp::Gt => lhs > rhs,
CmpOp::Ge => lhs >= rhs,
CmpOp::Eq => lhs == rhs,
CmpOp::Ne => lhs != rhs,
}
}
}
impl MetavariablePatternConstraint {
fn from_yaml(clause: &SemgrepMetavariablePatternClause, lang: Language) -> Option<Self> {
let sub_matcher = if let Some(ref pat) = clause.pattern {
PatternMatcher::Single(CompiledAstPattern::new(pat.clone(), lang))
} else if let Some(ref regex) = clause.pattern_regex {
match compile_regex(regex) {
Ok(r) => PatternMatcher::Regex(r),
Err(e) => {
eprintln!(
"Warning: metavariable-pattern for {} has invalid pattern-regex: {}; skipping constraint",
clause.metavariable, e
);
return None;
}
}
} else if let Some(ref entries) = clause.pattern_either {
match build_either_matchers(entries, lang) {
Ok(matchers) => PatternMatcher::Either(matchers),
Err(e) => {
eprintln!(
"Warning: metavariable-pattern for {} has invalid pattern-either: {}; skipping constraint",
clause.metavariable, e
);
return None;
}
}
} else {
eprintln!(
"Warning: metavariable-pattern for {} has no supported nested pattern form \
(pattern, pattern-regex, or pattern-either); skipping constraint",
clause.metavariable
);
return None;
};
Some(Self {
metavariable: clause.metavariable.clone(),
sub_matcher,
lang,
})
}
fn matches(&self, bindings: &HashMap<String, String>) -> bool {
let Some(bound_text) = bindings.get(&self.metavariable) else {
return false;
};
match &self.sub_matcher {
PatternMatcher::Regex(regex) => regex.is_match(bound_text),
_ => {
let Some(tree) = parse_file(bound_text, self.lang) else {
return false;
};
let root = tree.root_node();
!match_pattern_in_tree(&self.sub_matcher, root, bound_text).is_empty()
}
}
}
}
impl CompiledAstPattern {
fn new(source: String, lang: Language) -> Self {
let source = prepare_pattern_for_grammar(source, lang);
let tree = parse_file(&source, lang);
let selector_kind = tree
.as_ref()
.and_then(|tree| first_meaningful_node(tree.root_node(), &source))
.and_then(|node| selector_kind_for_pattern(node, &source));
Self {
source,
tree,
selector_kind,
}
}
fn pattern_node(&self) -> Option<tree_sitter::Node<'_>> {
let tree = self.tree.as_ref()?;
first_meaningful_node(tree.root_node(), &self.source)
}
}
const GO_ELLIPSIS_PLACEHOLDER: &str = "__foxguard_semgrep_ellipsis";
const GO_METAVAR_PREFIX: &str = "__foxguard_semgrep_meta_";
fn prepare_pattern_for_grammar(source: String, lang: Language) -> String {
match lang {
Language::Go => {
if let Some(tree) = parse_file(&source, lang) {
if !tree.root_node().has_error() {
return source;
}
}
let source = rewrite_go_semgrep_micro_syntax(&source);
let package_scoped = format!("package _\n{source}\n");
if let Some(tree) = parse_file(&package_scoped, lang) {
if !tree.root_node().has_error() {
return package_scoped;
}
}
format!("package _\nfunc _() {{\n{source}\n}}\n")
}
_ => source,
}
}
fn rewrite_go_semgrep_micro_syntax(source: &str) -> String {
static METAVARS_RE: OnceLock<Regex> = OnceLock::new();
let metavars = METAVARS_RE
.get_or_init(|| Regex::new(r"\$([A-Za-z0-9_]+)").expect("valid metavariable regex"));
let rewritten = metavars
.replace_all(source, format!("{GO_METAVAR_PREFIX}$1"))
.to_string()
.replace("...", GO_ELLIPSIS_PLACEHOLDER);
static FUNC_ELLIPSIS_RE: OnceLock<Regex> = OnceLock::new();
let func_ellipsis_params = FUNC_ELLIPSIS_RE.get_or_init(|| {
Regex::new(&format!(
r"(func\s+[A-Za-z_][A-Za-z0-9_]*\s*)\(\s*{}\s*\)",
regex::escape(GO_ELLIPSIS_PLACEHOLDER)
))
.expect("valid Go func ellipsis regex")
});
func_ellipsis_params
.replace_all(&rewritten, "$1()")
.to_string()
}
type MetavarRange = (usize, usize, usize, usize);
#[derive(Debug, Clone)]
struct MatchRange {
start_byte: usize,
end_byte: usize,
line: usize,
column: usize,
end_line: usize,
end_column: usize,
snippet: String,
bindings: HashMap<String, String>,
binding_ranges: HashMap<String, MetavarRange>,
}
type MatchResult = Vec<MatchRange>;
fn match_pattern_in_tree(
matcher: &PatternMatcher,
root: tree_sitter::Node,
source: &str,
) -> MatchResult {
match matcher {
PatternMatcher::Single(pat) => match_single_pattern(pat, root, source),
PatternMatcher::Regex(regex) => match_regex_pattern(regex, source),
PatternMatcher::Either(matchers) => {
let mut results = Vec::new();
for matcher in matchers {
results.extend(match_pattern_in_tree(matcher, root, source));
}
results.sort_by_key(|r| (r.start_byte, r.end_byte));
results.dedup_by_key(|r| (r.start_byte, r.end_byte));
results
}
PatternMatcher::Combined {
positives,
negatives,
inside,
not_inside,
metavariable_regexes,
metavariable_comparisons,
metavariable_patterns,
metavariable_analyses,
metavariable_types,
focus_metavariables,
} => {
let search_roots = if let Some(inside_pat) = inside {
let inside_matches = match_single_pattern(inside_pat, root, source);
inside_matches
.iter()
.map(|m| (m.start_byte, m.end_byte))
.collect::<Vec<_>>()
} else {
vec![]
};
let excluded_roots = if let Some(not_inside_pat) = not_inside {
let excluded_matches = match_single_pattern(not_inside_pat, root, source);
excluded_matches
.iter()
.map(|m| (m.start_byte, m.end_byte))
.collect::<Vec<_>>()
} else {
vec![]
};
let mut candidates: Option<Vec<MatchRange>> = None;
for pos in positives {
let matches = match_pattern_in_tree(pos, root, source);
candidates = Some(match candidates {
None => matches,
Some(prev) => intersect_match_sets(prev, matches),
});
}
let mut results = candidates.unwrap_or_default();
for neg in negatives {
let neg_matches = match_negative_pattern(neg, root, source);
results.retain(|r| !neg_matches.iter().any(|n| ranges_overlap(r, n)));
}
if !search_roots.is_empty() {
results.retain(|r| {
search_roots
.iter()
.any(|(start, end)| r.start_byte >= *start && r.end_byte <= *end)
});
}
if !excluded_roots.is_empty() {
results.retain(|r| {
!excluded_roots
.iter()
.any(|(start, end)| r.start_byte >= *start && r.end_byte <= *end)
});
}
for constraint in metavariable_regexes {
results.retain(|r| constraint.matches(&r.bindings));
}
for constraint in metavariable_comparisons {
results.retain(|r| constraint.matches(&r.bindings));
}
for constraint in metavariable_patterns {
results.retain(|r| constraint.matches(&r.bindings));
}
for constraint in metavariable_analyses {
results.retain(|r| constraint.matches(&r.bindings));
}
for constraint in metavariable_types {
results
.retain(|r| constraint.matches(root, source, &r.bindings, &r.binding_ranges));
}
if !focus_metavariables.is_empty() {
for result in &mut results {
for fmv in focus_metavariables.iter() {
if let Some(&(fline, fcol, fend_line, fend_col)) =
result.binding_ranges.get(fmv.as_str())
{
result.line = fline;
result.column = fcol;
result.end_line = fend_line;
result.end_column = fend_col;
if let Some(bound_text) = result.bindings.get(fmv.as_str()) {
result.snippet = find_source_line_by_line(source, fline)
.unwrap_or_else(|| bound_text.clone());
}
break;
}
}
}
}
results
}
}
}
fn match_single_pattern(
pattern: &CompiledAstPattern,
root: tree_sitter::Node,
source: &str,
) -> MatchResult {
let mut results = Vec::new();
let Some(pat_node) = pattern.pattern_node() else {
return results;
};
walk_and_match(root, source, pat_node, pattern, &mut results);
results
}
fn match_regex_pattern(regex: &CompiledRegex, source: &str) -> MatchResult {
regex
.find_matches(source)
.into_iter()
.map(|(start, end)| {
let (line, column) = byte_offset_to_position(source, start);
let (end_line, end_column) = byte_offset_to_position(source, end);
MatchRange {
start_byte: start,
end_byte: end,
line,
column,
end_line,
end_column,
snippet: get_source_line(source, start),
bindings: HashMap::new(),
binding_ranges: HashMap::new(),
}
})
.collect()
}
fn first_meaningful_node<'a>(
node: tree_sitter::Node<'a>,
_source: &str,
) -> Option<tree_sitter::Node<'a>> {
let kind = node.kind();
if kind == "module"
|| kind == "program"
|| kind == "source_file"
|| kind == "script"
|| kind == "source"
{
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if !child.is_extra() && child.kind() != "package_clause" {
return first_meaningful_node(child, _source);
}
}
return None;
}
if kind == "function_declaration" {
if let Some(name) = node.child_by_field_name("name") {
if &_source[name.byte_range()] == "_" {
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
let stmts: Vec<_> = body.named_children(&mut cursor).collect();
if let Some(first) = stmts.into_iter().next() {
if first.kind() == "statement_list" {
let mut c2 = first.walk();
let inner: Vec<_> = first.named_children(&mut c2).collect();
if let Some(stmt) = inner.into_iter().next() {
return first_meaningful_node(stmt, _source);
}
}
return first_meaningful_node(first, _source);
}
}
}
}
}
if kind == "expression_statement" {
if let Some(child) = node.child(0) {
return Some(child);
}
}
Some(node)
}
fn selector_kind_for_pattern(node: tree_sitter::Node<'_>, source: &str) -> Option<String> {
let text = &source[node.byte_range()];
let trimmed = text.trim();
if metavariable_key(trimmed).is_some() || is_ellipsis_pattern(trimmed) {
return None;
}
Some(node.kind().to_string())
}
fn selector_allows_node(selector_kind: Option<&str>, node: tree_sitter::Node<'_>) -> bool {
match selector_kind {
None => true,
Some(kind) => {
node.kind() == kind
|| node.named_child_count() == 1
|| node.child_count() == 1
}
}
}
fn walk_and_match(
node: tree_sitter::Node,
source: &str,
pat_node: tree_sitter::Node,
pattern: &CompiledAstPattern,
results: &mut MatchResult,
) {
if selector_allows_node(pattern.selector_kind.as_deref(), node) {
let mut bindings = HashMap::new();
let mut binding_ranges: HashMap<String, MetavarRange> = HashMap::new();
if match_node(
node,
source,
pat_node,
&pattern.source,
&mut bindings,
&mut binding_ranges,
) {
let start = node.start_position();
let end = node.end_position();
results.push(MatchRange {
start_byte: node.start_byte(),
end_byte: node.end_byte(),
line: start.row + 1,
column: start.column + 1,
end_line: end.row + 1,
end_column: end.column + 1,
snippet: get_source_line(source, node.start_byte()),
bindings,
binding_ranges,
});
return;
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_and_match(child, source, pat_node, pattern, results);
}
}
fn match_node(
target: tree_sitter::Node,
target_src: &str,
pattern: tree_sitter::Node,
pat_src: &str,
bindings: &mut HashMap<String, String>,
binding_ranges: &mut HashMap<String, MetavarRange>,
) -> bool {
let pat_text = &pat_src[pattern.byte_range()];
if let Some(metavar) = metavariable_key(pat_text) {
let target_text = &target_src[target.byte_range()];
if let Some(existing) = bindings.get(&metavar) {
return existing == target_text;
}
let start = target.start_position();
let end = target.end_position();
bindings.insert(metavar.clone(), target_text.to_string());
binding_ranges.insert(
metavar,
(start.row + 1, start.column + 1, end.row + 1, end.column + 1),
);
return true;
}
if is_ellipsis_pattern(pat_text) {
return true;
}
if is_any_string_pattern(pat_text) && is_string_node(target, target_src) {
return true;
}
if pattern.child_count() == 0 {
let target_text = &target_src[target.byte_range()];
return pat_text == target_text;
}
if pattern.kind() != target.kind() {
if pattern.child_count() == 1 {
if let Some(pc) = pattern.child(0) {
return match_node(target, target_src, pc, pat_src, bindings, binding_ranges);
}
}
if target.child_count() == 1 {
if let Some(tc) = target.child(0) {
return match_node(tc, target_src, pattern, pat_src, bindings, binding_ranges);
}
}
return false;
}
if let Some(pattern_gap) = operator_token(pattern, pat_src) {
match operator_token(target, target_src) {
Some(target_gap) if target_gap == pattern_gap => {}
_ => return false,
}
}
let pat_children = named_children(pattern);
let target_children = named_children(target);
match_children_with_ellipsis(
&target_children,
target_src,
&pat_children,
pat_src,
bindings,
binding_ranges,
)
}
fn named_children(node: tree_sitter::Node) -> Vec<tree_sitter::Node> {
let mut cursor = node.walk();
node.named_children(&mut cursor).collect()
}
fn operator_token(node: tree_sitter::Node, source: &str) -> Option<String> {
if !matches!(
node.kind(),
"binary_expression" | "binary_operator" | "boolean_operator" | "comparison_operator"
) {
return None;
}
let children = named_children(node);
if children.len() < 2 {
return None;
}
let gap = &source[children[0].end_byte()..children[1].start_byte()];
let normalized = gap
.chars()
.filter(|c| !c.is_whitespace() && *c != '$')
.collect::<String>();
(!normalized.is_empty()).then_some(normalized)
}
fn check_split_metavar(
pat_children: &[tree_sitter::Node],
pi: usize,
pat_src: &str,
) -> Option<String> {
if pi + 1 >= pat_children.len() {
return None;
}
let first = pat_children[pi];
let second = pat_children[pi + 1];
let first_text = &pat_src[first.byte_range()];
let second_text = &pat_src[second.byte_range()];
if first.kind() == "ERROR" && first_text.trim() == "$" && second.kind() == "identifier" {
let metavar = format!("${}", second_text);
return Some(metavar);
}
if first.kind() == "ERROR" {
return metavariable_key(first_text);
}
None
}
fn match_children_with_ellipsis(
target_children: &[tree_sitter::Node],
target_src: &str,
pat_children: &[tree_sitter::Node],
pat_src: &str,
bindings: &mut HashMap<String, String>,
binding_ranges: &mut HashMap<String, MetavarRange>,
) -> bool {
if pat_children.is_empty() {
return true;
}
let mut ti = 0;
let mut pi = 0;
while pi < pat_children.len() {
let pat_child = pat_children[pi];
let pat_text = &pat_src[pat_child.byte_range()];
if is_ellipsis_pattern(pat_text) {
pi += 1;
if pi >= pat_children.len() {
return true;
}
let next_pat = pat_children[pi];
while ti < target_children.len() {
let mut sub_bindings = bindings.clone();
let mut sub_ranges = binding_ranges.clone();
if match_node(
target_children[ti],
target_src,
next_pat,
pat_src,
&mut sub_bindings,
&mut sub_ranges,
) {
*bindings = sub_bindings;
*binding_ranges = sub_ranges;
pi += 1;
ti += 1;
break;
}
ti += 1;
}
if ti > target_children.len() {
return false;
}
} else if let Some(metavar) = check_split_metavar(pat_children, pi, pat_src) {
if ti >= target_children.len() {
return false;
}
let target_node = target_children[ti];
let target_text = &target_src[target_node.byte_range()];
if let Some(existing) = bindings.get(&metavar) {
if existing != target_text {
return false;
}
} else {
let start = target_node.start_position();
let end = target_node.end_position();
bindings.insert(metavar.clone(), target_text.to_string());
binding_ranges.insert(
metavar.clone(),
(start.row + 1, start.column + 1, end.row + 1, end.column + 1),
);
}
ti += 1;
pi += 2;
} else if pat_child.kind() == "ERROR" && pat_src[pat_child.byte_range()].trim() == "$" {
pi += 1;
} else {
if ti >= target_children.len() {
return false;
}
if !match_node(
target_children[ti],
target_src,
pat_child,
pat_src,
bindings,
binding_ranges,
) {
return false;
}
ti += 1;
pi += 1;
}
}
true
}
#[cfg(test)]
fn is_metavar(text: &str) -> bool {
metavariable_key(text).is_some()
}
fn apply_fix_template(template: &str, bindings: &HashMap<String, String>) -> String {
static METAVAR_RE: OnceLock<Regex> = OnceLock::new();
let re = METAVAR_RE.get_or_init(|| {
Regex::new(r"\$[A-Za-z0-9_]+").expect("valid metavariable regex for fix template")
});
re.replace_all(template, |caps: ®ex::Captures<'_>| -> String {
let token = caps.get(0).map_or("", |m| m.as_str());
bindings
.get(token)
.cloned()
.unwrap_or_else(|| token.to_string())
})
.into_owned()
}
fn metavariable_key(text: &str) -> Option<String> {
let t = text.trim();
if t.starts_with('$')
&& t.len() > 1
&& t[1..]
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Some(t.to_string());
}
t.strip_prefix(GO_METAVAR_PREFIX)
.filter(|name| {
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
})
.map(|name| format!("${name}"))
}
fn is_ellipsis_pattern(text: &str) -> bool {
matches!(text.trim(), "..." | GO_ELLIPSIS_PLACEHOLDER)
}
fn position_to_byte_offset(source: &str, line: usize, col: usize) -> Option<usize> {
let mut idx = 0usize;
for (current_line, l) in (1usize..).zip(source.split_inclusive('\n')) {
if current_line == line {
let off = idx + col.saturating_sub(1);
return Some(off.min(source.len()));
}
idx += l.len();
}
None
}
const SCOPE_KINDS: &[&str] = &[
"block",
"statement_block",
"function_body",
"constructor_body",
"class_body",
"declaration_list",
"statements",
"switch_block",
"program",
"source_file",
"compilation_unit",
"method_declaration",
"constructor_declaration",
"function_declaration",
"local_function_statement",
"lambda_expression",
"for_statement",
"enhanced_for_statement",
"for_each_statement",
"catch_clause",
];
fn scope_range_for_decl(node: tree_sitter::Node) -> (usize, usize) {
let mut n = node;
while let Some(parent) = n.parent() {
if SCOPE_KINDS.contains(&parent.kind()) {
return (parent.start_byte(), parent.end_byte());
}
n = parent;
}
(0, usize::MAX)
}
fn type_node_text<'a>(node: Option<tree_sitter::Node>, source: &'a str) -> Option<&'a str> {
let n = node?;
let text = &source[n.byte_range()];
(!text.trim().is_empty()).then_some(text)
}
fn find_child_of_kind<'a>(
node: tree_sitter::Node<'a>,
kind: &str,
) -> Option<tree_sitter::Node<'a>> {
let mut cursor = node.walk();
let found = node.children(&mut cursor).find(|c| c.kind() == kind);
found
}
fn decl_type_for<'a>(
lang: Language,
node: tree_sitter::Node<'a>,
source: &'a str,
name: &str,
) -> Option<(&'a str, (usize, usize))> {
let name_matches = |n: Option<tree_sitter::Node>| -> bool {
n.map(|n| &source[n.byte_range()] == name).unwrap_or(false)
};
match lang {
Language::Java => match node.kind() {
"formal_parameter" | "spread_parameter" => {
if name_matches(node.child_by_field_name("name")) {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
return Some((ty, scope_range_for_decl(node)));
}
None
}
"catch_formal_parameter" => {
if name_matches(node.child_by_field_name("name")) {
let ct = find_child_of_kind(node, "catch_type")?;
let ty = type_node_text(Some(ct), source)?;
return Some((ty, scope_range_for_decl(node)));
}
None
}
"enhanced_for_statement" => {
if name_matches(node.child_by_field_name("name")) {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
return Some((ty, (node.start_byte(), node.end_byte())));
}
None
}
"local_variable_declaration" | "field_declaration" => {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
let mut cursor = node.walk();
for declarator in node.children(&mut cursor) {
if declarator.kind() == "variable_declarator"
&& name_matches(declarator.child_by_field_name("name"))
{
return Some((ty, scope_range_for_decl(node)));
}
}
None
}
_ => None,
},
Language::CSharp => match node.kind() {
"parameter" => {
if name_matches(node.child_by_field_name("name")) {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
return Some((ty, scope_range_for_decl(node)));
}
None
}
"variable_declaration" => {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
let mut cursor = node.walk();
for declarator in node.children(&mut cursor) {
if declarator.kind() == "variable_declarator"
&& name_matches(declarator.child_by_field_name("name"))
{
return Some((ty, scope_range_for_decl(node)));
}
}
None
}
_ => None,
},
Language::Go => match node.kind() {
"parameter_declaration" => {
if name_matches(node.child_by_field_name("name")) {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
return Some((ty, scope_range_for_decl(node)));
}
None
}
"var_spec" | "const_spec" => {
if name_matches(node.child_by_field_name("name")) {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
return Some((ty, scope_range_for_decl(node)));
}
None
}
_ => None,
},
Language::Kotlin => match node.kind() {
"parameter" | "variable_declaration" => {
let ident = find_child_of_kind(node, "simple_identifier")?;
if &source[ident.byte_range()] != name {
return None;
}
let ty = type_node_text(find_child_of_kind(node, "user_type"), source)?;
Some((ty, scope_range_for_decl(node)))
}
_ => None,
},
Language::JavaScript => match node.kind() {
"required_parameter" | "optional_parameter" => {
if name_matches(node.child_by_field_name("pattern")) {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
return Some((ty, scope_range_for_decl(node)));
}
None
}
"variable_declarator" => {
if name_matches(node.child_by_field_name("name")) {
let ty = type_node_text(node.child_by_field_name("type"), source)?;
return Some((ty, scope_range_for_decl(node)));
}
None
}
_ => None,
},
_ => None,
}
}
fn resolve_declared_type(
lang: Language,
root: tree_sitter::Node,
source: &str,
name: &str,
offset: usize,
) -> Option<String> {
let mut best: Option<(usize, String)> = None;
collect_declared_type(lang, root, source, name, offset, &mut best);
best.map(|(_, ty)| ty)
}
fn collect_declared_type(
lang: Language,
node: tree_sitter::Node,
source: &str,
name: &str,
offset: usize,
best: &mut Option<(usize, String)>,
) {
if let Some((ty, (scope_start, scope_end))) = decl_type_for(lang, node, source, name) {
if offset >= scope_start && offset < scope_end {
if best
.as_ref()
.map(|(s, _)| scope_start >= *s)
.unwrap_or(true)
{
*best = Some((scope_start, ty.to_string()));
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_declared_type(lang, child, source, name, offset, best);
}
}
fn is_any_string_pattern(text: &str) -> bool {
let t = text.trim();
t == "\"...\"" || t == "'...'"
}
fn is_string_node(node: tree_sitter::Node, _source: &str) -> bool {
matches!(
node.kind(),
"string"
| "string_literal"
| "interpreted_string_literal"
| "raw_string_literal"
| "template_string"
)
}
fn byte_offset_to_position(source: &str, byte_offset: usize) -> (usize, usize) {
let prefix = &source[..byte_offset];
let line = prefix.bytes().filter(|b| *b == b'\n').count() + 1;
let line_start = prefix.rfind('\n').map_or(0, |pos| pos + 1);
let column = byte_offset - line_start + 1;
(line, column)
}
fn find_source_line_by_line(source: &str, line: usize) -> Option<String> {
source
.lines()
.nth(line.saturating_sub(1))
.map(|s| s.to_string())
}
fn ranges_overlap(left: &MatchRange, right: &MatchRange) -> bool {
left.start_byte < right.end_byte && right.start_byte < left.end_byte
}
fn merge_bindings(
left: &HashMap<String, String>,
right: &HashMap<String, String>,
) -> Option<HashMap<String, String>> {
let mut merged = left.clone();
for (key, value) in right {
if let Some(existing) = merged.get(key) {
if existing != value {
return None;
}
} else {
merged.insert(key.clone(), value.clone());
}
}
Some(merged)
}
fn merge_binding_ranges(
left: &HashMap<String, MetavarRange>,
right: &HashMap<String, MetavarRange>,
) -> HashMap<String, MetavarRange> {
let mut merged = left.clone();
for (key, value) in right {
merged.entry(key.clone()).or_insert(*value);
}
merged
}
fn intersect_match_sets(left: Vec<MatchRange>, right: Vec<MatchRange>) -> Vec<MatchRange> {
let mut merged = Vec::new();
for left_match in left {
for right_match in &right {
if !ranges_overlap(&left_match, right_match) {
continue;
}
let Some(bindings) = merge_bindings(&left_match.bindings, &right_match.bindings) else {
continue;
};
let binding_ranges =
merge_binding_ranges(&left_match.binding_ranges, &right_match.binding_ranges);
let mut combined = left_match.clone();
combined.bindings = bindings;
combined.binding_ranges = binding_ranges;
merged.push(combined);
}
}
merged.sort_by_key(|r| (r.start_byte, r.end_byte));
merged.dedup_by_key(|r| (r.start_byte, r.end_byte));
merged
}
fn match_negative_pattern(
negative: &NegativeMatcher,
root: tree_sitter::Node,
source: &str,
) -> MatchResult {
match negative {
NegativeMatcher::Pattern(pattern) => match_single_pattern(pattern, root, source),
NegativeMatcher::Regex(regex) => match_regex_pattern(regex, source),
}
}
fn map_severity(s: &SemgrepSeverity) -> Severity {
match s {
SemgrepSeverity::Error => Severity::Critical,
SemgrepSeverity::Warning => Severity::High,
SemgrepSeverity::Medium => Severity::High,
SemgrepSeverity::Info => Severity::Medium,
}
}
fn map_language(lang_str: &str) -> Option<Language> {
match lang_str.to_lowercase().as_str() {
"javascript" | "js" | "typescript" | "ts" | "jsx" | "tsx" => Some(Language::JavaScript),
"python" | "py" => Some(Language::Python),
"go" | "golang" => Some(Language::Go),
"ruby" | "rb" => Some(Language::Ruby),
"java" => Some(Language::Java),
"php" => Some(Language::Php),
"rust" | "rs" => Some(Language::Rust),
"csharp" | "c#" | "cs" => Some(Language::CSharp),
"swift" => Some(Language::Swift),
"kotlin" | "kt" => Some(Language::Kotlin),
"c" => Some(Language::C),
"hcl" | "terraform" | "tf" => Some(Language::Hcl),
"solidity" | "sol" => Some(Language::Solidity),
"yaml" | "yml" => Some(Language::Yaml),
"dockerfile" | "docker" => Some(Language::Dockerfile),
"bash" | "sh" => Some(Language::Bash),
"ocaml" | "ml" | "mli" => Some(Language::Ocaml),
"scala" | "sc" => Some(Language::Scala),
"elixir" | "ex" | "exs" => Some(Language::Elixir),
"json" => Some(Language::Json),
"apex" => Some(Language::Apex),
"clojure" | "clj" | "cljs" | "cljc" => Some(Language::Clojure),
"html" | "htm" => Some(Language::Html),
"xml" => Some(Language::Xml),
"dart" => Some(Language::Dart),
"haskell" | "hs" => Some(Language::Haskell),
_ => None,
}
}
fn is_generic_language_rule(languages: &[String]) -> bool {
languages.iter().any(|l| l.to_lowercase() == "generic")
}
fn is_regex_language_rule(languages: &[String]) -> bool {
languages.iter().any(|l| l.to_lowercase() == "regex")
}
const REGEX_MODE_ALL_LANGUAGES: &[Language] = &[
Language::JavaScript,
Language::Python,
Language::Go,
Language::Ruby,
Language::Java,
Language::Php,
Language::Rust,
Language::CSharp,
Language::Swift,
Language::Kotlin,
Language::C,
Language::Hcl,
Language::Solidity,
Language::Yaml,
Language::NginxConf,
Language::ApacheConf,
Language::HAProxyConf,
Language::Dockerfile,
Language::Manifest,
Language::Bash,
Language::Ocaml,
Language::Scala,
Language::Elixir,
Language::Json,
Language::Apex,
Language::Clojure,
Language::Html,
Language::Xml,
Language::Dart,
Language::Haskell,
];
struct RegexModeRule {
id: String,
message: String,
severity: Severity,
cwe: Option<String>,
lang: Language,
positives: std::sync::Arc<Vec<CompiledRegex>>,
negatives: std::sync::Arc<Vec<CompiledRegex>>,
path_filter: Option<std::sync::Arc<PathFilter>>,
}
impl Rule for RegexModeRule {
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 applies_to_path(&self, path: &Path) -> bool {
self.path_filter
.as_ref()
.is_none_or(|filter| filter.matches(path))
}
fn check(&self, source: &str, _tree: &tree_sitter::Tree) -> Vec<Finding> {
let candidates: Option<Vec<MatchRange>> =
self.positives
.iter()
.fold(None, |acc: Option<Vec<MatchRange>>, re| {
let hits: Vec<MatchRange> = match_regex_pattern(re, source);
Some(match acc {
None => hits,
Some(prev) => {
prev.into_iter()
.filter(|p| {
hits.iter().any(|h| {
p.start_byte < h.end_byte && h.start_byte < p.end_byte
})
})
.collect()
}
})
});
let mut results = candidates.unwrap_or_default();
if results.is_empty() {
return Vec::new();
}
for neg in self.negatives.iter() {
let neg_hits: Vec<MatchRange> = match_regex_pattern(neg, source);
if !neg_hits.is_empty() {
results.retain(|r| {
!neg_hits
.iter()
.any(|n| r.start_byte < n.end_byte && n.start_byte < r.end_byte)
});
}
}
results
.into_iter()
.map(|m| Finding {
rule_id: self.id.clone(),
severity: self.severity,
cwe: self.cwe.clone(),
description: self.message.clone(),
file: String::new(),
line: m.line,
column: m.column,
end_line: m.end_line,
end_column: m.end_column,
snippet: m.snippet,
source_line: None,
source_description: None,
sink_line: None,
sink_description: None,
fix_suggestion: None,
sink_start_byte: None,
sink_end_byte: None,
confidence: 0.7,
taint_hops: None,
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![],
crypto_material: None,
})
.collect()
}
}
fn build_regex_mode_rules(
yaml: &SemgrepRuleYaml,
severity: Severity,
cwe: &Option<String>,
path_filter: &Option<PathFilter>,
) -> Result<Vec<Box<dyn Rule>>, String> {
let mut positives: Vec<CompiledRegex> = Vec::new();
let mut negatives: Vec<CompiledRegex> = Vec::new();
macro_rules! push_regex {
($dest:expr, $re:expr, $label:expr) => {
match compile_regex($re) {
Ok(r) => $dest.push(r),
Err(e) => eprintln!(
"Warning: regex-mode rule '{}' {} has unsupported regex ({}); \
skipping clause",
yaml.id, $label, e
),
}
};
}
if let Some(ref re) = yaml.pattern_regex {
push_regex!(positives, re, "pattern-regex");
}
if let Some(ref re) = yaml.pattern_not_regex {
push_regex!(negatives, re, "pattern-not-regex");
}
if let Some(ref clauses) = yaml.patterns {
for clause in clauses {
if let Some(ref re) = clause.pattern_regex {
push_regex!(positives, re, "patterns[].pattern-regex");
}
if let Some(ref re) = clause.pattern_not_regex {
push_regex!(negatives, re, "patterns[].pattern-not-regex");
}
if let Some(ref entries) = clause.pattern_either {
for entry in entries {
if let Some(ref re) = entry.pattern_regex {
push_regex!(positives, re, "patterns[].pattern-either[].pattern-regex");
}
}
}
}
}
if let Some(ref entries) = yaml.pattern_either {
for entry in entries {
if let Some(ref re) = entry.pattern_regex {
push_regex!(positives, re, "pattern-either[].pattern-regex");
}
}
}
if positives.is_empty() {
eprintln!(
"Warning: languages: [regex] rule '{}' has no pattern-regex; \
regex mode cannot run AST patterns — skipping",
yaml.id
);
return Ok(Vec::new());
}
let positives = std::sync::Arc::new(positives);
let negatives = std::sync::Arc::new(negatives);
let path_filter = path_filter.clone().map(std::sync::Arc::new);
let rules = REGEX_MODE_ALL_LANGUAGES
.iter()
.map(|&lang| {
Box::new(RegexModeRule {
id: format!("semgrep/{}", yaml.id),
message: yaml.message.clone(),
severity,
cwe: cwe.clone(),
lang,
positives: std::sync::Arc::clone(&positives),
negatives: std::sync::Arc::clone(&negatives),
path_filter: path_filter.clone(),
}) as Box<dyn Rule>
})
.collect();
Ok(rules)
}
fn build_generic_mode_rules(
yaml: &SemgrepRuleYaml,
severity: Severity,
cwe: &Option<String>,
path_filter: &Option<PathFilter>,
) -> Result<Vec<Box<dyn Rule>>, String> {
use crate::rules::generic_mode::{
build_generic_rules, GenericEitherEntry, GenericPatternsClause, GenericRuleSpec,
};
fn map_clause(
clause: &PatternClause,
rule_id: &str,
strict: bool,
) -> Option<GenericPatternsClause> {
if clause.pattern_inside.is_some() {
eprintln!(
"Warning: generic mode does not support pattern-inside in rule '{rule_id}'; \
skipping clause"
);
return None;
}
if clause.pattern_not_inside.is_some() {
eprintln!(
"Warning: generic mode does not support pattern-not-inside in rule '{rule_id}'; \
skipping clause"
);
return None;
}
let pattern_either_entries: Vec<GenericEitherEntry> = clause
.pattern_either
.iter()
.flatten()
.map(map_either_arm)
.collect();
let metavariable_regex = clause
.metavariable_regex
.as_ref()
.map(|mr| (mr.metavariable.clone(), mr.regex.clone()));
let metavariable_comparison = clause
.metavariable_comparison
.as_ref()
.map(|mc| (mc.metavariable.clone(), mc.comparison.clone()));
let focus_metavariable = clause
.focus_metavariable
.clone()
.and_then(|f| f.into_vec().into_iter().next());
let unsupported_constraint = strict
&& (clause.metavariable_pattern.is_some() || clause.metavariable_analysis.is_some());
let has_positive = clause.pattern.is_some()
|| clause.pattern_regex.is_some()
|| !pattern_either_entries.is_empty();
let has_negative = clause.pattern_not.is_some() || clause.pattern_not_regex.is_some();
let has_constraint = metavariable_regex.is_some()
|| metavariable_comparison.is_some()
|| focus_metavariable.is_some()
|| unsupported_constraint;
if !has_positive && !has_negative && !has_constraint {
return None;
}
Some(GenericPatternsClause {
pattern: clause.pattern.clone(),
pattern_regex: clause.pattern_regex.clone(),
pattern_either: pattern_either_entries,
pattern_not: clause.pattern_not.clone(),
pattern_not_regex: clause.pattern_not_regex.clone(),
metavariable_regex,
metavariable_comparison,
focus_metavariable,
unsupported_constraint,
})
}
fn map_either_arm(entry: &PatternEntry) -> GenericEitherEntry {
let patterns = entry
.patterns
.as_ref()
.and_then(|v| serde_yaml_ng::from_value::<Vec<PatternClause>>(v.clone()).ok())
.unwrap_or_default()
.iter()
.filter_map(|c| map_clause(c, "<pattern-either arm>", true))
.collect();
GenericEitherEntry {
pattern: entry.pattern.clone(),
pattern_regex: entry.pattern_regex.clone(),
patterns,
}
}
let pattern_either: Vec<GenericEitherEntry> = yaml
.pattern_either
.iter()
.flatten()
.map(map_either_arm)
.collect();
let patterns_clauses: Vec<GenericPatternsClause> = yaml
.patterns
.iter()
.flatten()
.filter_map(|clause| map_clause(clause, &yaml.id, false))
.collect();
build_generic_rules(GenericRuleSpec {
id: &yaml.id,
message: &yaml.message,
severity,
cwe: cwe.clone(),
pattern: yaml.pattern.as_deref(),
pattern_regex: yaml.pattern_regex.as_deref(),
pattern_either,
pattern_not: yaml.pattern_not.as_deref(),
pattern_not_regex: yaml.pattern_not_regex.as_deref(),
patterns_clauses,
path_filter: path_filter.clone(),
})
}
fn build_matcher(yaml: &SemgrepRuleYaml, lang: Language) -> Result<PatternMatcher, String> {
if let Some(ref clauses) = yaml.patterns {
let mut positives = Vec::new();
let mut negatives = Vec::new();
let mut inside = None;
let mut not_inside = None;
let mut metavariable_regexes = Vec::new();
let mut metavariable_comparisons = Vec::new();
let mut metavariable_patterns = Vec::new();
let mut metavariable_analyses = Vec::new();
let mut metavariable_types = Vec::new();
let mut focus_metavariables: Vec<String> = Vec::new();
for clause in clauses {
if let Some(ref p) = clause.pattern {
positives.push(PatternMatcher::Single(CompiledAstPattern::new(
p.clone(),
lang,
)));
}
if let Some(ref regex) = clause.pattern_regex {
match compile_regex(regex) {
Ok(r) => positives.push(PatternMatcher::Regex(r)),
Err(e) => eprintln!(
"Warning: patterns: clause has unsupported pattern-regex ({}); \
skipping clause",
e
),
}
}
if let Some(ref pn) = clause.pattern_not {
negatives.push(NegativeMatcher::Pattern(CompiledAstPattern::new(
pn.clone(),
lang,
)));
}
if let Some(ref regex) = clause.pattern_not_regex {
match compile_regex(regex) {
Ok(r) => negatives.push(NegativeMatcher::Regex(r)),
Err(e) => eprintln!(
"Warning: patterns: clause has unsupported pattern-not-regex ({}); \
skipping clause",
e
),
}
}
if let Some(ref pi) = clause.pattern_inside {
inside = Some(CompiledAstPattern::new(pi.clone(), lang));
}
if let Some(pni) = clause.pattern_not_inside.clone() {
if let Some(pat_str) = pni.into_pattern_string() {
not_inside = Some(CompiledAstPattern::new(pat_str, lang));
}
}
if let Some(ref pe) = clause.pattern_either {
let matchers = build_either_matchers(pe, lang)?;
positives.push(PatternMatcher::Either(matchers));
}
if let Some(ref mr) = clause.metavariable_regex {
if let Some(constraint) = MetavariableRegexConstraint::from_yaml(mr) {
metavariable_regexes.push(constraint);
}
}
if let Some(ref mc) = clause.metavariable_comparison {
match MetavariableComparisonConstraint::from_yaml(mc) {
Ok(constraint) => metavariable_comparisons.push(constraint),
Err(e) => eprintln!("Warning: {e}"),
}
}
if let Some(ref mp) = clause.metavariable_pattern {
if let Some(constraint) = MetavariablePatternConstraint::from_yaml(mp, lang) {
metavariable_patterns.push(constraint);
}
}
if let Some(ref ma) = clause.metavariable_analysis {
if let Some(constraint) = MetavariableAnalysisConstraint::from_yaml(ma) {
metavariable_analyses.push(constraint);
}
}
if let Some(ref mt) = clause.metavariable_type {
if !metavariable_type_enforceable(lang) {
return Err(format!(
"metavariable-type on {} is not enforceable for {} \
(no syntactic type resolution); skipping rule",
mt.metavariable, lang
));
}
metavariable_types.push(MetavariableTypeConstraint::from_yaml(mt, lang));
}
if let Some(ref fmv) = clause.focus_metavariable {
focus_metavariables.extend(fmv.clone().into_vec());
}
}
return Ok(PatternMatcher::Combined {
positives,
negatives,
inside,
not_inside,
metavariable_regexes,
metavariable_comparisons,
metavariable_patterns,
metavariable_analyses,
metavariable_types,
focus_metavariables,
});
}
let mut positives = Vec::new();
let mut negatives = Vec::new();
if let Some(ref pat) = yaml.pattern {
positives.push(PatternMatcher::Single(CompiledAstPattern::new(
pat.clone(),
lang,
)));
}
if let Some(ref regex) = yaml.pattern_regex {
match compile_regex(regex) {
Ok(r) => positives.push(PatternMatcher::Regex(r)),
Err(e) => eprintln!(
"Warning: top-level pattern-regex uses unsupported features ({}); \
skipping pattern",
e
),
}
}
if let Some(ref either) = yaml.pattern_either {
positives.push(PatternMatcher::Either(build_either_matchers(either, lang)?));
}
if let Some(ref pat) = yaml.pattern_not {
negatives.push(NegativeMatcher::Pattern(CompiledAstPattern::new(
pat.clone(),
lang,
)));
}
if let Some(ref regex) = yaml.pattern_not_regex {
match compile_regex(regex) {
Ok(r) => negatives.push(NegativeMatcher::Regex(r)),
Err(e) => eprintln!(
"Warning: top-level pattern-not-regex uses unsupported features ({}); \
skipping pattern",
e
),
}
}
let not_inside_pat: Option<CompiledAstPattern> =
yaml.pattern_not_inside.clone().and_then(|pob| {
pob.into_pattern_string()
.map(|pat| CompiledAstPattern::new(pat, lang))
});
if positives.len() == 1
&& negatives.is_empty()
&& yaml.pattern_inside.is_none()
&& not_inside_pat.is_none()
{
return Ok(positives.into_iter().next().expect("checked len == 1"));
}
if !positives.is_empty() {
return Ok(PatternMatcher::Combined {
positives,
negatives,
inside: yaml
.pattern_inside
.as_ref()
.map(|pattern| CompiledAstPattern::new(pattern.clone(), lang)),
not_inside: not_inside_pat,
metavariable_regexes: Vec::new(),
metavariable_comparisons: Vec::new(),
metavariable_patterns: Vec::new(),
metavariable_analyses: Vec::new(),
metavariable_types: Vec::new(),
focus_metavariables: Vec::new(),
});
}
Ok(PatternMatcher::Either(Vec::new()))
}
fn build_either_matchers(
entries: &[PatternEntry],
lang: Language,
) -> Result<Vec<PatternMatcher>, String> {
let mut matchers = Vec::new();
for entry in entries {
if let Some(ref pattern) = entry.pattern {
matchers.push(PatternMatcher::Single(CompiledAstPattern::new(
pattern.clone(),
lang,
)));
}
if let Some(ref regex) = entry.pattern_regex {
match compile_regex(regex) {
Ok(r) => matchers.push(PatternMatcher::Regex(r)),
Err(e) => eprintln!(
"Warning: pattern-either entry has unsupported pattern-regex ({}); \
skipping entry",
e
),
}
}
}
Ok(matchers)
}
pub(crate) fn compile_regex(pattern: &str) -> Result<CompiledRegex, String> {
let normalised = pattern.replace(r"\Z", "$");
let normalised = escape_bare_braces(&normalised);
match Regex::new(&normalised) {
Ok(re) => Ok(CompiledRegex::Fast(re)),
Err(fast_err) => match fancy_regex::Regex::new(&normalised) {
Ok(re) => Ok(CompiledRegex::Fancy(re)),
Err(fancy_err) => Err(format!(
"Invalid pattern-regex '{}': {} (fancy-regex fallback also failed: {})",
pattern, fast_err, fancy_err
)),
},
}
}
fn escape_bare_braces(s: &str) -> String {
let bytes = s.as_bytes();
let n = bytes.len();
let mut out = String::with_capacity(n + 8);
let mut i = 0;
let mut backslash_run = 0usize; let mut in_class = false;
while i < n {
let b = bytes[i];
match b {
b'\\' => {
backslash_run += 1;
out.push(b as char);
i += 1;
}
b'[' if backslash_run.is_multiple_of(2) => {
in_class = true;
backslash_run = 0;
out.push('[');
i += 1;
}
b']' if backslash_run.is_multiple_of(2) => {
in_class = false;
backslash_run = 0;
out.push(']');
i += 1;
}
b'{' if backslash_run.is_multiple_of(2) && !in_class => {
backslash_run = 0;
if looks_like_quantifier(bytes, i + 1) {
out.push('{');
} else {
out.push_str(r"\{");
}
i += 1;
}
b'}' if backslash_run.is_multiple_of(2) && !in_class => {
backslash_run = 0;
let prev = out.as_bytes().last().copied();
if matches!(prev, Some(b'0'..=b'9') | Some(b',') | Some(b'{')) {
out.push('}');
} else {
out.push_str(r"\}");
}
i += 1;
}
_ => {
backslash_run = 0;
out.push(b as char);
i += 1;
}
}
}
out
}
fn looks_like_quantifier(bytes: &[u8], pos: usize) -> bool {
let n = bytes.len();
let mut i = pos;
if i >= n || !bytes[i].is_ascii_digit() {
return false;
}
while i < n && bytes[i].is_ascii_digit() {
i += 1;
}
if i >= n {
return false;
}
match bytes[i] {
b'}' => true, b',' => {
i += 1;
while i < n && bytes[i].is_ascii_digit() {
i += 1;
}
i < n && bytes[i] == b'}'
}
_ => false,
}
}
fn compile_globset(patterns: &[String]) -> Result<Option<GlobSet>, String> {
if patterns.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob =
Glob::new(pattern).map_err(|e| format!("Invalid paths glob '{}': {}", pattern, e))?;
builder.add(glob);
}
builder
.build()
.map(Some)
.map_err(|e| format!("Failed to build paths globset: {}", e))
}
fn normalize_rule_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn extract_cwe(yaml: &SemgrepRuleYaml) -> Option<String> {
let meta = yaml.metadata.as_ref()?;
let cwe = meta.cwe.as_ref()?;
match cwe {
CweValue::Single(s) => Some(s.clone()),
CweValue::List(v) => v.first().cloned(),
}
}
fn reserved_rule_namespace(rule_id: &str) -> Option<&'static str> {
let (namespace, _) = rule_id.split_once('/')?;
RESERVED_RULE_ID_NAMESPACES
.iter()
.copied()
.find(|reserved| *reserved == namespace)
}
fn validate_semgrep_rule_id(rule_id: &str, source_label: &str) -> Result<(), String> {
let Some(namespace) = reserved_rule_namespace(rule_id) else {
return Ok(());
};
Err(format!(
"Rule id '{}' in {} uses reserved namespace '{}/'. YAML rule packs must use a pack-specific namespace such as 'kernel/dirty-frag/...' or 'acme/security/...'. Reserved namespaces: {}",
rule_id,
source_label,
namespace,
RESERVED_RULE_ID_NAMESPACES.join(", ")
))
}
pub fn parse_semgrep_file(path: &Path) -> Result<Vec<Box<dyn Rule>>, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
parse_semgrep_str(&content, &path.display().to_string())
}
pub fn parse_semgrep_str(content: &str, source_label: &str) -> Result<Vec<Box<dyn Rule>>, String> {
use crate::rules::semgrep_taint::{self, TaintRuleParse};
use serde_yaml_ng::Value as YamlValue;
let raw_doc: YamlValue = serde_yaml_ng::from_str(content)
.map_err(|e| format!("Failed to parse YAML {}: {}", source_label, e))?;
let mut rules: Vec<Box<dyn Rule>> = Vec::new();
let mut pattern_rule_nodes: Vec<YamlValue> = Vec::new();
if let Some(raw_rules) = raw_doc.get("rules").and_then(YamlValue::as_sequence) {
for raw_rule in raw_rules {
if let Some(rule_id) = raw_rule.get("id").and_then(YamlValue::as_str) {
validate_semgrep_rule_id(rule_id, source_label)?;
}
if raw_rule
.get("engine")
.and_then(YamlValue::as_str)
.is_some_and(|engine| {
engine.eq_ignore_ascii_case("coccinelle")
|| engine.eq_ignore_ascii_case("codeql")
})
{
continue;
}
match semgrep_taint::parse_taint_rule(raw_rule) {
TaintRuleParse::Compiled(r) => rules.push(Box::new(r)),
TaintRuleParse::Skip(msg) => eprintln!("Warning: {}", msg),
TaintRuleParse::NotTaint => pattern_rule_nodes.push(raw_rule.clone()),
}
}
}
let pattern_file = YamlValue::Mapping({
let mut m = serde_yaml_ng::Mapping::new();
m.insert(
YamlValue::String("rules".into()),
YamlValue::Sequence(pattern_rule_nodes),
);
m
});
let semgrep_file: SemgrepFile = serde_yaml_ng::from_value(pattern_file)
.map_err(|e| format!("Failed to parse YAML {}: {}", source_label, e))?;
for yaml_rule in semgrep_file.rules {
let cwe = extract_cwe(&yaml_rule);
let severity = map_severity(&yaml_rule.severity);
let path_filter = PathFilter::from_yaml(yaml_rule.paths.as_ref())?;
if is_generic_language_rule(&yaml_rule.languages) {
rules.extend(build_generic_mode_rules(
&yaml_rule,
severity,
&cwe,
&path_filter,
)?);
continue;
}
if is_regex_language_rule(&yaml_rule.languages) {
rules.extend(build_regex_mode_rules(
&yaml_rule,
severity,
&cwe,
&path_filter,
)?);
continue;
}
let mut mapped_languages = Vec::new();
for lang_str in &yaml_rule.languages {
if let Some(lang) = map_language(lang_str) {
if !mapped_languages.contains(&lang) {
mapped_languages.push(lang);
}
}
}
for lang in mapped_languages {
let matcher = build_matcher(&yaml_rule, lang)?;
rules.push(Box::new(SemgrepRule {
id: format!("semgrep/{}", yaml_rule.id),
message: yaml_rule.message.clone(),
severity,
lang,
cwe: cwe.clone(),
matcher,
path_filter: path_filter.clone(),
fix_template: yaml_rule.fix.clone(),
}));
}
}
Ok(rules)
}
pub fn load_semgrep_rules(path: &Path) -> Vec<Box<dyn Rule>> {
let mut rules = Vec::new();
if path.is_file() {
match parse_semgrep_file(path) {
Ok(r) => rules.extend(r),
Err(e) => eprintln!("Warning: {}", e),
}
} else if path.is_dir() {
let walker = walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& matches!(
e.path().extension().and_then(|s| s.to_str()),
Some("yaml" | "yml")
)
});
for entry in walker {
match parse_semgrep_file(entry.path()) {
Ok(r) => rules.extend(r),
Err(e) => eprintln!("Warning: {}", e),
}
}
}
rules
}
pub fn load_semgrep_rules_from_embedded(dir: &include_dir::Dir<'_>) -> Vec<Box<dyn Rule>> {
let mut rules = Vec::new();
walk_embedded_dir(dir, &mut rules);
rules
}
fn walk_embedded_dir(dir: &include_dir::Dir<'_>, rules: &mut Vec<Box<dyn Rule>>) {
for file in dir.files() {
let path = file.path();
let ext = path.extension().and_then(|s| s.to_str());
if !matches!(ext, Some("yaml" | "yml")) {
continue;
}
let Some(content) = file.contents_utf8() else {
eprintln!(
"Warning: embedded rule {} is not valid UTF-8, skipping",
path.display()
);
continue;
};
let label = format!("<bundled:{}>", path.display());
match parse_semgrep_str(content, &label) {
Ok(r) => rules.extend(r),
Err(e) => eprintln!("Warning: {e}"),
}
}
for subdir in dir.dirs() {
if subdir.path().file_name().and_then(|s| s.to_str()) == Some("queries") {
continue;
}
walk_embedded_dir(subdir, rules);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn make_yaml(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn test_parse_simple_rule() {
let yaml = r#"
rules:
- id: test-eval
pattern: eval(...)
message: Do not use eval
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id(), "semgrep/test-eval");
assert_eq!(rules[0].severity(), Severity::Critical);
}
#[test]
fn test_reserved_rule_id_namespace_is_rejected() {
let yaml = r#"
rules:
- id: py/custom-eval
pattern: eval(...)
message: Do not use eval
severity: ERROR
languages: [python]
"#;
let err = match parse_semgrep_str(yaml, "org-pack.yml") {
Ok(_) => panic!("reserved rule namespace should be rejected"),
Err(err) => err,
};
assert!(err.contains("py/custom-eval"));
assert!(err.contains("reserved namespace 'py/'"));
assert!(err.contains("org-pack.yml"));
}
#[test]
fn test_reserved_rule_id_alias_namespaces_are_rejected() {
for namespace in ["cs", "csharp", "rs", "rust"] {
let rule_id = format!("{namespace}/custom-rule");
assert_eq!(reserved_rule_namespace(&rule_id), Some(namespace));
}
}
#[test]
fn ast_patterns_are_compiled_during_rule_load() {
let yaml = r#"
rules:
- id: test-eval
pattern: eval(...)
message: Do not use eval
severity: ERROR
languages: [python]
"#;
let parsed: SemgrepFile = serde_yaml_ng::from_str(yaml).unwrap();
let matcher = build_matcher(&parsed.rules[0], Language::Python).unwrap();
match matcher {
PatternMatcher::Single(pattern) => {
assert!(pattern.tree.is_some());
assert_eq!(pattern.selector_kind.as_deref(), Some("call"));
}
other => panic!("expected single compiled AST pattern, got {other:?}"),
}
}
#[test]
fn test_parse_pattern_either() {
let yaml = r#"
rules:
- id: dangerous-funcs
pattern-either:
- pattern: eval(...)
- pattern: exec(...)
message: Dangerous function
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert_eq!(rules.len(), 1);
}
#[test]
fn test_dedup_mapped_languages() {
let yaml = r#"
rules:
- id: js-send
pattern: res.send("Hello World")
message: Exact Express response send call
severity: WARNING
languages: [javascript, typescript]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].language(), Language::JavaScript);
}
#[test]
fn test_metavar_detection() {
assert!(is_metavar("$VAR"));
assert!(is_metavar("$X"));
assert!(is_metavar("$DB_NAME"));
assert!(!is_metavar("$"));
assert!(!is_metavar("foo"));
assert!(!is_metavar("$foo.bar"));
}
#[test]
fn test_match_eval_pattern() {
let yaml = r#"
rules:
- id: test-eval
pattern: eval(...)
message: No eval
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "x = eval(user_input)\ny = safe_func()\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
}
#[test]
fn semgrep_compat_findings_are_emitted_at_confidence_zero_point_seven() {
let yaml = r#"
rules:
- id: test-eval
pattern: eval(...)
message: No eval
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "x = eval(user_input)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert!((findings[0].confidence - 0.7).abs() < f32::EPSILON);
}
#[test]
fn test_match_hardcoded_string() {
let yaml = r#"
rules:
- id: hardcoded-password
pattern: password = "..."
message: Hardcoded password
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "password = \"supersecret\"\nusername = \"admin\"\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_match_string_concat_with_metavar() {
let yaml = r#"
rules:
- id: string-concat
pattern: '"..." + $VAR'
message: String concatenation
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "query = \"SELECT \" + user_input\nsafe = 1 + 2\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_match_pattern_regex() {
let yaml = r#"
rules:
- id: regex-secret
pattern-regex: "(?m)^SECRET_KEY\\s*="
message: Regex secret
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "password = \"supersecret\"\nSECRET_KEY = \"django-secret\"\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 2);
}
#[test]
fn test_pattern_not_regex_filters_matches() {
let yaml = r#"
rules:
- id: password-assign
patterns:
- pattern-regex: "(?m)^.*password.*="
- pattern-not-regex: "not_password"
message: Password assignment
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "password = \"supersecret\"\nnot_password = \"safe\"\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_metavariable_regex_filters_bound_matches() {
let yaml = r#"
rules:
- id: user-input-only
patterns:
- pattern: '"..." + $VAR'
- metavariable-regex:
metavariable: $VAR
regex: ^user_input$
message: user input only
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "query = \"SELECT \" + user_input\nquery2 = \"SELECT \" + safe_value\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_pattern_not_inside_excludes_nested_matches() {
let yaml = r#"
rules:
- id: redirect-outside-helpers
patterns:
- pattern: redirect(...)
- pattern-not-inside: |
def safe_redirect(...):
...
message: redirect outside helper
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "def safe_redirect(url):\n return redirect(url)\n\ndef do_redirect(url):\n return redirect(url)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 5);
}
#[test]
fn test_parse_comparison_lt() {
let (mv, op, lit, flip) = parse_comparison("$X < 10").unwrap();
assert_eq!(mv, "$X");
assert_eq!(op, CmpOp::Lt);
assert!((lit - 10.0).abs() < f64::EPSILON);
assert!(!flip);
}
#[test]
fn test_parse_comparison_le() {
let (mv, op, lit, _flip) = parse_comparison("$X <= 5.5").unwrap();
assert_eq!(op, CmpOp::Le);
assert!((lit - 5.5).abs() < f64::EPSILON);
assert_eq!(mv, "$X");
}
#[test]
fn test_parse_comparison_gt() {
let (_mv, op, lit, _flip) = parse_comparison("$N > 100").unwrap();
assert_eq!(op, CmpOp::Gt);
assert!((lit - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_parse_comparison_ge() {
let (_mv, op, lit, _flip) = parse_comparison("$N >= 0").unwrap();
assert_eq!(op, CmpOp::Ge);
assert!((lit - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_parse_comparison_eq() {
let (mv, op, lit, _flip) = parse_comparison("$VAL == 42").unwrap();
assert_eq!(op, CmpOp::Eq);
assert!((lit - 42.0).abs() < f64::EPSILON);
assert_eq!(mv, "$VAL");
}
#[test]
fn test_parse_comparison_ne() {
let (_mv, op, lit, _flip) = parse_comparison("$VAL != 0").unwrap();
assert_eq!(op, CmpOp::Ne);
assert!((lit - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_parse_comparison_literal_lhs() {
let (mv, op, lit, flip) = parse_comparison("10 < $X").unwrap();
assert_eq!(mv, "$X");
assert_eq!(op, CmpOp::Lt);
assert!((lit - 10.0).abs() < f64::EPSILON);
assert!(flip);
}
#[test]
fn test_parse_comparison_no_metavar_is_err() {
assert!(parse_comparison("10 < 20").is_err());
}
#[test]
fn test_parse_comparison_no_operator_is_err() {
assert!(parse_comparison("$X 10").is_err());
}
#[test]
fn test_constraint_matches_numeric_match() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X < 10".to_string(),
base: None,
strip: None,
};
let constraint = MetavariableComparisonConstraint::from_yaml(&clause).unwrap();
let mut bindings = HashMap::new();
bindings.insert("$X".to_string(), "5".to_string());
assert!(constraint.matches(&bindings));
}
#[test]
fn test_constraint_non_match() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X < 10".to_string(),
base: None,
strip: None,
};
let constraint = MetavariableComparisonConstraint::from_yaml(&clause).unwrap();
let mut bindings = HashMap::new();
bindings.insert("$X".to_string(), "15".to_string());
assert!(!constraint.matches(&bindings));
}
#[test]
fn test_constraint_non_numeric_binding_no_match() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X < 10".to_string(),
base: None,
strip: None,
};
let constraint = MetavariableComparisonConstraint::from_yaml(&clause).unwrap();
let mut bindings = HashMap::new();
bindings.insert("$X".to_string(), "not_a_number".to_string());
assert!(!constraint.matches(&bindings));
}
#[test]
fn test_constraint_unbound_metavar_no_match() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X < 10".to_string(),
base: None,
strip: None,
};
let constraint = MetavariableComparisonConstraint::from_yaml(&clause).unwrap();
let bindings = HashMap::new(); assert!(!constraint.matches(&bindings));
}
#[test]
fn test_constraint_float_comparison() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X >= 3.14".to_string(),
base: None,
strip: None,
};
let constraint = MetavariableComparisonConstraint::from_yaml(&clause).unwrap();
let mut bindings = HashMap::new();
bindings.insert("$X".to_string(), "3.14".to_string());
assert!(constraint.matches(&bindings));
let mut bindings2 = HashMap::new();
bindings2.insert("$X".to_string(), "2.0".to_string());
assert!(!constraint.matches(&bindings2));
}
#[test]
fn test_numeric_suffix_strips_c_float_f_and_preserves_hex() {
assert_eq!(parse_numeric(&strip_numeric_suffixes("2.5f")), Some(2.5));
assert_eq!(parse_numeric(&strip_numeric_suffixes("2.5F")), Some(2.5));
assert_eq!(parse_numeric(&strip_numeric_suffixes("10UL")), Some(10.0));
assert_eq!(parse_numeric(&strip_numeric_suffixes("0xFF")), Some(255.0));
assert_eq!(parse_numeric(&strip_numeric_suffixes("0xff")), Some(255.0));
assert_eq!(parse_numeric(&strip_numeric_suffixes("0b101")), Some(5.0));
}
#[test]
fn test_constraint_eq_is_exact() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X == 5".to_string(),
base: None,
strip: None,
};
let constraint = MetavariableComparisonConstraint::from_yaml(&clause).unwrap();
let mut hit = HashMap::new();
hit.insert("$X".to_string(), "5".to_string());
assert!(constraint.matches(&hit));
let mut miss = HashMap::new();
miss.insert("$X".to_string(), "6".to_string());
assert!(!constraint.matches(&miss));
}
#[test]
fn test_constraint_eq_c_float_suffix_binding() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X == 1.5".to_string(),
base: None,
strip: None,
};
let constraint = MetavariableComparisonConstraint::from_yaml(&clause).unwrap();
let mut bindings = HashMap::new();
bindings.insert("$X".to_string(), "1.5f".to_string());
assert!(constraint.matches(&bindings));
}
#[test]
fn test_constraint_unsupported_base_warn_skip() {
let clause = SemgrepMetavariableComparisonClause {
metavariable: Some("$X".to_string()),
comparison: "$X < 10".to_string(),
base: Some(16),
strip: None,
};
assert!(MetavariableComparisonConstraint::from_yaml(&clause).is_err());
}
#[test]
fn test_metavariable_comparison_filters_matches_end_to_end() {
let yaml = r#"
rules:
- id: small-arg
patterns:
- pattern: foo($X)
- metavariable-comparison:
metavariable: $X
comparison: $X < 10
message: foo called with small arg
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert_eq!(rules.len(), 1);
let source = "foo(5)\nfoo(20)\nfoo(bar)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_metavariable_comparison_eq_operator_end_to_end() {
let yaml = r#"
rules:
- id: exact-zero
patterns:
- pattern: check($N)
- metavariable-comparison:
metavariable: $N
comparison: $N == 0
message: called with zero
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "check(0)\ncheck(1)\ncheck(2)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_metavariable_pattern_match() {
let yaml = r#"
rules:
- id: mvp-match
patterns:
- pattern: eval($FUNC)
- metavariable-pattern:
metavariable: $FUNC
pattern: dangerous(...)
message: dangerous arg in eval
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "eval(dangerous(x))\neval(safe(x))\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1, "expected exactly one finding");
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_metavariable_pattern_no_match() {
let yaml = r#"
rules:
- id: mvp-no-match
patterns:
- pattern: eval($FUNC)
- metavariable-pattern:
metavariable: $FUNC
pattern: dangerous(...)
message: dangerous arg
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "eval(safe(x))\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(
findings.len(),
0,
"expected no findings when sub-pattern does not match"
);
}
#[test]
fn test_metavariable_pattern_regex_nested() {
let yaml = r#"
rules:
- id: mvp-regex
patterns:
- pattern: '"..." + $VAR'
- metavariable-pattern:
metavariable: $VAR
pattern-regex: '^user_'
message: user-prefixed var in concat
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "q = \"SELECT \" + user_input\nq2 = \"SELECT \" + data\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(
findings.len(),
1,
"expected exactly one finding for user_ variable"
);
assert_eq!(findings[0].line, 1);
}
#[test]
fn test_metavariable_pattern_unsupported_nested_shape_warn_skip() {
let yaml = r#"
rules:
- id: mvp-warn-skip
patterns:
- pattern: eval(...)
- metavariable-pattern:
metavariable: $FUNC
message: eval usage (constraint skipped)
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert_eq!(rules.len(), 1, "rule should still load after warn-skip");
let source = "eval(x)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert!(!findings.is_empty(), "positive pattern should still fire");
}
#[test]
fn test_focus_metavariable_range_override() {
let yaml = r#"
rules:
- id: focus-test
patterns:
- pattern: foo($ARG)
- focus-metavariable: $ARG
message: focus on arg
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "foo(bar)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1, "expected one finding");
assert_eq!(findings[0].line, 1, "finding should be on line 1");
assert_ne!(
findings[0].column, 1,
"column should NOT be 1 (that's the full match start); focus-metavariable must override it"
);
assert_eq!(
findings[0].column, 5,
"focused metavariable $ARG should start at column 5"
);
}
#[test]
fn test_focus_metavariable_list_syntax() {
let yaml = r#"
rules:
- id: focus-list-test
patterns:
- pattern: foo($ARG)
- focus-metavariable: [$ARG]
message: focus on arg
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "foo(bar)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1, "expected one finding");
assert_eq!(findings[0].column, 5, "$ARG should start at column 5");
}
#[test]
fn test_focus_metavariable_unbound_fallback() {
let yaml = r#"
rules:
- id: focus-fallback
patterns:
- pattern: eval(...)
- focus-metavariable: $MISSING
message: fallback test
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "eval(user_input)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1, "finding must not be dropped");
assert_eq!(findings[0].line, 1);
assert_eq!(
findings[0].column, 1,
"should fall back to full match column"
);
}
#[test]
fn test_fix_template_substitutes_bound_metavar() {
let yaml = r#"
rules:
- id: use-safe-func
pattern: unsafe_call($X)
fix: safe_call($X)
message: Use safe_call instead
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "unsafe_call(user_data)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].fix_suggestion.as_deref(),
Some("safe_call(user_data)"),
"metavar $X should be substituted with the bound text 'user_data'"
);
}
#[test]
fn test_no_fix_key_yields_no_fix_suggestion() {
let yaml = r#"
rules:
- id: test-eval-no-fix
pattern: eval(...)
message: Do not use eval
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "eval(user_input)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert!(
findings[0].fix_suggestion.is_none(),
"fix_suggestion should be None when no fix: key is present"
);
}
#[test]
fn test_fix_template_unbound_metavar_left_literal() {
let yaml = r#"
rules:
- id: fix-unbound
pattern: eval(...)
fix: safe_eval($UNBOUND)
message: Use safe_eval
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "eval(x)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].fix_suggestion.as_deref(),
Some("safe_eval($UNBOUND)"),
"unbound metavar token should be left literal, not panic"
);
}
#[test]
fn test_fix_template_multiple_metavars() {
let yaml = r#"
rules:
- id: fix-multi
pattern: old($A, $B)
fix: new($B, $A)
message: swap args
severity: INFO
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "old(foo, bar)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].fix_suggestion.as_deref(),
Some("new(bar, foo)"),
"both metavars should be substituted correctly"
);
}
#[test]
fn test_shannon_entropy_values() {
let high = shannon_entropy("Zq7Z9kW3pL8xT2nR4dB6m");
assert!(
high >= 3.5,
"expected entropy >= 3.5 for high-entropy token, got {high}"
);
let low = shannon_entropy("hello");
assert!(low < 3.0, "expected entropy < 3.0 for 'hello', got {low}");
assert_eq!(shannon_entropy(""), 0.0);
}
#[test]
fn test_metavariable_analysis_entropy_matches_high_entropy() {
let clause = SemgrepMetavariableAnalysisClause {
metavariable: "$TOKEN".to_string(),
analyzer: "entropy".to_string(),
};
let constraint = MetavariableAnalysisConstraint::from_yaml(&clause)
.expect("entropy analyzer should build successfully");
let mut bindings = HashMap::new();
bindings.insert(
"$TOKEN".to_string(),
"aB3xQz9mKp2LwYv5NtRsUhJdEfCgOiV7".to_string(),
);
assert!(
constraint.matches(&bindings),
"entropy constraint must match a high-entropy token"
);
}
#[test]
fn test_metavariable_analysis_entropy_no_match_low_entropy() {
let clause = SemgrepMetavariableAnalysisClause {
metavariable: "$TOKEN".to_string(),
analyzer: "entropy".to_string(),
};
let constraint = MetavariableAnalysisConstraint::from_yaml(&clause).unwrap();
let mut bindings = HashMap::new();
bindings.insert("$TOKEN".to_string(), "password".to_string());
assert!(
!constraint.matches(&bindings),
"entropy constraint must NOT match 'password'"
);
}
#[test]
fn test_metavariable_analysis_entropy_unbound_metavar_no_match() {
let clause = SemgrepMetavariableAnalysisClause {
metavariable: "$TOKEN".to_string(),
analyzer: "entropy".to_string(),
};
let constraint = MetavariableAnalysisConstraint::from_yaml(&clause).unwrap();
let bindings = HashMap::new(); assert!(
!constraint.matches(&bindings),
"unbound metavar must return false (no panic)"
);
}
#[test]
fn test_metavariable_analysis_redos_warn_skips() {
let clause = SemgrepMetavariableAnalysisClause {
metavariable: "$RE".to_string(),
analyzer: "redos".to_string(),
};
let result = MetavariableAnalysisConstraint::from_yaml(&clause);
assert!(
result.is_none(),
"redos analyzer must warn-skip (return None)"
);
}
#[test]
fn test_metavariable_analysis_unknown_analyzer_warn_skips() {
let clause = SemgrepMetavariableAnalysisClause {
metavariable: "$X".to_string(),
analyzer: "future-magic-analyzer".to_string(),
};
let result = MetavariableAnalysisConstraint::from_yaml(&clause);
assert!(
result.is_none(),
"unknown analyzer must warn-skip (return None)"
);
}
#[test]
fn test_metavariable_analysis_entropy_end_to_end() {
let yaml = r#"
rules:
- id: hardcoded-secret-entropy
patterns:
- pattern: 'token = "$VALUE"'
- metavariable-analysis:
metavariable: $VALUE
analyzer: entropy
message: Hardcoded high-entropy token detected
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert_eq!(rules.len(), 1, "rule must load successfully");
let high_entropy_source = r#"token = "aB3xQz9mKp2LwYv5NtRsUhJdEfCgOiV7"
"#;
let tree = parse_file(high_entropy_source, Language::Python).unwrap();
let findings = rules[0].check(high_entropy_source, &tree);
assert_eq!(
findings.len(),
1,
"entropy rule must fire on high-entropy token"
);
let low_entropy_source = r#"token = "password"
"#;
let tree2 = parse_file(low_entropy_source, Language::Python).unwrap();
let findings2 = rules[0].check(low_entropy_source, &tree2);
assert_eq!(
findings2.len(),
0,
"entropy rule must NOT fire on low-entropy word"
);
}
#[test]
fn test_metavariable_analysis_redos_warn_skip_end_to_end() {
let yaml = r#"
rules:
- id: redos-test
patterns:
- pattern: 'regex = "$PATTERN"'
- metavariable-analysis:
metavariable: $PATTERN
analyzer: redos
message: Possible ReDoS pattern
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert_eq!(rules.len(), 1, "rule must load after warn-skip");
let source = r#"regex = "(a+)+"
"#;
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(
findings.len(),
1,
"positive pattern must still fire when redos constraint is warn-skipped"
);
}
#[test]
fn test_focus_metavariable_multiline_source() {
let yaml = r#"
rules:
- id: focus-multiline
patterns:
- pattern: sink($X)
- focus-metavariable: $X
message: focus on X
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "safe(a)\nsink(b)\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(findings.len(), 1, "expected one finding on line 2");
assert_eq!(
findings[0].line, 2,
"focused metavar $X should be on line 2"
);
assert_eq!(findings[0].column, 6, "$X (`b`) should be at column 6");
}
#[test]
fn regex_lang_rule_loads_and_fires_on_matching_text() {
let yaml = r#"
rules:
- id: test/detect-token
pattern-regex: "MYTOKEN[0-9]{4}"
languages: [regex]
message: Token detected
severity: ERROR
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert!(
!rules.is_empty(),
"regex-mode rule should produce at least one rule instance"
);
for rule in &rules {
assert_eq!(rule.id(), "semgrep/test/detect-token");
assert_eq!(rule.severity(), Severity::Critical);
}
let source = "access_token = \"MYTOKEN1234\"\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(
findings.len(),
1,
"expected exactly one finding for matching text"
);
assert_eq!(findings[0].line, 1);
}
#[test]
fn regex_lang_rule_does_not_fire_on_non_matching_text() {
let yaml = r#"
rules:
- id: test/detect-token
pattern-regex: "MYTOKEN[0-9]{4}"
languages: [regex]
message: Token detected
severity: ERROR
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
let source = "access_token = \"NOT_A_TOKEN\"\n";
let tree = parse_file(source, Language::Python).unwrap();
let findings = rules[0].check(source, &tree);
assert!(
findings.is_empty(),
"regex-mode rule must not fire on non-matching text"
);
}
#[test]
fn regex_lang_rule_respects_paths_filter() {
let yaml = r#"
rules:
- id: test/jsp-scriptlet
pattern-regex: "<%[^@]"
languages: [regex]
message: JSP scriptlet detected
severity: WARNING
paths:
include:
- "*.jsp"
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert!(!rules.is_empty(), "should produce at least one rule");
assert!(
rules[0].applies_to_path(std::path::Path::new("view.jsp")),
"rule should apply to .jsp files"
);
assert!(
!rules[0].applies_to_path(std::path::Path::new("main.py")),
"rule must not apply to .py files when paths.include = [*.jsp]"
);
}
#[test]
fn regex_lang_rule_with_only_ast_pattern_warns_and_skips() {
let yaml = r#"
rules:
- id: test/ast-only-in-regex-mode
pattern: eval(...)
languages: [regex]
message: This should be skipped
severity: ERROR
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert!(
rules.is_empty(),
"regex-mode rule with only an AST pattern must produce zero rule instances"
);
}
#[test]
fn regex_lang_rule_patterns_block_with_negation() {
let yaml = r#"
rules:
- id: test/detect-artifactory-token
patterns:
- pattern-regex: "\\bAKC[a-zA-Z0-9]{10,}"
- pattern-not-regex: "sha(128|256|512)"
languages: [regex]
message: Artifactory token detected
severity: ERROR
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).unwrap();
assert!(!rules.is_empty(), "should produce rule instances");
let tree = parse_file("x = 1\n", Language::Python).unwrap();
let matching = "token = \"AKCp1234567890abcdef\"\n";
let findings = rules[0].check(matching, &tree);
assert_eq!(
findings.len(),
1,
"should fire on text matching pattern-regex"
);
let negated = "hash = \"sha256_AKCp1234567890abcdef\"\n";
let findings = rules[0].check(negated, &tree);
assert!(
findings.is_empty(),
"should not fire when pattern-not-regex also matches"
);
}
#[test]
fn test_severity_medium_loads() {
let yaml = r#"
rules:
- id: medium-sev-rule
pattern: foo()
message: medium severity rule
severity: MEDIUM
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).expect("MEDIUM severity rule must load");
assert_eq!(rules.len(), 1);
}
#[test]
fn test_metavariable_comparison_without_metavariable_key_loads_rule() {
let yaml = r#"
rules:
- id: cmp-no-metavar-key
patterns:
- pattern: foo($F1, $F2)
- metavariable-comparison:
comparison: $F1 > $F2
message: comparison without metavariable key
severity: WARNING
languages: [python]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).expect(
"rule with metavariable-comparison missing `metavariable:` key must still load",
);
assert_eq!(rules.len(), 1);
}
#[test]
fn test_metavariable_regex_with_lookahead_loads_rule() {
let yaml = r#"
rules:
- id: mv-regex-lookahead
patterns:
- pattern: |
var $X = "...";
- metavariable-regex:
metavariable: $X
regex: '(?!localhost).*'
message: hardcoded non-localhost value
severity: WARNING
languages: [javascript]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("rule with lookahead in metavariable-regex must still load");
assert_eq!(rules.len(), 1);
}
#[test]
fn test_pattern_regex_backslash_z_anchor_loads() {
let yaml = r#"
rules:
- id: pattern-regex-z-anchor
pattern-regex: 'assert\s*\(\s*\$\w+\s*\)\s*\Z'
message: assert usage
severity: WARNING
languages: [php]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("pattern-regex with \\Z anchor must load after normalisation");
assert_eq!(rules.len(), 1);
}
#[test]
fn test_dockerfile_language_rule_loads() {
let yaml = r#"
rules:
- id: dockerfile-no-latest
pattern-regex: ':latest'
message: Avoid using the latest tag in Dockerfile FROM instructions
severity: WARNING
languages: [dockerfile]
"#;
let f = make_yaml(yaml);
let rules =
parse_semgrep_file(f.path()).expect("dockerfile language rule must load without error");
assert_eq!(rules.len(), 1, "expected one rule for dockerfile language");
}
#[test]
fn test_docker_language_alias_loads() {
let yaml = r#"
rules:
- id: docker-root-user
pattern-regex: 'USER\s+root'
message: Container should not run as root
severity: ERROR
languages: [docker]
"#;
let f = make_yaml(yaml);
let rules =
parse_semgrep_file(f.path()).expect("docker language alias must load without error");
assert!(
!rules.is_empty(),
"docker alias should produce rule instances"
);
}
#[test]
fn test_dockerfile_pattern_regex_matches() {
use crate::engine::parser::parse_path;
use std::path::Path;
let yaml = r#"
rules:
- id: dockerfile-latest-tag
pattern-regex: ':latest'
message: Avoid :latest tag
severity: WARNING
languages: [dockerfile]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).expect("rule must load");
assert_eq!(rules.len(), 1);
let source = "FROM ubuntu:latest\nRUN apt-get update\nCMD [\"/bin/bash\"]\n";
let tree = parse_path(source, Language::Dockerfile, Path::new("Dockerfile"))
.expect("Dockerfile must parse");
assert!(
!tree.root_node().has_error(),
"Dockerfile parse must be error-free"
);
let findings = rules[0].check(source, &tree);
assert!(
!findings.is_empty(),
"pattern-regex ':latest' must match 'ubuntu:latest' in Dockerfile"
);
}
#[test]
fn test_bash_language_loads() {
let yaml = r#"
rules:
- id: bash-eval-call
pattern: eval $X
message: Avoid eval in bash
severity: WARNING
languages: [bash]
"#;
let f = make_yaml(yaml);
let rules =
parse_semgrep_file(f.path()).expect("bash language rule must load without error");
assert_eq!(rules.len(), 1, "expected one rule for bash language");
}
#[test]
fn test_ocaml_language_loads() {
let yaml = r#"
rules:
- id: ocaml-pattern
pattern-regex: 'Sys\.command'
message: Avoid Sys.command
severity: WARNING
languages: [ocaml]
"#;
let f = make_yaml(yaml);
let rules =
parse_semgrep_file(f.path()).expect("ocaml language rule must load without error");
assert!(!rules.is_empty(), "expected rules for ocaml language");
}
#[test]
fn test_apex_pattern_loads_and_matches() {
use crate::engine::parser::parse_path;
use std::path::Path;
let yaml = r#"
rules:
- id: apex-debug-call
pattern: System.debug(...)
message: Avoid System.debug
severity: WARNING
languages: [apex]
"#;
let rules = parse_semgrep_str(yaml, "apex.yml")
.expect("apex language rule must load without error");
assert_eq!(rules.len(), 1, "expected one rule for apex language");
let source = "public class A {\n void f() {\n System.debug('x');\n }\n}\n";
let tree = parse_path(source, Language::Apex, Path::new("A.cls")).expect("Apex must parse");
assert!(
!tree.root_node().has_error(),
"Apex parse must be error-free"
);
let findings = rules[0].check(source, &tree);
assert!(
!findings.is_empty(),
"pattern System.debug(...) must match in Apex"
);
}
#[test]
fn test_clojure_pattern_loads_and_matches() {
use crate::engine::parser::parse_path;
use std::path::Path;
let yaml = r#"
rules:
- id: clojure-eval-call
pattern: (eval $X)
message: Avoid eval
severity: WARNING
languages: [clojure]
"#;
let rules = parse_semgrep_str(yaml, "clojure.yml")
.expect("clojure language rule must load without error");
assert_eq!(rules.len(), 1, "expected one rule for clojure language");
let source = "(defn f [x]\n (eval x))\n";
let tree = parse_path(source, Language::Clojure, Path::new("core.clj"))
.expect("Clojure must parse");
assert!(
!tree.root_node().has_error(),
"Clojure parse must be error-free"
);
let findings = rules[0].check(source, &tree);
assert!(
!findings.is_empty(),
"pattern (eval ...) must match in Clojure"
);
}
#[test]
fn test_html_pattern_loads_and_matches() {
use crate::engine::parser::parse_path;
use std::path::Path;
let yaml = r#"
rules:
- id: html-inline-onclick
pattern-regex: 'onclick='
message: Avoid inline event handlers
severity: WARNING
languages: [html]
"#;
let rules = parse_semgrep_str(yaml, "html.yml").expect("html language rule must load");
assert_eq!(rules.len(), 1, "expected one rule for html language");
let source =
"<html>\n <body>\n <button onclick=\"go()\">x</button>\n </body>\n</html>\n";
let tree =
parse_path(source, Language::Html, Path::new("index.html")).expect("HTML must parse");
assert!(
!tree.root_node().has_error(),
"HTML parse must be error-free"
);
let findings = rules[0].check(source, &tree);
assert!(
!findings.is_empty(),
"pattern-regex onclick= must match in HTML"
);
}
#[test]
fn test_xml_pattern_loads_and_matches() {
use crate::engine::parser::parse_path;
use std::path::Path;
let yaml = r#"
rules:
- id: xml-doctype
pattern-regex: '<!DOCTYPE'
message: Avoid DOCTYPE declarations
severity: WARNING
languages: [xml]
"#;
let rules = parse_semgrep_str(yaml, "xml.yml").expect("xml language rule must load");
assert_eq!(rules.len(), 1, "expected one rule for xml language");
let source =
"<?xml version=\"1.0\"?>\n<!DOCTYPE root>\n<root>\n <child>text</child>\n</root>\n";
let tree =
parse_path(source, Language::Xml, Path::new("data.xml")).expect("XML must parse");
let findings = rules[0].check(source, &tree);
assert!(
!findings.is_empty(),
"pattern-regex <!DOCTYPE must match in XML"
);
}
#[test]
fn test_dart_pattern_loads_and_matches() {
use crate::engine::parser::parse_path;
use std::path::Path;
let yaml = r#"
rules:
- id: dart-print-call
pattern-regex: 'print\('
message: Avoid print
severity: WARNING
languages: [dart]
"#;
let rules = parse_semgrep_str(yaml, "dart.yml").expect("dart language rule must load");
assert_eq!(rules.len(), 1, "expected one rule for dart language");
let source = "void main() {\n print('hello');\n}\n";
let tree =
parse_path(source, Language::Dart, Path::new("main.dart")).expect("Dart must parse");
assert!(
!tree.root_node().has_error(),
"Dart parse must be error-free"
);
let findings = rules[0].check(source, &tree);
assert!(
!findings.is_empty(),
"pattern-regex print\\( must match in Dart"
);
}
#[test]
fn test_haskell_pattern_loads_and_matches() {
use crate::engine::parser::parse_path;
use std::path::Path;
let yaml = r#"
rules:
- id: haskell-foreign-import
pattern-regex: '\bforeign\s+import\b'
message: Review Haskell FFI boundary
severity: WARNING
languages: [haskell]
"#;
let rules =
parse_semgrep_str(yaml, "haskell.yml").expect("haskell language rule must load");
assert_eq!(rules.len(), 1, "expected one rule for haskell language");
let source = "module Bindings where\nforeign import ccall \"foo\" c_foo :: IO ()\n";
let tree = parse_path(source, Language::Haskell, Path::new("Bindings.hs"))
.expect("Haskell must parse");
assert!(
!tree.root_node().has_error(),
"Haskell parse must be error-free"
);
let findings = rules[0].check(source, &tree);
assert!(
!findings.is_empty(),
"pattern-regex foreign import must match in Haskell"
);
}
#[test]
fn test_scala_language_loads() {
let yaml = r#"
rules:
- id: scala-pattern
pattern-regex: 'Runtime\.getRuntime\(\)'
message: Avoid Runtime.getRuntime
severity: WARNING
languages: [scala]
"#;
let f = make_yaml(yaml);
let rules =
parse_semgrep_file(f.path()).expect("scala language rule must load without error");
assert!(!rules.is_empty(), "expected rules for scala language");
}
#[test]
fn test_elixir_language_loads() {
let yaml = r#"
rules:
- id: elixir-pattern
pattern: System.cmd($CMD, ...)
message: Avoid System.cmd with untrusted input
severity: WARNING
languages: [elixir]
"#;
let f = make_yaml(yaml);
let rules =
parse_semgrep_file(f.path()).expect("elixir language rule must load without error");
assert_eq!(rules.len(), 1, "expected one rule for elixir language");
}
#[test]
fn test_json_language_loads() {
let yaml = r#"
rules:
- id: json-pattern
pattern-regex: '"password"\s*:\s*"[^"]+"'
message: Hardcoded password in JSON
severity: ERROR
languages: [json]
"#;
let f = make_yaml(yaml);
let rules =
parse_semgrep_file(f.path()).expect("json language rule must load without error");
assert!(!rules.is_empty(), "expected rules for json language");
}
#[test]
fn test_bare_double_brace_in_pattern_regex_loads() {
let yaml = r#"
rules:
- id: test/flask-template-safe-filter
pattern-regex: '{{.*?\|\s*safe(\s*}})?'
message: Jinja2 template uses |safe filter
severity: WARNING
languages: [regex]
paths:
include:
- "*.html"
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("pattern-regex with bare {{ must load after brace normalisation");
assert!(
!rules.is_empty(),
"bare {{ pattern-regex rule must produce at least one rule instance"
);
}
#[test]
fn test_bare_brace_percent_in_pattern_regex_loads() {
let yaml = r#"
rules:
- id: test/flask-autoescape-off
pattern-regex: '{%\s*autoescape\s+false\s*%}'
message: Flask autoescape disabled
severity: WARNING
languages: [regex]
paths:
include:
- "*.html"
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("pattern-regex with bare {% must load after brace normalisation");
assert!(
!rules.is_empty(),
"bare {{%}} pattern-regex rule must produce at least one rule instance"
);
}
#[test]
fn test_bare_brace_before_bracket_in_pattern_regex_loads() {
let yaml = r#"
rules:
- id: test/slow-pattern
pattern-regex: 'function[^{]*{[\s\n]*\.\.\.[\s\n]*}'
message: Slow pattern
severity: WARNING
languages: [yaml]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("pattern-regex with bare { before [ must load after brace normalisation");
assert!(
!rules.is_empty(),
"rule must produce at least one instance after brace normalisation"
);
}
#[test]
fn test_bare_brace_in_pattern_either_entry_loads() {
let yaml = r#"
rules:
- id: test/pug-unescape
pattern-either:
- pattern-regex: '\w.*(!=)[^=].*'
- pattern-regex: '!{.*?}'
message: Pug explicit unescape
severity: WARNING
languages: [regex]
paths:
include:
- "*.pug"
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("pattern-either with bare { entry must load after brace normalisation");
assert!(
!rules.is_empty(),
"rule with pattern-either including bare-brace regex must load"
);
}
#[test]
fn test_lookahead_in_pattern_either_entry_is_gracefully_skipped() {
let yaml = r#"
rules:
- id: test/aws-credential-detection
patterns:
- pattern-inside: |
resource "$ANY" $ANYTHING {
...
}
- pattern-either:
- pattern-inside: 'AWS_ACCESS_KEY_ID = "$Y"'
- pattern-regex: '(?<![A-Z0-9])[A-Z0-9]{20}(?![A-Z0-9])'
- pattern-inside: 'AWS_SECRET_ACCESS_KEY = "$Y"'
- focus-metavariable: $Y
message: Hardcoded AWS credential
severity: ERROR
languages: [hcl]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("rule with lookahead in pattern-either must load with the bad entry skipped");
assert!(
!rules.is_empty(),
"rule must produce at least one rule instance"
);
}
#[test]
fn test_backreference_in_pattern_not_regex_is_gracefully_skipped() {
let yaml = r#"
rules:
- id: test/artifactory-password
patterns:
- pattern-regex: '\bAP[0-9A-F][a-zA-Z0-9]{8,}'
- pattern-regex: '(?i)artifactory'
- pattern-not-regex: '(\w|\.|\*)\1{4}'
languages: [regex]
message: Artifactory token detected
severity: ERROR
paths:
exclude:
- "*.svg"
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).expect(
"rule with backreference in pattern-not-regex must load with that entry skipped",
);
assert!(
!rules.is_empty(),
"rule must produce at least one rule instance"
);
}
#[test]
fn test_pattern_not_inside_nested_block_loads() {
let yaml = r#"
rules:
- id: test/last-user-is-root
patterns:
- pattern: USER root
- pattern-not-inside:
patterns:
- pattern: |
USER root
...
USER $X
- metavariable-pattern:
metavariable: $X
patterns:
- pattern-not: root
message: Last container user is root
severity: ERROR
languages: [dockerfile]
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("rule with nested patterns: block inside pattern-not-inside: must load");
assert!(
!rules.is_empty(),
"rule must produce at least one rule instance"
);
}
#[test]
fn test_double_brace_ampersand_pattern_in_pattern_either_loads() {
let yaml = r#"
rules:
- id: test/mustache-unescape
pattern-either:
- pattern-regex: '{{{((?!include).)*?}}}'
- pattern-regex: '{{[\s]*&.*}}'
message: Mustache explicit unescape
severity: WARNING
languages: [regex]
paths:
include:
- "*.mustache"
- "*.hbs"
- "*.html"
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path()).expect(
"mustache pattern-either with brace+lookahead entry must load (second entry survives)",
);
assert!(
!rules.is_empty(),
"rule must produce at least one rule instance from the second pattern-either entry"
);
}
#[test]
fn test_escape_bare_braces_quantifiers_unchanged() {
assert_eq!(escape_bare_braces(r"[A-Z]{20}"), r"[A-Z]{20}");
assert_eq!(escape_bare_braces(r"foo{1,3}bar"), r"foo{1,3}bar");
assert_eq!(escape_bare_braces(r"\w{8,}"), r"\w{8,}");
}
#[test]
fn test_escape_bare_braces_template_syntax_escaped() {
let result = escape_bare_braces(r"{{.*?\|\s*safe(\s*}})?");
Regex::new(&result).expect("normalised regex must compile");
let result2 = escape_bare_braces(r"{%\s*autoescape\s+false\s*%}");
Regex::new(&result2).expect("normalised regex must compile");
let result3 = escape_bare_braces(r"!{.*?}");
Regex::new(&result3).expect("normalised regex must compile");
}
#[test]
fn test_escape_bare_braces_already_escaped_not_doubled() {
let input = r"\{foo\}";
let result = escape_bare_braces(input);
assert_eq!(
result, input,
"already-escaped braces must not be double-escaped"
);
}
#[test]
fn test_escape_bare_braces_inside_char_class_unchanged() {
let input = r"[{}\s]*";
let result = escape_bare_braces(input);
Regex::new(&result).expect("normalised regex must compile");
}
#[test]
fn test_compile_regex_fast_path_for_plain_pattern() {
let compiled = compile_regex(r"password\s*=").expect("plain pattern must compile");
assert!(
matches!(compiled, CompiledRegex::Fast(_)),
"a pattern with no lookaround/backref must compile on the fast `regex` crate"
);
assert!(
compiled.is_match("password = 'hunter2'"),
"fast-path regex must match the obvious case"
);
assert!(
!compiled.is_match("token = 'hunter2'"),
"fast-path regex must not match unrelated text"
);
assert_eq!(
compiled.find_matches("x; password=1"),
vec![(3, 12)],
"fast-path find_matches must return the matched byte range"
);
}
#[test]
fn test_compile_regex_fancy_path_for_lookahead() {
let pattern = r"^(?!test_)password\s*=";
assert!(
Regex::new(pattern).is_err(),
"sanity: the fast `regex` crate must reject this lookahead pattern"
);
let compiled = compile_regex(pattern).expect("lookahead pattern must compile via fallback");
assert!(
matches!(compiled, CompiledRegex::Fancy(_)),
"a lookahead pattern must compile on the fancy-regex fallback engine"
);
assert!(
compiled.is_match("password = 'secret'"),
"fancy-path regex must match a non-test password assignment"
);
assert!(
!compiled.is_match("test_password = 'secret'"),
"fancy-path regex must reject a `test_`-prefixed password assignment"
);
}
#[test]
fn regex_lang_rule_with_lookahead_loads_and_matches() {
let yaml = r#"
rules:
- id: test/lookahead-password
pattern-regex: '^(?!test_)password\s*='
languages: [regex]
message: Hardcoded password assignment
severity: ERROR
"#;
let f = make_yaml(yaml);
let rules = parse_semgrep_file(f.path())
.expect("lookahead pattern-regex rule must load via the fancy-regex fallback");
assert!(
!rules.is_empty(),
"lookahead regex-mode rule must produce at least one rule instance"
);
let hit_src = "password = 'hunter2'\n";
let tree = parse_file(hit_src, Language::Python).unwrap();
let findings = rules[0].check(hit_src, &tree);
assert!(
!findings.is_empty(),
"rule must fire on a non-test password assignment"
);
let miss_src = "test_password = 'hunter2'\n";
let tree = parse_file(miss_src, Language::Python).unwrap();
let findings = rules[0].check(miss_src, &tree);
assert!(
findings.is_empty(),
"rule must NOT fire when the negative lookahead excludes the match"
);
}
#[test]
fn generic_lang_rule_with_lookahead_loads_and_fires() {
let yaml = r#"
rules:
- id: test/generic-maps-apikey
patterns:
- pattern-regex: 'AIza[0-9A-Za-z_\-]{4}(?!\S)'
languages: [generic]
message: Detected a Google Maps API key
severity: WARNING
"#;
let rules = parse_semgrep_str(yaml, "generic-lookahead.yml")
.expect("generic-mode rule with lookahead must load via fancy-regex");
assert!(
!rules.is_empty(),
"generic-mode lookahead rule must produce at least one rule instance"
);
let firing = "key = AIza1234\nnext line\n";
let tree = parse_file(firing, Language::JavaScript).unwrap();
let findings = rules[0].check(firing, &tree);
assert!(
!findings.is_empty(),
"generic lookahead rule must fire on a key followed by whitespace"
);
let safe = "key = AIza1234EXTRA\n";
let tree = parse_file(safe, Language::JavaScript).unwrap();
let findings = rules[0].check(safe, &tree);
assert!(
findings.is_empty(),
"generic lookahead rule must NOT fire when the lookahead is violated"
);
}
const METAVAR_TYPE_JAVA_RULE: &str = r#"
rules:
- id: sql-execute-on-statement
patterns:
- pattern: $X.executeQuery($Q)
- metavariable-type:
metavariable: $X
type: Statement
message: executeQuery on a Statement
severity: ERROR
languages: [java]
"#;
#[test]
fn test_metavariable_type_fires_on_matching_type() {
let f = make_yaml(METAVAR_TYPE_JAVA_RULE);
let rules = parse_semgrep_file(f.path()).expect("rule must load for java");
assert_eq!(rules.len(), 1, "one java rule expected");
let source = "class A { void m(String q) { Statement s; s.executeQuery(q); } }";
let tree = parse_file(source, Language::Java).unwrap();
let findings = rules[0].check(source, &tree);
assert_eq!(
findings.len(),
1,
"must fire when $X is declared as Statement"
);
}
#[test]
fn test_metavariable_type_silent_on_wrong_type() {
let f = make_yaml(METAVAR_TYPE_JAVA_RULE);
let rules = parse_semgrep_file(f.path()).expect("rule must load for java");
let source = "class A { void m(String q) { PreparedStatement p; p.executeQuery(q); } }";
let tree = parse_file(source, Language::Java).unwrap();
let findings = rules[0].check(source, &tree);
assert!(
findings.is_empty(),
"must not fire when $X is a PreparedStatement, not a Statement"
);
}
#[test]
fn test_metavariable_type_resolves_parameter() {
let f = make_yaml(METAVAR_TYPE_JAVA_RULE);
let rules = parse_semgrep_file(f.path()).expect("rule must load for java");
let fires = "class A { void m(Statement s, String q) { s.executeQuery(q); } }";
let tree = parse_file(fires, Language::Java).unwrap();
assert_eq!(
rules[0].check(fires, &tree).len(),
1,
"must fire when the Statement is a method parameter"
);
let silent = "class A { void m(PreparedStatement s, String q) { s.executeQuery(q); } }";
let tree = parse_file(silent, Language::Java).unwrap();
assert!(
rules[0].check(silent, &tree).is_empty(),
"must not fire when the parameter is a PreparedStatement"
);
}
#[test]
fn test_metavariable_type_matches_qualified_name() {
let f = make_yaml(METAVAR_TYPE_JAVA_RULE);
let rules = parse_semgrep_file(f.path()).expect("rule must load for java");
let source = "class A { void m(String q) { java.sql.Statement s; s.executeQuery(q); } }";
let tree = parse_file(source, Language::Java).unwrap();
assert_eq!(
rules[0].check(source, &tree).len(),
1,
"qualified type java.sql.Statement must satisfy type: Statement"
);
}
#[test]
fn test_metavariable_type_unenforceable_language_skips_rule() {
let yaml = r#"
rules:
- id: py-concat
patterns:
- pattern: $X + $Y
- metavariable-type:
metavariable: $X
type: str
message: string concat
severity: ERROR
languages: [python]
"#;
let f = make_yaml(yaml);
let result = parse_semgrep_file(f.path());
assert!(
result.is_err(),
"metavariable-type on python must cause the rule to be skipped, not loaded"
);
}
}