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, NodeId, NodeKind, TrustZone, META_ATTESTS, META_CHECKOUT_SELF,
5    META_CLI_FLAG_EXPOSED, META_CONTAINER, META_DIGEST, META_IDENTITY_SCOPE, META_IMPLICIT,
6    META_OIDC, META_PERMISSIONS, META_SELF_HOSTED, META_SERVICE_CONNECTION, META_TRIGGER,
7    META_VARIABLE_GROUP, META_WRITES_ENV_GATE,
8};
9use crate::propagation;
10
11fn cap_severity(severity: Severity, max_severity: Severity) -> Severity {
12    if severity < max_severity {
13        max_severity
14    } else {
15        severity
16    }
17}
18
19fn apply_confidence_cap(graph: &AuthorityGraph, findings: &mut [Finding]) {
20    if graph.completeness != AuthorityCompleteness::Partial {
21        return;
22    }
23
24    for finding in findings {
25        finding.severity = cap_severity(finding.severity, Severity::High);
26    }
27}
28
29/// MVP Rule 1: Authority (secret/identity) propagated across a trust boundary.
30///
31/// Severity graduation (tuned from real-world signal on 10 production workflows):
32/// - Untrusted sink: Critical (real risk — unpinned code with authority)
33/// - SHA-pinned ThirdParty sink: High (immutable code, but still cross-boundary)
34/// - SHA-pinned sink + constrained identity: Medium (lowest-risk form — read-only
35///   token to immutable third-party code, e.g. `contents:read` → `actions/checkout@sha`)
36pub fn authority_propagation(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
37    let paths = propagation::propagation_analysis(graph, max_hops);
38
39    paths
40        .into_iter()
41        .filter(|p| p.crossed_boundary)
42        .map(|path| {
43            let source_name = graph
44                .node(path.source)
45                .map(|n| n.name.as_str())
46                .unwrap_or("?");
47            let sink_name = graph
48                .node(path.sink)
49                .map(|n| n.name.as_str())
50                .unwrap_or("?");
51
52            // Graduate severity based on sink trust + source scope
53            let sink_is_pinned = graph
54                .node(path.sink)
55                .map(|n| {
56                    n.trust_zone == TrustZone::ThirdParty && n.metadata.contains_key(META_DIGEST)
57                })
58                .unwrap_or(false);
59
60            let source_is_constrained = graph
61                .node(path.source)
62                .and_then(|n| n.metadata.get(META_IDENTITY_SCOPE))
63                .map(|s| s == "constrained")
64                .unwrap_or(false);
65
66            let severity = if sink_is_pinned && source_is_constrained {
67                Severity::Medium
68            } else if sink_is_pinned {
69                Severity::High
70            } else {
71                Severity::Critical
72            };
73
74            Finding {
75                severity,
76                category: FindingCategory::AuthorityPropagation,
77                nodes_involved: vec![path.source, path.sink],
78                message: format!("{source_name} propagated to {sink_name} across trust boundary"),
79                recommendation: Recommendation::TsafeRemediation {
80                    command: "tsafe exec --ns <scoped-namespace> -- <command>".to_string(),
81                    explanation: format!("Scope {source_name} to only the steps that need it"),
82                },
83                path: Some(path),
84            }
85        })
86        .collect()
87}
88
89/// MVP Rule 2: Identity scope broader than actual usage.
90///
91/// Uses `IdentityScope` classification from the precision layer. Broad and
92/// Unknown scopes are flagged — Unknown is treated as risky because if we
93/// can't determine the scope, we shouldn't assume it's safe.
94pub fn over_privileged_identity(graph: &AuthorityGraph) -> Vec<Finding> {
95    let mut findings = Vec::new();
96
97    for identity in graph.nodes_of_kind(NodeKind::Identity) {
98        let granted_scope = identity
99            .metadata
100            .get(META_PERMISSIONS)
101            .cloned()
102            .unwrap_or_default();
103
104        // Use IdentityScope from metadata if set by parser, otherwise classify from permissions
105        let scope = identity
106            .metadata
107            .get(META_IDENTITY_SCOPE)
108            .and_then(|s| match s.as_str() {
109                "broad" => Some(IdentityScope::Broad),
110                "constrained" => Some(IdentityScope::Constrained),
111                "unknown" => Some(IdentityScope::Unknown),
112                _ => None,
113            })
114            .unwrap_or_else(|| IdentityScope::from_permissions(&granted_scope));
115
116        // Broad or Unknown scope — flag it. Unknown is treated as risky.
117        let (should_flag, severity) = match scope {
118            IdentityScope::Broad => (true, Severity::High),
119            IdentityScope::Unknown => (true, Severity::Medium),
120            IdentityScope::Constrained => (false, Severity::Info),
121        };
122
123        if !should_flag {
124            continue;
125        }
126
127        let accessor_steps: Vec<_> = graph
128            .edges_to(identity.id)
129            .filter(|e| e.kind == EdgeKind::HasAccessTo)
130            .filter_map(|e| graph.node(e.from))
131            .collect();
132
133        if !accessor_steps.is_empty() {
134            let scope_label = match scope {
135                IdentityScope::Broad => "broad",
136                IdentityScope::Unknown => "unknown (treat as risky)",
137                IdentityScope::Constrained => "constrained",
138            };
139
140            findings.push(Finding {
141                severity,
142                category: FindingCategory::OverPrivilegedIdentity,
143                path: None,
144                nodes_involved: std::iter::once(identity.id)
145                    .chain(accessor_steps.iter().map(|n| n.id))
146                    .collect(),
147                message: format!(
148                    "{} has {} scope (permissions: '{}') — likely broader than needed",
149                    identity.name, scope_label, granted_scope
150                ),
151                recommendation: Recommendation::ReducePermissions {
152                    current: granted_scope.clone(),
153                    minimum: "{ contents: read }".into(),
154                },
155            });
156        }
157    }
158
159    findings
160}
161
162/// MVP Rule 3: Third-party action/image without SHA pin.
163///
164/// Deduplicates by action reference — the same action used in multiple jobs
165/// produces multiple Image nodes but should only be flagged once.
166pub fn unpinned_action(graph: &AuthorityGraph) -> Vec<Finding> {
167    let mut findings = Vec::new();
168    let mut seen = std::collections::HashSet::new();
169
170    for image in graph.nodes_of_kind(NodeKind::Image) {
171        if image.trust_zone == TrustZone::FirstParty {
172            continue;
173        }
174
175        // Container images are handled by floating_image — skip here to avoid
176        // double-flagging the same node as both UnpinnedAction and FloatingImage.
177        if image
178            .metadata
179            .get(META_CONTAINER)
180            .map(|v| v == "true")
181            .unwrap_or(false)
182        {
183            continue;
184        }
185
186        // Deduplicate: same action reference flagged once
187        if !seen.insert(&image.name) {
188            continue;
189        }
190
191        let has_digest = image.metadata.contains_key(META_DIGEST);
192
193        if !has_digest && !is_sha_pinned(&image.name) {
194            findings.push(Finding {
195                severity: Severity::Medium,
196                category: FindingCategory::UnpinnedAction,
197                path: None,
198                nodes_involved: vec![image.id],
199                message: format!("{} is not pinned to a SHA digest", image.name),
200                recommendation: Recommendation::PinAction {
201                    current: image.name.clone(),
202                    pinned: format!(
203                        "{}@<sha256-digest>",
204                        image.name.split('@').next().unwrap_or(&image.name)
205                    ),
206                },
207            });
208        }
209    }
210
211    findings
212}
213
214/// MVP Rule 4: Untrusted step has direct access to secret/identity.
215pub fn untrusted_with_authority(graph: &AuthorityGraph) -> Vec<Finding> {
216    let mut findings = Vec::new();
217
218    for step in graph.nodes_in_zone(TrustZone::Untrusted) {
219        if step.kind != NodeKind::Step {
220            continue;
221        }
222
223        // Check if this untrusted step directly accesses any authority source
224        for edge in graph.edges_from(step.id) {
225            if edge.kind != EdgeKind::HasAccessTo {
226                continue;
227            }
228
229            if let Some(target) = graph.node(edge.to) {
230                if matches!(target.kind, NodeKind::Secret | NodeKind::Identity) {
231                    let cli_flag_exposed = target
232                        .metadata
233                        .get(META_CLI_FLAG_EXPOSED)
234                        .map(|v| v == "true")
235                        .unwrap_or(false);
236
237                    // Platform-implicit tokens (e.g. ADO System.AccessToken) are structurally
238                    // accessible to all tasks by design. Flag at Info — real but not actionable
239                    // as a misconfiguration. Explicit secrets/service connections stay Critical.
240                    let is_implicit = target
241                        .metadata
242                        .get(META_IMPLICIT)
243                        .map(|v| v == "true")
244                        .unwrap_or(false);
245
246                    let recommendation = if target.kind == NodeKind::Secret {
247                        if cli_flag_exposed {
248                            Recommendation::Manual {
249                                action: format!(
250                                    "Move '{}' from -var flag to TF_VAR_{} env var — \
251                                     -var values appear in pipeline logs and Terraform plan output",
252                                    target.name, target.name
253                                ),
254                            }
255                        } else {
256                            Recommendation::CellosRemediation {
257                                reason: format!(
258                                    "Untrusted step '{}' has direct access to secret '{}'",
259                                    step.name, target.name
260                                ),
261                                spec_hint: format!(
262                                    "cellos run --network deny-all --broker env:{}",
263                                    target.name
264                                ),
265                            }
266                        }
267                    } else {
268                        // Identity branch — for implicit platform tokens, add a CellOS
269                        // compensating-control note since the token cannot be un-injected
270                        // at the platform layer.
271                        let minimum = if is_implicit {
272                            "minimal required scope — or use CellOS deny-all egress as a compensating control to limit exfiltration of the injected token".into()
273                        } else {
274                            "minimal required scope".into()
275                        };
276                        Recommendation::ReducePermissions {
277                            current: target
278                                .metadata
279                                .get(META_PERMISSIONS)
280                                .cloned()
281                                .unwrap_or_else(|| "unknown".into()),
282                            minimum,
283                        }
284                    };
285
286                    let log_exposure_note = if cli_flag_exposed {
287                        " (passed as -var flag — value visible in pipeline logs)"
288                    } else {
289                        ""
290                    };
291
292                    let (severity, message) =
293                        if is_implicit {
294                            (
295                                Severity::Info,
296                                format!(
297                                "Untrusted step '{}' has structural access to implicit {} '{}' \
298                                 (platform-injected — all tasks receive this token by design){}",
299                                step.name,
300                                if target.kind == NodeKind::Secret { "secret" } else { "identity" },
301                                target.name,
302                                log_exposure_note,
303                            ),
304                            )
305                        } else {
306                            (
307                                Severity::Critical,
308                                format!(
309                                    "Untrusted step '{}' has direct access to {} '{}'{}",
310                                    step.name,
311                                    if target.kind == NodeKind::Secret {
312                                        "secret"
313                                    } else {
314                                        "identity"
315                                    },
316                                    target.name,
317                                    log_exposure_note,
318                                ),
319                            )
320                        };
321
322                    findings.push(Finding {
323                        severity,
324                        category: FindingCategory::UntrustedWithAuthority,
325                        path: None,
326                        nodes_involved: vec![step.id, target.id],
327                        message,
328                        recommendation,
329                    });
330                }
331            }
332        }
333    }
334
335    findings
336}
337
338/// MVP Rule 5: Artifact produced by privileged step consumed across trust boundary.
339pub fn artifact_boundary_crossing(graph: &AuthorityGraph) -> Vec<Finding> {
340    let mut findings = Vec::new();
341
342    for artifact in graph.nodes_of_kind(NodeKind::Artifact) {
343        // Find producer(s)
344        let producers: Vec<_> = graph
345            .edges_to(artifact.id)
346            .filter(|e| e.kind == EdgeKind::Produces)
347            .filter_map(|e| graph.node(e.from))
348            .collect();
349
350        // Find consumer(s) — Consumes edges go artifact -> step
351        let consumers: Vec<_> = graph
352            .edges_from(artifact.id)
353            .filter(|e| e.kind == EdgeKind::Consumes)
354            .filter_map(|e| graph.node(e.to))
355            .collect();
356
357        for producer in &producers {
358            // Only care if the producer is privileged (has access to secrets/identities)
359            let producer_has_authority = graph.edges_from(producer.id).any(|e| {
360                e.kind == EdgeKind::HasAccessTo
361                    && graph
362                        .node(e.to)
363                        .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
364                        .unwrap_or(false)
365            });
366
367            if !producer_has_authority {
368                continue;
369            }
370
371            for consumer in &consumers {
372                if consumer.trust_zone.is_lower_than(&producer.trust_zone) {
373                    findings.push(Finding {
374                        severity: Severity::High,
375                        category: FindingCategory::ArtifactBoundaryCrossing,
376                        path: None,
377                        nodes_involved: vec![producer.id, artifact.id, consumer.id],
378                        message: format!(
379                            "Artifact '{}' produced by privileged step '{}' consumed by '{}' ({:?} -> {:?})",
380                            artifact.name,
381                            producer.name,
382                            consumer.name,
383                            producer.trust_zone,
384                            consumer.trust_zone
385                        ),
386                        recommendation: Recommendation::TsafeRemediation {
387                            command: format!(
388                                "tsafe exec --ns {} -- <build-command>",
389                                producer.name
390                            ),
391                            explanation: format!(
392                                "Scope secrets to '{}' only; artifact '{}' should not carry authority",
393                                producer.name, artifact.name
394                            ),
395                        },
396                    });
397                }
398            }
399        }
400    }
401
402    findings
403}
404
405/// Stretch Rule 9: Secret name matches known long-lived/static credential pattern.
406///
407/// Heuristic: secrets named like AWS keys, API keys, passwords, or private keys
408/// are likely static credentials that should be replaced with OIDC federation.
409pub fn long_lived_credential(graph: &AuthorityGraph) -> Vec<Finding> {
410    const STATIC_PATTERNS: &[&str] = &[
411        "AWS_ACCESS_KEY",
412        "AWS_SECRET_ACCESS_KEY",
413        "_API_KEY",
414        "_APIKEY",
415        "_PASSWORD",
416        "_PASSWD",
417        "_PRIVATE_KEY",
418        "_SECRET_KEY",
419        "_SERVICE_ACCOUNT",
420        "_SIGNING_KEY",
421    ];
422
423    let mut findings = Vec::new();
424
425    for secret in graph.nodes_of_kind(NodeKind::Secret) {
426        let upper = secret.name.to_uppercase();
427        let is_static = STATIC_PATTERNS.iter().any(|p| upper.contains(p));
428
429        if is_static {
430            findings.push(Finding {
431                severity: Severity::Low,
432                category: FindingCategory::LongLivedCredential,
433                path: None,
434                nodes_involved: vec![secret.id],
435                message: format!(
436                    "'{}' looks like a long-lived static credential",
437                    secret.name
438                ),
439                recommendation: Recommendation::FederateIdentity {
440                    static_secret: secret.name.clone(),
441                    oidc_provider: "GitHub Actions OIDC (id-token: write)".into(),
442                },
443            });
444        }
445    }
446
447    findings
448}
449
450/// Tier 6 Rule: Container image without Docker digest pinning.
451///
452/// Job-level containers marked with `META_CONTAINER` that aren't pinned to
453/// `image@sha256:<64hex>` can be silently mutated between runs. Deduplicates
454/// by image name (same image in multiple jobs flags once).
455pub fn floating_image(graph: &AuthorityGraph) -> Vec<Finding> {
456    let mut findings = Vec::new();
457    let mut seen = std::collections::HashSet::new();
458
459    for image in graph.nodes_of_kind(NodeKind::Image) {
460        let is_container = image
461            .metadata
462            .get(META_CONTAINER)
463            .map(|v| v == "true")
464            .unwrap_or(false);
465
466        if !is_container {
467            continue;
468        }
469
470        if !seen.insert(image.name.as_str()) {
471            continue;
472        }
473
474        if !is_docker_digest_pinned(&image.name) {
475            findings.push(Finding {
476                severity: Severity::Medium,
477                category: FindingCategory::FloatingImage,
478                path: None,
479                nodes_involved: vec![image.id],
480                message: format!("Container image '{}' is not pinned to a digest", image.name),
481                recommendation: Recommendation::PinAction {
482                    current: image.name.clone(),
483                    pinned: format!(
484                        "{}@sha256:<digest>",
485                        image.name.split(':').next().unwrap_or(&image.name)
486                    ),
487                },
488            });
489        }
490    }
491
492    findings
493}
494
495/// Stretch Rule: checkout step with `persistCredentials: true` writes credentials to disk.
496///
497/// The PersistsTo edge connects a checkout step to the token it persists. Disk-resident
498/// credentials are accessible to all subsequent steps (and to any process with filesystem
499/// access), unlike runtime-only HasAccessTo authority which expires when the step exits.
500pub fn persisted_credential(graph: &AuthorityGraph) -> Vec<Finding> {
501    let mut findings = Vec::new();
502
503    for edge in &graph.edges {
504        if edge.kind != EdgeKind::PersistsTo {
505            continue;
506        }
507
508        let Some(step) = graph.node(edge.from) else {
509            continue;
510        };
511        let Some(target) = graph.node(edge.to) else {
512            continue;
513        };
514
515        findings.push(Finding {
516            severity: Severity::High,
517            category: FindingCategory::PersistedCredential,
518            path: None,
519            nodes_involved: vec![step.id, target.id],
520            message: format!(
521                "'{}' persists '{}' to disk via persistCredentials: true — \
522                 credential remains in .git/config and is accessible to all subsequent steps",
523                step.name, target.name
524            ),
525            recommendation: Recommendation::Manual {
526                action: "Remove persistCredentials: true from the checkout step. \
527                         Pass credentials explicitly only to steps that need them."
528                    .into(),
529            },
530        });
531    }
532
533    findings
534}
535
536/// Rule: dangerous trigger type (pull_request_target / pr) combined with secret/identity access.
537///
538/// Fires once per workflow when the graph-level `META_TRIGGER` indicates a high-risk
539/// trigger and at least one step holds authority. Aggregates all involved nodes.
540pub fn trigger_context_mismatch(graph: &AuthorityGraph) -> Vec<Finding> {
541    let trigger = match graph.metadata.get(META_TRIGGER) {
542        Some(t) => t.clone(),
543        None => return Vec::new(),
544    };
545
546    let severity = match trigger.as_str() {
547        "pull_request_target" => Severity::Critical,
548        "pr" => Severity::High,
549        _ => return Vec::new(),
550    };
551
552    // Collect steps that hold authority (HasAccessTo a Secret or Identity)
553    let mut steps_with_authority: Vec<NodeId> = Vec::new();
554    let mut authority_targets: Vec<NodeId> = Vec::new();
555
556    for step in graph.nodes_of_kind(NodeKind::Step) {
557        let mut step_holds_authority = false;
558        for edge in graph.edges_from(step.id) {
559            if edge.kind != EdgeKind::HasAccessTo {
560                continue;
561            }
562            if let Some(target) = graph.node(edge.to) {
563                if matches!(target.kind, NodeKind::Secret | NodeKind::Identity) {
564                    step_holds_authority = true;
565                    if !authority_targets.contains(&target.id) {
566                        authority_targets.push(target.id);
567                    }
568                }
569            }
570        }
571        if step_holds_authority {
572            steps_with_authority.push(step.id);
573        }
574    }
575
576    if steps_with_authority.is_empty() {
577        return Vec::new();
578    }
579
580    let n = steps_with_authority.len();
581    let mut nodes_involved = steps_with_authority.clone();
582    nodes_involved.extend(authority_targets);
583
584    vec![Finding {
585        severity,
586        category: FindingCategory::TriggerContextMismatch,
587        path: None,
588        nodes_involved,
589        message: format!(
590            "Workflow triggered by {trigger} with secret/identity access — {n} step(s) hold authority that attacker-controlled code could reach"
591        ),
592        recommendation: Recommendation::Manual {
593            action: "Use a separate workflow triggered by workflow_run (not pull_request_target) for privileged operations, or ensure no checkout of the PR head ref occurs before secret use".into(),
594        },
595    }]
596}
597
598/// Rule: authority (secret/identity) flows into an opaque external workflow via DelegatesTo.
599///
600/// For each Step node: find all `DelegatesTo` edges to Image nodes where the trust zone
601/// is not FirstParty. If the same step also has `HasAccessTo` any Secret or Identity,
602/// emit one finding per delegation edge.
603pub fn cross_workflow_authority_chain(graph: &AuthorityGraph) -> Vec<Finding> {
604    let mut findings = Vec::new();
605
606    for step in graph.nodes_of_kind(NodeKind::Step) {
607        // Collect authority sources this step holds
608        let authority_nodes: Vec<&_> = graph
609            .edges_from(step.id)
610            .filter(|e| e.kind == EdgeKind::HasAccessTo)
611            .filter_map(|e| graph.node(e.to))
612            .filter(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
613            .collect();
614
615        if authority_nodes.is_empty() {
616            continue;
617        }
618
619        // Find each DelegatesTo edge to a non-FirstParty Image
620        for edge in graph.edges_from(step.id) {
621            if edge.kind != EdgeKind::DelegatesTo {
622                continue;
623            }
624            let Some(target) = graph.node(edge.to) else {
625                continue;
626            };
627            if target.kind != NodeKind::Image {
628                continue;
629            }
630            if target.trust_zone == TrustZone::FirstParty {
631                continue;
632            }
633
634            let severity = match target.trust_zone {
635                TrustZone::Untrusted => Severity::Critical,
636                TrustZone::ThirdParty => Severity::High,
637                TrustZone::FirstParty => continue,
638            };
639
640            let authority_names: Vec<String> =
641                authority_nodes.iter().map(|n| n.name.clone()).collect();
642            let authority_label = authority_names.join(", ");
643
644            let mut nodes_involved = vec![step.id, target.id];
645            nodes_involved.extend(authority_nodes.iter().map(|n| n.id));
646
647            findings.push(Finding {
648                severity,
649                category: FindingCategory::CrossWorkflowAuthorityChain,
650                path: None,
651                nodes_involved,
652                message: format!(
653                    "'{}' delegates to '{}' ({:?}) while holding authority ({}) — authority chain extends into opaque external workflow",
654                    step.name, target.name, target.trust_zone, authority_label
655                ),
656                recommendation: Recommendation::Manual {
657                    action: format!(
658                        "Pin '{}' to a full SHA digest; audit what authority the called workflow receives",
659                        target.name
660                    ),
661                },
662            });
663        }
664    }
665
666    findings
667}
668
669/// Rule: circular DelegatesTo chain — workflow calls itself transitively.
670///
671/// Iterative DFS over `DelegatesTo` edges. Detects back edges (gray → gray) and
672/// collects all nodes that participate in any cycle. If any cycles exist, emits
673/// a single High-severity finding listing all cycle members.
674pub fn authority_cycle(graph: &AuthorityGraph) -> Vec<Finding> {
675    let n = graph.nodes.len();
676    if n == 0 {
677        return Vec::new();
678    }
679
680    // Pre-build adjacency list for DelegatesTo edges only.
681    let mut delegates_to: Vec<Vec<NodeId>> = vec![Vec::new(); n];
682    for edge in &graph.edges {
683        if edge.kind == EdgeKind::DelegatesTo && edge.from < n && edge.to < n {
684            delegates_to[edge.from].push(edge.to);
685        }
686    }
687
688    let mut color: Vec<u8> = vec![0u8; n]; // 0=white, 1=gray, 2=black
689    let mut cycle_nodes: std::collections::BTreeSet<NodeId> = std::collections::BTreeSet::new();
690
691    for start in 0..n {
692        if color[start] != 0 {
693            continue;
694        }
695        color[start] = 1;
696        let mut stack: Vec<(NodeId, usize)> = vec![(start, 0)];
697
698        loop {
699            let len = stack.len();
700            if len == 0 {
701                break;
702            }
703            let (node_id, edge_idx) = stack[len - 1];
704            if edge_idx < delegates_to[node_id].len() {
705                stack[len - 1].1 += 1;
706                let neighbor = delegates_to[node_id][edge_idx];
707                if color[neighbor] == 1 {
708                    // Back edge: cycle found. Collect every node between `neighbor`
709                    // (the cycle start) and `node_id` (the cycle end) along the
710                    // current DFS stack. All stack entries are gray by construction,
711                    // so we walk the stack from `neighbor` to the top.
712                    let cycle_start_idx =
713                        stack.iter().position(|&(n, _)| n == neighbor).unwrap_or(0);
714                    for &(n, _) in &stack[cycle_start_idx..] {
715                        cycle_nodes.insert(n);
716                    }
717                } else if color[neighbor] == 0 {
718                    color[neighbor] = 1;
719                    stack.push((neighbor, 0));
720                }
721            } else {
722                color[node_id] = 2;
723                stack.pop();
724            }
725        }
726    }
727
728    if cycle_nodes.is_empty() {
729        return Vec::new();
730    }
731
732    vec![Finding {
733        severity: Severity::High,
734        category: FindingCategory::AuthorityCycle,
735        path: None,
736        nodes_involved: cycle_nodes.into_iter().collect(),
737        message:
738            "Circular delegation detected — workflow calls itself transitively, creating unbounded privilege escalation paths"
739                .into(),
740        recommendation: Recommendation::Manual {
741            action: "Break the delegation cycle — a workflow must not directly or transitively call itself".into(),
742        },
743    }]
744}
745
746/// Rule: privileged workflow (OIDC/federated identity) with no provenance attestation step.
747///
748/// Scoped to workflows that actually use OIDC/federated identity (an Identity node with
749/// `META_OIDC = "true"` is present). If no node in the graph has `META_ATTESTS = "true"`,
750/// emit one Info-severity finding listing the steps with HasAccessTo an OIDC identity.
751pub fn uplift_without_attestation(graph: &AuthorityGraph) -> Vec<Finding> {
752    // Scope: only fire when the graph has at least one OIDC-capable Identity
753    let oidc_identity_ids: Vec<NodeId> = graph
754        .nodes_of_kind(NodeKind::Identity)
755        .filter(|n| {
756            n.metadata
757                .get(META_OIDC)
758                .map(|v| v == "true")
759                .unwrap_or(false)
760        })
761        .map(|n| n.id)
762        .collect();
763
764    if oidc_identity_ids.is_empty() {
765        return Vec::new();
766    }
767
768    // Bail if any node already has META_ATTESTS = true
769    let has_attestation = graph.nodes.iter().any(|n| {
770        n.metadata
771            .get(META_ATTESTS)
772            .map(|v| v == "true")
773            .unwrap_or(false)
774    });
775    if has_attestation {
776        return Vec::new();
777    }
778
779    // Collect steps that have HasAccessTo an OIDC identity
780    let mut steps_using_oidc: Vec<NodeId> = Vec::new();
781    for edge in &graph.edges {
782        if edge.kind != EdgeKind::HasAccessTo {
783            continue;
784        }
785        if oidc_identity_ids.contains(&edge.to) && !steps_using_oidc.contains(&edge.from) {
786            steps_using_oidc.push(edge.from);
787        }
788    }
789
790    if steps_using_oidc.is_empty() {
791        return Vec::new();
792    }
793
794    let n = steps_using_oidc.len();
795    let mut nodes_involved = steps_using_oidc.clone();
796    nodes_involved.extend(oidc_identity_ids);
797
798    vec![Finding {
799        severity: Severity::Info,
800        category: FindingCategory::UpliftWithoutAttestation,
801        path: None,
802        nodes_involved,
803        message: format!(
804            "{n} step(s) use OIDC/federated identity but no provenance attestation step was detected — artifact integrity cannot be verified"
805        ),
806        recommendation: Recommendation::Manual {
807            action: "Add 'actions/attest-build-provenance' after your build step (GHA) to provide SLSA provenance. See https://docs.github.com/en/actions/security-guides/using-artifact-attestations".into(),
808        },
809    }]
810}
811
812/// Rule: step writes to the environment gate ($GITHUB_ENV / ##vso[task.setvariable]).
813///
814/// Authority leaking through the environment gate propagates to subsequent steps
815/// outside the explicit graph edges. Severity:
816/// - Untrusted step: Critical (attacker-controlled values inject into pipeline env)
817/// - Step with secret/identity access: High (secrets may leak into env)
818/// - Otherwise: Medium (still a propagation risk)
819pub fn self_mutating_pipeline(graph: &AuthorityGraph) -> Vec<Finding> {
820    let mut findings = Vec::new();
821
822    for step in graph.nodes_of_kind(NodeKind::Step) {
823        let writes_gate = step
824            .metadata
825            .get(META_WRITES_ENV_GATE)
826            .map(|v| v == "true")
827            .unwrap_or(false);
828        if !writes_gate {
829            continue;
830        }
831
832        // Collect authority targets the step has HasAccessTo
833        let authority_nodes: Vec<&_> = graph
834            .edges_from(step.id)
835            .filter(|e| e.kind == EdgeKind::HasAccessTo)
836            .filter_map(|e| graph.node(e.to))
837            .filter(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
838            .collect();
839
840        let is_untrusted = step.trust_zone == TrustZone::Untrusted;
841        let has_authority = !authority_nodes.is_empty();
842
843        let severity = if is_untrusted {
844            Severity::Critical
845        } else if has_authority {
846            Severity::High
847        } else {
848            Severity::Medium
849        };
850
851        let mut nodes_involved = vec![step.id];
852        nodes_involved.extend(authority_nodes.iter().map(|n| n.id));
853
854        let message = if is_untrusted {
855            format!(
856                "Untrusted step '{}' writes to the environment gate — attacker-controlled values can inject into subsequent steps' environment",
857                step.name
858            )
859        } else if has_authority {
860            let authority_label: Vec<String> =
861                authority_nodes.iter().map(|n| n.name.clone()).collect();
862            format!(
863                "Step '{}' writes to the environment gate while holding authority ({}) — secrets may leak into pipeline environment",
864                step.name,
865                authority_label.join(", ")
866            )
867        } else {
868            format!(
869                "Step '{}' writes to the environment gate — values can propagate into subsequent steps' environment",
870                step.name
871            )
872        };
873
874        findings.push(Finding {
875            severity,
876            category: FindingCategory::SelfMutatingPipeline,
877            path: None,
878            nodes_involved,
879            message,
880            recommendation: Recommendation::Manual {
881                action: "Avoid writing secrets or attacker-controlled values to $GITHUB_ENV / $GITHUB_PATH / pipeline variables. Use explicit step outputs with narrow scoping instead.".into(),
882            },
883        });
884    }
885
886    findings
887}
888
889/// Rule: PR-triggered pipeline performs a self checkout.
890///
891/// When a PR/PRT-triggered pipeline checks out the repository, attacker-controlled
892/// code from the fork lands on the runner. Any subsequent step that reads workspace
893/// files (which is almost all of them) can exfiltrate secrets or tamper with build
894/// artifacts. Fires only when the graph has a PR-class trigger.
895pub fn checkout_self_pr_exposure(graph: &AuthorityGraph) -> Vec<Finding> {
896    // Only fires when the graph has a PR/PRT trigger
897    let trigger = graph.metadata.get(META_TRIGGER).map(|s| s.as_str());
898    let is_pr_context = matches!(trigger, Some("pr") | Some("pull_request_target"));
899    if !is_pr_context {
900        return vec![];
901    }
902
903    let mut findings = Vec::new();
904    for step in graph.nodes_of_kind(NodeKind::Step) {
905        let is_checkout_self = step
906            .metadata
907            .get(META_CHECKOUT_SELF)
908            .map(|v| v == "true")
909            .unwrap_or(false);
910        if !is_checkout_self {
911            continue;
912        }
913        findings.push(Finding {
914            category: FindingCategory::CheckoutSelfPrExposure,
915            severity: Severity::High,
916            message: format!(
917                "PR-triggered pipeline checks out the repository at step '{}' — \
918                 attacker-controlled code from the fork lands on the runner and is \
919                 readable by all subsequent steps",
920                step.name
921            ),
922            path: None,
923            nodes_involved: vec![step.id],
924            recommendation: Recommendation::Manual {
925                action: "Use `persist-credentials: false` and avoid reading workspace \
926                         files in subsequent privileged steps. Consider `checkout: none` \
927                         for jobs that only need pipeline config, not source code."
928                    .into(),
929            },
930        });
931    }
932    findings
933}
934
935/// Rule: ADO variable group consumed by a PR-triggered job.
936///
937/// Variable groups hold secrets scoped to pipelines. When a PR-triggered job has
938/// `HasAccessTo` a Secret/Identity carrying `META_VARIABLE_GROUP = "true"`, those
939/// secrets cross into an untrusted-contributor execution context.
940pub fn variable_group_in_pr_job(graph: &AuthorityGraph) -> Vec<Finding> {
941    // Only fires when the pipeline has a PR trigger
942    let trigger = graph
943        .metadata
944        .get(META_TRIGGER)
945        .map(|s| s.as_str())
946        .unwrap_or("");
947    if trigger != "pull_request_target" && trigger != "pr" {
948        return Vec::new();
949    }
950
951    let mut findings = Vec::new();
952
953    for step in graph.nodes_of_kind(NodeKind::Step) {
954        let accessed_var_groups: Vec<&_> = graph
955            .edges_from(step.id)
956            .filter(|e| e.kind == EdgeKind::HasAccessTo)
957            .filter_map(|e| graph.node(e.to))
958            .filter(|n| {
959                (n.kind == NodeKind::Secret || n.kind == NodeKind::Identity)
960                    && n.metadata
961                        .get(META_VARIABLE_GROUP)
962                        .map(|v| v == "true")
963                        .unwrap_or(false)
964            })
965            .collect();
966
967        if !accessed_var_groups.is_empty() {
968            let group_names: Vec<_> = accessed_var_groups
969                .iter()
970                .map(|n| n.name.as_str())
971                .collect();
972            findings.push(Finding {
973                severity: Severity::Critical,
974                category: FindingCategory::VariableGroupInPrJob,
975                path: None,
976                nodes_involved: std::iter::once(step.id)
977                    .chain(accessed_var_groups.iter().map(|n| n.id))
978                    .collect(),
979                message: format!(
980                    "PR-triggered step '{}' accesses variable group(s) [{}] — secrets cross into untrusted PR execution context",
981                    step.name,
982                    group_names.join(", ")
983                ),
984                recommendation: Recommendation::CellosRemediation {
985                    reason: format!(
986                        "PR-triggered step '{}' can exfiltrate variable group secrets via untrusted code",
987                        step.name
988                    ),
989                    spec_hint: "cellos run --network deny-all --policy requireEgressDeclared,requireRuntimeSecretDelivery".into(),
990                },
991            });
992        }
993    }
994
995    findings
996}
997
998/// Rule: self-hosted agent pool used by a PR-triggered pipeline that also checks out the repo.
999///
1000/// All three factors present — self-hosted pool + PR trigger + `checkout:self` — combine to
1001/// allow an attacker to land malicious git hooks on the shared runner via a PR. Those hooks
1002/// persist across pipeline runs and execute with full pipeline authority.
1003pub fn self_hosted_pool_pr_hijack(graph: &AuthorityGraph) -> Vec<Finding> {
1004    let trigger = graph
1005        .metadata
1006        .get(META_TRIGGER)
1007        .map(|s| s.as_str())
1008        .unwrap_or("");
1009    if trigger != "pull_request_target" && trigger != "pr" {
1010        return Vec::new();
1011    }
1012
1013    // Check if any Image node is self-hosted
1014    let has_self_hosted_pool = graph.nodes_of_kind(NodeKind::Image).any(|n| {
1015        n.metadata
1016            .get(META_SELF_HOSTED)
1017            .map(|v| v == "true")
1018            .unwrap_or(false)
1019    });
1020
1021    if !has_self_hosted_pool {
1022        return Vec::new();
1023    }
1024
1025    // Check if any Step does checkout:self
1026    let checkout_steps: Vec<&_> = graph
1027        .nodes_of_kind(NodeKind::Step)
1028        .filter(|n| {
1029            n.metadata
1030                .get(META_CHECKOUT_SELF)
1031                .map(|v| v == "true")
1032                .unwrap_or(false)
1033        })
1034        .collect();
1035
1036    if checkout_steps.is_empty() {
1037        return Vec::new();
1038    }
1039
1040    // All three factors present: self-hosted + PR trigger + checkout:self.
1041    // Collect self-hosted pool nodes for the finding.
1042    let pool_nodes: Vec<&_> = graph
1043        .nodes_of_kind(NodeKind::Image)
1044        .filter(|n| {
1045            n.metadata
1046                .get(META_SELF_HOSTED)
1047                .map(|v| v == "true")
1048                .unwrap_or(false)
1049        })
1050        .collect();
1051
1052    let mut nodes_involved: Vec<NodeId> = pool_nodes.iter().map(|n| n.id).collect();
1053    nodes_involved.extend(checkout_steps.iter().map(|n| n.id));
1054
1055    vec![Finding {
1056        severity: Severity::Critical,
1057        category: FindingCategory::SelfHostedPoolPrHijack,
1058        path: None,
1059        nodes_involved,
1060        message:
1061            "PR-triggered pipeline uses self-hosted agent pool with checkout:self — enables git hook injection persisting across pipeline runs on the shared runner"
1062                .into(),
1063        recommendation: Recommendation::Manual {
1064            action: "Run PR pipelines on Microsoft-hosted (ephemeral) agents, or disable checkout:self for PR-triggered jobs on self-hosted pools".into(),
1065        },
1066    }]
1067}
1068
1069/// Rule: ADO service connection with broad/unknown scope and no OIDC federation,
1070/// reachable from a PR-triggered job.
1071///
1072/// Static credentials backing broad-scope service connections can carry
1073/// subscription-wide Azure RBAC. When a PR-triggered step has `HasAccessTo` one of
1074/// these, PR-author-controlled code can move laterally into the Azure tenant.
1075pub fn service_connection_scope_mismatch(graph: &AuthorityGraph) -> Vec<Finding> {
1076    let trigger = graph
1077        .metadata
1078        .get(META_TRIGGER)
1079        .map(|s| s.as_str())
1080        .unwrap_or("");
1081    if trigger != "pull_request_target" && trigger != "pr" {
1082        return Vec::new();
1083    }
1084
1085    let mut findings = Vec::new();
1086
1087    for step in graph.nodes_of_kind(NodeKind::Step) {
1088        let broad_scs: Vec<&_> = graph
1089            .edges_from(step.id)
1090            .filter(|e| e.kind == EdgeKind::HasAccessTo)
1091            .filter_map(|e| graph.node(e.to))
1092            .filter(|n| {
1093                n.kind == NodeKind::Identity
1094                    && n.metadata
1095                        .get(META_SERVICE_CONNECTION)
1096                        .map(|v| v == "true")
1097                        .unwrap_or(false)
1098                    && n.metadata
1099                        .get(META_OIDC)
1100                        .map(|v| v != "true")
1101                        .unwrap_or(true) // not OIDC-federated
1102                    && matches!(
1103                        n.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
1104                        Some("broad") | Some("Broad") | None // unknown scope is also a risk
1105                    )
1106            })
1107            .collect();
1108
1109        for sc in &broad_scs {
1110            findings.push(Finding {
1111                severity: Severity::High,
1112                category: FindingCategory::ServiceConnectionScopeMismatch,
1113                path: None,
1114                nodes_involved: vec![step.id, sc.id],
1115                message: format!(
1116                    "PR-triggered step '{}' accesses service connection '{}' with broad/unknown scope and no OIDC federation — static credential may have subscription-wide Azure RBAC",
1117                    step.name, sc.name
1118                ),
1119                recommendation: Recommendation::CellosRemediation {
1120                    reason: "Broad-scope service connection reachable from PR code — CellOS egress isolation limits lateral movement even when connection cannot be immediately rescoped".into(),
1121                    spec_hint: "cellos run --network deny-all --policy requireEgressDeclared".into(),
1122                },
1123            });
1124        }
1125    }
1126
1127    findings
1128}
1129
1130/// Run all rules against a graph.
1131pub fn run_all_rules(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
1132    let mut findings = Vec::new();
1133    // MVP rules
1134    findings.extend(authority_propagation(graph, max_hops));
1135    findings.extend(over_privileged_identity(graph));
1136    findings.extend(unpinned_action(graph));
1137    findings.extend(untrusted_with_authority(graph));
1138    findings.extend(artifact_boundary_crossing(graph));
1139    // Stretch rules
1140    findings.extend(long_lived_credential(graph));
1141    findings.extend(floating_image(graph));
1142    findings.extend(persisted_credential(graph));
1143    findings.extend(trigger_context_mismatch(graph));
1144    findings.extend(cross_workflow_authority_chain(graph));
1145    findings.extend(authority_cycle(graph));
1146    findings.extend(uplift_without_attestation(graph));
1147    findings.extend(self_mutating_pipeline(graph));
1148    findings.extend(checkout_self_pr_exposure(graph));
1149    findings.extend(variable_group_in_pr_job(graph));
1150    findings.extend(self_hosted_pool_pr_hijack(graph));
1151    findings.extend(service_connection_scope_mismatch(graph));
1152
1153    apply_confidence_cap(graph, &mut findings);
1154
1155    findings.sort_by_key(|f| f.severity);
1156
1157    findings
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163    use crate::graph::*;
1164
1165    fn source(file: &str) -> PipelineSource {
1166        PipelineSource {
1167            file: file.into(),
1168            repo: None,
1169            git_ref: None,
1170        }
1171    }
1172
1173    #[test]
1174    fn unpinned_third_party_action_flagged() {
1175        let mut g = AuthorityGraph::new(source("ci.yml"));
1176        g.add_node(
1177            NodeKind::Image,
1178            "actions/checkout@v4",
1179            TrustZone::ThirdParty,
1180        );
1181
1182        let findings = unpinned_action(&g);
1183        assert_eq!(findings.len(), 1);
1184        assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
1185    }
1186
1187    #[test]
1188    fn pinned_action_not_flagged() {
1189        let mut g = AuthorityGraph::new(source("ci.yml"));
1190        g.add_node(
1191            NodeKind::Image,
1192            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
1193            TrustZone::ThirdParty,
1194        );
1195
1196        let findings = unpinned_action(&g);
1197        assert!(findings.is_empty());
1198    }
1199
1200    #[test]
1201    fn untrusted_step_with_secret_is_critical() {
1202        let mut g = AuthorityGraph::new(source("ci.yml"));
1203        let step = g.add_node(NodeKind::Step, "evil-action", TrustZone::Untrusted);
1204        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1205        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1206
1207        let findings = untrusted_with_authority(&g);
1208        assert_eq!(findings.len(), 1);
1209        assert_eq!(findings[0].severity, Severity::Critical);
1210    }
1211
1212    #[test]
1213    fn implicit_identity_downgrades_to_info() {
1214        let mut g = AuthorityGraph::new(source("ci.yml"));
1215        let step = g.add_node(NodeKind::Step, "AzureCLI@2", TrustZone::Untrusted);
1216        let mut meta = std::collections::HashMap::new();
1217        meta.insert(META_IMPLICIT.into(), "true".into());
1218        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1219        let token = g.add_node_with_metadata(
1220            NodeKind::Identity,
1221            "System.AccessToken",
1222            TrustZone::FirstParty,
1223            meta,
1224        );
1225        g.add_edge(step, token, EdgeKind::HasAccessTo);
1226
1227        let findings = untrusted_with_authority(&g);
1228        assert_eq!(findings.len(), 1);
1229        assert_eq!(
1230            findings[0].severity,
1231            Severity::Info,
1232            "implicit token must be Info not Critical"
1233        );
1234        assert!(findings[0].message.contains("platform-injected"));
1235    }
1236
1237    #[test]
1238    fn explicit_secret_remains_critical_despite_implicit_token() {
1239        let mut g = AuthorityGraph::new(source("ci.yml"));
1240        let step = g.add_node(NodeKind::Step, "AzureCLI@2", TrustZone::Untrusted);
1241        // implicit token → Info
1242        let mut meta = std::collections::HashMap::new();
1243        meta.insert(META_IMPLICIT.into(), "true".into());
1244        let token = g.add_node_with_metadata(
1245            NodeKind::Identity,
1246            "System.AccessToken",
1247            TrustZone::FirstParty,
1248            meta,
1249        );
1250        // explicit secret → Critical
1251        let secret = g.add_node(NodeKind::Secret, "ARM_CLIENT_SECRET", TrustZone::FirstParty);
1252        g.add_edge(step, token, EdgeKind::HasAccessTo);
1253        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1254
1255        let findings = untrusted_with_authority(&g);
1256        assert_eq!(findings.len(), 2);
1257        let info = findings
1258            .iter()
1259            .find(|f| f.severity == Severity::Info)
1260            .unwrap();
1261        let crit = findings
1262            .iter()
1263            .find(|f| f.severity == Severity::Critical)
1264            .unwrap();
1265        assert!(info.message.contains("platform-injected"));
1266        assert!(crit.message.contains("ARM_CLIENT_SECRET"));
1267    }
1268
1269    #[test]
1270    fn artifact_crossing_detected() {
1271        let mut g = AuthorityGraph::new(source("ci.yml"));
1272        let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
1273        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1274        let artifact = g.add_node(NodeKind::Artifact, "dist.zip", TrustZone::FirstParty);
1275        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
1276
1277        g.add_edge(build, secret, EdgeKind::HasAccessTo);
1278        g.add_edge(build, artifact, EdgeKind::Produces);
1279        g.add_edge(artifact, deploy, EdgeKind::Consumes);
1280
1281        let findings = artifact_boundary_crossing(&g);
1282        assert_eq!(findings.len(), 1);
1283        assert_eq!(
1284            findings[0].category,
1285            FindingCategory::ArtifactBoundaryCrossing
1286        );
1287    }
1288
1289    #[test]
1290    fn propagation_to_sha_pinned_is_high_not_critical() {
1291        let mut g = AuthorityGraph::new(source("ci.yml"));
1292        let mut meta = std::collections::HashMap::new();
1293        meta.insert(
1294            "digest".into(),
1295            "a5ac7e51b41094c92402da3b24376905380afc29".into(),
1296        );
1297        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1298        let step = g.add_node(NodeKind::Step, "checkout", TrustZone::ThirdParty);
1299        let image = g.add_node_with_metadata(
1300            NodeKind::Image,
1301            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
1302            TrustZone::ThirdParty,
1303            meta,
1304        );
1305
1306        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1307        g.add_edge(step, image, EdgeKind::UsesImage);
1308
1309        let findings = authority_propagation(&g, 4);
1310        // Should find propagation to the SHA-pinned image
1311        let image_findings: Vec<_> = findings
1312            .iter()
1313            .filter(|f| f.nodes_involved.contains(&image))
1314            .collect();
1315        assert!(!image_findings.is_empty());
1316        // SHA-pinned targets get High, not Critical
1317        assert_eq!(image_findings[0].severity, Severity::High);
1318    }
1319
1320    #[test]
1321    fn propagation_to_untrusted_is_critical() {
1322        let mut g = AuthorityGraph::new(source("ci.yml"));
1323        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1324        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
1325        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
1326
1327        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1328        g.add_edge(step, image, EdgeKind::UsesImage);
1329
1330        let findings = authority_propagation(&g, 4);
1331        let image_findings: Vec<_> = findings
1332            .iter()
1333            .filter(|f| f.nodes_involved.contains(&image))
1334            .collect();
1335        assert!(!image_findings.is_empty());
1336        assert_eq!(image_findings[0].severity, Severity::Critical);
1337    }
1338
1339    #[test]
1340    fn long_lived_credential_detected() {
1341        let mut g = AuthorityGraph::new(source("ci.yml"));
1342        g.add_node(NodeKind::Secret, "AWS_ACCESS_KEY_ID", TrustZone::FirstParty);
1343        g.add_node(NodeKind::Secret, "NPM_TOKEN", TrustZone::FirstParty);
1344        g.add_node(NodeKind::Secret, "DEPLOY_API_KEY", TrustZone::FirstParty);
1345        // Non-matching names
1346        g.add_node(NodeKind::Secret, "CACHE_TTL", TrustZone::FirstParty);
1347
1348        let findings = long_lived_credential(&g);
1349        assert_eq!(findings.len(), 2); // AWS_ACCESS_KEY_ID + DEPLOY_API_KEY
1350        assert!(findings
1351            .iter()
1352            .all(|f| f.category == FindingCategory::LongLivedCredential));
1353    }
1354
1355    #[test]
1356    fn duplicate_unpinned_actions_deduplicated() {
1357        let mut g = AuthorityGraph::new(source("ci.yml"));
1358        // Same action used in two jobs — two Image nodes, same name
1359        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
1360        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
1361        g.add_node(
1362            NodeKind::Image,
1363            "actions/setup-node@v3",
1364            TrustZone::Untrusted,
1365        );
1366
1367        let findings = unpinned_action(&g);
1368        // Should get 2 findings (checkout + setup-node), not 3
1369        assert_eq!(findings.len(), 2);
1370    }
1371
1372    #[test]
1373    fn broad_identity_scope_flagged_as_high() {
1374        let mut g = AuthorityGraph::new(source("ci.yml"));
1375        let mut meta = std::collections::HashMap::new();
1376        meta.insert(META_PERMISSIONS.into(), "write-all".into());
1377        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1378        let identity = g.add_node_with_metadata(
1379            NodeKind::Identity,
1380            "GITHUB_TOKEN",
1381            TrustZone::FirstParty,
1382            meta,
1383        );
1384        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1385        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1386
1387        let findings = over_privileged_identity(&g);
1388        assert_eq!(findings.len(), 1);
1389        assert_eq!(findings[0].severity, Severity::High);
1390        assert!(findings[0].message.contains("broad"));
1391    }
1392
1393    #[test]
1394    fn unknown_identity_scope_flagged_as_medium() {
1395        let mut g = AuthorityGraph::new(source("ci.yml"));
1396        let mut meta = std::collections::HashMap::new();
1397        meta.insert(META_PERMISSIONS.into(), "custom-scope".into());
1398        meta.insert(META_IDENTITY_SCOPE.into(), "unknown".into());
1399        let identity = g.add_node_with_metadata(
1400            NodeKind::Identity,
1401            "GITHUB_TOKEN",
1402            TrustZone::FirstParty,
1403            meta,
1404        );
1405        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1406        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1407
1408        let findings = over_privileged_identity(&g);
1409        assert_eq!(findings.len(), 1);
1410        assert_eq!(findings[0].severity, Severity::Medium);
1411        assert!(findings[0].message.contains("unknown"));
1412    }
1413
1414    #[test]
1415    fn floating_image_unpinned_container_flagged() {
1416        let mut g = AuthorityGraph::new(source("ci.yml"));
1417        let mut meta = std::collections::HashMap::new();
1418        meta.insert(META_CONTAINER.into(), "true".into());
1419        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
1420
1421        let findings = floating_image(&g);
1422        assert_eq!(findings.len(), 1);
1423        assert_eq!(findings[0].category, FindingCategory::FloatingImage);
1424        assert_eq!(findings[0].severity, Severity::Medium);
1425    }
1426
1427    #[test]
1428    fn partial_graph_caps_critical_findings_at_high() {
1429        let mut g = AuthorityGraph::new(source("ci.yml"));
1430        g.mark_partial("matrix strategy hides some authority paths");
1431
1432        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1433        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
1434        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
1435
1436        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1437        g.add_edge(step, image, EdgeKind::UsesImage);
1438
1439        let findings = run_all_rules(&g, 4);
1440        assert!(findings
1441            .iter()
1442            .any(|f| f.category == FindingCategory::AuthorityPropagation));
1443        assert!(findings
1444            .iter()
1445            .any(|f| f.category == FindingCategory::UntrustedWithAuthority));
1446        assert!(findings.iter().all(|f| f.severity >= Severity::High));
1447        assert!(!findings.iter().any(|f| f.severity == Severity::Critical));
1448    }
1449
1450    #[test]
1451    fn complete_graph_keeps_critical_findings() {
1452        let mut g = AuthorityGraph::new(source("ci.yml"));
1453
1454        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1455        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
1456        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
1457
1458        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1459        g.add_edge(step, image, EdgeKind::UsesImage);
1460
1461        let findings = run_all_rules(&g, 4);
1462        assert!(findings.iter().any(|f| f.severity == Severity::Critical));
1463    }
1464
1465    #[test]
1466    fn floating_image_digest_pinned_container_not_flagged() {
1467        let mut g = AuthorityGraph::new(source("ci.yml"));
1468        let mut meta = std::collections::HashMap::new();
1469        meta.insert(META_CONTAINER.into(), "true".into());
1470        g.add_node_with_metadata(
1471            NodeKind::Image,
1472            "ubuntu@sha256:a5ac7e51b41094c92402da3b24376905380afc29a5ac7e51b41094c92402da3b",
1473            TrustZone::ThirdParty,
1474            meta,
1475        );
1476
1477        let findings = floating_image(&g);
1478        assert!(
1479            findings.is_empty(),
1480            "digest-pinned container should not be flagged"
1481        );
1482    }
1483
1484    #[test]
1485    fn unpinned_action_does_not_flag_container_images() {
1486        // Regression: container Image nodes are handled by floating_image, not unpinned_action.
1487        // The same node must not generate findings from both rules.
1488        let mut g = AuthorityGraph::new(source("ci.yml"));
1489        let mut meta = std::collections::HashMap::new();
1490        meta.insert(META_CONTAINER.into(), "true".into());
1491        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
1492
1493        let findings = unpinned_action(&g);
1494        assert!(
1495            findings.is_empty(),
1496            "unpinned_action must skip container images to avoid double-flagging"
1497        );
1498    }
1499
1500    #[test]
1501    fn floating_image_ignores_action_images() {
1502        let mut g = AuthorityGraph::new(source("ci.yml"));
1503        // Image node without META_CONTAINER — this is a step uses: action, not a container
1504        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
1505
1506        let findings = floating_image(&g);
1507        assert!(
1508            findings.is_empty(),
1509            "floating_image should not flag step actions"
1510        );
1511    }
1512
1513    #[test]
1514    fn persisted_credential_rule_fires_on_persists_to_edge() {
1515        let mut g = AuthorityGraph::new(source("ci.yml"));
1516        let token = g.add_node(
1517            NodeKind::Identity,
1518            "System.AccessToken",
1519            TrustZone::FirstParty,
1520        );
1521        let checkout = g.add_node(NodeKind::Step, "checkout", TrustZone::FirstParty);
1522        g.add_edge(checkout, token, EdgeKind::PersistsTo);
1523
1524        let findings = persisted_credential(&g);
1525        assert_eq!(findings.len(), 1);
1526        assert_eq!(findings[0].category, FindingCategory::PersistedCredential);
1527        assert_eq!(findings[0].severity, Severity::High);
1528        assert!(findings[0].message.contains("persistCredentials"));
1529    }
1530
1531    #[test]
1532    fn untrusted_with_cli_flag_exposed_secret_notes_log_exposure() {
1533        let mut g = AuthorityGraph::new(source("ci.yml"));
1534        let step = g.add_node(NodeKind::Step, "TerraformCLI@0", TrustZone::Untrusted);
1535        let mut meta = std::collections::HashMap::new();
1536        meta.insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
1537        let secret =
1538            g.add_node_with_metadata(NodeKind::Secret, "db_password", TrustZone::FirstParty, meta);
1539        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1540
1541        let findings = untrusted_with_authority(&g);
1542        assert_eq!(findings.len(), 1);
1543        assert!(
1544            findings[0].message.contains("-var flag"),
1545            "message should note -var flag log exposure"
1546        );
1547        assert!(matches!(
1548            findings[0].recommendation,
1549            Recommendation::Manual { .. }
1550        ));
1551    }
1552
1553    #[test]
1554    fn constrained_identity_scope_not_flagged() {
1555        let mut g = AuthorityGraph::new(source("ci.yml"));
1556        let mut meta = std::collections::HashMap::new();
1557        meta.insert(META_PERMISSIONS.into(), "{ contents: read }".into());
1558        meta.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
1559        let identity = g.add_node_with_metadata(
1560            NodeKind::Identity,
1561            "GITHUB_TOKEN",
1562            TrustZone::FirstParty,
1563            meta,
1564        );
1565        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1566        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1567
1568        let findings = over_privileged_identity(&g);
1569        assert!(
1570            findings.is_empty(),
1571            "constrained scope should not be flagged"
1572        );
1573    }
1574
1575    #[test]
1576    fn trigger_context_mismatch_fires_on_pull_request_target_with_secret() {
1577        let mut g = AuthorityGraph::new(source("ci.yml"));
1578        g.metadata
1579            .insert(META_TRIGGER.into(), "pull_request_target".into());
1580        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1581        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1582        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1583
1584        let findings = trigger_context_mismatch(&g);
1585        assert_eq!(findings.len(), 1);
1586        assert_eq!(findings[0].severity, Severity::Critical);
1587        assert_eq!(
1588            findings[0].category,
1589            FindingCategory::TriggerContextMismatch
1590        );
1591    }
1592
1593    #[test]
1594    fn trigger_context_mismatch_no_fire_without_trigger_metadata() {
1595        let mut g = AuthorityGraph::new(source("ci.yml"));
1596        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1597        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1598        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1599
1600        let findings = trigger_context_mismatch(&g);
1601        assert!(findings.is_empty(), "no trigger metadata → no finding");
1602    }
1603
1604    #[test]
1605    fn cross_workflow_authority_chain_detected() {
1606        let mut g = AuthorityGraph::new(source("ci.yml"));
1607        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1608        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1609        let external = g.add_node(
1610            NodeKind::Image,
1611            "evil/workflow.yml@main",
1612            TrustZone::Untrusted,
1613        );
1614        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1615        g.add_edge(step, external, EdgeKind::DelegatesTo);
1616
1617        let findings = cross_workflow_authority_chain(&g);
1618        assert_eq!(findings.len(), 1);
1619        assert_eq!(findings[0].severity, Severity::Critical);
1620        assert_eq!(
1621            findings[0].category,
1622            FindingCategory::CrossWorkflowAuthorityChain
1623        );
1624    }
1625
1626    #[test]
1627    fn cross_workflow_authority_chain_no_fire_if_local_delegation() {
1628        let mut g = AuthorityGraph::new(source("ci.yml"));
1629        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1630        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1631        let local = g.add_node(NodeKind::Image, "./local-action", TrustZone::FirstParty);
1632        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1633        g.add_edge(step, local, EdgeKind::DelegatesTo);
1634
1635        let findings = cross_workflow_authority_chain(&g);
1636        assert!(
1637            findings.is_empty(),
1638            "FirstParty delegation should not be flagged"
1639        );
1640    }
1641
1642    #[test]
1643    fn authority_cycle_detected() {
1644        let mut g = AuthorityGraph::new(source("ci.yml"));
1645        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
1646        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
1647        g.add_edge(a, b, EdgeKind::DelegatesTo);
1648        g.add_edge(b, a, EdgeKind::DelegatesTo);
1649
1650        let findings = authority_cycle(&g);
1651        assert_eq!(findings.len(), 1);
1652        assert_eq!(findings[0].category, FindingCategory::AuthorityCycle);
1653        assert_eq!(findings[0].severity, Severity::High);
1654    }
1655
1656    #[test]
1657    fn authority_cycle_no_fire_for_acyclic_graph() {
1658        let mut g = AuthorityGraph::new(source("ci.yml"));
1659        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
1660        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
1661        let c = g.add_node(NodeKind::Step, "C", TrustZone::FirstParty);
1662        g.add_edge(a, b, EdgeKind::DelegatesTo);
1663        g.add_edge(b, c, EdgeKind::DelegatesTo);
1664
1665        let findings = authority_cycle(&g);
1666        assert!(findings.is_empty(), "acyclic graph must not fire");
1667    }
1668
1669    #[test]
1670    fn uplift_without_attestation_fires_when_oidc_no_attests() {
1671        let mut g = AuthorityGraph::new(source("ci.yml"));
1672        let mut meta = std::collections::HashMap::new();
1673        meta.insert(META_OIDC.into(), "true".into());
1674        let identity = g.add_node_with_metadata(
1675            NodeKind::Identity,
1676            "AWS/deploy-role",
1677            TrustZone::FirstParty,
1678            meta,
1679        );
1680        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1681        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1682
1683        let findings = uplift_without_attestation(&g);
1684        assert_eq!(findings.len(), 1);
1685        assert_eq!(findings[0].severity, Severity::Info);
1686        assert_eq!(
1687            findings[0].category,
1688            FindingCategory::UpliftWithoutAttestation
1689        );
1690    }
1691
1692    #[test]
1693    fn uplift_without_attestation_no_fire_when_attests_present() {
1694        let mut g = AuthorityGraph::new(source("ci.yml"));
1695        let mut id_meta = std::collections::HashMap::new();
1696        id_meta.insert(META_OIDC.into(), "true".into());
1697        let identity = g.add_node_with_metadata(
1698            NodeKind::Identity,
1699            "AWS/deploy-role",
1700            TrustZone::FirstParty,
1701            id_meta,
1702        );
1703        let mut step_meta = std::collections::HashMap::new();
1704        step_meta.insert(META_ATTESTS.into(), "true".into());
1705        let attest_step =
1706            g.add_node_with_metadata(NodeKind::Step, "attest", TrustZone::FirstParty, step_meta);
1707        let build_step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1708        g.add_edge(build_step, identity, EdgeKind::HasAccessTo);
1709        // Touch attest_step so the variable is used (avoid unused warning)
1710        let _ = attest_step;
1711
1712        let findings = uplift_without_attestation(&g);
1713        assert!(findings.is_empty(), "attestation present → no finding");
1714    }
1715
1716    #[test]
1717    fn uplift_without_attestation_no_fire_without_oidc() {
1718        let mut g = AuthorityGraph::new(source("ci.yml"));
1719        let mut meta = std::collections::HashMap::new();
1720        meta.insert(META_PERMISSIONS.into(), "write-all".into());
1721        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1722        // Note: no META_OIDC
1723        let identity = g.add_node_with_metadata(
1724            NodeKind::Identity,
1725            "GITHUB_TOKEN",
1726            TrustZone::FirstParty,
1727            meta,
1728        );
1729        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1730        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1731
1732        let findings = uplift_without_attestation(&g);
1733        assert!(
1734            findings.is_empty(),
1735            "broad identity without OIDC must not fire"
1736        );
1737    }
1738
1739    #[test]
1740    fn self_mutating_pipeline_untrusted_is_critical() {
1741        let mut g = AuthorityGraph::new(source("ci.yml"));
1742        let mut meta = std::collections::HashMap::new();
1743        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
1744        g.add_node_with_metadata(NodeKind::Step, "fork-step", TrustZone::Untrusted, meta);
1745
1746        let findings = self_mutating_pipeline(&g);
1747        assert_eq!(findings.len(), 1);
1748        assert_eq!(findings[0].severity, Severity::Critical);
1749        assert_eq!(findings[0].category, FindingCategory::SelfMutatingPipeline);
1750    }
1751
1752    #[test]
1753    fn self_mutating_pipeline_privileged_step_is_high() {
1754        let mut g = AuthorityGraph::new(source("ci.yml"));
1755        let mut meta = std::collections::HashMap::new();
1756        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
1757        let step = g.add_node_with_metadata(NodeKind::Step, "build", TrustZone::FirstParty, meta);
1758        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1759        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1760
1761        let findings = self_mutating_pipeline(&g);
1762        assert_eq!(findings.len(), 1);
1763        assert_eq!(findings[0].severity, Severity::High);
1764    }
1765
1766    #[test]
1767    fn trigger_context_mismatch_fires_on_ado_pr_with_secret_as_high() {
1768        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1769        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1770        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1771        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1772        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1773
1774        let findings = trigger_context_mismatch(&g);
1775        assert_eq!(findings.len(), 1);
1776        assert_eq!(findings[0].severity, Severity::High);
1777        assert_eq!(
1778            findings[0].category,
1779            FindingCategory::TriggerContextMismatch
1780        );
1781    }
1782
1783    #[test]
1784    fn cross_workflow_authority_chain_third_party_is_high() {
1785        let mut g = AuthorityGraph::new(source("ci.yml"));
1786        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1787        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1788        // ThirdParty target (SHA-pinned external workflow)
1789        let external = g.add_node(
1790            NodeKind::Image,
1791            "org/repo/.github/workflows/deploy.yml@a5ac7e51b41094c92402da3b24376905380afc29",
1792            TrustZone::ThirdParty,
1793        );
1794        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1795        g.add_edge(step, external, EdgeKind::DelegatesTo);
1796
1797        let findings = cross_workflow_authority_chain(&g);
1798        assert_eq!(findings.len(), 1);
1799        assert_eq!(
1800            findings[0].severity,
1801            Severity::High,
1802            "ThirdParty delegation target should be High (Critical reserved for Untrusted)"
1803        );
1804        assert_eq!(
1805            findings[0].category,
1806            FindingCategory::CrossWorkflowAuthorityChain
1807        );
1808    }
1809
1810    #[test]
1811    fn self_mutating_pipeline_first_party_no_authority_is_medium() {
1812        let mut g = AuthorityGraph::new(source("ci.yml"));
1813        let mut meta = std::collections::HashMap::new();
1814        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
1815        // FirstParty step writes the gate but holds no secret/identity access.
1816        g.add_node_with_metadata(NodeKind::Step, "set-version", TrustZone::FirstParty, meta);
1817
1818        let findings = self_mutating_pipeline(&g);
1819        assert_eq!(findings.len(), 1);
1820        assert_eq!(findings[0].severity, Severity::Medium);
1821        assert_eq!(findings[0].category, FindingCategory::SelfMutatingPipeline);
1822    }
1823
1824    #[test]
1825    fn authority_cycle_3node_cycle_includes_all_members() {
1826        // A → B → C → A should produce one finding whose nodes_involved
1827        // contains all three node IDs, not just the back-edge endpoints.
1828        let mut g = AuthorityGraph::new(source("test.yml"));
1829        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
1830        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
1831        let c = g.add_node(NodeKind::Step, "C", TrustZone::FirstParty);
1832        g.add_edge(a, b, EdgeKind::DelegatesTo);
1833        g.add_edge(b, c, EdgeKind::DelegatesTo);
1834        g.add_edge(c, a, EdgeKind::DelegatesTo);
1835
1836        let findings = authority_cycle(&g);
1837        assert_eq!(findings.len(), 1);
1838        assert_eq!(findings[0].category, FindingCategory::AuthorityCycle);
1839        assert!(
1840            findings[0].nodes_involved.contains(&a),
1841            "A must be in nodes_involved"
1842        );
1843        assert!(
1844            findings[0].nodes_involved.contains(&b),
1845            "B must be in nodes_involved — middle of A→B→C→A cycle"
1846        );
1847        assert!(
1848            findings[0].nodes_involved.contains(&c),
1849            "C must be in nodes_involved"
1850        );
1851    }
1852
1853    #[test]
1854    fn variable_group_in_pr_job_fires_on_pr_trigger_with_var_group() {
1855        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1856        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1857        let mut secret_meta = std::collections::HashMap::new();
1858        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
1859        let secret = g.add_node_with_metadata(
1860            NodeKind::Secret,
1861            "prod-deploy-secrets",
1862            TrustZone::FirstParty,
1863            secret_meta,
1864        );
1865        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1866        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1867
1868        let findings = variable_group_in_pr_job(&g);
1869        assert_eq!(findings.len(), 1);
1870        assert_eq!(findings[0].severity, Severity::Critical);
1871        assert_eq!(findings[0].category, FindingCategory::VariableGroupInPrJob);
1872        assert!(findings[0].message.contains("prod-deploy-secrets"));
1873    }
1874
1875    #[test]
1876    fn variable_group_in_pr_job_no_fire_without_pr_trigger() {
1877        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1878        // No trigger metadata — should not fire
1879        let mut secret_meta = std::collections::HashMap::new();
1880        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
1881        let secret = g.add_node_with_metadata(
1882            NodeKind::Secret,
1883            "prod-deploy-secrets",
1884            TrustZone::FirstParty,
1885            secret_meta,
1886        );
1887        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1888        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1889
1890        let findings = variable_group_in_pr_job(&g);
1891        assert!(
1892            findings.is_empty(),
1893            "no PR trigger → variable_group_in_pr_job must not fire"
1894        );
1895    }
1896
1897    #[test]
1898    fn self_hosted_pool_pr_hijack_fires_when_all_three_factors_present() {
1899        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1900        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1901
1902        let mut pool_meta = std::collections::HashMap::new();
1903        pool_meta.insert(META_SELF_HOSTED.into(), "true".into());
1904        g.add_node_with_metadata(
1905            NodeKind::Image,
1906            "self-hosted-pool",
1907            TrustZone::FirstParty,
1908            pool_meta,
1909        );
1910
1911        let mut step_meta = std::collections::HashMap::new();
1912        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
1913        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
1914
1915        let findings = self_hosted_pool_pr_hijack(&g);
1916        assert_eq!(findings.len(), 1);
1917        assert_eq!(findings[0].severity, Severity::Critical);
1918        assert_eq!(
1919            findings[0].category,
1920            FindingCategory::SelfHostedPoolPrHijack
1921        );
1922        assert!(findings[0].message.contains("self-hosted"));
1923    }
1924
1925    #[test]
1926    fn self_hosted_pool_pr_hijack_no_fire_without_pr_trigger() {
1927        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1928        // No trigger metadata
1929
1930        let mut pool_meta = std::collections::HashMap::new();
1931        pool_meta.insert(META_SELF_HOSTED.into(), "true".into());
1932        g.add_node_with_metadata(
1933            NodeKind::Image,
1934            "self-hosted-pool",
1935            TrustZone::FirstParty,
1936            pool_meta,
1937        );
1938
1939        let mut step_meta = std::collections::HashMap::new();
1940        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
1941        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
1942
1943        let findings = self_hosted_pool_pr_hijack(&g);
1944        assert!(
1945            findings.is_empty(),
1946            "no PR trigger → self_hosted_pool_pr_hijack must not fire"
1947        );
1948    }
1949
1950    #[test]
1951    fn service_connection_scope_mismatch_fires_on_pr_broad_non_oidc() {
1952        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1953        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1954
1955        let mut sc_meta = std::collections::HashMap::new();
1956        sc_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
1957        sc_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1958        // No META_OIDC → treated as not OIDC-federated
1959        let sc = g.add_node_with_metadata(
1960            NodeKind::Identity,
1961            "prod-azure-sc",
1962            TrustZone::FirstParty,
1963            sc_meta,
1964        );
1965        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1966        g.add_edge(step, sc, EdgeKind::HasAccessTo);
1967
1968        let findings = service_connection_scope_mismatch(&g);
1969        assert_eq!(findings.len(), 1);
1970        assert_eq!(findings[0].severity, Severity::High);
1971        assert_eq!(
1972            findings[0].category,
1973            FindingCategory::ServiceConnectionScopeMismatch
1974        );
1975        assert!(findings[0].message.contains("prod-azure-sc"));
1976    }
1977
1978    #[test]
1979    fn service_connection_scope_mismatch_no_fire_without_pr_trigger() {
1980        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1981        // No trigger metadata
1982        let mut sc_meta = std::collections::HashMap::new();
1983        sc_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
1984        sc_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1985        let sc = g.add_node_with_metadata(
1986            NodeKind::Identity,
1987            "prod-azure-sc",
1988            TrustZone::FirstParty,
1989            sc_meta,
1990        );
1991        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1992        g.add_edge(step, sc, EdgeKind::HasAccessTo);
1993
1994        let findings = service_connection_scope_mismatch(&g);
1995        assert!(
1996            findings.is_empty(),
1997            "no PR trigger → service_connection_scope_mismatch must not fire"
1998        );
1999    }
2000
2001    #[test]
2002    fn checkout_self_pr_exposure_fires_on_pr_trigger() {
2003        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
2004        g.metadata.insert(META_TRIGGER.into(), "pr".into());
2005        let mut step_meta = std::collections::HashMap::new();
2006        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
2007        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
2008
2009        let findings = checkout_self_pr_exposure(&g);
2010        assert_eq!(findings.len(), 1);
2011        assert_eq!(
2012            findings[0].category,
2013            FindingCategory::CheckoutSelfPrExposure
2014        );
2015        assert_eq!(findings[0].severity, Severity::High);
2016    }
2017
2018    #[test]
2019    fn checkout_self_pr_exposure_no_fire_without_pr_trigger() {
2020        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
2021        // No META_TRIGGER set
2022        let mut step_meta = std::collections::HashMap::new();
2023        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
2024        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
2025
2026        let findings = checkout_self_pr_exposure(&g);
2027        assert!(
2028            findings.is_empty(),
2029            "no PR trigger → checkout_self_pr_exposure must not fire"
2030        );
2031    }
2032
2033    #[test]
2034    fn variable_group_in_pr_job_uses_cellos_remediation() {
2035        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
2036        g.metadata.insert(META_TRIGGER.into(), "pr".into());
2037
2038        let mut secret_meta = std::collections::HashMap::new();
2039        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
2040        let secret = g.add_node_with_metadata(
2041            NodeKind::Secret,
2042            "prod-secret",
2043            TrustZone::FirstParty,
2044            secret_meta,
2045        );
2046        let step = g.add_node(NodeKind::Step, "deploy step", TrustZone::Untrusted);
2047        g.add_edge(step, secret, EdgeKind::HasAccessTo);
2048
2049        let findings = variable_group_in_pr_job(&g);
2050        assert!(!findings.is_empty());
2051        assert!(
2052            matches!(
2053                findings[0].recommendation,
2054                Recommendation::CellosRemediation { .. }
2055            ),
2056            "variable_group_in_pr_job must recommend CellosRemediation"
2057        );
2058    }
2059
2060    #[test]
2061    fn service_connection_scope_mismatch_uses_cellos_remediation() {
2062        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
2063        g.metadata.insert(META_TRIGGER.into(), "pr".into());
2064
2065        let mut id_meta = std::collections::HashMap::new();
2066        id_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
2067        id_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
2068        // No META_OIDC → treated as not OIDC-federated
2069        let identity = g.add_node_with_metadata(
2070            NodeKind::Identity,
2071            "sub-conn",
2072            TrustZone::FirstParty,
2073            id_meta,
2074        );
2075        let step = g.add_node(NodeKind::Step, "azure deploy", TrustZone::Untrusted);
2076        g.add_edge(step, identity, EdgeKind::HasAccessTo);
2077
2078        let findings = service_connection_scope_mismatch(&g);
2079        assert!(!findings.is_empty());
2080        assert!(
2081            matches!(
2082                findings[0].recommendation,
2083                Recommendation::CellosRemediation { .. }
2084            ),
2085            "service_connection_scope_mismatch must recommend CellosRemediation"
2086        );
2087    }
2088}