1mod rules;
23
24use std::collections::{HashMap, HashSet};
25use std::fmt;
26use std::path::Path;
27use std::sync::LazyLock;
28
29use serde::{Deserialize, Serialize};
30use yaml_serde::Value;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
38pub enum Severity {
39 Error,
41 Warning,
43 Info,
45 Hint,
47}
48
49impl fmt::Display for Severity {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Severity::Error => write!(f, "error"),
53 Severity::Warning => write!(f, "warning"),
54 Severity::Info => write!(f, "info"),
55 Severity::Hint => write!(f, "hint"),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
62pub enum LintRule {
63 YamlParseError,
65 NotAMapping,
66 FileReadError,
67 SchemaViolation,
68
69 MissingTitle,
71 EmptyTitle,
72 TitleTooLong,
73 MissingDescription,
74 MissingAuthor,
75 InvalidId,
76 InvalidStatus,
77 MissingLevel,
78 InvalidLevel,
79 InvalidDate,
80 InvalidModified,
81 ModifiedBeforeDate,
82 DescriptionTooLong,
83 NameTooLong,
84 TaxonomyTooLong,
85 NonLowercaseKey,
86
87 MissingLogsource,
89 MissingDetection,
90 MissingCondition,
91 EmptyDetection,
92 InvalidRelatedType,
93 InvalidRelatedId,
94 RelatedMissingRequired,
95 DeprecatedWithoutRelated,
96 InvalidTag,
97 UnknownTagNamespace,
98 DuplicateTags,
99 DuplicateReferences,
100 DuplicateFields,
101 FalsepositiveTooShort,
102 ScopeTooShort,
103 LogsourceValueNotLowercase,
104 ConditionReferencesUnknown,
105 DeprecatedAggregationSyntax,
106
107 MissingCorrelation,
109 MissingCorrelationType,
110 InvalidCorrelationType,
111 MissingCorrelationRules,
112 EmptyCorrelationRules,
113 MissingCorrelationTimespan,
114 InvalidTimespanFormat,
115 MissingGroupBy,
116 MissingCorrelationCondition,
117 MissingConditionField,
118 InvalidConditionOperator,
119 ConditionValueNotNumeric,
120 GenerateNotBoolean,
121
122 MissingFilter,
124 MissingFilterRules,
125 EmptyFilterRules,
126 MissingFilterSelection,
127 MissingFilterCondition,
128 FilterHasLevel,
129 FilterHasStatus,
130 MissingFilterLogsource,
131
132 NullInValueList,
134 SingleValueAllModifier,
135 AllWithRe,
136 IncompatibleModifiers,
137 EmptyValueList,
138 WildcardOnlyValue,
139 UnknownKey,
140}
141
142impl fmt::Display for LintRule {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 let s = match self {
145 LintRule::YamlParseError => "yaml_parse_error",
146 LintRule::NotAMapping => "not_a_mapping",
147 LintRule::FileReadError => "file_read_error",
148 LintRule::SchemaViolation => "schema_violation",
149 LintRule::MissingTitle => "missing_title",
150 LintRule::EmptyTitle => "empty_title",
151 LintRule::TitleTooLong => "title_too_long",
152 LintRule::MissingDescription => "missing_description",
153 LintRule::MissingAuthor => "missing_author",
154 LintRule::InvalidId => "invalid_id",
155 LintRule::InvalidStatus => "invalid_status",
156 LintRule::MissingLevel => "missing_level",
157 LintRule::InvalidLevel => "invalid_level",
158 LintRule::InvalidDate => "invalid_date",
159 LintRule::InvalidModified => "invalid_modified",
160 LintRule::ModifiedBeforeDate => "modified_before_date",
161 LintRule::DescriptionTooLong => "description_too_long",
162 LintRule::NameTooLong => "name_too_long",
163 LintRule::TaxonomyTooLong => "taxonomy_too_long",
164 LintRule::NonLowercaseKey => "non_lowercase_key",
165 LintRule::MissingLogsource => "missing_logsource",
166 LintRule::MissingDetection => "missing_detection",
167 LintRule::MissingCondition => "missing_condition",
168 LintRule::EmptyDetection => "empty_detection",
169 LintRule::InvalidRelatedType => "invalid_related_type",
170 LintRule::InvalidRelatedId => "invalid_related_id",
171 LintRule::RelatedMissingRequired => "related_missing_required",
172 LintRule::DeprecatedWithoutRelated => "deprecated_without_related",
173 LintRule::InvalidTag => "invalid_tag",
174 LintRule::UnknownTagNamespace => "unknown_tag_namespace",
175 LintRule::DuplicateTags => "duplicate_tags",
176 LintRule::DuplicateReferences => "duplicate_references",
177 LintRule::DuplicateFields => "duplicate_fields",
178 LintRule::FalsepositiveTooShort => "falsepositive_too_short",
179 LintRule::ScopeTooShort => "scope_too_short",
180 LintRule::LogsourceValueNotLowercase => "logsource_value_not_lowercase",
181 LintRule::ConditionReferencesUnknown => "condition_references_unknown",
182 LintRule::DeprecatedAggregationSyntax => "deprecated_aggregation_syntax",
183 LintRule::MissingCorrelation => "missing_correlation",
184 LintRule::MissingCorrelationType => "missing_correlation_type",
185 LintRule::InvalidCorrelationType => "invalid_correlation_type",
186 LintRule::MissingCorrelationRules => "missing_correlation_rules",
187 LintRule::EmptyCorrelationRules => "empty_correlation_rules",
188 LintRule::MissingCorrelationTimespan => "missing_correlation_timespan",
189 LintRule::InvalidTimespanFormat => "invalid_timespan_format",
190 LintRule::MissingGroupBy => "missing_group_by",
191 LintRule::MissingCorrelationCondition => "missing_correlation_condition",
192 LintRule::MissingConditionField => "missing_condition_field",
193 LintRule::InvalidConditionOperator => "invalid_condition_operator",
194 LintRule::ConditionValueNotNumeric => "condition_value_not_numeric",
195 LintRule::GenerateNotBoolean => "generate_not_boolean",
196 LintRule::MissingFilter => "missing_filter",
197 LintRule::MissingFilterRules => "missing_filter_rules",
198 LintRule::EmptyFilterRules => "empty_filter_rules",
199 LintRule::MissingFilterSelection => "missing_filter_selection",
200 LintRule::MissingFilterCondition => "missing_filter_condition",
201 LintRule::FilterHasLevel => "filter_has_level",
202 LintRule::FilterHasStatus => "filter_has_status",
203 LintRule::MissingFilterLogsource => "missing_filter_logsource",
204 LintRule::NullInValueList => "null_in_value_list",
205 LintRule::SingleValueAllModifier => "single_value_all_modifier",
206 LintRule::AllWithRe => "all_with_re",
207 LintRule::IncompatibleModifiers => "incompatible_modifiers",
208 LintRule::EmptyValueList => "empty_value_list",
209 LintRule::WildcardOnlyValue => "wildcard_only_value",
210 LintRule::UnknownKey => "unknown_key",
211 };
212 write!(f, "{s}")
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
218pub struct Span {
219 pub start_line: u32,
220 pub start_col: u32,
221 pub end_line: u32,
222 pub end_col: u32,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
231pub enum FixDisposition {
232 Safe,
233 Unsafe,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
238pub enum FixPatch {
239 ReplaceValue { path: String, new_value: String },
240 ReplaceKey { path: String, new_key: String },
241 Remove { path: String },
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
246pub struct Fix {
247 pub title: String,
248 pub disposition: FixDisposition,
249 pub patches: Vec<FixPatch>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
254pub struct LintWarning {
255 pub rule: LintRule,
256 pub severity: Severity,
257 pub message: String,
258 pub path: String,
259 pub span: Option<Span>,
260 pub fix: Option<Fix>,
261}
262
263impl fmt::Display for LintWarning {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 write!(
266 f,
267 "{}[{}]: {}\n --> {}",
268 self.severity, self.rule, self.message, self.path
269 )
270 }
271}
272
273#[derive(Debug, Clone, Serialize)]
275pub struct FileLintResult {
276 pub path: std::path::PathBuf,
277 pub warnings: Vec<LintWarning>,
278}
279
280impl FileLintResult {
281 pub fn has_errors(&self) -> bool {
282 self.warnings.iter().any(|w| w.severity == Severity::Error)
283 }
284
285 pub fn error_count(&self) -> usize {
286 self.warnings
287 .iter()
288 .filter(|w| w.severity == Severity::Error)
289 .count()
290 }
291
292 pub fn warning_count(&self) -> usize {
293 self.warnings
294 .iter()
295 .filter(|w| w.severity == Severity::Warning)
296 .count()
297 }
298
299 pub fn info_count(&self) -> usize {
300 self.warnings
301 .iter()
302 .filter(|w| w.severity == Severity::Info)
303 .count()
304 }
305
306 pub fn hint_count(&self) -> usize {
307 self.warnings
308 .iter()
309 .filter(|w| w.severity == Severity::Hint)
310 .count()
311 }
312}
313
314static KEY_CACHE: LazyLock<HashMap<&'static str, Value>> = LazyLock::new(|| {
319 [
320 "action",
321 "author",
322 "category",
323 "condition",
324 "correlation",
325 "date",
326 "description",
327 "detection",
328 "falsepositives",
329 "field",
330 "fields",
331 "filter",
332 "generate",
333 "group-by",
334 "id",
335 "level",
336 "logsource",
337 "modified",
338 "name",
339 "product",
340 "references",
341 "related",
342 "rules",
343 "scope",
344 "selection",
345 "service",
346 "status",
347 "tags",
348 "taxonomy",
349 "timeframe",
350 "timespan",
351 "title",
352 "type",
353 ]
354 .into_iter()
355 .map(|n| (n, Value::String(n.into())))
356 .collect()
357});
358
359pub(crate) fn key(s: &str) -> &'static Value {
360 KEY_CACHE
361 .get(s)
362 .unwrap_or_else(|| panic!("lint key not pre-cached: \"{s}\" — add it to KEY_CACHE"))
363}
364
365pub(crate) fn get_str<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a str> {
366 m.get(key(k)).and_then(|v| v.as_str())
367}
368
369pub(crate) fn get_mapping<'a>(
370 m: &'a yaml_serde::Mapping,
371 k: &str,
372) -> Option<&'a yaml_serde::Mapping> {
373 m.get(key(k)).and_then(|v| v.as_mapping())
374}
375
376pub(crate) fn get_seq<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a yaml_serde::Sequence> {
377 m.get(key(k)).and_then(|v| v.as_sequence())
378}
379
380pub(crate) fn warn(
381 rule: LintRule,
382 severity: Severity,
383 message: impl Into<String>,
384 path: impl Into<String>,
385) -> LintWarning {
386 LintWarning {
387 rule,
388 severity,
389 message: message.into(),
390 path: path.into(),
391 span: None,
392 fix: None,
393 }
394}
395
396pub(crate) fn err(
397 rule: LintRule,
398 message: impl Into<String>,
399 path: impl Into<String>,
400) -> LintWarning {
401 warn(rule, Severity::Error, message, path)
402}
403
404pub(crate) fn warning(
405 rule: LintRule,
406 message: impl Into<String>,
407 path: impl Into<String>,
408) -> LintWarning {
409 warn(rule, Severity::Warning, message, path)
410}
411
412pub(crate) fn info(
413 rule: LintRule,
414 message: impl Into<String>,
415 path: impl Into<String>,
416) -> LintWarning {
417 warn(rule, Severity::Info, message, path)
418}
419
420pub(crate) fn safe_fix(title: impl Into<String>, patches: Vec<FixPatch>) -> Option<Fix> {
421 Some(Fix {
422 title: title.into(),
423 disposition: FixDisposition::Safe,
424 patches,
425 })
426}
427
428pub(crate) fn closest_match<'a>(
430 input: &str,
431 candidates: &[&'a str],
432 max_distance: usize,
433) -> Option<&'a str> {
434 candidates
435 .iter()
436 .filter(|c| edit_distance(input, c) <= max_distance)
437 .min_by_key(|c| edit_distance(input, c))
438 .copied()
439}
440
441pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
443 let (a_len, b_len) = (a.len(), b.len());
444 if a_len == 0 {
445 return b_len;
446 }
447 if b_len == 0 {
448 return a_len;
449 }
450 let mut prev: Vec<usize> = (0..=b_len).collect();
451 let mut curr = vec![0; b_len + 1];
452 for (i, ca) in a.bytes().enumerate() {
453 curr[0] = i + 1;
454 for (j, cb) in b.bytes().enumerate() {
455 let cost = if ca == cb { 0 } else { 1 };
456 curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
457 }
458 std::mem::swap(&mut prev, &mut curr);
459 }
460 prev[b_len]
461}
462
463pub(crate) const TYPO_MAX_EDIT_DISTANCE: usize = 2;
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub(crate) enum DocType {
471 Detection,
472 Correlation,
473 Filter,
474}
475
476impl DocType {
477 pub(crate) fn known_keys(&self) -> &'static [&'static str] {
478 match self {
479 DocType::Detection => rules::shared::KNOWN_KEYS_DETECTION,
480 DocType::Correlation => rules::shared::KNOWN_KEYS_CORRELATION,
481 DocType::Filter => rules::shared::KNOWN_KEYS_FILTER,
482 }
483 }
484}
485
486fn detect_doc_type(m: &yaml_serde::Mapping) -> DocType {
487 if m.contains_key(key("correlation")) {
488 DocType::Correlation
489 } else if m.contains_key(key("filter")) {
490 DocType::Filter
491 } else {
492 DocType::Detection
493 }
494}
495
496fn is_action_fragment(m: &yaml_serde::Mapping) -> bool {
497 matches!(get_str(m, "action"), Some("global" | "reset" | "repeat"))
498}
499
500pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
506 let Some(m) = value.as_mapping() else {
507 return vec![err(
508 LintRule::NotAMapping,
509 "document is not a YAML mapping",
510 "/",
511 )];
512 };
513
514 if is_action_fragment(m) {
515 return Vec::new();
516 }
517
518 let mut warnings = Vec::new();
519
520 rules::metadata::lint_shared(m, &mut warnings);
521
522 let doc_type = detect_doc_type(m);
523 match doc_type {
524 DocType::Detection => rules::detection::lint_detection_rule(m, &mut warnings),
525 DocType::Correlation => rules::correlation::lint_correlation_rule(m, &mut warnings),
526 DocType::Filter => rules::filter::lint_filter_rule(m, &mut warnings),
527 }
528
529 rules::shared::lint_unknown_keys(m, doc_type, &mut warnings);
530
531 warnings
532}
533
534pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
536 let mut all_warnings = Vec::new();
537
538 for doc in yaml_serde::Deserializer::from_str(text) {
539 let value: Value = match Value::deserialize(doc) {
540 Ok(v) => v,
541 Err(e) => {
542 let mut w = err(
543 LintRule::YamlParseError,
544 format!("YAML parse error: {e}"),
545 "/",
546 );
547 if let Some(loc) = e.location() {
548 w.span = Some(Span {
549 start_line: loc.line().saturating_sub(1) as u32,
550 start_col: loc.column() as u32,
551 end_line: loc.line().saturating_sub(1) as u32,
552 end_col: loc.column() as u32 + 1,
553 });
554 }
555 all_warnings.push(w);
556 break;
557 }
558 };
559
560 let warnings = lint_yaml_value(&value);
561 for mut w in warnings {
562 w.span = resolve_path_to_span(text, &w.path);
563 all_warnings.push(w);
564 }
565 }
566
567 all_warnings
568}
569
570fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
571 if path == "/" || path.is_empty() {
572 for (i, line) in text.lines().enumerate() {
573 let trimmed = line.trim();
574 if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
575 return Some(Span {
576 start_line: i as u32,
577 start_col: 0,
578 end_line: i as u32,
579 end_col: line.len() as u32,
580 });
581 }
582 }
583 return None;
584 }
585
586 let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
587
588 if segments.is_empty() {
589 return None;
590 }
591
592 let lines: Vec<&str> = text.lines().collect();
593 let mut current_indent: i32 = -1;
594 let mut search_start = 0usize;
595 let mut last_matched_line: Option<usize> = None;
596
597 for segment in &segments {
598 let array_index: Option<usize> = segment.parse().ok();
599 let mut found = false;
600
601 let mut line_num = search_start;
602 while line_num < lines.len() {
603 let line = lines[line_num];
604 let trimmed = line.trim();
605 if trimmed.is_empty() || trimmed.starts_with('#') {
606 line_num += 1;
607 continue;
608 }
609
610 let indent = (line.len() - trimmed.len()) as i32;
611
612 if indent <= current_indent && found {
613 break;
614 }
615 if indent <= current_indent {
616 line_num += 1;
617 continue;
618 }
619
620 if let Some(idx) = array_index {
621 if trimmed.starts_with("- ") && indent > current_indent {
622 let mut count = 0usize;
623 for (offset, sl) in lines[search_start..].iter().enumerate() {
624 let scan = search_start + offset;
625 let st = sl.trim();
626 if st.is_empty() || st.starts_with('#') {
627 continue;
628 }
629 let si = (sl.len() - st.len()) as i32;
630 if si == indent && st.starts_with("- ") {
631 if count == idx {
632 last_matched_line = Some(scan);
633 search_start = scan + 1;
634 current_indent = indent;
635 found = true;
636 break;
637 }
638 count += 1;
639 }
640 if si < indent && count > 0 {
641 break;
642 }
643 }
644 break;
645 }
646 } else {
647 let key_pattern = format!("{segment}:");
648 if trimmed.starts_with(&key_pattern) || trimmed == *segment {
649 last_matched_line = Some(line_num);
650 search_start = line_num + 1;
651 current_indent = indent;
652 found = true;
653 break;
654 }
655 }
656
657 line_num += 1;
658 }
659
660 if !found && last_matched_line.is_none() {
661 break;
662 }
663 }
664
665 last_matched_line.map(|line_num| {
666 let line = lines[line_num];
667 Span {
668 start_line: line_num as u32,
669 start_col: 0,
670 end_line: line_num as u32,
671 end_col: line.len() as u32,
672 }
673 })
674}
675
676pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
678 let content = std::fs::read_to_string(path)?;
679 let warnings = lint_yaml_str(&content);
680 Ok(FileLintResult {
681 path: path.to_path_buf(),
682 warnings,
683 })
684}
685
686pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
688 let mut results = Vec::new();
689 let mut visited = HashSet::new();
690
691 fn walk(
692 dir: &Path,
693 results: &mut Vec<FileLintResult>,
694 visited: &mut HashSet<std::path::PathBuf>,
695 ) -> crate::error::Result<()> {
696 let canonical = match dir.canonicalize() {
697 Ok(p) => p,
698 Err(_) => return Ok(()),
699 };
700 if !visited.insert(canonical) {
701 return Ok(());
702 }
703
704 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
705 entries.sort_by_key(|e| e.path());
706
707 for entry in entries {
708 let path = entry.path();
709
710 if path.is_dir() {
711 if path
712 .file_name()
713 .and_then(|n| n.to_str())
714 .is_some_and(|n| n.starts_with('.'))
715 {
716 continue;
717 }
718 walk(&path, results, visited)?;
719 } else if matches!(
720 path.extension().and_then(|e| e.to_str()),
721 Some("yml" | "yaml")
722 ) {
723 match crate::lint::lint_yaml_file(&path) {
724 Ok(file_result) => results.push(file_result),
725 Err(e) => {
726 results.push(FileLintResult {
727 path: path.clone(),
728 warnings: vec![err(
729 LintRule::FileReadError,
730 format!("error reading file: {e}"),
731 "/",
732 )],
733 });
734 }
735 }
736 }
737 }
738 Ok(())
739 }
740
741 walk(dir, &mut results, &mut visited)?;
742 Ok(results)
743}
744
745#[derive(Debug, Clone, Default, Serialize)]
751pub struct LintConfig {
752 pub disabled_rules: HashSet<String>,
753 pub severity_overrides: HashMap<String, Severity>,
754 pub exclude_patterns: Vec<String>,
755}
756
757#[derive(Debug, Deserialize)]
758struct RawLintConfig {
759 #[serde(default)]
760 disabled_rules: Vec<String>,
761 #[serde(default)]
762 severity_overrides: HashMap<String, String>,
763 #[serde(default)]
764 exclude: Vec<String>,
765}
766
767impl LintConfig {
768 pub fn load(path: &Path) -> crate::error::Result<Self> {
769 let content = std::fs::read_to_string(path)?;
770 let raw: RawLintConfig = yaml_serde::from_str(&content)?;
771
772 let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
773 let mut severity_overrides = HashMap::new();
774 for (rule, sev_str) in &raw.severity_overrides {
775 let sev = match sev_str.as_str() {
776 "error" => Severity::Error,
777 "warning" => Severity::Warning,
778 "info" => Severity::Info,
779 "hint" => Severity::Hint,
780 other => {
781 return Err(crate::error::SigmaParserError::InvalidRule(format!(
782 "invalid severity '{other}' for rule '{rule}' in lint config"
783 )));
784 }
785 };
786 severity_overrides.insert(rule.clone(), sev);
787 }
788
789 Ok(LintConfig {
790 disabled_rules,
791 severity_overrides,
792 exclude_patterns: raw.exclude,
793 })
794 }
795
796 pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
797 let dir = if start_path.is_file() {
798 start_path.parent()?
799 } else {
800 start_path
801 };
802
803 let mut current = dir;
804 loop {
805 let candidate = current.join(".rsigma-lint.yml");
806 if candidate.is_file() {
807 return Some(candidate);
808 }
809 let candidate_yaml = current.join(".rsigma-lint.yaml");
810 if candidate_yaml.is_file() {
811 return Some(candidate_yaml);
812 }
813 current = current.parent()?;
814 }
815 }
816
817 pub fn merge(&mut self, other: &LintConfig) {
818 self.disabled_rules
819 .extend(other.disabled_rules.iter().cloned());
820 for (rule, sev) in &other.severity_overrides {
821 self.severity_overrides.insert(rule.clone(), *sev);
822 }
823 self.exclude_patterns
824 .extend(other.exclude_patterns.iter().cloned());
825 }
826
827 pub fn is_disabled(&self, rule: &LintRule) -> bool {
828 self.disabled_rules.contains(&rule.to_string())
829 }
830
831 pub fn build_exclude_set(&self) -> Option<globset::GlobSet> {
832 if self.exclude_patterns.is_empty() {
833 return None;
834 }
835 let mut builder = globset::GlobSetBuilder::new();
836 for pat in &self.exclude_patterns {
837 if let Ok(glob) = globset::GlobBuilder::new(pat)
838 .literal_separator(false)
839 .build()
840 {
841 builder.add(glob);
842 }
843 }
844 builder.build().ok()
845 }
846}
847
848#[derive(Debug, Clone, Default)]
853pub struct InlineSuppressions {
854 pub disable_all: bool,
855 pub file_disabled: HashSet<String>,
856 pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
857}
858
859pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
860 let mut result = InlineSuppressions::default();
861
862 for (i, line) in text.lines().enumerate() {
863 let trimmed = line.trim();
864
865 let comment = if let Some(pos) = find_yaml_comment(trimmed) {
866 trimmed[pos + 1..].trim()
867 } else {
868 continue;
869 };
870
871 if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
872 let rest = rest.trim();
873 let next_line = (i + 1) as u32;
874 if rest.is_empty() {
875 result.line_disabled.insert(next_line, None);
876 } else {
877 let rules: HashSet<String> = rest
878 .split(',')
879 .map(|s| s.trim().to_string())
880 .filter(|s| !s.is_empty())
881 .collect();
882 if !rules.is_empty() {
883 result
884 .line_disabled
885 .entry(next_line)
886 .and_modify(|existing| {
887 if let Some(existing_set) = existing {
888 existing_set.extend(rules.iter().cloned());
889 }
890 })
891 .or_insert(Some(rules));
892 }
893 }
894 } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
895 let rest = rest.trim();
896 if rest.is_empty() {
897 result.disable_all = true;
898 } else {
899 for rule in rest.split(',') {
900 let rule = rule.trim();
901 if !rule.is_empty() {
902 result.file_disabled.insert(rule.to_string());
903 }
904 }
905 }
906 }
907 }
908
909 result
910}
911
912fn find_yaml_comment(line: &str) -> Option<usize> {
913 let mut in_single = false;
914 let mut in_double = false;
915 for (i, c) in line.char_indices() {
916 match c {
917 '\'' if !in_double => in_single = !in_single,
918 '"' if !in_single => in_double = !in_double,
919 '#' if !in_single && !in_double => return Some(i),
920 _ => {}
921 }
922 }
923 None
924}
925
926impl InlineSuppressions {
927 pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
928 if self.disable_all {
929 return true;
930 }
931
932 let rule_name = warning.rule.to_string();
933 if self.file_disabled.contains(&rule_name) {
934 return true;
935 }
936
937 if let Some(span) = &warning.span
938 && let Some(line_rules) = self.line_disabled.get(&span.start_line)
939 {
940 return match line_rules {
941 None => true,
942 Some(rules) => rules.contains(&rule_name),
943 };
944 }
945
946 false
947 }
948}
949
950pub fn apply_suppressions(
955 warnings: Vec<LintWarning>,
956 config: &LintConfig,
957 inline: &InlineSuppressions,
958) -> Vec<LintWarning> {
959 warnings
960 .into_iter()
961 .filter(|w| !config.is_disabled(&w.rule))
962 .filter(|w| !inline.is_suppressed(w))
963 .map(|mut w| {
964 let rule_name = w.rule.to_string();
965 if let Some(sev) = config.severity_overrides.get(&rule_name) {
966 w.severity = *sev;
967 }
968 w
969 })
970 .collect()
971}
972
973pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
974 let warnings = lint_yaml_str(text);
975 let inline = parse_inline_suppressions(text);
976 apply_suppressions(warnings, config, &inline)
977}
978
979pub fn lint_yaml_file_with_config(
980 path: &Path,
981 config: &LintConfig,
982) -> crate::error::Result<FileLintResult> {
983 let content = std::fs::read_to_string(path)?;
984 let warnings = lint_yaml_str_with_config(&content, config);
985 Ok(FileLintResult {
986 path: path.to_path_buf(),
987 warnings,
988 })
989}
990
991pub fn lint_yaml_directory_with_config(
992 dir: &Path,
993 config: &LintConfig,
994) -> crate::error::Result<Vec<FileLintResult>> {
995 let mut results = Vec::new();
996 let mut visited = HashSet::new();
997 let exclude_set = config.build_exclude_set();
998
999 fn walk(
1000 dir: &Path,
1001 base: &Path,
1002 config: &LintConfig,
1003 exclude_set: &Option<globset::GlobSet>,
1004 results: &mut Vec<FileLintResult>,
1005 visited: &mut HashSet<std::path::PathBuf>,
1006 ) -> crate::error::Result<()> {
1007 let canonical = match dir.canonicalize() {
1008 Ok(p) => p,
1009 Err(_) => return Ok(()),
1010 };
1011 if !visited.insert(canonical) {
1012 return Ok(());
1013 }
1014
1015 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
1016 entries.sort_by_key(|e| e.path());
1017
1018 for entry in entries {
1019 let path = entry.path();
1020
1021 if let Some(gs) = exclude_set
1022 && let Ok(rel) = path.strip_prefix(base)
1023 && gs.is_match(rel)
1024 {
1025 continue;
1026 }
1027
1028 if path.is_dir() {
1029 if path
1030 .file_name()
1031 .and_then(|n| n.to_str())
1032 .is_some_and(|n| n.starts_with('.'))
1033 {
1034 continue;
1035 }
1036 walk(&path, base, config, exclude_set, results, visited)?;
1037 } else if matches!(
1038 path.extension().and_then(|e| e.to_str()),
1039 Some("yml" | "yaml")
1040 ) {
1041 match lint_yaml_file_with_config(&path, config) {
1042 Ok(file_result) => results.push(file_result),
1043 Err(e) => {
1044 results.push(FileLintResult {
1045 path: path.clone(),
1046 warnings: vec![err(
1047 LintRule::FileReadError,
1048 format!("error reading file: {e}"),
1049 "/",
1050 )],
1051 });
1052 }
1053 }
1054 }
1055 }
1056 Ok(())
1057 }
1058
1059 walk(dir, dir, config, &exclude_set, &mut results, &mut visited)?;
1060 Ok(results)
1061}
1062
1063#[cfg(test)]
1068mod tests {
1069 use super::*;
1070
1071 fn yaml_value(yaml: &str) -> Value {
1072 yaml_serde::from_str(yaml).unwrap()
1073 }
1074
1075 fn lint(yaml: &str) -> Vec<LintWarning> {
1076 lint_yaml_value(&yaml_value(yaml))
1077 }
1078
1079 fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1080 warnings.iter().any(|w| w.rule == rule)
1081 }
1082
1083 fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1084 !has_rule(warnings, rule)
1085 }
1086
1087 #[test]
1088 fn valid_detection_rule_no_errors() {
1089 let w = lint(
1090 r#"
1091title: Test Rule
1092id: 929a690e-bef0-4204-a928-ef5e620d6fcc
1093status: test
1094logsource:
1095 category: process_creation
1096 product: windows
1097detection:
1098 selection:
1099 CommandLine|contains: 'whoami'
1100 condition: selection
1101level: medium
1102tags:
1103 - attack.execution
1104 - attack.t1059
1105"#,
1106 );
1107 let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
1108 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1109 }
1110
1111 #[test]
1112 fn not_a_mapping() {
1113 let v: yaml_serde::Value = yaml_serde::from_str("- item1\n- item2").unwrap();
1114 let w = lint_yaml_value(&v);
1115 assert!(has_rule(&w, LintRule::NotAMapping));
1116 }
1117
1118 #[test]
1119 fn lint_yaml_str_produces_spans() {
1120 let text = r#"title: Test
1121status: invalid_status
1122logsource:
1123 category: test
1124detection:
1125 selection:
1126 field: value
1127 condition: selection
1128level: medium
1129"#;
1130 let warnings = lint_yaml_str(text);
1131 let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
1132 assert!(invalid_status.is_some(), "expected InvalidStatus warning");
1133 let span = invalid_status.unwrap().span;
1134 assert!(span.is_some(), "expected span to be resolved");
1135 assert_eq!(span.unwrap().start_line, 1);
1136 }
1137
1138 #[test]
1139 fn yaml_parse_error_uses_correct_rule() {
1140 let text = "title: [unclosed";
1141 let warnings = lint_yaml_str(text);
1142 assert!(has_rule(&warnings, LintRule::YamlParseError));
1143 assert!(has_no_rule(&warnings, LintRule::MissingTitle));
1144 }
1145
1146 #[test]
1147 fn action_global_skipped() {
1148 let w = lint(
1149 r#"
1150action: global
1151title: Global Template
1152logsource:
1153 product: windows
1154"#,
1155 );
1156 assert!(w.is_empty());
1157 }
1158
1159 #[test]
1160 fn action_reset_skipped() {
1161 let w = lint(
1162 r#"
1163action: reset
1164"#,
1165 );
1166 assert!(w.is_empty());
1167 }
1168
1169 #[test]
1170 fn resolve_path_to_span_root() {
1171 let text = "title: Test\nstatus: test\n";
1172 let span = resolve_path_to_span(text, "/");
1173 assert!(span.is_some());
1174 assert_eq!(span.unwrap().start_line, 0);
1175 }
1176
1177 #[test]
1178 fn resolve_path_to_span_top_level_key() {
1179 let text = "title: Test\nstatus: test\nlevel: high\n";
1180 let span = resolve_path_to_span(text, "/status");
1181 assert!(span.is_some());
1182 assert_eq!(span.unwrap().start_line, 1);
1183 }
1184
1185 #[test]
1186 fn resolve_path_to_span_nested_key() {
1187 let text = "title: Test\nlogsource:\n category: test\n product: windows\n";
1188 let span = resolve_path_to_span(text, "/logsource/product");
1189 assert!(span.is_some());
1190 assert_eq!(span.unwrap().start_line, 3);
1191 }
1192
1193 #[test]
1194 fn resolve_path_to_span_missing_key() {
1195 let text = "title: Test\nstatus: test\n";
1196 let span = resolve_path_to_span(text, "/nonexistent");
1197 assert!(span.is_none());
1198 }
1199
1200 #[test]
1201 fn multi_doc_yaml_lints_all_documents() {
1202 let text = r#"title: Rule 1
1203logsource:
1204 category: test
1205detection:
1206 selection:
1207 field: value
1208 condition: selection
1209level: medium
1210---
1211title: Rule 2
1212status: bad_status
1213logsource:
1214 category: test
1215detection:
1216 selection:
1217 field: value
1218 condition: selection
1219level: medium
1220"#;
1221 let warnings = lint_yaml_str(text);
1222 assert!(has_rule(&warnings, LintRule::InvalidStatus));
1223 }
1224
1225 #[test]
1226 fn severity_display() {
1227 assert_eq!(format!("{}", Severity::Error), "error");
1228 assert_eq!(format!("{}", Severity::Warning), "warning");
1229 assert_eq!(format!("{}", Severity::Info), "info");
1230 assert_eq!(format!("{}", Severity::Hint), "hint");
1231 }
1232
1233 #[test]
1234 fn file_lint_result_has_errors() {
1235 let result = FileLintResult {
1236 path: std::path::PathBuf::from("test.yml"),
1237 warnings: vec![
1238 warning(LintRule::TitleTooLong, "too long", "/title"),
1239 err(
1240 LintRule::MissingCondition,
1241 "missing",
1242 "/detection/condition",
1243 ),
1244 ],
1245 };
1246 assert!(result.has_errors());
1247 assert_eq!(result.error_count(), 1);
1248 assert_eq!(result.warning_count(), 1);
1249 }
1250
1251 #[test]
1252 fn file_lint_result_no_errors() {
1253 let result = FileLintResult {
1254 path: std::path::PathBuf::from("test.yml"),
1255 warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
1256 };
1257 assert!(!result.has_errors());
1258 assert_eq!(result.error_count(), 0);
1259 assert_eq!(result.warning_count(), 1);
1260 }
1261
1262 #[test]
1263 fn file_lint_result_empty() {
1264 let result = FileLintResult {
1265 path: std::path::PathBuf::from("test.yml"),
1266 warnings: vec![],
1267 };
1268 assert!(!result.has_errors());
1269 assert_eq!(result.error_count(), 0);
1270 assert_eq!(result.warning_count(), 0);
1271 }
1272
1273 #[test]
1274 fn lint_warning_display() {
1275 let w = err(
1276 LintRule::MissingTitle,
1277 "missing required field 'title'",
1278 "/title",
1279 );
1280 let display = format!("{w}");
1281 assert!(display.contains("error"));
1282 assert!(display.contains("missing_title"));
1283 assert!(display.contains("/title"));
1284 }
1285
1286 #[test]
1287 fn file_lint_result_info_count() {
1288 let result = FileLintResult {
1289 path: std::path::PathBuf::from("test.yml"),
1290 warnings: vec![
1291 info(LintRule::MissingDescription, "missing desc", "/description"),
1292 info(LintRule::MissingAuthor, "missing author", "/author"),
1293 warning(LintRule::TitleTooLong, "too long", "/title"),
1294 ],
1295 };
1296 assert_eq!(result.info_count(), 2);
1297 assert_eq!(result.warning_count(), 1);
1298 assert_eq!(result.error_count(), 0);
1299 assert!(!result.has_errors());
1300 }
1301
1302 #[test]
1303 fn parse_inline_disable_all() {
1304 let text = "# rsigma-disable\ntitle: Test\n";
1305 let sup = parse_inline_suppressions(text);
1306 assert!(sup.disable_all);
1307 }
1308
1309 #[test]
1310 fn parse_inline_disable_specific_rules() {
1311 let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
1312 let sup = parse_inline_suppressions(text);
1313 assert!(!sup.disable_all);
1314 assert!(sup.file_disabled.contains("missing_description"));
1315 assert!(sup.file_disabled.contains("missing_author"));
1316 }
1317
1318 #[test]
1319 fn parse_inline_disable_next_line_all() {
1320 let text = "# rsigma-disable-next-line\ntitle: Test\n";
1321 let sup = parse_inline_suppressions(text);
1322 assert!(!sup.disable_all);
1323 assert!(sup.line_disabled.contains_key(&1));
1324 assert!(sup.line_disabled[&1].is_none());
1325 }
1326
1327 #[test]
1328 fn parse_inline_disable_next_line_specific() {
1329 let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
1330 let sup = parse_inline_suppressions(text);
1331 assert!(sup.line_disabled.contains_key(&2));
1332 let rules = sup.line_disabled[&2].as_ref().unwrap();
1333 assert!(rules.contains("missing_level"));
1334 }
1335
1336 #[test]
1337 fn parse_inline_no_comments() {
1338 let text = "title: Test\nstatus: test\n";
1339 let sup = parse_inline_suppressions(text);
1340 assert!(!sup.disable_all);
1341 assert!(sup.file_disabled.is_empty());
1342 assert!(sup.line_disabled.is_empty());
1343 }
1344
1345 #[test]
1346 fn parse_inline_comment_in_quoted_string() {
1347 let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
1348 let sup = parse_inline_suppressions(text);
1349 assert!(!sup.disable_all);
1350 assert!(sup.file_disabled.is_empty());
1351 }
1352
1353 #[test]
1354 fn apply_suppressions_disables_rule() {
1355 let warnings = vec![
1356 info(LintRule::MissingDescription, "desc", "/description"),
1357 info(LintRule::MissingAuthor, "author", "/author"),
1358 warning(LintRule::TitleTooLong, "title", "/title"),
1359 ];
1360 let mut config = LintConfig::default();
1361 config
1362 .disabled_rules
1363 .insert("missing_description".to_string());
1364 let inline = InlineSuppressions::default();
1365
1366 let result = apply_suppressions(warnings, &config, &inline);
1367 assert_eq!(result.len(), 2);
1368 assert!(
1369 result
1370 .iter()
1371 .all(|w| w.rule != LintRule::MissingDescription)
1372 );
1373 }
1374
1375 #[test]
1376 fn apply_suppressions_severity_override() {
1377 let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
1378 let mut config = LintConfig::default();
1379 config
1380 .severity_overrides
1381 .insert("title_too_long".to_string(), Severity::Info);
1382 let inline = InlineSuppressions::default();
1383
1384 let result = apply_suppressions(warnings, &config, &inline);
1385 assert_eq!(result.len(), 1);
1386 assert_eq!(result[0].severity, Severity::Info);
1387 }
1388
1389 #[test]
1390 fn apply_suppressions_inline_file_disable() {
1391 let warnings = vec![
1392 info(LintRule::MissingDescription, "desc", "/description"),
1393 info(LintRule::MissingAuthor, "author", "/author"),
1394 ];
1395 let config = LintConfig::default();
1396 let mut inline = InlineSuppressions::default();
1397 inline.file_disabled.insert("missing_author".to_string());
1398
1399 let result = apply_suppressions(warnings, &config, &inline);
1400 assert_eq!(result.len(), 1);
1401 assert_eq!(result[0].rule, LintRule::MissingDescription);
1402 }
1403
1404 #[test]
1405 fn apply_suppressions_inline_disable_all() {
1406 let warnings = vec![
1407 err(LintRule::MissingTitle, "title", "/title"),
1408 warning(LintRule::TitleTooLong, "long", "/title"),
1409 ];
1410 let config = LintConfig::default();
1411 let inline = InlineSuppressions {
1412 disable_all: true,
1413 ..Default::default()
1414 };
1415
1416 let result = apply_suppressions(warnings, &config, &inline);
1417 assert!(result.is_empty());
1418 }
1419
1420 #[test]
1421 fn apply_suppressions_inline_next_line() {
1422 let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
1423 w1.span = Some(Span {
1424 start_line: 5,
1425 start_col: 0,
1426 end_line: 5,
1427 end_col: 10,
1428 });
1429 let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
1430 w2.span = Some(Span {
1431 start_line: 6,
1432 start_col: 0,
1433 end_line: 6,
1434 end_col: 10,
1435 });
1436
1437 let config = LintConfig::default();
1438 let mut inline = InlineSuppressions::default();
1439 inline.line_disabled.insert(5, None);
1440
1441 let result = apply_suppressions(vec![w1, w2], &config, &inline);
1442 assert_eq!(result.len(), 1);
1443 assert_eq!(result[0].rule, LintRule::InvalidStatus);
1444 }
1445
1446 #[test]
1447 fn lint_with_config_disables_rules() {
1448 let text = r#"title: Test
1449logsource:
1450 category: test
1451detection:
1452 selection:
1453 field: value
1454 condition: selection
1455level: medium
1456"#;
1457 let mut config = LintConfig::default();
1458 config
1459 .disabled_rules
1460 .insert("missing_description".to_string());
1461 config.disabled_rules.insert("missing_author".to_string());
1462
1463 let warnings = lint_yaml_str_with_config(text, &config);
1464 assert!(
1465 !warnings
1466 .iter()
1467 .any(|w| w.rule == LintRule::MissingDescription)
1468 );
1469 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1470 }
1471
1472 #[test]
1473 fn lint_with_inline_disable_next_line() {
1474 let text = r#"title: Test
1475# rsigma-disable-next-line missing_level
1476logsource:
1477 category: test
1478detection:
1479 selection:
1480 field: value
1481 condition: selection
1482"#;
1483 let config = LintConfig::default();
1484 let warnings = lint_yaml_str_with_config(text, &config);
1485 assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
1486 }
1487
1488 #[test]
1489 fn lint_with_inline_file_disable() {
1490 let text = r#"# rsigma-disable missing_description, missing_author
1491title: Test
1492logsource:
1493 category: test
1494detection:
1495 selection:
1496 field: value
1497 condition: selection
1498level: medium
1499"#;
1500 let config = LintConfig::default();
1501 let warnings = lint_yaml_str_with_config(text, &config);
1502 assert!(
1503 !warnings
1504 .iter()
1505 .any(|w| w.rule == LintRule::MissingDescription)
1506 );
1507 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1508 }
1509
1510 #[test]
1511 fn lint_with_inline_disable_all() {
1512 let text = r#"# rsigma-disable
1513title: Test
1514status: invalid_status
1515logsource:
1516 category: test
1517detection:
1518 selection:
1519 field: value
1520 condition: selection
1521"#;
1522 let config = LintConfig::default();
1523 let warnings = lint_yaml_str_with_config(text, &config);
1524 assert!(warnings.is_empty());
1525 }
1526
1527 #[test]
1528 fn lint_config_merge() {
1529 let mut base = LintConfig::default();
1530 base.disabled_rules.insert("rule_a".to_string());
1531 base.severity_overrides
1532 .insert("rule_b".to_string(), Severity::Info);
1533
1534 let other = LintConfig {
1535 disabled_rules: ["rule_c".to_string()].into_iter().collect(),
1536 severity_overrides: [("rule_d".to_string(), Severity::Hint)]
1537 .into_iter()
1538 .collect(),
1539 exclude_patterns: vec!["test/**".to_string()],
1540 };
1541
1542 base.merge(&other);
1543 assert!(base.disabled_rules.contains("rule_a"));
1544 assert!(base.disabled_rules.contains("rule_c"));
1545 assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
1546 assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
1547 assert_eq!(base.exclude_patterns, vec!["test/**".to_string()]);
1548 }
1549
1550 #[test]
1551 fn lint_config_is_disabled() {
1552 let mut config = LintConfig::default();
1553 config.disabled_rules.insert("missing_title".to_string());
1554 assert!(config.is_disabled(&LintRule::MissingTitle));
1555 assert!(!config.is_disabled(&LintRule::EmptyTitle));
1556 }
1557
1558 #[test]
1559 fn find_yaml_comment_basic() {
1560 assert_eq!(find_yaml_comment("# comment"), Some(0));
1561 assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
1562 assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
1563 assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
1564 assert_eq!(find_yaml_comment("key: value"), None);
1565 }
1566
1567 #[test]
1568 fn no_fix_for_unfixable_rule() {
1569 let w = lint(
1570 r#"
1571title: Test
1572logsource:
1573 category: test
1574"#,
1575 );
1576 assert!(has_rule(&w, LintRule::MissingDetection));
1577 let fix = w
1578 .iter()
1579 .find(|w| w.rule == LintRule::MissingDetection)
1580 .and_then(|w| w.fix.as_ref());
1581 assert!(fix.is_none());
1582 }
1583
1584 #[test]
1585 fn lint_config_exclude_from_yaml() {
1586 let yaml = r#"
1587disabled_rules:
1588 - missing_description
1589exclude:
1590 - "config/**"
1591 - "**/unsupported/**"
1592"#;
1593 let tmp = std::env::temp_dir().join("rsigma_test_exclude.yml");
1594 std::fs::write(&tmp, yaml).unwrap();
1595 let config = LintConfig::load(&tmp).unwrap();
1596 std::fs::remove_file(&tmp).ok();
1597
1598 assert!(config.disabled_rules.contains("missing_description"));
1599 assert_eq!(config.exclude_patterns.len(), 2);
1600 assert_eq!(config.exclude_patterns[0], "config/**");
1601 assert_eq!(config.exclude_patterns[1], "**/unsupported/**");
1602 }
1603
1604 #[test]
1605 fn lint_config_build_exclude_set_empty() {
1606 let config = LintConfig::default();
1607 assert!(config.build_exclude_set().is_none());
1608 }
1609
1610 #[test]
1611 fn lint_config_build_exclude_set_matches() {
1612 let config = LintConfig {
1613 exclude_patterns: vec!["config/**".to_string()],
1614 ..Default::default()
1615 };
1616 let gs = config.build_exclude_set().expect("should build");
1617 assert!(gs.is_match("config/data_mapping/foo.yaml"));
1618 assert!(gs.is_match("config/nested/deep/bar.yml"));
1619 assert!(!gs.is_match("rules/windows/test.yml"));
1620 }
1621
1622 #[test]
1623 fn lint_directory_with_excludes() {
1624 let tmp = tempfile::tempdir().unwrap();
1625 let rules_dir = tmp.path().join("rules");
1626 let config_dir = tmp.path().join("config");
1627 std::fs::create_dir_all(&rules_dir).unwrap();
1628 std::fs::create_dir_all(&config_dir).unwrap();
1629
1630 std::fs::write(
1631 rules_dir.join("good.yml"),
1632 r#"
1633title: Good Rule
1634logsource:
1635 category: test
1636detection:
1637 sel:
1638 field: value
1639 condition: sel
1640level: medium
1641"#,
1642 )
1643 .unwrap();
1644
1645 std::fs::write(
1646 config_dir.join("mapping.yaml"),
1647 r#"
1648Title: Logon
1649Channel: Security
1650EventID: 4624
1651"#,
1652 )
1653 .unwrap();
1654
1655 let no_exclude = LintConfig::default();
1656 let results = lint_yaml_directory_with_config(tmp.path(), &no_exclude).unwrap();
1657 let config_warnings: Vec<_> = results
1658 .iter()
1659 .filter(|r| r.path.to_string_lossy().contains("config"))
1660 .flat_map(|r| &r.warnings)
1661 .collect();
1662 assert!(
1663 !config_warnings.is_empty(),
1664 "config file should produce warnings without excludes"
1665 );
1666
1667 let with_exclude = LintConfig {
1668 exclude_patterns: vec!["config/**".to_string()],
1669 ..Default::default()
1670 };
1671 let results = lint_yaml_directory_with_config(tmp.path(), &with_exclude).unwrap();
1672 let config_results: Vec<_> = results
1673 .iter()
1674 .filter(|r| r.path.to_string_lossy().contains("config"))
1675 .collect();
1676 assert!(config_results.is_empty(), "config file should be excluded");
1677
1678 let rule_results: Vec<_> = results
1679 .iter()
1680 .filter(|r| r.path.to_string_lossy().contains("good.yml"))
1681 .collect();
1682 assert_eq!(rule_results.len(), 1);
1683 }
1684
1685 #[test]
1686 fn all_lint_keys_are_cached() {
1687 const ALL_LINT_KEYS: &[&str] = &[
1688 "action",
1689 "author",
1690 "condition",
1691 "correlation",
1692 "date",
1693 "description",
1694 "detection",
1695 "field",
1696 "filter",
1697 "generate",
1698 "group-by",
1699 "id",
1700 "level",
1701 "logsource",
1702 "modified",
1703 "name",
1704 "rules",
1705 "selection",
1706 "status",
1707 "tags",
1708 "taxonomy",
1709 "timeframe",
1710 "timespan",
1711 "title",
1712 "type",
1713 ];
1714 for key_str in ALL_LINT_KEYS {
1715 assert!(KEY_CACHE.contains_key(key_str), "key not cached: {key_str}");
1716 }
1717 }
1718}