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