use rsigma_parser::parse_sigma_yaml;
fn rule_with_detection(detection: &str) -> String {
format!("title: T\nlogsource:\n category: test\ndetection:\n{detection}\n")
}
fn rule_with_selection(selection: &str) -> String {
let body: String = selection
.lines()
.map(|line| format!(" {line}\n"))
.collect();
rule_with_detection(&format!(" sel:\n{body} condition: sel"))
}
fn parse_error(yaml: &str) -> String {
let collection = parse_sigma_yaml(yaml).unwrap();
assert!(
collection.rules.is_empty(),
"expected a parse error for:\n{yaml}"
);
collection.errors.join("\n")
}
fn assert_parses(yaml: &str) {
let collection = parse_sigma_yaml(yaml).unwrap();
assert!(
collection.errors.is_empty() && collection.rules.len() == 1,
"expected a clean parse for:\n{yaml}\nerrors: {:?}",
collection.errors
);
}
#[test]
fn rejects_modifiers_on_values_of_the_wrong_type() {
for (selection, expected) in [
(
"F|contains: 5",
"field 'F': |contains requires a string value, got 5",
),
("F|cased: 5", "|cased requires a string value"),
("F|expand: 5", "|expand requires a string value"),
("F|fieldref: 5", "|fieldref requires a string value"),
("F|windash: 5", "|windash requires a string value"),
("F|re: 5", "|re requires a string value"),
(
"F|startswith: true",
"|startswith requires a string value, got true",
),
(
"F|endswith: null",
"|endswith requires a string value, got null",
),
("F|gt: abc", "|gt requires a numeric value, got 'abc'"),
("F|gt: '5'", "|gt requires a numeric value, got '5'"),
("F|lte: null", "|lte requires a numeric value"),
("F|minute: x", "|minute requires a numeric value, got 'x'"),
("F|hour: '3'", "|hour requires a numeric value, got '3'"),
] {
let err = parse_error(&rule_with_selection(selection));
assert!(err.contains(expected), "{selection}: {err}");
}
}
#[test]
fn rejects_invalid_values() {
for (selection, expected) in [
("F|base64: 'a*'", "do not support wildcards"),
("F|base64offset|contains: 'a?b'", "do not support wildcards"),
("F|cidr: 10.1.2.3/8", "host bits set"),
("F|cidr: not-a-network", "expected address/prefix"),
("F|re: 'a(b'", "invalid regular expression"),
("F|re: '[z-a]'", "invalid regular expression"),
("F|fieldref: 'Other*'", "must not contain wildcards"),
("F|wide: 'é'", "require an ASCII value"),
("F|exists: maybe", "|exists takes a single boolean value"),
("F|exists: 'yes'", "|exists takes a single boolean value"),
("F|exists: 'true'", "|exists takes a single boolean value"),
("F|exists: no", "|exists takes a single boolean value"),
("F|all: []", "|all requires at least one value"),
(
"F|exists: [true, false]",
"|exists takes a single boolean value",
),
("'|exists': true", "|exists must be applied to a field"),
("F: {a: b}", "'F' takes a value or a list of values"),
("F: [[a, b]]", "'F' takes a value or a list of values"),
] {
let err = parse_error(&rule_with_selection(selection));
assert!(err.contains(expected), "{selection}: {err}");
}
}
#[test]
fn rejects_conflicting_modifiers() {
for (selection, expected) in [
(
"F|lt|gt: 5",
"at most one operator may be set per field; got |gt, |lt",
),
("F|contains|re: x", "got |contains, |re"),
("F|contains|cidr: 10.0.0.0/8", "got |contains, |cidr"),
("F|re|cased: x", "got |re, |cased"),
("F|cidr|cased: 10.0.0.0/8", "got |cidr, |cased"),
("F|exists|cased: true", "got |exists, |cased"),
("F|gt|cased: 5", "got |gt, |cased"),
("F|minute|cased: 5", "got |minute, |cased"),
("F|i: x", "have no effect without |re"),
("F|i|re: x", "must follow |re"),
("F|contains|m: x", "|m have no effect without |re"),
("F|contains|fieldref: G", "|contains must follow |fieldref"),
(
"F|base64|base64offset: x",
"mutually exclusive base64 strategies",
),
("F|wide|utf16: x", "mutually exclusive UTF-16 encodings"),
("F|windash|gt: 5", "value transformations |windash"),
] {
let err = parse_error(&rule_with_selection(selection));
assert!(
err.contains("Invalid modifier combination") && err.contains(expected),
"{selection}: {err}"
);
}
}
#[test]
fn rejects_invalid_detections() {
for (detection, expected) in [
(" sel: {}\n condition: sel", "'sel' is empty"),
(" sel: []\n condition: sel", "'sel' is empty"),
(
" sel:\n F: x\n condition: []",
"condition list must not be empty",
),
(
" kw:\n - null\n condition: kw",
"'kw' uses null as a keyword",
),
(" kw:\n condition: kw", "'kw' uses null as a keyword"),
(
" sel:\n - - a\n - b\n condition: sel",
"must not contain nested lists",
),
(
" sel:\n F: x\n condition: sel and other",
"condition references unknown detection identifier 'other'",
),
(
" sel:\n F: x\n condition: 1 of filter_*",
"selector '1 of filter_*' matches no detection identifier",
),
(
" _hidden:\n F: x\n condition: all of them",
"selector 'all of them' matches no detection identifier",
),
] {
let err = parse_error(&rule_with_detection(detection));
assert!(err.contains(expected), "{detection}: {err}");
}
}
#[test]
fn checks_conditions_inside_extended_array_blocks() {
let yaml = "title: T\nsigma-version: 3\nlogsource:\n category: test\ndetection:\n sel:\n conns[any]:\n a:\n port: 80\n condition: a and b\n condition: sel\n";
let err = parse_error(yaml);
assert!(err.contains("unknown detection identifier 'b'"), "{err}");
let yaml = "title: T\nsigma-version: 3\nlogsource:\n category: test\ndetection:\n sel:\n conns[any]:\n a:\n port: 80\n condition: []\n condition: sel\n";
let err = parse_error(yaml);
assert!(
err.contains("array block 'condition' list must not be empty"),
"{err}"
);
}
#[test]
fn accepts_valid_neighbors() {
for selection in [
"F|re: '(?<!\\\\)cmd'",
"F|re: '(a)\\1'",
"F|re|i: '^cmd$'",
"F|gt: 5",
"F|gte: 1.5",
"F|hour: 3",
"F|cidr: 2001:db8::/32",
"F|exists: true",
"F|exists: false",
"F|contains|all: [a, b]",
"F|contains|all: a",
"F|fieldref|contains: G",
"F|fieldref|cased: G",
"F|cased: abc",
"F|base64: 'a\\*b'",
"F|wide|base64: 'é'",
"F: 5",
"F: [a, null]",
"F: []",
"F|neq: [1, 2]",
"F|expand: '%admins%'",
] {
assert_parses(&rule_with_selection(selection));
}
for detection in [
" kw:\n - foo\n - 5\n condition: kw",
" sel:\n - F: a\n - G: b\n condition: sel",
" sel_a:\n F: x\n _hidden:\n G: y\n condition: 1 of sel_* and not 1 of _hid*",
] {
assert_parses(&rule_with_detection(detection));
}
}
#[test]
fn filter_rules_are_validated() {
let yaml = "title: F\nlogsource:\n category: test\nfilter:\n rules: any\n sel:\n F|contains: 5\n condition: not sel\n";
let err = parse_error_any(yaml);
assert!(err.contains("|contains requires a string value"), "{err}");
let yaml = "title: F\nlogsource:\n category: test\nfilter:\n rules: any\n sel:\n F: x\n condition: not other\n";
let err = parse_error_any(yaml);
assert!(
err.contains("unknown detection identifier 'other'"),
"{err}"
);
}
fn parse_error_any(yaml: &str) -> String {
let collection = parse_sigma_yaml(yaml).unwrap();
assert!(collection.is_empty(), "expected a parse error for:\n{yaml}");
collection.errors.join("\n")
}
#[test]
fn requires_a_logsource() {
let detection = "detection:\n sel:\n F: x\n condition: sel\n";
for (logsource, expected) in [
("", "Missing required field 'logsource'"),
(
"logsource: {}\n",
"at least one of category, product, or service",
),
(
"logsource:\n definition: d\n",
"at least one of category, product, or service",
),
(
"logsource:\n category: ''\n",
"at least one of category, product, or service",
),
(
"logsource:\n category: 5\n",
"logsource category must be a string",
),
("logsource: windows\n", "logsource must be a mapping"),
] {
let err = parse_error(&format!("title: T\n{logsource}{detection}"));
assert!(err.contains(expected), "{logsource:?}: {err}");
}
for logsource in [
"logsource:\n product: windows\n",
"logsource:\n service: sysmon\n category: null\n",
] {
assert_parses(&format!("title: T\n{logsource}{detection}"));
}
let filter =
"title: F\nfilter:\n rules: any\n sel:\n F: x\n condition: not sel\n";
assert!(parse_error_any(filter).contains("Missing required field 'logsource'"));
}