1pub mod catalogue;
23#[cfg(feature = "fix")]
24pub mod fix;
25mod rules;
26
27use std::collections::{HashMap, HashSet};
28use std::fmt;
29use std::path::Path;
30use std::sync::LazyLock;
31
32use serde::{Deserialize, Serialize};
33use yaml_serde::Value;
34
35use crate::ads::AdsSection;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
43pub enum Severity {
44 Error,
46 Warning,
48 Info,
50 Hint,
52}
53
54impl fmt::Display for Severity {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 Severity::Error => write!(f, "error"),
58 Severity::Warning => write!(f, "warning"),
59 Severity::Info => write!(f, "info"),
60 Severity::Hint => write!(f, "hint"),
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
67pub enum LintRule {
68 YamlParseError,
70 NotAMapping,
71 FileReadError,
72 SchemaViolation,
73
74 MissingTitle,
76 EmptyTitle,
77 TitleTooLong,
78 MissingDescription,
79 MissingAuthor,
80 InvalidId,
81 InvalidStatus,
82 MissingLevel,
83 InvalidLevel,
84 InvalidDate,
85 InvalidModified,
86 ModifiedBeforeDate,
87 DescriptionTooLong,
88 NameTooLong,
89 TaxonomyTooLong,
90 NonLowercaseKey,
91
92 MissingLogsource,
94 MissingDetection,
95 MissingCondition,
96 EmptyDetection,
97 InvalidRelatedType,
98 InvalidRelatedId,
99 RelatedMissingRequired,
100 DeprecatedWithoutRelated,
101 InvalidTag,
102 UnknownTagNamespace,
103 DuplicateTags,
104 DuplicateReferences,
105 DuplicateFields,
106 FalsepositiveTooShort,
107 ScopeTooShort,
108 LogsourceValueNotLowercase,
109 ConditionReferencesUnknown,
110 DeprecatedAggregationSyntax,
111
112 MissingCorrelation,
114 MissingCorrelationType,
115 InvalidCorrelationType,
116 MissingCorrelationRules,
117 EmptyCorrelationRules,
118 MissingCorrelationTimespan,
119 InvalidTimespanFormat,
120 InvalidWindowMode,
121 MissingSessionGap,
122 GapWithoutSession,
123 InvalidGapFormat,
124 MissingGroupBy,
125 MissingCorrelationCondition,
126 MissingConditionField,
127 InvalidConditionOperator,
128 ConditionValueNotNumeric,
129 GenerateNotBoolean,
130
131 MissingFilter,
133 MissingFilterRules,
134 EmptyFilterRules,
135 MissingFilterSelection,
136 MissingFilterCondition,
137 FilterHasLevel,
138 FilterHasStatus,
139 MissingFilterLogsource,
140
141 NullInValueList,
143 SingleValueAllModifier,
144 AllWithRe,
145 IncompatibleModifiers,
146 EmptyValueList,
147 WildcardOnlyValue,
148 FlattenedArrayCorrelation,
149 UnsupportedSigmaVersion,
150 ArrayMatchingWithoutVersion,
151 SigmaVersionMismatch,
152 UnknownRuleReference,
153 UnknownKey,
154
155 AdsMissingGoal,
157 AdsMissingCategorization,
158 AdsMissingStrategy,
159 AdsMissingTechnicalContext,
160 AdsMissingBlindSpots,
161 AdsMissingFalsePositives,
162 AdsMissingValidation,
163 AdsMissingPriority,
164 AdsMissingResponse,
165 AdsEmptySection,
166 AdsUnknownSection,
167
168 ExemplarShape,
170 ExemplarWrongRuleKind,
171}
172
173impl fmt::Display for LintRule {
174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175 let s = match self {
176 LintRule::YamlParseError => "yaml_parse_error",
177 LintRule::NotAMapping => "not_a_mapping",
178 LintRule::FileReadError => "file_read_error",
179 LintRule::SchemaViolation => "schema_violation",
180 LintRule::MissingTitle => "missing_title",
181 LintRule::EmptyTitle => "empty_title",
182 LintRule::TitleTooLong => "title_too_long",
183 LintRule::MissingDescription => "missing_description",
184 LintRule::MissingAuthor => "missing_author",
185 LintRule::InvalidId => "invalid_id",
186 LintRule::InvalidStatus => "invalid_status",
187 LintRule::MissingLevel => "missing_level",
188 LintRule::InvalidLevel => "invalid_level",
189 LintRule::InvalidDate => "invalid_date",
190 LintRule::InvalidModified => "invalid_modified",
191 LintRule::ModifiedBeforeDate => "modified_before_date",
192 LintRule::DescriptionTooLong => "description_too_long",
193 LintRule::NameTooLong => "name_too_long",
194 LintRule::TaxonomyTooLong => "taxonomy_too_long",
195 LintRule::NonLowercaseKey => "non_lowercase_key",
196 LintRule::MissingLogsource => "missing_logsource",
197 LintRule::MissingDetection => "missing_detection",
198 LintRule::MissingCondition => "missing_condition",
199 LintRule::EmptyDetection => "empty_detection",
200 LintRule::InvalidRelatedType => "invalid_related_type",
201 LintRule::InvalidRelatedId => "invalid_related_id",
202 LintRule::RelatedMissingRequired => "related_missing_required",
203 LintRule::DeprecatedWithoutRelated => "deprecated_without_related",
204 LintRule::InvalidTag => "invalid_tag",
205 LintRule::UnknownTagNamespace => "unknown_tag_namespace",
206 LintRule::DuplicateTags => "duplicate_tags",
207 LintRule::DuplicateReferences => "duplicate_references",
208 LintRule::DuplicateFields => "duplicate_fields",
209 LintRule::FalsepositiveTooShort => "falsepositive_too_short",
210 LintRule::ScopeTooShort => "scope_too_short",
211 LintRule::LogsourceValueNotLowercase => "logsource_value_not_lowercase",
212 LintRule::ConditionReferencesUnknown => "condition_references_unknown",
213 LintRule::DeprecatedAggregationSyntax => "deprecated_aggregation_syntax",
214 LintRule::MissingCorrelation => "missing_correlation",
215 LintRule::MissingCorrelationType => "missing_correlation_type",
216 LintRule::InvalidCorrelationType => "invalid_correlation_type",
217 LintRule::MissingCorrelationRules => "missing_correlation_rules",
218 LintRule::EmptyCorrelationRules => "empty_correlation_rules",
219 LintRule::MissingCorrelationTimespan => "missing_correlation_timespan",
220 LintRule::InvalidTimespanFormat => "invalid_timespan_format",
221 LintRule::InvalidWindowMode => "invalid_window_mode",
222 LintRule::MissingSessionGap => "missing_session_gap",
223 LintRule::GapWithoutSession => "gap_without_session",
224 LintRule::InvalidGapFormat => "invalid_gap_format",
225 LintRule::MissingGroupBy => "missing_group_by",
226 LintRule::MissingCorrelationCondition => "missing_correlation_condition",
227 LintRule::MissingConditionField => "missing_condition_field",
228 LintRule::InvalidConditionOperator => "invalid_condition_operator",
229 LintRule::ConditionValueNotNumeric => "condition_value_not_numeric",
230 LintRule::GenerateNotBoolean => "generate_not_boolean",
231 LintRule::MissingFilter => "missing_filter",
232 LintRule::MissingFilterRules => "missing_filter_rules",
233 LintRule::EmptyFilterRules => "empty_filter_rules",
234 LintRule::MissingFilterSelection => "missing_filter_selection",
235 LintRule::MissingFilterCondition => "missing_filter_condition",
236 LintRule::FilterHasLevel => "filter_has_level",
237 LintRule::FilterHasStatus => "filter_has_status",
238 LintRule::MissingFilterLogsource => "missing_filter_logsource",
239 LintRule::NullInValueList => "null_in_value_list",
240 LintRule::SingleValueAllModifier => "single_value_all_modifier",
241 LintRule::AllWithRe => "all_with_re",
242 LintRule::IncompatibleModifiers => "incompatible_modifiers",
243 LintRule::EmptyValueList => "empty_value_list",
244 LintRule::WildcardOnlyValue => "wildcard_only_value",
245 LintRule::FlattenedArrayCorrelation => "flattened_array_correlation",
246 LintRule::UnsupportedSigmaVersion => "unsupported_sigma_version",
247 LintRule::ArrayMatchingWithoutVersion => "array_matching_without_version",
248 LintRule::SigmaVersionMismatch => "sigma_version_mismatch",
249 LintRule::UnknownRuleReference => "unknown_rule_reference",
250 LintRule::UnknownKey => "unknown_key",
251 LintRule::AdsMissingGoal => "ads_missing_goal",
252 LintRule::AdsMissingCategorization => "ads_missing_categorization",
253 LintRule::AdsMissingStrategy => "ads_missing_strategy",
254 LintRule::AdsMissingTechnicalContext => "ads_missing_technical_context",
255 LintRule::AdsMissingBlindSpots => "ads_missing_blind_spots",
256 LintRule::AdsMissingFalsePositives => "ads_missing_false_positives",
257 LintRule::AdsMissingValidation => "ads_missing_validation",
258 LintRule::AdsMissingPriority => "ads_missing_priority",
259 LintRule::AdsMissingResponse => "ads_missing_response",
260 LintRule::AdsEmptySection => "ads_empty_section",
261 LintRule::AdsUnknownSection => "ads_unknown_section",
262 LintRule::ExemplarShape => "exemplar_shape",
263 LintRule::ExemplarWrongRuleKind => "exemplar_wrong_rule_kind",
264 };
265 write!(f, "{s}")
266 }
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
271pub struct Span {
272 pub start_line: u32,
273 pub start_col: u32,
274 pub end_line: u32,
275 pub end_col: u32,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
284pub enum FixDisposition {
285 Safe,
286 Unsafe,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub enum FixPatch {
292 ReplaceValue { path: String, new_value: String },
293 ReplaceKey { path: String, new_key: String },
294 Remove { path: String },
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
299pub struct Fix {
300 pub title: String,
301 pub disposition: FixDisposition,
302 pub patches: Vec<FixPatch>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
307pub struct LintWarning {
308 pub rule: LintRule,
309 pub severity: Severity,
310 pub message: String,
311 pub path: String,
312 pub span: Option<Span>,
313 pub fix: Option<Fix>,
314}
315
316impl fmt::Display for LintWarning {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 write!(
319 f,
320 "{}[{}]: {}\n --> {}",
321 self.severity, self.rule, self.message, self.path
322 )
323 }
324}
325
326#[derive(Debug, Clone, Serialize)]
328pub struct FileLintResult {
329 pub path: std::path::PathBuf,
330 pub warnings: Vec<LintWarning>,
331}
332
333impl FileLintResult {
334 pub fn has_errors(&self) -> bool {
335 self.warnings.iter().any(|w| w.severity == Severity::Error)
336 }
337
338 pub fn error_count(&self) -> usize {
339 self.warnings
340 .iter()
341 .filter(|w| w.severity == Severity::Error)
342 .count()
343 }
344
345 pub fn warning_count(&self) -> usize {
346 self.warnings
347 .iter()
348 .filter(|w| w.severity == Severity::Warning)
349 .count()
350 }
351
352 pub fn info_count(&self) -> usize {
353 self.warnings
354 .iter()
355 .filter(|w| w.severity == Severity::Info)
356 .count()
357 }
358
359 pub fn hint_count(&self) -> usize {
360 self.warnings
361 .iter()
362 .filter(|w| w.severity == Severity::Hint)
363 .count()
364 }
365}
366
367static KEY_CACHE: LazyLock<HashMap<&'static str, Value>> = LazyLock::new(|| {
372 [
373 "action",
374 "author",
375 "category",
376 "condition",
377 "correlation",
378 "custom_attributes",
379 "date",
380 "description",
381 "detection",
382 "falsepositives",
383 "field",
384 "fields",
385 "filter",
386 "gap",
387 "generate",
388 "group-by",
389 "id",
390 "level",
391 "logsource",
392 "modified",
393 "name",
394 "product",
395 "references",
396 "related",
397 "rsigma.gap",
398 "rsigma.window",
399 "rules",
400 "scope",
401 "selection",
402 "service",
403 "sigma-version",
404 "status",
405 "tags",
406 "taxonomy",
407 "timeframe",
408 "timespan",
409 "title",
410 "type",
411 "window",
412 ]
413 .into_iter()
414 .map(|n| (n, Value::String(n.into())))
415 .collect()
416});
417
418pub(crate) fn key(s: &str) -> &'static Value {
419 KEY_CACHE
420 .get(s)
421 .unwrap_or_else(|| panic!("lint key not pre-cached: \"{s}\" — add it to KEY_CACHE"))
422}
423
424pub(crate) fn get_str<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a str> {
425 m.get(key(k)).and_then(|v| v.as_str())
426}
427
428pub(crate) fn get_mapping<'a>(
429 m: &'a yaml_serde::Mapping,
430 k: &str,
431) -> Option<&'a yaml_serde::Mapping> {
432 m.get(key(k)).and_then(|v| v.as_mapping())
433}
434
435pub(crate) fn get_seq<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a yaml_serde::Sequence> {
436 m.get(key(k)).and_then(|v| v.as_sequence())
437}
438
439pub(crate) fn warn(
440 rule: LintRule,
441 severity: Severity,
442 message: impl Into<String>,
443 path: impl Into<String>,
444) -> LintWarning {
445 LintWarning {
446 rule,
447 severity,
448 message: message.into(),
449 path: path.into(),
450 span: None,
451 fix: None,
452 }
453}
454
455pub(crate) fn err(
456 rule: LintRule,
457 message: impl Into<String>,
458 path: impl Into<String>,
459) -> LintWarning {
460 warn(rule, Severity::Error, message, path)
461}
462
463pub(crate) fn warning(
464 rule: LintRule,
465 message: impl Into<String>,
466 path: impl Into<String>,
467) -> LintWarning {
468 warn(rule, Severity::Warning, message, path)
469}
470
471pub(crate) fn info(
472 rule: LintRule,
473 message: impl Into<String>,
474 path: impl Into<String>,
475) -> LintWarning {
476 warn(rule, Severity::Info, message, path)
477}
478
479pub(crate) fn safe_fix(title: impl Into<String>, patches: Vec<FixPatch>) -> Option<Fix> {
480 Some(Fix {
481 title: title.into(),
482 disposition: FixDisposition::Safe,
483 patches,
484 })
485}
486
487pub(crate) fn closest_match<'a>(
489 input: &str,
490 candidates: &[&'a str],
491 max_distance: usize,
492) -> Option<&'a str> {
493 candidates
494 .iter()
495 .filter(|c| edit_distance(input, c) <= max_distance)
496 .min_by_key(|c| edit_distance(input, c))
497 .copied()
498}
499
500pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
502 let (a_len, b_len) = (a.len(), b.len());
503 if a_len == 0 {
504 return b_len;
505 }
506 if b_len == 0 {
507 return a_len;
508 }
509 let mut prev: Vec<usize> = (0..=b_len).collect();
510 let mut curr = vec![0; b_len + 1];
511 for (i, ca) in a.bytes().enumerate() {
512 curr[0] = i + 1;
513 for (j, cb) in b.bytes().enumerate() {
514 let cost = if ca == cb { 0 } else { 1 };
515 curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
516 }
517 std::mem::swap(&mut prev, &mut curr);
518 }
519 prev[b_len]
520}
521
522pub(crate) const TYPO_MAX_EDIT_DISTANCE: usize = 2;
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub(crate) enum DocType {
530 Detection,
531 Correlation,
532 Filter,
533}
534
535impl DocType {
536 pub(crate) fn known_keys(&self) -> &'static [&'static str] {
537 match self {
538 DocType::Detection => rules::shared::KNOWN_KEYS_DETECTION,
539 DocType::Correlation => rules::shared::KNOWN_KEYS_CORRELATION,
540 DocType::Filter => rules::shared::KNOWN_KEYS_FILTER,
541 }
542 }
543}
544
545fn detect_doc_type(m: &yaml_serde::Mapping) -> DocType {
546 if m.contains_key(key("correlation")) {
547 DocType::Correlation
548 } else if m.contains_key(key("filter")) {
549 DocType::Filter
550 } else {
551 DocType::Detection
552 }
553}
554
555fn is_action_fragment(m: &yaml_serde::Mapping) -> bool {
556 matches!(get_str(m, "action"), Some("global" | "reset" | "repeat"))
557}
558
559struct RuleIndex {
568 majors: HashMap<String, u32>,
569 complete: bool,
573}
574
575impl RuleIndex {
576 fn new(complete: bool) -> Self {
577 Self {
578 majors: HashMap::new(),
579 complete,
580 }
581 }
582
583 fn add_text(&mut self, text: &str) {
585 for doc in yaml_serde::Deserializer::from_str(text) {
586 let Ok(value) = Value::deserialize(doc) else {
587 break;
588 };
589 self.add_value(&value);
590 }
591 }
592
593 fn add_value(&mut self, value: &Value) {
594 let Some(m) = value.as_mapping() else {
595 return;
596 };
597 if is_action_fragment(m) {
598 return;
599 }
600 if matches!(
602 detect_doc_type(m),
603 DocType::Detection | DocType::Correlation
604 ) {
605 let major = crate::version::resolve_major(
606 m.get(key("sigma-version"))
607 .and_then(crate::version::major_from_value),
608 );
609 for id_key in ["id", "name"] {
610 if let Some(v) = get_str(m, id_key) {
611 self.majors.insert(v.to_string(), major);
612 }
613 }
614 }
615 }
616}
617
618fn reference_list(v: Option<&Value>) -> Vec<String> {
620 match v {
621 Some(Value::String(s)) => vec![s.clone()],
622 Some(Value::Sequence(seq)) => seq
623 .iter()
624 .filter_map(|x| x.as_str().map(str::to_string))
625 .collect(),
626 _ => Vec::new(),
627 }
628}
629
630fn correlation_rule_refs(m: &yaml_serde::Mapping) -> Vec<String> {
632 m.get(key("correlation"))
633 .and_then(|c| c.as_mapping())
634 .map(|c| reference_list(c.get(key("rules"))))
635 .unwrap_or_default()
636}
637
638fn filter_rule_refs(m: &yaml_serde::Mapping) -> Option<Vec<String>> {
641 let f = m.get(key("filter"))?.as_mapping()?;
642 let rules = f.get(key("rules"))?;
643 if let Some(s) = rules.as_str()
644 && s.eq_ignore_ascii_case("any")
645 {
646 return None;
647 }
648 Some(reference_list(Some(rules)))
649}
650
651fn lint_cross_references(docs: &[Value], index: &RuleIndex, warnings: &mut Vec<LintWarning>) {
659 for value in docs {
660 let Some(m) = value.as_mapping() else {
661 continue;
662 };
663 if is_action_fragment(m) {
664 continue;
665 }
666 let (refs, path) = match detect_doc_type(m) {
667 DocType::Correlation => (correlation_rule_refs(m), "/correlation/rules"),
668 DocType::Filter => match filter_rule_refs(m) {
669 Some(refs) => (refs, "/filter/rules"),
670 None => continue,
671 },
672 DocType::Detection => continue,
673 };
674 if refs.is_empty() {
675 continue;
676 }
677 let self_major = crate::version::resolve_major(
678 m.get(key("sigma-version"))
679 .and_then(crate::version::major_from_value),
680 );
681 let label = get_str(m, "title")
682 .or_else(|| get_str(m, "name"))
683 .unwrap_or("<rule>");
684 for r in refs {
685 match index.majors.get(&r).copied() {
686 Some(target) if target != self_major => warnings.push(warning(
687 LintRule::SigmaVersionMismatch,
688 format!(
689 "'{label}' targets sigma-version major {self_major} but references rule \
690 '{r}' which targets major {target}; cross-referencing rules must share a \
691 specification major"
692 ),
693 path,
694 )),
695 Some(_) => {}
696 None if index.complete => warnings.push(warning(
697 LintRule::UnknownRuleReference,
698 format!(
699 "'{label}' references rule '{r}', which was not found among the linted \
700 rules (matched by id or name)"
701 ),
702 path,
703 )),
704 None => {}
705 }
706 }
707 }
708}
709
710fn lint_yaml_value_ext(
715 value: &Value,
716 extra_ns: &[String],
717 ads: Option<&AdsConfig>,
718) -> Vec<LintWarning> {
719 let Some(m) = value.as_mapping() else {
720 return vec![err(
721 LintRule::NotAMapping,
722 "document is not a YAML mapping",
723 "/",
724 )];
725 };
726
727 if is_action_fragment(m) {
728 let mut warnings = Vec::new();
729 rules::exemplar::lint_exemplars(m, None, &mut warnings);
730 return warnings;
731 }
732
733 let mut warnings = Vec::new();
734
735 rules::metadata::lint_shared(m, &mut warnings);
736
737 let doc_type = detect_doc_type(m);
738 match doc_type {
739 DocType::Detection => rules::detection::lint_detection_rule(m, &mut warnings, extra_ns),
740 DocType::Correlation => rules::correlation::lint_correlation_rule(m, &mut warnings),
741 DocType::Filter => rules::filter::lint_filter_rule(m, &mut warnings),
742 }
743
744 rules::version::lint_sigma_version(m, doc_type, &mut warnings);
745 rules::shared::lint_unknown_keys(m, doc_type, &mut warnings);
746 rules::exemplar::lint_exemplars(m, Some(doc_type), &mut warnings);
747
748 if let Some(ads_cfg) = ads
751 && doc_type == DocType::Detection
752 {
753 rules::ads::lint_ads(m, ads_cfg, extra_ns, &mut warnings);
754 }
755
756 warnings
757}
758
759pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
761 lint_yaml_value_ext(value, &[], None)
762}
763
764fn lint_yaml_str_ext(text: &str, extra_ns: &[String], ads: Option<&AdsConfig>) -> Vec<LintWarning> {
765 lint_yaml_str_indexed(text, extra_ns, ads, None)
766}
767
768fn lint_yaml_str_indexed(
773 text: &str,
774 extra_ns: &[String],
775 ads: Option<&AdsConfig>,
776 external_index: Option<&RuleIndex>,
777) -> Vec<LintWarning> {
778 let mut all_warnings = Vec::new();
779 let mut docs: Vec<Value> = Vec::new();
780
781 for doc in yaml_serde::Deserializer::from_str(text) {
782 let value: Value = match Value::deserialize(doc) {
783 Ok(v) => v,
784 Err(e) => {
785 let mut w = err(
786 LintRule::YamlParseError,
787 format!("YAML parse error: {e}"),
788 "/",
789 );
790 if let Some(loc) = e.location() {
791 w.span = Some(Span {
792 start_line: loc.line().saturating_sub(1) as u32,
793 start_col: loc.column() as u32,
794 end_line: loc.line().saturating_sub(1) as u32,
795 end_col: loc.column() as u32 + 1,
796 });
797 }
798 all_warnings.push(w);
799 break;
800 }
801 };
802
803 for mut w in lint_yaml_value_ext(&value, extra_ns, ads) {
804 w.span = resolve_path_to_span(text, &w.path);
805 all_warnings.push(w);
806 }
807 docs.push(value);
808 }
809
810 let local_index;
813 let index = match external_index {
814 Some(idx) => idx,
815 None => {
816 let mut idx = RuleIndex::new(false);
817 for v in &docs {
818 idx.add_value(v);
819 }
820 local_index = idx;
821 &local_index
822 }
823 };
824 let mut xref = Vec::new();
825 lint_cross_references(&docs, index, &mut xref);
826 for mut w in xref {
827 w.span = resolve_path_to_span(text, &w.path);
828 all_warnings.push(w);
829 }
830
831 all_warnings
832}
833
834pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
836 lint_yaml_str_ext(text, &[], None)
837}
838
839fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
840 if path == "/" || path.is_empty() {
841 for (i, line) in text.lines().enumerate() {
842 let trimmed = line.trim();
843 if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
844 return Some(Span {
845 start_line: i as u32,
846 start_col: 0,
847 end_line: i as u32,
848 end_col: line.len() as u32,
849 });
850 }
851 }
852 return None;
853 }
854
855 let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
856
857 if segments.is_empty() {
858 return None;
859 }
860
861 let lines: Vec<&str> = text.lines().collect();
862 let mut current_indent: i32 = -1;
863 let mut search_start = 0usize;
864 let mut last_matched_line: Option<usize> = None;
865
866 for segment in &segments {
867 let array_index: Option<usize> = segment.parse().ok();
868 let mut found = false;
869
870 let mut line_num = search_start;
871 while line_num < lines.len() {
872 let line = lines[line_num];
873 let trimmed = line.trim();
874 if trimmed.is_empty() || trimmed.starts_with('#') {
875 line_num += 1;
876 continue;
877 }
878
879 let indent = (line.len() - trimmed.len()) as i32;
880
881 if indent <= current_indent && found {
882 break;
883 }
884 if indent <= current_indent {
885 line_num += 1;
886 continue;
887 }
888
889 if let Some(idx) = array_index {
890 if trimmed.starts_with("- ") && indent > current_indent {
891 let mut count = 0usize;
892 for (offset, sl) in lines[search_start..].iter().enumerate() {
893 let scan = search_start + offset;
894 let st = sl.trim();
895 if st.is_empty() || st.starts_with('#') {
896 continue;
897 }
898 let si = (sl.len() - st.len()) as i32;
899 if si == indent && st.starts_with("- ") {
900 if count == idx {
901 last_matched_line = Some(scan);
902 search_start = scan + 1;
903 current_indent = indent;
904 found = true;
905 break;
906 }
907 count += 1;
908 }
909 if si < indent && count > 0 {
910 break;
911 }
912 }
913 break;
914 }
915 } else {
916 let key_pattern = format!("{segment}:");
917 if trimmed.starts_with(&key_pattern) || trimmed == *segment {
918 last_matched_line = Some(line_num);
919 search_start = line_num + 1;
920 current_indent = indent;
921 found = true;
922 break;
923 }
924 }
925
926 line_num += 1;
927 }
928
929 if !found && last_matched_line.is_none() {
930 break;
931 }
932 }
933
934 last_matched_line.map(|line_num| {
935 let line = lines[line_num];
936 Span {
937 start_line: line_num as u32,
938 start_col: 0,
939 end_line: line_num as u32,
940 end_col: line.len() as u32,
941 }
942 })
943}
944
945pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
947 let content = std::fs::read_to_string(path)?;
948 let warnings = lint_yaml_str(&content);
949 Ok(FileLintResult {
950 path: path.to_path_buf(),
951 warnings,
952 })
953}
954
955fn collect_yaml_files(
959 dir: &Path,
960 base: &Path,
961 exclude_set: Option<&globset::GlobSet>,
962 files: &mut Vec<std::path::PathBuf>,
963 visited: &mut HashSet<std::path::PathBuf>,
964) -> crate::error::Result<()> {
965 let canonical = match dir.canonicalize() {
966 Ok(p) => p,
967 Err(_) => return Ok(()),
968 };
969 if !visited.insert(canonical) {
970 return Ok(());
971 }
972
973 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
974 entries.sort_by_key(|e| e.path());
975
976 for entry in entries {
977 let path = entry.path();
978
979 if let Some(gs) = exclude_set
980 && let Ok(rel) = path.strip_prefix(base)
981 && gs.is_match(rel)
982 {
983 continue;
984 }
985
986 if path.is_dir() {
987 if path
988 .file_name()
989 .and_then(|n| n.to_str())
990 .is_some_and(|n| n.starts_with('.'))
991 {
992 continue;
993 }
994 collect_yaml_files(&path, base, exclude_set, files, visited)?;
995 } else if matches!(
996 path.extension().and_then(|e| e.to_str()),
997 Some("yml" | "yaml")
998 ) {
999 files.push(path);
1000 }
1001 }
1002 Ok(())
1003}
1004
1005fn lint_directory_impl(
1009 dir: &Path,
1010 config: Option<&LintConfig>,
1011) -> crate::error::Result<Vec<FileLintResult>> {
1012 let exclude_set = config.and_then(LintConfig::build_exclude_set);
1013 let mut files = Vec::new();
1014 let mut visited = HashSet::new();
1015 collect_yaml_files(dir, dir, exclude_set.as_ref(), &mut files, &mut visited)?;
1016
1017 let mut index = RuleIndex::new(true);
1019 let mut contents: Vec<(std::path::PathBuf, std::result::Result<String, String>)> =
1020 Vec::with_capacity(files.len());
1021 for path in files {
1022 match std::fs::read_to_string(&path) {
1023 Ok(text) => {
1024 index.add_text(&text);
1025 contents.push((path, Ok(text)));
1026 }
1027 Err(e) => contents.push((path, Err(format!("error reading file: {e}")))),
1028 }
1029 }
1030
1031 let mut results = Vec::with_capacity(contents.len());
1032 for (path, content) in contents {
1033 match content {
1034 Ok(text) => {
1035 let warnings = match config {
1036 Some(cfg) => {
1037 let w = lint_yaml_str_indexed(
1038 &text,
1039 &cfg.tag_namespaces,
1040 cfg.ads.as_ref(),
1041 Some(&index),
1042 );
1043 apply_suppressions(w, cfg, &parse_inline_suppressions(&text))
1044 }
1045 None => lint_yaml_str_indexed(&text, &[], None, Some(&index)),
1046 };
1047 results.push(FileLintResult { path, warnings });
1048 }
1049 Err(msg) => results.push(FileLintResult {
1050 path,
1051 warnings: vec![err(LintRule::FileReadError, msg, "/")],
1052 }),
1053 }
1054 }
1055 Ok(results)
1056}
1057
1058pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
1060 lint_directory_impl(dir, None)
1061}
1062
1063#[derive(Debug, Clone, Default, Serialize)]
1069pub struct LintConfig {
1070 pub disabled_rules: HashSet<String>,
1071 pub severity_overrides: HashMap<String, Severity>,
1072 pub exclude_patterns: Vec<String>,
1073 pub tag_namespaces: Vec<String>,
1075 #[serde(skip_serializing_if = "Option::is_none")]
1078 pub ads: Option<AdsConfig>,
1079}
1080
1081#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1088pub struct AdsConfig {
1089 pub enforce_status: Vec<String>,
1091 pub required: Vec<String>,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1096 pub severity: Option<Severity>,
1097}
1098
1099impl Default for AdsConfig {
1100 fn default() -> Self {
1101 AdsConfig {
1102 enforce_status: vec!["stable".to_string()],
1103 required: AdsSection::all()
1104 .iter()
1105 .map(|s| s.id().to_string())
1106 .collect(),
1107 severity: None,
1108 }
1109 }
1110}
1111
1112impl AdsConfig {
1113 pub fn enforces_status(&self, status: Option<&str>) -> bool {
1116 match status {
1117 Some(s) => self.enforce_status.iter().any(|e| e == s),
1118 None => false,
1119 }
1120 }
1121
1122 pub fn requires(&self, section_id: &str) -> bool {
1124 self.required.iter().any(|r| r == section_id)
1125 }
1126}
1127
1128#[derive(Debug, Deserialize)]
1129struct RawLintConfig {
1130 #[serde(default)]
1131 disabled_rules: Vec<String>,
1132 #[serde(default)]
1133 severity_overrides: HashMap<String, String>,
1134 #[serde(default)]
1135 exclude: Vec<String>,
1136 #[serde(default)]
1137 tag_namespaces: Vec<String>,
1138 #[serde(default)]
1139 ads: Option<RawAdsConfig>,
1140}
1141
1142#[derive(Debug, Deserialize)]
1143struct RawAdsConfig {
1144 #[serde(default)]
1145 enforce_status: Option<Vec<String>>,
1146 #[serde(default)]
1147 required: Option<Vec<String>>,
1148 #[serde(default)]
1149 severity: Option<String>,
1150}
1151
1152fn parse_severity(s: &str) -> Option<Severity> {
1154 match s {
1155 "error" => Some(Severity::Error),
1156 "warning" => Some(Severity::Warning),
1157 "info" => Some(Severity::Info),
1158 "hint" => Some(Severity::Hint),
1159 _ => None,
1160 }
1161}
1162
1163fn ads_config_from_raw(raw: RawAdsConfig) -> crate::error::Result<AdsConfig> {
1166 let mut config = AdsConfig::default();
1167
1168 if let Some(statuses) = raw.enforce_status {
1169 const VALID_STATUSES: &[&str] = &[
1170 "stable",
1171 "test",
1172 "experimental",
1173 "deprecated",
1174 "unsupported",
1175 ];
1176 let mut normalised = Vec::with_capacity(statuses.len());
1177 for s in statuses {
1178 let lower = s.to_lowercase();
1179 if !VALID_STATUSES.contains(&lower.as_str()) {
1180 return Err(crate::error::SigmaParserError::InvalidRule(format!(
1181 "invalid ads.enforce_status '{s}'; expected one of: {}",
1182 VALID_STATUSES.join(", ")
1183 )));
1184 }
1185 normalised.push(lower);
1186 }
1187 dedup_preserving_order(&mut normalised);
1188 config.enforce_status = normalised;
1189 }
1190
1191 if let Some(required) = raw.required {
1192 let mut ids = Vec::with_capacity(required.len());
1193 for id in required {
1194 let lower = id.to_lowercase();
1195 if AdsSection::from_id(&lower).is_none() {
1196 return Err(crate::error::SigmaParserError::InvalidRule(format!(
1197 "invalid ads.required section '{id}'; expected one of: {}",
1198 AdsSection::all()
1199 .iter()
1200 .map(|s| s.id())
1201 .collect::<Vec<_>>()
1202 .join(", ")
1203 )));
1204 }
1205 ids.push(lower);
1206 }
1207 dedup_preserving_order(&mut ids);
1208 config.required = ids;
1209 }
1210
1211 if let Some(sev) = raw.severity {
1212 config.severity = Some(parse_severity(&sev).ok_or_else(|| {
1213 crate::error::SigmaParserError::InvalidRule(format!(
1214 "invalid ads.severity '{sev}'; expected error, warning, info, or hint"
1215 ))
1216 })?);
1217 }
1218
1219 Ok(config)
1220}
1221
1222fn dedup_preserving_order(items: &mut Vec<String>) {
1226 let mut seen = HashSet::new();
1227 items.retain(|item| seen.insert(item.clone()));
1228}
1229
1230impl LintConfig {
1231 pub fn load(path: &Path) -> crate::error::Result<Self> {
1232 let content = std::fs::read_to_string(path)?;
1233 let raw: RawLintConfig = yaml_serde::from_str(&content)?;
1234
1235 let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
1236 let mut severity_overrides = HashMap::new();
1237 for (rule, sev_str) in &raw.severity_overrides {
1238 let sev = parse_severity(sev_str).ok_or_else(|| {
1239 crate::error::SigmaParserError::InvalidRule(format!(
1240 "invalid severity '{sev_str}' for rule '{rule}' in lint config"
1241 ))
1242 })?;
1243 severity_overrides.insert(rule.clone(), sev);
1244 }
1245
1246 let mut exclude_patterns = raw.exclude;
1247 dedup_preserving_order(&mut exclude_patterns);
1248
1249 let mut tag_namespaces: Vec<String> = raw
1250 .tag_namespaces
1251 .into_iter()
1252 .map(|s| s.to_lowercase())
1253 .collect();
1254 dedup_preserving_order(&mut tag_namespaces);
1255
1256 let ads = raw.ads.map(ads_config_from_raw).transpose()?;
1257
1258 Ok(LintConfig {
1259 disabled_rules,
1260 severity_overrides,
1261 exclude_patterns,
1262 tag_namespaces,
1263 ads,
1264 })
1265 }
1266
1267 pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
1268 let dir = if start_path.is_file() {
1269 start_path.parent()?
1270 } else {
1271 start_path
1272 };
1273
1274 let mut current = dir;
1275 loop {
1276 let candidate = current.join(".rsigma-lint.yml");
1277 if candidate.is_file() {
1278 return Some(candidate);
1279 }
1280 let candidate_yaml = current.join(".rsigma-lint.yaml");
1281 if candidate_yaml.is_file() {
1282 return Some(candidate_yaml);
1283 }
1284 current = current.parent()?;
1285 }
1286 }
1287
1288 pub fn merge(&mut self, other: &LintConfig) {
1289 self.disabled_rules
1290 .extend(other.disabled_rules.iter().cloned());
1291 for (rule, sev) in &other.severity_overrides {
1292 self.severity_overrides.insert(rule.clone(), *sev);
1293 }
1294 self.exclude_patterns
1295 .extend(other.exclude_patterns.iter().cloned());
1296 dedup_preserving_order(&mut self.exclude_patterns);
1297 self.tag_namespaces
1298 .extend(other.tag_namespaces.iter().cloned());
1299 dedup_preserving_order(&mut self.tag_namespaces);
1300 if other.ads.is_some() {
1303 self.ads = other.ads.clone();
1304 }
1305 }
1306
1307 pub fn is_disabled(&self, rule: &LintRule) -> bool {
1308 self.disabled_rules.contains(&rule.to_string())
1309 }
1310
1311 pub fn build_exclude_set(&self) -> Option<globset::GlobSet> {
1312 if self.exclude_patterns.is_empty() {
1313 return None;
1314 }
1315 let mut builder = globset::GlobSetBuilder::new();
1316 for pat in &self.exclude_patterns {
1317 if let Ok(glob) = globset::GlobBuilder::new(pat)
1318 .literal_separator(false)
1319 .build()
1320 {
1321 builder.add(glob);
1322 }
1323 }
1324 builder.build().ok()
1325 }
1326}
1327
1328#[derive(Debug, Clone, Default)]
1333pub struct InlineSuppressions {
1334 pub disable_all: bool,
1335 pub file_disabled: HashSet<String>,
1336 pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
1337}
1338
1339pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
1340 let mut result = InlineSuppressions::default();
1341
1342 for (i, line) in text.lines().enumerate() {
1343 let trimmed = line.trim();
1344
1345 let comment = if let Some(pos) = find_yaml_comment(trimmed) {
1346 trimmed[pos + 1..].trim()
1347 } else {
1348 continue;
1349 };
1350
1351 if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
1352 let rest = rest.trim();
1353 let next_line = (i + 1) as u32;
1354 if rest.is_empty() {
1355 result.line_disabled.insert(next_line, None);
1356 } else {
1357 let rules: HashSet<String> = rest
1358 .split(',')
1359 .map(|s| s.trim().to_string())
1360 .filter(|s| !s.is_empty())
1361 .collect();
1362 if !rules.is_empty() {
1363 result
1364 .line_disabled
1365 .entry(next_line)
1366 .and_modify(|existing| {
1367 if let Some(existing_set) = existing {
1368 existing_set.extend(rules.iter().cloned());
1369 }
1370 })
1371 .or_insert(Some(rules));
1372 }
1373 }
1374 } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
1375 let rest = rest.trim();
1376 if rest.is_empty() {
1377 result.disable_all = true;
1378 } else {
1379 for rule in rest.split(',') {
1380 let rule = rule.trim();
1381 if !rule.is_empty() {
1382 result.file_disabled.insert(rule.to_string());
1383 }
1384 }
1385 }
1386 }
1387 }
1388
1389 result
1390}
1391
1392fn find_yaml_comment(line: &str) -> Option<usize> {
1393 let mut in_single = false;
1394 let mut in_double = false;
1395 for (i, c) in line.char_indices() {
1396 match c {
1397 '\'' if !in_double => in_single = !in_single,
1398 '"' if !in_single => in_double = !in_double,
1399 '#' if !in_single && !in_double => return Some(i),
1400 _ => {}
1401 }
1402 }
1403 None
1404}
1405
1406impl InlineSuppressions {
1407 pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
1408 if self.disable_all {
1409 return true;
1410 }
1411
1412 let rule_name = warning.rule.to_string();
1413 if self.file_disabled.contains(&rule_name) {
1414 return true;
1415 }
1416
1417 if let Some(span) = &warning.span
1418 && let Some(line_rules) = self.line_disabled.get(&span.start_line)
1419 {
1420 return match line_rules {
1421 None => true,
1422 Some(rules) => rules.contains(&rule_name),
1423 };
1424 }
1425
1426 false
1427 }
1428}
1429
1430pub fn apply_suppressions(
1435 warnings: Vec<LintWarning>,
1436 config: &LintConfig,
1437 inline: &InlineSuppressions,
1438) -> Vec<LintWarning> {
1439 warnings
1440 .into_iter()
1441 .filter(|w| !config.is_disabled(&w.rule))
1442 .filter(|w| !inline.is_suppressed(w))
1443 .map(|mut w| {
1444 let rule_name = w.rule.to_string();
1445 if let Some(sev) = config.severity_overrides.get(&rule_name) {
1446 w.severity = *sev;
1447 }
1448 w
1449 })
1450 .collect()
1451}
1452
1453pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
1454 let warnings = lint_yaml_str_ext(text, &config.tag_namespaces, config.ads.as_ref());
1455 let inline = parse_inline_suppressions(text);
1456 apply_suppressions(warnings, config, &inline)
1457}
1458
1459pub fn lint_yaml_file_with_config(
1460 path: &Path,
1461 config: &LintConfig,
1462) -> crate::error::Result<FileLintResult> {
1463 let content = std::fs::read_to_string(path)?;
1464 let warnings = lint_yaml_str_with_config(&content, config);
1465 Ok(FileLintResult {
1466 path: path.to_path_buf(),
1467 warnings,
1468 })
1469}
1470
1471pub fn lint_yaml_directory_with_config(
1472 dir: &Path,
1473 config: &LintConfig,
1474) -> crate::error::Result<Vec<FileLintResult>> {
1475 lint_directory_impl(dir, Some(config))
1476}
1477
1478#[cfg(test)]
1483mod tests {
1484 use super::*;
1485
1486 fn yaml_value(yaml: &str) -> Value {
1487 yaml_serde::from_str(yaml).unwrap()
1488 }
1489
1490 fn lint(yaml: &str) -> Vec<LintWarning> {
1491 lint_yaml_value(&yaml_value(yaml))
1492 }
1493
1494 fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1495 warnings.iter().any(|w| w.rule == rule)
1496 }
1497
1498 fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1499 !has_rule(warnings, rule)
1500 }
1501
1502 #[test]
1503 fn valid_detection_rule_no_errors() {
1504 let w = lint(
1505 r#"
1506title: Test Rule
1507id: 929a690e-bef0-4204-a928-ef5e620d6fcc
1508status: test
1509logsource:
1510 category: process_creation
1511 product: windows
1512detection:
1513 selection:
1514 CommandLine|contains: 'whoami'
1515 condition: selection
1516level: medium
1517tags:
1518 - attack.execution
1519 - attack.t1059
1520"#,
1521 );
1522 let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
1523 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1524 }
1525
1526 #[test]
1527 fn not_a_mapping() {
1528 let v: yaml_serde::Value = yaml_serde::from_str("- item1\n- item2").unwrap();
1529 let w = lint_yaml_value(&v);
1530 assert!(has_rule(&w, LintRule::NotAMapping));
1531 }
1532
1533 #[test]
1534 fn lint_yaml_str_produces_spans() {
1535 let text = r#"title: Test
1536status: invalid_status
1537logsource:
1538 category: test
1539detection:
1540 selection:
1541 field: value
1542 condition: selection
1543level: medium
1544"#;
1545 let warnings = lint_yaml_str(text);
1546 let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
1547 assert!(invalid_status.is_some(), "expected InvalidStatus warning");
1548 let span = invalid_status.unwrap().span;
1549 assert!(span.is_some(), "expected span to be resolved");
1550 assert_eq!(span.unwrap().start_line, 1);
1551 }
1552
1553 #[test]
1554 fn yaml_parse_error_uses_correct_rule() {
1555 let text = "title: [unclosed";
1556 let warnings = lint_yaml_str(text);
1557 assert!(has_rule(&warnings, LintRule::YamlParseError));
1558 assert!(has_no_rule(&warnings, LintRule::MissingTitle));
1559 }
1560
1561 #[test]
1562 fn action_global_skipped() {
1563 let w = lint(
1564 r#"
1565action: global
1566title: Global Template
1567logsource:
1568 product: windows
1569"#,
1570 );
1571 assert!(w.is_empty());
1572 }
1573
1574 #[test]
1575 fn action_reset_skipped() {
1576 let w = lint(
1577 r#"
1578action: reset
1579"#,
1580 );
1581 assert!(w.is_empty());
1582 }
1583
1584 #[test]
1585 fn resolve_path_to_span_root() {
1586 let text = "title: Test\nstatus: test\n";
1587 let span = resolve_path_to_span(text, "/");
1588 assert!(span.is_some());
1589 assert_eq!(span.unwrap().start_line, 0);
1590 }
1591
1592 #[test]
1593 fn resolve_path_to_span_top_level_key() {
1594 let text = "title: Test\nstatus: test\nlevel: high\n";
1595 let span = resolve_path_to_span(text, "/status");
1596 assert!(span.is_some());
1597 assert_eq!(span.unwrap().start_line, 1);
1598 }
1599
1600 #[test]
1601 fn resolve_path_to_span_nested_key() {
1602 let text = "title: Test\nlogsource:\n category: test\n product: windows\n";
1603 let span = resolve_path_to_span(text, "/logsource/product");
1604 assert!(span.is_some());
1605 assert_eq!(span.unwrap().start_line, 3);
1606 }
1607
1608 #[test]
1609 fn resolve_path_to_span_missing_key() {
1610 let text = "title: Test\nstatus: test\n";
1611 let span = resolve_path_to_span(text, "/nonexistent");
1612 assert!(span.is_none());
1613 }
1614
1615 #[test]
1616 fn multi_doc_yaml_lints_all_documents() {
1617 let text = r#"title: Rule 1
1618logsource:
1619 category: test
1620detection:
1621 selection:
1622 field: value
1623 condition: selection
1624level: medium
1625---
1626title: Rule 2
1627status: bad_status
1628logsource:
1629 category: test
1630detection:
1631 selection:
1632 field: value
1633 condition: selection
1634level: medium
1635"#;
1636 let warnings = lint_yaml_str(text);
1637 assert!(has_rule(&warnings, LintRule::InvalidStatus));
1638 }
1639
1640 #[test]
1641 fn severity_display() {
1642 assert_eq!(format!("{}", Severity::Error), "error");
1643 assert_eq!(format!("{}", Severity::Warning), "warning");
1644 assert_eq!(format!("{}", Severity::Info), "info");
1645 assert_eq!(format!("{}", Severity::Hint), "hint");
1646 }
1647
1648 #[test]
1649 fn file_lint_result_has_errors() {
1650 let result = FileLintResult {
1651 path: std::path::PathBuf::from("test.yml"),
1652 warnings: vec![
1653 warning(LintRule::TitleTooLong, "too long", "/title"),
1654 err(
1655 LintRule::MissingCondition,
1656 "missing",
1657 "/detection/condition",
1658 ),
1659 ],
1660 };
1661 assert!(result.has_errors());
1662 assert_eq!(result.error_count(), 1);
1663 assert_eq!(result.warning_count(), 1);
1664 }
1665
1666 #[test]
1667 fn file_lint_result_no_errors() {
1668 let result = FileLintResult {
1669 path: std::path::PathBuf::from("test.yml"),
1670 warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
1671 };
1672 assert!(!result.has_errors());
1673 assert_eq!(result.error_count(), 0);
1674 assert_eq!(result.warning_count(), 1);
1675 }
1676
1677 #[test]
1678 fn file_lint_result_empty() {
1679 let result = FileLintResult {
1680 path: std::path::PathBuf::from("test.yml"),
1681 warnings: vec![],
1682 };
1683 assert!(!result.has_errors());
1684 assert_eq!(result.error_count(), 0);
1685 assert_eq!(result.warning_count(), 0);
1686 }
1687
1688 #[test]
1689 fn lint_warning_display() {
1690 let w = err(
1691 LintRule::MissingTitle,
1692 "missing required field 'title'",
1693 "/title",
1694 );
1695 let display = format!("{w}");
1696 assert!(display.contains("error"));
1697 assert!(display.contains("missing_title"));
1698 assert!(display.contains("/title"));
1699 }
1700
1701 #[test]
1702 fn file_lint_result_info_count() {
1703 let result = FileLintResult {
1704 path: std::path::PathBuf::from("test.yml"),
1705 warnings: vec![
1706 info(LintRule::MissingDescription, "missing desc", "/description"),
1707 info(LintRule::MissingAuthor, "missing author", "/author"),
1708 warning(LintRule::TitleTooLong, "too long", "/title"),
1709 ],
1710 };
1711 assert_eq!(result.info_count(), 2);
1712 assert_eq!(result.warning_count(), 1);
1713 assert_eq!(result.error_count(), 0);
1714 assert!(!result.has_errors());
1715 }
1716
1717 #[test]
1718 fn parse_inline_disable_all() {
1719 let text = "# rsigma-disable\ntitle: Test\n";
1720 let sup = parse_inline_suppressions(text);
1721 assert!(sup.disable_all);
1722 }
1723
1724 #[test]
1725 fn parse_inline_disable_specific_rules() {
1726 let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
1727 let sup = parse_inline_suppressions(text);
1728 assert!(!sup.disable_all);
1729 assert!(sup.file_disabled.contains("missing_description"));
1730 assert!(sup.file_disabled.contains("missing_author"));
1731 }
1732
1733 #[test]
1734 fn parse_inline_disable_next_line_all() {
1735 let text = "# rsigma-disable-next-line\ntitle: Test\n";
1736 let sup = parse_inline_suppressions(text);
1737 assert!(!sup.disable_all);
1738 assert!(sup.line_disabled.contains_key(&1));
1739 assert!(sup.line_disabled[&1].is_none());
1740 }
1741
1742 #[test]
1743 fn parse_inline_disable_next_line_specific() {
1744 let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
1745 let sup = parse_inline_suppressions(text);
1746 assert!(sup.line_disabled.contains_key(&2));
1747 let rules = sup.line_disabled[&2].as_ref().unwrap();
1748 assert!(rules.contains("missing_level"));
1749 }
1750
1751 #[test]
1752 fn parse_inline_no_comments() {
1753 let text = "title: Test\nstatus: test\n";
1754 let sup = parse_inline_suppressions(text);
1755 assert!(!sup.disable_all);
1756 assert!(sup.file_disabled.is_empty());
1757 assert!(sup.line_disabled.is_empty());
1758 }
1759
1760 #[test]
1761 fn parse_inline_comment_in_quoted_string() {
1762 let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
1763 let sup = parse_inline_suppressions(text);
1764 assert!(!sup.disable_all);
1765 assert!(sup.file_disabled.is_empty());
1766 }
1767
1768 #[test]
1769 fn apply_suppressions_disables_rule() {
1770 let warnings = vec![
1771 info(LintRule::MissingDescription, "desc", "/description"),
1772 info(LintRule::MissingAuthor, "author", "/author"),
1773 warning(LintRule::TitleTooLong, "title", "/title"),
1774 ];
1775 let mut config = LintConfig::default();
1776 config
1777 .disabled_rules
1778 .insert("missing_description".to_string());
1779 let inline = InlineSuppressions::default();
1780
1781 let result = apply_suppressions(warnings, &config, &inline);
1782 assert_eq!(result.len(), 2);
1783 assert!(
1784 result
1785 .iter()
1786 .all(|w| w.rule != LintRule::MissingDescription)
1787 );
1788 }
1789
1790 #[test]
1791 fn apply_suppressions_severity_override() {
1792 let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
1793 let mut config = LintConfig::default();
1794 config
1795 .severity_overrides
1796 .insert("title_too_long".to_string(), Severity::Info);
1797 let inline = InlineSuppressions::default();
1798
1799 let result = apply_suppressions(warnings, &config, &inline);
1800 assert_eq!(result.len(), 1);
1801 assert_eq!(result[0].severity, Severity::Info);
1802 }
1803
1804 #[test]
1805 fn apply_suppressions_inline_file_disable() {
1806 let warnings = vec![
1807 info(LintRule::MissingDescription, "desc", "/description"),
1808 info(LintRule::MissingAuthor, "author", "/author"),
1809 ];
1810 let config = LintConfig::default();
1811 let mut inline = InlineSuppressions::default();
1812 inline.file_disabled.insert("missing_author".to_string());
1813
1814 let result = apply_suppressions(warnings, &config, &inline);
1815 assert_eq!(result.len(), 1);
1816 assert_eq!(result[0].rule, LintRule::MissingDescription);
1817 }
1818
1819 #[test]
1820 fn apply_suppressions_inline_disable_all() {
1821 let warnings = vec![
1822 err(LintRule::MissingTitle, "title", "/title"),
1823 warning(LintRule::TitleTooLong, "long", "/title"),
1824 ];
1825 let config = LintConfig::default();
1826 let inline = InlineSuppressions {
1827 disable_all: true,
1828 ..Default::default()
1829 };
1830
1831 let result = apply_suppressions(warnings, &config, &inline);
1832 assert!(result.is_empty());
1833 }
1834
1835 #[test]
1836 fn apply_suppressions_inline_next_line() {
1837 let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
1838 w1.span = Some(Span {
1839 start_line: 5,
1840 start_col: 0,
1841 end_line: 5,
1842 end_col: 10,
1843 });
1844 let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
1845 w2.span = Some(Span {
1846 start_line: 6,
1847 start_col: 0,
1848 end_line: 6,
1849 end_col: 10,
1850 });
1851
1852 let config = LintConfig::default();
1853 let mut inline = InlineSuppressions::default();
1854 inline.line_disabled.insert(5, None);
1855
1856 let result = apply_suppressions(vec![w1, w2], &config, &inline);
1857 assert_eq!(result.len(), 1);
1858 assert_eq!(result[0].rule, LintRule::InvalidStatus);
1859 }
1860
1861 #[test]
1862 fn lint_with_config_disables_rules() {
1863 let text = r#"title: Test
1864logsource:
1865 category: test
1866detection:
1867 selection:
1868 field: value
1869 condition: selection
1870level: medium
1871"#;
1872 let mut config = LintConfig::default();
1873 config
1874 .disabled_rules
1875 .insert("missing_description".to_string());
1876 config.disabled_rules.insert("missing_author".to_string());
1877
1878 let warnings = lint_yaml_str_with_config(text, &config);
1879 assert!(
1880 !warnings
1881 .iter()
1882 .any(|w| w.rule == LintRule::MissingDescription)
1883 );
1884 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1885 }
1886
1887 #[test]
1888 fn lint_with_inline_disable_next_line() {
1889 let text = r#"title: Test
1890# rsigma-disable-next-line missing_level
1891logsource:
1892 category: test
1893detection:
1894 selection:
1895 field: value
1896 condition: selection
1897"#;
1898 let config = LintConfig::default();
1899 let warnings = lint_yaml_str_with_config(text, &config);
1900 assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
1901 }
1902
1903 #[test]
1904 fn lint_with_inline_file_disable() {
1905 let text = r#"# rsigma-disable missing_description, missing_author
1906title: Test
1907logsource:
1908 category: test
1909detection:
1910 selection:
1911 field: value
1912 condition: selection
1913level: medium
1914"#;
1915 let config = LintConfig::default();
1916 let warnings = lint_yaml_str_with_config(text, &config);
1917 assert!(
1918 !warnings
1919 .iter()
1920 .any(|w| w.rule == LintRule::MissingDescription)
1921 );
1922 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1923 }
1924
1925 #[test]
1926 fn lint_with_inline_disable_all() {
1927 let text = r#"# rsigma-disable
1928title: Test
1929status: invalid_status
1930logsource:
1931 category: test
1932detection:
1933 selection:
1934 field: value
1935 condition: selection
1936"#;
1937 let config = LintConfig::default();
1938 let warnings = lint_yaml_str_with_config(text, &config);
1939 assert!(warnings.is_empty());
1940 }
1941
1942 #[test]
1943 fn lint_config_merge() {
1944 let mut base = LintConfig::default();
1945 base.disabled_rules.insert("rule_a".to_string());
1946 base.severity_overrides
1947 .insert("rule_b".to_string(), Severity::Info);
1948
1949 let other = LintConfig {
1950 disabled_rules: ["rule_c".to_string()].into_iter().collect(),
1951 severity_overrides: [("rule_d".to_string(), Severity::Hint)]
1952 .into_iter()
1953 .collect(),
1954 exclude_patterns: vec!["test/**".to_string()],
1955 tag_namespaces: vec!["myns".to_string()],
1956 ads: None,
1957 };
1958
1959 base.merge(&other);
1960 assert!(base.disabled_rules.contains("rule_a"));
1961 assert!(base.disabled_rules.contains("rule_c"));
1962 assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
1963 assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
1964 assert_eq!(base.exclude_patterns, vec!["test/**".to_string()]);
1965 assert!(base.tag_namespaces.contains(&"myns".to_string()));
1966 }
1967
1968 #[test]
1969 fn lint_config_merge_dedups_lists() {
1970 let mut base = LintConfig {
1971 exclude_patterns: vec!["config/**".to_string(), "shared/**".to_string()],
1972 tag_namespaces: vec!["myorg".to_string(), "shared".to_string()],
1973 ..Default::default()
1974 };
1975 let other = LintConfig {
1976 exclude_patterns: vec!["shared/**".to_string(), "extra/**".to_string()],
1978 tag_namespaces: vec!["shared".to_string(), "internal".to_string()],
1979 ..Default::default()
1980 };
1981
1982 base.merge(&other);
1983
1984 assert_eq!(
1985 base.exclude_patterns,
1986 vec![
1987 "config/**".to_string(),
1988 "shared/**".to_string(),
1989 "extra/**".to_string()
1990 ]
1991 );
1992 assert_eq!(
1993 base.tag_namespaces,
1994 vec![
1995 "myorg".to_string(),
1996 "shared".to_string(),
1997 "internal".to_string()
1998 ]
1999 );
2000 }
2001
2002 #[test]
2003 fn lint_config_load_dedups_and_normalises() {
2004 let yaml = r#"
2005exclude:
2006 - "config/**"
2007 - "config/**"
2008tag_namespaces:
2009 - MyOrg
2010 - myorg
2011 - internal
2012"#;
2013 let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
2014 std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
2015 let config = LintConfig::load(tmp.path()).unwrap();
2016
2017 assert_eq!(config.exclude_patterns, vec!["config/**".to_string()]);
2018 assert_eq!(
2020 config.tag_namespaces,
2021 vec!["myorg".to_string(), "internal".to_string()]
2022 );
2023 }
2024
2025 #[test]
2026 fn lint_config_is_disabled() {
2027 let mut config = LintConfig::default();
2028 config.disabled_rules.insert("missing_title".to_string());
2029 assert!(config.is_disabled(&LintRule::MissingTitle));
2030 assert!(!config.is_disabled(&LintRule::EmptyTitle));
2031 }
2032
2033 #[test]
2034 fn find_yaml_comment_basic() {
2035 assert_eq!(find_yaml_comment("# comment"), Some(0));
2036 assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
2037 assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
2038 assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
2039 assert_eq!(find_yaml_comment("key: value"), None);
2040 }
2041
2042 #[test]
2043 fn no_fix_for_unfixable_rule() {
2044 let w = lint(
2045 r#"
2046title: Test
2047logsource:
2048 category: test
2049"#,
2050 );
2051 assert!(has_rule(&w, LintRule::MissingDetection));
2052 let fix = w
2053 .iter()
2054 .find(|w| w.rule == LintRule::MissingDetection)
2055 .and_then(|w| w.fix.as_ref());
2056 assert!(fix.is_none());
2057 }
2058
2059 #[test]
2060 fn lint_config_exclude_from_yaml() {
2061 let yaml = r#"
2062disabled_rules:
2063 - missing_description
2064exclude:
2065 - "config/**"
2066 - "**/unsupported/**"
2067"#;
2068 let tmp = std::env::temp_dir().join("rsigma_test_exclude.yml");
2069 std::fs::write(&tmp, yaml).unwrap();
2070 let config = LintConfig::load(&tmp).unwrap();
2071 std::fs::remove_file(&tmp).ok();
2072
2073 assert!(config.disabled_rules.contains("missing_description"));
2074 assert_eq!(config.exclude_patterns.len(), 2);
2075 assert_eq!(config.exclude_patterns[0], "config/**");
2076 assert_eq!(config.exclude_patterns[1], "**/unsupported/**");
2077 }
2078
2079 #[test]
2080 fn lint_config_build_exclude_set_empty() {
2081 let config = LintConfig::default();
2082 assert!(config.build_exclude_set().is_none());
2083 }
2084
2085 #[test]
2086 fn lint_config_build_exclude_set_matches() {
2087 let config = LintConfig {
2088 exclude_patterns: vec!["config/**".to_string()],
2089 ..Default::default()
2090 };
2091 let gs = config.build_exclude_set().expect("should build");
2092 assert!(gs.is_match("config/data_mapping/foo.yaml"));
2093 assert!(gs.is_match("config/nested/deep/bar.yml"));
2094 assert!(!gs.is_match("rules/windows/test.yml"));
2095 }
2096
2097 #[test]
2098 fn cross_ref_version_mismatch_within_file() {
2099 let yaml = r#"
2103title: Base Rule
2104name: base_rule
2105sigma-version: 2
2106logsource:
2107 category: test
2108detection:
2109 selection:
2110 EventID: 1
2111 condition: selection
2112---
2113title: Brute Force
2114sigma-version: 3
2115correlation:
2116 type: event_count
2117 rules:
2118 - base_rule
2119 group-by:
2120 - SourceIP
2121 timespan: 5m
2122 condition:
2123 gte: 10
2124"#;
2125 let w = lint_yaml_str(yaml);
2126 assert!(has_rule(&w, LintRule::SigmaVersionMismatch));
2127 assert!(has_no_rule(&w, LintRule::UnknownRuleReference));
2128 }
2129
2130 #[test]
2131 fn cross_ref_matching_version_no_mismatch() {
2132 let yaml = r#"
2133title: Base Rule
2134name: base_rule
2135sigma-version: 3
2136logsource:
2137 category: test
2138detection:
2139 selection:
2140 EventID: 1
2141 condition: selection
2142---
2143title: Brute Force
2144sigma-version: 3
2145correlation:
2146 type: event_count
2147 rules:
2148 - base_rule
2149 group-by:
2150 - SourceIP
2151 timespan: 5m
2152 condition:
2153 gte: 10
2154"#;
2155 assert!(has_no_rule(
2156 &lint_yaml_str(yaml),
2157 LintRule::SigmaVersionMismatch
2158 ));
2159 }
2160
2161 #[test]
2162 fn cross_ref_unknown_only_with_complete_index() {
2163 let yaml = r#"
2164title: Brute Force
2165correlation:
2166 type: event_count
2167 rules:
2168 - nonexistent_rule
2169 group-by:
2170 - SourceIP
2171 timespan: 5m
2172 condition:
2173 gte: 10
2174"#;
2175 assert!(has_no_rule(
2178 &lint_yaml_str(yaml),
2179 LintRule::UnknownRuleReference
2180 ));
2181
2182 let tmp = tempfile::tempdir().unwrap();
2184 std::fs::write(tmp.path().join("corr.yml"), yaml).unwrap();
2185 let results = lint_yaml_directory(tmp.path()).unwrap();
2186 assert!(
2187 results
2188 .iter()
2189 .flat_map(|r| &r.warnings)
2190 .any(|w| w.rule == LintRule::UnknownRuleReference)
2191 );
2192 }
2193
2194 #[test]
2195 fn cross_ref_resolves_across_files() {
2196 let tmp = tempfile::tempdir().unwrap();
2199 std::fs::write(
2200 tmp.path().join("base.yml"),
2201 r#"
2202title: Base Rule
2203name: base_rule
2204sigma-version: 2
2205logsource:
2206 category: test
2207detection:
2208 selection:
2209 EventID: 1
2210 condition: selection
2211"#,
2212 )
2213 .unwrap();
2214 std::fs::write(
2215 tmp.path().join("corr.yml"),
2216 r#"
2217title: Brute Force
2218sigma-version: 3
2219correlation:
2220 type: event_count
2221 rules:
2222 - base_rule
2223 group-by:
2224 - SourceIP
2225 timespan: 5m
2226 condition:
2227 gte: 10
2228"#,
2229 )
2230 .unwrap();
2231 let results = lint_yaml_directory(tmp.path()).unwrap();
2232 let all: Vec<_> = results.iter().flat_map(|r| &r.warnings).collect();
2233 assert!(all.iter().any(|w| w.rule == LintRule::SigmaVersionMismatch));
2234 assert!(!all.iter().any(|w| w.rule == LintRule::UnknownRuleReference));
2235 }
2236
2237 #[test]
2238 fn lint_directory_with_excludes() {
2239 let tmp = tempfile::tempdir().unwrap();
2240 let rules_dir = tmp.path().join("rules");
2241 let config_dir = tmp.path().join("config");
2242 std::fs::create_dir_all(&rules_dir).unwrap();
2243 std::fs::create_dir_all(&config_dir).unwrap();
2244
2245 std::fs::write(
2246 rules_dir.join("good.yml"),
2247 r#"
2248title: Good Rule
2249logsource:
2250 category: test
2251detection:
2252 sel:
2253 field: value
2254 condition: sel
2255level: medium
2256"#,
2257 )
2258 .unwrap();
2259
2260 std::fs::write(
2261 config_dir.join("mapping.yaml"),
2262 r#"
2263Title: Logon
2264Channel: Security
2265EventID: 4624
2266"#,
2267 )
2268 .unwrap();
2269
2270 let no_exclude = LintConfig::default();
2271 let results = lint_yaml_directory_with_config(tmp.path(), &no_exclude).unwrap();
2272 let config_warnings: Vec<_> = results
2273 .iter()
2274 .filter(|r| r.path.to_string_lossy().contains("config"))
2275 .flat_map(|r| &r.warnings)
2276 .collect();
2277 assert!(
2278 !config_warnings.is_empty(),
2279 "config file should produce warnings without excludes"
2280 );
2281
2282 let with_exclude = LintConfig {
2283 exclude_patterns: vec!["config/**".to_string()],
2284 ..Default::default()
2285 };
2286 let results = lint_yaml_directory_with_config(tmp.path(), &with_exclude).unwrap();
2287 let config_results: Vec<_> = results
2288 .iter()
2289 .filter(|r| r.path.to_string_lossy().contains("config"))
2290 .collect();
2291 assert!(config_results.is_empty(), "config file should be excluded");
2292
2293 let rule_results: Vec<_> = results
2294 .iter()
2295 .filter(|r| r.path.to_string_lossy().contains("good.yml"))
2296 .collect();
2297 assert_eq!(rule_results.len(), 1);
2298 }
2299
2300 #[test]
2301 fn all_lint_keys_are_cached() {
2302 const ALL_LINT_KEYS: &[&str] = &[
2303 "action",
2304 "author",
2305 "condition",
2306 "correlation",
2307 "date",
2308 "description",
2309 "detection",
2310 "field",
2311 "filter",
2312 "generate",
2313 "group-by",
2314 "id",
2315 "level",
2316 "logsource",
2317 "modified",
2318 "name",
2319 "rules",
2320 "selection",
2321 "status",
2322 "tags",
2323 "taxonomy",
2324 "timeframe",
2325 "timespan",
2326 "title",
2327 "type",
2328 ];
2329 for key_str in ALL_LINT_KEYS {
2330 assert!(KEY_CACHE.contains_key(key_str), "key not cached: {key_str}");
2331 }
2332 }
2333
2334 #[test]
2335 fn extra_tag_namespace_suppresses_warning() {
2336 let text = r#"title: Test
2337logsource:
2338 category: test
2339detection:
2340 selection:
2341 field: value
2342 condition: selection
2343level: medium
2344tags:
2345 - myorg.custom_tag
2346"#;
2347 let warnings = lint_yaml_str(text);
2349 assert!(has_rule(&warnings, LintRule::UnknownTagNamespace));
2350
2351 let config = LintConfig {
2353 tag_namespaces: vec!["myorg".to_string()],
2354 ..Default::default()
2355 };
2356 let warnings = lint_yaml_str_with_config(text, &config);
2357 assert!(has_no_rule(&warnings, LintRule::UnknownTagNamespace));
2358 }
2359
2360 #[test]
2361 fn extra_tag_namespace_from_config_file() {
2362 let yaml = r#"
2363tag_namespaces:
2364 - myorg
2365 - internal
2366"#;
2367 let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
2368 std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
2369 let config = LintConfig::load(tmp.path()).unwrap();
2370
2371 assert!(config.tag_namespaces.contains(&"myorg".to_string()));
2372 assert!(config.tag_namespaces.contains(&"internal".to_string()));
2373 }
2374}