Skip to main content

taudit_core/
rules.rs

1use crate::finding::{Finding, FindingCategory, Recommendation, Severity};
2use crate::graph::{
3    is_docker_digest_pinned, is_sha_pinned, AuthorityCompleteness, AuthorityGraph, EdgeKind,
4    IdentityScope, NodeKind, TrustZone, META_CLI_FLAG_EXPOSED, META_CONTAINER, META_DIGEST,
5    META_IDENTITY_SCOPE, META_PERMISSIONS,
6};
7use crate::propagation;
8
9fn cap_severity(severity: Severity, max_severity: Severity) -> Severity {
10    if severity < max_severity {
11        max_severity
12    } else {
13        severity
14    }
15}
16
17fn apply_confidence_cap(graph: &AuthorityGraph, findings: &mut [Finding]) {
18    if graph.completeness != AuthorityCompleteness::Partial {
19        return;
20    }
21
22    for finding in findings {
23        finding.severity = cap_severity(finding.severity, Severity::High);
24    }
25}
26
27/// MVP Rule 1: Authority (secret/identity) propagated across a trust boundary.
28///
29/// Severity graduation (tuned from real-world signal on 10 production workflows):
30/// - Untrusted sink: Critical (real risk — unpinned code with authority)
31/// - SHA-pinned ThirdParty sink: High (immutable code, but still cross-boundary)
32/// - SHA-pinned sink + constrained identity: Medium (lowest-risk form — read-only
33///   token to immutable third-party code, e.g. `contents:read` → `actions/checkout@sha`)
34pub fn authority_propagation(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
35    let paths = propagation::propagation_analysis(graph, max_hops);
36
37    paths
38        .into_iter()
39        .filter(|p| p.crossed_boundary)
40        .map(|path| {
41            let source_name = graph
42                .node(path.source)
43                .map(|n| n.name.as_str())
44                .unwrap_or("?");
45            let sink_name = graph
46                .node(path.sink)
47                .map(|n| n.name.as_str())
48                .unwrap_or("?");
49
50            // Graduate severity based on sink trust + source scope
51            let sink_is_pinned = graph
52                .node(path.sink)
53                .map(|n| {
54                    n.trust_zone == TrustZone::ThirdParty && n.metadata.contains_key(META_DIGEST)
55                })
56                .unwrap_or(false);
57
58            let source_is_constrained = graph
59                .node(path.source)
60                .and_then(|n| n.metadata.get(META_IDENTITY_SCOPE))
61                .map(|s| s == "constrained")
62                .unwrap_or(false);
63
64            let severity = if sink_is_pinned && source_is_constrained {
65                Severity::Medium
66            } else if sink_is_pinned {
67                Severity::High
68            } else {
69                Severity::Critical
70            };
71
72            Finding {
73                severity,
74                category: FindingCategory::AuthorityPropagation,
75                nodes_involved: vec![path.source, path.sink],
76                message: format!("{source_name} propagated to {sink_name} across trust boundary"),
77                recommendation: Recommendation::TsafeRemediation {
78                    command: "tsafe exec --ns <scoped-namespace> -- <command>".to_string(),
79                    explanation: format!("Scope {source_name} to only the steps that need it"),
80                },
81                path: Some(path),
82            }
83        })
84        .collect()
85}
86
87/// MVP Rule 2: Identity scope broader than actual usage.
88///
89/// Uses `IdentityScope` classification from the precision layer. Broad and
90/// Unknown scopes are flagged — Unknown is treated as risky because if we
91/// can't determine the scope, we shouldn't assume it's safe.
92pub fn over_privileged_identity(graph: &AuthorityGraph) -> Vec<Finding> {
93    let mut findings = Vec::new();
94
95    for identity in graph.nodes_of_kind(NodeKind::Identity) {
96        let granted_scope = identity
97            .metadata
98            .get(META_PERMISSIONS)
99            .cloned()
100            .unwrap_or_default();
101
102        // Use IdentityScope from metadata if set by parser, otherwise classify from permissions
103        let scope = identity
104            .metadata
105            .get(META_IDENTITY_SCOPE)
106            .and_then(|s| match s.as_str() {
107                "broad" => Some(IdentityScope::Broad),
108                "constrained" => Some(IdentityScope::Constrained),
109                "unknown" => Some(IdentityScope::Unknown),
110                _ => None,
111            })
112            .unwrap_or_else(|| IdentityScope::from_permissions(&granted_scope));
113
114        // Broad or Unknown scope — flag it. Unknown is treated as risky.
115        let (should_flag, severity) = match scope {
116            IdentityScope::Broad => (true, Severity::High),
117            IdentityScope::Unknown => (true, Severity::Medium),
118            IdentityScope::Constrained => (false, Severity::Info),
119        };
120
121        if !should_flag {
122            continue;
123        }
124
125        let accessor_steps: Vec<_> = graph
126            .edges_to(identity.id)
127            .filter(|e| e.kind == EdgeKind::HasAccessTo)
128            .filter_map(|e| graph.node(e.from))
129            .collect();
130
131        if !accessor_steps.is_empty() {
132            let scope_label = match scope {
133                IdentityScope::Broad => "broad",
134                IdentityScope::Unknown => "unknown (treat as risky)",
135                IdentityScope::Constrained => "constrained",
136            };
137
138            findings.push(Finding {
139                severity,
140                category: FindingCategory::OverPrivilegedIdentity,
141                path: None,
142                nodes_involved: std::iter::once(identity.id)
143                    .chain(accessor_steps.iter().map(|n| n.id))
144                    .collect(),
145                message: format!(
146                    "{} has {} scope (permissions: '{}') — likely broader than needed",
147                    identity.name, scope_label, granted_scope
148                ),
149                recommendation: Recommendation::ReducePermissions {
150                    current: granted_scope.clone(),
151                    minimum: "{ contents: read }".into(),
152                },
153            });
154        }
155    }
156
157    findings
158}
159
160/// MVP Rule 3: Third-party action/image without SHA pin.
161///
162/// Deduplicates by action reference — the same action used in multiple jobs
163/// produces multiple Image nodes but should only be flagged once.
164pub fn unpinned_action(graph: &AuthorityGraph) -> Vec<Finding> {
165    let mut findings = Vec::new();
166    let mut seen = std::collections::HashSet::new();
167
168    for image in graph.nodes_of_kind(NodeKind::Image) {
169        if image.trust_zone == TrustZone::FirstParty {
170            continue;
171        }
172
173        // Container images are handled by floating_image — skip here to avoid
174        // double-flagging the same node as both UnpinnedAction and FloatingImage.
175        if image
176            .metadata
177            .get(META_CONTAINER)
178            .map(|v| v == "true")
179            .unwrap_or(false)
180        {
181            continue;
182        }
183
184        // Deduplicate: same action reference flagged once
185        if !seen.insert(&image.name) {
186            continue;
187        }
188
189        let has_digest = image.metadata.contains_key(META_DIGEST);
190
191        if !has_digest && !is_sha_pinned(&image.name) {
192            findings.push(Finding {
193                severity: Severity::Medium,
194                category: FindingCategory::UnpinnedAction,
195                path: None,
196                nodes_involved: vec![image.id],
197                message: format!("{} is not pinned to a SHA digest", image.name),
198                recommendation: Recommendation::PinAction {
199                    current: image.name.clone(),
200                    pinned: format!(
201                        "{}@<sha256-digest>",
202                        image.name.split('@').next().unwrap_or(&image.name)
203                    ),
204                },
205            });
206        }
207    }
208
209    findings
210}
211
212/// MVP Rule 4: Untrusted step has direct access to secret/identity.
213pub fn untrusted_with_authority(graph: &AuthorityGraph) -> Vec<Finding> {
214    let mut findings = Vec::new();
215
216    for step in graph.nodes_in_zone(TrustZone::Untrusted) {
217        if step.kind != NodeKind::Step {
218            continue;
219        }
220
221        // Check if this untrusted step directly accesses any authority source
222        for edge in graph.edges_from(step.id) {
223            if edge.kind != EdgeKind::HasAccessTo {
224                continue;
225            }
226
227            if let Some(target) = graph.node(edge.to) {
228                if matches!(target.kind, NodeKind::Secret | NodeKind::Identity) {
229                    let cli_flag_exposed = target
230                        .metadata
231                        .get(META_CLI_FLAG_EXPOSED)
232                        .map(|v| v == "true")
233                        .unwrap_or(false);
234
235                    let recommendation = if target.kind == NodeKind::Secret {
236                        if cli_flag_exposed {
237                            Recommendation::Manual {
238                                action: format!(
239                                    "Move '{}' from -var flag to TF_VAR_{} env var — \
240                                     -var values appear in pipeline logs and Terraform plan output",
241                                    target.name, target.name
242                                ),
243                            }
244                        } else {
245                            Recommendation::CellosRemediation {
246                                reason: format!(
247                                    "Untrusted step '{}' has direct access to secret '{}'",
248                                    step.name, target.name
249                                ),
250                                spec_hint: format!(
251                                    "cellos run --network deny-all --broker env:{}",
252                                    target.name
253                                ),
254                            }
255                        }
256                    } else {
257                        Recommendation::ReducePermissions {
258                            current: target
259                                .metadata
260                                .get(META_PERMISSIONS)
261                                .cloned()
262                                .unwrap_or_else(|| "unknown".into()),
263                            minimum: "minimal required scope".into(),
264                        }
265                    };
266
267                    let log_exposure_note = if cli_flag_exposed {
268                        " (passed as -var flag — value visible in pipeline logs)"
269                    } else {
270                        ""
271                    };
272
273                    findings.push(Finding {
274                        severity: Severity::Critical,
275                        category: FindingCategory::UntrustedWithAuthority,
276                        path: None,
277                        nodes_involved: vec![step.id, target.id],
278                        message: format!(
279                            "Untrusted step '{}' has direct access to {} '{}'{}",
280                            step.name,
281                            if target.kind == NodeKind::Secret {
282                                "secret"
283                            } else {
284                                "identity"
285                            },
286                            target.name,
287                            log_exposure_note,
288                        ),
289                        recommendation,
290                    });
291                }
292            }
293        }
294    }
295
296    findings
297}
298
299/// MVP Rule 5: Artifact produced by privileged step consumed across trust boundary.
300pub fn artifact_boundary_crossing(graph: &AuthorityGraph) -> Vec<Finding> {
301    let mut findings = Vec::new();
302
303    for artifact in graph.nodes_of_kind(NodeKind::Artifact) {
304        // Find producer(s)
305        let producers: Vec<_> = graph
306            .edges_to(artifact.id)
307            .filter(|e| e.kind == EdgeKind::Produces)
308            .filter_map(|e| graph.node(e.from))
309            .collect();
310
311        // Find consumer(s) — Consumes edges go artifact -> step
312        let consumers: Vec<_> = graph
313            .edges_from(artifact.id)
314            .filter(|e| e.kind == EdgeKind::Consumes)
315            .filter_map(|e| graph.node(e.to))
316            .collect();
317
318        for producer in &producers {
319            // Only care if the producer is privileged (has access to secrets/identities)
320            let producer_has_authority = graph.edges_from(producer.id).any(|e| {
321                e.kind == EdgeKind::HasAccessTo
322                    && graph
323                        .node(e.to)
324                        .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
325                        .unwrap_or(false)
326            });
327
328            if !producer_has_authority {
329                continue;
330            }
331
332            for consumer in &consumers {
333                if consumer.trust_zone.is_lower_than(&producer.trust_zone) {
334                    findings.push(Finding {
335                        severity: Severity::High,
336                        category: FindingCategory::ArtifactBoundaryCrossing,
337                        path: None,
338                        nodes_involved: vec![producer.id, artifact.id, consumer.id],
339                        message: format!(
340                            "Artifact '{}' produced by privileged step '{}' consumed by '{}' ({:?} -> {:?})",
341                            artifact.name,
342                            producer.name,
343                            consumer.name,
344                            producer.trust_zone,
345                            consumer.trust_zone
346                        ),
347                        recommendation: Recommendation::TsafeRemediation {
348                            command: format!(
349                                "tsafe exec --ns {} -- <build-command>",
350                                producer.name
351                            ),
352                            explanation: format!(
353                                "Scope secrets to '{}' only; artifact '{}' should not carry authority",
354                                producer.name, artifact.name
355                            ),
356                        },
357                    });
358                }
359            }
360        }
361    }
362
363    findings
364}
365
366/// Stretch Rule 9: Secret name matches known long-lived/static credential pattern.
367///
368/// Heuristic: secrets named like AWS keys, API keys, passwords, or private keys
369/// are likely static credentials that should be replaced with OIDC federation.
370pub fn long_lived_credential(graph: &AuthorityGraph) -> Vec<Finding> {
371    const STATIC_PATTERNS: &[&str] = &[
372        "AWS_ACCESS_KEY",
373        "AWS_SECRET_ACCESS_KEY",
374        "_API_KEY",
375        "_APIKEY",
376        "_PASSWORD",
377        "_PASSWD",
378        "_PRIVATE_KEY",
379        "_SECRET_KEY",
380        "_SERVICE_ACCOUNT",
381        "_SIGNING_KEY",
382    ];
383
384    let mut findings = Vec::new();
385
386    for secret in graph.nodes_of_kind(NodeKind::Secret) {
387        let upper = secret.name.to_uppercase();
388        let is_static = STATIC_PATTERNS.iter().any(|p| upper.contains(p));
389
390        if is_static {
391            findings.push(Finding {
392                severity: Severity::Low,
393                category: FindingCategory::LongLivedCredential,
394                path: None,
395                nodes_involved: vec![secret.id],
396                message: format!(
397                    "'{}' looks like a long-lived static credential",
398                    secret.name
399                ),
400                recommendation: Recommendation::FederateIdentity {
401                    static_secret: secret.name.clone(),
402                    oidc_provider: "GitHub Actions OIDC (id-token: write)".into(),
403                },
404            });
405        }
406    }
407
408    findings
409}
410
411/// Tier 6 Rule: Container image without Docker digest pinning.
412///
413/// Job-level containers marked with `META_CONTAINER` that aren't pinned to
414/// `image@sha256:<64hex>` can be silently mutated between runs. Deduplicates
415/// by image name (same image in multiple jobs flags once).
416pub fn floating_image(graph: &AuthorityGraph) -> Vec<Finding> {
417    let mut findings = Vec::new();
418    let mut seen = std::collections::HashSet::new();
419
420    for image in graph.nodes_of_kind(NodeKind::Image) {
421        let is_container = image
422            .metadata
423            .get(META_CONTAINER)
424            .map(|v| v == "true")
425            .unwrap_or(false);
426
427        if !is_container {
428            continue;
429        }
430
431        if !seen.insert(image.name.as_str()) {
432            continue;
433        }
434
435        if !is_docker_digest_pinned(&image.name) {
436            findings.push(Finding {
437                severity: Severity::Medium,
438                category: FindingCategory::FloatingImage,
439                path: None,
440                nodes_involved: vec![image.id],
441                message: format!("Container image '{}' is not pinned to a digest", image.name),
442                recommendation: Recommendation::PinAction {
443                    current: image.name.clone(),
444                    pinned: format!(
445                        "{}@sha256:<digest>",
446                        image.name.split(':').next().unwrap_or(&image.name)
447                    ),
448                },
449            });
450        }
451    }
452
453    findings
454}
455
456/// Stretch Rule: checkout step with `persistCredentials: true` writes credentials to disk.
457///
458/// The PersistsTo edge connects a checkout step to the token it persists. Disk-resident
459/// credentials are accessible to all subsequent steps (and to any process with filesystem
460/// access), unlike runtime-only HasAccessTo authority which expires when the step exits.
461pub fn persisted_credential(graph: &AuthorityGraph) -> Vec<Finding> {
462    let mut findings = Vec::new();
463
464    for edge in &graph.edges {
465        if edge.kind != EdgeKind::PersistsTo {
466            continue;
467        }
468
469        let Some(step) = graph.node(edge.from) else {
470            continue;
471        };
472        let Some(target) = graph.node(edge.to) else {
473            continue;
474        };
475
476        findings.push(Finding {
477            severity: Severity::High,
478            category: FindingCategory::PersistedCredential,
479            path: None,
480            nodes_involved: vec![step.id, target.id],
481            message: format!(
482                "'{}' persists '{}' to disk via persistCredentials: true — \
483                 credential remains in .git/config and is accessible to all subsequent steps",
484                step.name, target.name
485            ),
486            recommendation: Recommendation::Manual {
487                action: "Remove persistCredentials: true from the checkout step. \
488                         Pass credentials explicitly only to steps that need them."
489                    .into(),
490            },
491        });
492    }
493
494    findings
495}
496
497/// Run all rules against a graph.
498pub fn run_all_rules(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
499    let mut findings = Vec::new();
500    // MVP rules
501    findings.extend(authority_propagation(graph, max_hops));
502    findings.extend(over_privileged_identity(graph));
503    findings.extend(unpinned_action(graph));
504    findings.extend(untrusted_with_authority(graph));
505    findings.extend(artifact_boundary_crossing(graph));
506    // Stretch rules
507    findings.extend(long_lived_credential(graph));
508    findings.extend(floating_image(graph));
509    findings.extend(persisted_credential(graph));
510
511    apply_confidence_cap(graph, &mut findings);
512
513    findings.sort_by_key(|f| f.severity);
514
515    findings
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::graph::*;
522
523    fn source(file: &str) -> PipelineSource {
524        PipelineSource {
525            file: file.into(),
526            repo: None,
527            git_ref: None,
528        }
529    }
530
531    #[test]
532    fn unpinned_third_party_action_flagged() {
533        let mut g = AuthorityGraph::new(source("ci.yml"));
534        g.add_node(
535            NodeKind::Image,
536            "actions/checkout@v4",
537            TrustZone::ThirdParty,
538        );
539
540        let findings = unpinned_action(&g);
541        assert_eq!(findings.len(), 1);
542        assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
543    }
544
545    #[test]
546    fn pinned_action_not_flagged() {
547        let mut g = AuthorityGraph::new(source("ci.yml"));
548        g.add_node(
549            NodeKind::Image,
550            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
551            TrustZone::ThirdParty,
552        );
553
554        let findings = unpinned_action(&g);
555        assert!(findings.is_empty());
556    }
557
558    #[test]
559    fn untrusted_step_with_secret_is_critical() {
560        let mut g = AuthorityGraph::new(source("ci.yml"));
561        let step = g.add_node(NodeKind::Step, "evil-action", TrustZone::Untrusted);
562        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
563        g.add_edge(step, secret, EdgeKind::HasAccessTo);
564
565        let findings = untrusted_with_authority(&g);
566        assert_eq!(findings.len(), 1);
567        assert_eq!(findings[0].severity, Severity::Critical);
568    }
569
570    #[test]
571    fn artifact_crossing_detected() {
572        let mut g = AuthorityGraph::new(source("ci.yml"));
573        let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
574        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
575        let artifact = g.add_node(NodeKind::Artifact, "dist.zip", TrustZone::FirstParty);
576        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
577
578        g.add_edge(build, secret, EdgeKind::HasAccessTo);
579        g.add_edge(build, artifact, EdgeKind::Produces);
580        g.add_edge(artifact, deploy, EdgeKind::Consumes);
581
582        let findings = artifact_boundary_crossing(&g);
583        assert_eq!(findings.len(), 1);
584        assert_eq!(
585            findings[0].category,
586            FindingCategory::ArtifactBoundaryCrossing
587        );
588    }
589
590    #[test]
591    fn propagation_to_sha_pinned_is_high_not_critical() {
592        let mut g = AuthorityGraph::new(source("ci.yml"));
593        let mut meta = std::collections::HashMap::new();
594        meta.insert(
595            "digest".into(),
596            "a5ac7e51b41094c92402da3b24376905380afc29".into(),
597        );
598        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
599        let step = g.add_node(NodeKind::Step, "checkout", TrustZone::ThirdParty);
600        let image = g.add_node_with_metadata(
601            NodeKind::Image,
602            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
603            TrustZone::ThirdParty,
604            meta,
605        );
606
607        g.add_edge(step, identity, EdgeKind::HasAccessTo);
608        g.add_edge(step, image, EdgeKind::UsesImage);
609
610        let findings = authority_propagation(&g, 4);
611        // Should find propagation to the SHA-pinned image
612        let image_findings: Vec<_> = findings
613            .iter()
614            .filter(|f| f.nodes_involved.contains(&image))
615            .collect();
616        assert!(!image_findings.is_empty());
617        // SHA-pinned targets get High, not Critical
618        assert_eq!(image_findings[0].severity, Severity::High);
619    }
620
621    #[test]
622    fn propagation_to_untrusted_is_critical() {
623        let mut g = AuthorityGraph::new(source("ci.yml"));
624        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
625        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
626        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
627
628        g.add_edge(step, identity, EdgeKind::HasAccessTo);
629        g.add_edge(step, image, EdgeKind::UsesImage);
630
631        let findings = authority_propagation(&g, 4);
632        let image_findings: Vec<_> = findings
633            .iter()
634            .filter(|f| f.nodes_involved.contains(&image))
635            .collect();
636        assert!(!image_findings.is_empty());
637        assert_eq!(image_findings[0].severity, Severity::Critical);
638    }
639
640    #[test]
641    fn long_lived_credential_detected() {
642        let mut g = AuthorityGraph::new(source("ci.yml"));
643        g.add_node(NodeKind::Secret, "AWS_ACCESS_KEY_ID", TrustZone::FirstParty);
644        g.add_node(NodeKind::Secret, "NPM_TOKEN", TrustZone::FirstParty);
645        g.add_node(NodeKind::Secret, "DEPLOY_API_KEY", TrustZone::FirstParty);
646        // Non-matching names
647        g.add_node(NodeKind::Secret, "CACHE_TTL", TrustZone::FirstParty);
648
649        let findings = long_lived_credential(&g);
650        assert_eq!(findings.len(), 2); // AWS_ACCESS_KEY_ID + DEPLOY_API_KEY
651        assert!(findings
652            .iter()
653            .all(|f| f.category == FindingCategory::LongLivedCredential));
654    }
655
656    #[test]
657    fn duplicate_unpinned_actions_deduplicated() {
658        let mut g = AuthorityGraph::new(source("ci.yml"));
659        // Same action used in two jobs — two Image nodes, same name
660        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
661        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
662        g.add_node(
663            NodeKind::Image,
664            "actions/setup-node@v3",
665            TrustZone::Untrusted,
666        );
667
668        let findings = unpinned_action(&g);
669        // Should get 2 findings (checkout + setup-node), not 3
670        assert_eq!(findings.len(), 2);
671    }
672
673    #[test]
674    fn broad_identity_scope_flagged_as_high() {
675        let mut g = AuthorityGraph::new(source("ci.yml"));
676        let mut meta = std::collections::HashMap::new();
677        meta.insert(META_PERMISSIONS.into(), "write-all".into());
678        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
679        let identity = g.add_node_with_metadata(
680            NodeKind::Identity,
681            "GITHUB_TOKEN",
682            TrustZone::FirstParty,
683            meta,
684        );
685        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
686        g.add_edge(step, identity, EdgeKind::HasAccessTo);
687
688        let findings = over_privileged_identity(&g);
689        assert_eq!(findings.len(), 1);
690        assert_eq!(findings[0].severity, Severity::High);
691        assert!(findings[0].message.contains("broad"));
692    }
693
694    #[test]
695    fn unknown_identity_scope_flagged_as_medium() {
696        let mut g = AuthorityGraph::new(source("ci.yml"));
697        let mut meta = std::collections::HashMap::new();
698        meta.insert(META_PERMISSIONS.into(), "custom-scope".into());
699        meta.insert(META_IDENTITY_SCOPE.into(), "unknown".into());
700        let identity = g.add_node_with_metadata(
701            NodeKind::Identity,
702            "GITHUB_TOKEN",
703            TrustZone::FirstParty,
704            meta,
705        );
706        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
707        g.add_edge(step, identity, EdgeKind::HasAccessTo);
708
709        let findings = over_privileged_identity(&g);
710        assert_eq!(findings.len(), 1);
711        assert_eq!(findings[0].severity, Severity::Medium);
712        assert!(findings[0].message.contains("unknown"));
713    }
714
715    #[test]
716    fn floating_image_unpinned_container_flagged() {
717        let mut g = AuthorityGraph::new(source("ci.yml"));
718        let mut meta = std::collections::HashMap::new();
719        meta.insert(META_CONTAINER.into(), "true".into());
720        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
721
722        let findings = floating_image(&g);
723        assert_eq!(findings.len(), 1);
724        assert_eq!(findings[0].category, FindingCategory::FloatingImage);
725        assert_eq!(findings[0].severity, Severity::Medium);
726    }
727
728    #[test]
729    fn partial_graph_caps_critical_findings_at_high() {
730        let mut g = AuthorityGraph::new(source("ci.yml"));
731        g.mark_partial("matrix strategy hides some authority paths");
732
733        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
734        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
735        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
736
737        g.add_edge(step, identity, EdgeKind::HasAccessTo);
738        g.add_edge(step, image, EdgeKind::UsesImage);
739
740        let findings = run_all_rules(&g, 4);
741        assert!(findings
742            .iter()
743            .any(|f| f.category == FindingCategory::AuthorityPropagation));
744        assert!(findings
745            .iter()
746            .any(|f| f.category == FindingCategory::UntrustedWithAuthority));
747        assert!(findings.iter().all(|f| f.severity >= Severity::High));
748        assert!(!findings.iter().any(|f| f.severity == Severity::Critical));
749    }
750
751    #[test]
752    fn complete_graph_keeps_critical_findings() {
753        let mut g = AuthorityGraph::new(source("ci.yml"));
754
755        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
756        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
757        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
758
759        g.add_edge(step, identity, EdgeKind::HasAccessTo);
760        g.add_edge(step, image, EdgeKind::UsesImage);
761
762        let findings = run_all_rules(&g, 4);
763        assert!(findings.iter().any(|f| f.severity == Severity::Critical));
764    }
765
766    #[test]
767    fn floating_image_digest_pinned_container_not_flagged() {
768        let mut g = AuthorityGraph::new(source("ci.yml"));
769        let mut meta = std::collections::HashMap::new();
770        meta.insert(META_CONTAINER.into(), "true".into());
771        g.add_node_with_metadata(
772            NodeKind::Image,
773            "ubuntu@sha256:a5ac7e51b41094c92402da3b24376905380afc29a5ac7e51b41094c92402da3b",
774            TrustZone::ThirdParty,
775            meta,
776        );
777
778        let findings = floating_image(&g);
779        assert!(
780            findings.is_empty(),
781            "digest-pinned container should not be flagged"
782        );
783    }
784
785    #[test]
786    fn unpinned_action_does_not_flag_container_images() {
787        // Regression: container Image nodes are handled by floating_image, not unpinned_action.
788        // The same node must not generate findings from both rules.
789        let mut g = AuthorityGraph::new(source("ci.yml"));
790        let mut meta = std::collections::HashMap::new();
791        meta.insert(META_CONTAINER.into(), "true".into());
792        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
793
794        let findings = unpinned_action(&g);
795        assert!(
796            findings.is_empty(),
797            "unpinned_action must skip container images to avoid double-flagging"
798        );
799    }
800
801    #[test]
802    fn floating_image_ignores_action_images() {
803        let mut g = AuthorityGraph::new(source("ci.yml"));
804        // Image node without META_CONTAINER — this is a step uses: action, not a container
805        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
806
807        let findings = floating_image(&g);
808        assert!(
809            findings.is_empty(),
810            "floating_image should not flag step actions"
811        );
812    }
813
814    #[test]
815    fn persisted_credential_rule_fires_on_persists_to_edge() {
816        let mut g = AuthorityGraph::new(source("ci.yml"));
817        let token = g.add_node(
818            NodeKind::Identity,
819            "System.AccessToken",
820            TrustZone::FirstParty,
821        );
822        let checkout = g.add_node(NodeKind::Step, "checkout", TrustZone::FirstParty);
823        g.add_edge(checkout, token, EdgeKind::PersistsTo);
824
825        let findings = persisted_credential(&g);
826        assert_eq!(findings.len(), 1);
827        assert_eq!(findings[0].category, FindingCategory::PersistedCredential);
828        assert_eq!(findings[0].severity, Severity::High);
829        assert!(findings[0].message.contains("persistCredentials"));
830    }
831
832    #[test]
833    fn untrusted_with_cli_flag_exposed_secret_notes_log_exposure() {
834        let mut g = AuthorityGraph::new(source("ci.yml"));
835        let step = g.add_node(NodeKind::Step, "TerraformCLI@0", TrustZone::Untrusted);
836        let mut meta = std::collections::HashMap::new();
837        meta.insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
838        let secret =
839            g.add_node_with_metadata(NodeKind::Secret, "db_password", TrustZone::FirstParty, meta);
840        g.add_edge(step, secret, EdgeKind::HasAccessTo);
841
842        let findings = untrusted_with_authority(&g);
843        assert_eq!(findings.len(), 1);
844        assert!(
845            findings[0].message.contains("-var flag"),
846            "message should note -var flag log exposure"
847        );
848        assert!(matches!(
849            findings[0].recommendation,
850            Recommendation::Manual { .. }
851        ));
852    }
853
854    #[test]
855    fn constrained_identity_scope_not_flagged() {
856        let mut g = AuthorityGraph::new(source("ci.yml"));
857        let mut meta = std::collections::HashMap::new();
858        meta.insert(META_PERMISSIONS.into(), "{ contents: read }".into());
859        meta.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
860        let identity = g.add_node_with_metadata(
861            NodeKind::Identity,
862            "GITHUB_TOKEN",
863            TrustZone::FirstParty,
864            meta,
865        );
866        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
867        g.add_edge(step, identity, EdgeKind::HasAccessTo);
868
869        let findings = over_privileged_identity(&g);
870        assert!(
871            findings.is_empty(),
872            "constrained scope should not be flagged"
873        );
874    }
875}