use crate::rules::common::get_source_line;
use crate::rules::semgrep_compat::PathFilter;
use crate::rules::Rule;
use crate::{Finding, Language, Severity};
use 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,
];
#[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)]
enum GenericMatcher {
Pattern(Vec<PatternElem>),
Regex(Regex),
Either(Vec<GenericMatcher>),
Filtered {
positive: Box<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)
.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::Filtered {
positive,
negatives,
} => {
let mut matches = positive.find_all(source, tokens);
if !negatives.is_empty() {
let neg: Vec<GenericMatch> = negatives
.iter()
.flat_map(|n| n.find_all(source, tokens))
.collect();
matches.retain(|m| !neg.iter().any(|n| overlaps(m, n)));
}
matches
}
}
}
}
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 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)
}
fn build_matcher(
pattern: Option<&str>,
pattern_regex: Option<&str>,
pattern_either: &[String],
pattern_not: Option<&str>,
) -> Result<GenericMatcher, String> {
let positive = if let Some(p) = pattern {
GenericMatcher::Pattern(compile_pattern(p))
} else if let Some(re) = pattern_regex {
GenericMatcher::Regex(compile_regex(re)?)
} else if !pattern_either.is_empty() {
let inner = pattern_either
.iter()
.map(|p| GenericMatcher::Pattern(compile_pattern(p)))
.collect();
GenericMatcher::Either(inner)
} else {
return Err("generic rule has no pattern / pattern-regex / pattern-either".to_string());
};
if let Some(pn) = pattern_not {
Ok(GenericMatcher::Filtered {
positive: Box::new(positive),
negatives: vec![GenericMatcher::Pattern(compile_pattern(pn))],
})
} else {
Ok(positive)
}
}
fn compile_regex(pattern: &str) -> Result<Regex, String> {
Regex::new(pattern).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<String>,
pub pattern_not: Option<&'a str>,
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.pattern,
spec.pattern_regex,
&spec.pattern_either,
spec.pattern_not,
)?);
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 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,
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");
}
}