1use rsigma_ir::{IrMatcher, IrNumber, IrPattern, IrPatternPart};
19
20use crate::error::{ConvertError, Result};
21
22use super::{
23 Frontend, QueryDialect, QueryExpr, QueryLeaf, ReverseCtx, infer_str_matcher, parse_pattern,
24};
25
26pub const LUCENE_DIALECT: QueryDialect = QueryDialect {
28 name: "lucene",
29 and_tokens: &["AND", "&&"],
30 or_tokens: &["OR", "||"],
31 not_tokens: &["NOT", "!"],
32 implicit_and: true,
33};
34
35#[derive(Debug, Default, Clone, Copy)]
37pub struct LuceneFrontend;
38
39impl Frontend for LuceneFrontend {
40 fn name(&self) -> &str {
41 LUCENE_DIALECT.name
42 }
43
44 fn dialect(&self) -> &QueryDialect {
45 &LUCENE_DIALECT
46 }
47
48 fn parse_atom(&self, atom: &str, ctx: &ReverseCtx) -> Result<QueryExpr<QueryLeaf>> {
49 if let Some((field, value)) = split_field(atom) {
50 if field == "_exists_" {
51 return Ok(leaf(field_leaf(unquote(value), IrMatcher::Exists(true))));
52 }
53 if is_field_name(field) {
54 return parse_field(field, value.trim(), ctx);
55 }
56 }
57 Ok(leaf(QueryLeaf::Keyword(keyword_matcher(atom)?)))
58 }
59}
60
61fn parse_field(field: &str, value: &str, ctx: &ReverseCtx) -> Result<QueryExpr<QueryLeaf>> {
66 if let Some(inner) = strip_pair(value, '(', ')') {
67 return parse_value_group(field, inner, ctx);
68 }
69 if value.starts_with('[') || value.starts_with('{') {
70 return parse_range(field, value);
71 }
72 if let Some(pattern) = strip_pair(value, '/', '/') {
73 return Ok(leaf(field_leaf(
74 field.to_string(),
75 IrMatcher::Regex {
76 pattern: regex_unescape(pattern),
77 case_insensitive: false,
78 multiline: false,
79 dotall: false,
80 cased: false,
81 },
82 )));
83 }
84 if let Some(matcher) = parse_comparison(value)? {
85 return Ok(leaf(field_leaf(field.to_string(), matcher)));
86 }
87 Ok(leaf(field_leaf(field.to_string(), scalar_matcher(value)?)))
88}
89
90fn parse_value_group(field: &str, inner: &str, _ctx: &ReverseCtx) -> Result<QueryExpr<QueryLeaf>> {
93 let tokens = super::tokenize(&LUCENE_DIALECT, inner)?;
94 let tree = super::parse_boolean(&LUCENE_DIALECT, tokens)?;
95 tree.expand(&mut |atom: String| {
96 Ok(QueryExpr::Leaf(field_leaf(
97 field.to_string(),
98 scalar_matcher(&atom)?,
99 )))
100 })
101}
102
103fn parse_range(field: &str, value: &str) -> Result<QueryExpr<QueryLeaf>> {
105 let open = value.chars().next().unwrap_or('[');
106 let close = value.chars().last().unwrap_or(']');
107 let inner = &value[1..value.len().saturating_sub(1)];
108
109 let (low, high) = split_range(inner).ok_or_else(|| {
110 ConvertError::QueryParse(format!("range must be '[low TO high]', got '{value}'"))
111 })?;
112
113 let inclusive_low = open == '[';
114 let inclusive_high = close == ']';
115
116 let mut bounds = Vec::new();
117 if low != "*" {
118 let n = range_number(low)?;
119 bounds.push(if inclusive_low {
120 IrMatcher::NumericGte(n)
121 } else {
122 IrMatcher::NumericGt(n)
123 });
124 }
125 if high != "*" {
126 let n = range_number(high)?;
127 bounds.push(if inclusive_high {
128 IrMatcher::NumericLte(n)
129 } else {
130 IrMatcher::NumericLt(n)
131 });
132 }
133
134 match bounds.len() {
135 0 => Ok(leaf(field_leaf(field.to_string(), IrMatcher::Exists(true)))),
136 1 => Ok(leaf(field_leaf(field.to_string(), bounds.pop().unwrap()))),
137 _ => Ok(QueryExpr::And(
138 bounds
139 .into_iter()
140 .map(|m| leaf(field_leaf(field.to_string(), m)))
141 .collect(),
142 )),
143 }
144}
145
146fn split_range(inner: &str) -> Option<(&str, &str)> {
147 for sep in [" TO ", " to "] {
148 if let Some(idx) = inner.find(sep) {
149 let low = inner[..idx].trim();
150 let high = inner[idx + sep.len()..].trim();
151 return Some((low, high));
152 }
153 }
154 None
155}
156
157fn range_number(s: &str) -> Result<IrNumber> {
158 s.parse::<f64>().map(IrNumber::Literal).map_err(|_| {
159 ConvertError::UnsupportedConstruct(format!(
160 "non-numeric range bound '{s}' has no Sigma equivalent"
161 ))
162 })
163}
164
165fn parse_comparison(value: &str) -> Result<Option<IrMatcher>> {
166 let (ctor, rest): (fn(IrNumber) -> IrMatcher, &str) = if let Some(r) = value.strip_prefix(">=")
167 {
168 (IrMatcher::NumericGte, r)
169 } else if let Some(r) = value.strip_prefix("<=") {
170 (IrMatcher::NumericLte, r)
171 } else if let Some(r) = value.strip_prefix('>') {
172 (IrMatcher::NumericGt, r)
173 } else if let Some(r) = value.strip_prefix('<') {
174 (IrMatcher::NumericLt, r)
175 } else {
176 return Ok(None);
177 };
178 let n = rest.trim().parse::<f64>().map_err(|_| {
179 ConvertError::UnsupportedConstruct(format!("non-numeric comparison bound '{rest}'"))
180 })?;
181 Ok(Some(ctor(IrNumber::Literal(n))))
182}
183
184fn scalar_matcher(value: &str) -> Result<IrMatcher> {
186 if let Some(inner) = quoted_inner(value) {
187 return Ok(IrMatcher::Str {
188 op: rsigma_ir::IrStrOp::Exact,
189 pattern: plain_pattern(&inner),
190 case_insensitive: true,
191 });
192 }
193
194 reject_boost_fuzzy(value)?;
195
196 match value {
197 "true" => return Ok(IrMatcher::BoolEq(true)),
198 "false" => return Ok(IrMatcher::BoolEq(false)),
199 _ => {}
200 }
201
202 let has_wildcard = has_unescaped_wildcard(value);
203 if !has_wildcard {
204 if let Ok(n) = value.parse::<i64>() {
205 return Ok(IrMatcher::NumericEq(IrNumber::Literal(n as f64)));
206 }
207 if let Ok(f) = value.parse::<f64>() {
208 return Ok(IrMatcher::NumericEq(IrNumber::Literal(f)));
209 }
210 }
211
212 Ok(infer_str_matcher(&lucene_value_to_sigma(value), true))
213}
214
215fn keyword_matcher(term: &str) -> Result<IrMatcher> {
216 if let Some(inner) = quoted_inner(term) {
217 return Ok(IrMatcher::Str {
218 op: rsigma_ir::IrStrOp::Contains,
219 pattern: plain_pattern(&inner),
220 case_insensitive: true,
221 });
222 }
223 reject_boost_fuzzy(term)?;
224 Ok(IrMatcher::Str {
225 op: rsigma_ir::IrStrOp::Contains,
226 pattern: parse_pattern(&lucene_value_to_sigma(term)),
227 case_insensitive: true,
228 })
229}
230
231fn leaf(l: QueryLeaf) -> QueryExpr<QueryLeaf> {
236 QueryExpr::Leaf(l)
237}
238
239fn field_leaf(field: String, matcher: IrMatcher) -> QueryLeaf {
240 QueryLeaf::Field { field, matcher }
241}
242
243fn plain_pattern(s: &str) -> IrPattern {
244 IrPattern {
245 parts: if s.is_empty() {
246 Vec::new()
247 } else {
248 vec![IrPatternPart::Literal(s.to_string())]
249 },
250 }
251}
252
253fn split_field(atom: &str) -> Option<(&str, &str)> {
255 let chars: Vec<(usize, char)> = atom.char_indices().collect();
256 let mut depth = 0usize;
257 let mut quote: Option<char> = None;
258 for (byte_idx, c) in &chars {
259 match quote {
260 Some(q) => {
261 if *c == q {
262 quote = None;
263 }
264 }
265 None => match c {
266 '"' | '\'' => quote = Some(*c),
267 '(' | '[' | '{' => depth += 1,
268 ')' | ']' | '}' => depth = depth.saturating_sub(1),
269 ':' if depth == 0 => {
270 return Some((&atom[..*byte_idx], &atom[byte_idx + 1..]));
271 }
272 _ => {}
273 },
274 }
275 }
276 None
277}
278
279fn is_field_name(name: &str) -> bool {
280 let mut chars = name.chars();
281 match chars.next() {
282 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '@' => {}
283 _ => return false,
284 }
285 name.chars()
286 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-' | '@'))
287}
288
289fn strip_pair(value: &str, open: char, close: char) -> Option<&str> {
290 let mut chars = value.chars();
291 if chars.next()? != open {
292 return None;
293 }
294 if value.chars().count() < 2 || !value.ends_with(close) {
295 return None;
296 }
297 let start = open.len_utf8();
298 let end = value.len() - close.len_utf8();
299 Some(&value[start..end])
300}
301
302fn quoted_inner(value: &str) -> Option<String> {
303 for q in ['"', '\''] {
304 if value.len() >= 2 && value.starts_with(q) && value.ends_with(q) {
305 let inner = &value[1..value.len() - 1];
306 let mut out = String::with_capacity(inner.len());
309 let mut chars = inner.chars();
310 while let Some(c) = chars.next() {
311 if c == '\\' {
312 if let Some(next) = chars.next() {
313 out.push(next);
314 }
315 } else {
316 out.push(c);
317 }
318 }
319 return Some(out);
320 }
321 }
322 None
323}
324
325fn unquote(value: &str) -> String {
326 quoted_inner(value).unwrap_or_else(|| value.to_string())
327}
328
329fn has_unescaped_wildcard(value: &str) -> bool {
330 let mut chars = value.chars().peekable();
331 while let Some(c) = chars.next() {
332 match c {
333 '\\' => {
334 chars.next();
335 }
336 '*' | '?' => return true,
337 _ => {}
338 }
339 }
340 false
341}
342
343fn reject_boost_fuzzy(value: &str) -> Result<()> {
344 let mut chars = value.chars();
345 while let Some(c) = chars.next() {
346 match c {
347 '\\' => {
348 chars.next();
349 }
350 '^' => {
351 return Err(ConvertError::UnsupportedConstruct(
352 "term boosting (^) has no Sigma equivalent".into(),
353 ));
354 }
355 '~' => {
356 return Err(ConvertError::UnsupportedConstruct(
357 "fuzzy/proximity (~) has no Sigma equivalent".into(),
358 ));
359 }
360 _ => {}
361 }
362 }
363 Ok(())
364}
365
366fn lucene_value_to_sigma(value: &str) -> String {
370 let mut out = String::with_capacity(value.len());
371 let chars: Vec<char> = value.chars().collect();
372 let mut i = 0;
373 while i < chars.len() {
374 if chars[i] == '\\' && i + 1 < chars.len() {
375 let next = chars[i + 1];
376 match next {
377 '*' | '?' | '\\' => {
378 out.push('\\');
379 out.push(next);
380 }
381 _ => out.push(next),
382 }
383 i += 2;
384 } else {
385 out.push(chars[i]);
386 i += 1;
387 }
388 }
389 out
390}
391
392fn regex_unescape(pattern: &str) -> String {
393 pattern.replace("\\/", "/")
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::reverse::ReverseCtx;
400
401 fn rule_yaml(query: &str) -> String {
402 let frontend = LuceneFrontend;
403 let ctx = ReverseCtx {
404 title: Some("Test".into()),
405 product: Some("windows".into()),
406 ..Default::default()
407 };
408 let ir = frontend.parse_query(query, &ctx).expect("parses");
409 let rule = rsigma_ir::raise_rule(&ir, &rsigma_ir::RaiseOptions::default()).expect("raises");
410 rsigma_parser::emit_rule_yaml(&rule)
411 }
412
413 #[test]
414 fn simple_field_equality() {
415 let out = rule_yaml("EventID:4688");
416 assert!(out.contains("EventID: 4688"), "{out}");
417 }
418
419 #[test]
420 fn wildcards_become_modifiers() {
421 let out = rule_yaml("Image:*\\\\cmd.exe");
422 assert!(out.contains("Image|endswith:"), "{out}");
423 }
424
425 #[test]
426 fn regex_maps_to_re_modifier() {
427 let out = rule_yaml("CommandLine:/a.*b/");
428 assert!(out.contains("CommandLine|re: 'a.*b'"), "{out}");
429 }
430
431 #[test]
432 fn inclusive_range_becomes_gte_lte() {
433 let out = rule_yaml("Port:[1024 TO 65535]");
434 assert!(out.contains("Port|gte: 1024"), "{out}");
435 assert!(out.contains("Port|lte: 65535"), "{out}");
436 }
437
438 #[test]
439 fn comparison_shorthand() {
440 let out = rule_yaml("Port:>=1024");
441 assert!(out.contains("Port|gte: 1024"), "{out}");
442 }
443
444 #[test]
445 fn value_group_becomes_value_list() {
446 let out = rule_yaml("Image:(\"a.exe\" OR \"b.exe\")");
447 assert!(out.contains("Image:"), "{out}");
448 assert!(out.contains("- a.exe"), "{out}");
449 assert!(out.contains("- b.exe"), "{out}");
450 }
451
452 #[test]
453 fn exists_maps_to_exists_modifier() {
454 let out = rule_yaml("_exists_:CommandLine");
455 assert!(out.contains("CommandLine|exists: true"), "{out}");
456 }
457
458 #[test]
459 fn keyword_term_becomes_keywords_selection() {
460 let out = rule_yaml("mimikatz");
461 assert!(out.contains("keywords:"), "{out}");
462 assert!(out.contains("- mimikatz"), "{out}");
463 }
464
465 #[test]
466 fn boosting_is_rejected() {
467 let frontend = LuceneFrontend;
468 let err = frontend
469 .parse_query("field:value^2", &ReverseCtx::default())
470 .unwrap_err();
471 assert!(matches!(err, ConvertError::UnsupportedConstruct(_)));
472 }
473
474 #[test]
475 fn fuzzy_is_rejected() {
476 let frontend = LuceneFrontend;
477 let err = frontend
478 .parse_query("roam~", &ReverseCtx::default())
479 .unwrap_err();
480 assert!(matches!(err, ConvertError::UnsupportedConstruct(_)));
481 }
482}