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#[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#[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#[derive(Debug, Clone, Deserialize)]
59#[serde(untagged)]
60pub enum MetadataPredicate {
61 Equals(String),
63 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 #[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 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#[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
149impl<'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 #[serde(default)]
200 pub node_type: Option<OneOrMany<NodeKind>>,
201 #[serde(default)]
203 pub trust_zone: Option<OneOrMany<TrustZone>>,
204 #[serde(default)]
205 pub metadata: MetadataMatcher,
206 #[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
254pub 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 #[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
340pub 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 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 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 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 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 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 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 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 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 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 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 assert!(
921 msg.contains("metadata") || msg.contains("variant"),
922 "parse should fail with a meaningful location: {msg}"
923 );
924 }
925}