1use std::collections::{HashMap, HashSet};
23use std::fmt;
24use std::path::Path;
25use std::sync::LazyLock;
26
27use serde::{Deserialize, Serialize};
28use serde_yaml::Value;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
36pub enum Severity {
37 Error,
39 Warning,
41 Info,
43 Hint,
45}
46
47impl fmt::Display for Severity {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 Severity::Error => write!(f, "error"),
51 Severity::Warning => write!(f, "warning"),
52 Severity::Info => write!(f, "info"),
53 Severity::Hint => write!(f, "hint"),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
60pub enum LintRule {
61 YamlParseError,
63 NotAMapping,
64 FileReadError,
65 SchemaViolation,
66
67 MissingTitle,
69 EmptyTitle,
70 TitleTooLong,
71 MissingDescription,
72 MissingAuthor,
73 InvalidId,
74 InvalidStatus,
75 MissingLevel,
76 InvalidLevel,
77 InvalidDate,
78 InvalidModified,
79 ModifiedBeforeDate,
80 DescriptionTooLong,
81 NameTooLong,
82 TaxonomyTooLong,
83 NonLowercaseKey,
84
85 MissingLogsource,
87 MissingDetection,
88 MissingCondition,
89 EmptyDetection,
90 InvalidRelatedType,
91 InvalidRelatedId,
92 RelatedMissingRequired,
93 DeprecatedWithoutRelated,
94 InvalidTag,
95 UnknownTagNamespace,
96 DuplicateTags,
97 DuplicateReferences,
98 DuplicateFields,
99 FalsepositiveTooShort,
100 ScopeTooShort,
101 LogsourceValueNotLowercase,
102 ConditionReferencesUnknown,
103
104 MissingCorrelation,
106 MissingCorrelationType,
107 InvalidCorrelationType,
108 MissingCorrelationRules,
109 EmptyCorrelationRules,
110 MissingCorrelationTimespan,
111 InvalidTimespanFormat,
112 MissingGroupBy,
113 MissingCorrelationCondition,
114 MissingConditionField,
115 InvalidConditionOperator,
116 ConditionValueNotNumeric,
117 GenerateNotBoolean,
118
119 MissingFilter,
121 MissingFilterRules,
122 EmptyFilterRules,
123 MissingFilterSelection,
124 MissingFilterCondition,
125 FilterHasLevel,
126 FilterHasStatus,
127 MissingFilterLogsource,
128
129 NullInValueList,
131 SingleValueAllModifier,
132 AllWithRe,
133 EmptyValueList,
134 WildcardOnlyValue,
135 UnknownKey,
136}
137
138impl fmt::Display for LintRule {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 let s = match self {
141 LintRule::YamlParseError => "yaml_parse_error",
142 LintRule::NotAMapping => "not_a_mapping",
143 LintRule::FileReadError => "file_read_error",
144 LintRule::SchemaViolation => "schema_violation",
145 LintRule::MissingTitle => "missing_title",
146 LintRule::EmptyTitle => "empty_title",
147 LintRule::TitleTooLong => "title_too_long",
148 LintRule::MissingDescription => "missing_description",
149 LintRule::MissingAuthor => "missing_author",
150 LintRule::InvalidId => "invalid_id",
151 LintRule::InvalidStatus => "invalid_status",
152 LintRule::MissingLevel => "missing_level",
153 LintRule::InvalidLevel => "invalid_level",
154 LintRule::InvalidDate => "invalid_date",
155 LintRule::InvalidModified => "invalid_modified",
156 LintRule::ModifiedBeforeDate => "modified_before_date",
157 LintRule::DescriptionTooLong => "description_too_long",
158 LintRule::NameTooLong => "name_too_long",
159 LintRule::TaxonomyTooLong => "taxonomy_too_long",
160 LintRule::NonLowercaseKey => "non_lowercase_key",
161 LintRule::MissingLogsource => "missing_logsource",
162 LintRule::MissingDetection => "missing_detection",
163 LintRule::MissingCondition => "missing_condition",
164 LintRule::EmptyDetection => "empty_detection",
165 LintRule::InvalidRelatedType => "invalid_related_type",
166 LintRule::InvalidRelatedId => "invalid_related_id",
167 LintRule::RelatedMissingRequired => "related_missing_required",
168 LintRule::DeprecatedWithoutRelated => "deprecated_without_related",
169 LintRule::InvalidTag => "invalid_tag",
170 LintRule::UnknownTagNamespace => "unknown_tag_namespace",
171 LintRule::DuplicateTags => "duplicate_tags",
172 LintRule::DuplicateReferences => "duplicate_references",
173 LintRule::DuplicateFields => "duplicate_fields",
174 LintRule::FalsepositiveTooShort => "falsepositive_too_short",
175 LintRule::ScopeTooShort => "scope_too_short",
176 LintRule::LogsourceValueNotLowercase => "logsource_value_not_lowercase",
177 LintRule::ConditionReferencesUnknown => "condition_references_unknown",
178 LintRule::MissingCorrelation => "missing_correlation",
179 LintRule::MissingCorrelationType => "missing_correlation_type",
180 LintRule::InvalidCorrelationType => "invalid_correlation_type",
181 LintRule::MissingCorrelationRules => "missing_correlation_rules",
182 LintRule::EmptyCorrelationRules => "empty_correlation_rules",
183 LintRule::MissingCorrelationTimespan => "missing_correlation_timespan",
184 LintRule::InvalidTimespanFormat => "invalid_timespan_format",
185 LintRule::MissingGroupBy => "missing_group_by",
186 LintRule::MissingCorrelationCondition => "missing_correlation_condition",
187 LintRule::MissingConditionField => "missing_condition_field",
188 LintRule::InvalidConditionOperator => "invalid_condition_operator",
189 LintRule::ConditionValueNotNumeric => "condition_value_not_numeric",
190 LintRule::GenerateNotBoolean => "generate_not_boolean",
191 LintRule::MissingFilter => "missing_filter",
192 LintRule::MissingFilterRules => "missing_filter_rules",
193 LintRule::EmptyFilterRules => "empty_filter_rules",
194 LintRule::MissingFilterSelection => "missing_filter_selection",
195 LintRule::MissingFilterCondition => "missing_filter_condition",
196 LintRule::FilterHasLevel => "filter_has_level",
197 LintRule::FilterHasStatus => "filter_has_status",
198 LintRule::MissingFilterLogsource => "missing_filter_logsource",
199 LintRule::NullInValueList => "null_in_value_list",
200 LintRule::SingleValueAllModifier => "single_value_all_modifier",
201 LintRule::AllWithRe => "all_with_re",
202 LintRule::EmptyValueList => "empty_value_list",
203 LintRule::WildcardOnlyValue => "wildcard_only_value",
204 LintRule::UnknownKey => "unknown_key",
205 };
206 write!(f, "{s}")
207 }
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
216pub struct Span {
217 pub start_line: u32,
219 pub start_col: u32,
221 pub end_line: u32,
223 pub end_col: u32,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
229pub struct LintWarning {
230 pub rule: LintRule,
232 pub severity: Severity,
234 pub message: String,
236 pub path: String,
238 pub span: Option<Span>,
242}
243
244impl fmt::Display for LintWarning {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 write!(
247 f,
248 "{}[{}]: {}\n --> {}",
249 self.severity, self.rule, self.message, self.path
250 )
251 }
252}
253
254#[derive(Debug, Clone, Serialize)]
256pub struct FileLintResult {
257 pub path: std::path::PathBuf,
258 pub warnings: Vec<LintWarning>,
259}
260
261impl FileLintResult {
262 pub fn has_errors(&self) -> bool {
263 self.warnings.iter().any(|w| w.severity == Severity::Error)
264 }
265
266 pub fn error_count(&self) -> usize {
267 self.warnings
268 .iter()
269 .filter(|w| w.severity == Severity::Error)
270 .count()
271 }
272
273 pub fn warning_count(&self) -> usize {
274 self.warnings
275 .iter()
276 .filter(|w| w.severity == Severity::Warning)
277 .count()
278 }
279
280 pub fn info_count(&self) -> usize {
281 self.warnings
282 .iter()
283 .filter(|w| w.severity == Severity::Info)
284 .count()
285 }
286
287 pub fn hint_count(&self) -> usize {
288 self.warnings
289 .iter()
290 .filter(|w| w.severity == Severity::Hint)
291 .count()
292 }
293}
294
295static KEY_CACHE: LazyLock<HashMap<&'static str, Value>> = LazyLock::new(|| {
302 [
303 "action",
304 "author",
305 "category",
306 "condition",
307 "correlation",
308 "date",
309 "description",
310 "detection",
311 "falsepositives",
312 "field",
313 "fields",
314 "filter",
315 "generate",
316 "group-by",
317 "id",
318 "level",
319 "logsource",
320 "modified",
321 "name",
322 "product",
323 "references",
324 "related",
325 "rules",
326 "scope",
327 "selection",
328 "service",
329 "status",
330 "tags",
331 "taxonomy",
332 "timeframe",
333 "timespan",
334 "title",
335 "type",
336 ]
337 .into_iter()
338 .map(|n| (n, Value::String(n.into())))
339 .collect()
340});
341
342fn key(s: &str) -> &'static Value {
343 KEY_CACHE
344 .get(s)
345 .unwrap_or_else(|| panic!("lint key not pre-cached: \"{s}\" — add it to KEY_CACHE"))
346}
347
348fn get_str<'a>(m: &'a serde_yaml::Mapping, k: &str) -> Option<&'a str> {
349 m.get(key(k)).and_then(|v| v.as_str())
350}
351
352fn get_mapping<'a>(m: &'a serde_yaml::Mapping, k: &str) -> Option<&'a serde_yaml::Mapping> {
353 m.get(key(k)).and_then(|v| v.as_mapping())
354}
355
356fn get_seq<'a>(m: &'a serde_yaml::Mapping, k: &str) -> Option<&'a serde_yaml::Sequence> {
357 m.get(key(k)).and_then(|v| v.as_sequence())
358}
359
360fn warn(
361 rule: LintRule,
362 severity: Severity,
363 message: impl Into<String>,
364 path: impl Into<String>,
365) -> LintWarning {
366 LintWarning {
367 rule,
368 severity,
369 message: message.into(),
370 path: path.into(),
371 span: None,
372 }
373}
374
375fn err(rule: LintRule, message: impl Into<String>, path: impl Into<String>) -> LintWarning {
376 warn(rule, Severity::Error, message, path)
377}
378
379fn warning(rule: LintRule, message: impl Into<String>, path: impl Into<String>) -> LintWarning {
380 warn(rule, Severity::Warning, message, path)
381}
382
383fn info(rule: LintRule, message: impl Into<String>, path: impl Into<String>) -> LintWarning {
384 warn(rule, Severity::Info, message, path)
385}
386
387fn is_valid_date(s: &str) -> bool {
389 if s.len() != 10 {
390 return false;
391 }
392 let bytes = s.as_bytes();
393 if bytes[4] != b'-' || bytes[7] != b'-' {
394 return false;
395 }
396 let year_ok = bytes[0..4].iter().all(|b| b.is_ascii_digit());
397 let year: u16 = s[0..4].parse().unwrap_or(0);
398 let month: u8 = s[5..7].parse().unwrap_or(0);
399 let day: u8 = s[8..10].parse().unwrap_or(0);
400 if !year_ok || !(1..=12).contains(&month) || day == 0 {
401 return false;
402 }
403 let is_leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
404 let max_day = match month {
405 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
406 4 | 6 | 9 | 11 => 30,
407 2 => {
408 if is_leap {
409 29
410 } else {
411 28
412 }
413 }
414 _ => return false,
415 };
416 day <= max_day
417}
418
419fn extract_date_string(raw: &Value) -> Option<String> {
424 raw.as_str().map(|s| s.to_string()).or_else(|| {
425 serde_yaml::to_string(raw)
426 .ok()
427 .map(|s| s.trim().to_string())
428 })
429}
430
431fn is_valid_uuid(s: &str) -> bool {
433 if s.len() != 36 {
434 return false;
435 }
436 let parts: Vec<&str> = s.split('-').collect();
437 if parts.len() != 5 {
438 return false;
439 }
440 let expected_lens = [8, 4, 4, 4, 12];
441 parts
442 .iter()
443 .zip(expected_lens.iter())
444 .all(|(part, &len)| part.len() == len && part.chars().all(|c| c.is_ascii_hexdigit()))
445}
446
447fn is_valid_logsource_value(s: &str) -> bool {
449 !s.is_empty()
450 && s.chars().all(|c| {
451 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.' || c == '-'
452 })
453}
454
455const KNOWN_TAG_NAMESPACES: &[&str] =
457 &["attack", "car", "cve", "d3fend", "detection", "stp", "tlp"];
458
459const VALID_STATUSES: &[&str] = &[
461 "stable",
462 "test",
463 "experimental",
464 "deprecated",
465 "unsupported",
466];
467
468const VALID_LEVELS: &[&str] = &["informational", "low", "medium", "high", "critical"];
470
471const VALID_RELATED_TYPES: &[&str] = &["derived", "obsolete", "merged", "renamed", "similar"];
473
474const VALID_CORRELATION_TYPES: &[&str] = &[
476 "event_count",
477 "value_count",
478 "temporal",
479 "temporal_ordered",
480 "value_sum",
481 "value_avg",
482 "value_percentile",
483 "value_median",
484];
485
486const VALID_CONDITION_OPERATORS: &[&str] = &["gt", "gte", "lt", "lte", "eq", "neq"];
488
489const TYPES_REQUIRING_CONDITION: &[&str] = &[
491 "event_count",
492 "value_count",
493 "value_sum",
494 "value_avg",
495 "value_percentile",
496];
497
498const TYPES_REQUIRING_FIELD: &[&str] =
500 &["value_count", "value_sum", "value_avg", "value_percentile"];
501
502const KNOWN_KEYS_SHARED: &[&str] = &[
504 "title",
505 "id",
506 "name",
507 "status",
508 "description",
509 "author",
510 "date",
511 "modified",
512 "related",
513 "taxonomy",
514 "action",
515 "license",
516 "references",
517 "tags",
518];
519
520const KNOWN_KEYS_DETECTION: &[&str] = &[
522 "logsource",
523 "detection",
524 "fields",
525 "falsepositives",
526 "level",
527 "scope",
528];
529
530const KNOWN_KEYS_CORRELATION: &[&str] = &["correlation", "level", "generate"];
532
533const KNOWN_KEYS_FILTER: &[&str] = &["logsource", "filter"];
535
536fn is_valid_tag(s: &str) -> bool {
538 let parts: Vec<&str> = s.splitn(2, '.').collect();
539 if parts.len() != 2 {
540 return false;
541 }
542 let ns_ok = !parts[0].is_empty()
543 && parts[0]
544 .chars()
545 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
546 let rest_ok = !parts[1].is_empty()
547 && parts[1].chars().all(|c| {
548 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-'
549 });
550 ns_ok && rest_ok
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
558enum DocType {
559 Detection,
560 Correlation,
561 Filter,
562}
563
564impl DocType {
565 fn known_keys(&self) -> &'static [&'static str] {
566 match self {
567 DocType::Detection => KNOWN_KEYS_DETECTION,
568 DocType::Correlation => KNOWN_KEYS_CORRELATION,
569 DocType::Filter => KNOWN_KEYS_FILTER,
570 }
571 }
572}
573
574fn detect_doc_type(m: &serde_yaml::Mapping) -> DocType {
575 if m.contains_key(key("correlation")) {
576 DocType::Correlation
577 } else if m.contains_key(key("filter")) {
578 DocType::Filter
579 } else {
580 DocType::Detection
581 }
582}
583
584fn is_action_fragment(m: &serde_yaml::Mapping) -> bool {
588 matches!(get_str(m, "action"), Some("global" | "reset" | "repeat"))
589}
590
591fn lint_shared(m: &serde_yaml::Mapping, warnings: &mut Vec<LintWarning>) {
596 match get_str(m, "title") {
598 None => warnings.push(err(
599 LintRule::MissingTitle,
600 "missing required field 'title'",
601 "/title",
602 )),
603 Some(t) if t.trim().is_empty() => {
604 warnings.push(err(
605 LintRule::EmptyTitle,
606 "title must not be empty",
607 "/title",
608 ));
609 }
610 Some(t) if t.len() > 256 => {
611 warnings.push(warning(
612 LintRule::TitleTooLong,
613 format!("title is {} characters, maximum is 256", t.len()),
614 "/title",
615 ));
616 }
617 _ => {}
618 }
619
620 if let Some(id) = get_str(m, "id")
622 && !is_valid_uuid(id)
623 {
624 warnings.push(warning(
625 LintRule::InvalidId,
626 format!("id \"{id}\" is not a valid UUID"),
627 "/id",
628 ));
629 }
630
631 if let Some(status) = get_str(m, "status")
633 && !VALID_STATUSES.contains(&status)
634 {
635 warnings.push(err(
636 LintRule::InvalidStatus,
637 format!(
638 "invalid status \"{status}\", expected one of: {}",
639 VALID_STATUSES.join(", ")
640 ),
641 "/status",
642 ));
643 }
644
645 if let Some(level) = get_str(m, "level")
647 && !VALID_LEVELS.contains(&level)
648 {
649 warnings.push(err(
650 LintRule::InvalidLevel,
651 format!(
652 "invalid level \"{level}\", expected one of: {}",
653 VALID_LEVELS.join(", ")
654 ),
655 "/level",
656 ));
657 }
658
659 let date_string = m.get(key("date")).and_then(extract_date_string);
661 if let Some(d) = &date_string
662 && !is_valid_date(d)
663 {
664 warnings.push(err(
665 LintRule::InvalidDate,
666 format!("invalid date \"{d}\", expected YYYY-MM-DD"),
667 "/date",
668 ));
669 }
670
671 let modified_string = m.get(key("modified")).and_then(extract_date_string);
673 if let Some(d) = &modified_string
674 && !is_valid_date(d)
675 {
676 warnings.push(err(
677 LintRule::InvalidModified,
678 format!("invalid modified date \"{d}\", expected YYYY-MM-DD"),
679 "/modified",
680 ));
681 }
682
683 if let (Some(date_val), Some(mod_val)) = (&date_string, &modified_string)
685 && is_valid_date(date_val)
686 && is_valid_date(mod_val)
687 && mod_val.as_str() < date_val.as_str()
688 {
689 warnings.push(warning(
690 LintRule::ModifiedBeforeDate,
691 format!("modified date \"{mod_val}\" is before creation date \"{date_val}\""),
692 "/modified",
693 ));
694 }
695
696 if !m.contains_key(key("description")) {
698 warnings.push(info(
699 LintRule::MissingDescription,
700 "missing recommended field 'description'",
701 "/description",
702 ));
703 }
704
705 if !m.contains_key(key("author")) {
707 warnings.push(info(
708 LintRule::MissingAuthor,
709 "missing recommended field 'author'",
710 "/author",
711 ));
712 }
713
714 if let Some(desc) = get_str(m, "description")
716 && desc.len() > 65535
717 {
718 warnings.push(warning(
719 LintRule::DescriptionTooLong,
720 format!("description is {} characters, maximum is 65535", desc.len()),
721 "/description",
722 ));
723 }
724
725 if let Some(name) = get_str(m, "name")
727 && name.len() > 256
728 {
729 warnings.push(warning(
730 LintRule::NameTooLong,
731 format!("name is {} characters, maximum is 256", name.len()),
732 "/name",
733 ));
734 }
735
736 if let Some(tax) = get_str(m, "taxonomy")
738 && tax.len() > 256
739 {
740 warnings.push(warning(
741 LintRule::TaxonomyTooLong,
742 format!("taxonomy is {} characters, maximum is 256", tax.len()),
743 "/taxonomy",
744 ));
745 }
746
747 for k in m.keys() {
749 if let Some(ks) = k.as_str()
750 && ks != ks.to_ascii_lowercase()
751 {
752 warnings.push(warning(
753 LintRule::NonLowercaseKey,
754 format!("key \"{ks}\" should be lowercase"),
755 format!("/{ks}"),
756 ));
757 }
758 }
759}
760
761fn lint_detection_rule(m: &serde_yaml::Mapping, warnings: &mut Vec<LintWarning>) {
766 if !m.contains_key(key("level")) {
768 warnings.push(warning(
769 LintRule::MissingLevel,
770 "missing recommended field 'level'",
771 "/level",
772 ));
773 }
774
775 if !m.contains_key(key("logsource")) {
777 warnings.push(err(
778 LintRule::MissingLogsource,
779 "missing required field 'logsource'",
780 "/logsource",
781 ));
782 } else {
783 lint_logsource(m, warnings);
784 }
785
786 if let Some(det_val) = m.get(key("detection")) {
788 if let Some(det) = det_val.as_mapping() {
789 let det_keys: HashSet<&str> = det
791 .keys()
792 .filter_map(|k| k.as_str())
793 .filter(|k| *k != "condition" && *k != "timeframe")
794 .collect();
795
796 if !det.contains_key(key("condition")) {
797 warnings.push(err(
798 LintRule::MissingCondition,
799 "detection section is missing required 'condition'",
800 "/detection/condition",
801 ));
802 } else if let Some(cond_str) = get_str(det, "condition") {
803 for ident in extract_condition_identifiers(cond_str) {
805 if !det_keys.contains(ident.as_str()) {
806 warnings.push(err(
807 LintRule::ConditionReferencesUnknown,
808 format!(
809 "condition references '{ident}' but no such detection identifier exists"
810 ),
811 "/detection/condition",
812 ));
813 }
814 }
815 }
816
817 if det_keys.is_empty() {
818 warnings.push(warning(
819 LintRule::EmptyDetection,
820 "detection section has no named search identifiers",
821 "/detection",
822 ));
823 }
824
825 lint_detection_logic(det, warnings);
827 }
828 } else {
829 warnings.push(err(
830 LintRule::MissingDetection,
831 "missing required field 'detection'",
832 "/detection",
833 ));
834 }
835
836 if let Some(related) = get_seq(m, "related") {
838 for (i, item) in related.iter().enumerate() {
839 let path_prefix = format!("/related/{i}");
840 if let Some(item_map) = item.as_mapping() {
841 let has_id = item_map.contains_key(key("id"));
842 let has_type = item_map.contains_key(key("type"));
843
844 if !has_id || !has_type {
845 warnings.push(err(
846 LintRule::RelatedMissingRequired,
847 "related entry must have both 'id' and 'type'",
848 &path_prefix,
849 ));
850 }
851
852 if let Some(id) = get_str(item_map, "id")
853 && !is_valid_uuid(id)
854 {
855 warnings.push(warning(
856 LintRule::InvalidRelatedId,
857 format!("related id \"{id}\" is not a valid UUID"),
858 format!("{path_prefix}/id"),
859 ));
860 }
861
862 if let Some(type_val) = get_str(item_map, "type")
863 && !VALID_RELATED_TYPES.contains(&type_val)
864 {
865 warnings.push(err(
866 LintRule::InvalidRelatedType,
867 format!(
868 "invalid related type \"{type_val}\", expected one of: {}",
869 VALID_RELATED_TYPES.join(", ")
870 ),
871 format!("{path_prefix}/type"),
872 ));
873 }
874 }
875 }
876 }
877
878 if get_str(m, "status") == Some("deprecated") {
880 let has_related = get_seq(m, "related")
881 .map(|seq| !seq.is_empty())
882 .unwrap_or(false);
883 if !has_related {
884 warnings.push(warning(
885 LintRule::DeprecatedWithoutRelated,
886 "deprecated rule should have a 'related' entry linking to its replacement",
887 "/status",
888 ));
889 }
890 }
891
892 if let Some(tags) = get_seq(m, "tags") {
894 let mut seen_tags: HashSet<String> = HashSet::new();
895 for (i, tag_val) in tags.iter().enumerate() {
896 if let Some(tag) = tag_val.as_str() {
897 if !is_valid_tag(tag) {
898 warnings.push(warning(
899 LintRule::InvalidTag,
900 format!(
901 "tag \"{tag}\" does not match required pattern (lowercase, dotted namespace)"
902 ),
903 format!("/tags/{i}"),
904 ));
905 } else {
906 if let Some(ns) = tag.split('.').next()
908 && !KNOWN_TAG_NAMESPACES.contains(&ns)
909 {
910 warnings.push(warning(
911 LintRule::UnknownTagNamespace,
912 format!(
913 "unknown tag namespace \"{ns}\", known namespaces: {}",
914 KNOWN_TAG_NAMESPACES.join(", ")
915 ),
916 format!("/tags/{i}"),
917 ));
918 }
919 }
920
921 if !seen_tags.insert(tag.to_string()) {
922 warnings.push(warning(
923 LintRule::DuplicateTags,
924 format!("duplicate tag \"{tag}\""),
925 format!("/tags/{i}"),
926 ));
927 }
928 }
929 }
930 }
931
932 if let Some(refs) = get_seq(m, "references") {
934 let mut seen: HashSet<String> = HashSet::new();
935 for (i, r) in refs.iter().enumerate() {
936 if let Some(s) = r.as_str()
937 && !seen.insert(s.to_string())
938 {
939 warnings.push(warning(
940 LintRule::DuplicateReferences,
941 format!("duplicate reference \"{s}\""),
942 format!("/references/{i}"),
943 ));
944 }
945 }
946 }
947
948 if let Some(fields) = get_seq(m, "fields") {
950 let mut seen: HashSet<String> = HashSet::new();
951 for (i, f) in fields.iter().enumerate() {
952 if let Some(s) = f.as_str()
953 && !seen.insert(s.to_string())
954 {
955 warnings.push(warning(
956 LintRule::DuplicateFields,
957 format!("duplicate field \"{s}\""),
958 format!("/fields/{i}"),
959 ));
960 }
961 }
962 }
963
964 if let Some(fps) = get_seq(m, "falsepositives") {
966 for (i, fp) in fps.iter().enumerate() {
967 if let Some(s) = fp.as_str()
968 && s.len() < 2
969 {
970 warnings.push(warning(
971 LintRule::FalsepositiveTooShort,
972 format!("falsepositive entry \"{s}\" must be at least 2 characters"),
973 format!("/falsepositives/{i}"),
974 ));
975 }
976 }
977 }
978
979 if let Some(scope) = get_seq(m, "scope") {
981 for (i, s_val) in scope.iter().enumerate() {
982 if let Some(s) = s_val.as_str()
983 && s.len() < 2
984 {
985 warnings.push(warning(
986 LintRule::ScopeTooShort,
987 format!("scope entry \"{s}\" must be at least 2 characters"),
988 format!("/scope/{i}"),
989 ));
990 }
991 }
992 }
993}
994
995fn lint_logsource(m: &serde_yaml::Mapping, warnings: &mut Vec<LintWarning>) {
996 if let Some(ls) = get_mapping(m, "logsource") {
997 for field in &["category", "product", "service"] {
998 if let Some(val) = get_str(ls, field)
999 && !is_valid_logsource_value(val)
1000 {
1001 warnings.push(warning(
1002 LintRule::LogsourceValueNotLowercase,
1003 format!("logsource {field} \"{val}\" should be lowercase (a-z, 0-9, _, ., -)"),
1004 format!("/logsource/{field}"),
1005 ));
1006 }
1007 }
1008 }
1009}
1010
1011fn extract_condition_identifiers(condition: &str) -> Vec<String> {
1014 const KEYWORDS: &[&str] = &["and", "or", "not", "of", "all", "them"];
1015 condition
1016 .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '*')
1017 .filter(|s| !s.is_empty())
1018 .filter(|s| !KEYWORDS.contains(s))
1019 .filter(|s| !s.chars().all(|c| c.is_ascii_digit()))
1020 .filter(|s| !s.contains('*'))
1021 .map(|s| s.to_string())
1022 .collect()
1023}
1024
1025fn lint_detection_logic(det: &serde_yaml::Mapping, warnings: &mut Vec<LintWarning>) {
1027 for (det_key, det_val) in det {
1028 let det_key_str = det_key.as_str().unwrap_or("");
1029 if det_key_str == "condition" || det_key_str == "timeframe" {
1030 continue;
1031 }
1032
1033 lint_detection_value(det_val, det_key_str, warnings);
1034 }
1035}
1036
1037fn lint_detection_value(value: &Value, det_name: &str, warnings: &mut Vec<LintWarning>) {
1038 match value {
1039 Value::Mapping(m) => {
1040 for (field_key, field_val) in m {
1041 let field_key_str = field_key.as_str().unwrap_or("");
1042
1043 if field_key_str.contains("|all") && field_key_str.contains("|re") {
1045 warnings.push(warning(
1046 LintRule::AllWithRe,
1047 format!(
1048 "'{field_key_str}' in '{det_name}' combines |all with |re; \
1049 regex alternation (|) already handles multi-match — \
1050 |all is redundant or misleading here"
1051 ),
1052 format!("/detection/{det_name}/{field_key_str}"),
1053 ));
1054 }
1055
1056 if field_key_str.contains("|all") {
1058 if let Value::Sequence(seq) = field_val {
1059 if seq.len() <= 1 {
1060 warnings.push(warning(
1061 LintRule::SingleValueAllModifier,
1062 format!(
1063 "'{field_key_str}' in '{det_name}' uses |all modifier with {} value(s); |all requires multiple values",
1064 seq.len()
1065 ),
1066 format!("/detection/{det_name}/{field_key_str}"),
1067 ));
1068 }
1069 } else {
1070 warnings.push(warning(
1072 LintRule::SingleValueAllModifier,
1073 format!(
1074 "'{field_key_str}' in '{det_name}' uses |all modifier with a single value; |all requires multiple values"
1075 ),
1076 format!("/detection/{det_name}/{field_key_str}"),
1077 ));
1078 }
1079 }
1080
1081 if let Value::Sequence(seq) = field_val {
1083 if seq.is_empty() {
1084 warnings.push(warning(
1085 LintRule::EmptyValueList,
1086 format!("'{field_key_str}' in '{det_name}' has an empty value list"),
1087 format!("/detection/{det_name}/{field_key_str}"),
1088 ));
1089 } else {
1090 let has_null = seq.iter().any(|v| v.is_null());
1091 let has_non_null = seq.iter().any(|v| !v.is_null());
1092 if has_null && has_non_null {
1093 warnings.push(warning(
1094 LintRule::NullInValueList,
1095 format!(
1096 "'{field_key_str}' in '{det_name}' mixes null with other values; null should be in its own selection"
1097 ),
1098 format!("/detection/{det_name}/{field_key_str}"),
1099 ));
1100 }
1101 }
1102 }
1103
1104 let base_field = field_key_str.split('|').next().unwrap_or(field_key_str);
1106 let is_wildcard_only = match field_val {
1107 Value::String(s) => s == "*",
1108 Value::Sequence(seq) => seq.len() == 1 && seq[0].as_str() == Some("*"),
1109 _ => false,
1110 };
1111 if is_wildcard_only && !field_key_str.contains("|re") {
1112 warnings.push(warning(
1113 LintRule::WildcardOnlyValue,
1114 format!(
1115 "'{field_key_str}' in '{det_name}' uses a lone wildcard '*'; \
1116 consider '{base_field}|exists: true' instead"
1117 ),
1118 format!("/detection/{det_name}/{field_key_str}"),
1119 ));
1120 }
1121 }
1122 }
1123 Value::Sequence(seq) => {
1124 for item in seq {
1126 if item.is_mapping() {
1127 lint_detection_value(item, det_name, warnings);
1128 }
1129 }
1130 }
1131 _ => {}
1132 }
1133}
1134
1135fn lint_correlation_rule(m: &serde_yaml::Mapping, warnings: &mut Vec<LintWarning>) {
1140 let Some(corr_val) = m.get(key("correlation")) else {
1141 warnings.push(err(
1142 LintRule::MissingCorrelation,
1143 "missing required field 'correlation'",
1144 "/correlation",
1145 ));
1146 return;
1147 };
1148
1149 let Some(corr) = corr_val.as_mapping() else {
1150 warnings.push(err(
1151 LintRule::MissingCorrelation,
1152 "'correlation' must be a mapping",
1153 "/correlation",
1154 ));
1155 return;
1156 };
1157
1158 let corr_type = get_str(corr, "type");
1160 match corr_type {
1161 None => {
1162 warnings.push(err(
1163 LintRule::MissingCorrelationType,
1164 "missing required field 'correlation.type'",
1165 "/correlation/type",
1166 ));
1167 }
1168 Some(t) if !VALID_CORRELATION_TYPES.contains(&t) => {
1169 warnings.push(err(
1170 LintRule::InvalidCorrelationType,
1171 format!(
1172 "invalid correlation type \"{t}\", expected one of: {}",
1173 VALID_CORRELATION_TYPES.join(", ")
1174 ),
1175 "/correlation/type",
1176 ));
1177 }
1178 _ => {}
1179 }
1180
1181 if let Some(rules) = corr.get(key("rules")) {
1183 if let Some(seq) = rules.as_sequence()
1184 && seq.is_empty()
1185 {
1186 warnings.push(warning(
1187 LintRule::EmptyCorrelationRules,
1188 "correlation.rules should not be empty",
1189 "/correlation/rules",
1190 ));
1191 }
1192 } else {
1193 warnings.push(err(
1194 LintRule::MissingCorrelationRules,
1195 "missing required field 'correlation.rules'",
1196 "/correlation/rules",
1197 ));
1198 }
1199
1200 if let Some(ts) = get_str(corr, "timespan").or_else(|| get_str(corr, "timeframe")) {
1202 if !is_valid_timespan(ts) {
1203 warnings.push(err(
1204 LintRule::InvalidTimespanFormat,
1205 format!(
1206 "invalid timespan \"{ts}\", expected format like 5m, 1h, 30s, 7d, 1w, 1M, 1y"
1207 ),
1208 "/correlation/timespan",
1209 ));
1210 }
1211 } else {
1212 warnings.push(err(
1213 LintRule::MissingCorrelationTimespan,
1214 "missing required field 'correlation.timespan'",
1215 "/correlation/timespan",
1216 ));
1217 }
1218
1219 if let Some(ct) = corr_type {
1221 if !corr.contains_key(key("group-by")) {
1223 warnings.push(err(
1224 LintRule::MissingGroupBy,
1225 format!("{ct} correlation requires 'group-by'"),
1226 "/correlation/group-by",
1227 ));
1228 }
1229
1230 if TYPES_REQUIRING_CONDITION.contains(&ct) {
1232 if let Some(cond_val) = corr.get(key("condition")) {
1233 if let Some(cond_map) = cond_val.as_mapping() {
1234 lint_correlation_condition(cond_map, ct, warnings);
1235 }
1236 } else {
1237 warnings.push(err(
1238 LintRule::MissingCorrelationCondition,
1239 format!("{ct} correlation requires a 'condition'"),
1240 "/correlation/condition",
1241 ));
1242 }
1243 }
1244 }
1245
1246 if let Some(gen_val) = corr.get(key("generate"))
1248 && !gen_val.is_bool()
1249 {
1250 warnings.push(err(
1251 LintRule::GenerateNotBoolean,
1252 "'generate' must be a boolean (true/false)",
1253 "/correlation/generate",
1254 ));
1255 }
1256}
1257
1258fn lint_correlation_condition(
1259 cond: &serde_yaml::Mapping,
1260 corr_type: &str,
1261 warnings: &mut Vec<LintWarning>,
1262) {
1263 if TYPES_REQUIRING_FIELD.contains(&corr_type) && !cond.contains_key(key("field")) {
1265 warnings.push(err(
1266 LintRule::MissingConditionField,
1267 format!("{corr_type} correlation condition requires 'field'"),
1268 "/correlation/condition/field",
1269 ));
1270 }
1271
1272 for (k, v) in cond {
1274 let ks = k.as_str().unwrap_or("");
1275 if ks == "field" {
1276 continue;
1277 }
1278 if !VALID_CONDITION_OPERATORS.contains(&ks) {
1279 warnings.push(err(
1280 LintRule::InvalidConditionOperator,
1281 format!(
1282 "invalid condition operator \"{ks}\", expected one of: {}",
1283 VALID_CONDITION_OPERATORS.join(", ")
1284 ),
1285 format!("/correlation/condition/{ks}"),
1286 ));
1287 } else if !v.is_i64() && !v.is_u64() && !v.is_f64() {
1288 warnings.push(err(
1289 LintRule::ConditionValueNotNumeric,
1290 format!("condition operator '{ks}' requires a numeric value"),
1291 format!("/correlation/condition/{ks}"),
1292 ));
1293 }
1294 }
1295}
1296
1297fn is_valid_timespan(s: &str) -> bool {
1298 if s.is_empty() {
1299 return false;
1300 }
1301 let last = s.as_bytes()[s.len() - 1];
1302 if !matches!(last, b's' | b'm' | b'h' | b'd' | b'w' | b'M' | b'y') {
1304 return false;
1305 }
1306 let num_part = &s[..s.len() - 1];
1307 !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit())
1308}
1309
1310fn lint_filter_rule(m: &serde_yaml::Mapping, warnings: &mut Vec<LintWarning>) {
1315 let Some(filter_val) = m.get(key("filter")) else {
1317 warnings.push(err(
1318 LintRule::MissingFilter,
1319 "missing required field 'filter'",
1320 "/filter",
1321 ));
1322 return;
1323 };
1324
1325 let Some(filter) = filter_val.as_mapping() else {
1326 warnings.push(err(
1327 LintRule::MissingFilter,
1328 "'filter' must be a mapping",
1329 "/filter",
1330 ));
1331 return;
1332 };
1333
1334 if let Some(rules_val) = filter.get(key("rules")) {
1336 if let Some(seq) = rules_val.as_sequence()
1337 && seq.is_empty()
1338 {
1339 warnings.push(warning(
1340 LintRule::EmptyFilterRules,
1341 "filter.rules should have at least one entry",
1342 "/filter/rules",
1343 ));
1344 }
1345 } else {
1346 warnings.push(err(
1347 LintRule::MissingFilterRules,
1348 "missing required field 'filter.rules'",
1349 "/filter/rules",
1350 ));
1351 }
1352
1353 if !filter.contains_key(key("selection")) {
1355 warnings.push(err(
1356 LintRule::MissingFilterSelection,
1357 "missing required field 'filter.selection'",
1358 "/filter/selection",
1359 ));
1360 }
1361
1362 if !filter.contains_key(key("condition")) {
1364 warnings.push(err(
1365 LintRule::MissingFilterCondition,
1366 "missing required field 'filter.condition'",
1367 "/filter/condition",
1368 ));
1369 }
1370
1371 if !m.contains_key(key("logsource")) {
1373 warnings.push(err(
1374 LintRule::MissingFilterLogsource,
1375 "missing required field 'logsource' for filter rule",
1376 "/logsource",
1377 ));
1378 } else {
1379 lint_logsource(m, warnings);
1380 }
1381
1382 if m.contains_key(key("level")) {
1384 warnings.push(warning(
1385 LintRule::FilterHasLevel,
1386 "filter rules should not have a 'level' field",
1387 "/level",
1388 ));
1389 }
1390
1391 if m.contains_key(key("status")) {
1392 warnings.push(warning(
1393 LintRule::FilterHasStatus,
1394 "filter rules should not have a 'status' field",
1395 "/status",
1396 ));
1397 }
1398}
1399
1400fn edit_distance(a: &str, b: &str) -> usize {
1406 let (a_len, b_len) = (a.len(), b.len());
1407 if a_len == 0 {
1408 return b_len;
1409 }
1410 if b_len == 0 {
1411 return a_len;
1412 }
1413 let mut prev: Vec<usize> = (0..=b_len).collect();
1414 let mut curr = vec![0; b_len + 1];
1415 for (i, ca) in a.bytes().enumerate() {
1416 curr[0] = i + 1;
1417 for (j, cb) in b.bytes().enumerate() {
1418 let cost = if ca == cb { 0 } else { 1 };
1419 curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
1420 }
1421 std::mem::swap(&mut prev, &mut curr);
1422 }
1423 prev[b_len]
1424}
1425
1426const TYPO_MAX_EDIT_DISTANCE: usize = 2;
1428
1429fn lint_unknown_keys(m: &serde_yaml::Mapping, doc_type: DocType, warnings: &mut Vec<LintWarning>) {
1436 let type_keys = doc_type.known_keys();
1437 let all_known: Vec<&str> = KNOWN_KEYS_SHARED
1438 .iter()
1439 .chain(type_keys.iter())
1440 .copied()
1441 .collect();
1442
1443 for k in m.keys() {
1444 let Some(ks) = k.as_str() else { continue };
1445 if KNOWN_KEYS_SHARED.contains(&ks) || type_keys.contains(&ks) {
1446 continue;
1447 }
1448 if let Some(closest) = all_known
1450 .iter()
1451 .filter(|known| edit_distance(ks, known) <= TYPO_MAX_EDIT_DISTANCE)
1452 .min_by_key(|known| edit_distance(ks, known))
1453 {
1454 warnings.push(info(
1455 LintRule::UnknownKey,
1456 format!("unknown top-level key \"{ks}\"; did you mean \"{closest}\"?"),
1457 format!("/{ks}"),
1458 ));
1459 }
1460 }
1461}
1462
1463pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
1468 let Some(m) = value.as_mapping() else {
1469 return vec![err(
1470 LintRule::NotAMapping,
1471 "document is not a YAML mapping",
1472 "/",
1473 )];
1474 };
1475
1476 if is_action_fragment(m) {
1478 return Vec::new();
1479 }
1480
1481 let mut warnings = Vec::new();
1482
1483 lint_shared(m, &mut warnings);
1485
1486 let doc_type = detect_doc_type(m);
1488 match doc_type {
1489 DocType::Detection => lint_detection_rule(m, &mut warnings),
1490 DocType::Correlation => lint_correlation_rule(m, &mut warnings),
1491 DocType::Filter => lint_filter_rule(m, &mut warnings),
1492 }
1493
1494 lint_unknown_keys(m, doc_type, &mut warnings);
1496
1497 warnings
1498}
1499
1500pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
1506 let mut all_warnings = Vec::new();
1507
1508 for doc in serde_yaml::Deserializer::from_str(text) {
1509 let value: Value = match Value::deserialize(doc) {
1510 Ok(v) => v,
1511 Err(e) => {
1512 let mut w = err(
1513 LintRule::YamlParseError,
1514 format!("YAML parse error: {e}"),
1515 "/",
1516 );
1517 if let Some(loc) = e.location() {
1519 w.span = Some(Span {
1520 start_line: loc.line().saturating_sub(1) as u32,
1521 start_col: loc.column() as u32,
1522 end_line: loc.line().saturating_sub(1) as u32,
1523 end_col: loc.column() as u32 + 1,
1524 });
1525 }
1526 all_warnings.push(w);
1527 break;
1532 }
1533 };
1534
1535 let warnings = lint_yaml_value(&value);
1536 for mut w in warnings {
1538 w.span = resolve_path_to_span(text, &w.path);
1539 all_warnings.push(w);
1540 }
1541 }
1542
1543 all_warnings
1544}
1545
1546fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
1550 if path == "/" || path.is_empty() {
1551 for (i, line) in text.lines().enumerate() {
1553 let trimmed = line.trim();
1554 if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
1555 return Some(Span {
1556 start_line: i as u32,
1557 start_col: 0,
1558 end_line: i as u32,
1559 end_col: line.len() as u32,
1560 });
1561 }
1562 }
1563 return None;
1564 }
1565
1566 let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
1567
1568 if segments.is_empty() {
1569 return None;
1570 }
1571
1572 let lines: Vec<&str> = text.lines().collect();
1573 let mut current_indent: i32 = -1;
1574 let mut search_start = 0usize;
1575 let mut last_matched_line: Option<usize> = None;
1576
1577 for segment in &segments {
1578 let array_index: Option<usize> = segment.parse().ok();
1579 let mut found = false;
1580
1581 let mut line_num = search_start;
1582 while line_num < lines.len() {
1583 let line = lines[line_num];
1584 let trimmed = line.trim();
1585 if trimmed.is_empty() || trimmed.starts_with('#') {
1586 line_num += 1;
1587 continue;
1588 }
1589
1590 let indent = (line.len() - trimmed.len()) as i32;
1591
1592 if indent <= current_indent && found {
1593 break;
1594 }
1595 if indent <= current_indent {
1596 line_num += 1;
1597 continue;
1598 }
1599
1600 if let Some(idx) = array_index {
1601 if trimmed.starts_with("- ") && indent > current_indent {
1602 let mut count = 0usize;
1603 for (offset, sl) in lines[search_start..].iter().enumerate() {
1604 let scan = search_start + offset;
1605 let st = sl.trim();
1606 if st.is_empty() || st.starts_with('#') {
1607 continue;
1608 }
1609 let si = (sl.len() - st.len()) as i32;
1610 if si == indent && st.starts_with("- ") {
1611 if count == idx {
1612 last_matched_line = Some(scan);
1613 search_start = scan + 1;
1614 current_indent = indent;
1615 found = true;
1616 break;
1617 }
1618 count += 1;
1619 }
1620 if si < indent && count > 0 {
1621 break;
1622 }
1623 }
1624 break;
1625 }
1626 } else {
1627 let key_pattern = format!("{segment}:");
1628 if trimmed.starts_with(&key_pattern) || trimmed == *segment {
1629 last_matched_line = Some(line_num);
1630 search_start = line_num + 1;
1631 current_indent = indent;
1632 found = true;
1633 break;
1634 }
1635 }
1636
1637 line_num += 1;
1638 }
1639
1640 if !found && last_matched_line.is_none() {
1641 break;
1642 }
1643 }
1644
1645 last_matched_line.map(|line_num| {
1646 let line = lines[line_num];
1647 Span {
1648 start_line: line_num as u32,
1649 start_col: 0,
1650 end_line: line_num as u32,
1651 end_col: line.len() as u32,
1652 }
1653 })
1654}
1655
1656pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
1662 let content = std::fs::read_to_string(path)?;
1663 let warnings = lint_yaml_str(&content);
1664 Ok(FileLintResult {
1665 path: path.to_path_buf(),
1666 warnings,
1667 })
1668}
1669
1670pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
1675 let mut results = Vec::new();
1676 let mut visited = HashSet::new();
1677
1678 fn walk(
1679 dir: &Path,
1680 results: &mut Vec<FileLintResult>,
1681 visited: &mut HashSet<std::path::PathBuf>,
1682 ) -> crate::error::Result<()> {
1683 let canonical = match dir.canonicalize() {
1685 Ok(p) => p,
1686 Err(_) => return Ok(()),
1687 };
1688 if !visited.insert(canonical) {
1689 return Ok(());
1691 }
1692
1693 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
1694 entries.sort_by_key(|e| e.path());
1695
1696 for entry in entries {
1697 let path = entry.path();
1698
1699 if path.is_dir() {
1701 if path
1702 .file_name()
1703 .and_then(|n| n.to_str())
1704 .is_some_and(|n| n.starts_with('.'))
1705 {
1706 continue;
1707 }
1708 walk(&path, results, visited)?;
1709 } else if matches!(
1710 path.extension().and_then(|e| e.to_str()),
1711 Some("yml" | "yaml")
1712 ) {
1713 match crate::lint::lint_yaml_file(&path) {
1714 Ok(file_result) => results.push(file_result),
1715 Err(e) => {
1716 results.push(FileLintResult {
1717 path: path.clone(),
1718 warnings: vec![err(
1719 LintRule::FileReadError,
1720 format!("error reading file: {e}"),
1721 "/",
1722 )],
1723 });
1724 }
1725 }
1726 }
1727 }
1728 Ok(())
1729 }
1730
1731 walk(dir, &mut results, &mut visited)?;
1732 Ok(results)
1733}
1734
1735#[derive(Debug, Clone, Default, Serialize)]
1744pub struct LintConfig {
1745 pub disabled_rules: HashSet<String>,
1747 pub severity_overrides: HashMap<String, Severity>,
1749}
1750
1751#[derive(Debug, Deserialize)]
1753struct RawLintConfig {
1754 #[serde(default)]
1755 disabled_rules: Vec<String>,
1756 #[serde(default)]
1757 severity_overrides: HashMap<String, String>,
1758}
1759
1760impl LintConfig {
1761 pub fn load(path: &Path) -> crate::error::Result<Self> {
1763 let content = std::fs::read_to_string(path)?;
1764 let raw: RawLintConfig = serde_yaml::from_str(&content)?;
1765
1766 let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
1767 let mut severity_overrides = HashMap::new();
1768 for (rule, sev_str) in &raw.severity_overrides {
1769 let sev = match sev_str.as_str() {
1770 "error" => Severity::Error,
1771 "warning" => Severity::Warning,
1772 "info" => Severity::Info,
1773 "hint" => Severity::Hint,
1774 other => {
1775 return Err(crate::error::SigmaParserError::InvalidRule(format!(
1776 "invalid severity '{other}' for rule '{rule}' in lint config"
1777 )));
1778 }
1779 };
1780 severity_overrides.insert(rule.clone(), sev);
1781 }
1782
1783 Ok(LintConfig {
1784 disabled_rules,
1785 severity_overrides,
1786 })
1787 }
1788
1789 pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
1794 let dir = if start_path.is_file() {
1795 start_path.parent()?
1796 } else {
1797 start_path
1798 };
1799
1800 let mut current = dir;
1801 loop {
1802 let candidate = current.join(".rsigma-lint.yml");
1803 if candidate.is_file() {
1804 return Some(candidate);
1805 }
1806 let candidate_yaml = current.join(".rsigma-lint.yaml");
1808 if candidate_yaml.is_file() {
1809 return Some(candidate_yaml);
1810 }
1811 current = current.parent()?;
1812 }
1813 }
1814
1815 pub fn merge(&mut self, other: &LintConfig) {
1817 self.disabled_rules
1818 .extend(other.disabled_rules.iter().cloned());
1819 for (rule, sev) in &other.severity_overrides {
1820 self.severity_overrides.insert(rule.clone(), *sev);
1821 }
1822 }
1823
1824 pub fn is_disabled(&self, rule: &LintRule) -> bool {
1826 self.disabled_rules.contains(&rule.to_string())
1827 }
1828}
1829
1830#[derive(Debug, Clone, Default)]
1836pub struct InlineSuppressions {
1837 pub disable_all: bool,
1839 pub file_disabled: HashSet<String>,
1841 pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
1844}
1845
1846pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
1854 let mut result = InlineSuppressions::default();
1855
1856 for (i, line) in text.lines().enumerate() {
1857 let trimmed = line.trim();
1858
1859 let comment = if let Some(pos) = find_yaml_comment(trimmed) {
1861 trimmed[pos + 1..].trim()
1862 } else {
1863 continue;
1864 };
1865
1866 if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
1867 let rest = rest.trim();
1868 let next_line = (i + 1) as u32;
1869 if rest.is_empty() {
1870 result.line_disabled.insert(next_line, None);
1872 } else {
1873 let rules: HashSet<String> = rest
1875 .split(',')
1876 .map(|s| s.trim().to_string())
1877 .filter(|s| !s.is_empty())
1878 .collect();
1879 if !rules.is_empty() {
1880 result
1881 .line_disabled
1882 .entry(next_line)
1883 .and_modify(|existing| {
1884 if let Some(existing_set) = existing {
1885 existing_set.extend(rules.iter().cloned());
1886 }
1887 })
1889 .or_insert(Some(rules));
1890 }
1891 }
1892 } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
1893 let rest = rest.trim();
1894 if rest.is_empty() {
1895 result.disable_all = true;
1897 } else {
1898 for rule in rest.split(',') {
1900 let rule = rule.trim();
1901 if !rule.is_empty() {
1902 result.file_disabled.insert(rule.to_string());
1903 }
1904 }
1905 }
1906 }
1907 }
1908
1909 result
1910}
1911
1912fn find_yaml_comment(line: &str) -> Option<usize> {
1916 let mut in_single = false;
1917 let mut in_double = false;
1918 for (i, c) in line.char_indices() {
1919 match c {
1920 '\'' if !in_double => in_single = !in_single,
1921 '"' if !in_single => in_double = !in_double,
1922 '#' if !in_single && !in_double => return Some(i),
1923 _ => {}
1924 }
1925 }
1926 None
1927}
1928
1929impl InlineSuppressions {
1930 pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
1932 if self.disable_all {
1934 return true;
1935 }
1936
1937 let rule_name = warning.rule.to_string();
1939 if self.file_disabled.contains(&rule_name) {
1940 return true;
1941 }
1942
1943 if let Some(span) = &warning.span
1945 && let Some(line_rules) = self.line_disabled.get(&span.start_line)
1946 {
1947 return match line_rules {
1948 None => true, Some(rules) => rules.contains(&rule_name),
1950 };
1951 }
1952
1953 false
1954 }
1955}
1956
1957pub fn apply_suppressions(
1967 warnings: Vec<LintWarning>,
1968 config: &LintConfig,
1969 inline: &InlineSuppressions,
1970) -> Vec<LintWarning> {
1971 warnings
1972 .into_iter()
1973 .filter(|w| !config.is_disabled(&w.rule))
1974 .filter(|w| !inline.is_suppressed(w))
1975 .map(|mut w| {
1976 let rule_name = w.rule.to_string();
1977 if let Some(sev) = config.severity_overrides.get(&rule_name) {
1978 w.severity = *sev;
1979 }
1980 w
1981 })
1982 .collect()
1983}
1984
1985pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
1990 let warnings = lint_yaml_str(text);
1991 let inline = parse_inline_suppressions(text);
1992 apply_suppressions(warnings, config, &inline)
1993}
1994
1995pub fn lint_yaml_file_with_config(
1997 path: &Path,
1998 config: &LintConfig,
1999) -> crate::error::Result<FileLintResult> {
2000 let content = std::fs::read_to_string(path)?;
2001 let warnings = lint_yaml_str_with_config(&content, config);
2002 Ok(FileLintResult {
2003 path: path.to_path_buf(),
2004 warnings,
2005 })
2006}
2007
2008pub fn lint_yaml_directory_with_config(
2010 dir: &Path,
2011 config: &LintConfig,
2012) -> crate::error::Result<Vec<FileLintResult>> {
2013 let mut results = Vec::new();
2014 let mut visited = HashSet::new();
2015
2016 fn walk(
2017 dir: &Path,
2018 config: &LintConfig,
2019 results: &mut Vec<FileLintResult>,
2020 visited: &mut HashSet<std::path::PathBuf>,
2021 ) -> crate::error::Result<()> {
2022 let canonical = match dir.canonicalize() {
2023 Ok(p) => p,
2024 Err(_) => return Ok(()),
2025 };
2026 if !visited.insert(canonical) {
2027 return Ok(());
2028 }
2029
2030 let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
2031 entries.sort_by_key(|e| e.path());
2032
2033 for entry in entries {
2034 let path = entry.path();
2035 if path.is_dir() {
2036 if path
2037 .file_name()
2038 .and_then(|n| n.to_str())
2039 .is_some_and(|n| n.starts_with('.'))
2040 {
2041 continue;
2042 }
2043 walk(&path, config, results, visited)?;
2044 } else if matches!(
2045 path.extension().and_then(|e| e.to_str()),
2046 Some("yml" | "yaml")
2047 ) {
2048 match lint_yaml_file_with_config(&path, config) {
2049 Ok(file_result) => results.push(file_result),
2050 Err(e) => {
2051 results.push(FileLintResult {
2052 path: path.clone(),
2053 warnings: vec![err(
2054 LintRule::FileReadError,
2055 format!("error reading file: {e}"),
2056 "/",
2057 )],
2058 });
2059 }
2060 }
2061 }
2062 }
2063 Ok(())
2064 }
2065
2066 walk(dir, config, &mut results, &mut visited)?;
2067 Ok(results)
2068}
2069
2070#[cfg(test)]
2075mod tests {
2076 use super::*;
2077
2078 fn yaml_value(yaml: &str) -> Value {
2079 serde_yaml::from_str(yaml).unwrap()
2080 }
2081
2082 fn lint(yaml: &str) -> Vec<LintWarning> {
2083 lint_yaml_value(&yaml_value(yaml))
2084 }
2085
2086 fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
2087 warnings.iter().any(|w| w.rule == rule)
2088 }
2089
2090 fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
2091 !has_rule(warnings, rule)
2092 }
2093
2094 #[test]
2097 fn valid_detection_rule_no_errors() {
2098 let w = lint(
2099 r#"
2100title: Test Rule
2101id: 929a690e-bef0-4204-a928-ef5e620d6fcc
2102status: test
2103logsource:
2104 category: process_creation
2105 product: windows
2106detection:
2107 selection:
2108 CommandLine|contains: 'whoami'
2109 condition: selection
2110level: medium
2111tags:
2112 - attack.execution
2113 - attack.t1059
2114"#,
2115 );
2116 let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
2117 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
2118 }
2119
2120 #[test]
2123 fn missing_title() {
2124 let w = lint(
2125 r#"
2126logsource:
2127 category: test
2128detection:
2129 selection:
2130 field: value
2131 condition: selection
2132"#,
2133 );
2134 assert!(has_rule(&w, LintRule::MissingTitle));
2135 }
2136
2137 #[test]
2138 fn title_too_long() {
2139 let long_title = "a".repeat(257);
2140 let yaml = format!(
2141 r#"
2142title: '{long_title}'
2143logsource:
2144 category: test
2145detection:
2146 selection:
2147 field: value
2148 condition: selection
2149"#
2150 );
2151 let w = lint(&yaml);
2152 assert!(has_rule(&w, LintRule::TitleTooLong));
2153 }
2154
2155 #[test]
2156 fn invalid_id() {
2157 let w = lint(
2158 r#"
2159title: Test
2160id: not-a-uuid
2161logsource:
2162 category: test
2163detection:
2164 selection:
2165 field: value
2166 condition: selection
2167"#,
2168 );
2169 assert!(has_rule(&w, LintRule::InvalidId));
2170 }
2171
2172 #[test]
2173 fn valid_id_no_warning() {
2174 let w = lint(
2175 r#"
2176title: Test
2177id: 929a690e-bef0-4204-a928-ef5e620d6fcc
2178logsource:
2179 category: test
2180detection:
2181 selection:
2182 field: value
2183 condition: selection
2184"#,
2185 );
2186 assert!(has_no_rule(&w, LintRule::InvalidId));
2187 }
2188
2189 #[test]
2190 fn invalid_status() {
2191 let w = lint(
2192 r#"
2193title: Test
2194status: invalid
2195logsource:
2196 category: test
2197detection:
2198 selection:
2199 field: value
2200 condition: selection
2201"#,
2202 );
2203 assert!(has_rule(&w, LintRule::InvalidStatus));
2204 }
2205
2206 #[test]
2207 fn invalid_level() {
2208 let w = lint(
2209 r#"
2210title: Test
2211level: important
2212logsource:
2213 category: test
2214detection:
2215 selection:
2216 field: value
2217 condition: selection
2218"#,
2219 );
2220 assert!(has_rule(&w, LintRule::InvalidLevel));
2221 }
2222
2223 #[test]
2224 fn invalid_date_format() {
2225 let w = lint(
2226 r#"
2227title: Test
2228date: 'Jan 2025'
2229logsource:
2230 category: test
2231detection:
2232 selection:
2233 field: value
2234 condition: selection
2235"#,
2236 );
2237 assert!(has_rule(&w, LintRule::InvalidDate));
2238 }
2239
2240 #[test]
2241 fn modified_before_date() {
2242 let w = lint(
2243 r#"
2244title: Test
2245date: '2025-06-15'
2246modified: '2025-06-10'
2247logsource:
2248 category: test
2249detection:
2250 selection:
2251 field: value
2252 condition: selection
2253"#,
2254 );
2255 assert!(has_rule(&w, LintRule::ModifiedBeforeDate));
2256 }
2257
2258 #[test]
2259 fn non_lowercase_key() {
2260 let w = lint(
2261 r#"
2262title: Test
2263Status: test
2264logsource:
2265 category: test
2266detection:
2267 selection:
2268 field: value
2269 condition: selection
2270"#,
2271 );
2272 assert!(has_rule(&w, LintRule::NonLowercaseKey));
2273 }
2274
2275 #[test]
2278 fn missing_logsource() {
2279 let w = lint(
2280 r#"
2281title: Test
2282detection:
2283 selection:
2284 field: value
2285 condition: selection
2286"#,
2287 );
2288 assert!(has_rule(&w, LintRule::MissingLogsource));
2289 }
2290
2291 #[test]
2292 fn missing_detection() {
2293 let w = lint(
2294 r#"
2295title: Test
2296logsource:
2297 category: test
2298"#,
2299 );
2300 assert!(has_rule(&w, LintRule::MissingDetection));
2301 }
2302
2303 #[test]
2304 fn missing_condition() {
2305 let w = lint(
2306 r#"
2307title: Test
2308logsource:
2309 category: test
2310detection:
2311 selection:
2312 field: value
2313"#,
2314 );
2315 assert!(has_rule(&w, LintRule::MissingCondition));
2316 }
2317
2318 #[test]
2319 fn empty_detection() {
2320 let w = lint(
2321 r#"
2322title: Test
2323logsource:
2324 category: test
2325detection:
2326 condition: selection
2327"#,
2328 );
2329 assert!(has_rule(&w, LintRule::EmptyDetection));
2330 }
2331
2332 #[test]
2333 fn invalid_related_type() {
2334 let w = lint(
2335 r#"
2336title: Test
2337logsource:
2338 category: test
2339detection:
2340 selection:
2341 field: value
2342 condition: selection
2343related:
2344 - id: 929a690e-bef0-4204-a928-ef5e620d6fcc
2345 type: invalid_type
2346"#,
2347 );
2348 assert!(has_rule(&w, LintRule::InvalidRelatedType));
2349 }
2350
2351 #[test]
2352 fn related_missing_required_fields() {
2353 let w = lint(
2354 r#"
2355title: Test
2356logsource:
2357 category: test
2358detection:
2359 selection:
2360 field: value
2361 condition: selection
2362related:
2363 - id: 929a690e-bef0-4204-a928-ef5e620d6fcc
2364"#,
2365 );
2366 assert!(has_rule(&w, LintRule::RelatedMissingRequired));
2367 }
2368
2369 #[test]
2370 fn deprecated_without_related() {
2371 let w = lint(
2372 r#"
2373title: Test
2374status: deprecated
2375logsource:
2376 category: test
2377detection:
2378 selection:
2379 field: value
2380 condition: selection
2381"#,
2382 );
2383 assert!(has_rule(&w, LintRule::DeprecatedWithoutRelated));
2384 }
2385
2386 #[test]
2387 fn invalid_tag_pattern() {
2388 let w = lint(
2389 r#"
2390title: Test
2391logsource:
2392 category: test
2393detection:
2394 selection:
2395 field: value
2396 condition: selection
2397tags:
2398 - 'Invalid Tag'
2399"#,
2400 );
2401 assert!(has_rule(&w, LintRule::InvalidTag));
2402 }
2403
2404 #[test]
2405 fn unknown_tag_namespace() {
2406 let w = lint(
2407 r#"
2408title: Test
2409logsource:
2410 category: test
2411detection:
2412 selection:
2413 field: value
2414 condition: selection
2415tags:
2416 - custom.something
2417"#,
2418 );
2419 assert!(has_rule(&w, LintRule::UnknownTagNamespace));
2420 }
2421
2422 #[test]
2423 fn duplicate_tags() {
2424 let w = lint(
2425 r#"
2426title: Test
2427logsource:
2428 category: test
2429detection:
2430 selection:
2431 field: value
2432 condition: selection
2433tags:
2434 - attack.execution
2435 - attack.execution
2436"#,
2437 );
2438 assert!(has_rule(&w, LintRule::DuplicateTags));
2439 }
2440
2441 #[test]
2442 fn logsource_not_lowercase() {
2443 let w = lint(
2444 r#"
2445title: Test
2446logsource:
2447 category: Process_Creation
2448 product: Windows
2449detection:
2450 selection:
2451 field: value
2452 condition: selection
2453"#,
2454 );
2455 assert!(has_rule(&w, LintRule::LogsourceValueNotLowercase));
2456 }
2457
2458 #[test]
2459 fn single_value_all_modifier() {
2460 let w = lint(
2461 r#"
2462title: Test
2463logsource:
2464 category: test
2465detection:
2466 selection:
2467 CommandLine|contains|all: 'single'
2468 condition: selection
2469"#,
2470 );
2471 assert!(has_rule(&w, LintRule::SingleValueAllModifier));
2472 }
2473
2474 #[test]
2475 fn null_in_value_list() {
2476 let w = lint(
2477 r#"
2478title: Test
2479logsource:
2480 category: test
2481detection:
2482 selection:
2483 FieldA:
2484 - 'value1'
2485 - null
2486 condition: selection
2487"#,
2488 );
2489 assert!(has_rule(&w, LintRule::NullInValueList));
2490 }
2491
2492 #[test]
2495 fn valid_correlation_no_errors() {
2496 let w = lint(
2497 r#"
2498title: Brute Force
2499correlation:
2500 type: event_count
2501 rules:
2502 - 929a690e-bef0-4204-a928-ef5e620d6fcc
2503 group-by:
2504 - User
2505 timespan: 1h
2506 condition:
2507 gte: 100
2508level: high
2509"#,
2510 );
2511 let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
2512 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
2513 }
2514
2515 #[test]
2516 fn invalid_correlation_type() {
2517 let w = lint(
2518 r#"
2519title: Test
2520correlation:
2521 type: invalid_type
2522 rules:
2523 - some-rule
2524 timespan: 1h
2525 group-by:
2526 - User
2527"#,
2528 );
2529 assert!(has_rule(&w, LintRule::InvalidCorrelationType));
2530 }
2531
2532 #[test]
2533 fn missing_correlation_timespan() {
2534 let w = lint(
2535 r#"
2536title: Test
2537correlation:
2538 type: event_count
2539 rules:
2540 - some-rule
2541 group-by:
2542 - User
2543 condition:
2544 gte: 10
2545"#,
2546 );
2547 assert!(has_rule(&w, LintRule::MissingCorrelationTimespan));
2548 }
2549
2550 #[test]
2551 fn invalid_timespan_format() {
2552 let w = lint(
2553 r#"
2554title: Test
2555correlation:
2556 type: event_count
2557 rules:
2558 - some-rule
2559 group-by:
2560 - User
2561 timespan: 1hour
2562 condition:
2563 gte: 10
2564"#,
2565 );
2566 assert!(has_rule(&w, LintRule::InvalidTimespanFormat));
2567 }
2568
2569 #[test]
2570 fn missing_group_by() {
2571 let w = lint(
2572 r#"
2573title: Test
2574correlation:
2575 type: event_count
2576 rules:
2577 - some-rule
2578 timespan: 1h
2579 condition:
2580 gte: 10
2581"#,
2582 );
2583 assert!(has_rule(&w, LintRule::MissingGroupBy));
2584 }
2585
2586 #[test]
2587 fn missing_condition_field_for_value_count() {
2588 let w = lint(
2589 r#"
2590title: Test
2591correlation:
2592 type: value_count
2593 rules:
2594 - some-rule
2595 group-by:
2596 - User
2597 timespan: 1h
2598 condition:
2599 gte: 10
2600"#,
2601 );
2602 assert!(has_rule(&w, LintRule::MissingConditionField));
2603 }
2604
2605 #[test]
2606 fn invalid_condition_operator() {
2607 let w = lint(
2608 r#"
2609title: Test
2610correlation:
2611 type: event_count
2612 rules:
2613 - some-rule
2614 group-by:
2615 - User
2616 timespan: 1h
2617 condition:
2618 bigger: 10
2619"#,
2620 );
2621 assert!(has_rule(&w, LintRule::InvalidConditionOperator));
2622 }
2623
2624 #[test]
2625 fn generate_not_boolean() {
2626 let w = lint(
2627 r#"
2628title: Test
2629correlation:
2630 type: event_count
2631 rules:
2632 - some-rule
2633 group-by:
2634 - User
2635 timespan: 1h
2636 condition:
2637 gte: 10
2638 generate: 'yes'
2639"#,
2640 );
2641 assert!(has_rule(&w, LintRule::GenerateNotBoolean));
2642 }
2643
2644 #[test]
2647 fn valid_filter_no_errors() {
2648 let w = lint(
2649 r#"
2650title: Filter Admin
2651logsource:
2652 category: process_creation
2653 product: windows
2654filter:
2655 rules:
2656 - 929a690e-bef0-4204-a928-ef5e620d6fcc
2657 selection:
2658 User|startswith: 'adm_'
2659 condition: selection
2660"#,
2661 );
2662 let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
2663 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
2664 }
2665
2666 #[test]
2667 fn missing_filter_rules() {
2668 let w = lint(
2669 r#"
2670title: Test
2671logsource:
2672 category: test
2673filter:
2674 selection:
2675 User: admin
2676 condition: selection
2677"#,
2678 );
2679 assert!(has_rule(&w, LintRule::MissingFilterRules));
2680 }
2681
2682 #[test]
2683 fn missing_filter_selection() {
2684 let w = lint(
2685 r#"
2686title: Test
2687logsource:
2688 category: test
2689filter:
2690 rules:
2691 - some-rule
2692 condition: selection
2693"#,
2694 );
2695 assert!(has_rule(&w, LintRule::MissingFilterSelection));
2696 }
2697
2698 #[test]
2699 fn missing_filter_condition() {
2700 let w = lint(
2701 r#"
2702title: Test
2703logsource:
2704 category: test
2705filter:
2706 rules:
2707 - some-rule
2708 selection:
2709 User: admin
2710"#,
2711 );
2712 assert!(has_rule(&w, LintRule::MissingFilterCondition));
2713 }
2714
2715 #[test]
2716 fn filter_has_level_warning() {
2717 let w = lint(
2718 r#"
2719title: Test
2720logsource:
2721 category: test
2722level: high
2723filter:
2724 rules:
2725 - some-rule
2726 selection:
2727 User: admin
2728 condition: selection
2729"#,
2730 );
2731 assert!(has_rule(&w, LintRule::FilterHasLevel));
2732 }
2733
2734 #[test]
2735 fn filter_has_status_warning() {
2736 let w = lint(
2737 r#"
2738title: Test
2739logsource:
2740 category: test
2741status: test
2742filter:
2743 rules:
2744 - some-rule
2745 selection:
2746 User: admin
2747 condition: selection
2748"#,
2749 );
2750 assert!(has_rule(&w, LintRule::FilterHasStatus));
2751 }
2752
2753 #[test]
2754 fn missing_filter_logsource() {
2755 let w = lint(
2756 r#"
2757title: Test
2758filter:
2759 rules:
2760 - some-rule
2761 selection:
2762 User: admin
2763 condition: selection
2764"#,
2765 );
2766 assert!(has_rule(&w, LintRule::MissingFilterLogsource));
2767 }
2768
2769 #[test]
2772 fn action_global_skipped() {
2773 let w = lint(
2774 r#"
2775action: global
2776title: Global Template
2777logsource:
2778 product: windows
2779"#,
2780 );
2781 assert!(w.is_empty());
2782 }
2783
2784 #[test]
2785 fn action_reset_skipped() {
2786 let w = lint(
2787 r#"
2788action: reset
2789"#,
2790 );
2791 assert!(w.is_empty());
2792 }
2793
2794 #[test]
2797 fn empty_title() {
2798 let w = lint(
2799 r#"
2800title: ''
2801logsource:
2802 category: test
2803detection:
2804 selection:
2805 field: value
2806 condition: selection
2807level: medium
2808"#,
2809 );
2810 assert!(has_rule(&w, LintRule::EmptyTitle));
2811 }
2812
2813 #[test]
2814 fn missing_level() {
2815 let w = lint(
2816 r#"
2817title: Test
2818logsource:
2819 category: test
2820detection:
2821 selection:
2822 field: value
2823 condition: selection
2824"#,
2825 );
2826 assert!(has_rule(&w, LintRule::MissingLevel));
2827 }
2828
2829 #[test]
2830 fn valid_level_no_missing_warning() {
2831 let w = lint(
2832 r#"
2833title: Test
2834logsource:
2835 category: test
2836detection:
2837 selection:
2838 field: value
2839 condition: selection
2840level: medium
2841"#,
2842 );
2843 assert!(has_no_rule(&w, LintRule::MissingLevel));
2844 }
2845
2846 #[test]
2847 fn invalid_date_feb_30() {
2848 assert!(!is_valid_date("2025-02-30"));
2849 }
2850
2851 #[test]
2852 fn invalid_date_apr_31() {
2853 assert!(!is_valid_date("2025-04-31"));
2854 }
2855
2856 #[test]
2857 fn valid_date_feb_28() {
2858 assert!(is_valid_date("2025-02-28"));
2859 }
2860
2861 #[test]
2862 fn valid_date_leap_year_feb_29() {
2863 assert!(is_valid_date("2024-02-29"));
2864 }
2865
2866 #[test]
2867 fn invalid_date_non_leap_feb_29() {
2868 assert!(!is_valid_date("2025-02-29"));
2869 }
2870
2871 #[test]
2872 fn condition_references_unknown() {
2873 let w = lint(
2874 r#"
2875title: Test
2876logsource:
2877 category: test
2878detection:
2879 selection:
2880 field: value
2881 condition: sel_main
2882level: medium
2883"#,
2884 );
2885 assert!(has_rule(&w, LintRule::ConditionReferencesUnknown));
2886 }
2887
2888 #[test]
2889 fn condition_references_valid() {
2890 let w = lint(
2891 r#"
2892title: Test
2893logsource:
2894 category: test
2895detection:
2896 selection:
2897 field: value
2898 condition: selection
2899level: medium
2900"#,
2901 );
2902 assert!(has_no_rule(&w, LintRule::ConditionReferencesUnknown));
2903 }
2904
2905 #[test]
2906 fn condition_references_complex_valid() {
2907 let w = lint(
2908 r#"
2909title: Test
2910logsource:
2911 category: test
2912detection:
2913 sel_main:
2914 field: value
2915 filter_fp:
2916 User: admin
2917 condition: sel_main and not filter_fp
2918level: medium
2919"#,
2920 );
2921 assert!(has_no_rule(&w, LintRule::ConditionReferencesUnknown));
2922 }
2923
2924 #[test]
2925 fn empty_value_list() {
2926 let w = lint(
2927 r#"
2928title: Test
2929logsource:
2930 category: test
2931detection:
2932 selection:
2933 field: []
2934 condition: selection
2935level: medium
2936"#,
2937 );
2938 assert!(has_rule(&w, LintRule::EmptyValueList));
2939 }
2940
2941 #[test]
2942 fn not_a_mapping() {
2943 let v: serde_yaml::Value = serde_yaml::from_str("- item1\n- item2").unwrap();
2944 let w = lint_yaml_value(&v);
2945 assert!(has_rule(&w, LintRule::NotAMapping));
2946 }
2947
2948 #[test]
2949 fn lint_yaml_str_produces_spans() {
2950 let text = r#"title: Test
2951status: invalid_status
2952logsource:
2953 category: test
2954detection:
2955 selection:
2956 field: value
2957 condition: selection
2958level: medium
2959"#;
2960 let warnings = lint_yaml_str(text);
2961 let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
2963 assert!(invalid_status.is_some(), "expected InvalidStatus warning");
2964 let span = invalid_status.unwrap().span;
2965 assert!(span.is_some(), "expected span to be resolved");
2966 assert_eq!(span.unwrap().start_line, 1);
2968 }
2969
2970 #[test]
2971 fn yaml_parse_error_uses_correct_rule() {
2972 let text = "title: [unclosed";
2973 let warnings = lint_yaml_str(text);
2974 assert!(has_rule(&warnings, LintRule::YamlParseError));
2975 assert!(has_no_rule(&warnings, LintRule::MissingTitle));
2976 }
2977
2978 #[test]
2981 fn unknown_key_typo_detected() {
2982 let w = lint(
2983 r#"
2984title: Test
2985desciption: Typo field
2986logsource:
2987 category: test
2988detection:
2989 selection:
2990 field: value
2991 condition: selection
2992level: medium
2993"#,
2994 );
2995 assert!(has_rule(&w, LintRule::UnknownKey));
2996 let unk = w.iter().find(|w| w.rule == LintRule::UnknownKey).unwrap();
2997 assert!(unk.message.contains("desciption"));
2998 assert!(unk.message.contains("description"));
2999 assert_eq!(unk.severity, Severity::Info);
3000 }
3001
3002 #[test]
3003 fn known_keys_no_unknown_warning() {
3004 let w = lint(
3005 r#"
3006title: Test Rule
3007id: 929a690e-bef0-4204-a928-ef5e620d6fcc
3008status: test
3009description: A valid description
3010author: tester
3011date: '2025-01-01'
3012modified: '2025-06-01'
3013license: MIT
3014logsource:
3015 category: process_creation
3016 product: windows
3017detection:
3018 selection:
3019 CommandLine|contains: 'whoami'
3020 condition: selection
3021level: medium
3022tags:
3023 - attack.execution
3024references:
3025 - https://example.com
3026fields:
3027 - CommandLine
3028falsepositives:
3029 - Legitimate admin
3030"#,
3031 );
3032 assert!(has_no_rule(&w, LintRule::UnknownKey));
3033 }
3034
3035 #[test]
3036 fn custom_fields_allowed_by_spec() {
3037 let w = lint(
3041 r#"
3042title: Test Rule
3043logsource:
3044 category: test
3045detection:
3046 selection:
3047 field: value
3048 condition: selection
3049level: medium
3050simulation:
3051 action: scan
3052regression_tests_path: tests/
3053custom_metadata: hello
3054"#,
3055 );
3056 assert!(has_no_rule(&w, LintRule::UnknownKey));
3057 }
3058
3059 #[test]
3060 fn unknown_key_typo_correlation() {
3061 let w = lint(
3063 r#"
3064title: Correlation Test
3065name: test_correlation
3066correlation:
3067 type: event_count
3068 rules:
3069 - rule1
3070 group-by:
3071 - src_ip
3072 timespan: 5m
3073 condition:
3074 gte: 10
3075lvel: high
3076"#,
3077 );
3078 assert!(has_rule(&w, LintRule::UnknownKey));
3079 let unk = w.iter().find(|w| w.rule == LintRule::UnknownKey).unwrap();
3080 assert!(unk.message.contains("lvel"));
3081 assert!(unk.message.contains("level"));
3082 }
3083
3084 #[test]
3085 fn unknown_key_custom_field_filter() {
3086 let w = lint(
3088 r#"
3089title: Filter Test
3090logsource:
3091 category: test
3092filter:
3093 rules:
3094 - rule1
3095 selection:
3096 User: admin
3097 condition: selection
3098badkey: foo
3099"#,
3100 );
3101 assert!(has_no_rule(&w, LintRule::UnknownKey));
3102 }
3103
3104 #[test]
3107 fn wildcard_only_value_string() {
3108 let w = lint(
3109 r#"
3110title: Test
3111logsource:
3112 category: test
3113detection:
3114 selection:
3115 TargetFilename: '*'
3116 condition: selection
3117level: medium
3118"#,
3119 );
3120 assert!(has_rule(&w, LintRule::WildcardOnlyValue));
3121 }
3122
3123 #[test]
3124 fn wildcard_only_value_list() {
3125 let w = lint(
3126 r#"
3127title: Test
3128logsource:
3129 category: test
3130detection:
3131 selection:
3132 TargetFilename:
3133 - '*'
3134 condition: selection
3135level: medium
3136"#,
3137 );
3138 assert!(has_rule(&w, LintRule::WildcardOnlyValue));
3139 }
3140
3141 #[test]
3142 fn wildcard_with_other_values_no_warning() {
3143 let w = lint(
3144 r#"
3145title: Test
3146logsource:
3147 category: test
3148detection:
3149 selection:
3150 TargetFilename:
3151 - '*temp*'
3152 - '*cache*'
3153 condition: selection
3154level: medium
3155"#,
3156 );
3157 assert!(has_no_rule(&w, LintRule::WildcardOnlyValue));
3158 }
3159
3160 #[test]
3161 fn wildcard_regex_no_warning() {
3162 let w = lint(
3163 r#"
3164title: Test
3165logsource:
3166 category: test
3167detection:
3168 selection:
3169 TargetFilename|re: '*'
3170 condition: selection
3171level: medium
3172"#,
3173 );
3174 assert!(has_no_rule(&w, LintRule::WildcardOnlyValue));
3175 }
3176
3177 #[test]
3180 fn resolve_path_to_span_root() {
3181 let text = "title: Test\nstatus: test\n";
3182 let span = resolve_path_to_span(text, "/");
3183 assert!(span.is_some());
3184 assert_eq!(span.unwrap().start_line, 0);
3185 }
3186
3187 #[test]
3188 fn resolve_path_to_span_top_level_key() {
3189 let text = "title: Test\nstatus: test\nlevel: high\n";
3190 let span = resolve_path_to_span(text, "/status");
3191 assert!(span.is_some());
3192 assert_eq!(span.unwrap().start_line, 1);
3193 }
3194
3195 #[test]
3196 fn resolve_path_to_span_nested_key() {
3197 let text = "title: Test\nlogsource:\n category: test\n product: windows\n";
3198 let span = resolve_path_to_span(text, "/logsource/product");
3199 assert!(span.is_some());
3200 assert_eq!(span.unwrap().start_line, 3);
3201 }
3202
3203 #[test]
3204 fn resolve_path_to_span_missing_key() {
3205 let text = "title: Test\nstatus: test\n";
3206 let span = resolve_path_to_span(text, "/nonexistent");
3207 assert!(span.is_none());
3208 }
3209
3210 #[test]
3213 fn multi_doc_yaml_lints_all_documents() {
3214 let text = r#"title: Rule 1
3215logsource:
3216 category: test
3217detection:
3218 selection:
3219 field: value
3220 condition: selection
3221level: medium
3222---
3223title: Rule 2
3224status: bad_status
3225logsource:
3226 category: test
3227detection:
3228 selection:
3229 field: value
3230 condition: selection
3231level: medium
3232"#;
3233 let warnings = lint_yaml_str(text);
3234 assert!(has_rule(&warnings, LintRule::InvalidStatus));
3236 }
3237
3238 #[test]
3241 fn timespan_zero_seconds() {
3242 assert!(is_valid_timespan("0s"));
3243 }
3244
3245 #[test]
3246 fn timespan_no_digits() {
3247 assert!(!is_valid_timespan("s"));
3248 }
3249
3250 #[test]
3251 fn timespan_no_unit() {
3252 assert!(!is_valid_timespan("123"));
3253 }
3254
3255 #[test]
3256 fn timespan_invalid_unit() {
3257 assert!(!is_valid_timespan("5x"));
3258 }
3259
3260 #[test]
3261 fn timespan_valid_variants() {
3262 assert!(is_valid_timespan("30s"));
3263 assert!(is_valid_timespan("5m"));
3264 assert!(is_valid_timespan("1h"));
3265 assert!(is_valid_timespan("7d"));
3266 assert!(is_valid_timespan("1w"));
3267 assert!(is_valid_timespan("1M"));
3268 assert!(is_valid_timespan("1y"));
3269 }
3270
3271 #[test]
3274 fn file_lint_result_has_errors() {
3275 let result = FileLintResult {
3276 path: std::path::PathBuf::from("test.yml"),
3277 warnings: vec![
3278 warning(LintRule::TitleTooLong, "too long", "/title"),
3279 err(
3280 LintRule::MissingCondition,
3281 "missing",
3282 "/detection/condition",
3283 ),
3284 ],
3285 };
3286 assert!(result.has_errors());
3287 assert_eq!(result.error_count(), 1);
3288 assert_eq!(result.warning_count(), 1);
3289 }
3290
3291 #[test]
3292 fn file_lint_result_no_errors() {
3293 let result = FileLintResult {
3294 path: std::path::PathBuf::from("test.yml"),
3295 warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
3296 };
3297 assert!(!result.has_errors());
3298 assert_eq!(result.error_count(), 0);
3299 assert_eq!(result.warning_count(), 1);
3300 }
3301
3302 #[test]
3303 fn file_lint_result_empty() {
3304 let result = FileLintResult {
3305 path: std::path::PathBuf::from("test.yml"),
3306 warnings: vec![],
3307 };
3308 assert!(!result.has_errors());
3309 assert_eq!(result.error_count(), 0);
3310 assert_eq!(result.warning_count(), 0);
3311 }
3312
3313 #[test]
3316 fn lint_warning_display() {
3317 let w = err(
3318 LintRule::MissingTitle,
3319 "missing required field 'title'",
3320 "/title",
3321 );
3322 let display = format!("{w}");
3323 assert!(display.contains("error"));
3324 assert!(display.contains("missing_title"));
3325 assert!(display.contains("/title"));
3326 }
3327
3328 #[test]
3331 fn missing_description_info() {
3332 let w = lint(
3333 r#"
3334title: Test
3335logsource:
3336 category: test
3337detection:
3338 selection:
3339 field: value
3340 condition: selection
3341level: medium
3342"#,
3343 );
3344 assert!(has_rule(&w, LintRule::MissingDescription));
3345 let md = w
3346 .iter()
3347 .find(|w| w.rule == LintRule::MissingDescription)
3348 .unwrap();
3349 assert_eq!(md.severity, Severity::Info);
3350 }
3351
3352 #[test]
3353 fn has_description_no_info() {
3354 let w = lint(
3355 r#"
3356title: Test
3357description: A fine description
3358logsource:
3359 category: test
3360detection:
3361 selection:
3362 field: value
3363 condition: selection
3364level: medium
3365"#,
3366 );
3367 assert!(has_no_rule(&w, LintRule::MissingDescription));
3368 }
3369
3370 #[test]
3371 fn missing_author_info() {
3372 let w = lint(
3373 r#"
3374title: Test
3375logsource:
3376 category: test
3377detection:
3378 selection:
3379 field: value
3380 condition: selection
3381level: medium
3382"#,
3383 );
3384 assert!(has_rule(&w, LintRule::MissingAuthor));
3385 let ma = w
3386 .iter()
3387 .find(|w| w.rule == LintRule::MissingAuthor)
3388 .unwrap();
3389 assert_eq!(ma.severity, Severity::Info);
3390 }
3391
3392 #[test]
3393 fn has_author_no_info() {
3394 let w = lint(
3395 r#"
3396title: Test
3397author: tester
3398logsource:
3399 category: test
3400detection:
3401 selection:
3402 field: value
3403 condition: selection
3404level: medium
3405"#,
3406 );
3407 assert!(has_no_rule(&w, LintRule::MissingAuthor));
3408 }
3409
3410 #[test]
3411 fn all_with_re_warning() {
3412 let w = lint(
3413 r#"
3414title: Test
3415logsource:
3416 category: test
3417detection:
3418 selection:
3419 CommandLine|all|re:
3420 - '(?i)whoami'
3421 - '(?i)net user'
3422 condition: selection
3423level: medium
3424"#,
3425 );
3426 assert!(has_rule(&w, LintRule::AllWithRe));
3427 }
3428
3429 #[test]
3430 fn all_without_re_no_all_with_re() {
3431 let w = lint(
3432 r#"
3433title: Test
3434logsource:
3435 category: test
3436detection:
3437 selection:
3438 CommandLine|contains|all:
3439 - 'whoami'
3440 - 'net user'
3441 condition: selection
3442level: medium
3443"#,
3444 );
3445 assert!(has_no_rule(&w, LintRule::AllWithRe));
3446 }
3447
3448 #[test]
3449 fn re_without_all_no_all_with_re() {
3450 let w = lint(
3451 r#"
3452title: Test
3453logsource:
3454 category: test
3455detection:
3456 selection:
3457 CommandLine|re: '(?i)whoami|net user'
3458 condition: selection
3459level: medium
3460"#,
3461 );
3462 assert!(has_no_rule(&w, LintRule::AllWithRe));
3463 }
3464
3465 #[test]
3468 fn severity_display() {
3469 assert_eq!(format!("{}", Severity::Error), "error");
3470 assert_eq!(format!("{}", Severity::Warning), "warning");
3471 assert_eq!(format!("{}", Severity::Info), "info");
3472 assert_eq!(format!("{}", Severity::Hint), "hint");
3473 }
3474
3475 #[test]
3476 fn file_lint_result_info_count() {
3477 let result = FileLintResult {
3478 path: std::path::PathBuf::from("test.yml"),
3479 warnings: vec![
3480 info(LintRule::MissingDescription, "missing desc", "/description"),
3481 info(LintRule::MissingAuthor, "missing author", "/author"),
3482 warning(LintRule::TitleTooLong, "too long", "/title"),
3483 ],
3484 };
3485 assert_eq!(result.info_count(), 2);
3486 assert_eq!(result.warning_count(), 1);
3487 assert_eq!(result.error_count(), 0);
3488 assert!(!result.has_errors());
3489 }
3490
3491 #[test]
3494 fn parse_inline_disable_all() {
3495 let text = "# rsigma-disable\ntitle: Test\n";
3496 let sup = parse_inline_suppressions(text);
3497 assert!(sup.disable_all);
3498 }
3499
3500 #[test]
3501 fn parse_inline_disable_specific_rules() {
3502 let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
3503 let sup = parse_inline_suppressions(text);
3504 assert!(!sup.disable_all);
3505 assert!(sup.file_disabled.contains("missing_description"));
3506 assert!(sup.file_disabled.contains("missing_author"));
3507 }
3508
3509 #[test]
3510 fn parse_inline_disable_next_line_all() {
3511 let text = "# rsigma-disable-next-line\ntitle: Test\n";
3512 let sup = parse_inline_suppressions(text);
3513 assert!(!sup.disable_all);
3514 assert!(sup.line_disabled.contains_key(&1));
3516 assert!(sup.line_disabled[&1].is_none()); }
3518
3519 #[test]
3520 fn parse_inline_disable_next_line_specific() {
3521 let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
3522 let sup = parse_inline_suppressions(text);
3523 assert!(sup.line_disabled.contains_key(&2));
3525 let rules = sup.line_disabled[&2].as_ref().unwrap();
3526 assert!(rules.contains("missing_level"));
3527 }
3528
3529 #[test]
3530 fn parse_inline_no_comments() {
3531 let text = "title: Test\nstatus: test\n";
3532 let sup = parse_inline_suppressions(text);
3533 assert!(!sup.disable_all);
3534 assert!(sup.file_disabled.is_empty());
3535 assert!(sup.line_disabled.is_empty());
3536 }
3537
3538 #[test]
3539 fn parse_inline_comment_in_quoted_string() {
3540 let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
3542 let sup = parse_inline_suppressions(text);
3543 assert!(!sup.disable_all);
3544 assert!(sup.file_disabled.is_empty());
3545 }
3546
3547 #[test]
3550 fn apply_suppressions_disables_rule() {
3551 let warnings = vec![
3552 info(LintRule::MissingDescription, "desc", "/description"),
3553 info(LintRule::MissingAuthor, "author", "/author"),
3554 warning(LintRule::TitleTooLong, "title", "/title"),
3555 ];
3556 let mut config = LintConfig::default();
3557 config
3558 .disabled_rules
3559 .insert("missing_description".to_string());
3560 let inline = InlineSuppressions::default();
3561
3562 let result = apply_suppressions(warnings, &config, &inline);
3563 assert_eq!(result.len(), 2);
3564 assert!(
3565 result
3566 .iter()
3567 .all(|w| w.rule != LintRule::MissingDescription)
3568 );
3569 }
3570
3571 #[test]
3572 fn apply_suppressions_severity_override() {
3573 let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
3574 let mut config = LintConfig::default();
3575 config
3576 .severity_overrides
3577 .insert("title_too_long".to_string(), Severity::Info);
3578 let inline = InlineSuppressions::default();
3579
3580 let result = apply_suppressions(warnings, &config, &inline);
3581 assert_eq!(result.len(), 1);
3582 assert_eq!(result[0].severity, Severity::Info);
3583 }
3584
3585 #[test]
3586 fn apply_suppressions_inline_file_disable() {
3587 let warnings = vec![
3588 info(LintRule::MissingDescription, "desc", "/description"),
3589 info(LintRule::MissingAuthor, "author", "/author"),
3590 ];
3591 let config = LintConfig::default();
3592 let mut inline = InlineSuppressions::default();
3593 inline.file_disabled.insert("missing_author".to_string());
3594
3595 let result = apply_suppressions(warnings, &config, &inline);
3596 assert_eq!(result.len(), 1);
3597 assert_eq!(result[0].rule, LintRule::MissingDescription);
3598 }
3599
3600 #[test]
3601 fn apply_suppressions_inline_disable_all() {
3602 let warnings = vec![
3603 err(LintRule::MissingTitle, "title", "/title"),
3604 warning(LintRule::TitleTooLong, "long", "/title"),
3605 ];
3606 let config = LintConfig::default();
3607 let inline = InlineSuppressions {
3608 disable_all: true,
3609 ..Default::default()
3610 };
3611
3612 let result = apply_suppressions(warnings, &config, &inline);
3613 assert!(result.is_empty());
3614 }
3615
3616 #[test]
3617 fn apply_suppressions_inline_next_line() {
3618 let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
3619 w1.span = Some(Span {
3620 start_line: 5,
3621 start_col: 0,
3622 end_line: 5,
3623 end_col: 10,
3624 });
3625 let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
3626 w2.span = Some(Span {
3627 start_line: 6,
3628 start_col: 0,
3629 end_line: 6,
3630 end_col: 10,
3631 });
3632
3633 let config = LintConfig::default();
3634 let mut inline = InlineSuppressions::default();
3635 inline.line_disabled.insert(5, None);
3637
3638 let result = apply_suppressions(vec![w1, w2], &config, &inline);
3639 assert_eq!(result.len(), 1);
3640 assert_eq!(result[0].rule, LintRule::InvalidStatus);
3641 }
3642
3643 #[test]
3646 fn lint_with_config_disables_rules() {
3647 let text = r#"title: Test
3648logsource:
3649 category: test
3650detection:
3651 selection:
3652 field: value
3653 condition: selection
3654level: medium
3655"#;
3656 let mut config = LintConfig::default();
3657 config
3658 .disabled_rules
3659 .insert("missing_description".to_string());
3660 config.disabled_rules.insert("missing_author".to_string());
3661
3662 let warnings = lint_yaml_str_with_config(text, &config);
3663 assert!(
3664 !warnings
3665 .iter()
3666 .any(|w| w.rule == LintRule::MissingDescription)
3667 );
3668 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
3669 }
3670
3671 #[test]
3672 fn lint_with_inline_disable_next_line() {
3673 let text = r#"title: Test
3674# rsigma-disable-next-line missing_level
3675logsource:
3676 category: test
3677detection:
3678 selection:
3679 field: value
3680 condition: selection
3681"#;
3682 let config = LintConfig::default();
3687 let warnings = lint_yaml_str_with_config(text, &config);
3688 assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
3690 }
3691
3692 #[test]
3693 fn lint_with_inline_file_disable() {
3694 let text = r#"# rsigma-disable missing_description, missing_author
3695title: Test
3696logsource:
3697 category: test
3698detection:
3699 selection:
3700 field: value
3701 condition: selection
3702level: medium
3703"#;
3704 let config = LintConfig::default();
3705 let warnings = lint_yaml_str_with_config(text, &config);
3706 assert!(
3707 !warnings
3708 .iter()
3709 .any(|w| w.rule == LintRule::MissingDescription)
3710 );
3711 assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
3712 }
3713
3714 #[test]
3715 fn lint_with_inline_disable_all() {
3716 let text = r#"# rsigma-disable
3717title: Test
3718status: invalid_status
3719logsource:
3720 category: test
3721detection:
3722 selection:
3723 field: value
3724 condition: selection
3725"#;
3726 let config = LintConfig::default();
3727 let warnings = lint_yaml_str_with_config(text, &config);
3728 assert!(warnings.is_empty());
3729 }
3730
3731 #[test]
3734 fn lint_config_merge() {
3735 let mut base = LintConfig::default();
3736 base.disabled_rules.insert("rule_a".to_string());
3737 base.severity_overrides
3738 .insert("rule_b".to_string(), Severity::Info);
3739
3740 let other = LintConfig {
3741 disabled_rules: ["rule_c".to_string()].into_iter().collect(),
3742 severity_overrides: [("rule_d".to_string(), Severity::Hint)]
3743 .into_iter()
3744 .collect(),
3745 };
3746
3747 base.merge(&other);
3748 assert!(base.disabled_rules.contains("rule_a"));
3749 assert!(base.disabled_rules.contains("rule_c"));
3750 assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
3751 assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
3752 }
3753
3754 #[test]
3755 fn lint_config_is_disabled() {
3756 let mut config = LintConfig::default();
3757 config.disabled_rules.insert("missing_title".to_string());
3758 assert!(config.is_disabled(&LintRule::MissingTitle));
3759 assert!(!config.is_disabled(&LintRule::EmptyTitle));
3760 }
3761
3762 #[test]
3763 fn find_yaml_comment_basic() {
3764 assert_eq!(find_yaml_comment("# comment"), Some(0));
3765 assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
3766 assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
3767 assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
3768 assert_eq!(find_yaml_comment("key: value"), None);
3769 }
3770}