use crate::rules::common::get_source_line;
use crate::rules::semgrep_compat::PathFilter;
use crate::rules::Rule;
use crate::{Finding, Language, Severity};
use fancy_regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
const 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::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,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Token<'a> {
text: &'a str,
start: usize,
end: usize,
}
fn tokenize(source: &str) -> Vec<Token<'_>> {
let mut tokens = Vec::new();
let bytes = source.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_whitespace() {
i += 1;
continue;
}
if is_word_byte(b) {
let start = i;
while i < bytes.len() && is_word_byte(bytes[i]) {
i += 1;
}
tokens.push(Token {
text: &source[start..i],
start,
end: i,
});
} else {
let char_len = utf8_char_len(b);
let end = (i + char_len).min(source.len());
tokens.push(Token {
text: &source[i..end],
start: i,
end,
});
i = end;
}
}
tokens
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn utf8_char_len(first: u8) -> usize {
if first < 0x80 {
1
} else if first >> 5 == 0b110 {
2
} else if first >> 4 == 0b1110 {
3
} else if first >> 3 == 0b11110 {
4
} else {
1
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PatternElem {
Ellipsis,
Metavar(String),
Literal(String),
}
fn compile_pattern(pattern: &str) -> Vec<PatternElem> {
tokenize(pattern)
.into_iter()
.map(|tok| classify(tok.text))
.collect::<Vec<_>>()
.pipe_coalesce_ellipsis()
}
fn classify(text: &str) -> RawElem {
if text == "$" {
RawElem::Dollar
} else {
RawElem::Elem(PatternElem::Literal(text.to_string()))
}
}
#[derive(Debug, Clone)]
enum RawElem {
Dollar,
Elem(PatternElem),
}
trait CoalesceExt {
fn pipe_coalesce_ellipsis(self) -> Vec<PatternElem>;
}
impl CoalesceExt for Vec<RawElem> {
fn pipe_coalesce_ellipsis(self) -> Vec<PatternElem> {
let folded_metavars = fold_dollars(self);
fold_ellipsis(folded_metavars)
}
}
fn fold_dollars(raw: Vec<RawElem>) -> Vec<PatternElem> {
let mut out = Vec::new();
let mut iter = raw.into_iter().peekable();
while let Some(elem) = iter.next() {
match elem {
RawElem::Dollar => {
if let Some(RawElem::Elem(PatternElem::Literal(name))) = iter.peek() {
if is_metavar_name(name) {
let name = name.clone();
iter.next();
out.push(PatternElem::Metavar(format!("${name}")));
continue;
}
}
out.push(PatternElem::Literal("$".to_string()));
}
RawElem::Elem(e) => out.push(e),
}
}
out
}
fn fold_ellipsis(elems: Vec<PatternElem>) -> Vec<PatternElem> {
let mut out: Vec<PatternElem> = Vec::new();
let mut dots = 0usize;
for elem in elems {
if matches!(&elem, PatternElem::Literal(l) if l == ".") {
dots += 1;
if dots == 3 {
out.push(PatternElem::Ellipsis);
dots = 0;
}
continue;
}
for _ in 0..dots {
out.push(PatternElem::Literal(".".to_string()));
}
dots = 0;
out.push(elem);
}
for _ in 0..dots {
out.push(PatternElem::Literal(".".to_string()));
}
out
}
fn is_metavar_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CmpOp {
Lt,
Le,
Gt,
Ge,
Eq,
Ne,
}
#[derive(Debug, Clone)]
enum MvConstraint {
Regex { group: String, re: Regex },
Compare {
group: String,
op: CmpOp,
literal: f64,
literal_is_lhs: bool,
},
}
impl MvConstraint {
fn passes(&self, captures: &HashMap<String, String>) -> bool {
match self {
MvConstraint::Regex { group, re } => captures
.get(group)
.is_some_and(|text| re.is_match(text).unwrap_or(false)),
MvConstraint::Compare {
group,
op,
literal,
literal_is_lhs,
} => {
let Some(text) = captures.get(group) else {
return false;
};
let Some(value) = parse_numeric(text.trim()) else {
return false;
};
let (lhs, rhs) = if *literal_is_lhs {
(*literal, value)
} else {
(value, *literal)
};
match op {
CmpOp::Lt => lhs < rhs,
CmpOp::Le => lhs <= rhs,
CmpOp::Gt => lhs > rhs,
CmpOp::Ge => lhs >= rhs,
CmpOp::Eq => (lhs - rhs).abs() < f64::EPSILON,
CmpOp::Ne => (lhs - rhs).abs() >= f64::EPSILON,
}
}
}
}
fn group(&self) -> &str {
match self {
MvConstraint::Regex { group, .. } | MvConstraint::Compare { group, .. } => group,
}
}
}
fn build_mv_regex(metavariable: &str, regex: &str) -> Option<MvConstraint> {
let group = metavariable.trim_start_matches('$').to_string();
match compile_regex(regex) {
Ok(re) => Some(MvConstraint::Regex { group, re }),
Err(e) => {
eprintln!(
"Warning: generic metavariable-regex for {metavariable} did not compile ({e}); \
skipping constraint"
);
None
}
}
}
fn build_mv_comparison(metavariable: Option<&str>, comparison: &str) -> Option<MvConstraint> {
let (group, op, literal, literal_is_lhs) = parse_generic_comparison(comparison)?;
let group = metavariable
.map(|m| m.trim_start_matches('$').to_string())
.unwrap_or(group);
Some(MvConstraint::Compare {
group,
op,
literal,
literal_is_lhs,
})
}
fn parse_generic_comparison(comparison: &str) -> Option<(String, CmpOp, f64, bool)> {
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_side, literal_str, literal_is_lhs) = if let Some(g) = capture_name(lhs) {
(g, rhs, false)
} else if let Some(g) = capture_name(rhs) {
(g, lhs, true)
} else {
return None;
};
let literal = parse_numeric(literal_str.trim())?;
return Some((metavar_side, *op, literal, literal_is_lhs));
}
}
None
}
fn capture_name(operand: &str) -> Option<String> {
let mut t = operand.trim();
for wrapper in ["int(", "str(", "float("] {
if let Some(inner) = t.strip_prefix(wrapper) {
if let Some(inner) = inner.strip_suffix(')') {
t = inner.trim();
break;
}
}
}
let name = t.strip_prefix('$')?;
if !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
Some(name.to_string())
} else {
None
}
}
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()
}
#[derive(Debug, Clone)]
enum GenericMatcher {
Pattern(Vec<PatternElem>),
Regex(Regex),
Either(Vec<GenericMatcher>),
RegexConstraints {
regexes: Vec<Regex>,
constraints: Vec<MvConstraint>,
focus: Option<String>,
},
Filtered {
positive: Box<GenericMatcher>,
negatives: Vec<GenericMatcher>,
},
Combined {
positives: Vec<GenericMatcher>,
negatives: Vec<GenericMatcher>,
},
}
#[derive(Debug, Clone)]
struct GenericMatch {
start_byte: usize,
end_byte: usize,
}
impl GenericMatcher {
fn find_all(&self, source: &str, tokens: &[Token<'_>]) -> Vec<GenericMatch> {
match self {
GenericMatcher::Pattern(elems) => find_pattern(elems, tokens),
GenericMatcher::Regex(re) => re
.find_iter(source)
.filter_map(|m| m.ok())
.map(|m| GenericMatch {
start_byte: m.start(),
end_byte: m.end(),
})
.collect(),
GenericMatcher::Either(inner) => {
let mut all = Vec::new();
for m in inner {
all.extend(m.find_all(source, tokens));
}
dedup(all)
}
GenericMatcher::RegexConstraints {
regexes,
constraints,
focus,
} => find_regex_constraints(source, regexes, constraints, focus.as_deref()),
GenericMatcher::Filtered {
positive,
negatives,
} => {
let mut matches = positive.find_all(source, tokens);
if !negatives.is_empty() {
apply_negatives(&mut matches, negatives, source, tokens);
}
matches
}
GenericMatcher::Combined {
positives,
negatives,
} => {
let mut candidates: Option<Vec<GenericMatch>> = None;
for pos in positives {
let hits = pos.find_all(source, tokens);
candidates = Some(match candidates {
None => hits,
Some(prev) => {
prev.into_iter()
.filter(|p| hits.iter().any(|h| overlaps(p, h)))
.collect()
}
});
}
let mut results = candidates.unwrap_or_default();
if !negatives.is_empty() {
apply_negatives(&mut results, negatives, source, tokens);
}
results
}
}
}
}
fn apply_negatives(
positives: &mut Vec<GenericMatch>,
negatives: &[GenericMatcher],
source: &str,
tokens: &[Token<'_>],
) {
for neg in negatives {
if positives.is_empty() {
break;
}
match neg {
GenericMatcher::Regex(re) => {
if re.is_match(source).unwrap_or(false) {
positives.clear();
}
}
_ => {
let neg_matches = neg.find_all(source, tokens);
positives.retain(|m| !neg_matches.iter().any(|n| overlaps(m, n)));
}
}
}
}
fn overlaps(a: &GenericMatch, b: &GenericMatch) -> bool {
a.start_byte < b.end_byte && b.start_byte < a.end_byte
}
fn dedup(mut matches: Vec<GenericMatch>) -> Vec<GenericMatch> {
matches.sort_by_key(|m| (m.start_byte, m.end_byte));
matches.dedup_by_key(|m| (m.start_byte, m.end_byte));
matches
}
fn find_pattern(elems: &[PatternElem], tokens: &[Token<'_>]) -> Vec<GenericMatch> {
if elems.is_empty() {
return Vec::new();
}
let mut matches = Vec::new();
for start in 0..tokens.len() {
let mut bindings: HashMap<String, String> = HashMap::new();
if let Some(end_idx) = match_from(elems, tokens, start, &mut bindings) {
if end_idx > start {
let span_start = tokens[start].start;
let span_end = tokens[end_idx - 1].end;
matches.push(GenericMatch {
start_byte: span_start,
end_byte: span_end,
});
}
}
}
dedup(matches)
}
fn find_regex_constraints(
source: &str,
regexes: &[Regex],
constraints: &[MvConstraint],
focus: Option<&str>,
) -> Vec<GenericMatch> {
let Some((first, rest)) = regexes.split_first() else {
return Vec::new();
};
let mut out = Vec::new();
for cand in first.captures_iter(source).filter_map(|c| c.ok()) {
let Some(whole) = cand.get(0) else { continue };
let cand_start = whole.start();
let cand_end = whole.end();
let mut captures: HashMap<String, String> = HashMap::new();
let mut focus_span: Option<(usize, usize)> = None;
collect_named(first, &cand, focus, &mut captures, &mut focus_span);
let mut all_present = true;
for re in rest {
let mut matched = false;
for c in re.captures_iter(source).filter_map(|c| c.ok()) {
if let Some(m0) = c.get(0) {
if m0.start() < cand_end && cand_start < m0.end() {
collect_named(re, &c, focus, &mut captures, &mut focus_span);
matched = true;
break;
}
}
}
if !matched {
all_present = false;
break;
}
}
if !all_present {
continue;
}
if !constraints
.iter()
.all(|c| captures.contains_key(c.group()) && c.passes(&captures))
{
continue;
}
let (start_byte, end_byte) = focus_span.unwrap_or((cand_start, cand_end));
out.push(GenericMatch {
start_byte,
end_byte,
});
}
dedup(out)
}
fn collect_named(
re: &Regex,
caps: &fancy_regex::Captures<'_>,
focus: Option<&str>,
captures: &mut HashMap<String, String>,
focus_span: &mut Option<(usize, usize)>,
) {
for name in re.capture_names().flatten() {
if let Some(m) = caps.name(name) {
captures
.entry(name.to_string())
.or_insert_with(|| m.as_str().to_string());
if Some(name) == focus && focus_span.is_none() {
*focus_span = Some((m.start(), m.end()));
}
}
}
}
fn match_from(
elems: &[PatternElem],
tokens: &[Token<'_>],
mut ti: usize,
bindings: &mut HashMap<String, String>,
) -> Option<usize> {
let mut pi = 0;
while pi < elems.len() {
match &elems[pi] {
PatternElem::Ellipsis => {
if pi + 1 == elems.len() {
return Some(ti);
}
let rest = &elems[pi + 1..];
for skip in ti..=tokens.len() {
let mut trial = bindings.clone();
if let Some(end) = match_from(rest, tokens, skip, &mut trial) {
*bindings = trial;
return Some(end);
}
}
return None;
}
PatternElem::Metavar(name) => {
let tok = tokens.get(ti)?;
if let Some(existing) = bindings.get(name) {
if existing != tok.text {
return None;
}
} else {
bindings.insert(name.clone(), tok.text.to_string());
}
ti += 1;
pi += 1;
}
PatternElem::Literal(lit) => {
let tok = tokens.get(ti)?;
if tok.text != lit {
return None;
}
ti += 1;
pi += 1;
}
}
}
Some(ti)
}
pub struct GenericRule {
id: String,
message: String,
severity: Severity,
lang: Language,
cwe: Option<String>,
matcher: Arc<GenericMatcher>,
path_filter: Option<Arc<PathFilter>>,
}
impl Rule for GenericRule {
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 tokens = tokenize(source);
let mut matches = self.matcher.find_all(source, &tokens);
matches.sort_by_key(|m| (m.start_byte, m.end_byte));
matches.dedup_by_key(|m| (m.start_byte, m.end_byte));
matches
.into_iter()
.map(|m| {
let (line, column) = byte_offset_to_position(source, m.start_byte);
let (end_line, end_column) = byte_offset_to_position(source, m.end_byte);
Finding {
rule_id: self.id.clone(),
severity: self.severity,
cwe: self.cwe.clone(),
description: self.message.clone(),
file: String::new(),
line,
column,
end_line,
end_column,
snippet: get_source_line(source, m.start_byte),
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![],
}
})
.collect()
}
}
fn byte_offset_to_position(source: &str, byte_offset: usize) -> (usize, usize) {
let byte_offset = byte_offset.min(source.len());
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)
}
#[derive(Debug, Clone, Default)]
pub struct GenericPatternsClause {
pub pattern: Option<String>,
pub pattern_regex: Option<String>,
pub pattern_either: Vec<GenericEitherEntry>,
pub pattern_not: Option<String>,
pub pattern_not_regex: Option<String>,
pub metavariable_regex: Option<(String, String)>,
pub metavariable_comparison: Option<(Option<String>, String)>,
pub focus_metavariable: Option<String>,
pub unsupported_constraint: bool,
}
#[derive(Debug, Clone, Default)]
pub struct GenericEitherEntry {
pub pattern: Option<String>,
pub pattern_regex: Option<String>,
pub patterns: Vec<GenericPatternsClause>,
}
fn build_either_matcher(entries: &[GenericEitherEntry]) -> Result<GenericMatcher, String> {
let mut inner = Vec::new();
for entry in entries {
if !entry.patterns.is_empty() {
match build_patterns_block(&entry.patterns) {
Ok(m) => inner.push(m),
Err(e) => eprintln!(
"Warning: generic pattern-either arm (patterns: block) did not build ({e}); \
skipping arm"
),
}
continue;
}
if let Some(ref p) = entry.pattern {
inner.push(GenericMatcher::Pattern(compile_pattern(p)));
} else if let Some(ref re) = entry.pattern_regex {
inner.push(GenericMatcher::Regex(compile_regex(re)?));
}
}
if inner.is_empty() {
return Err(
"pattern-either: block has no supported pattern or pattern-regex entries".to_string(),
);
}
if inner.len() == 1 {
Ok(inner.into_iter().next().expect("checked len==1"))
} else {
Ok(GenericMatcher::Either(inner))
}
}
fn build_patterns_block(clauses: &[GenericPatternsClause]) -> Result<GenericMatcher, String> {
if clauses.iter().any(|c| c.unsupported_constraint) {
return Err(
"patterns: block uses a constraint generic mode cannot enforce \
(metavariable-pattern / metavariable-analysis)"
.to_string(),
);
}
let mut constraints: Vec<MvConstraint> = Vec::new();
let mut focus: Option<String> = None;
for clause in clauses {
if let Some((ref mv, ref re)) = clause.metavariable_regex {
if let Some(c) = build_mv_regex(mv, re) {
constraints.push(c);
}
}
if let Some((ref mv, ref cmp)) = clause.metavariable_comparison {
if let Some(c) = build_mv_comparison(mv.as_deref(), cmp) {
constraints.push(c);
}
}
if focus.is_none() {
if let Some(ref f) = clause.focus_metavariable {
focus = Some(f.trim_start_matches('$').to_string());
}
}
}
let mut regexes: Vec<Regex> = Vec::new();
let mut other_positives: Vec<GenericMatcher> = Vec::new();
let mut negatives: Vec<GenericMatcher> = Vec::new();
for clause in clauses {
if let Some(ref p) = clause.pattern {
other_positives.push(GenericMatcher::Pattern(compile_pattern(p)));
}
if let Some(ref re) = clause.pattern_regex {
match compile_regex(re) {
Ok(r) => regexes.push(r),
Err(e) => eprintln!(
"Warning: generic patterns clause has invalid pattern-regex: {e}; skipping clause"
),
}
}
if !clause.pattern_either.is_empty() {
match build_either_matcher(&clause.pattern_either) {
Ok(m) => other_positives.push(m),
Err(e) => eprintln!(
"Warning: generic patterns clause has invalid pattern-either: {e}; skipping clause"
),
}
}
if let Some(ref pn) = clause.pattern_not {
negatives.push(GenericMatcher::Pattern(compile_pattern(pn)));
}
if let Some(ref re) = clause.pattern_not_regex {
match compile_regex(re) {
Ok(r) => negatives.push(GenericMatcher::Regex(r)),
Err(e) => eprintln!(
"Warning: generic patterns clause has invalid pattern-not-regex: {e}; skipping clause"
),
}
}
}
let named: std::collections::HashSet<String> = regexes
.iter()
.flat_map(|r| r.capture_names().flatten().map(|s| s.to_string()))
.collect();
let focus_named = focus.as_ref().is_some_and(|f| named.contains(f));
let constrained = !constraints.is_empty() || focus_named;
let mut positives: Vec<GenericMatcher> = other_positives;
if constrained && !regexes.is_empty() {
if let Some(missing) = constraints.iter().find(|c| !named.contains(c.group())) {
return Err(format!(
"metavariable constraint references capture '${}' not present in any pattern-regex",
missing.group()
));
}
positives.push(GenericMatcher::RegexConstraints {
regexes,
constraints,
focus: if focus_named { focus } else { None },
});
} else {
positives.extend(regexes.into_iter().map(GenericMatcher::Regex));
}
if positives.is_empty() {
return Err("generic patterns: block has no supported positive matchers".to_string());
}
if positives.len() == 1 && negatives.is_empty() {
return Ok(positives.into_iter().next().expect("len==1"));
}
if positives.len() == 1 {
return Ok(GenericMatcher::Filtered {
positive: Box::new(positives.into_iter().next().expect("len==1")),
negatives,
});
}
Ok(GenericMatcher::Combined {
positives,
negatives,
})
}
fn build_matcher(spec: &GenericRuleSpec<'_>) -> Result<GenericMatcher, String> {
if !spec.patterns_clauses.is_empty() {
return build_patterns_block(&spec.patterns_clauses);
}
let wrap_with_negatives = |positive: GenericMatcher,
pattern_not: Option<&str>,
pattern_not_regex: Option<&str>|
-> Result<GenericMatcher, String> {
let mut negatives: Vec<GenericMatcher> = Vec::new();
if let Some(pn) = pattern_not {
negatives.push(GenericMatcher::Pattern(compile_pattern(pn)));
}
if let Some(re) = pattern_not_regex {
negatives.push(GenericMatcher::Regex(compile_regex(re)?));
}
if negatives.is_empty() {
Ok(positive)
} else {
Ok(GenericMatcher::Filtered {
positive: Box::new(positive),
negatives,
})
}
};
if !spec.pattern_either.is_empty() {
let positive = build_either_matcher(&spec.pattern_either)?;
return wrap_with_negatives(positive, spec.pattern_not, spec.pattern_not_regex);
}
if let Some(p) = spec.pattern {
let positive = GenericMatcher::Pattern(compile_pattern(p));
return wrap_with_negatives(positive, spec.pattern_not, spec.pattern_not_regex);
}
if let Some(re) = spec.pattern_regex {
let positive = GenericMatcher::Regex(compile_regex(re)?);
return wrap_with_negatives(positive, spec.pattern_not, spec.pattern_not_regex);
}
Err("generic rule has no expressible matcher (no pattern / pattern-regex / pattern-either / patterns)".to_string())
}
fn compile_regex(pattern: &str) -> Result<Regex, String> {
let normalised = pattern.replace(r"\Z", "$");
Regex::new(&normalised).map_err(|e| format!("Invalid pattern-regex '{pattern}': {e}"))
}
pub struct GenericRuleSpec<'a> {
pub id: &'a str,
pub message: &'a str,
pub severity: Severity,
pub cwe: Option<String>,
pub pattern: Option<&'a str>,
pub pattern_regex: Option<&'a str>,
pub pattern_either: Vec<GenericEitherEntry>,
pub pattern_not: Option<&'a str>,
pub pattern_not_regex: Option<&'a str>,
pub patterns_clauses: Vec<GenericPatternsClause>,
pub path_filter: Option<PathFilter>,
}
pub fn build_generic_rules(spec: GenericRuleSpec<'_>) -> Result<Vec<Box<dyn Rule>>, String> {
let matcher = Arc::new(build_matcher(&spec)?);
let path_filter = spec.path_filter.map(Arc::new);
let rules = ALL_LANGUAGES
.iter()
.map(|&lang| {
Box::new(GenericRule {
id: format!("semgrep/{}", spec.id),
message: spec.message.to_string(),
severity: spec.severity,
lang,
cwe: spec.cwe.clone(),
matcher: Arc::clone(&matcher),
path_filter: path_filter.clone(),
}) as Box<dyn Rule>
})
.collect();
Ok(rules)
}
#[cfg(test)]
mod tests {
use super::*;
fn matches(pattern: &str, source: &str) -> Vec<(usize, usize)> {
let m = GenericMatcher::Pattern(compile_pattern(pattern));
let tokens = tokenize(source);
m.find_all(source, &tokens)
.into_iter()
.map(|m| byte_offset_to_position(source, m.start_byte))
.collect()
}
#[test]
fn tokenizes_words_and_punctuation() {
let toks: Vec<&str> = tokenize("ssl_protocols TLSv1;")
.iter()
.map(|t| t.text)
.collect();
assert_eq!(toks, vec!["ssl_protocols", "TLSv1", ";"]);
}
#[test]
fn compiles_metavar_and_ellipsis() {
let elems = compile_pattern("listen $PORT ... ssl");
assert_eq!(
elems,
vec![
PatternElem::Literal("listen".to_string()),
PatternElem::Metavar("$PORT".to_string()),
PatternElem::Ellipsis,
PatternElem::Literal("ssl".to_string()),
]
);
}
#[test]
fn lone_dollar_is_literal() {
let elems = compile_pattern("cost $ 5");
assert_eq!(
elems,
vec![
PatternElem::Literal("cost".to_string()),
PatternElem::Literal("$".to_string()),
PatternElem::Literal("5".to_string()),
]
);
}
#[test]
fn literal_match_finds_line() {
let positions = matches(
"ssl_protocols TLSv1",
"server {\n ssl_protocols TLSv1;\n}\n",
);
assert_eq!(positions, vec![(2, 3)]);
}
#[test]
fn ellipsis_matches_token_run() {
let positions = matches(
"location ... proxy_pass",
"location /api {\n proxy_pass http://up;\n}\n",
);
assert_eq!(positions.len(), 1);
}
#[test]
fn ellipsis_crosses_newlines() {
let positions = matches("foo ... baz", "foo\nbar\nbaz\n");
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].0, 1);
}
#[test]
fn metavar_equality_is_enforced() {
assert_eq!(matches("$X = $X", "a = a").len(), 1);
assert!(matches("$X = $X", "a = b").is_empty());
}
#[test]
fn metavar_binds_single_token() {
let positions = matches("set $KEY $VAL", "set color red\nset size 10\n");
assert_eq!(positions.len(), 2);
}
#[test]
fn pattern_not_filters_overlapping_matches() {
let matcher = GenericMatcher::Filtered {
positive: Box::new(GenericMatcher::Pattern(compile_pattern(
"ssl_protocols ...",
))),
negatives: vec![GenericMatcher::Pattern(compile_pattern(
"ssl_protocols TLSv1_3",
))],
};
let source = "ssl_protocols TLSv1;\nssl_protocols TLSv1_3;\n";
let tokens = tokenize(source);
let found = matcher.find_all(source, &tokens);
assert!(!found.is_empty());
for m in &found {
assert_eq!(byte_offset_to_position(source, m.start_byte).0, 1);
}
}
#[test]
fn multiline_pattern_matches_across_lines() {
let positions = matches(
"server { ... listen 80",
"server {\n server_name x;\n listen 80;\n}\n",
);
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].0, 1);
}
#[test]
fn regex_passthrough_matches() {
let m = GenericMatcher::Regex(compile_regex(r"AKIA[0-9A-Z]{4}").unwrap());
let source = "key = AKIA1234XYZ\n";
let tokens = tokenize(source);
assert_eq!(m.find_all(source, &tokens).len(), 1);
}
#[test]
fn regex_with_negative_lookahead_compiles_and_matches() {
let m = GenericMatcher::Regex(
compile_regex(r"AIza[0-9A-Za-z_\-]{4}(?!\S)")
.expect("negative-lookahead regex must compile via fancy-regex"),
);
let hit = "key = AIza1234 rest\n";
let toks = tokenize(hit);
assert_eq!(
m.find_all(hit, &toks).len(),
1,
"lookahead-satisfied key (followed by space) must match"
);
let miss = "key = AIza1234XYZ\n";
let toks = tokenize(miss);
assert!(
m.find_all(miss, &toks).is_empty(),
"lookahead-violating key (trailing non-space) must NOT match"
);
}
#[test]
fn build_generic_rules_fans_out_per_language() {
let spec = GenericRuleSpec {
id: "generic-test",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: Some("ssl_protocols TLSv1"),
pattern_regex: None,
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: Vec::new(),
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
assert_eq!(rules.len(), ALL_LANGUAGES.len());
assert_eq!(rules[0].id(), "semgrep/generic-test");
}
fn dummy_tree() -> tree_sitter::Tree {
use crate::engine::parser::parse_file;
parse_file("fn main() {}", Language::Rust).expect("Rust parser must succeed")
}
#[test]
fn generic_patterns_and_block_with_pattern_not() {
let spec = GenericRuleSpec {
id: "and-block-test",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: None,
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: vec![GenericPatternsClause {
pattern: Some("ssl_protocols ...".to_string()),
pattern_not: Some("ssl_protocols TLSv1_3".to_string()),
..Default::default()
}],
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
assert_eq!(rules.len(), ALL_LANGUAGES.len());
let tree = dummy_tree();
let findings = rules[0].check("ssl_protocols TLSv1;\n", &tree);
assert!(
!findings.is_empty(),
"expected a finding for ssl_protocols TLSv1"
);
let findings = rules[0].check("ssl_protocols TLSv1_3;\n", &tree);
assert!(
findings.is_empty(),
"expected no finding when pattern-not matches (ssl_protocols TLSv1_3)"
);
}
#[test]
fn generic_patterns_with_pattern_either_clause() {
let spec = GenericRuleSpec {
id: "either-in-patterns",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: None,
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: vec![GenericPatternsClause {
pattern_either: vec![
GenericEitherEntry {
pattern: Some("rewrite ... redirect".to_string()),
pattern_regex: None,
patterns: Vec::new(),
},
GenericEitherEntry {
pattern: Some("rewrite ... permanent".to_string()),
pattern_regex: None,
patterns: Vec::new(),
},
],
..Default::default()
}],
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
assert_eq!(rules.len(), ALL_LANGUAGES.len());
let tree = dummy_tree();
let source_redirect = "rewrite ^/old$ /new redirect;\n";
let source_permanent = "rewrite ^/old$ /new permanent;\n";
let source_none = "location / { proxy_pass http://up; }\n";
assert!(
!rules[0].check(source_redirect, &tree).is_empty(),
"expected a finding for 'rewrite ... redirect'"
);
assert!(
!rules[0].check(source_permanent, &tree).is_empty(),
"expected a finding for 'rewrite ... permanent'"
);
assert!(
rules[0].check(source_none, &tree).is_empty(),
"expected no finding when neither branch matches"
);
}
#[test]
fn generic_top_level_pattern_either() {
let spec = GenericRuleSpec {
id: "top-either",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: None,
pattern_either: vec![
GenericEitherEntry {
pattern: Some("ssl_protocols TLSv1".to_string()),
pattern_regex: None,
patterns: Vec::new(),
},
GenericEitherEntry {
pattern: Some("ssl_protocols TLSv1_1".to_string()),
pattern_regex: None,
patterns: Vec::new(),
},
],
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: Vec::new(),
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
let tree = dummy_tree();
assert!(!rules[0].check("ssl_protocols TLSv1;\n", &tree).is_empty());
assert!(!rules[0].check("ssl_protocols TLSv1_1;\n", &tree).is_empty());
assert!(rules[0].check("ssl_protocols TLSv1_3;\n", &tree).is_empty());
}
#[test]
fn generic_top_level_pattern_either_regex_arms() {
let spec = GenericRuleSpec {
id: "top-either-regex",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: None,
pattern_either: vec![
GenericEitherEntry {
pattern: None,
pattern_regex: Some("ANTHROPIC_BASE_URL\\s*=".to_string()),
patterns: Vec::new(),
},
GenericEitherEntry {
pattern: None,
pattern_regex: Some("OPENAI_BASE_URL\\s*=".to_string()),
patterns: Vec::new(),
},
],
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: Vec::new(),
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
let tree = dummy_tree();
assert!(!rules[0]
.check("ANTHROPIC_BASE_URL = https://evil.com\n", &tree)
.is_empty());
assert!(!rules[0]
.check("OPENAI_BASE_URL = https://evil.com\n", &tree)
.is_empty());
assert!(rules[0]
.check("SOME_OTHER_URL = https://safe.com\n", &tree)
.is_empty());
}
#[test]
fn generic_patterns_with_pattern_regex_clause() {
let spec = GenericRuleSpec {
id: "regex-in-patterns",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: None,
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: vec![GenericPatternsClause {
pattern_regex: Some("baseURL\\s*=\\s*\"[^h]".to_string()),
..Default::default()
}],
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
let tree = dummy_tree();
let match_src = "baseURL = \"/relative/path\"\n";
let no_match_src = "baseURL = \"https://example.com\"\n";
assert!(
!rules[0].check(match_src, &tree).is_empty(),
"expected a finding for non-http baseURL"
);
assert!(
rules[0].check(no_match_src, &tree).is_empty(),
"expected no finding for https baseURL"
);
}
#[test]
fn generic_patterns_with_pattern_not_regex() {
let spec = GenericRuleSpec {
id: "not-regex-test",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: None,
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: vec![GenericPatternsClause {
pattern: Some("baseURL = ...".to_string()),
pattern_not_regex: Some("(?i)https://".to_string()),
..Default::default()
}],
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
let tree = dummy_tree();
let match_src = "baseURL = \"/relative\"\n";
let no_match_src = "baseURL = \"https://example.com\"\n";
assert!(
!rules[0].check(match_src, &tree).is_empty(),
"expected finding when no https"
);
assert!(
rules[0].check(no_match_src, &tree).is_empty(),
"expected no finding when https present"
);
}
#[test]
fn generic_top_level_pattern_regex() {
let spec = GenericRuleSpec {
id: "top-regex",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: Some("ANTHROPIC_BASE_URL\\s*="),
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: Vec::new(),
path_filter: None,
};
let rules = build_generic_rules(spec).unwrap();
let tree = dummy_tree();
assert!(!rules[0]
.check("ANTHROPIC_BASE_URL = https://evil.com\n", &tree)
.is_empty());
assert!(rules[0]
.check("ANTHROPIC_BASE_URL_EXTRA = something\n", &tree)
.is_empty());
}
#[test]
fn generic_patterns_empty_positives_returns_error() {
let spec = GenericRuleSpec {
id: "empty-positives",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: None,
pattern_regex: None,
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: vec![GenericPatternsClause {
pattern_not: Some("foo".to_string()),
..Default::default()
}],
path_filter: None,
};
assert!(build_generic_rules(spec).is_err());
}
#[test]
fn generic_paths_filter_respected() {
use crate::rules::semgrep_compat::{PathFilter, SemgrepPaths};
use std::path::PathBuf;
let path_filter = PathFilter::from_yaml(Some(&SemgrepPaths {
include: vec!["*.conf".to_string()],
exclude: vec![],
}))
.unwrap()
.unwrap();
let spec = GenericRuleSpec {
id: "path-filter-test",
message: "msg",
severity: Severity::High,
cwe: None,
pattern: Some("ssl_protocols TLSv1"),
pattern_regex: None,
pattern_either: Vec::new(),
pattern_not: None,
pattern_not_regex: None,
patterns_clauses: Vec::new(),
path_filter: Some(path_filter),
};
let rules = build_generic_rules(spec).unwrap();
let rule = &rules[0];
assert!(rule.applies_to_path(&PathBuf::from("nginx/site.conf")));
assert!(!rule.applies_to_path(&PathBuf::from("nginx/site.py")));
}
fn loader_fires(rule_yaml: &str, source: &str) -> bool {
use crate::rules::semgrep_compat::parse_semgrep_str;
let rules = parse_semgrep_str(rule_yaml, "<test>").expect("rule must load");
assert!(!rules.is_empty(), "rule produced no rule objects");
let tree = dummy_tree();
rules.iter().any(|r| !r.check(source, &tree).is_empty())
}
#[test]
fn named_capture_comparison_is_enforced() {
let yaml = r#"
rules:
- id: min-age-too-low
pattern-either:
- patterns:
- pattern-regex: 'min-release-age\s*=\s*\d+'
- pattern-regex: '=\s*(?P<AGE>\d+)'
- metavariable-comparison:
metavariable: $AGE
comparison: int($AGE) < 7
- focus-metavariable: $AGE
message: min-release-age set too low
severity: MEDIUM
languages: [generic]
"#;
assert!(loader_fires(yaml, "min-release-age = 3\n"));
assert!(!loader_fires(yaml, "min-release-age = 7\n"));
assert!(!loader_fires(yaml, "min-release-age = 30\n"));
}
#[test]
fn named_capture_regex_is_enforced() {
let yaml = r#"
rules:
- id: exclude-newer-bad-format
pattern-either:
- patterns:
- pattern-regex: 'exclude-newer\s*=\s*"(?P<VAL>[^"]+)"'
- metavariable-regex:
metavariable: $VAL
regex: '^(?!\d+ days?$)(?!\d{4}-\d{2}-\d{2}$)'
- focus-metavariable: $VAL
message: exclude-newer invalid format
severity: MEDIUM
languages: [generic]
"#;
assert!(loader_fires(yaml, "exclude-newer = \"soon\"\n"));
assert!(!loader_fires(yaml, "exclude-newer = \"7 days\"\n"));
assert!(!loader_fires(yaml, "exclude-newer = \"2026-01-01\"\n"));
}
#[test]
fn unenforceable_metavariable_pattern_arm_refuses_to_load() {
use crate::rules::semgrep_compat::parse_semgrep_str;
let yaml = r#"
rules:
- id: relative-workdir
pattern-either:
- patterns:
- pattern: WORKDIR $VALUE
- metavariable-pattern:
metavariable: $VALUE
patterns:
- pattern-not-regex: (\/.*)
message: relative WORKDIR
severity: WARNING
languages: [generic]
"#;
assert!(parse_semgrep_str(yaml, "<test>").is_err());
}
#[test]
fn constraint_referencing_unknown_capture_refuses_to_load() {
use crate::rules::semgrep_compat::parse_semgrep_str;
let yaml = r#"
rules:
- id: bad-capture-ref
pattern-either:
- patterns:
- pattern-regex: 'value\s*=\s*(?P<AGE>\d+)'
- metavariable-comparison:
metavariable: $NOPE
comparison: int($NOPE) < 7
message: bad capture reference
severity: MEDIUM
languages: [generic]
"#;
assert!(parse_semgrep_str(yaml, "<test>").is_err());
}
#[test]
fn parse_generic_comparison_handles_int_wrapper() {
let (g, op, lit, lhs) = parse_generic_comparison("int($AGE) < 604800").unwrap();
assert_eq!(g, "AGE");
assert_eq!(op, CmpOp::Lt);
assert_eq!(lit, 604800.0);
assert!(!lhs);
let (g, op, lit, lhs) = parse_generic_comparison("7 >= $DAYS").unwrap();
assert_eq!(g, "DAYS");
assert_eq!(op, CmpOp::Ge);
assert_eq!(lit, 7.0);
assert!(lhs);
}
}