1pub mod lucene;
26
27use std::collections::HashMap;
28
29use rsigma_ir::{
30 IrCondition, IrDetection, IrDetectionItem, IrMatcher, IrPattern, IrPatternPart, IrRule,
31 IrRuleMetadata, IrStrOp, RaiseOptions, raise_rule,
32};
33use rsigma_parser::{Level, LogSource, SigmaRule, SigmaString, Status, StringPart};
34
35use crate::error::{ConvertError, Result};
36
37pub use lucene::{LUCENE_DIALECT, LuceneFrontend};
38
39#[derive(Debug, Clone, Copy)]
50pub struct QueryDialect {
51 pub name: &'static str,
53 pub and_tokens: &'static [&'static str],
55 pub or_tokens: &'static [&'static str],
57 pub not_tokens: &'static [&'static str],
59 pub implicit_and: bool,
62}
63
64#[derive(Debug, Clone, PartialEq)]
75pub enum QueryExpr<L> {
76 And(Vec<QueryExpr<L>>),
77 Or(Vec<QueryExpr<L>>),
78 Not(Box<QueryExpr<L>>),
79 Leaf(L),
80}
81
82impl<L> QueryExpr<L> {
83 fn expand<T, F>(self, f: &mut F) -> Result<QueryExpr<T>>
85 where
86 F: FnMut(L) -> Result<QueryExpr<T>>,
87 {
88 match self {
89 QueryExpr::And(items) => Ok(QueryExpr::And(
90 items
91 .into_iter()
92 .map(|e| e.expand(f))
93 .collect::<Result<_>>()?,
94 )),
95 QueryExpr::Or(items) => Ok(QueryExpr::Or(
96 items
97 .into_iter()
98 .map(|e| e.expand(f))
99 .collect::<Result<_>>()?,
100 )),
101 QueryExpr::Not(inner) => Ok(QueryExpr::Not(Box::new(inner.expand(f)?))),
102 QueryExpr::Leaf(leaf) => f(leaf),
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub enum QueryLeaf {
110 Field { field: String, matcher: IrMatcher },
112 Keyword(IrMatcher),
114}
115
116#[derive(Debug, Clone, Default)]
123pub struct ReverseCtx {
124 pub title: Option<String>,
125 pub id: Option<String>,
126 pub status: Option<Status>,
127 pub level: Option<Level>,
128 pub product: Option<String>,
129 pub category: Option<String>,
130 pub service: Option<String>,
131 pub strict: bool,
134}
135
136impl ReverseCtx {
137 fn metadata(&self) -> IrRuleMetadata {
138 IrRuleMetadata {
139 title: self
140 .title
141 .clone()
142 .unwrap_or_else(|| "Converted query".to_string()),
143 id: self.id.clone(),
144 level: self.level,
145 status: self.status,
146 ..Default::default()
147 }
148 }
149
150 fn logsource(&self) -> LogSource {
151 LogSource {
152 category: self.category.clone(),
153 product: self.product.clone(),
154 service: self.service.clone(),
155 definition: None,
156 custom: HashMap::new(),
157 }
158 }
159}
160
161#[derive(Debug, Clone)]
163pub struct ReverseResult {
164 pub query: String,
166 pub rule: SigmaRule,
168 pub yaml: String,
170}
171
172#[derive(Debug, Default)]
176pub struct ReverseOutput {
177 pub rules: Vec<ReverseResult>,
178 pub errors: Vec<(String, ConvertError)>,
179}
180
181pub trait Frontend {
192 fn name(&self) -> &str;
194
195 fn dialect(&self) -> &QueryDialect;
197
198 fn parse_atom(&self, atom: &str, ctx: &ReverseCtx) -> Result<QueryExpr<QueryLeaf>>;
201
202 fn parse_query(&self, query: &str, ctx: &ReverseCtx) -> Result<IrRule> {
204 let dialect = self.dialect();
205 let tokens = tokenize(dialect, query)?;
206 let tree = parse_boolean(dialect, tokens)?;
207 let mut expand = |atom: String| self.parse_atom(&atom, ctx);
208 let resolved = tree.expand(&mut expand)?;
209 assemble_rule(resolved, ctx)
210 }
211}
212
213pub fn reverse_collection(
215 frontend: &dyn Frontend,
216 queries: &[String],
217 ctx: &ReverseCtx,
218) -> ReverseOutput {
219 let mut output = ReverseOutput::default();
220 for query in queries {
221 match convert_one(frontend, query, ctx) {
222 Ok(result) => output.rules.push(result),
223 Err(e) => output.errors.push((query.clone(), e)),
224 }
225 }
226 output
227}
228
229fn convert_one(frontend: &dyn Frontend, query: &str, ctx: &ReverseCtx) -> Result<ReverseResult> {
230 if [&ctx.category, &ctx.product, &ctx.service]
231 .into_iter()
232 .all(|value| value.as_deref().is_none_or(str::is_empty))
233 {
234 return Err(ConvertError::RuleConversion(
235 "a Sigma rule needs a logsource; set a product, category, or service".into(),
236 ));
237 }
238 let ir = frontend.parse_query(query, ctx)?;
239 let rule = raise_rule(&ir, &RaiseOptions::default())
240 .map_err(|e| ConvertError::RuleConversion(e.to_string()))?;
241 let yaml = rsigma_parser::emit_rule_yaml(&rule);
242 Ok(ReverseResult {
243 query: query.to_string(),
244 rule,
245 yaml,
246 })
247}
248
249#[derive(Debug, Clone, PartialEq)]
254enum Token {
255 LParen,
256 RParen,
257 And,
258 Or,
259 Not,
260 Atom(String),
261}
262
263fn tokenize(dialect: &QueryDialect, query: &str) -> Result<Vec<Token>> {
267 let chars: Vec<char> = query.chars().collect();
268 let mut tokens = Vec::new();
269 let mut i = 0;
270 let mut at_term_start = true;
273
274 while i < chars.len() {
275 if chars[i].is_whitespace() {
276 i += 1;
277 continue;
278 }
279 match chars[i] {
280 '(' => {
281 tokens.push(Token::LParen);
282 i += 1;
283 at_term_start = true;
284 continue;
285 }
286 ')' => {
287 tokens.push(Token::RParen);
288 i += 1;
289 at_term_start = false;
290 continue;
291 }
292 _ => {}
293 }
294
295 if let Some((token, consumed)) = match_operator(dialect, &chars, i) {
296 tokens.push(token);
297 i += consumed;
298 at_term_start = true;
299 continue;
300 }
301
302 if at_term_start && (chars[i] == '+' || chars[i] == '-') {
303 if chars[i] == '-' {
304 tokens.push(Token::Not);
305 }
306 i += 1;
307 continue;
308 }
309
310 let (atom, consumed) = read_atom(&chars, i)?;
311 if consumed == 0 {
312 return Err(ConvertError::QueryParse(format!(
313 "unexpected character '{}' at position {i}",
314 chars[i]
315 )));
316 }
317 tokens.push(Token::Atom(atom));
318 i += consumed;
319 at_term_start = false;
320 }
321
322 if tokens.is_empty() {
323 return Err(ConvertError::QueryParse("empty query".into()));
324 }
325 Ok(tokens)
326}
327
328fn match_operator(dialect: &QueryDialect, chars: &[char], i: usize) -> Option<(Token, usize)> {
329 for (token, list) in [
330 (Token::And, dialect.and_tokens),
331 (Token::Or, dialect.or_tokens),
332 (Token::Not, dialect.not_tokens),
333 ] {
334 for &candidate in list {
335 if token_matches(chars, i, candidate) {
336 return Some((token.clone(), candidate.chars().count()));
337 }
338 }
339 }
340 None
341}
342
343fn token_matches(chars: &[char], i: usize, token: &str) -> bool {
346 let token_chars: Vec<char> = token.chars().collect();
347 if i + token_chars.len() > chars.len() {
348 return false;
349 }
350 if chars[i..i + token_chars.len()] != token_chars[..] {
351 return false;
352 }
353 if token_chars.iter().all(|c| c.is_ascii_alphabetic()) {
354 match chars.get(i + token_chars.len()) {
355 None => true,
356 Some(c) => c.is_whitespace() || *c == '(' || *c == ')',
357 }
358 } else {
359 true
360 }
361}
362
363fn read_atom(chars: &[char], start: usize) -> Result<(String, usize)> {
366 let mut out = String::new();
367 let mut i = start;
368 while i < chars.len() {
369 let c = chars[i];
370 if c.is_whitespace() || c == ')' {
371 break;
372 }
373 match c {
374 '(' if out.ends_with(':') => {
375 let (group, consumed) = read_balanced(chars, i, '(', ')')?;
376 out.push_str(&group);
377 i += consumed;
378 }
379 '(' => break,
380 '"' | '\'' => {
381 let (quoted, consumed) = read_quoted(chars, i, c)?;
382 out.push_str("ed);
383 i += consumed;
384 }
385 '/' if out.ends_with(':') => {
386 let (regex, consumed) = read_quoted(chars, i, '/')?;
387 out.push_str(®ex);
388 i += consumed;
389 }
390 '[' => {
391 let (range, consumed) = read_balanced(chars, i, '[', ']')?;
392 out.push_str(&range);
393 i += consumed;
394 }
395 '{' => {
396 let (range, consumed) = read_balanced(chars, i, '{', '}')?;
397 out.push_str(&range);
398 i += consumed;
399 }
400 '\\' => {
401 out.push('\\');
402 i += 1;
403 if i < chars.len() {
404 out.push(chars[i]);
405 i += 1;
406 }
407 }
408 other => {
409 out.push(other);
410 i += 1;
411 }
412 }
413 }
414 Ok((out, i - start))
415}
416
417fn read_quoted(chars: &[char], start: usize, delim: char) -> Result<(String, usize)> {
419 let mut out = String::new();
420 out.push(chars[start]);
421 let mut i = start + 1;
422 while i < chars.len() {
423 let c = chars[i];
424 out.push(c);
425 i += 1;
426 if c == '\\' && i < chars.len() {
427 out.push(chars[i]);
428 i += 1;
429 continue;
430 }
431 if c == delim {
432 return Ok((out, i - start));
433 }
434 }
435 Err(ConvertError::QueryParse(format!(
436 "unterminated {delim}-delimited value"
437 )))
438}
439
440fn read_balanced(chars: &[char], start: usize, open: char, close: char) -> Result<(String, usize)> {
442 let mut out = String::new();
443 let mut depth = 0usize;
444 let mut i = start;
445 while i < chars.len() {
446 let c = chars[i];
447 if c == '"' || c == '\'' {
448 let (quoted, consumed) = read_quoted(chars, i, c)?;
449 out.push_str("ed);
450 i += consumed;
451 continue;
452 }
453 out.push(c);
454 i += 1;
455 if c == open {
456 depth += 1;
457 } else if c == close {
458 depth -= 1;
459 if depth == 0 {
460 return Ok((out, i - start));
461 }
462 }
463 }
464 Err(ConvertError::QueryParse(format!(
465 "unbalanced '{open}{close}' group"
466 )))
467}
468
469struct Parser<'a> {
474 tokens: Vec<Token>,
475 pos: usize,
476 dialect: &'a QueryDialect,
477}
478
479fn parse_boolean(dialect: &QueryDialect, tokens: Vec<Token>) -> Result<QueryExpr<String>> {
480 let mut parser = Parser {
481 tokens,
482 pos: 0,
483 dialect,
484 };
485 let expr = parser.parse_or()?;
486 if parser.pos != parser.tokens.len() {
487 return Err(ConvertError::QueryParse(
488 "unexpected trailing tokens (check parentheses)".into(),
489 ));
490 }
491 Ok(expr)
492}
493
494impl Parser<'_> {
495 fn peek(&self) -> Option<&Token> {
496 self.tokens.get(self.pos)
497 }
498
499 fn starts_term(&self) -> bool {
500 matches!(
501 self.peek(),
502 Some(Token::Atom(_)) | Some(Token::LParen) | Some(Token::Not)
503 )
504 }
505
506 fn parse_or(&mut self) -> Result<QueryExpr<String>> {
507 let mut nodes = vec![self.parse_and()?];
508 loop {
509 if matches!(self.peek(), Some(Token::Or)) {
510 self.pos += 1;
511 nodes.push(self.parse_and()?);
512 } else if !self.dialect.implicit_and && self.starts_term() {
513 nodes.push(self.parse_and()?);
514 } else {
515 break;
516 }
517 }
518 Ok(collapse(QueryExpr::Or, nodes))
519 }
520
521 fn parse_and(&mut self) -> Result<QueryExpr<String>> {
522 let mut nodes = vec![self.parse_not()?];
523 loop {
524 if matches!(self.peek(), Some(Token::And)) {
525 self.pos += 1;
526 nodes.push(self.parse_not()?);
527 } else if self.dialect.implicit_and && self.starts_term() {
528 nodes.push(self.parse_not()?);
529 } else {
530 break;
531 }
532 }
533 Ok(collapse(QueryExpr::And, nodes))
534 }
535
536 fn parse_not(&mut self) -> Result<QueryExpr<String>> {
537 if matches!(self.peek(), Some(Token::Not)) {
538 self.pos += 1;
539 Ok(QueryExpr::Not(Box::new(self.parse_not()?)))
540 } else {
541 self.parse_primary()
542 }
543 }
544
545 fn parse_primary(&mut self) -> Result<QueryExpr<String>> {
546 match self.peek() {
547 Some(Token::LParen) => {
548 self.pos += 1;
549 let inner = self.parse_or()?;
550 match self.peek() {
551 Some(Token::RParen) => {
552 self.pos += 1;
553 Ok(inner)
554 }
555 _ => Err(ConvertError::QueryParse("missing closing ')'".into())),
556 }
557 }
558 Some(Token::Atom(_)) => {
559 let Some(Token::Atom(atom)) = self.tokens.get(self.pos).cloned() else {
560 unreachable!()
561 };
562 self.pos += 1;
563 Ok(QueryExpr::Leaf(atom))
564 }
565 Some(other) => Err(ConvertError::QueryParse(format!(
566 "unexpected token: {other:?}"
567 ))),
568 None => Err(ConvertError::QueryParse("unexpected end of query".into())),
569 }
570 }
571}
572
573fn collapse<L>(
574 ctor: fn(Vec<QueryExpr<L>>) -> QueryExpr<L>,
575 mut nodes: Vec<QueryExpr<L>>,
576) -> QueryExpr<L> {
577 if nodes.len() == 1 {
578 nodes.pop().unwrap()
579 } else {
580 ctor(nodes)
581 }
582}
583
584pub fn assemble_rule(expr: QueryExpr<QueryLeaf>, ctx: &ReverseCtx) -> Result<IrRule> {
593 let mut asm = Assembler::default();
594 let condition = asm.build(expr, "selection");
595 Ok(IrRule {
596 metadata: ctx.metadata(),
597 logsource: ctx.logsource(),
598 sigma_version: None,
599 detections: asm.detections,
600 conditions: vec![condition],
601 })
602}
603
604#[derive(Default)]
605struct Assembler {
606 detections: HashMap<String, IrDetection>,
607 counters: HashMap<&'static str, usize>,
608}
609
610impl Assembler {
611 fn name(&mut self, prefix: &'static str) -> String {
612 let counter = self.counters.entry(prefix).or_insert(0);
613 let name = if *counter == 0 {
614 prefix.to_string()
615 } else {
616 format!("{prefix}_{counter}")
617 };
618 *counter += 1;
619 name
620 }
621
622 fn add(&mut self, prefix: &'static str, detection: IrDetection) -> IrCondition {
623 let name = self.name(prefix);
624 self.detections.insert(name.clone(), detection);
625 IrCondition::Detection(name)
626 }
627
628 fn build(&mut self, expr: QueryExpr<QueryLeaf>, prefix: &'static str) -> IrCondition {
629 match expr {
630 QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) => {
631 self.add(prefix, IrDetection::AllOf(vec![field_item(field, matcher)]))
632 }
633 QueryExpr::Leaf(QueryLeaf::Keyword(matcher)) => {
634 self.add("keywords", IrDetection::Keywords(matcher))
635 }
636 QueryExpr::And(children) => self.build_and(children, prefix),
637 QueryExpr::Or(children) => self.build_or(children, prefix),
638 QueryExpr::Not(inner) => IrCondition::Not(Box::new(self.build(*inner, "filter"))),
639 }
640 }
641
642 fn build_and(
643 &mut self,
644 children: Vec<QueryExpr<QueryLeaf>>,
645 prefix: &'static str,
646 ) -> IrCondition {
647 let mut field_items = Vec::new();
648 let mut others = Vec::new();
649 for child in children {
650 match child {
651 QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) => {
652 field_items.push(field_item(field, matcher));
653 }
654 other => others.push(other),
655 }
656 }
657
658 let mut conditions = Vec::new();
659 if !field_items.is_empty() {
660 conditions.push(self.add(prefix, IrDetection::AllOf(field_items)));
661 }
662 for other in others {
663 conditions.push(self.build(other, prefix));
664 }
665 collapse_cond(IrCondition::And, conditions)
666 }
667
668 fn build_or(
669 &mut self,
670 children: Vec<QueryExpr<QueryLeaf>>,
671 prefix: &'static str,
672 ) -> IrCondition {
673 if let Some(item) = same_field_value_list(&children) {
674 return self.add(prefix, IrDetection::AllOf(vec![item]));
675 }
676 let conditions = children
677 .into_iter()
678 .map(|c| self.build(c, prefix))
679 .collect();
680 collapse_cond(IrCondition::Or, conditions)
681 }
682}
683
684fn field_item(field: String, matcher: IrMatcher) -> IrDetectionItem {
685 let exists = match &matcher {
686 IrMatcher::Exists(b) => Some(*b),
687 _ => None,
688 };
689 IrDetectionItem {
690 field: Some(field),
691 matcher,
692 exists,
693 }
694}
695
696fn collapse_cond(
697 ctor: fn(Vec<IrCondition>) -> IrCondition,
698 mut nodes: Vec<IrCondition>,
699) -> IrCondition {
700 match nodes.len() {
701 0 => IrCondition::And(Vec::new()),
702 1 => nodes.pop().unwrap(),
703 _ => ctor(nodes),
704 }
705}
706
707fn same_field_value_list(children: &[QueryExpr<QueryLeaf>]) -> Option<IrDetectionItem> {
710 let mut field_name: Option<&str> = None;
711 let mut op_ci: Option<(IrStrOp, bool)> = None;
712 let mut matchers = Vec::with_capacity(children.len());
713
714 for child in children {
715 let QueryExpr::Leaf(QueryLeaf::Field { field, matcher }) = child else {
716 return None;
717 };
718 let IrMatcher::Str {
719 op,
720 case_insensitive,
721 ..
722 } = matcher
723 else {
724 return None;
725 };
726 match field_name {
727 None => field_name = Some(field),
728 Some(prev) if prev == field => {}
729 Some(_) => return None,
730 }
731 match op_ci {
732 None => op_ci = Some((*op, *case_insensitive)),
733 Some(prev) if prev == (*op, *case_insensitive) => {}
734 Some(_) => return None,
735 }
736 matchers.push(matcher.clone());
737 }
738
739 let field = field_name?.to_string();
740 Some(IrDetectionItem {
741 field: Some(field),
742 matcher: IrMatcher::AnyOf(matchers),
743 exists: None,
744 })
745}
746
747pub fn parse_pattern(raw: &str) -> IrPattern {
754 let sigma = SigmaString::new(raw);
755 IrPattern {
756 parts: sigma
757 .parts
758 .iter()
759 .map(|p| match p {
760 StringPart::Plain(t) => IrPatternPart::Literal(t.clone()),
761 StringPart::Special(rsigma_parser::SpecialChar::WildcardMulti) => {
762 IrPatternPart::WildcardMulti
763 }
764 StringPart::Special(rsigma_parser::SpecialChar::WildcardSingle) => {
765 IrPatternPart::WildcardSingle
766 }
767 })
768 .collect(),
769 }
770}
771
772pub fn infer_str_matcher(raw: &str, case_insensitive: bool) -> IrMatcher {
776 let pattern = parse_pattern(raw);
777 let parts = &pattern.parts;
778 let lead = matches!(parts.first(), Some(IrPatternPart::WildcardMulti));
779 let trail = parts.len() > 1 && matches!(parts.last(), Some(IrPatternPart::WildcardMulti));
780
781 let inner = &parts[lead as usize..parts.len() - trail as usize];
782 let inner_has_wildcard = inner.iter().any(|p| {
783 matches!(
784 p,
785 IrPatternPart::WildcardMulti | IrPatternPart::WildcardSingle
786 )
787 });
788
789 if !inner.is_empty() && !inner_has_wildcard {
790 let pattern = IrPattern {
791 parts: inner.to_vec(),
792 };
793 let op = match (lead, trail) {
794 (true, true) => Some(IrStrOp::Contains),
795 (false, true) => Some(IrStrOp::StartsWith),
796 (true, false) => Some(IrStrOp::EndsWith),
797 (false, false) => None,
798 };
799 if let Some(op) = op {
800 return IrMatcher::Str {
801 op,
802 pattern,
803 case_insensitive,
804 };
805 }
806 }
807
808 IrMatcher::Str {
809 op: IrStrOp::Exact,
810 pattern,
811 case_insensitive,
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818
819 fn ctx() -> ReverseCtx {
820 ReverseCtx {
821 title: Some("T".into()),
822 product: Some("windows".into()),
823 ..Default::default()
824 }
825 }
826
827 fn yaml(query: &str) -> String {
828 let frontend = LuceneFrontend;
829 convert_one(&frontend, query, &ctx())
830 .expect("converts")
831 .yaml
832 }
833
834 #[test]
835 fn requires_a_logsource() {
836 for ctx in [
837 ReverseCtx {
838 title: Some("T".into()),
839 ..Default::default()
840 },
841 ReverseCtx {
842 title: Some("T".into()),
843 product: Some(String::new()),
844 ..Default::default()
845 },
846 ] {
847 let err = convert_one(&LuceneFrontend, "EventID:1", &ctx).unwrap_err();
848 assert!(err.to_string().contains("needs a logsource"), "{err}");
849 }
850 }
851
852 #[test]
853 fn infers_string_operators_from_wildcards() {
854 assert!(matches!(
855 infer_str_matcher("*foo*", true),
856 IrMatcher::Str {
857 op: IrStrOp::Contains,
858 ..
859 }
860 ));
861 assert!(matches!(
862 infer_str_matcher("foo*", true),
863 IrMatcher::Str {
864 op: IrStrOp::StartsWith,
865 ..
866 }
867 ));
868 assert!(matches!(
869 infer_str_matcher("*foo", true),
870 IrMatcher::Str {
871 op: IrStrOp::EndsWith,
872 ..
873 }
874 ));
875 assert!(matches!(
876 infer_str_matcher("foo", true),
877 IrMatcher::Str {
878 op: IrStrOp::Exact,
879 ..
880 }
881 ));
882 }
883
884 #[test]
885 fn and_of_fields_merges_into_one_selection() {
886 let out = yaml("Image:*\\\\cmd.exe AND CommandLine:*whoami*");
887 assert!(out.contains("selection:"), "{out}");
888 assert!(out.contains("Image|endswith:"), "{out}");
889 assert!(out.contains("CommandLine|contains:"), "{out}");
890 assert!(out.contains("condition: selection"), "{out}");
891 }
892
893 #[test]
894 fn not_becomes_filter_selection() {
895 let out = yaml("EventID:1 AND NOT User:SYSTEM");
896 assert!(out.contains("filter:"), "{out}");
897 assert!(out.contains("condition: selection and not filter"), "{out}");
898 }
899
900 #[test]
901 fn same_field_or_collapses_to_value_list() {
902 let out = yaml("Image:*\\\\a.exe OR Image:*\\\\b.exe");
903 assert!(out.contains("Image|endswith:"), "{out}");
904 assert!(
906 out.contains("- '\\a.exe'") || out.contains("- '\\\\a.exe'"),
907 "{out}"
908 );
909 assert!(out.contains("condition: selection"), "{out}");
910 }
911}