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
500fn lint_yaml_value_ext(value: &Value, extra_ns: &[String]) -> Vec<LintWarning> {
505 let Some(m) = value.as_mapping() else {
506 return vec![err(
507 LintRule::NotAMapping,
508 "document is not a YAML mapping",
509 "/",
510 )];
511 };
512
513 if is_action_fragment(m) {
514 return Vec::new();
515 }
516
517 let mut warnings = Vec::new();
518
519 rules::metadata::lint_shared(m, &mut warnings);
520
521 let doc_type = detect_doc_type(m);
522 match doc_type {
523 DocType::Detection => rules::detection::lint_detection_rule(m, &mut warnings, extra_ns),
524 DocType::Correlation => rules::correlation::lint_correlation_rule(m, &mut warnings),
525 DocType::Filter => rules::filter::lint_filter_rule(m, &mut warnings),
526 }
527
528 rules::shared::lint_unknown_keys(m, doc_type, &mut warnings);
529
530 warnings
531}
532
533pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
535 lint_yaml_value_ext(value, &[])
536}
537
538fn lint_yaml_str_ext(text: &str, extra_ns: &[String]) -> Vec<LintWarning> {
539 let mut all_warnings = Vec::new();
540
541 for doc in yaml_serde::Deserializer::from_str(text) {
542 let value: Value = match Value::deserialize(doc) {
543 Ok(v) => v,
544 Err(e) => {
545 let mut w = err(
546 LintRule::YamlParseError,
547 format!("YAML parse error: {e}"),
548 "/",
549 );
550 if let Some(loc) = e.location() {
551 w.span = Some(Span {
552 start_line: loc.line().saturating_sub(1) as u32,
553 start_col: loc.column() as u32,
554 end_line: loc.line().saturating_sub(1) as u32,
555 end_col: loc.column() as u32 + 1,
556 });
557 }
558 all_warnings.push(w);
559 break;
560 }
561 };
562
563 for mut w in lint_yaml_value_ext(&value, extra_ns) {
564 w.span = resolve_path_to_span(text, &w.path);
565 all_warnings.push(w);
566 }
567 }
568
569 all_warnings
570}
571
572pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
574 lint_yaml_str_ext(text, &[])
575}
576
577fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
578 if path == "/" || path.is_empty() {
579 for (i, line) in text.lines().enumerate() {
580 let trimmed = line.trim();
581 if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
582 return Some(Span {
583 start_line: i as u32,
584 start_col: 0,
585 end_line: i as u32,
586 end_col: line.len() as u32,
587 });
588 }
589 }
590 return None;
591 }
592
593 let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
594
595 if segments.is_empty() {
596 return None;
597 }
598
599 let lines: Vec<&str> = text.lines().collect();
600 let mut current_indent: i32 = -1;
601 let mut search_start = 0usize;
602 let mut last_matched_line: Option<usize> = None;
603
604 for segment in &segments {
605 let array_index: Option<usize> = segment.parse().ok();
606 let mut found = false;
607
608 let mut line_num = search_start;
609 while line_num < lines.len() {
610 let line = lines[line_num];
611 let trimmed = line.trim();
612 if trimmed.is_empty() || trimmed.starts_with('#') {
613 line_num += 1;
614 continue;
615 }
616
617 let indent = (line.len() - trimmed.len()) as i32;
618
619 if indent <= current_indent && found {
620 break;
621 }
622 if indent <= current_indent {
623 line_num += 1;
624 continue;
625 }
626
627 if let Some(idx) = array_index {
628 if trimmed.starts_with("- ") && indent > current_indent {
629 let mut count = 0usize;
630 for (offset, sl) in lines[search_start..].iter().enumerate() {
631 let scan = search_start + offset;
632 let st = sl.trim();
633 if st.is_empty() || st.starts_with('#') {
634 continue;
635 }
636 let si = (sl.len() - st.len()) as i32;
637 if si == indent && st.starts_with("- ") {
638 if count == idx {
639 last_matched_line = Some(scan);
640 search_start = scan + 1;
641 current_indent = indent;
642 found = true;
643 break;
644 }
645 count += 1;
646 }
647 if si < indent && count > 0 {
648 break;
649 }
650 }
651 break;
652 }
653 } else {
654 let key_pattern = format!("{segment}:");
655 if trimmed.starts_with(&key_pattern) || trimmed == *segment {
656 last_matched_line = Some(line_num);
657 search_start = line_num + 1;
658 current_indent = indent;
659 found = true;
660 break;
661 }
662 }
663
664 line_num += 1;
665 }
666
667 if !found && last_matched_line.is_none() {
668 break;
669 }
670 }
671
672 last_matched_line.map(|line_num| {
673 let line = lines[line_num];
674 Span {
675 start_line: line_num as u32,
676 start_col: 0,
677 end_line: line_num as u32,
678 end_col: line.len() as u32,
679 }
680 })
681}
682
683pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
685 let content = std::fs::read_to_string(path)?;
686 let warnings = lint_yaml_str(&content);
687 Ok(FileLintResult {
688 path: path.to_path_buf(),
689 warnings,
690 })
691}
692
693pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
695 let mut results = Vec::new();
696 let mut visited = HashSet::new();
697
698 fn walk(
699 dir: &Path,
700 results: &mut Vec<FileLintResult>,
701 visited: &mut HashSet<std::path::PathBuf>,
702 ) -> crate::error::Result<()> {
703 let canonical = match dir.canonicalize() {
704 Ok(p) => p,
705 Err(_) => return Ok(()),
706 };
707 if !visited.insert(canonical) {
708 return Ok(());
709 }
710
711 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
712 entries.sort_by_key(|e| e.path());
713
714 for entry in entries {
715 let path = entry.path();
716
717 if path.is_dir() {
718 if path
719 .file_name()
720 .and_then(|n| n.to_str())
721 .is_some_and(|n| n.starts_with('.'))
722 {
723 continue;
724 }
725 walk(&path, results, visited)?;
726 } else if matches!(
727 path.extension().and_then(|e| e.to_str()),
728 Some("yml" | "yaml")
729 ) {
730 match crate::lint::lint_yaml_file(&path) {
731 Ok(file_result) => results.push(file_result),
732 Err(e) => {
733 results.push(FileLintResult {
734 path: path.clone(),
735 warnings: vec![err(
736 LintRule::FileReadError,
737 format!("error reading file: {e}"),
738 "/",
739 )],
740 });
741 }
742 }
743 }
744 }
745 Ok(())
746 }
747
748 walk(dir, &mut results, &mut visited)?;
749 Ok(results)
750}
751
752#[derive(Debug, Clone, Default, Serialize)]
758pub struct LintConfig {
759 pub disabled_rules: HashSet<String>,
760 pub severity_overrides: HashMap<String, Severity>,
761 pub exclude_patterns: Vec<String>,
762 pub tag_namespaces: Vec<String>,
764}
765
766#[derive(Debug, Deserialize)]
767struct RawLintConfig {
768 #[serde(default)]
769 disabled_rules: Vec<String>,
770 #[serde(default)]
771 severity_overrides: HashMap<String, String>,
772 #[serde(default)]
773 exclude: Vec<String>,
774 #[serde(default)]
775 tag_namespaces: Vec<String>,
776}
777
778fn dedup_preserving_order(items: &mut Vec<String>) {
782 let mut seen = HashSet::new();
783 items.retain(|item| seen.insert(item.clone()));
784}
785
786impl LintConfig {
787 pub fn load(path: &Path) -> crate::error::Result<Self> {
788 let content = std::fs::read_to_string(path)?;
789 let raw: RawLintConfig = yaml_serde::from_str(&content)?;
790
791 let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
792 let mut severity_overrides = HashMap::new();
793 for (rule, sev_str) in &raw.severity_overrides {
794 let sev = match sev_str.as_str() {
795 "error" => Severity::Error,
796 "warning" => Severity::Warning,
797 "info" => Severity::Info,
798 "hint" => Severity::Hint,
799 other => {
800 return Err(crate::error::SigmaParserError::InvalidRule(format!(
801 "invalid severity '{other}' for rule '{rule}' in lint config"
802 )));
803 }
804 };
805 severity_overrides.insert(rule.clone(), sev);
806 }
807
808 let mut exclude_patterns = raw.exclude;
809 dedup_preserving_order(&mut exclude_patterns);
810
811 let mut tag_namespaces: Vec<String> = raw
812 .tag_namespaces
813 .into_iter()
814 .map(|s| s.to_lowercase())
815 .collect();
816 dedup_preserving_order(&mut tag_namespaces);
817
818 Ok(LintConfig {
819 disabled_rules,
820 severity_overrides,
821 exclude_patterns,
822 tag_namespaces,
823 })
824 }
825
826 pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
827 let dir = if start_path.is_file() {
828 start_path.parent()?
829 } else {
830 start_path
831 };
832
833 let mut current = dir;
834 loop {
835 let candidate = current.join(".rsigma-lint.yml");
836 if candidate.is_file() {
837 return Some(candidate);
838 }
839 let candidate_yaml = current.join(".rsigma-lint.yaml");
840 if candidate_yaml.is_file() {
841 return Some(candidate_yaml);
842 }
843 current = current.parent()?;
844 }
845 }
846
847 pub fn merge(&mut self, other: &LintConfig) {
848 self.disabled_rules
849 .extend(other.disabled_rules.iter().cloned());
850 for (rule, sev) in &other.severity_overrides {
851 self.severity_overrides.insert(rule.clone(), *sev);
852 }
853 self.exclude_patterns
854 .extend(other.exclude_patterns.iter().cloned());
855 dedup_preserving_order(&mut self.exclude_patterns);
856 self.tag_namespaces
857 .extend(other.tag_namespaces.iter().cloned());
858 dedup_preserving_order(&mut self.tag_namespaces);
859 }
860
861 pub fn is_disabled(&self, rule: &LintRule) -> bool {
862 self.disabled_rules.contains(&rule.to_string())
863 }
864
865 pub fn build_exclude_set(&self) -> Option<globset::GlobSet> {
866 if self.exclude_patterns.is_empty() {
867 return None;
868 }
869 let mut builder = globset::GlobSetBuilder::new();
870 for pat in &self.exclude_patterns {
871 if let Ok(glob) = globset::GlobBuilder::new(pat)
872 .literal_separator(false)
873 .build()
874 {
875 builder.add(glob);
876 }
877 }
878 builder.build().ok()
879 }
880}
881
882#[derive(Debug, Clone, Default)]
887pub struct InlineSuppressions {
888 pub disable_all: bool,
889 pub file_disabled: HashSet<String>,
890 pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
891}
892
893pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
894 let mut result = InlineSuppressions::default();
895
896 for (i, line) in text.lines().enumerate() {
897 let trimmed = line.trim();
898
899 let comment = if let Some(pos) = find_yaml_comment(trimmed) {
900 trimmed[pos + 1..].trim()
901 } else {
902 continue;
903 };
904
905 if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
906 let rest = rest.trim();
907 let next_line = (i + 1) as u32;
908 if rest.is_empty() {
909 result.line_disabled.insert(next_line, None);
910 } else {
911 let rules: HashSet<String> = rest
912 .split(',')
913 .map(|s| s.trim().to_string())
914 .filter(|s| !s.is_empty())
915 .collect();
916 if !rules.is_empty() {
917 result
918 .line_disabled
919 .entry(next_line)
920 .and_modify(|existing| {
921 if let Some(existing_set) = existing {
922 existing_set.extend(rules.iter().cloned());
923 }
924 })
925 .or_insert(Some(rules));
926 }
927 }
928 } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
929 let rest = rest.trim();
930 if rest.is_empty() {
931 result.disable_all = true;
932 } else {
933 for rule in rest.split(',') {
934 let rule = rule.trim();
935 if !rule.is_empty() {
936 result.file_disabled.insert(rule.to_string());
937 }
938 }
939 }
940 }
941 }
942
943 result
944}
945
946fn find_yaml_comment(line: &str) -> Option<usize> {
947 let mut in_single = false;
948 let mut in_double = false;
949 for (i, c) in line.char_indices() {
950 match c {
951 '\'' if !in_double => in_single = !in_single,
952 '"' if !in_single => in_double = !in_double,
953 '#' if !in_single && !in_double => return Some(i),
954 _ => {}
955 }
956 }
957 None
958}
959
960impl InlineSuppressions {
961 pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
962 if self.disable_all {
963 return true;
964 }
965
966 let rule_name = warning.rule.to_string();
967 if self.file_disabled.contains(&rule_name) {
968 return true;
969 }
970
971 if let Some(span) = &warning.span
972 && let Some(line_rules) = self.line_disabled.get(&span.start_line)
973 {
974 return match line_rules {
975 None => true,
976 Some(rules) => rules.contains(&rule_name),
977 };
978 }
979
980 false
981 }
982}
983
984pub fn apply_suppressions(
989 warnings: Vec<LintWarning>,
990 config: &LintConfig,
991 inline: &InlineSuppressions,
992) -> Vec<LintWarning> {
993 warnings
994 .into_iter()
995 .filter(|w| !config.is_disabled(&w.rule))
996 .filter(|w| !inline.is_suppressed(w))
997 .map(|mut w| {
998 let rule_name = w.rule.to_string();
999 if let Some(sev) = config.severity_overrides.get(&rule_name) {
1000 w.severity = *sev;
1001 }
1002 w
1003 })
1004 .collect()
1005}
1006
1007pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
1008 let warnings = lint_yaml_str_ext(text, &config.tag_namespaces);
1009 let inline = parse_inline_suppressions(text);
1010 apply_suppressions(warnings, config, &inline)
1011}
1012
1013pub fn lint_yaml_file_with_config(
1014 path: &Path,
1015 config: &LintConfig,
1016) -> crate::error::Result<FileLintResult> {
1017 let content = std::fs::read_to_string(path)?;
1018 let warnings = lint_yaml_str_with_config(&content, config);
1019 Ok(FileLintResult {
1020 path: path.to_path_buf(),
1021 warnings,
1022 })
1023}
1024
1025pub fn lint_yaml_directory_with_config(
1026 dir: &Path,
1027 config: &LintConfig,
1028) -> crate::error::Result<Vec<FileLintResult>> {
1029 let mut results = Vec::new();
1030 let mut visited = HashSet::new();
1031 let exclude_set = config.build_exclude_set();
1032
1033 fn walk(
1034 dir: &Path,
1035 base: &Path,
1036 config: &LintConfig,
1037 exclude_set: &Option<globset::GlobSet>,
1038 results: &mut Vec<FileLintResult>,
1039 visited: &mut HashSet<std::path::PathBuf>,
1040 ) -> crate::error::Result<()> {
1041 let canonical = match dir.canonicalize() {
1042 Ok(p) => p,
1043 Err(_) => return Ok(()),
1044 };
1045 if !visited.insert(canonical) {
1046 return Ok(());
1047 }
1048
1049 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
1050 entries.sort_by_key(|e| e.path());
1051
1052 for entry in entries {
1053 let path = entry.path();
1054
1055 if let Some(gs) = exclude_set
1056 && let Ok(rel) = path.strip_prefix(base)
1057 && gs.is_match(rel)
1058 {
1059 continue;
1060 }
1061
1062 if path.is_dir() {
1063 if path
1064 .file_name()
1065 .and_then(|n| n.to_str())
1066 .is_some_and(|n| n.starts_with('.'))
1067 {
1068 continue;
1069 }
1070 walk(&path, base, config, exclude_set, results, visited)?;
1071 } else if matches!(
1072 path.extension().and_then(|e| e.to_str()),
1073 Some("yml" | "yaml")
1074 ) {
1075 match lint_yaml_file_with_config(&path, config) {
1076 Ok(file_result) => results.push(file_result),
1077 Err(e) => {
1078 results.push(FileLintResult {
1079 path: path.clone(),
1080 warnings: vec![err(
1081 LintRule::FileReadError,
1082 format!("error reading file: {e}"),
1083 "/",
1084 )],
1085 });
1086 }
1087 }
1088 }
1089 }
1090 Ok(())
1091 }
1092
1093 walk(dir, dir, config, &exclude_set, &mut results, &mut visited)?;
1094 Ok(results)
1095}
1096
1097#[cfg(test)]
1102mod tests {
1103 use super::*;
1104
1105 fn yaml_value(yaml: &str) -> Value {
1106 yaml_serde::from_str(yaml).unwrap()
1107 }
1108
1109 fn lint(yaml: &str) -> Vec<LintWarning> {
1110 lint_yaml_value(&yaml_value(yaml))
1111 }
1112
1113 fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1114 warnings.iter().any(|w| w.rule == rule)
1115 }
1116
1117 fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1118 !has_rule(warnings, rule)
1119 }
1120
1121 #[test]
1122 fn valid_detection_rule_no_errors() {
1123 let w = lint(
1124 r#"
1125title: Test Rule
1126id: 929a690e-bef0-4204-a928-ef5e620d6fcc
1127status: test
1128logsource:
1129 category: process_creation
1130 product: windows
1131detection:
1132 selection:
1133 CommandLine|contains: 'whoami'
1134 condition: selection
1135level: medium
1136tags:
1137 - attack.execution
1138 - attack.t1059
1139"#,
1140 );
1141 let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
1142 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1143 }
1144
1145 #[test]
1146 fn not_a_mapping() {
1147 let v: yaml_serde::Value = yaml_serde::from_str("- item1\n- item2").unwrap();
1148 let w = lint_yaml_value(&v);
1149 assert!(has_rule(&w, LintRule::NotAMapping));
1150 }
1151
1152 #[test]
1153 fn lint_yaml_str_produces_spans() {
1154 let text = r#"title: Test
1155status: invalid_status
1156logsource:
1157 category: test
1158detection:
1159 selection:
1160 field: value
1161 condition: selection
1162level: medium
1163"#;
1164 let warnings = lint_yaml_str(text);
1165 let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
1166 assert!(invalid_status.is_some(), "expected InvalidStatus warning");
1167 let span = invalid_status.unwrap().span;
1168 assert!(span.is_some(), "expected span to be resolved");
1169 assert_eq!(span.unwrap().start_line, 1);
1170 }
1171
1172 #[test]
1173 fn yaml_parse_error_uses_correct_rule() {
1174 let text = "title: [unclosed";
1175 let warnings = lint_yaml_str(text);
1176 assert!(has_rule(&warnings, LintRule::YamlParseError));
1177 assert!(has_no_rule(&warnings, LintRule::MissingTitle));
1178 }
1179
1180 #[test]
1181 fn action_global_skipped() {
1182 let w = lint(
1183 r#"
1184action: global
1185title: Global Template
1186logsource:
1187 product: windows
1188"#,
1189 );
1190 assert!(w.is_empty());
1191 }
1192
1193 #[test]
1194 fn action_reset_skipped() {
1195 let w = lint(
1196 r#"
1197action: reset
1198"#,
1199 );
1200 assert!(w.is_empty());
1201 }
1202
1203 #[test]
1204 fn resolve_path_to_span_root() {
1205 let text = "title: Test\nstatus: test\n";
1206 let span = resolve_path_to_span(text, "/");
1207 assert!(span.is_some());
1208 assert_eq!(span.unwrap().start_line, 0);
1209 }
1210
1211 #[test]
1212 fn resolve_path_to_span_top_level_key() {
1213 let text = "title: Test\nstatus: test\nlevel: high\n";
1214 let span = resolve_path_to_span(text, "/status");
1215 assert!(span.is_some());
1216 assert_eq!(span.unwrap().start_line, 1);
1217 }
1218
1219 #[test]
1220 fn resolve_path_to_span_nested_key() {
1221 let text = "title: Test\nlogsource:\n category: test\n product: windows\n";
1222 let span = resolve_path_to_span(text, "/logsource/product");
1223 assert!(span.is_some());
1224 assert_eq!(span.unwrap().start_line, 3);
1225 }
1226
1227 #[test]
1228 fn resolve_path_to_span_missing_key() {
1229 let text = "title: Test\nstatus: test\n";
1230 let span = resolve_path_to_span(text, "/nonexistent");
1231 assert!(span.is_none());
1232 }
1233
1234 #[test]
1235 fn multi_doc_yaml_lints_all_documents() {
1236 let text = r#"title: Rule 1
1237logsource:
1238 category: test
1239detection:
1240 selection:
1241 field: value
1242 condition: selection
1243level: medium
1244---
1245title: Rule 2
1246status: bad_status
1247logsource:
1248 category: test
1249detection:
1250 selection:
1251 field: value
1252 condition: selection
1253level: medium
1254"#;
1255 let warnings = lint_yaml_str(text);
1256 assert!(has_rule(&warnings, LintRule::InvalidStatus));
1257 }
1258
1259 #[test]
1260 fn severity_display() {
1261 assert_eq!(format!("{}", Severity::Error), "error");
1262 assert_eq!(format!("{}", Severity::Warning), "warning");
1263 assert_eq!(format!("{}", Severity::Info), "info");
1264 assert_eq!(format!("{}", Severity::Hint), "hint");
1265 }
1266
1267 #[test]
1268 fn file_lint_result_has_errors() {
1269 let result = FileLintResult {
1270 path: std::path::PathBuf::from("test.yml"),
1271 warnings: vec![
1272 warning(LintRule::TitleTooLong, "too long", "/title"),
1273 err(
1274 LintRule::MissingCondition,
1275 "missing",
1276 "/detection/condition",
1277 ),
1278 ],
1279 };
1280 assert!(result.has_errors());
1281 assert_eq!(result.error_count(), 1);
1282 assert_eq!(result.warning_count(), 1);
1283 }
1284
1285 #[test]
1286 fn file_lint_result_no_errors() {
1287 let result = FileLintResult {
1288 path: std::path::PathBuf::from("test.yml"),
1289 warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
1290 };
1291 assert!(!result.has_errors());
1292 assert_eq!(result.error_count(), 0);
1293 assert_eq!(result.warning_count(), 1);
1294 }
1295
1296 #[test]
1297 fn file_lint_result_empty() {
1298 let result = FileLintResult {
1299 path: std::path::PathBuf::from("test.yml"),
1300 warnings: vec![],
1301 };
1302 assert!(!result.has_errors());
1303 assert_eq!(result.error_count(), 0);
1304 assert_eq!(result.warning_count(), 0);
1305 }
1306
1307 #[test]
1308 fn lint_warning_display() {
1309 let w = err(
1310 LintRule::MissingTitle,
1311 "missing required field 'title'",
1312 "/title",
1313 );
1314 let display = format!("{w}");
1315 assert!(display.contains("error"));
1316 assert!(display.contains("missing_title"));
1317 assert!(display.contains("/title"));
1318 }
1319
1320 #[test]
1321 fn file_lint_result_info_count() {
1322 let result = FileLintResult {
1323 path: std::path::PathBuf::from("test.yml"),
1324 warnings: vec![
1325 info(LintRule::MissingDescription, "missing desc", "/description"),
1326 info(LintRule::MissingAuthor, "missing author", "/author"),
1327 warning(LintRule::TitleTooLong, "too long", "/title"),
1328 ],
1329 };
1330 assert_eq!(result.info_count(), 2);
1331 assert_eq!(result.warning_count(), 1);
1332 assert_eq!(result.error_count(), 0);
1333 assert!(!result.has_errors());
1334 }
1335
1336 #[test]
1337 fn parse_inline_disable_all() {
1338 let text = "# rsigma-disable\ntitle: Test\n";
1339 let sup = parse_inline_suppressions(text);
1340 assert!(sup.disable_all);
1341 }
1342
1343 #[test]
1344 fn parse_inline_disable_specific_rules() {
1345 let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
1346 let sup = parse_inline_suppressions(text);
1347 assert!(!sup.disable_all);
1348 assert!(sup.file_disabled.contains("missing_description"));
1349 assert!(sup.file_disabled.contains("missing_author"));
1350 }
1351
1352 #[test]
1353 fn parse_inline_disable_next_line_all() {
1354 let text = "# rsigma-disable-next-line\ntitle: Test\n";
1355 let sup = parse_inline_suppressions(text);
1356 assert!(!sup.disable_all);
1357 assert!(sup.line_disabled.contains_key(&1));
1358 assert!(sup.line_disabled[&1].is_none());
1359 }
1360
1361 #[test]
1362 fn parse_inline_disable_next_line_specific() {
1363 let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
1364 let sup = parse_inline_suppressions(text);
1365 assert!(sup.line_disabled.contains_key(&2));
1366 let rules = sup.line_disabled[&2].as_ref().unwrap();
1367 assert!(rules.contains("missing_level"));
1368 }
1369
1370 #[test]
1371 fn parse_inline_no_comments() {
1372 let text = "title: Test\nstatus: test\n";
1373 let sup = parse_inline_suppressions(text);
1374 assert!(!sup.disable_all);
1375 assert!(sup.file_disabled.is_empty());
1376 assert!(sup.line_disabled.is_empty());
1377 }
1378
1379 #[test]
1380 fn parse_inline_comment_in_quoted_string() {
1381 let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
1382 let sup = parse_inline_suppressions(text);
1383 assert!(!sup.disable_all);
1384 assert!(sup.file_disabled.is_empty());
1385 }
1386
1387 #[test]
1388 fn apply_suppressions_disables_rule() {
1389 let warnings = vec![
1390 info(LintRule::MissingDescription, "desc", "/description"),
1391 info(LintRule::MissingAuthor, "author", "/author"),
1392 warning(LintRule::TitleTooLong, "title", "/title"),
1393 ];
1394 let mut config = LintConfig::default();
1395 config
1396 .disabled_rules
1397 .insert("missing_description".to_string());
1398 let inline = InlineSuppressions::default();
1399
1400 let result = apply_suppressions(warnings, &config, &inline);
1401 assert_eq!(result.len(), 2);
1402 assert!(
1403 result
1404 .iter()
1405 .all(|w| w.rule != LintRule::MissingDescription)
1406 );
1407 }
1408
1409 #[test]
1410 fn apply_suppressions_severity_override() {
1411 let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
1412 let mut config = LintConfig::default();
1413 config
1414 .severity_overrides
1415 .insert("title_too_long".to_string(), Severity::Info);
1416 let inline = InlineSuppressions::default();
1417
1418 let result = apply_suppressions(warnings, &config, &inline);
1419 assert_eq!(result.len(), 1);
1420 assert_eq!(result[0].severity, Severity::Info);
1421 }
1422
1423 #[test]
1424 fn apply_suppressions_inline_file_disable() {
1425 let warnings = vec![
1426 info(LintRule::MissingDescription, "desc", "/description"),
1427 info(LintRule::MissingAuthor, "author", "/author"),
1428 ];
1429 let config = LintConfig::default();
1430 let mut inline = InlineSuppressions::default();
1431 inline.file_disabled.insert("missing_author".to_string());
1432
1433 let result = apply_suppressions(warnings, &config, &inline);
1434 assert_eq!(result.len(), 1);
1435 assert_eq!(result[0].rule, LintRule::MissingDescription);
1436 }
1437
1438 #[test]
1439 fn apply_suppressions_inline_disable_all() {
1440 let warnings = vec![
1441 err(LintRule::MissingTitle, "title", "/title"),
1442 warning(LintRule::TitleTooLong, "long", "/title"),
1443 ];
1444 let config = LintConfig::default();
1445 let inline = InlineSuppressions {
1446 disable_all: true,
1447 ..Default::default()
1448 };
1449
1450 let result = apply_suppressions(warnings, &config, &inline);
1451 assert!(result.is_empty());
1452 }
1453
1454 #[test]
1455 fn apply_suppressions_inline_next_line() {
1456 let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
1457 w1.span = Some(Span {
1458 start_line: 5,
1459 start_col: 0,
1460 end_line: 5,
1461 end_col: 10,
1462 });
1463 let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
1464 w2.span = Some(Span {
1465 start_line: 6,
1466 start_col: 0,
1467 end_line: 6,
1468 end_col: 10,
1469 });
1470
1471 let config = LintConfig::default();
1472 let mut inline = InlineSuppressions::default();
1473 inline.line_disabled.insert(5, None);
1474
1475 let result = apply_suppressions(vec![w1, w2], &config, &inline);
1476 assert_eq!(result.len(), 1);
1477 assert_eq!(result[0].rule, LintRule::InvalidStatus);
1478 }
1479
1480 #[test]
1481 fn lint_with_config_disables_rules() {
1482 let text = r#"title: Test
1483logsource:
1484 category: test
1485detection:
1486 selection:
1487 field: value
1488 condition: selection
1489level: medium
1490"#;
1491 let mut config = LintConfig::default();
1492 config
1493 .disabled_rules
1494 .insert("missing_description".to_string());
1495 config.disabled_rules.insert("missing_author".to_string());
1496
1497 let warnings = lint_yaml_str_with_config(text, &config);
1498 assert!(
1499 !warnings
1500 .iter()
1501 .any(|w| w.rule == LintRule::MissingDescription)
1502 );
1503 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1504 }
1505
1506 #[test]
1507 fn lint_with_inline_disable_next_line() {
1508 let text = r#"title: Test
1509# rsigma-disable-next-line missing_level
1510logsource:
1511 category: test
1512detection:
1513 selection:
1514 field: value
1515 condition: selection
1516"#;
1517 let config = LintConfig::default();
1518 let warnings = lint_yaml_str_with_config(text, &config);
1519 assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
1520 }
1521
1522 #[test]
1523 fn lint_with_inline_file_disable() {
1524 let text = r#"# rsigma-disable missing_description, missing_author
1525title: Test
1526logsource:
1527 category: test
1528detection:
1529 selection:
1530 field: value
1531 condition: selection
1532level: medium
1533"#;
1534 let config = LintConfig::default();
1535 let warnings = lint_yaml_str_with_config(text, &config);
1536 assert!(
1537 !warnings
1538 .iter()
1539 .any(|w| w.rule == LintRule::MissingDescription)
1540 );
1541 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1542 }
1543
1544 #[test]
1545 fn lint_with_inline_disable_all() {
1546 let text = r#"# rsigma-disable
1547title: Test
1548status: invalid_status
1549logsource:
1550 category: test
1551detection:
1552 selection:
1553 field: value
1554 condition: selection
1555"#;
1556 let config = LintConfig::default();
1557 let warnings = lint_yaml_str_with_config(text, &config);
1558 assert!(warnings.is_empty());
1559 }
1560
1561 #[test]
1562 fn lint_config_merge() {
1563 let mut base = LintConfig::default();
1564 base.disabled_rules.insert("rule_a".to_string());
1565 base.severity_overrides
1566 .insert("rule_b".to_string(), Severity::Info);
1567
1568 let other = LintConfig {
1569 disabled_rules: ["rule_c".to_string()].into_iter().collect(),
1570 severity_overrides: [("rule_d".to_string(), Severity::Hint)]
1571 .into_iter()
1572 .collect(),
1573 exclude_patterns: vec!["test/**".to_string()],
1574 tag_namespaces: vec!["myns".to_string()],
1575 };
1576
1577 base.merge(&other);
1578 assert!(base.disabled_rules.contains("rule_a"));
1579 assert!(base.disabled_rules.contains("rule_c"));
1580 assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
1581 assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
1582 assert_eq!(base.exclude_patterns, vec!["test/**".to_string()]);
1583 assert!(base.tag_namespaces.contains(&"myns".to_string()));
1584 }
1585
1586 #[test]
1587 fn lint_config_merge_dedups_lists() {
1588 let mut base = LintConfig {
1589 exclude_patterns: vec!["config/**".to_string(), "shared/**".to_string()],
1590 tag_namespaces: vec!["myorg".to_string(), "shared".to_string()],
1591 ..Default::default()
1592 };
1593 let other = LintConfig {
1594 exclude_patterns: vec!["shared/**".to_string(), "extra/**".to_string()],
1596 tag_namespaces: vec!["shared".to_string(), "internal".to_string()],
1597 ..Default::default()
1598 };
1599
1600 base.merge(&other);
1601
1602 assert_eq!(
1603 base.exclude_patterns,
1604 vec![
1605 "config/**".to_string(),
1606 "shared/**".to_string(),
1607 "extra/**".to_string()
1608 ]
1609 );
1610 assert_eq!(
1611 base.tag_namespaces,
1612 vec![
1613 "myorg".to_string(),
1614 "shared".to_string(),
1615 "internal".to_string()
1616 ]
1617 );
1618 }
1619
1620 #[test]
1621 fn lint_config_load_dedups_and_normalises() {
1622 let yaml = r#"
1623exclude:
1624 - "config/**"
1625 - "config/**"
1626tag_namespaces:
1627 - MyOrg
1628 - myorg
1629 - internal
1630"#;
1631 let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
1632 std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
1633 let config = LintConfig::load(tmp.path()).unwrap();
1634
1635 assert_eq!(config.exclude_patterns, vec!["config/**".to_string()]);
1636 assert_eq!(
1638 config.tag_namespaces,
1639 vec!["myorg".to_string(), "internal".to_string()]
1640 );
1641 }
1642
1643 #[test]
1644 fn lint_config_is_disabled() {
1645 let mut config = LintConfig::default();
1646 config.disabled_rules.insert("missing_title".to_string());
1647 assert!(config.is_disabled(&LintRule::MissingTitle));
1648 assert!(!config.is_disabled(&LintRule::EmptyTitle));
1649 }
1650
1651 #[test]
1652 fn find_yaml_comment_basic() {
1653 assert_eq!(find_yaml_comment("# comment"), Some(0));
1654 assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
1655 assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
1656 assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
1657 assert_eq!(find_yaml_comment("key: value"), None);
1658 }
1659
1660 #[test]
1661 fn no_fix_for_unfixable_rule() {
1662 let w = lint(
1663 r#"
1664title: Test
1665logsource:
1666 category: test
1667"#,
1668 );
1669 assert!(has_rule(&w, LintRule::MissingDetection));
1670 let fix = w
1671 .iter()
1672 .find(|w| w.rule == LintRule::MissingDetection)
1673 .and_then(|w| w.fix.as_ref());
1674 assert!(fix.is_none());
1675 }
1676
1677 #[test]
1678 fn lint_config_exclude_from_yaml() {
1679 let yaml = r#"
1680disabled_rules:
1681 - missing_description
1682exclude:
1683 - "config/**"
1684 - "**/unsupported/**"
1685"#;
1686 let tmp = std::env::temp_dir().join("rsigma_test_exclude.yml");
1687 std::fs::write(&tmp, yaml).unwrap();
1688 let config = LintConfig::load(&tmp).unwrap();
1689 std::fs::remove_file(&tmp).ok();
1690
1691 assert!(config.disabled_rules.contains("missing_description"));
1692 assert_eq!(config.exclude_patterns.len(), 2);
1693 assert_eq!(config.exclude_patterns[0], "config/**");
1694 assert_eq!(config.exclude_patterns[1], "**/unsupported/**");
1695 }
1696
1697 #[test]
1698 fn lint_config_build_exclude_set_empty() {
1699 let config = LintConfig::default();
1700 assert!(config.build_exclude_set().is_none());
1701 }
1702
1703 #[test]
1704 fn lint_config_build_exclude_set_matches() {
1705 let config = LintConfig {
1706 exclude_patterns: vec!["config/**".to_string()],
1707 ..Default::default()
1708 };
1709 let gs = config.build_exclude_set().expect("should build");
1710 assert!(gs.is_match("config/data_mapping/foo.yaml"));
1711 assert!(gs.is_match("config/nested/deep/bar.yml"));
1712 assert!(!gs.is_match("rules/windows/test.yml"));
1713 }
1714
1715 #[test]
1716 fn lint_directory_with_excludes() {
1717 let tmp = tempfile::tempdir().unwrap();
1718 let rules_dir = tmp.path().join("rules");
1719 let config_dir = tmp.path().join("config");
1720 std::fs::create_dir_all(&rules_dir).unwrap();
1721 std::fs::create_dir_all(&config_dir).unwrap();
1722
1723 std::fs::write(
1724 rules_dir.join("good.yml"),
1725 r#"
1726title: Good Rule
1727logsource:
1728 category: test
1729detection:
1730 sel:
1731 field: value
1732 condition: sel
1733level: medium
1734"#,
1735 )
1736 .unwrap();
1737
1738 std::fs::write(
1739 config_dir.join("mapping.yaml"),
1740 r#"
1741Title: Logon
1742Channel: Security
1743EventID: 4624
1744"#,
1745 )
1746 .unwrap();
1747
1748 let no_exclude = LintConfig::default();
1749 let results = lint_yaml_directory_with_config(tmp.path(), &no_exclude).unwrap();
1750 let config_warnings: Vec<_> = results
1751 .iter()
1752 .filter(|r| r.path.to_string_lossy().contains("config"))
1753 .flat_map(|r| &r.warnings)
1754 .collect();
1755 assert!(
1756 !config_warnings.is_empty(),
1757 "config file should produce warnings without excludes"
1758 );
1759
1760 let with_exclude = LintConfig {
1761 exclude_patterns: vec!["config/**".to_string()],
1762 ..Default::default()
1763 };
1764 let results = lint_yaml_directory_with_config(tmp.path(), &with_exclude).unwrap();
1765 let config_results: Vec<_> = results
1766 .iter()
1767 .filter(|r| r.path.to_string_lossy().contains("config"))
1768 .collect();
1769 assert!(config_results.is_empty(), "config file should be excluded");
1770
1771 let rule_results: Vec<_> = results
1772 .iter()
1773 .filter(|r| r.path.to_string_lossy().contains("good.yml"))
1774 .collect();
1775 assert_eq!(rule_results.len(), 1);
1776 }
1777
1778 #[test]
1779 fn all_lint_keys_are_cached() {
1780 const ALL_LINT_KEYS: &[&str] = &[
1781 "action",
1782 "author",
1783 "condition",
1784 "correlation",
1785 "date",
1786 "description",
1787 "detection",
1788 "field",
1789 "filter",
1790 "generate",
1791 "group-by",
1792 "id",
1793 "level",
1794 "logsource",
1795 "modified",
1796 "name",
1797 "rules",
1798 "selection",
1799 "status",
1800 "tags",
1801 "taxonomy",
1802 "timeframe",
1803 "timespan",
1804 "title",
1805 "type",
1806 ];
1807 for key_str in ALL_LINT_KEYS {
1808 assert!(KEY_CACHE.contains_key(key_str), "key not cached: {key_str}");
1809 }
1810 }
1811
1812 #[test]
1813 fn extra_tag_namespace_suppresses_warning() {
1814 let text = r#"title: Test
1815logsource:
1816 category: test
1817detection:
1818 selection:
1819 field: value
1820 condition: selection
1821level: medium
1822tags:
1823 - myorg.custom_tag
1824"#;
1825 let warnings = lint_yaml_str(text);
1827 assert!(has_rule(&warnings, LintRule::UnknownTagNamespace));
1828
1829 let config = LintConfig {
1831 tag_namespaces: vec!["myorg".to_string()],
1832 ..Default::default()
1833 };
1834 let warnings = lint_yaml_str_with_config(text, &config);
1835 assert!(has_no_rule(&warnings, LintRule::UnknownTagNamespace));
1836 }
1837
1838 #[test]
1839 fn extra_tag_namespace_from_config_file() {
1840 let yaml = r#"
1841tag_namespaces:
1842 - myorg
1843 - internal
1844"#;
1845 let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
1846 std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
1847 let config = LintConfig::load(tmp.path()).unwrap();
1848
1849 assert!(config.tag_namespaces.contains(&"myorg".to_string()));
1850 assert!(config.tag_namespaces.contains(&"internal".to_string()));
1851 }
1852}