use yaml_serde::Value;
use super::super::{DocType, LintRule, LintWarning, err, key, warning};
use crate::fieldpath::{ends_with_unescaped, first_unescaped};
use crate::version::{
SPEC_VERSION_ARRAY_MATCHING, SPEC_VERSION_SUPPORTED, major_from_value, resolve_major,
};
pub(crate) fn lint_sigma_version(
m: &yaml_serde::Mapping,
doc_type: DocType,
warnings: &mut Vec<LintWarning>,
) {
let declared = m.get(key("sigma-version")).and_then(major_from_value);
if let Some(major) = declared
&& major > SPEC_VERSION_SUPPORTED
{
warnings.push(err(
LintRule::UnsupportedSigmaVersion,
format!(
"sigma-version {major} is newer than the supported specification major \
{SPEC_VERSION_SUPPORTED}; upgrade the tool or target a supported version"
),
"/sigma-version",
));
}
if resolve_major(declared) < SPEC_VERSION_ARRAY_MATCHING {
let section_key = match doc_type {
DocType::Detection => "detection",
DocType::Filter => "filter",
DocType::Correlation => return,
};
if let Some(section) = m.get(key(section_key))
&& let Some(offending) = section_has_selector(section)
{
warnings.push(warning(
LintRule::ArrayMatchingWithoutVersion,
format!(
"key '{offending}' uses array-matching selector syntax, but this document \
targets specification major {} where brackets are literal field-name \
characters; add `sigma-version: {SPEC_VERSION_ARRAY_MATCHING}` to read them \
as array selectors, or escape the brackets (`\\[` / `\\]`) to keep them \
literal",
resolve_major(declared)
),
format!("/{section_key}"),
));
}
}
}
fn section_has_selector(value: &Value) -> Option<String> {
match value {
Value::Mapping(m) => {
for (k, v) in m {
if let Some(ks) = k.as_str() {
let field_part = ks.split('|').next().unwrap_or(ks);
if field_part_has_selector(field_part) {
return Some(ks.to_string());
}
}
if let Some(found) = section_has_selector(v) {
return Some(found);
}
}
None
}
Value::Sequence(seq) => seq.iter().find_map(section_has_selector),
_ => None,
}
}
fn field_part_has_selector(field_part: &str) -> bool {
field_part.split('.').any(segment_has_selector)
}
fn segment_has_selector(seg: &str) -> bool {
let Some(open) = first_unescaped(seg, b'[') else {
return false;
};
if open == 0 || !ends_with_unescaped(seg, b']') {
return false;
}
let token = &seg[open + 1..seg.len() - 1];
matches!(token, "any" | "all" | "all_or_empty" | "none") || token.parse::<i64>().is_ok()
}
#[cfg(test)]
mod tests {
use crate::lint::lint_yaml_str;
fn rule_ids(yaml: &str) -> Vec<String> {
lint_yaml_str(yaml)
.iter()
.map(|w| w.rule.to_string())
.collect()
}
#[test]
fn unsupported_major_is_error() {
let yaml = "title: T\nsigma-version: 99\nlogsource:\n category: test\ndetection:\n selection:\n a: b\n condition: selection\n";
assert!(rule_ids(yaml).contains(&"unsupported_sigma_version".to_string()));
}
#[test]
fn supported_major_no_version_findings() {
let yaml = "title: T\nsigma-version: 3\nlogsource:\n category: test\ndetection:\n selection:\n connections[any]:\n protocol: TCP\n condition: selection\n";
let ids = rule_ids(yaml);
assert!(!ids.contains(&"unsupported_sigma_version".to_string()));
assert!(!ids.contains(&"array_matching_without_version".to_string()));
}
#[test]
fn array_selector_without_version_warns() {
let yaml = "title: T\nlogsource:\n category: test\ndetection:\n selection:\n connections[any]:\n protocol: TCP\n condition: selection\n";
assert!(rule_ids(yaml).contains(&"array_matching_without_version".to_string()));
}
#[test]
fn no_selector_no_warning() {
let yaml = "title: T\nlogsource:\n category: test\ndetection:\n selection:\n CommandLine: whoami\n condition: selection\n";
assert!(!rule_ids(yaml).contains(&"array_matching_without_version".to_string()));
}
#[test]
fn sigma_version_is_a_known_key() {
let yaml = "title: T\nsigma-version: 3\nlogsource:\n category: test\ndetection:\n selection:\n a: b\n condition: selection\n";
assert!(!rule_ids(yaml).contains(&"unknown_key".to_string()));
}
}