server/inspect/src/yara_lite.rs
//! **A built-in matcher for the plain subset of the YARA rule language.**
//!
//! Most signature rules an air-gapped site receives are string rules: text and
//! hex strings and a boolean condition over them. This module runs exactly that
//! subset in process, so the YARA adapter works with no engine installed and
//! can scan every member of an archive without spawning a process per member.
//!
//! Supported:
//! * `rule NAME [: tags] { meta: … strings: … condition: … }`, with the
//! `private` and `global` modifiers accepted and ignored;
//! * text strings with `\" \\ \n \r \t \xHH` escapes and the `nocase`, `ascii`,
//! `wide` and `fullword` modifiers;
//! * hex strings of byte pairs and `??` wildcards;
//! * conditions built from `$id`, `true`, `false`, `not`, `and`, `or`,
//! parentheses, and `any|all|N of them` / `of ($a, $b*)`.
//!
//! Anything else (imports, regular expressions, jumps, alternatives, counts,
//! offsets, `filesize`, modules) is refused by NAME with [`Unsupported`]. The
//! adapter then needs the real `yara` binary, and without it reports
//! "not configured" rather than silently matching less than the rule says.
use std::fmt;
/// The rules use something outside the subset, or do not parse.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unsupported(pub String);
impl fmt::Display for Unsupported {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
fn bail<T>(msg: impl Into<String>) -> Result<T, Unsupported> {
Err(Unsupported(msg.into()))
}
#[derive(Debug, Clone)]
struct Pattern {
/// `None` is a `??` wildcard.
bytes: Vec<Option<u8>>,
nocase: bool,
fullword: bool,
}
#[derive(Debug, Clone)]
struct StringDef {
id: String,
/// Any of these matching is the string matching (ascii and wide forms).
forms: Vec<Pattern>,
}
#[derive(Debug, Clone)]
enum Expr {
Bool(bool),
Var(String),
Not(Box<Expr>),
And(Box<Expr>, Box<Expr>),
Or(Box<Expr>, Box<Expr>),
/// `None` = all; the patterns are `$id` or `$prefix*`, empty = them.
Of(Option<usize>, Vec<String>),
}
#[derive(Debug, Clone)]
struct Rule {
name: String,
strings: Vec<StringDef>,
condition: Expr,
}
/// A compiled rule set.
#[derive(Debug, Clone)]
pub struct Rules {
rules: Vec<Rule>,
prefilter: Prefilter,
}
/// Where one pattern form lives: `rules[rule].strings[def].forms[form]`.
#[derive(Debug, Clone, Copy)]
struct FormAt {
rule: usize,
def: usize,
form: usize,
}
/// The literal-atom prefilter (lane T10). Each pattern form's longest run of
/// fixed bytes is its atom; one Aho-Corasick pass per case mode finds every
/// place an atom occurs, and only there is the whole form checked. A form with
/// no fixed byte at all (`{ ?? ?? }`) is tried at every offset, as before.
///
/// Every occurrence of the atom is reported (overlapping search), and a form can
/// only match where its atom does, so the prefilter tests a subset of the
/// offsets the plain scan tests that contains every offset that can match: the
/// answers are the plain scan's.
#[derive(Debug, Clone)]
struct Prefilter {
forms: Vec<FormAt>,
/// Case-sensitive atoms, and per atom `(form index, atom offset in the form)`.
exact: Option<(aho_corasick::AhoCorasick, Vec<(usize, usize)>)>,
/// ASCII case-insensitive atoms (`nocase` forms).
nocase: Option<(aho_corasick::AhoCorasick, Vec<(usize, usize)>)>,
/// Forms with no fixed byte.
unanchored: Vec<usize>,
}
/// The longest run of fixed bytes in `p`: `(offset, bytes)`.
fn atom(p: &Pattern) -> Option<(usize, Vec<u8>)> {
let (mut best, mut cur_start) = (None::<(usize, usize)>, None::<usize>);
for i in 0..=p.bytes.len() {
match p.bytes.get(i).copied().flatten() {
Some(_) => {
cur_start.get_or_insert(i);
}
None => {
if let Some(st) = cur_start.take() {
if best.is_none_or(|(_, len)| i - st > len) {
best = Some((st, i - st));
}
}
}
}
}
best.map(|(st, len)| {
(
st,
p.bytes[st..st + len].iter().map(|b| b.unwrap()).collect(),
)
})
}
impl Prefilter {
fn build(rules: &[Rule]) -> Result<Prefilter, Unsupported> {
let mut forms = Vec::new();
let (mut ex_atoms, mut ex_ids, mut nc_atoms, mut nc_ids, mut unanchored) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
for (ri, r) in rules.iter().enumerate() {
for (di, d) in r.strings.iter().enumerate() {
for (fi, f) in d.forms.iter().enumerate() {
let id = forms.len();
forms.push(FormAt {
rule: ri,
def: di,
form: fi,
});
match atom(f) {
None => unanchored.push(id),
Some((off, bytes)) if f.nocase => {
nc_atoms.push(bytes);
nc_ids.push((id, off));
}
Some((off, bytes)) => {
ex_atoms.push(bytes);
ex_ids.push((id, off));
}
}
}
}
}
let build = |atoms: Vec<Vec<u8>>, ids: Vec<(usize, usize)>, nocase: bool| {
if atoms.is_empty() {
return Ok(None);
}
aho_corasick::AhoCorasick::builder()
.ascii_case_insensitive(nocase)
.build(&atoms)
.map(|ac| Some((ac, ids)))
.map_err(|e| Unsupported(format!("the literal prefilter: {e}")))
};
Ok(Prefilter {
forms,
exact: build(ex_atoms, ex_ids, false)?,
nocase: build(nc_atoms, nc_ids, true)?,
unanchored,
})
}
/// Which forms occur in `data`, indexed like `self.forms`.
fn found(&self, rules: &[Rule], data: &[u8]) -> Vec<bool> {
let form = |id: usize| {
let at = self.forms[id];
&rules[at.rule].strings[at.def].forms[at.form]
};
let mut found = vec![false; self.forms.len()];
let mut left = self.forms.len() - self.unanchored.len();
for (ac, ids) in [&self.exact, &self.nocase].into_iter().flatten() {
if left == 0 {
break;
}
for m in ac.find_overlapping_iter(data) {
let (id, off) = ids[m.pattern().as_usize()];
if found[id] || m.start() < off {
continue;
}
if form(id).matches_at(data, m.start() - off) {
found[id] = true;
left -= 1;
if left == 0 {
break;
}
}
}
}
for &id in &self.unanchored {
found[id] = form(id).found_in(data);
}
found
}
}
// ── lexer ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
enum Tok {
Ident(String),
Var(String),
Str(Vec<u8>),
Hex(Vec<Option<u8>>),
Num(u64),
Punct(char),
}
struct Lexer<'a> {
s: &'a [u8],
i: usize,
/// The previous token was `=` after a `$var`, so a `{` opens a hex string.
hex_next: bool,
}
impl<'a> Lexer<'a> {
fn skip_ws(&mut self) -> Result<(), Unsupported> {
loop {
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
self.i += 1;
}
if self.s[self.i..].starts_with(b"//") {
while self.i < self.s.len() && self.s[self.i] != b'\n' {
self.i += 1;
}
} else if self.s[self.i..].starts_with(b"/*") {
match self.s[self.i + 2..].windows(2).position(|w| w == b"*/") {
Some(p) => self.i += p + 4,
None => return bail("unterminated comment"),
}
} else {
return Ok(());
}
}
}
fn next(&mut self) -> Result<Option<Tok>, Unsupported> {
self.skip_ws()?;
let Some(&c) = self.s.get(self.i) else {
return Ok(None);
};
let hex_next = std::mem::replace(&mut self.hex_next, false);
if c == b'{' && hex_next {
self.i += 1;
let end = match self.s[self.i..].iter().position(|&b| b == b'}') {
Some(p) => self.i + p,
None => return bail("unterminated hex string"),
};
let body = std::str::from_utf8(&self.s[self.i..end])
.map_err(|_| Unsupported("hex string is not text".into()))?;
self.i = end + 1;
let mut out = Vec::new();
for t in body.split_whitespace() {
if t == "??" {
out.push(None);
} else if t.len() == 2 && t.chars().all(|c| c.is_ascii_hexdigit()) {
out.push(Some(u8::from_str_radix(t, 16).unwrap()));
} else {
return bail(format!("hex token {t:?} (jumps, alternatives and nibble wildcards need the yara engine)"));
}
}
if out.is_empty() {
return bail("empty hex string");
}
return Ok(Some(Tok::Hex(out)));
}
if c == b'"' {
self.i += 1;
let mut out = Vec::new();
loop {
let Some(&b) = self.s.get(self.i) else {
return bail("unterminated text string");
};
self.i += 1;
match b {
b'"' => break,
b'\\' => {
let Some(&e) = self.s.get(self.i) else {
return bail("unterminated escape");
};
self.i += 1;
out.push(match e {
b'"' => b'"',
b'\\' => b'\\',
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'x' => {
let h = self
.s
.get(self.i..self.i + 2)
.and_then(|h| std::str::from_utf8(h).ok());
let Some(v) = h.and_then(|h| u8::from_str_radix(h, 16).ok()) else {
return bail("bad \\x escape");
};
self.i += 2;
v
}
other => return bail(format!("escape \\{}", other as char)),
});
}
b => out.push(b),
}
}
return Ok(Some(Tok::Str(out)));
}
if c == b'/' {
return bail("regular expressions need the yara engine");
}
if c == b'$' || c == b'_' || c.is_ascii_alphabetic() {
let start = self.i;
self.i += 1;
while self.i < self.s.len()
&& (self.s[self.i] == b'_'
|| self.s[self.i].is_ascii_alphanumeric()
|| self.s[self.i] == b'*')
{
self.i += 1;
}
let word = String::from_utf8_lossy(&self.s[start..self.i]).into_owned();
return Ok(Some(if c == b'$' {
Tok::Var(word)
} else {
Tok::Ident(word)
}));
}
if c.is_ascii_digit() {
let start = self.i;
while self.i < self.s.len() && self.s[self.i].is_ascii_digit() {
self.i += 1;
}
let n = std::str::from_utf8(&self.s[start..self.i])
.unwrap()
.parse()
.map_err(|_| Unsupported("number".into()))?;
return Ok(Some(Tok::Num(n)));
}
if b"{}():=,".contains(&c) {
self.i += 1;
return Ok(Some(Tok::Punct(c as char)));
}
bail(format!("unexpected character {:?}", c as char))
}
}
fn lex(src: &str) -> Result<Vec<Tok>, Unsupported> {
let mut lx = Lexer {
s: src.as_bytes(),
i: 0,
hex_next: false,
};
let mut out: Vec<Tok> = Vec::new();
while let Some(t) = lx.next()? {
// `$id =` followed by `{` is a hex string, not a block.
if t == Tok::Punct('=') && matches!(out.last(), Some(Tok::Var(_))) {
lx.hex_next = true;
}
out.push(t);
}
Ok(out)
}
// ── parser ──────────────────────────────────────────────────────────────────
struct Parser {
t: Vec<Tok>,
i: usize,
}
impl Parser {
fn peek(&self) -> Option<&Tok> {
self.t.get(self.i)
}
fn bump(&mut self) -> Option<Tok> {
let t = self.t.get(self.i).cloned();
self.i += 1;
t
}
fn is_ident(&self, w: &str) -> bool {
matches!(self.peek(), Some(Tok::Ident(x)) if x == w)
}
fn expect_punct(&mut self, c: char) -> Result<(), Unsupported> {
match self.bump() {
Some(Tok::Punct(p)) if p == c => Ok(()),
other => bail(format!("expected '{c}', found {other:?}")),
}
}
fn expect_ident(&mut self) -> Result<String, Unsupported> {
match self.bump() {
Some(Tok::Ident(w)) => Ok(w),
other => bail(format!("expected a name, found {other:?}")),
}
}
fn rule(&mut self) -> Result<Rule, Unsupported> {
while self.is_ident("private") || self.is_ident("global") {
self.bump();
}
if self.is_ident("import") || self.is_ident("include") {
return bail("imports and includes need the yara engine");
}
if self.expect_ident()? != "rule" {
return bail("expected 'rule'");
}
let name = self.expect_ident()?;
if self.peek() == Some(&Tok::Punct(':')) {
self.bump();
while matches!(self.peek(), Some(Tok::Ident(_))) {
self.bump();
}
}
self.expect_punct('{')?;
let mut strings = Vec::new();
let mut condition = None;
loop {
match self.bump() {
Some(Tok::Punct('}')) => break,
Some(Tok::Ident(sec)) if sec == "meta" => {
self.expect_punct(':')?;
while matches!(self.peek(), Some(Tok::Ident(w)) if !["strings", "condition"].contains(&w.as_str()))
{
self.bump();
self.expect_punct('=')?;
match self.bump() {
Some(Tok::Str(_)) | Some(Tok::Num(_)) | Some(Tok::Ident(_)) => {}
other => return bail(format!("meta value {other:?}")),
}
}
}
Some(Tok::Ident(sec)) if sec == "strings" => {
self.expect_punct(':')?;
while let Some(Tok::Var(id)) = self.peek().cloned() {
self.bump();
self.expect_punct('=')?;
strings.push(self.string_def(id)?);
}
}
Some(Tok::Ident(sec)) if sec == "condition" => {
self.expect_punct(':')?;
condition = Some(self.or()?);
}
other => return bail(format!("in rule {name}: unexpected {other:?}")),
}
}
let condition =
condition.ok_or_else(|| Unsupported(format!("rule {name} has no condition")))?;
Ok(Rule {
name,
strings,
condition,
})
}
fn string_def(&mut self, id: String) -> Result<StringDef, Unsupported> {
match self.bump() {
Some(Tok::Hex(bytes)) => Ok(StringDef {
id,
forms: vec![Pattern {
bytes,
nocase: false,
fullword: false,
}],
}),
Some(Tok::Str(text)) => {
let (mut nocase, mut ascii, mut wide, mut fullword) = (false, false, false, false);
while let Some(Tok::Ident(m)) = self.peek().cloned() {
match m.as_str() {
"nocase" => nocase = true,
"ascii" => ascii = true,
"wide" => wide = true,
"fullword" => fullword = true,
"private" => {}
_ => break,
}
self.bump();
}
if text.is_empty() {
return bail(format!("{id} is empty"));
}
let mut forms = Vec::new();
if ascii || !wide {
forms.push(Pattern {
bytes: text.iter().map(|b| Some(*b)).collect(),
nocase,
fullword,
});
}
if wide {
forms.push(Pattern {
bytes: text.iter().flat_map(|b| [Some(*b), Some(0)]).collect(),
nocase,
fullword,
});
}
Ok(StringDef { id, forms })
}
other => bail(format!(
"{id}: regular expressions and {other:?} need the yara engine"
)),
}
}
fn or(&mut self) -> Result<Expr, Unsupported> {
let mut e = self.and()?;
while self.is_ident("or") {
self.bump();
e = Expr::Or(Box::new(e), Box::new(self.and()?));
}
Ok(e)
}
fn and(&mut self) -> Result<Expr, Unsupported> {
let mut e = self.not()?;
while self.is_ident("and") {
self.bump();
e = Expr::And(Box::new(e), Box::new(self.not()?));
}
Ok(e)
}
fn not(&mut self) -> Result<Expr, Unsupported> {
if self.is_ident("not") {
self.bump();
return Ok(Expr::Not(Box::new(self.not()?)));
}
self.primary()
}
fn primary(&mut self) -> Result<Expr, Unsupported> {
match self.bump() {
Some(Tok::Punct('(')) => {
let e = self.or()?;
self.expect_punct(')')?;
Ok(e)
}
Some(Tok::Var(v)) => Ok(Expr::Var(v)),
Some(Tok::Ident(w)) if w == "true" => Ok(Expr::Bool(true)),
Some(Tok::Ident(w)) if w == "false" => Ok(Expr::Bool(false)),
Some(Tok::Ident(w)) if w == "any" || w == "all" => {
self.of(if w == "any" { Some(1) } else { None })
}
Some(Tok::Num(n)) => self.of(Some(n as usize)),
other => bail(format!("condition {other:?} needs the yara engine")),
}
}
fn of(&mut self, n: Option<usize>) -> Result<Expr, Unsupported> {
if !self.is_ident("of") {
return bail("counts and offsets need the yara engine");
}
self.bump();
if self.is_ident("them") {
self.bump();
return Ok(Expr::Of(n, Vec::new()));
}
self.expect_punct('(')?;
let mut pats = Vec::new();
loop {
match self.bump() {
Some(Tok::Var(v)) => pats.push(v),
other => return bail(format!("in 'of (...)': {other:?}")),
}
match self.bump() {
Some(Tok::Punct(',')) => continue,
Some(Tok::Punct(')')) => break,
other => return bail(format!("in 'of (...)': {other:?}")),
}
}
Ok(Expr::Of(n, pats))
}
}
// ── matching ────────────────────────────────────────────────────────────────
fn word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
impl Pattern {
/// Does the whole pattern (and its `fullword` rule) match at `at`?
fn matches_at(&self, hay: &[u8], at: usize) -> bool {
let n = self.bytes.len();
if n == 0 || at + n > hay.len() {
return false;
}
let eq = |p: Option<u8>, h: u8| match p {
None => true,
Some(p) if self.nocase => p.eq_ignore_ascii_case(&h),
Some(p) => p == h,
};
self.bytes
.iter()
.zip(&hay[at..at + n])
.all(|(p, h)| eq(*p, *h))
&& (!self.fullword
|| ((at == 0 || !word_byte(hay[at - 1]))
&& (at + n == hay.len() || !word_byte(hay[at + n]))))
}
/// The plain scan: the pattern tried at every offset.
fn found_in(&self, hay: &[u8]) -> bool {
let n = self.bytes.len();
if n == 0 || hay.len() < n {
return false;
}
let eq = |p: Option<u8>, h: u8| match p {
None => true,
Some(p) if self.nocase => p.eq_ignore_ascii_case(&h),
Some(p) => p == h,
};
// Anchor on the first fixed byte to keep the naive scan cheap.
let anchor = self.bytes.iter().position(|b| b.is_some());
(0..=hay.len() - n).any(|at| {
if let Some(a) = anchor {
if !eq(self.bytes[a], hay[at + a]) {
return false;
}
}
self.bytes
.iter()
.zip(&hay[at..at + n])
.all(|(p, h)| eq(*p, *h))
&& (!self.fullword
|| ((at == 0 || !word_byte(hay[at - 1]))
&& (at + n == hay.len() || !word_byte(hay[at + n]))))
})
}
}
fn var_matches(pat: &str, id: &str) -> bool {
match pat.strip_suffix('*') {
Some(prefix) => id.starts_with(prefix),
None => pat == id,
}
}
impl Expr {
fn eval(&self, defs: &[StringDef], hit: &[bool]) -> bool {
match self {
Expr::Bool(b) => *b,
Expr::Var(v) => defs.iter().zip(hit).any(|(d, h)| d.id == *v && *h),
Expr::Not(e) => !e.eval(defs, hit),
Expr::And(a, b) => a.eval(defs, hit) && b.eval(defs, hit),
Expr::Or(a, b) => a.eval(defs, hit) || b.eval(defs, hit),
Expr::Of(n, pats) => {
let chosen: Vec<bool> = defs
.iter()
.zip(hit)
.filter(|(d, _)| pats.is_empty() || pats.iter().any(|p| var_matches(p, &d.id)))
.map(|(_, h)| *h)
.collect();
let got = chosen.iter().filter(|h| **h).count();
match n {
None => !chosen.is_empty() && got == chosen.len(),
Some(n) => got >= *n,
}
}
}
}
fn vars(&self, out: &mut Vec<String>) {
match self {
Expr::Var(v) => out.push(v.clone()),
Expr::Not(e) => e.vars(out),
Expr::And(a, b) | Expr::Or(a, b) => {
a.vars(out);
b.vars(out);
}
Expr::Of(_, p) => out.extend(p.iter().cloned()),
Expr::Bool(_) => {}
}
}
}
impl Rules {
/// Compile rule source, or say which construct needs the real engine.
pub fn parse(src: &str) -> Result<Rules, Unsupported> {
let mut p = Parser { t: lex(src)?, i: 0 };
let mut rules = Vec::new();
while p.peek().is_some() {
let r = p.rule()?;
let mut used = Vec::new();
r.condition.vars(&mut used);
for v in used {
if !r.strings.iter().any(|d| var_matches(&v, &d.id)) {
return bail(format!("rule {}: {v} is not defined", r.name));
}
}
rules.push(r);
}
if rules.is_empty() {
return bail("no rules");
}
let prefilter = Prefilter::build(&rules)?;
Ok(Rules { rules, prefilter })
}
pub fn len(&self) -> usize {
self.rules.len()
}
/// `(forms the literal prefilter finds, forms tried at every offset)`.
pub fn prefiltered_forms(&self) -> (usize, usize) {
let p = &self.prefilter;
(p.forms.len() - p.unanchored.len(), p.unanchored.len())
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// The names of the rules that match `data`.
///
/// One literal-atom pass finds the candidate offsets for every pattern of
/// every rule ([`Prefilter`]); the conditions are then evaluated as before.
pub fn scan(&self, data: &[u8]) -> Vec<String> {
let found = self.prefilter.found(&self.rules, data);
let mut next = 0;
let mut out = Vec::new();
for r in &self.rules {
let hit: Vec<bool> = r
.strings
.iter()
.map(|d| {
let any = found[next..next + d.forms.len()].iter().any(|h| *h);
next += d.forms.len();
any
})
.collect();
if r.condition.eval(&r.strings, &hit) {
out.push(r.name.clone());
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_and_hex_strings_with_a_condition() {
let r = Rules::parse(
r#"
// a comment
rule Two : tag1 tag2 {
meta: author = "holger" score = 5
strings:
$a = "evil" nocase
$b = { 4D 5A ?? 00 }
condition: $a and $b
}
rule Any { strings: $x1 = "one" $x2 = "two" condition: any of ($x*) }
"#,
)
.unwrap();
assert_eq!(r.len(), 2);
assert_eq!(r.scan(b"MZ\x90\x00 ... EVIL"), vec!["Two".to_string()]);
assert_eq!(r.scan(b"two"), vec!["Any".to_string()]);
assert!(r.scan(b"nothing").is_empty());
}
#[test]
fn wide_and_fullword() {
let r =
Rules::parse(r#"rule W { strings: $a = "ab" wide fullword condition: all of them }"#)
.unwrap();
assert_eq!(r.scan(b"a\0b\0").len(), 1);
assert!(r.scan(b"ab").is_empty(), "wide only");
let f = Rules::parse(r#"rule F { strings: $a = "cat" fullword condition: $a }"#).unwrap();
assert!(f.scan(b"concatenate").is_empty());
assert_eq!(f.scan(b"a cat.").len(), 1);
}
/// The plain scan every rule set must agree with: each pattern tried at every
/// offset of the data (the matcher before the literal-atom prefilter).
fn naive_scan(r: &Rules, data: &[u8]) -> Vec<String> {
r.rules
.iter()
.filter(|r| {
let hit: Vec<bool> = r
.strings
.iter()
.map(|d| d.forms.iter().any(|f| f.found_in(data)))
.collect();
r.condition.eval(&r.strings, &hit)
})
.map(|r| r.name.clone())
.collect()
}
const EQUIV_RULES: &str = r#"
rule Eicar { strings: $a = "EICAR-STANDARD-ANTIVIRUS-TEST-FILE" condition: $a }
rule NoCase { strings: $a = "discord.com/api/webhooks/" nocase condition: $a }
rule All3 { strings: $a = "curl " $b = "| sh" $c = "\"preinstall\"" condition: all of them }
rule Wide { strings: $a = "evil" wide ascii nocase condition: $a }
rule Full { strings: $a = "cat" fullword $b = "dog" fullword condition: any of them }
rule Hex { strings: $mz = { 4D 5A 90 00 03 00 00 00 } condition: $mz }
rule HexWild { strings: $a = { ?? ?? 4D ?? 5A ?? } condition: $a }
rule AllWild { strings: $a = { ?? ?? ?? } condition: $a }
rule Lead { strings: $a = { ?? 41 41 41 } condition: $a }
rule Overlap { strings: $a = "aab" $b = "aaab" condition: $a and $b }
rule NotIt { strings: $a = "zz" condition: not $a }
rule Count2 { strings: $x1 = "one" $x2 = "two" $x3 = "three" condition: 2 of ($x*) }
rule OneByte { strings: $a = "q" fullword condition: $a }
"#;
#[test]
fn the_prefiltered_scan_answers_what_the_plain_scan_answers() {
let r = Rules::parse(EQUIV_RULES).unwrap();
let mut inputs: Vec<Vec<u8>> = vec![
Vec::new(),
b"x".to_vec(),
b"AB".to_vec(),
b"X5O EICAR-STANDARD-ANTIVIRUS-TEST-FILE!".to_vec(),
b"https://DISCORD.com/API/webhooks/123".to_vec(),
b"\"preinstall\": \"curl x | sh\"".to_vec(),
b"curl | sh".to_vec(),
b"e\0V\0i\0L\0".to_vec(),
b"EvIl".to_vec(),
b"concatenate".to_vec(),
b"a cat.".to_vec(),
b"cat".to_vec(),
b"hotdog dog_".to_vec(),
b"MZ\x90\x00\x03\x00\x00\x00".to_vec(),
b"xxMZ\x90\x00\x03\x00\x00".to_vec(),
b"..M.Z.".to_vec(),
b"M.Z.".to_vec(),
b"AAAA".to_vec(),
b"AAA".to_vec(),
b"aaab".to_vec(),
b"aab".to_vec(),
b"zz".to_vec(),
b"one three".to_vec(),
b"two".to_vec(),
b"q".to_vec(),
b"qq q".to_vec(),
];
// Pseudo-random data over a small alphabet, so atoms overlap and repeat.
let alphabet = b"aAbBcCqtzM Z\x00\x90.|sh-";
let mut x: u64 = 0x9e37_79b9_7f4a_7c15;
for len in [7usize, 31, 257, 4099] {
for _ in 0..40 {
let mut v = Vec::with_capacity(len);
for _ in 0..len {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
v.push(alphabet[(x % alphabet.len() as u64) as usize]);
}
inputs.push(v);
}
}
let mut matched = 0;
for data in &inputs {
let want = naive_scan(&r, data);
assert_eq!(r.scan(data), want, "{:?}", String::from_utf8_lossy(data));
matched += want.len();
}
assert!(
matched > 50,
"the inputs must exercise real matches, got {matched}"
);
}
#[test]
fn every_pattern_with_a_fixed_byte_runs_through_the_literal_prefilter() {
let r = Rules::parse(EQUIV_RULES).unwrap();
// 19 strings, 20 forms (Wide's $a is ascii and wide); only AllWild's
// `{ ?? ?? ?? }` has no literal atom to search for.
assert_eq!(r.prefiltered_forms(), (19, 1));
}
#[test]
fn what_the_subset_cannot_say_is_refused_by_name() {
for src in [
r#"import "pe" rule X { condition: true }"#,
r#"rule X { strings: $a = /ev[il]+/ condition: $a }"#,
r#"rule X { strings: $a = { 4D [2-4] 5A } condition: $a }"#,
r#"rule X { strings: $a = "x" condition: #a > 2 }"#,
r#"rule X { strings: $a = "x" condition: $b }"#,
] {
assert!(Rules::parse(src).is_err(), "{src}");
}
}
}