Skip to main content

taudit_core/
custom_rules.rs

1use crate::finding::{Finding, FindingCategory, Recommendation, Severity};
2use crate::graph::{AuthorityGraph, NodeKind, TrustZone};
3use crate::propagation::PropagationPath;
4use serde::de::{self, MapAccess, Visitor};
5use serde::{Deserialize, Deserializer};
6use std::collections::HashMap;
7use std::fmt;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12/// A user-defined rule loaded from YAML. Fires when source, sink, and path
13/// predicates all match a propagation path produced by the engine.
14#[derive(Debug, Clone, Deserialize)]
15pub struct CustomRule {
16    pub id: String,
17    pub name: String,
18    #[serde(default)]
19    pub description: String,
20    pub severity: Severity,
21    pub category: FindingCategory,
22    #[serde(rename = "match", default)]
23    pub match_spec: MatchSpec,
24}
25
26#[derive(Debug, Clone, Default, Deserialize)]
27pub struct MatchSpec {
28    #[serde(default)]
29    pub source: NodeMatcher,
30    #[serde(default)]
31    pub sink: NodeMatcher,
32    #[serde(default)]
33    pub path: PathMatcher,
34}
35
36/// A scalar-or-list helper. Lets YAML write `node_type: secret` (single value)
37/// or `node_type: [secret, identity]` (any-of). Single form preserved for
38/// backward compatibility with v0.4.x rule files.
39#[derive(Debug, Clone, Deserialize)]
40#[serde(untagged)]
41pub enum OneOrMany<T> {
42    One(T),
43    Many(Vec<T>),
44}
45
46impl<T: PartialEq> OneOrMany<T> {
47    fn contains(&self, value: &T) -> bool {
48        match self {
49            OneOrMany::One(v) => v == value,
50            OneOrMany::Many(vs) => vs.iter().any(|v| v == value),
51        }
52    }
53}
54
55/// Per-field metadata predicate. Bare string is `equals` (back-compat with
56/// v0.4.x). Operator object supports `equals`, `not_equals`, `contains` (substring
57/// match on string values), and `in` (any-of allowed values).
58#[derive(Debug, Clone, Deserialize)]
59#[serde(untagged)]
60pub enum MetadataPredicate {
61    /// `key: "value"` — equality (back-compat).
62    Equals(String),
63    /// `key: { equals/not_equals/contains/in: ... }`
64    Op(MetadataOp),
65}
66
67#[derive(Debug, Clone, Default, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct MetadataOp {
70    #[serde(default)]
71    pub equals: Option<String>,
72    #[serde(default)]
73    pub not_equals: Option<String>,
74    /// Substring match on the string-valued metadata field.
75    #[serde(default)]
76    pub contains: Option<String>,
77    #[serde(default, rename = "in")]
78    pub in_: Option<Vec<String>>,
79}
80
81impl MetadataOp {
82    fn matches(&self, actual: Option<&String>) -> bool {
83        // If the metadata key is absent, only `not_equals` can succeed (against
84        // anything-not-this-value), all positive operators fail.
85        if let Some(want) = &self.equals {
86            if actual.map(|s| s.as_str()) != Some(want.as_str()) {
87                return false;
88            }
89        }
90        if let Some(want) = &self.not_equals {
91            if actual.map(|s| s.as_str()) == Some(want.as_str()) {
92                return false;
93            }
94        }
95        if let Some(needle) = &self.contains {
96            match actual {
97                Some(s) if s.contains(needle.as_str()) => {}
98                _ => return false,
99            }
100        }
101        if let Some(allowed) = &self.in_ {
102            match actual {
103                Some(s) if allowed.iter().any(|a| a == s) => {}
104                _ => return false,
105            }
106        }
107        true
108    }
109}
110
111impl MetadataPredicate {
112    fn matches(&self, actual: Option<&String>) -> bool {
113        match self {
114            MetadataPredicate::Equals(want) => actual.map(|s| s.as_str()) == Some(want.as_str()),
115            MetadataPredicate::Op(op) => op.matches(actual),
116        }
117    }
118}
119
120/// Metadata matcher: map of field -> predicate, with an optional `not`
121/// sub-matcher (negation). The `not:` key is reserved and parsed specially —
122/// it cannot be used as a metadata field name.
123#[derive(Debug, Clone, Default)]
124pub struct MetadataMatcher {
125    pub fields: HashMap<String, MetadataPredicate>,
126    pub not: Option<Box<MetadataMatcher>>,
127}
128
129impl MetadataMatcher {
130    fn matches(&self, metadata: &HashMap<String, String>) -> bool {
131        for (key, pred) in &self.fields {
132            if !pred.matches(metadata.get(key)) {
133                return false;
134            }
135        }
136        if let Some(inner) = &self.not {
137            if inner.matches(metadata) {
138                return false;
139            }
140        }
141        true
142    }
143
144    fn is_empty(&self) -> bool {
145        self.fields.is_empty() && self.not.is_none()
146    }
147}
148
149// Custom Deserialize: pull out reserved `not` key, rest become field predicates.
150impl<'de> Deserialize<'de> for MetadataMatcher {
151    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
152    where
153        D: Deserializer<'de>,
154    {
155        struct MetadataMatcherVisitor;
156
157        impl<'de> Visitor<'de> for MetadataMatcherVisitor {
158            type Value = MetadataMatcher;
159
160            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
161                f.write_str("a metadata predicate map (field -> string|operator) with optional `not:` sub-map")
162            }
163
164            fn visit_map<M>(self, mut map: M) -> Result<MetadataMatcher, M::Error>
165            where
166                M: MapAccess<'de>,
167            {
168                let mut fields: HashMap<String, MetadataPredicate> = HashMap::new();
169                let mut not: Option<Box<MetadataMatcher>> = None;
170
171                while let Some(key) = map.next_key::<String>()? {
172                    if key == "not" {
173                        if not.is_some() {
174                            return Err(de::Error::duplicate_field("not"));
175                        }
176                        let inner: MetadataMatcher = map.next_value()?;
177                        not = Some(Box::new(inner));
178                    } else {
179                        let value: MetadataPredicate = map.next_value()?;
180                        if fields.insert(key.clone(), value).is_some() {
181                            return Err(de::Error::custom(format!(
182                                "duplicate metadata field `{key}`"
183                            )));
184                        }
185                    }
186                }
187
188                Ok(MetadataMatcher { fields, not })
189            }
190        }
191
192        deserializer.deserialize_map(MetadataMatcherVisitor)
193    }
194}
195
196#[derive(Debug, Clone, Default, Deserialize)]
197pub struct NodeMatcher {
198    /// Single value (`node_type: secret`) or any-of list (`[secret, identity]`).
199    #[serde(default)]
200    pub node_type: Option<OneOrMany<NodeKind>>,
201    /// Single value or any-of list.
202    #[serde(default)]
203    pub trust_zone: Option<OneOrMany<TrustZone>>,
204    #[serde(default)]
205    pub metadata: MetadataMatcher,
206    /// Negation: matches when the inner sub-matcher does NOT match.
207    /// Nested `not` is allowed and double-negation collapses naturally.
208    #[serde(default)]
209    pub not: Option<Box<NodeMatcher>>,
210}
211
212#[derive(Debug, Clone, Default, Deserialize)]
213pub struct PathMatcher {
214    #[serde(default)]
215    pub crosses_to: Vec<TrustZone>,
216}
217
218#[derive(Debug)]
219pub enum CustomRuleError {
220    FileRead(PathBuf, io::Error),
221    YamlParse(PathBuf, serde_yaml::Error),
222}
223
224impl fmt::Display for CustomRuleError {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        match self {
227            CustomRuleError::FileRead(path, err) => {
228                write!(
229                    f,
230                    "failed to read custom rule file {}: {err}",
231                    path.display()
232                )
233            }
234            CustomRuleError::YamlParse(path, err) => {
235                write!(
236                    f,
237                    "failed to parse custom rule file {}: {err}",
238                    path.display()
239                )
240            }
241        }
242    }
243}
244
245impl std::error::Error for CustomRuleError {
246    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
247        match self {
248            CustomRuleError::FileRead(_, err) => Some(err),
249            CustomRuleError::YamlParse(_, err) => Some(err),
250        }
251    }
252}
253
254/// Load all `*.yml` and `*.yaml` files from `dir`. Files are read in sorted
255/// order for deterministic output. Returns a list of all errors alongside
256/// successfully parsed rules — callers decide whether to fail fast or continue.
257pub fn load_rules_dir(dir: &Path) -> Result<Vec<CustomRule>, Vec<CustomRuleError>> {
258    let mut entries: Vec<PathBuf> = Vec::new();
259    let read_dir = match fs::read_dir(dir) {
260        Ok(rd) => rd,
261        Err(err) => return Err(vec![CustomRuleError::FileRead(dir.to_path_buf(), err)]),
262    };
263
264    for entry in read_dir.flatten() {
265        let path = entry.path();
266        if !path.is_file() {
267            continue;
268        }
269        match path.extension().and_then(|e| e.to_str()) {
270            Some("yml") | Some("yaml") => entries.push(path),
271            _ => {}
272        }
273    }
274    entries.sort();
275
276    let mut rules = Vec::new();
277    let mut errors = Vec::new();
278    for path in entries {
279        match fs::read_to_string(&path) {
280            Ok(content) => match serde_yaml::from_str::<CustomRule>(&content) {
281                Ok(rule) => rules.push(rule),
282                Err(err) => errors.push(CustomRuleError::YamlParse(path, err)),
283            },
284            Err(err) => errors.push(CustomRuleError::FileRead(path, err)),
285        }
286    }
287
288    if errors.is_empty() {
289        Ok(rules)
290    } else {
291        Err(errors)
292    }
293}
294
295impl NodeMatcher {
296    fn matches(&self, node: &crate::graph::Node) -> bool {
297        if let Some(kinds) = &self.node_type {
298            if !kinds.contains(&node.kind) {
299                return false;
300            }
301        }
302        if let Some(zones) = &self.trust_zone {
303            if !zones.contains(&node.trust_zone) {
304                return false;
305            }
306        }
307        if !self.metadata.matches(&node.metadata) {
308            return false;
309        }
310        if let Some(inner) = &self.not {
311            if inner.matches(node) {
312                return false;
313            }
314        }
315        true
316    }
317
318    /// True when the matcher has no constraints — used by tests/tooling.
319    #[allow(dead_code)]
320    fn is_wildcard(&self) -> bool {
321        self.node_type.is_none()
322            && self.trust_zone.is_none()
323            && self.metadata.is_empty()
324            && self.not.is_none()
325    }
326}
327
328impl PathMatcher {
329    fn matches(&self, path: &PropagationPath) -> bool {
330        if self.crosses_to.is_empty() {
331            return true;
332        }
333        match path.boundary_crossing {
334            Some((_, to_zone)) => self.crosses_to.contains(&to_zone),
335            None => false,
336        }
337    }
338}
339
340/// Evaluate every (rule, path) pair. A finding is produced when the rule's
341/// source, sink, and path predicates all match. Findings carry the rule id in
342/// the message so operators can trace back to the originating YAML.
343pub fn evaluate_custom_rules(
344    graph: &AuthorityGraph,
345    paths: &[PropagationPath],
346    rules: &[CustomRule],
347) -> Vec<Finding> {
348    let mut findings = Vec::new();
349
350    for rule in rules {
351        for path in paths {
352            let source_node = match graph.node(path.source) {
353                Some(n) => n,
354                None => continue,
355            };
356            let sink_node = match graph.node(path.sink) {
357                Some(n) => n,
358                None => continue,
359            };
360
361            if !rule.match_spec.source.matches(source_node) {
362                continue;
363            }
364            if !rule.match_spec.sink.matches(sink_node) {
365                continue;
366            }
367            if !rule.match_spec.path.matches(path) {
368                continue;
369            }
370
371            findings.push(Finding {
372                severity: rule.severity,
373                category: rule.category,
374                nodes_involved: vec![path.source, path.sink],
375                message: format!(
376                    "[{}] {}: {} -> {}",
377                    rule.id, rule.name, source_node.name, sink_node.name
378                ),
379                recommendation: Recommendation::Manual {
380                    action: if rule.description.is_empty() {
381                        format!("Review custom rule '{}'", rule.id)
382                    } else {
383                        rule.description.clone()
384                    },
385                },
386                path: Some(path.clone()),
387            });
388        }
389    }
390
391    findings
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::graph::{AuthorityGraph, EdgeKind, PipelineSource};
398    use crate::propagation::{propagation_analysis, DEFAULT_MAX_HOPS};
399
400    fn source() -> PipelineSource {
401        PipelineSource {
402            file: "test.yml".into(),
403            repo: None,
404            git_ref: None,
405            commit_sha: None,
406        }
407    }
408
409    fn build_graph_with_paths() -> (AuthorityGraph, Vec<PropagationPath>) {
410        let mut g = AuthorityGraph::new(source());
411        let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
412        let trusted = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
413        let untrusted = g.add_node(NodeKind::Step, "third-party", TrustZone::Untrusted);
414
415        g.add_edge(trusted, secret, EdgeKind::HasAccessTo);
416        g.add_edge(trusted, untrusted, EdgeKind::DelegatesTo);
417
418        let paths = propagation_analysis(&g, DEFAULT_MAX_HOPS);
419        (g, paths)
420    }
421
422    fn one<T>(v: T) -> Option<OneOrMany<T>> {
423        Some(OneOrMany::One(v))
424    }
425
426    #[test]
427    fn custom_rule_fires_on_matching_path() {
428        let (graph, paths) = build_graph_with_paths();
429
430        let rule = CustomRule {
431            id: "secret_to_untrusted".into(),
432            name: "Secret reaching untrusted step".into(),
433            description: "Custom policy".into(),
434            severity: Severity::Critical,
435            category: FindingCategory::AuthorityPropagation,
436            match_spec: MatchSpec {
437                source: NodeMatcher {
438                    node_type: None,
439                    trust_zone: one(TrustZone::FirstParty),
440                    metadata: MetadataMatcher::default(),
441                    not: None,
442                },
443                sink: NodeMatcher {
444                    node_type: None,
445                    trust_zone: one(TrustZone::Untrusted),
446                    metadata: MetadataMatcher::default(),
447                    not: None,
448                },
449                path: PathMatcher::default(),
450            },
451        };
452
453        let findings = evaluate_custom_rules(&graph, &paths, &[rule]);
454        assert_eq!(findings.len(), 1);
455        assert_eq!(findings[0].severity, Severity::Critical);
456        assert!(findings[0].message.contains("secret_to_untrusted"));
457    }
458
459    #[test]
460    fn custom_rule_does_not_fire_when_predicates_miss() {
461        let (graph, paths) = build_graph_with_paths();
462
463        let rule = CustomRule {
464            id: "miss".into(),
465            name: "Untrusted source".into(),
466            description: String::new(),
467            severity: Severity::Critical,
468            category: FindingCategory::AuthorityPropagation,
469            match_spec: MatchSpec {
470                source: NodeMatcher {
471                    node_type: None,
472                    trust_zone: one(TrustZone::Untrusted),
473                    metadata: MetadataMatcher::default(),
474                    not: None,
475                },
476                sink: NodeMatcher::default(),
477                path: PathMatcher::default(),
478            },
479        };
480
481        let findings = evaluate_custom_rules(&graph, &paths, &[rule]);
482        assert!(findings.is_empty());
483    }
484
485    #[test]
486    fn yaml_round_trip_loads_full_rule() {
487        let yaml = r#"
488id: my_secret_to_untrusted
489name: Secret reaching untrusted step
490description: "Custom policy: secrets must not reach untrusted steps"
491severity: critical
492category: authority_propagation
493match:
494  source:
495    node_type: secret
496    trust_zone: first_party
497  sink:
498    node_type: step
499    trust_zone: untrusted
500  path:
501    crosses_to: [untrusted]
502"#;
503        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml must parse");
504        assert_eq!(rule.id, "my_secret_to_untrusted");
505        assert_eq!(rule.severity, Severity::Critical);
506        assert!(matches!(
507            rule.match_spec.source.node_type,
508            Some(OneOrMany::One(NodeKind::Secret))
509        ));
510        assert!(matches!(
511            rule.match_spec.sink.trust_zone,
512            Some(OneOrMany::One(TrustZone::Untrusted))
513        ));
514        assert_eq!(rule.match_spec.path.crosses_to, vec![TrustZone::Untrusted]);
515    }
516
517    #[test]
518    fn metadata_predicate_must_match_all_keys() {
519        let mut g = AuthorityGraph::new(source());
520        let mut meta = HashMap::new();
521        meta.insert("kind".to_string(), "deploy".to_string());
522        let secret =
523            g.add_node_with_metadata(NodeKind::Secret, "TOKEN", TrustZone::FirstParty, meta);
524        let sink = g.add_node(NodeKind::Step, "remote", TrustZone::Untrusted);
525        let step = g.add_node(NodeKind::Step, "use", TrustZone::FirstParty);
526        g.add_edge(step, secret, EdgeKind::HasAccessTo);
527        g.add_edge(step, sink, EdgeKind::DelegatesTo);
528
529        let paths = propagation_analysis(&g, DEFAULT_MAX_HOPS);
530
531        let mut want_fields = HashMap::new();
532        want_fields.insert(
533            "kind".to_string(),
534            MetadataPredicate::Equals("deploy".to_string()),
535        );
536        let hit = CustomRule {
537            id: "hit".into(),
538            name: "n".into(),
539            description: String::new(),
540            severity: Severity::High,
541            category: FindingCategory::AuthorityPropagation,
542            match_spec: MatchSpec {
543                source: NodeMatcher {
544                    node_type: one(NodeKind::Secret),
545                    trust_zone: None,
546                    metadata: MetadataMatcher {
547                        fields: want_fields,
548                        not: None,
549                    },
550                    not: None,
551                },
552                sink: NodeMatcher::default(),
553                path: PathMatcher::default(),
554            },
555        };
556        assert_eq!(evaluate_custom_rules(&g, &paths, &[hit]).len(), 1);
557
558        let mut wrong_fields = HashMap::new();
559        wrong_fields.insert(
560            "kind".to_string(),
561            MetadataPredicate::Equals("build".to_string()),
562        );
563        let miss = CustomRule {
564            id: "miss".into(),
565            name: "n".into(),
566            description: String::new(),
567            severity: Severity::High,
568            category: FindingCategory::AuthorityPropagation,
569            match_spec: MatchSpec {
570                source: NodeMatcher {
571                    node_type: one(NodeKind::Secret),
572                    trust_zone: None,
573                    metadata: MetadataMatcher {
574                        fields: wrong_fields,
575                        not: None,
576                    },
577                    not: None,
578                },
579                sink: NodeMatcher::default(),
580                path: PathMatcher::default(),
581            },
582        };
583        assert!(evaluate_custom_rules(&g, &paths, &[miss]).is_empty());
584    }
585
586    #[test]
587    fn load_rules_dir_reads_yml_and_yaml() {
588        let tmp = std::env::temp_dir().join(format!("taudit-custom-rules-{}", std::process::id()));
589        fs::create_dir_all(&tmp).unwrap();
590        let yml_path = tmp.join("a.yml");
591        let yaml_path = tmp.join("b.yaml");
592        let other_path = tmp.join("c.txt");
593
594        fs::write(
595            &yml_path,
596            "id: a\nname: a\nseverity: high\ncategory: authority_propagation\n",
597        )
598        .unwrap();
599        fs::write(
600            &yaml_path,
601            "id: b\nname: b\nseverity: medium\ncategory: unpinned_action\n",
602        )
603        .unwrap();
604        fs::write(&other_path, "ignored").unwrap();
605
606        let rules = load_rules_dir(&tmp).expect("load must succeed");
607        assert_eq!(rules.len(), 2);
608        assert_eq!(rules[0].id, "a");
609        assert_eq!(rules[1].id, "b");
610
611        // cleanup
612        let _ = fs::remove_dir_all(&tmp);
613    }
614
615    #[test]
616    fn load_rules_dir_reports_yaml_errors_with_path() {
617        let tmp =
618            std::env::temp_dir().join(format!("taudit-custom-rules-bad-{}", std::process::id()));
619        fs::create_dir_all(&tmp).unwrap();
620        let bad = tmp.join("bad.yml");
621        fs::write(&bad, "id: x\nseverity: not-a-real-severity\n").unwrap();
622
623        let errs = load_rules_dir(&tmp).expect_err("should fail");
624        assert_eq!(errs.len(), 1);
625        let msg = errs[0].to_string();
626        assert!(msg.contains("bad.yml"), "error must mention path: {msg}");
627
628        let _ = fs::remove_dir_all(&tmp);
629    }
630
631    // ── v0.6 grammar additions: negation + typed metadata predicates ─────
632
633    /// Build a graph with one secret in first_party reaching one untrusted
634    /// step. Used by the new grammar tests.
635    fn simple_first_to_untrusted_graph() -> (AuthorityGraph, Vec<PropagationPath>) {
636        let mut g = AuthorityGraph::new(source());
637        let mut meta = HashMap::new();
638        meta.insert("oidc".to_string(), "true".to_string());
639        meta.insert("permissions".to_string(), "contents: write".to_string());
640        meta.insert("role".to_string(), "admin".to_string());
641        let secret =
642            g.add_node_with_metadata(NodeKind::Identity, "GH_TOKEN", TrustZone::FirstParty, meta);
643        let step = g.add_node(NodeKind::Step, "use-it", TrustZone::FirstParty);
644        let untrusted = g.add_node(NodeKind::Step, "third-party", TrustZone::Untrusted);
645        g.add_edge(step, secret, EdgeKind::HasAccessTo);
646        g.add_edge(step, untrusted, EdgeKind::DelegatesTo);
647        let paths = propagation_analysis(&g, DEFAULT_MAX_HOPS);
648        (g, paths)
649    }
650
651    #[test]
652    fn negation_on_trust_zone_inverts_match() {
653        let (graph, paths) = simple_first_to_untrusted_graph();
654        // sink is untrusted; "not untrusted" must NOT match the sink → no findings
655        let yaml = r#"
656id: r
657name: r
658severity: high
659category: authority_propagation
660match:
661  sink:
662    not:
663      trust_zone: untrusted
664"#;
665        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml parses");
666        assert!(evaluate_custom_rules(&graph, &paths, &[rule]).is_empty());
667    }
668
669    #[test]
670    fn negation_on_node_type_list_matches_other_kinds() {
671        let (graph, paths) = simple_first_to_untrusted_graph();
672        // source kinds in fixtures: identity. "not [secret, identity]" excludes it
673        // → source predicate fails → no findings.
674        let yaml = r#"
675id: r
676name: r
677severity: high
678category: authority_propagation
679match:
680  source:
681    not:
682      node_type: [secret, identity]
683"#;
684        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml parses");
685        assert!(evaluate_custom_rules(&graph, &paths, &[rule]).is_empty());
686
687        // Inverse: "not [step]" — source is identity, so the inner does NOT match,
688        // therefore the not-wrapper matches → at least one finding fires.
689        let yaml2 = r#"
690id: r2
691name: r2
692severity: high
693category: authority_propagation
694match:
695  source:
696    not:
697      node_type: [step]
698"#;
699        let rule2: CustomRule = serde_yaml::from_str(yaml2).expect("yaml parses");
700        assert!(!evaluate_custom_rules(&graph, &paths, &[rule2]).is_empty());
701    }
702
703    #[test]
704    fn metadata_negation_matches_absent_or_other_value() {
705        let (graph, paths) = simple_first_to_untrusted_graph();
706        // The identity has oidc=true. `not: { oidc: "true" }` excludes it →
707        // no finding when applied to the source.
708        let yaml = r#"
709id: r
710name: r
711severity: high
712category: authority_propagation
713match:
714  source:
715    metadata:
716      not:
717        oidc: "true"
718"#;
719        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml parses");
720        assert!(evaluate_custom_rules(&graph, &paths, &[rule]).is_empty());
721    }
722
723    #[test]
724    fn metadata_contains_does_substring_match() {
725        let (graph, paths) = simple_first_to_untrusted_graph();
726        let yaml = r#"
727id: r
728name: r
729severity: high
730category: authority_propagation
731match:
732  source:
733    metadata:
734      permissions:
735        contains: "contents: write"
736"#;
737        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml parses");
738        assert_eq!(evaluate_custom_rules(&graph, &paths, &[rule]).len(), 1);
739
740        // negative case: substring not present
741        let yaml_miss = r#"
742id: r
743name: r
744severity: high
745category: authority_propagation
746match:
747  source:
748    metadata:
749      permissions:
750        contains: "actions: write"
751"#;
752        let rule_miss: CustomRule = serde_yaml::from_str(yaml_miss).expect("yaml parses");
753        assert!(evaluate_custom_rules(&graph, &paths, &[rule_miss]).is_empty());
754    }
755
756    #[test]
757    fn metadata_in_matches_any_of_allowed_values() {
758        let (graph, paths) = simple_first_to_untrusted_graph();
759        let yaml = r#"
760id: r
761name: r
762severity: high
763category: authority_propagation
764match:
765  source:
766    metadata:
767      role:
768        in: [admin, owner, write]
769"#;
770        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml parses");
771        assert_eq!(evaluate_custom_rules(&graph, &paths, &[rule]).len(), 1);
772
773        let yaml_miss = r#"
774id: r
775name: r
776severity: high
777category: authority_propagation
778match:
779  source:
780    metadata:
781      role:
782        in: [reader, none]
783"#;
784        let rule_miss: CustomRule = serde_yaml::from_str(yaml_miss).expect("yaml parses");
785        assert!(evaluate_custom_rules(&graph, &paths, &[rule_miss]).is_empty());
786    }
787
788    #[test]
789    fn metadata_not_equals_excludes_specific_value() {
790        let (graph, paths) = simple_first_to_untrusted_graph();
791        let yaml = r#"
792id: r
793name: r
794severity: high
795category: authority_propagation
796match:
797  source:
798    metadata:
799      role:
800        not_equals: admin
801"#;
802        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml parses");
803        // role=admin → not_equals fails → no findings
804        assert!(evaluate_custom_rules(&graph, &paths, &[rule]).is_empty());
805
806        let yaml_hit = r#"
807id: r
808name: r
809severity: high
810category: authority_propagation
811match:
812  source:
813    metadata:
814      role:
815        not_equals: reader
816"#;
817        let rule_hit: CustomRule = serde_yaml::from_str(yaml_hit).expect("yaml parses");
818        assert_eq!(evaluate_custom_rules(&graph, &paths, &[rule_hit]).len(), 1);
819    }
820
821    #[test]
822    fn nested_not_collapses_to_inner_condition() {
823        let (graph, paths) = simple_first_to_untrusted_graph();
824        // not(not(trust_zone=first_party)) ≡ trust_zone=first_party.
825        // The source is first_party so this should fire.
826        let yaml = r#"
827id: r
828name: r
829severity: high
830category: authority_propagation
831match:
832  source:
833    not:
834      not:
835        trust_zone: first_party
836"#;
837        let rule: CustomRule = serde_yaml::from_str(yaml).expect("yaml parses");
838        assert!(!evaluate_custom_rules(&graph, &paths, &[rule]).is_empty());
839    }
840
841    #[test]
842    fn node_type_accepts_single_value_back_compat() {
843        // The original v0.4 simple form must still parse and behave identically.
844        let yaml = r#"
845id: r
846name: r
847severity: high
848category: authority_propagation
849match:
850  source:
851    node_type: identity
852    trust_zone: first_party
853    metadata:
854      oidc: "true"
855"#;
856        let rule: CustomRule = serde_yaml::from_str(yaml).expect("v0.4 form must still parse");
857        assert!(matches!(
858            rule.match_spec.source.node_type,
859            Some(OneOrMany::One(NodeKind::Identity))
860        ));
861        assert!(matches!(
862            rule.match_spec.source.trust_zone,
863            Some(OneOrMany::One(TrustZone::FirstParty))
864        ));
865        let pred = rule
866            .match_spec
867            .source
868            .metadata
869            .fields
870            .get("oidc")
871            .expect("oidc predicate");
872        assert!(matches!(pred, MetadataPredicate::Equals(v) if v == "true"));
873
874        let (graph, paths) = simple_first_to_untrusted_graph();
875        assert_eq!(evaluate_custom_rules(&graph, &paths, &[rule]).len(), 1);
876    }
877
878    #[test]
879    fn node_type_accepts_list_form() {
880        let yaml = r#"
881id: r
882name: r
883severity: high
884category: authority_propagation
885match:
886  source:
887    node_type: [secret, identity]
888    trust_zone: [first_party, third_party]
889"#;
890        let rule: CustomRule = serde_yaml::from_str(yaml).expect("list form must parse");
891        match &rule.match_spec.source.node_type {
892            Some(OneOrMany::Many(v)) => {
893                assert_eq!(v, &vec![NodeKind::Secret, NodeKind::Identity]);
894            }
895            other => panic!("expected list form, got {other:?}"),
896        }
897        let (graph, paths) = simple_first_to_untrusted_graph();
898        assert_eq!(evaluate_custom_rules(&graph, &paths, &[rule]).len(), 1);
899    }
900
901    #[test]
902    fn unknown_metadata_operator_is_rejected() {
903        let yaml = r#"
904id: r
905name: r
906severity: high
907category: authority_propagation
908match:
909  source:
910    metadata:
911      role:
912        starts_with: adm
913"#;
914        let err = serde_yaml::from_str::<CustomRule>(yaml)
915            .expect_err("unknown operator must be rejected");
916        let msg = err.to_string();
917        // serde_yaml's untagged-enum error doesn't always echo the unknown
918        // field name; the important guarantee is that the parse fails (so
919        // typos in operator names don't silently match nothing).
920        assert!(
921            msg.contains("metadata") || msg.contains("variant"),
922            "parse should fail with a meaningful location: {msg}"
923        );
924    }
925}