Skip to main content

tirith_core/
rule_metadata.rs

1use crate::license::Tier;
2use crate::verdict::{Finding, RuleId, Severity};
3
4/// Metadata for time-boxed early access gating (ADR-14).
5///
6/// New detection rules may ship to Pro/Team first, then become universally
7/// free after a defined date. Critical findings always bypass the gate.
8pub struct RuleMeta {
9    pub rule_id: RuleId,
10    /// Minimum tier required during early access window.
11    pub min_tier: Option<Tier>,
12    /// ISO 8601 date (exclusive) — rule becomes free at the start of this date.
13    /// `None` means no early access gate (always free).
14    pub early_access_until: Option<&'static str>,
15}
16
17/// Early access metadata table.
18///
19/// When a rule is in early-access, findings for tiers below `min_tier` are
20/// suppressed — UNLESS the finding severity is Critical (security-critical
21/// detection is always free immediately).
22///
23/// After `early_access_until` passes, the entry is ignored at runtime and
24/// removed in the next release.
25pub const RULE_META: &[RuleMeta] = &[
26    // No rules are currently in early access.
27    // Example entry (commented out):
28    // RuleMeta {
29    //     rule_id: RuleId::ServerCloaking,
30    //     min_tier: Some(Tier::Pro),
31    //     early_access_until: Some("2026-03-15"),
32    // },
33];
34
35/// Check if an early access gate is active for a given rule on a given date.
36///
37/// Returns `true` if the gate is still active (i.e., the rule should be
38/// suppressed for tiers below `min_tier`).
39///
40/// The `early_access_until` date is exclusive — the gate expires at the
41/// start of that date (UTC midnight).
42pub fn is_early_access_active(meta: &RuleMeta, now: chrono::NaiveDate) -> bool {
43    let Some(date_str) = meta.early_access_until else {
44        return false;
45    };
46    match chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
47        Ok(expiry) => now < expiry, // Exclusive: gate expires ON this date
48        Err(_) => {
49            // Malformed date → fail open (never silently gate forever)
50            eprintln!(
51                "tirith: warning: malformed early_access_until date \
52                 for {:?}: {:?}",
53                meta.rule_id, date_str
54            );
55            false
56        }
57    }
58}
59
60/// Filter findings based on early access gates and current tier.
61///
62/// Removes findings for rules that are in an active early-access window
63/// when the user's tier is below the required minimum. Critical findings
64/// always pass through regardless of gating.
65pub fn filter_early_access(findings: &mut Vec<Finding>, tier: Tier) {
66    let today = chrono::Utc::now().date_naive();
67    filter_early_access_at(findings, tier, today);
68}
69
70/// Testable version of `filter_early_access` with explicit date.
71pub fn filter_early_access_at(findings: &mut Vec<Finding>, tier: Tier, now: chrono::NaiveDate) {
72    filter_early_access_with(findings, tier, now, RULE_META);
73}
74
75/// Core filter logic, parameterized over the metadata table for testing.
76pub fn filter_early_access_with(
77    findings: &mut Vec<Finding>,
78    tier: Tier,
79    now: chrono::NaiveDate,
80    rule_meta: &[RuleMeta],
81) {
82    findings.retain(|finding| {
83        let Some(meta) = rule_meta.iter().find(|m| m.rule_id == finding.rule_id) else {
84            return true; // No metadata → always pass through
85        };
86
87        let Some(min_tier) = meta.min_tier else {
88            return true; // No tier gate → pass through
89        };
90
91        // Critical findings always bypass gating
92        if finding.severity == Severity::Critical {
93            return true;
94        }
95
96        // Gate not active → pass through
97        if !is_early_access_active(meta, now) {
98            return true;
99        }
100
101        // Gate active and tier below minimum → suppress
102        tier >= min_tier
103    });
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::verdict::Evidence;
110    use chrono::NaiveDate;
111
112    fn make_finding(rule_id: RuleId, severity: Severity) -> Finding {
113        Finding {
114            rule_id,
115            severity,
116            title: "test".into(),
117            description: "test".into(),
118            evidence: vec![Evidence::Text {
119                detail: "test".into(),
120            }],
121            human_view: None,
122            agent_view: None,
123            mitre_id: None,
124            custom_rule_id: None,
125        }
126    }
127
128    // Use a rule that exists in the enum for testing.
129    // We test the metadata lookup logic; actual RULE_META is empty in prod.
130    const TEST_RULE: RuleId = RuleId::ShortenedUrl;
131
132    fn test_meta(until: Option<&'static str>) -> RuleMeta {
133        RuleMeta {
134            rule_id: TEST_RULE,
135            min_tier: Some(Tier::Pro),
136            early_access_until: until,
137        }
138    }
139
140    #[test]
141    fn test_day_before_expiry_gate_active() {
142        let meta = test_meta(Some("2026-03-15"));
143        let day_before = NaiveDate::from_ymd_opt(2026, 3, 14).unwrap();
144        assert!(is_early_access_active(&meta, day_before));
145    }
146
147    #[test]
148    fn test_day_of_expiry_gate_expired() {
149        // Exclusive boundary: gate expires ON this date
150        let meta = test_meta(Some("2026-03-15"));
151        let day_of = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
152        assert!(!is_early_access_active(&meta, day_of));
153    }
154
155    #[test]
156    fn test_day_after_expiry_gate_expired() {
157        let meta = test_meta(Some("2026-03-15"));
158        let day_after = NaiveDate::from_ymd_opt(2026, 3, 16).unwrap();
159        assert!(!is_early_access_active(&meta, day_after));
160    }
161
162    #[test]
163    fn test_none_date_means_no_gate() {
164        let meta = test_meta(None);
165        let any_date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
166        assert!(!is_early_access_active(&meta, any_date));
167    }
168
169    #[test]
170    fn test_malformed_date_fails_open() {
171        let meta = RuleMeta {
172            rule_id: TEST_RULE,
173            min_tier: Some(Tier::Pro),
174            early_access_until: Some("not-a-date"),
175        };
176        let any_date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
177        // Malformed → fail open (gate not active)
178        assert!(!is_early_access_active(&meta, any_date));
179    }
180
181    #[test]
182    fn test_critical_finding_bypasses_gate() {
183        // Gate is active and tier is below minimum, but finding is Critical
184        // — must pass through the actual filter logic.
185        let custom_meta = &[RuleMeta {
186            rule_id: TEST_RULE,
187            min_tier: Some(Tier::Pro),
188            early_access_until: Some("2099-12-31"),
189        }];
190        let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
191
192        let mut findings = vec![make_finding(TEST_RULE, Severity::Critical)];
193        filter_early_access_with(&mut findings, Tier::Community, now, custom_meta);
194        assert_eq!(
195            findings.len(),
196            1,
197            "Critical finding must bypass active early-access gate"
198        );
199    }
200
201    #[test]
202    fn test_filter_suppresses_medium_for_free_tier() {
203        // Gate is active, tier is Community (below Pro min), finding is Medium
204        // — must be suppressed by the actual filter.
205        let custom_meta = &[RuleMeta {
206            rule_id: TEST_RULE,
207            min_tier: Some(Tier::Pro),
208            early_access_until: Some("2099-12-31"),
209        }];
210        let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
211
212        let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
213        filter_early_access_with(&mut findings, Tier::Community, now, custom_meta);
214        assert_eq!(
215            findings.len(),
216            0,
217            "Medium finding must be suppressed for Community tier"
218        );
219    }
220
221    #[test]
222    fn test_filter_passes_medium_for_pro_tier() {
223        // Gate is active but tier meets minimum — finding must pass through.
224        let custom_meta = &[RuleMeta {
225            rule_id: TEST_RULE,
226            min_tier: Some(Tier::Pro),
227            early_access_until: Some("2099-12-31"),
228        }];
229        let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
230
231        let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
232        filter_early_access_with(&mut findings, Tier::Pro, now, custom_meta);
233        assert_eq!(
234            findings.len(),
235            1,
236            "Medium finding must pass for Pro tier when gate requires Pro"
237        );
238    }
239
240    #[test]
241    fn test_filter_no_metadata_passes_through() {
242        // Finding with no entry in metadata table always passes through.
243        let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
244        let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
245        filter_early_access_with(&mut findings, Tier::Community, now, &[]);
246        assert_eq!(
247            findings.len(),
248            1,
249            "Finding with no metadata must always pass through"
250        );
251    }
252}