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: ADO variable group consumed by a PR-triggered job.
890///
891/// Variable groups hold secrets scoped to pipelines. When a PR-triggered job has
892/// `HasAccessTo` a Secret/Identity carrying `META_VARIABLE_GROUP = "true"`, those
893/// secrets cross into an untrusted-contributor execution context.
894pub fn variable_group_in_pr_job(graph: &AuthorityGraph) -> Vec<Finding> {
895    // Only fires when the pipeline has a PR trigger
896    let trigger = graph
897        .metadata
898        .get(META_TRIGGER)
899        .map(|s| s.as_str())
900        .unwrap_or("");
901    if trigger != "pull_request_target" && trigger != "pr" {
902        return Vec::new();
903    }
904
905    let mut findings = Vec::new();
906
907    for step in graph.nodes_of_kind(NodeKind::Step) {
908        let accessed_var_groups: Vec<&_> = graph
909            .edges_from(step.id)
910            .filter(|e| e.kind == EdgeKind::HasAccessTo)
911            .filter_map(|e| graph.node(e.to))
912            .filter(|n| {
913                (n.kind == NodeKind::Secret || n.kind == NodeKind::Identity)
914                    && n.metadata
915                        .get(META_VARIABLE_GROUP)
916                        .map(|v| v == "true")
917                        .unwrap_or(false)
918            })
919            .collect();
920
921        if !accessed_var_groups.is_empty() {
922            let group_names: Vec<_> = accessed_var_groups
923                .iter()
924                .map(|n| n.name.as_str())
925                .collect();
926            findings.push(Finding {
927                severity: Severity::Critical,
928                category: FindingCategory::VariableGroupInPrJob,
929                path: None,
930                nodes_involved: std::iter::once(step.id)
931                    .chain(accessed_var_groups.iter().map(|n| n.id))
932                    .collect(),
933                message: format!(
934                    "PR-triggered step '{}' accesses variable group(s) [{}] — secrets cross into untrusted PR execution context",
935                    step.name,
936                    group_names.join(", ")
937                ),
938                recommendation: Recommendation::CellosRemediation {
939                    reason: format!(
940                        "PR-triggered step '{}' can exfiltrate variable group secrets via untrusted code",
941                        step.name
942                    ),
943                    spec_hint: "cellos run --network deny-all --policy requireEgressDeclared,requireRuntimeSecretDelivery".into(),
944                },
945            });
946        }
947    }
948
949    findings
950}
951
952/// Rule: self-hosted agent pool used by a PR-triggered pipeline that also checks out the repo.
953///
954/// All three factors present — self-hosted pool + PR trigger + `checkout:self` — combine to
955/// allow an attacker to land malicious git hooks on the shared runner via a PR. Those hooks
956/// persist across pipeline runs and execute with full pipeline authority.
957pub fn self_hosted_pool_pr_hijack(graph: &AuthorityGraph) -> Vec<Finding> {
958    let trigger = graph
959        .metadata
960        .get(META_TRIGGER)
961        .map(|s| s.as_str())
962        .unwrap_or("");
963    if trigger != "pull_request_target" && trigger != "pr" {
964        return Vec::new();
965    }
966
967    // Check if any Image node is self-hosted
968    let has_self_hosted_pool = graph.nodes_of_kind(NodeKind::Image).any(|n| {
969        n.metadata
970            .get(META_SELF_HOSTED)
971            .map(|v| v == "true")
972            .unwrap_or(false)
973    });
974
975    if !has_self_hosted_pool {
976        return Vec::new();
977    }
978
979    // Check if any Step does checkout:self
980    let checkout_steps: Vec<&_> = graph
981        .nodes_of_kind(NodeKind::Step)
982        .filter(|n| {
983            n.metadata
984                .get(META_CHECKOUT_SELF)
985                .map(|v| v == "true")
986                .unwrap_or(false)
987        })
988        .collect();
989
990    if checkout_steps.is_empty() {
991        return Vec::new();
992    }
993
994    // All three factors present: self-hosted + PR trigger + checkout:self.
995    // Collect self-hosted pool nodes for the finding.
996    let pool_nodes: Vec<&_> = graph
997        .nodes_of_kind(NodeKind::Image)
998        .filter(|n| {
999            n.metadata
1000                .get(META_SELF_HOSTED)
1001                .map(|v| v == "true")
1002                .unwrap_or(false)
1003        })
1004        .collect();
1005
1006    let mut nodes_involved: Vec<NodeId> = pool_nodes.iter().map(|n| n.id).collect();
1007    nodes_involved.extend(checkout_steps.iter().map(|n| n.id));
1008
1009    vec![Finding {
1010        severity: Severity::Critical,
1011        category: FindingCategory::SelfHostedPoolPrHijack,
1012        path: None,
1013        nodes_involved,
1014        message:
1015            "PR-triggered pipeline uses self-hosted agent pool with checkout:self — enables git hook injection persisting across pipeline runs on the shared runner"
1016                .into(),
1017        recommendation: Recommendation::Manual {
1018            action: "Run PR pipelines on Microsoft-hosted (ephemeral) agents, or disable checkout:self for PR-triggered jobs on self-hosted pools".into(),
1019        },
1020    }]
1021}
1022
1023/// Rule: ADO service connection with broad/unknown scope and no OIDC federation,
1024/// reachable from a PR-triggered job.
1025///
1026/// Static credentials backing broad-scope service connections can carry
1027/// subscription-wide Azure RBAC. When a PR-triggered step has `HasAccessTo` one of
1028/// these, PR-author-controlled code can move laterally into the Azure tenant.
1029pub fn service_connection_scope_mismatch(graph: &AuthorityGraph) -> Vec<Finding> {
1030    let trigger = graph
1031        .metadata
1032        .get(META_TRIGGER)
1033        .map(|s| s.as_str())
1034        .unwrap_or("");
1035    if trigger != "pull_request_target" && trigger != "pr" {
1036        return Vec::new();
1037    }
1038
1039    let mut findings = Vec::new();
1040
1041    for step in graph.nodes_of_kind(NodeKind::Step) {
1042        let broad_scs: Vec<&_> = graph
1043            .edges_from(step.id)
1044            .filter(|e| e.kind == EdgeKind::HasAccessTo)
1045            .filter_map(|e| graph.node(e.to))
1046            .filter(|n| {
1047                n.kind == NodeKind::Identity
1048                    && n.metadata
1049                        .get(META_SERVICE_CONNECTION)
1050                        .map(|v| v == "true")
1051                        .unwrap_or(false)
1052                    && n.metadata
1053                        .get(META_OIDC)
1054                        .map(|v| v != "true")
1055                        .unwrap_or(true) // not OIDC-federated
1056                    && matches!(
1057                        n.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
1058                        Some("broad") | Some("Broad") | None // unknown scope is also a risk
1059                    )
1060            })
1061            .collect();
1062
1063        for sc in &broad_scs {
1064            findings.push(Finding {
1065                severity: Severity::High,
1066                category: FindingCategory::ServiceConnectionScopeMismatch,
1067                path: None,
1068                nodes_involved: vec![step.id, sc.id],
1069                message: format!(
1070                    "PR-triggered step '{}' accesses service connection '{}' with broad/unknown scope and no OIDC federation — static credential may have subscription-wide Azure RBAC",
1071                    step.name, sc.name
1072                ),
1073                recommendation: Recommendation::CellosRemediation {
1074                    reason: "Broad-scope service connection reachable from PR code — CellOS egress isolation limits lateral movement even when connection cannot be immediately rescoped".into(),
1075                    spec_hint: "cellos run --network deny-all --policy requireEgressDeclared".into(),
1076                },
1077            });
1078        }
1079    }
1080
1081    findings
1082}
1083
1084/// Run all rules against a graph.
1085pub fn run_all_rules(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
1086    let mut findings = Vec::new();
1087    // MVP rules
1088    findings.extend(authority_propagation(graph, max_hops));
1089    findings.extend(over_privileged_identity(graph));
1090    findings.extend(unpinned_action(graph));
1091    findings.extend(untrusted_with_authority(graph));
1092    findings.extend(artifact_boundary_crossing(graph));
1093    // Stretch rules
1094    findings.extend(long_lived_credential(graph));
1095    findings.extend(floating_image(graph));
1096    findings.extend(persisted_credential(graph));
1097    findings.extend(trigger_context_mismatch(graph));
1098    findings.extend(cross_workflow_authority_chain(graph));
1099    findings.extend(authority_cycle(graph));
1100    findings.extend(uplift_without_attestation(graph));
1101    findings.extend(self_mutating_pipeline(graph));
1102    findings.extend(variable_group_in_pr_job(graph));
1103    findings.extend(self_hosted_pool_pr_hijack(graph));
1104    findings.extend(service_connection_scope_mismatch(graph));
1105
1106    apply_confidence_cap(graph, &mut findings);
1107
1108    findings.sort_by_key(|f| f.severity);
1109
1110    findings
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116    use crate::graph::*;
1117
1118    fn source(file: &str) -> PipelineSource {
1119        PipelineSource {
1120            file: file.into(),
1121            repo: None,
1122            git_ref: None,
1123        }
1124    }
1125
1126    #[test]
1127    fn unpinned_third_party_action_flagged() {
1128        let mut g = AuthorityGraph::new(source("ci.yml"));
1129        g.add_node(
1130            NodeKind::Image,
1131            "actions/checkout@v4",
1132            TrustZone::ThirdParty,
1133        );
1134
1135        let findings = unpinned_action(&g);
1136        assert_eq!(findings.len(), 1);
1137        assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
1138    }
1139
1140    #[test]
1141    fn pinned_action_not_flagged() {
1142        let mut g = AuthorityGraph::new(source("ci.yml"));
1143        g.add_node(
1144            NodeKind::Image,
1145            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
1146            TrustZone::ThirdParty,
1147        );
1148
1149        let findings = unpinned_action(&g);
1150        assert!(findings.is_empty());
1151    }
1152
1153    #[test]
1154    fn untrusted_step_with_secret_is_critical() {
1155        let mut g = AuthorityGraph::new(source("ci.yml"));
1156        let step = g.add_node(NodeKind::Step, "evil-action", TrustZone::Untrusted);
1157        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1158        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1159
1160        let findings = untrusted_with_authority(&g);
1161        assert_eq!(findings.len(), 1);
1162        assert_eq!(findings[0].severity, Severity::Critical);
1163    }
1164
1165    #[test]
1166    fn implicit_identity_downgrades_to_info() {
1167        let mut g = AuthorityGraph::new(source("ci.yml"));
1168        let step = g.add_node(NodeKind::Step, "AzureCLI@2", TrustZone::Untrusted);
1169        let mut meta = std::collections::HashMap::new();
1170        meta.insert(META_IMPLICIT.into(), "true".into());
1171        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1172        let token = g.add_node_with_metadata(
1173            NodeKind::Identity,
1174            "System.AccessToken",
1175            TrustZone::FirstParty,
1176            meta,
1177        );
1178        g.add_edge(step, token, EdgeKind::HasAccessTo);
1179
1180        let findings = untrusted_with_authority(&g);
1181        assert_eq!(findings.len(), 1);
1182        assert_eq!(
1183            findings[0].severity,
1184            Severity::Info,
1185            "implicit token must be Info not Critical"
1186        );
1187        assert!(findings[0].message.contains("platform-injected"));
1188    }
1189
1190    #[test]
1191    fn explicit_secret_remains_critical_despite_implicit_token() {
1192        let mut g = AuthorityGraph::new(source("ci.yml"));
1193        let step = g.add_node(NodeKind::Step, "AzureCLI@2", TrustZone::Untrusted);
1194        // implicit token → Info
1195        let mut meta = std::collections::HashMap::new();
1196        meta.insert(META_IMPLICIT.into(), "true".into());
1197        let token = g.add_node_with_metadata(
1198            NodeKind::Identity,
1199            "System.AccessToken",
1200            TrustZone::FirstParty,
1201            meta,
1202        );
1203        // explicit secret → Critical
1204        let secret = g.add_node(NodeKind::Secret, "ARM_CLIENT_SECRET", TrustZone::FirstParty);
1205        g.add_edge(step, token, EdgeKind::HasAccessTo);
1206        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1207
1208        let findings = untrusted_with_authority(&g);
1209        assert_eq!(findings.len(), 2);
1210        let info = findings
1211            .iter()
1212            .find(|f| f.severity == Severity::Info)
1213            .unwrap();
1214        let crit = findings
1215            .iter()
1216            .find(|f| f.severity == Severity::Critical)
1217            .unwrap();
1218        assert!(info.message.contains("platform-injected"));
1219        assert!(crit.message.contains("ARM_CLIENT_SECRET"));
1220    }
1221
1222    #[test]
1223    fn artifact_crossing_detected() {
1224        let mut g = AuthorityGraph::new(source("ci.yml"));
1225        let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
1226        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1227        let artifact = g.add_node(NodeKind::Artifact, "dist.zip", TrustZone::FirstParty);
1228        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
1229
1230        g.add_edge(build, secret, EdgeKind::HasAccessTo);
1231        g.add_edge(build, artifact, EdgeKind::Produces);
1232        g.add_edge(artifact, deploy, EdgeKind::Consumes);
1233
1234        let findings = artifact_boundary_crossing(&g);
1235        assert_eq!(findings.len(), 1);
1236        assert_eq!(
1237            findings[0].category,
1238            FindingCategory::ArtifactBoundaryCrossing
1239        );
1240    }
1241
1242    #[test]
1243    fn propagation_to_sha_pinned_is_high_not_critical() {
1244        let mut g = AuthorityGraph::new(source("ci.yml"));
1245        let mut meta = std::collections::HashMap::new();
1246        meta.insert(
1247            "digest".into(),
1248            "a5ac7e51b41094c92402da3b24376905380afc29".into(),
1249        );
1250        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1251        let step = g.add_node(NodeKind::Step, "checkout", TrustZone::ThirdParty);
1252        let image = g.add_node_with_metadata(
1253            NodeKind::Image,
1254            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
1255            TrustZone::ThirdParty,
1256            meta,
1257        );
1258
1259        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1260        g.add_edge(step, image, EdgeKind::UsesImage);
1261
1262        let findings = authority_propagation(&g, 4);
1263        // Should find propagation to the SHA-pinned image
1264        let image_findings: Vec<_> = findings
1265            .iter()
1266            .filter(|f| f.nodes_involved.contains(&image))
1267            .collect();
1268        assert!(!image_findings.is_empty());
1269        // SHA-pinned targets get High, not Critical
1270        assert_eq!(image_findings[0].severity, Severity::High);
1271    }
1272
1273    #[test]
1274    fn propagation_to_untrusted_is_critical() {
1275        let mut g = AuthorityGraph::new(source("ci.yml"));
1276        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1277        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
1278        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
1279
1280        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1281        g.add_edge(step, image, EdgeKind::UsesImage);
1282
1283        let findings = authority_propagation(&g, 4);
1284        let image_findings: Vec<_> = findings
1285            .iter()
1286            .filter(|f| f.nodes_involved.contains(&image))
1287            .collect();
1288        assert!(!image_findings.is_empty());
1289        assert_eq!(image_findings[0].severity, Severity::Critical);
1290    }
1291
1292    #[test]
1293    fn long_lived_credential_detected() {
1294        let mut g = AuthorityGraph::new(source("ci.yml"));
1295        g.add_node(NodeKind::Secret, "AWS_ACCESS_KEY_ID", TrustZone::FirstParty);
1296        g.add_node(NodeKind::Secret, "NPM_TOKEN", TrustZone::FirstParty);
1297        g.add_node(NodeKind::Secret, "DEPLOY_API_KEY", TrustZone::FirstParty);
1298        // Non-matching names
1299        g.add_node(NodeKind::Secret, "CACHE_TTL", TrustZone::FirstParty);
1300
1301        let findings = long_lived_credential(&g);
1302        assert_eq!(findings.len(), 2); // AWS_ACCESS_KEY_ID + DEPLOY_API_KEY
1303        assert!(findings
1304            .iter()
1305            .all(|f| f.category == FindingCategory::LongLivedCredential));
1306    }
1307
1308    #[test]
1309    fn duplicate_unpinned_actions_deduplicated() {
1310        let mut g = AuthorityGraph::new(source("ci.yml"));
1311        // Same action used in two jobs — two Image nodes, same name
1312        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
1313        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
1314        g.add_node(
1315            NodeKind::Image,
1316            "actions/setup-node@v3",
1317            TrustZone::Untrusted,
1318        );
1319
1320        let findings = unpinned_action(&g);
1321        // Should get 2 findings (checkout + setup-node), not 3
1322        assert_eq!(findings.len(), 2);
1323    }
1324
1325    #[test]
1326    fn broad_identity_scope_flagged_as_high() {
1327        let mut g = AuthorityGraph::new(source("ci.yml"));
1328        let mut meta = std::collections::HashMap::new();
1329        meta.insert(META_PERMISSIONS.into(), "write-all".into());
1330        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1331        let identity = g.add_node_with_metadata(
1332            NodeKind::Identity,
1333            "GITHUB_TOKEN",
1334            TrustZone::FirstParty,
1335            meta,
1336        );
1337        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1338        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1339
1340        let findings = over_privileged_identity(&g);
1341        assert_eq!(findings.len(), 1);
1342        assert_eq!(findings[0].severity, Severity::High);
1343        assert!(findings[0].message.contains("broad"));
1344    }
1345
1346    #[test]
1347    fn unknown_identity_scope_flagged_as_medium() {
1348        let mut g = AuthorityGraph::new(source("ci.yml"));
1349        let mut meta = std::collections::HashMap::new();
1350        meta.insert(META_PERMISSIONS.into(), "custom-scope".into());
1351        meta.insert(META_IDENTITY_SCOPE.into(), "unknown".into());
1352        let identity = g.add_node_with_metadata(
1353            NodeKind::Identity,
1354            "GITHUB_TOKEN",
1355            TrustZone::FirstParty,
1356            meta,
1357        );
1358        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1359        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1360
1361        let findings = over_privileged_identity(&g);
1362        assert_eq!(findings.len(), 1);
1363        assert_eq!(findings[0].severity, Severity::Medium);
1364        assert!(findings[0].message.contains("unknown"));
1365    }
1366
1367    #[test]
1368    fn floating_image_unpinned_container_flagged() {
1369        let mut g = AuthorityGraph::new(source("ci.yml"));
1370        let mut meta = std::collections::HashMap::new();
1371        meta.insert(META_CONTAINER.into(), "true".into());
1372        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
1373
1374        let findings = floating_image(&g);
1375        assert_eq!(findings.len(), 1);
1376        assert_eq!(findings[0].category, FindingCategory::FloatingImage);
1377        assert_eq!(findings[0].severity, Severity::Medium);
1378    }
1379
1380    #[test]
1381    fn partial_graph_caps_critical_findings_at_high() {
1382        let mut g = AuthorityGraph::new(source("ci.yml"));
1383        g.mark_partial("matrix strategy hides some authority paths");
1384
1385        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1386        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
1387        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
1388
1389        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1390        g.add_edge(step, image, EdgeKind::UsesImage);
1391
1392        let findings = run_all_rules(&g, 4);
1393        assert!(findings
1394            .iter()
1395            .any(|f| f.category == FindingCategory::AuthorityPropagation));
1396        assert!(findings
1397            .iter()
1398            .any(|f| f.category == FindingCategory::UntrustedWithAuthority));
1399        assert!(findings.iter().all(|f| f.severity >= Severity::High));
1400        assert!(!findings.iter().any(|f| f.severity == Severity::Critical));
1401    }
1402
1403    #[test]
1404    fn complete_graph_keeps_critical_findings() {
1405        let mut g = AuthorityGraph::new(source("ci.yml"));
1406
1407        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
1408        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
1409        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
1410
1411        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1412        g.add_edge(step, image, EdgeKind::UsesImage);
1413
1414        let findings = run_all_rules(&g, 4);
1415        assert!(findings.iter().any(|f| f.severity == Severity::Critical));
1416    }
1417
1418    #[test]
1419    fn floating_image_digest_pinned_container_not_flagged() {
1420        let mut g = AuthorityGraph::new(source("ci.yml"));
1421        let mut meta = std::collections::HashMap::new();
1422        meta.insert(META_CONTAINER.into(), "true".into());
1423        g.add_node_with_metadata(
1424            NodeKind::Image,
1425            "ubuntu@sha256:a5ac7e51b41094c92402da3b24376905380afc29a5ac7e51b41094c92402da3b",
1426            TrustZone::ThirdParty,
1427            meta,
1428        );
1429
1430        let findings = floating_image(&g);
1431        assert!(
1432            findings.is_empty(),
1433            "digest-pinned container should not be flagged"
1434        );
1435    }
1436
1437    #[test]
1438    fn unpinned_action_does_not_flag_container_images() {
1439        // Regression: container Image nodes are handled by floating_image, not unpinned_action.
1440        // The same node must not generate findings from both rules.
1441        let mut g = AuthorityGraph::new(source("ci.yml"));
1442        let mut meta = std::collections::HashMap::new();
1443        meta.insert(META_CONTAINER.into(), "true".into());
1444        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
1445
1446        let findings = unpinned_action(&g);
1447        assert!(
1448            findings.is_empty(),
1449            "unpinned_action must skip container images to avoid double-flagging"
1450        );
1451    }
1452
1453    #[test]
1454    fn floating_image_ignores_action_images() {
1455        let mut g = AuthorityGraph::new(source("ci.yml"));
1456        // Image node without META_CONTAINER — this is a step uses: action, not a container
1457        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
1458
1459        let findings = floating_image(&g);
1460        assert!(
1461            findings.is_empty(),
1462            "floating_image should not flag step actions"
1463        );
1464    }
1465
1466    #[test]
1467    fn persisted_credential_rule_fires_on_persists_to_edge() {
1468        let mut g = AuthorityGraph::new(source("ci.yml"));
1469        let token = g.add_node(
1470            NodeKind::Identity,
1471            "System.AccessToken",
1472            TrustZone::FirstParty,
1473        );
1474        let checkout = g.add_node(NodeKind::Step, "checkout", TrustZone::FirstParty);
1475        g.add_edge(checkout, token, EdgeKind::PersistsTo);
1476
1477        let findings = persisted_credential(&g);
1478        assert_eq!(findings.len(), 1);
1479        assert_eq!(findings[0].category, FindingCategory::PersistedCredential);
1480        assert_eq!(findings[0].severity, Severity::High);
1481        assert!(findings[0].message.contains("persistCredentials"));
1482    }
1483
1484    #[test]
1485    fn untrusted_with_cli_flag_exposed_secret_notes_log_exposure() {
1486        let mut g = AuthorityGraph::new(source("ci.yml"));
1487        let step = g.add_node(NodeKind::Step, "TerraformCLI@0", TrustZone::Untrusted);
1488        let mut meta = std::collections::HashMap::new();
1489        meta.insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
1490        let secret =
1491            g.add_node_with_metadata(NodeKind::Secret, "db_password", TrustZone::FirstParty, meta);
1492        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1493
1494        let findings = untrusted_with_authority(&g);
1495        assert_eq!(findings.len(), 1);
1496        assert!(
1497            findings[0].message.contains("-var flag"),
1498            "message should note -var flag log exposure"
1499        );
1500        assert!(matches!(
1501            findings[0].recommendation,
1502            Recommendation::Manual { .. }
1503        ));
1504    }
1505
1506    #[test]
1507    fn constrained_identity_scope_not_flagged() {
1508        let mut g = AuthorityGraph::new(source("ci.yml"));
1509        let mut meta = std::collections::HashMap::new();
1510        meta.insert(META_PERMISSIONS.into(), "{ contents: read }".into());
1511        meta.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
1512        let identity = g.add_node_with_metadata(
1513            NodeKind::Identity,
1514            "GITHUB_TOKEN",
1515            TrustZone::FirstParty,
1516            meta,
1517        );
1518        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1519        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1520
1521        let findings = over_privileged_identity(&g);
1522        assert!(
1523            findings.is_empty(),
1524            "constrained scope should not be flagged"
1525        );
1526    }
1527
1528    #[test]
1529    fn trigger_context_mismatch_fires_on_pull_request_target_with_secret() {
1530        let mut g = AuthorityGraph::new(source("ci.yml"));
1531        g.metadata
1532            .insert(META_TRIGGER.into(), "pull_request_target".into());
1533        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1534        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1535        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1536
1537        let findings = trigger_context_mismatch(&g);
1538        assert_eq!(findings.len(), 1);
1539        assert_eq!(findings[0].severity, Severity::Critical);
1540        assert_eq!(
1541            findings[0].category,
1542            FindingCategory::TriggerContextMismatch
1543        );
1544    }
1545
1546    #[test]
1547    fn trigger_context_mismatch_no_fire_without_trigger_metadata() {
1548        let mut g = AuthorityGraph::new(source("ci.yml"));
1549        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1550        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1551        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1552
1553        let findings = trigger_context_mismatch(&g);
1554        assert!(findings.is_empty(), "no trigger metadata → no finding");
1555    }
1556
1557    #[test]
1558    fn cross_workflow_authority_chain_detected() {
1559        let mut g = AuthorityGraph::new(source("ci.yml"));
1560        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1561        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1562        let external = g.add_node(
1563            NodeKind::Image,
1564            "evil/workflow.yml@main",
1565            TrustZone::Untrusted,
1566        );
1567        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1568        g.add_edge(step, external, EdgeKind::DelegatesTo);
1569
1570        let findings = cross_workflow_authority_chain(&g);
1571        assert_eq!(findings.len(), 1);
1572        assert_eq!(findings[0].severity, Severity::Critical);
1573        assert_eq!(
1574            findings[0].category,
1575            FindingCategory::CrossWorkflowAuthorityChain
1576        );
1577    }
1578
1579    #[test]
1580    fn cross_workflow_authority_chain_no_fire_if_local_delegation() {
1581        let mut g = AuthorityGraph::new(source("ci.yml"));
1582        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1583        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1584        let local = g.add_node(NodeKind::Image, "./local-action", TrustZone::FirstParty);
1585        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1586        g.add_edge(step, local, EdgeKind::DelegatesTo);
1587
1588        let findings = cross_workflow_authority_chain(&g);
1589        assert!(
1590            findings.is_empty(),
1591            "FirstParty delegation should not be flagged"
1592        );
1593    }
1594
1595    #[test]
1596    fn authority_cycle_detected() {
1597        let mut g = AuthorityGraph::new(source("ci.yml"));
1598        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
1599        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
1600        g.add_edge(a, b, EdgeKind::DelegatesTo);
1601        g.add_edge(b, a, EdgeKind::DelegatesTo);
1602
1603        let findings = authority_cycle(&g);
1604        assert_eq!(findings.len(), 1);
1605        assert_eq!(findings[0].category, FindingCategory::AuthorityCycle);
1606        assert_eq!(findings[0].severity, Severity::High);
1607    }
1608
1609    #[test]
1610    fn authority_cycle_no_fire_for_acyclic_graph() {
1611        let mut g = AuthorityGraph::new(source("ci.yml"));
1612        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
1613        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
1614        let c = g.add_node(NodeKind::Step, "C", TrustZone::FirstParty);
1615        g.add_edge(a, b, EdgeKind::DelegatesTo);
1616        g.add_edge(b, c, EdgeKind::DelegatesTo);
1617
1618        let findings = authority_cycle(&g);
1619        assert!(findings.is_empty(), "acyclic graph must not fire");
1620    }
1621
1622    #[test]
1623    fn uplift_without_attestation_fires_when_oidc_no_attests() {
1624        let mut g = AuthorityGraph::new(source("ci.yml"));
1625        let mut meta = std::collections::HashMap::new();
1626        meta.insert(META_OIDC.into(), "true".into());
1627        let identity = g.add_node_with_metadata(
1628            NodeKind::Identity,
1629            "AWS/deploy-role",
1630            TrustZone::FirstParty,
1631            meta,
1632        );
1633        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1634        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1635
1636        let findings = uplift_without_attestation(&g);
1637        assert_eq!(findings.len(), 1);
1638        assert_eq!(findings[0].severity, Severity::Info);
1639        assert_eq!(
1640            findings[0].category,
1641            FindingCategory::UpliftWithoutAttestation
1642        );
1643    }
1644
1645    #[test]
1646    fn uplift_without_attestation_no_fire_when_attests_present() {
1647        let mut g = AuthorityGraph::new(source("ci.yml"));
1648        let mut id_meta = std::collections::HashMap::new();
1649        id_meta.insert(META_OIDC.into(), "true".into());
1650        let identity = g.add_node_with_metadata(
1651            NodeKind::Identity,
1652            "AWS/deploy-role",
1653            TrustZone::FirstParty,
1654            id_meta,
1655        );
1656        let mut step_meta = std::collections::HashMap::new();
1657        step_meta.insert(META_ATTESTS.into(), "true".into());
1658        let attest_step =
1659            g.add_node_with_metadata(NodeKind::Step, "attest", TrustZone::FirstParty, step_meta);
1660        let build_step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1661        g.add_edge(build_step, identity, EdgeKind::HasAccessTo);
1662        // Touch attest_step so the variable is used (avoid unused warning)
1663        let _ = attest_step;
1664
1665        let findings = uplift_without_attestation(&g);
1666        assert!(findings.is_empty(), "attestation present → no finding");
1667    }
1668
1669    #[test]
1670    fn uplift_without_attestation_no_fire_without_oidc() {
1671        let mut g = AuthorityGraph::new(source("ci.yml"));
1672        let mut meta = std::collections::HashMap::new();
1673        meta.insert(META_PERMISSIONS.into(), "write-all".into());
1674        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1675        // Note: no META_OIDC
1676        let identity = g.add_node_with_metadata(
1677            NodeKind::Identity,
1678            "GITHUB_TOKEN",
1679            TrustZone::FirstParty,
1680            meta,
1681        );
1682        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1683        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1684
1685        let findings = uplift_without_attestation(&g);
1686        assert!(
1687            findings.is_empty(),
1688            "broad identity without OIDC must not fire"
1689        );
1690    }
1691
1692    #[test]
1693    fn self_mutating_pipeline_untrusted_is_critical() {
1694        let mut g = AuthorityGraph::new(source("ci.yml"));
1695        let mut meta = std::collections::HashMap::new();
1696        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
1697        g.add_node_with_metadata(NodeKind::Step, "fork-step", TrustZone::Untrusted, meta);
1698
1699        let findings = self_mutating_pipeline(&g);
1700        assert_eq!(findings.len(), 1);
1701        assert_eq!(findings[0].severity, Severity::Critical);
1702        assert_eq!(findings[0].category, FindingCategory::SelfMutatingPipeline);
1703    }
1704
1705    #[test]
1706    fn self_mutating_pipeline_privileged_step_is_high() {
1707        let mut g = AuthorityGraph::new(source("ci.yml"));
1708        let mut meta = std::collections::HashMap::new();
1709        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
1710        let step = g.add_node_with_metadata(NodeKind::Step, "build", TrustZone::FirstParty, meta);
1711        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1712        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1713
1714        let findings = self_mutating_pipeline(&g);
1715        assert_eq!(findings.len(), 1);
1716        assert_eq!(findings[0].severity, Severity::High);
1717    }
1718
1719    #[test]
1720    fn trigger_context_mismatch_fires_on_ado_pr_with_secret_as_high() {
1721        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1722        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1723        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1724        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
1725        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1726
1727        let findings = trigger_context_mismatch(&g);
1728        assert_eq!(findings.len(), 1);
1729        assert_eq!(findings[0].severity, Severity::High);
1730        assert_eq!(
1731            findings[0].category,
1732            FindingCategory::TriggerContextMismatch
1733        );
1734    }
1735
1736    #[test]
1737    fn cross_workflow_authority_chain_third_party_is_high() {
1738        let mut g = AuthorityGraph::new(source("ci.yml"));
1739        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1740        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
1741        // ThirdParty target (SHA-pinned external workflow)
1742        let external = g.add_node(
1743            NodeKind::Image,
1744            "org/repo/.github/workflows/deploy.yml@a5ac7e51b41094c92402da3b24376905380afc29",
1745            TrustZone::ThirdParty,
1746        );
1747        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1748        g.add_edge(step, external, EdgeKind::DelegatesTo);
1749
1750        let findings = cross_workflow_authority_chain(&g);
1751        assert_eq!(findings.len(), 1);
1752        assert_eq!(
1753            findings[0].severity,
1754            Severity::High,
1755            "ThirdParty delegation target should be High (Critical reserved for Untrusted)"
1756        );
1757        assert_eq!(
1758            findings[0].category,
1759            FindingCategory::CrossWorkflowAuthorityChain
1760        );
1761    }
1762
1763    #[test]
1764    fn self_mutating_pipeline_first_party_no_authority_is_medium() {
1765        let mut g = AuthorityGraph::new(source("ci.yml"));
1766        let mut meta = std::collections::HashMap::new();
1767        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
1768        // FirstParty step writes the gate but holds no secret/identity access.
1769        g.add_node_with_metadata(NodeKind::Step, "set-version", TrustZone::FirstParty, meta);
1770
1771        let findings = self_mutating_pipeline(&g);
1772        assert_eq!(findings.len(), 1);
1773        assert_eq!(findings[0].severity, Severity::Medium);
1774        assert_eq!(findings[0].category, FindingCategory::SelfMutatingPipeline);
1775    }
1776
1777    #[test]
1778    fn authority_cycle_3node_cycle_includes_all_members() {
1779        // A → B → C → A should produce one finding whose nodes_involved
1780        // contains all three node IDs, not just the back-edge endpoints.
1781        let mut g = AuthorityGraph::new(source("test.yml"));
1782        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
1783        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
1784        let c = g.add_node(NodeKind::Step, "C", TrustZone::FirstParty);
1785        g.add_edge(a, b, EdgeKind::DelegatesTo);
1786        g.add_edge(b, c, EdgeKind::DelegatesTo);
1787        g.add_edge(c, a, EdgeKind::DelegatesTo);
1788
1789        let findings = authority_cycle(&g);
1790        assert_eq!(findings.len(), 1);
1791        assert_eq!(findings[0].category, FindingCategory::AuthorityCycle);
1792        assert!(
1793            findings[0].nodes_involved.contains(&a),
1794            "A must be in nodes_involved"
1795        );
1796        assert!(
1797            findings[0].nodes_involved.contains(&b),
1798            "B must be in nodes_involved — middle of A→B→C→A cycle"
1799        );
1800        assert!(
1801            findings[0].nodes_involved.contains(&c),
1802            "C must be in nodes_involved"
1803        );
1804    }
1805
1806    #[test]
1807    fn variable_group_in_pr_job_fires_on_pr_trigger_with_var_group() {
1808        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1809        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1810        let mut secret_meta = std::collections::HashMap::new();
1811        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
1812        let secret = g.add_node_with_metadata(
1813            NodeKind::Secret,
1814            "prod-deploy-secrets",
1815            TrustZone::FirstParty,
1816            secret_meta,
1817        );
1818        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1819        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1820
1821        let findings = variable_group_in_pr_job(&g);
1822        assert_eq!(findings.len(), 1);
1823        assert_eq!(findings[0].severity, Severity::Critical);
1824        assert_eq!(findings[0].category, FindingCategory::VariableGroupInPrJob);
1825        assert!(findings[0].message.contains("prod-deploy-secrets"));
1826    }
1827
1828    #[test]
1829    fn variable_group_in_pr_job_no_fire_without_pr_trigger() {
1830        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1831        // No trigger metadata — should not fire
1832        let mut secret_meta = std::collections::HashMap::new();
1833        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
1834        let secret = g.add_node_with_metadata(
1835            NodeKind::Secret,
1836            "prod-deploy-secrets",
1837            TrustZone::FirstParty,
1838            secret_meta,
1839        );
1840        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1841        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1842
1843        let findings = variable_group_in_pr_job(&g);
1844        assert!(
1845            findings.is_empty(),
1846            "no PR trigger → variable_group_in_pr_job must not fire"
1847        );
1848    }
1849
1850    #[test]
1851    fn self_hosted_pool_pr_hijack_fires_when_all_three_factors_present() {
1852        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1853        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1854
1855        let mut pool_meta = std::collections::HashMap::new();
1856        pool_meta.insert(META_SELF_HOSTED.into(), "true".into());
1857        g.add_node_with_metadata(
1858            NodeKind::Image,
1859            "self-hosted-pool",
1860            TrustZone::FirstParty,
1861            pool_meta,
1862        );
1863
1864        let mut step_meta = std::collections::HashMap::new();
1865        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
1866        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
1867
1868        let findings = self_hosted_pool_pr_hijack(&g);
1869        assert_eq!(findings.len(), 1);
1870        assert_eq!(findings[0].severity, Severity::Critical);
1871        assert_eq!(
1872            findings[0].category,
1873            FindingCategory::SelfHostedPoolPrHijack
1874        );
1875        assert!(findings[0].message.contains("self-hosted"));
1876    }
1877
1878    #[test]
1879    fn self_hosted_pool_pr_hijack_no_fire_without_pr_trigger() {
1880        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1881        // No trigger metadata
1882
1883        let mut pool_meta = std::collections::HashMap::new();
1884        pool_meta.insert(META_SELF_HOSTED.into(), "true".into());
1885        g.add_node_with_metadata(
1886            NodeKind::Image,
1887            "self-hosted-pool",
1888            TrustZone::FirstParty,
1889            pool_meta,
1890        );
1891
1892        let mut step_meta = std::collections::HashMap::new();
1893        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
1894        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
1895
1896        let findings = self_hosted_pool_pr_hijack(&g);
1897        assert!(
1898            findings.is_empty(),
1899            "no PR trigger → self_hosted_pool_pr_hijack must not fire"
1900        );
1901    }
1902
1903    #[test]
1904    fn service_connection_scope_mismatch_fires_on_pr_broad_non_oidc() {
1905        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1906        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1907
1908        let mut sc_meta = std::collections::HashMap::new();
1909        sc_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
1910        sc_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1911        // No META_OIDC → treated as not OIDC-federated
1912        let sc = g.add_node_with_metadata(
1913            NodeKind::Identity,
1914            "prod-azure-sc",
1915            TrustZone::FirstParty,
1916            sc_meta,
1917        );
1918        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1919        g.add_edge(step, sc, EdgeKind::HasAccessTo);
1920
1921        let findings = service_connection_scope_mismatch(&g);
1922        assert_eq!(findings.len(), 1);
1923        assert_eq!(findings[0].severity, Severity::High);
1924        assert_eq!(
1925            findings[0].category,
1926            FindingCategory::ServiceConnectionScopeMismatch
1927        );
1928        assert!(findings[0].message.contains("prod-azure-sc"));
1929    }
1930
1931    #[test]
1932    fn service_connection_scope_mismatch_no_fire_without_pr_trigger() {
1933        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1934        // No trigger metadata
1935        let mut sc_meta = std::collections::HashMap::new();
1936        sc_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
1937        sc_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1938        let sc = g.add_node_with_metadata(
1939            NodeKind::Identity,
1940            "prod-azure-sc",
1941            TrustZone::FirstParty,
1942            sc_meta,
1943        );
1944        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
1945        g.add_edge(step, sc, EdgeKind::HasAccessTo);
1946
1947        let findings = service_connection_scope_mismatch(&g);
1948        assert!(
1949            findings.is_empty(),
1950            "no PR trigger → service_connection_scope_mismatch must not fire"
1951        );
1952    }
1953
1954    #[test]
1955    fn variable_group_in_pr_job_uses_cellos_remediation() {
1956        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1957        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1958
1959        let mut secret_meta = std::collections::HashMap::new();
1960        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
1961        let secret = g.add_node_with_metadata(
1962            NodeKind::Secret,
1963            "prod-secret",
1964            TrustZone::FirstParty,
1965            secret_meta,
1966        );
1967        let step = g.add_node(NodeKind::Step, "deploy step", TrustZone::Untrusted);
1968        g.add_edge(step, secret, EdgeKind::HasAccessTo);
1969
1970        let findings = variable_group_in_pr_job(&g);
1971        assert!(!findings.is_empty());
1972        assert!(
1973            matches!(
1974                findings[0].recommendation,
1975                Recommendation::CellosRemediation { .. }
1976            ),
1977            "variable_group_in_pr_job must recommend CellosRemediation"
1978        );
1979    }
1980
1981    #[test]
1982    fn service_connection_scope_mismatch_uses_cellos_remediation() {
1983        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
1984        g.metadata.insert(META_TRIGGER.into(), "pr".into());
1985
1986        let mut id_meta = std::collections::HashMap::new();
1987        id_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
1988        id_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
1989        // No META_OIDC → treated as not OIDC-federated
1990        let identity = g.add_node_with_metadata(
1991            NodeKind::Identity,
1992            "sub-conn",
1993            TrustZone::FirstParty,
1994            id_meta,
1995        );
1996        let step = g.add_node(NodeKind::Step, "azure deploy", TrustZone::Untrusted);
1997        g.add_edge(step, identity, EdgeKind::HasAccessTo);
1998
1999        let findings = service_connection_scope_mismatch(&g);
2000        assert!(!findings.is_empty());
2001        assert!(
2002            matches!(
2003                findings[0].recommendation,
2004                Recommendation::CellosRemediation { .. }
2005            ),
2006            "service_connection_scope_mismatch must recommend CellosRemediation"
2007        );
2008    }
2009}