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