Skip to main content

rsigma_parser/lint/
catalogue.rs

1//! Programmatic metadata for every [`LintRule`] variant.
2//!
3//! [`catalogue`] returns one [`LintRuleInfo`] per lint rule (its stable
4//! snake_case id, default severity, fix disposition, and a one-line
5//! description). It lets tools — the MCP server's `rsigma://lint/catalogue`
6//! resource, docs generators, editor integrations — ground themselves on the
7//! exact lint vocabulary without scraping the rule modules.
8//!
9//! The list is generated by one macro so the same source drives both the
10//! catalogue and an *exhaustive* `match`: adding a [`LintRule`] variant without
11//! a catalogue entry is a compile error.
12//!
13//! # Example
14//!
15//! ```rust
16//! use rsigma_parser::lint::catalogue::catalogue;
17//!
18//! let entries = catalogue();
19//! assert_eq!(entries.len(), 86);
20//! let invalid_status = entries.iter().find(|e| e.name == "invalid_status").unwrap();
21//! assert!(invalid_status.fix.is_some()); // has a safe auto-fix
22//! ```
23
24use serde::Serialize;
25
26use super::{FixDisposition, LintRule, Severity};
27
28/// Metadata describing one lint rule.
29#[derive(Debug, Clone, Copy, Serialize)]
30pub struct LintRuleInfo {
31    /// Stable snake_case identifier (matches `LintRule`'s `Display`).
32    pub name: &'static str,
33    /// The severity the rule fires at by default (before config overrides).
34    pub default_severity: Severity,
35    /// The disposition of the auto-fix the rule can attach, if any. `None`
36    /// means the rule never carries a fix; `Some(Safe)` means it can attach a
37    /// safe, auto-appliable fix.
38    pub fix: Option<FixDisposition>,
39    /// One-line, human-readable description of what the rule checks.
40    pub description: &'static str,
41}
42
43/// Build the catalogue plus the exhaustive metadata lookup from one list.
44///
45/// Every `LintRule` variant must appear exactly once. The generated `describe`
46/// match has no wildcard arm, so a new variant fails to compile until it is
47/// added here.
48macro_rules! lint_catalogue {
49    ($($variant:ident => ($sev:expr, $fix:expr, $desc:expr)),+ $(,)?) => {
50        /// All lint rules, in declaration order.
51        const ALL_LINT_RULES: &[LintRule] = &[$(LintRule::$variant),+];
52
53        fn describe(rule: LintRule) -> (Severity, Option<FixDisposition>, &'static str) {
54            match rule {
55                $(LintRule::$variant => ($sev, $fix, $desc)),+
56            }
57        }
58    };
59}
60
61const SAFE: Option<FixDisposition> = Some(FixDisposition::Safe);
62const NONE: Option<FixDisposition> = None;
63
64lint_catalogue! {
65    // ── Infrastructure / parse errors ────────────────────────────────────
66    YamlParseError => (Severity::Error, NONE, "The document is not valid YAML."),
67    NotAMapping => (Severity::Error, NONE, "The document root is not a YAML mapping."),
68    FileReadError => (Severity::Error, NONE, "The file could not be read from disk."),
69    SchemaViolation => (Severity::Error, NONE, "Reserved for JSON-schema validation failures."),
70
71    // ── Shared (all document types) ──────────────────────────────────────
72    MissingTitle => (Severity::Error, NONE, "Required field 'title' is missing."),
73    EmptyTitle => (Severity::Error, NONE, "'title' is present but empty."),
74    TitleTooLong => (Severity::Warning, NONE, "'title' exceeds the 256-character maximum."),
75    MissingDescription => (Severity::Info, NONE, "Recommended field 'description' is missing."),
76    MissingAuthor => (Severity::Info, NONE, "Recommended field 'author' is missing."),
77    InvalidId => (Severity::Warning, NONE, "'id' is not a valid UUID."),
78    InvalidStatus => (Severity::Error, SAFE, "'status' is not one of the allowed values."),
79    MissingLevel => (Severity::Warning, NONE, "Recommended field 'level' is missing."),
80    InvalidLevel => (Severity::Error, SAFE, "'level' is not one of the allowed values."),
81    InvalidDate => (Severity::Error, NONE, "'date' is not a valid YYYY-MM-DD date."),
82    InvalidModified => (Severity::Error, NONE, "'modified' is not a valid YYYY-MM-DD date."),
83    ModifiedBeforeDate => (Severity::Warning, NONE, "'modified' is earlier than 'date'."),
84    DescriptionTooLong => (Severity::Warning, NONE, "'description' exceeds the 65535-character maximum."),
85    NameTooLong => (Severity::Warning, NONE, "'name' exceeds the 256-character maximum."),
86    TaxonomyTooLong => (Severity::Warning, NONE, "'taxonomy' exceeds the 256-character maximum."),
87    NonLowercaseKey => (Severity::Warning, SAFE, "A top-level key is not lowercase."),
88
89    // ── Detection rules ──────────────────────────────────────────────────
90    MissingLogsource => (Severity::Error, NONE, "Detection rule is missing 'logsource'."),
91    MissingDetection => (Severity::Error, NONE, "Detection rule is missing 'detection'."),
92    MissingCondition => (Severity::Error, NONE, "'detection' is missing 'condition'."),
93    EmptyDetection => (Severity::Warning, NONE, "'detection' has no named search identifiers."),
94    InvalidRelatedType => (Severity::Error, NONE, "A 'related' entry uses an invalid relation type."),
95    InvalidRelatedId => (Severity::Warning, NONE, "A 'related' entry id is not a valid UUID."),
96    RelatedMissingRequired => (Severity::Error, NONE, "A 'related' entry is missing 'id' or 'type'."),
97    DeprecatedWithoutRelated => (Severity::Warning, NONE, "A deprecated rule has no 'related' replacement link."),
98    InvalidTag => (Severity::Warning, NONE, "A tag does not match the expected pattern."),
99    UnknownTagNamespace => (Severity::Warning, NONE, "A tag uses an unrecognised namespace."),
100    DuplicateTags => (Severity::Warning, SAFE, "The 'tags' list has duplicate entries."),
101    DuplicateReferences => (Severity::Warning, SAFE, "The 'references' list has duplicate entries."),
102    DuplicateFields => (Severity::Warning, SAFE, "The 'fields' list has duplicate entries."),
103    FalsepositiveTooShort => (Severity::Warning, NONE, "A 'falsepositives' entry is too short."),
104    ScopeTooShort => (Severity::Warning, NONE, "A 'scope' entry is too short."),
105    LogsourceValueNotLowercase => (Severity::Warning, SAFE, "A logsource value is not lowercase."),
106    ConditionReferencesUnknown => (Severity::Error, NONE, "The condition references an undefined selection."),
107    DeprecatedAggregationSyntax => (Severity::Warning, NONE, "The condition uses deprecated v1.x aggregation syntax."),
108
109    // ── Correlation rules ────────────────────────────────────────────────
110    MissingCorrelation => (Severity::Error, NONE, "Correlation rule is missing 'correlation'."),
111    MissingCorrelationType => (Severity::Error, NONE, "'correlation' is missing 'type'."),
112    InvalidCorrelationType => (Severity::Error, NONE, "'correlation.type' is not a known correlation type."),
113    MissingCorrelationRules => (Severity::Error, NONE, "'correlation' is missing 'rules'."),
114    EmptyCorrelationRules => (Severity::Warning, NONE, "'correlation.rules' is present but empty."),
115    MissingCorrelationTimespan => (Severity::Error, NONE, "'correlation' is missing 'timespan'."),
116    InvalidTimespanFormat => (Severity::Error, NONE, "'correlation.timespan' is not a valid duration."),
117    InvalidWindowMode => (Severity::Error, NONE, "'correlation.window' is not sliding, tumbling, or session."),
118    MissingSessionGap => (Severity::Error, NONE, "window: session requires a 'gap'."),
119    GapWithoutSession => (Severity::Error, NONE, "'gap' is only valid with window: session."),
120    InvalidGapFormat => (Severity::Error, NONE, "'gap' is not a valid duration."),
121    MissingGroupBy => (Severity::Error, NONE, "This correlation type requires 'group-by'."),
122    MissingCorrelationCondition => (Severity::Error, NONE, "This correlation type requires a 'condition'."),
123    MissingConditionField => (Severity::Error, NONE, "The correlation condition requires 'field'."),
124    InvalidConditionOperator => (Severity::Error, NONE, "The correlation condition uses an invalid operator."),
125    ConditionValueNotNumeric => (Severity::Error, NONE, "The correlation condition value must be numeric."),
126    GenerateNotBoolean => (Severity::Error, NONE, "'generate' must be a boolean."),
127
128    // ── Filter rules ─────────────────────────────────────────────────────
129    MissingFilter => (Severity::Error, NONE, "Filter rule is missing 'filter'."),
130    MissingFilterRules => (Severity::Error, NONE, "'filter.rules' is malformed."),
131    EmptyFilterRules => (Severity::Warning, NONE, "'filter.rules' is present but empty."),
132    MissingFilterSelection => (Severity::Error, NONE, "'filter' is missing 'selection'."),
133    MissingFilterCondition => (Severity::Error, NONE, "'filter' is missing 'condition'."),
134    FilterHasLevel => (Severity::Warning, SAFE, "Filter rules should not have a 'level' field."),
135    FilterHasStatus => (Severity::Warning, SAFE, "Filter rules should not have a 'status' field."),
136    MissingFilterLogsource => (Severity::Error, NONE, "Filter rule is missing 'logsource'."),
137
138    // ── Detection logic (cross-cutting) ──────────────────────────────────
139    NullInValueList => (Severity::Warning, NONE, "A value list contains a null entry."),
140    SingleValueAllModifier => (Severity::Warning, SAFE, "|all is used with a single value."),
141    AllWithRe => (Severity::Warning, SAFE, "|all combined with |re is redundant."),
142    IncompatibleModifiers => (Severity::Warning, NONE, "A field uses an incompatible combination of modifiers."),
143    EmptyValueList => (Severity::Warning, NONE, "A field has an empty value list."),
144    WildcardOnlyValue => (Severity::Warning, SAFE, "A value is a lone wildcard; consider |exists instead."),
145    FlattenedArrayCorrelation => (Severity::Warning, NONE, "A correlation references a flattened array field."),
146    UnsupportedSigmaVersion => (Severity::Error, NONE, "The declared sigma-version is unsupported."),
147    ArrayMatchingWithoutVersion => (Severity::Warning, NONE, "Array matching is used without declaring sigma-version 3."),
148    SigmaVersionMismatch => (Severity::Warning, NONE, "Cross-referenced rules declare different spec majors."),
149    UnknownRuleReference => (Severity::Warning, NONE, "A reference resolves to no known rule."),
150    UnknownKey => (Severity::Info, SAFE, "An unknown top-level key (likely a typo)."),
151
152    // ── ADS detection-strategy metadata ──────────────────────────────────
153    AdsMissingGoal => (Severity::Warning, NONE, "An enforced rule has no ADS goal (description)."),
154    AdsMissingCategorization => (Severity::Warning, NONE, "An enforced rule has no ADS categorization (attack.* tag)."),
155    AdsMissingStrategy => (Severity::Warning, NONE, "An enforced rule has no ADS strategy abstract."),
156    AdsMissingTechnicalContext => (Severity::Warning, NONE, "An enforced rule has no ADS technical context."),
157    AdsMissingBlindSpots => (Severity::Warning, NONE, "An enforced rule states no ADS blind spots or assumptions."),
158    AdsMissingFalsePositives => (Severity::Warning, NONE, "An enforced rule has no ADS false-positive notes (falsepositives)."),
159    AdsMissingValidation => (Severity::Warning, NONE, "An enforced rule has no ADS validation recipe."),
160    AdsMissingPriority => (Severity::Info, NONE, "An enforced rule has no ADS priority rationale."),
161    AdsMissingResponse => (Severity::Warning, NONE, "An enforced rule has no ADS response plan."),
162    AdsEmptySection => (Severity::Info, NONE, "A present rsigma.ads.* section is blank or too short."),
163    AdsUnknownSection => (Severity::Info, SAFE, "An unknown rsigma.ads.* section (likely a typo)."),
164}
165
166/// Return metadata for every [`LintRule`] variant, in declaration order.
167pub fn catalogue() -> Vec<LintRuleInfo> {
168    ALL_LINT_RULES
169        .iter()
170        .map(|&rule| {
171            let (default_severity, fix, description) = describe(rule);
172            LintRuleInfo {
173                name: rule_name(rule),
174                default_severity,
175                fix,
176                description,
177            }
178        })
179        .collect()
180}
181
182/// The stable snake_case id for a rule (its `Display` form).
183fn rule_name(rule: LintRule) -> &'static str {
184    // `LintRule: Copy` and its `Display` returns a fixed `&'static str` per
185    // variant; resolve it through a small lookup so the catalogue can hold a
186    // `&'static str` rather than an owned `String`.
187    LINT_RULE_NAMES
188        .iter()
189        .find(|(r, _)| *r == rule)
190        .map(|(_, name)| *name)
191        .expect("every rule has a name")
192}
193
194/// Static (rule, name) pairs. Built once from `ALL_LINT_RULES` Display strings
195/// is not possible at const time, so the names are listed here and verified
196/// against `Display` by the test below.
197const LINT_RULE_NAMES: &[(LintRule, &str)] = &[
198    (LintRule::YamlParseError, "yaml_parse_error"),
199    (LintRule::NotAMapping, "not_a_mapping"),
200    (LintRule::FileReadError, "file_read_error"),
201    (LintRule::SchemaViolation, "schema_violation"),
202    (LintRule::MissingTitle, "missing_title"),
203    (LintRule::EmptyTitle, "empty_title"),
204    (LintRule::TitleTooLong, "title_too_long"),
205    (LintRule::MissingDescription, "missing_description"),
206    (LintRule::MissingAuthor, "missing_author"),
207    (LintRule::InvalidId, "invalid_id"),
208    (LintRule::InvalidStatus, "invalid_status"),
209    (LintRule::MissingLevel, "missing_level"),
210    (LintRule::InvalidLevel, "invalid_level"),
211    (LintRule::InvalidDate, "invalid_date"),
212    (LintRule::InvalidModified, "invalid_modified"),
213    (LintRule::ModifiedBeforeDate, "modified_before_date"),
214    (LintRule::DescriptionTooLong, "description_too_long"),
215    (LintRule::NameTooLong, "name_too_long"),
216    (LintRule::TaxonomyTooLong, "taxonomy_too_long"),
217    (LintRule::NonLowercaseKey, "non_lowercase_key"),
218    (LintRule::MissingLogsource, "missing_logsource"),
219    (LintRule::MissingDetection, "missing_detection"),
220    (LintRule::MissingCondition, "missing_condition"),
221    (LintRule::EmptyDetection, "empty_detection"),
222    (LintRule::InvalidRelatedType, "invalid_related_type"),
223    (LintRule::InvalidRelatedId, "invalid_related_id"),
224    (LintRule::RelatedMissingRequired, "related_missing_required"),
225    (
226        LintRule::DeprecatedWithoutRelated,
227        "deprecated_without_related",
228    ),
229    (LintRule::InvalidTag, "invalid_tag"),
230    (LintRule::UnknownTagNamespace, "unknown_tag_namespace"),
231    (LintRule::DuplicateTags, "duplicate_tags"),
232    (LintRule::DuplicateReferences, "duplicate_references"),
233    (LintRule::DuplicateFields, "duplicate_fields"),
234    (LintRule::FalsepositiveTooShort, "falsepositive_too_short"),
235    (LintRule::ScopeTooShort, "scope_too_short"),
236    (
237        LintRule::LogsourceValueNotLowercase,
238        "logsource_value_not_lowercase",
239    ),
240    (
241        LintRule::ConditionReferencesUnknown,
242        "condition_references_unknown",
243    ),
244    (
245        LintRule::DeprecatedAggregationSyntax,
246        "deprecated_aggregation_syntax",
247    ),
248    (LintRule::MissingCorrelation, "missing_correlation"),
249    (LintRule::MissingCorrelationType, "missing_correlation_type"),
250    (LintRule::InvalidCorrelationType, "invalid_correlation_type"),
251    (
252        LintRule::MissingCorrelationRules,
253        "missing_correlation_rules",
254    ),
255    (LintRule::EmptyCorrelationRules, "empty_correlation_rules"),
256    (
257        LintRule::MissingCorrelationTimespan,
258        "missing_correlation_timespan",
259    ),
260    (LintRule::InvalidTimespanFormat, "invalid_timespan_format"),
261    (LintRule::InvalidWindowMode, "invalid_window_mode"),
262    (LintRule::MissingSessionGap, "missing_session_gap"),
263    (LintRule::GapWithoutSession, "gap_without_session"),
264    (LintRule::InvalidGapFormat, "invalid_gap_format"),
265    (LintRule::MissingGroupBy, "missing_group_by"),
266    (
267        LintRule::MissingCorrelationCondition,
268        "missing_correlation_condition",
269    ),
270    (LintRule::MissingConditionField, "missing_condition_field"),
271    (
272        LintRule::InvalidConditionOperator,
273        "invalid_condition_operator",
274    ),
275    (
276        LintRule::ConditionValueNotNumeric,
277        "condition_value_not_numeric",
278    ),
279    (LintRule::GenerateNotBoolean, "generate_not_boolean"),
280    (LintRule::MissingFilter, "missing_filter"),
281    (LintRule::MissingFilterRules, "missing_filter_rules"),
282    (LintRule::EmptyFilterRules, "empty_filter_rules"),
283    (LintRule::MissingFilterSelection, "missing_filter_selection"),
284    (LintRule::MissingFilterCondition, "missing_filter_condition"),
285    (LintRule::FilterHasLevel, "filter_has_level"),
286    (LintRule::FilterHasStatus, "filter_has_status"),
287    (LintRule::MissingFilterLogsource, "missing_filter_logsource"),
288    (LintRule::NullInValueList, "null_in_value_list"),
289    (
290        LintRule::SingleValueAllModifier,
291        "single_value_all_modifier",
292    ),
293    (LintRule::AllWithRe, "all_with_re"),
294    (LintRule::IncompatibleModifiers, "incompatible_modifiers"),
295    (LintRule::EmptyValueList, "empty_value_list"),
296    (LintRule::WildcardOnlyValue, "wildcard_only_value"),
297    (
298        LintRule::FlattenedArrayCorrelation,
299        "flattened_array_correlation",
300    ),
301    (
302        LintRule::UnsupportedSigmaVersion,
303        "unsupported_sigma_version",
304    ),
305    (
306        LintRule::ArrayMatchingWithoutVersion,
307        "array_matching_without_version",
308    ),
309    (LintRule::SigmaVersionMismatch, "sigma_version_mismatch"),
310    (LintRule::UnknownRuleReference, "unknown_rule_reference"),
311    (LintRule::UnknownKey, "unknown_key"),
312    (LintRule::AdsMissingGoal, "ads_missing_goal"),
313    (
314        LintRule::AdsMissingCategorization,
315        "ads_missing_categorization",
316    ),
317    (LintRule::AdsMissingStrategy, "ads_missing_strategy"),
318    (
319        LintRule::AdsMissingTechnicalContext,
320        "ads_missing_technical_context",
321    ),
322    (LintRule::AdsMissingBlindSpots, "ads_missing_blind_spots"),
323    (
324        LintRule::AdsMissingFalsePositives,
325        "ads_missing_false_positives",
326    ),
327    (LintRule::AdsMissingValidation, "ads_missing_validation"),
328    (LintRule::AdsMissingPriority, "ads_missing_priority"),
329    (LintRule::AdsMissingResponse, "ads_missing_response"),
330    (LintRule::AdsEmptySection, "ads_empty_section"),
331    (LintRule::AdsUnknownSection, "ads_unknown_section"),
332];
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::collections::HashSet;
338
339    #[test]
340    fn catalogue_covers_every_rule() {
341        // 86 LintRule variants. The exhaustive `describe` match guarantees a
342        // metadata entry per variant at compile time; this asserts the count
343        // and the `ALL_LINT_RULES`/`LINT_RULE_NAMES` lists stay in sync.
344        let entries = catalogue();
345        assert_eq!(entries.len(), 86, "expected 86 catalogue entries");
346        assert_eq!(ALL_LINT_RULES.len(), 86);
347        assert_eq!(LINT_RULE_NAMES.len(), 86);
348    }
349
350    #[test]
351    fn names_match_display_and_are_unique() {
352        let mut seen = HashSet::new();
353        for &rule in ALL_LINT_RULES {
354            let name = rule_name(rule);
355            assert_eq!(
356                name,
357                rule.to_string(),
358                "catalogue name must match LintRule Display"
359            );
360            assert!(seen.insert(name), "duplicate catalogue name: {name}");
361        }
362    }
363
364    #[test]
365    fn fixable_rules_are_safe() {
366        for info in catalogue() {
367            if let Some(disposition) = info.fix {
368                assert_eq!(
369                    disposition,
370                    FixDisposition::Safe,
371                    "{} has a non-safe fix in the catalogue",
372                    info.name
373                );
374            }
375        }
376    }
377}