Skip to main content

taudit_core/
rules.rs

1use crate::finding::{
2    Finding, FindingCategory, FindingExtras, FindingSource, Recommendation, Severity,
3};
4use crate::graph::{
5    is_docker_digest_pinned, is_sha_pinned, AuthorityCompleteness, AuthorityGraph, EdgeKind,
6    IdentityScope, NodeId, NodeKind, TrustZone, META_ADD_SPN_TO_ENV, META_ATTESTS,
7    META_CHECKOUT_SELF, META_CLI_FLAG_EXPOSED, META_CONTAINER, META_DIGEST, META_ENV_APPROVAL,
8    META_FORK_CHECK, META_IDENTITY_SCOPE, META_IMPLICIT, META_JOB_NAME,
9    META_NO_WORKFLOW_PERMISSIONS, META_OIDC, META_PERMISSIONS, META_PLATFORM, META_READS_ENV,
10    META_REPOSITORIES, META_RULES_PROTECTED_ONLY, META_SCRIPT_BODY, META_SELF_HOSTED,
11    META_SERVICE_CONNECTION, META_SERVICE_CONNECTION_NAME, META_TERRAFORM_AUTO_APPROVE,
12    META_TRIGGER, META_VARIABLE_GROUP, META_WRITES_ENV_GATE,
13};
14use crate::propagation;
15
16fn cap_severity(severity: Severity, max_severity: Severity) -> Severity {
17    if severity < max_severity {
18        max_severity
19    } else {
20        severity
21    }
22}
23
24fn apply_confidence_cap(graph: &AuthorityGraph, findings: &mut [Finding]) {
25    if graph.completeness != AuthorityCompleteness::Partial {
26        return;
27    }
28
29    for finding in findings {
30        finding.severity = cap_severity(finding.severity, Severity::High);
31    }
32}
33
34/// MVP Rule 1: Authority (secret/identity) propagated across a trust boundary.
35///
36/// **Clustering (v0.9.x):** all paths from the same root authority node
37/// (Secret/Identity) collapse into ONE finding per source. The single
38/// finding carries every reached sink in `nodes_involved` — `[source,
39/// sink_a, sink_b, ...]` — and lists them in the message. This matches
40/// the SARIF fingerprint behaviour (which already collapses per
41/// `root_authority_node_name`) and removes the alert-fatigue cliff seen
42/// on the GHA corpus where one `GITHUB_TOKEN` could produce 8+ near-
43/// identical findings as it propagated through a matrix workflow.
44///
45/// Severity graduation (per-path, then max-over-paths):
46/// - Untrusted sink: Critical (real risk — unpinned code with authority)
47/// - SHA-pinned ThirdParty sink: High (immutable code, but still cross-boundary)
48/// - SHA-pinned sink + constrained identity: Medium (lowest-risk form — read-only
49///   token to immutable third-party code, e.g. `contents:read` → `actions/checkout@sha`)
50///
51/// When every path in a cluster crosses an environment approval gate,
52/// the cluster's severity is downgraded one step (mirroring the
53/// per-path downgrade the previous emitter applied).
54pub fn authority_propagation(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
55    let paths = propagation::propagation_analysis(graph, max_hops);
56
57    // Group by root authority source node. We preserve insertion order so
58    // findings come out in the same order they would have under per-hop
59    // emission (callers and golden-file tests rely on the source-first
60    // ordering of authority_propagation findings).
61    let mut order: Vec<NodeId> = Vec::new();
62    let mut groups: std::collections::HashMap<NodeId, Vec<propagation::PropagationPath>> =
63        std::collections::HashMap::new();
64
65    for path in paths.into_iter().filter(|p| p.crossed_boundary) {
66        groups
67            .entry(path.source)
68            .or_insert_with(|| {
69                order.push(path.source);
70                Vec::new()
71            })
72            .push(path);
73    }
74
75    let mut findings = Vec::with_capacity(order.len());
76
77    for source_id in order {
78        let paths = match groups.remove(&source_id) {
79            Some(p) if !p.is_empty() => p,
80            _ => continue,
81        };
82
83        let source_name = graph
84            .node(source_id)
85            .map(|n| n.name.as_str())
86            .unwrap_or("?")
87            .to_string();
88        let source_is_constrained = graph
89            .node(source_id)
90            .and_then(|n| n.metadata.get(META_IDENTITY_SCOPE))
91            .map(|s| s == "constrained")
92            .unwrap_or(false);
93        let source_is_oidc = graph
94            .node(source_id)
95            .and_then(|n| n.metadata.get(META_OIDC))
96            .map(|v| v == "true")
97            .unwrap_or(false);
98
99        // Walk every path in the cluster and compute (severity, gated?,
100        // sink id, representative path) — the cluster takes the max
101        // severity (i.e. the worst sink wins). Severity is downgraded
102        // only when every path in the cluster crosses an env-approval
103        // gate; if even one path bypasses the gate, the cluster is not
104        // downgraded.
105        let mut worst_sev = Severity::Info;
106        let mut all_gated = true;
107        let mut best_path: Option<propagation::PropagationPath> = None;
108        let mut sink_ids: Vec<NodeId> = Vec::new();
109        let mut seen_sinks = std::collections::HashSet::new();
110
111        for path in &paths {
112            let sink_is_pinned = graph
113                .node(path.sink)
114                .map(|n| {
115                    n.trust_zone == TrustZone::ThirdParty && n.metadata.contains_key(META_DIGEST)
116                })
117                .unwrap_or(false);
118
119            let base_severity = if sink_is_pinned && source_is_constrained && !source_is_oidc {
120                Severity::Medium
121            } else if sink_is_pinned && !source_is_oidc {
122                Severity::High
123            } else {
124                Severity::Critical
125            };
126
127            let gated = path_crosses_env_approval(graph, path);
128            let effective_severity = if gated {
129                downgrade_one_step(base_severity)
130            } else {
131                base_severity
132            };
133
134            if !gated {
135                all_gated = false;
136            }
137
138            if effective_severity < worst_sev {
139                worst_sev = effective_severity;
140                best_path = Some(path.clone());
141            }
142
143            if seen_sinks.insert(path.sink) {
144                sink_ids.push(path.sink);
145            }
146        }
147
148        // Build sink name list for the message. Truncate aggressively past
149        // ~5 names to avoid an unbounded message string on extreme inputs;
150        // the full set is still in `nodes_involved`.
151        let mut sink_names: Vec<String> = sink_ids
152            .iter()
153            .filter_map(|id| graph.node(*id).map(|n| n.name.clone()))
154            .collect();
155        let truncated = if sink_names.len() > 5 {
156            let extra = sink_names.len() - 5;
157            sink_names.truncate(5);
158            format!(", …+{extra} more")
159        } else {
160            String::new()
161        };
162        let sink_list = sink_names.join(", ");
163
164        let suffix = if all_gated && !paths.is_empty() {
165            " (mitigated: environment approval gate)"
166        } else {
167            ""
168        };
169
170        let mut nodes_involved = Vec::with_capacity(sink_ids.len() + 1);
171        nodes_involved.push(source_id);
172        nodes_involved.extend(sink_ids.iter().copied());
173
174        let n = paths.len();
175        let unique_sinks = sink_ids.len();
176        let message = if unique_sinks == 1 {
177            format!("{source_name} propagated to {sink_list} across trust boundary{suffix}")
178        } else {
179            format!(
180                "{source_name} reaches {unique_sinks} sinks via authority propagation: [{sink_list}{truncated}]{suffix}"
181            )
182        };
183
184        let _ = n; // path count retained in the cluster's `path` field; not surfaced separately
185
186        findings.push(Finding {
187            severity: worst_sev,
188            category: FindingCategory::AuthorityPropagation,
189            nodes_involved,
190            message,
191            recommendation: Recommendation::TsafeRemediation {
192                command: "tsafe exec --ns <scoped-namespace> -- <command>".to_string(),
193                explanation: format!("Scope {source_name} to only the steps that need it"),
194            },
195            path: best_path,
196            source: FindingSource::BuiltIn,
197            extras: FindingExtras::default(),
198        });
199    }
200
201    findings
202}
203
204/// Returns true if any node touched by `path` (source, sink, or any edge
205/// endpoint along the way) carries META_ENV_APPROVAL = "true".
206fn path_crosses_env_approval(graph: &AuthorityGraph, path: &propagation::PropagationPath) -> bool {
207    let has_marker = |id: NodeId| {
208        graph
209            .node(id)
210            .and_then(|n| n.metadata.get(META_ENV_APPROVAL))
211            .map(|v| v == "true")
212            .unwrap_or(false)
213    };
214
215    if has_marker(path.source) || has_marker(path.sink) {
216        return true;
217    }
218
219    for &edge_id in &path.edges {
220        if let Some(edge) = graph.edge(edge_id) {
221            if has_marker(edge.from) || has_marker(edge.to) {
222                return true;
223            }
224        }
225    }
226    false
227}
228
229/// Reduce a severity by one step. Critical→High, High→Medium, Medium→Low.
230/// Low and Info are already at the floor of meaningful reduction and are
231/// returned unchanged.
232fn downgrade_one_step(severity: Severity) -> Severity {
233    match severity {
234        Severity::Critical => Severity::High,
235        Severity::High => Severity::Medium,
236        Severity::Medium => Severity::Low,
237        Severity::Low => Severity::Low,
238        Severity::Info => Severity::Info,
239    }
240}
241
242/// MVP Rule 2: Identity scope broader than actual usage.
243///
244/// Uses `IdentityScope` classification from the precision layer. Broad and
245/// Unknown scopes are flagged — Unknown is treated as risky because if we
246/// can't determine the scope, we shouldn't assume it's safe.
247pub fn over_privileged_identity(graph: &AuthorityGraph) -> Vec<Finding> {
248    let mut findings = Vec::new();
249
250    for identity in graph.nodes_of_kind(NodeKind::Identity) {
251        let granted_scope = identity
252            .metadata
253            .get(META_PERMISSIONS)
254            .cloned()
255            .unwrap_or_default();
256
257        // Use IdentityScope from metadata if set by parser, otherwise classify from permissions
258        let scope = identity
259            .metadata
260            .get(META_IDENTITY_SCOPE)
261            .and_then(|s| match s.as_str() {
262                "broad" => Some(IdentityScope::Broad),
263                "constrained" => Some(IdentityScope::Constrained),
264                "unknown" => Some(IdentityScope::Unknown),
265                _ => None,
266            })
267            .unwrap_or_else(|| IdentityScope::from_permissions(&granted_scope));
268
269        // Broad or Unknown scope — flag it. Unknown is treated as risky.
270        let (should_flag, severity) = match scope {
271            IdentityScope::Broad => (true, Severity::High),
272            IdentityScope::Unknown => (true, Severity::Medium),
273            IdentityScope::Constrained => (false, Severity::Info),
274        };
275
276        if !should_flag {
277            continue;
278        }
279
280        let accessor_steps: Vec<_> = graph
281            .edges_to(identity.id)
282            .filter(|e| e.kind == EdgeKind::HasAccessTo)
283            .filter_map(|e| graph.node(e.from))
284            .collect();
285
286        if !accessor_steps.is_empty() {
287            let scope_label = match scope {
288                IdentityScope::Broad => "broad",
289                IdentityScope::Unknown => "unknown (treat as risky)",
290                IdentityScope::Constrained => "constrained",
291            };
292
293            findings.push(Finding {
294                severity,
295                category: FindingCategory::OverPrivilegedIdentity,
296                path: None,
297                nodes_involved: std::iter::once(identity.id)
298                    .chain(accessor_steps.iter().map(|n| n.id))
299                    .collect(),
300                message: format!(
301                    "{} has {} scope (permissions: '{}') — likely broader than needed",
302                    identity.name, scope_label, granted_scope
303                ),
304                recommendation: Recommendation::ReducePermissions {
305                    current: granted_scope.clone(),
306                    minimum: "{ contents: read }".into(),
307                },
308                source: FindingSource::BuiltIn,
309                // Working out the minimum-needed scope across N jobs is a
310                // ~1 hour audit, not a flag flip — Small.
311                extras: FindingExtras {
312                    time_to_fix: Some(crate::finding::FixEffort::Small),
313                    ..FindingExtras::default()
314                },
315            });
316        }
317    }
318
319    findings
320}
321
322/// MVP Rule 3: Third-party action/image without SHA pin.
323///
324/// **Severity tiering (v0.9.x):** the rule used to fire at a single severity
325/// regardless of which action was unpinned, which produced uniform noise on
326/// monorepo CI files where the action owner determined the actual risk.
327/// The blue-team corpus report (`MEMORY/.../blueteam-corpus-defense.md`)
328/// recommended splitting:
329///   * Same-repo composite action (`./.github/actions/*`) → **Info**.
330///     The action lives in the consumer's own repo — there's no external
331///     supply-chain surface; pinning is a hygiene preference, not a
332///     control gap.
333///   * Owner is a well-known first-party org (`actions/*`, `github/*`,
334///     `actions-rs/*`, `docker/*`) → **Medium**. These are GitHub-org or
335///     adjacent tooling maintainers; the supply-chain surface exists but
336///     is operationally narrow and well-monitored.
337///   * Anything else (`random-org/foo@v1`, etc.) → **High**. Unbounded
338///     supply-chain risk — this is the case the rule was originally
339///     designed for.
340///
341/// Deduplicates by action reference — the same action used in multiple jobs
342/// produces multiple Image nodes but should only be flagged once.
343pub fn unpinned_action(graph: &AuthorityGraph) -> Vec<Finding> {
344    let mut findings = Vec::new();
345    let mut seen = std::collections::HashSet::new();
346
347    for image in graph.nodes_of_kind(NodeKind::Image) {
348        // Container images are handled by floating_image — skip here to avoid
349        // double-flagging the same node as both UnpinnedAction and FloatingImage.
350        if image
351            .metadata
352            .get(META_CONTAINER)
353            .map(|v| v == "true")
354            .unwrap_or(false)
355        {
356            continue;
357        }
358
359        // Self-hosted runner labels live in the FirstParty zone but aren't
360        // an action reference — they have no `@version` to pin and the rule
361        // would otherwise flag every `runs-on: self-hosted` line.
362        if image
363            .metadata
364            .get(META_SELF_HOSTED)
365            .map(|v| v == "true")
366            .unwrap_or(false)
367        {
368            continue;
369        }
370
371        // Same-repo composite actions (`./.github/actions/foo`) sit in the
372        // FirstParty zone. Other FirstParty Image nodes (e.g. self-hosted
373        // pool labels, hosted runner names) are not flaggable references —
374        // we admit FirstParty into the severity ladder ONLY when the name
375        // is the relative-path form, and emit Info for it.
376        let is_local_composite = image.name.starts_with("./");
377        if image.trust_zone == TrustZone::FirstParty && !is_local_composite {
378            continue;
379        }
380
381        // Deduplicate: same action reference flagged once
382        if !seen.insert(&image.name) {
383            continue;
384        }
385
386        let has_digest = image.metadata.contains_key(META_DIGEST);
387
388        if has_digest || is_sha_pinned(&image.name) {
389            continue;
390        }
391
392        // Tier severity by owner. `is_local_composite` already handled the
393        // same-repo case; for everything else, look at the `<owner>/...`
394        // prefix and decide first-party vs unknown supplier.
395        let severity = if is_local_composite {
396            Severity::Info
397        } else if is_well_known_first_party_action(&image.name) {
398            Severity::Medium
399        } else {
400            Severity::High
401        };
402
403        findings.push(Finding {
404            severity,
405            category: FindingCategory::UnpinnedAction,
406            path: None,
407            nodes_involved: vec![image.id],
408            message: format!("{} is not pinned to a SHA digest", image.name),
409            recommendation: Recommendation::PinAction {
410                current: image.name.clone(),
411                pinned: format!(
412                    "{}@<sha256-digest>",
413                    image.name.split('@').next().unwrap_or(&image.name)
414                ),
415            },
416            source: FindingSource::BuiltIn,
417            // Mechanical fix: replace `@v3` with `@<40-char-sha>`. ~5 min.
418            extras: FindingExtras {
419                time_to_fix: Some(crate::finding::FixEffort::Trivial),
420                ..FindingExtras::default()
421            },
422        });
423    }
424
425    findings
426}
427
428/// Owners we treat as well-known first-party for the purpose of severity
429/// tiering. The list is intentionally short and conservative — adding an
430/// org here downgrades every unpinned action it ships, so the bar is
431/// "GitHub-maintained or directly adjacent core tooling." Anything else
432/// stays at the High default.
433fn is_well_known_first_party_action(uses: &str) -> bool {
434    // Strip an optional `@<ref>` suffix, then take the leading owner segment.
435    let bare = uses.split('@').next().unwrap_or(uses);
436    let owner = bare.split('/').next().unwrap_or("");
437    matches!(owner, "actions" | "github" | "actions-rs" | "docker")
438}
439
440/// MVP Rule 4: Untrusted step has direct access to secret/identity.
441pub fn untrusted_with_authority(graph: &AuthorityGraph) -> Vec<Finding> {
442    let mut findings = Vec::new();
443
444    for step in graph.nodes_in_zone(TrustZone::Untrusted) {
445        if step.kind != NodeKind::Step {
446            continue;
447        }
448
449        // Check if this untrusted step directly accesses any authority source
450        for edge in graph.edges_from(step.id) {
451            if edge.kind != EdgeKind::HasAccessTo {
452                continue;
453            }
454
455            if let Some(target) = graph.node(edge.to) {
456                if matches!(target.kind, NodeKind::Secret | NodeKind::Identity) {
457                    let cli_flag_exposed = target
458                        .metadata
459                        .get(META_CLI_FLAG_EXPOSED)
460                        .map(|v| v == "true")
461                        .unwrap_or(false);
462
463                    // Platform-implicit tokens (e.g. ADO System.AccessToken) are structurally
464                    // accessible to all tasks by design. Flag at Info — real but not actionable
465                    // as a misconfiguration. Explicit secrets/service connections stay Critical.
466                    let is_implicit = target
467                        .metadata
468                        .get(META_IMPLICIT)
469                        .map(|v| v == "true")
470                        .unwrap_or(false);
471
472                    let recommendation = if target.kind == NodeKind::Secret {
473                        if cli_flag_exposed {
474                            Recommendation::Manual {
475                                action: format!(
476                                    "Move '{}' from -var flag to TF_VAR_{} env var — \
477                                     -var values appear in pipeline logs and Terraform plan output",
478                                    target.name, target.name
479                                ),
480                            }
481                        } else {
482                            Recommendation::CellosRemediation {
483                                reason: format!(
484                                    "Untrusted step '{}' has direct access to secret '{}'",
485                                    step.name, target.name
486                                ),
487                                spec_hint: format!(
488                                    "cellos run --network deny-all --broker env:{}",
489                                    target.name
490                                ),
491                            }
492                        }
493                    } else {
494                        // Identity branch — for implicit platform tokens, add a CellOS
495                        // compensating-control note since the token cannot be un-injected
496                        // at the platform layer.
497                        let minimum = if is_implicit {
498                            "minimal required scope — or use CellOS deny-all egress as a compensating control to limit exfiltration of the injected token".into()
499                        } else {
500                            "minimal required scope".into()
501                        };
502                        Recommendation::ReducePermissions {
503                            current: target
504                                .metadata
505                                .get(META_PERMISSIONS)
506                                .cloned()
507                                .unwrap_or_else(|| "unknown".into()),
508                            minimum,
509                        }
510                    };
511
512                    let log_exposure_note = if cli_flag_exposed {
513                        " (passed as -var flag — value visible in pipeline logs)"
514                    } else {
515                        ""
516                    };
517
518                    let (severity, message) =
519                        if is_implicit {
520                            (
521                                Severity::Info,
522                                format!(
523                                "Untrusted step '{}' has structural access to implicit {} '{}' \
524                                 (platform-injected — all tasks receive this token by design){}",
525                                step.name,
526                                if target.kind == NodeKind::Secret { "secret" } else { "identity" },
527                                target.name,
528                                log_exposure_note,
529                            ),
530                            )
531                        } else {
532                            (
533                                Severity::Critical,
534                                format!(
535                                    "Untrusted step '{}' has direct access to {} '{}'{}",
536                                    step.name,
537                                    if target.kind == NodeKind::Secret {
538                                        "secret"
539                                    } else {
540                                        "identity"
541                                    },
542                                    target.name,
543                                    log_exposure_note,
544                                ),
545                            )
546                        };
547
548                    findings.push(Finding {
549                        severity,
550                        category: FindingCategory::UntrustedWithAuthority,
551                        path: None,
552                        nodes_involved: vec![step.id, target.id],
553                        message,
554                        recommendation,
555                        source: FindingSource::BuiltIn,
556                        extras: FindingExtras::default(),
557                    });
558                }
559            }
560        }
561    }
562
563    findings
564}
565
566/// MVP Rule 5: Artifact produced by privileged step consumed across trust boundary.
567pub fn artifact_boundary_crossing(graph: &AuthorityGraph) -> Vec<Finding> {
568    let mut findings = Vec::new();
569
570    for artifact in graph.nodes_of_kind(NodeKind::Artifact) {
571        // Find producer(s)
572        let producers: Vec<_> = graph
573            .edges_to(artifact.id)
574            .filter(|e| e.kind == EdgeKind::Produces)
575            .filter_map(|e| graph.node(e.from))
576            .collect();
577
578        // Find consumer(s) — Consumes edges go artifact -> step
579        let consumers: Vec<_> = graph
580            .edges_from(artifact.id)
581            .filter(|e| e.kind == EdgeKind::Consumes)
582            .filter_map(|e| graph.node(e.to))
583            .collect();
584
585        for producer in &producers {
586            // Only care if the producer is privileged (has access to secrets/identities)
587            let producer_has_authority = graph.edges_from(producer.id).any(|e| {
588                e.kind == EdgeKind::HasAccessTo
589                    && graph
590                        .node(e.to)
591                        .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
592                        .unwrap_or(false)
593            });
594
595            if !producer_has_authority {
596                continue;
597            }
598
599            for consumer in &consumers {
600                if consumer.trust_zone.is_lower_than(&producer.trust_zone) {
601                    findings.push(Finding {
602                        severity: Severity::High,
603                        category: FindingCategory::ArtifactBoundaryCrossing,
604                        path: None,
605                        nodes_involved: vec![producer.id, artifact.id, consumer.id],
606                        message: format!(
607                            "Artifact '{}' produced by privileged step '{}' consumed by '{}' ({:?} -> {:?})",
608                            artifact.name,
609                            producer.name,
610                            consumer.name,
611                            producer.trust_zone,
612                            consumer.trust_zone
613                        ),
614                        recommendation: Recommendation::TsafeRemediation {
615                            command: format!(
616                                "tsafe exec --ns {} -- <build-command>",
617                                producer.name
618                            ),
619                            explanation: format!(
620                                "Scope secrets to '{}' only; artifact '{}' should not carry authority",
621                                producer.name, artifact.name
622                            ),
623                        },
624                        source: FindingSource::BuiltIn,
625                                        extras: FindingExtras::default(),
626});
627                }
628            }
629        }
630    }
631
632    findings
633}
634
635/// Stretch Rule 9: Secret name matches known long-lived/static credential pattern.
636///
637/// Heuristic: secrets named like AWS keys, API keys, passwords, or private keys
638/// are likely static credentials that should be replaced with OIDC federation.
639pub fn long_lived_credential(graph: &AuthorityGraph) -> Vec<Finding> {
640    const STATIC_PATTERNS: &[&str] = &[
641        "AWS_ACCESS_KEY",
642        "AWS_SECRET_ACCESS_KEY",
643        "_API_KEY",
644        "_APIKEY",
645        "_PASSWORD",
646        "_PASSWD",
647        "_PRIVATE_KEY",
648        "_SECRET_KEY",
649        "_SERVICE_ACCOUNT",
650        "_SIGNING_KEY",
651    ];
652
653    let mut findings = Vec::new();
654
655    for secret in graph.nodes_of_kind(NodeKind::Secret) {
656        let upper = secret.name.to_uppercase();
657        let is_static = STATIC_PATTERNS.iter().any(|p| upper.contains(p));
658
659        if is_static {
660            findings.push(Finding {
661                severity: Severity::Low,
662                category: FindingCategory::LongLivedCredential,
663                path: None,
664                nodes_involved: vec![secret.id],
665                message: format!(
666                    "'{}' looks like a long-lived static credential",
667                    secret.name
668                ),
669                recommendation: Recommendation::FederateIdentity {
670                    static_secret: secret.name.clone(),
671                    oidc_provider: "GitHub Actions OIDC (id-token: write)".into(),
672                },
673                source: FindingSource::BuiltIn,
674                // Migrating from PATs to OIDC across an org touches identity
675                // policy, IAM trust relationships, and every downstream
676                // consumer of the credential — Large effort.
677                extras: FindingExtras {
678                    time_to_fix: Some(crate::finding::FixEffort::Large),
679                    ..FindingExtras::default()
680                },
681            });
682        }
683    }
684
685    findings
686}
687
688/// Tier 6 Rule: Container image without Docker digest pinning.
689///
690/// Job-level containers marked with `META_CONTAINER` that aren't pinned to
691/// `image@sha256:<64hex>` can be silently mutated between runs. Deduplicates
692/// by image name (same image in multiple jobs flags once).
693pub fn floating_image(graph: &AuthorityGraph) -> Vec<Finding> {
694    let mut findings = Vec::new();
695    let mut seen = std::collections::HashSet::new();
696
697    for image in graph.nodes_of_kind(NodeKind::Image) {
698        let is_container = image
699            .metadata
700            .get(META_CONTAINER)
701            .map(|v| v == "true")
702            .unwrap_or(false);
703
704        if !is_container {
705            continue;
706        }
707
708        if !seen.insert(image.name.as_str()) {
709            continue;
710        }
711
712        if !is_docker_digest_pinned(&image.name) {
713            findings.push(Finding {
714                severity: Severity::Medium,
715                category: FindingCategory::FloatingImage,
716                path: None,
717                nodes_involved: vec![image.id],
718                message: format!("Container image '{}' is not pinned to a digest", image.name),
719                recommendation: Recommendation::PinAction {
720                    current: image.name.clone(),
721                    pinned: format!(
722                        "{}@sha256:<digest>",
723                        image.name.split(':').next().unwrap_or(&image.name)
724                    ),
725                },
726                source: FindingSource::BuiltIn,
727                // `docker pull <image>` once and append `@sha256:<digest>` —
728                // identical mechanical fix to unpinned_action. Trivial.
729                extras: FindingExtras {
730                    time_to_fix: Some(crate::finding::FixEffort::Trivial),
731                    ..FindingExtras::default()
732                },
733            });
734        }
735    }
736
737    findings
738}
739
740/// Stretch Rule: checkout step with `persistCredentials: true` writes credentials to disk.
741///
742/// The PersistsTo edge connects a checkout step to the token it persists. Disk-resident
743/// credentials are accessible to all subsequent steps (and to any process with filesystem
744/// access), unlike runtime-only HasAccessTo authority which expires when the step exits.
745pub fn persisted_credential(graph: &AuthorityGraph) -> Vec<Finding> {
746    let mut findings = Vec::new();
747
748    for edge in &graph.edges {
749        if edge.kind != EdgeKind::PersistsTo {
750            continue;
751        }
752
753        let Some(step) = graph.node(edge.from) else {
754            continue;
755        };
756        let Some(target) = graph.node(edge.to) else {
757            continue;
758        };
759
760        findings.push(Finding {
761            severity: Severity::High,
762            category: FindingCategory::PersistedCredential,
763            path: None,
764            nodes_involved: vec![step.id, target.id],
765            message: format!(
766                "'{}' persists '{}' to disk via persistCredentials: true — \
767                 credential remains in .git/config and is accessible to all subsequent steps",
768                step.name, target.name
769            ),
770            recommendation: Recommendation::Manual {
771                action: "Remove persistCredentials: true from the checkout step. \
772                         Pass credentials explicitly only to steps that need them."
773                    .into(),
774            },
775            source: FindingSource::BuiltIn,
776            extras: FindingExtras::default(),
777        });
778    }
779
780    findings
781}
782
783/// Rule: dangerous trigger type (pull_request_target / pr) combined with secret/identity access.
784///
785/// Fires once per workflow when the graph-level `META_TRIGGER` indicates a high-risk
786/// trigger and at least one step holds authority. Aggregates all involved nodes.
787pub fn trigger_context_mismatch(graph: &AuthorityGraph) -> Vec<Finding> {
788    let trigger = match graph.metadata.get(META_TRIGGER) {
789        Some(t) => t.clone(),
790        None => return Vec::new(),
791    };
792
793    let severity = match trigger.as_str() {
794        "pull_request_target" => Severity::Critical,
795        "pr" => Severity::High,
796        _ => return Vec::new(),
797    };
798
799    // Collect steps that hold authority (HasAccessTo a Secret or Identity)
800    let mut steps_with_authority: Vec<NodeId> = Vec::new();
801    let mut authority_targets: Vec<NodeId> = Vec::new();
802
803    for step in graph.nodes_of_kind(NodeKind::Step) {
804        let mut step_holds_authority = false;
805        for edge in graph.edges_from(step.id) {
806            if edge.kind != EdgeKind::HasAccessTo {
807                continue;
808            }
809            if let Some(target) = graph.node(edge.to) {
810                if matches!(target.kind, NodeKind::Secret | NodeKind::Identity) {
811                    step_holds_authority = true;
812                    if !authority_targets.contains(&target.id) {
813                        authority_targets.push(target.id);
814                    }
815                }
816            }
817        }
818        if step_holds_authority {
819            steps_with_authority.push(step.id);
820        }
821    }
822
823    if steps_with_authority.is_empty() {
824        return Vec::new();
825    }
826
827    let n = steps_with_authority.len();
828    let mut nodes_involved = steps_with_authority.clone();
829    nodes_involved.extend(authority_targets);
830
831    vec![Finding {
832        severity,
833        category: FindingCategory::TriggerContextMismatch,
834        path: None,
835        nodes_involved,
836        message: format!(
837            "Workflow triggered by {trigger} with secret/identity access — {n} step(s) hold authority that attacker-controlled code could reach"
838        ),
839        recommendation: Recommendation::Manual {
840            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(),
841        },
842        source: FindingSource::BuiltIn,
843        extras: FindingExtras::default(),
844}]
845}
846
847/// Rule: authority (secret/identity) flows into an opaque external workflow via DelegatesTo.
848///
849/// For each Step node: find all `DelegatesTo` edges to Image nodes where the trust zone
850/// is not FirstParty. If the same step also has `HasAccessTo` any Secret or Identity,
851/// emit one finding per delegation edge.
852pub fn cross_workflow_authority_chain(graph: &AuthorityGraph) -> Vec<Finding> {
853    let mut findings = Vec::new();
854
855    for step in graph.nodes_of_kind(NodeKind::Step) {
856        // Collect authority sources this step holds
857        let authority_nodes: Vec<&_> = graph
858            .edges_from(step.id)
859            .filter(|e| e.kind == EdgeKind::HasAccessTo)
860            .filter_map(|e| graph.node(e.to))
861            .filter(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
862            .collect();
863
864        if authority_nodes.is_empty() {
865            continue;
866        }
867
868        // Find each DelegatesTo edge to a non-FirstParty Image
869        for edge in graph.edges_from(step.id) {
870            if edge.kind != EdgeKind::DelegatesTo {
871                continue;
872            }
873            let Some(target) = graph.node(edge.to) else {
874                continue;
875            };
876            if target.kind != NodeKind::Image {
877                continue;
878            }
879            if target.trust_zone == TrustZone::FirstParty {
880                continue;
881            }
882
883            let severity = match target.trust_zone {
884                TrustZone::Untrusted => Severity::Critical,
885                TrustZone::ThirdParty => Severity::High,
886                TrustZone::FirstParty => continue,
887            };
888
889            let authority_names: Vec<String> =
890                authority_nodes.iter().map(|n| n.name.clone()).collect();
891            let authority_label = authority_names.join(", ");
892
893            let mut nodes_involved = vec![step.id, target.id];
894            nodes_involved.extend(authority_nodes.iter().map(|n| n.id));
895
896            findings.push(Finding {
897                severity,
898                category: FindingCategory::CrossWorkflowAuthorityChain,
899                path: None,
900                nodes_involved,
901                message: format!(
902                    "'{}' delegates to '{}' ({:?}) while holding authority ({}) — authority chain extends into opaque external workflow",
903                    step.name, target.name, target.trust_zone, authority_label
904                ),
905                recommendation: Recommendation::Manual {
906                    action: format!(
907                        "Pin '{}' to a full SHA digest; audit what authority the called workflow receives",
908                        target.name
909                    ),
910                },
911                source: FindingSource::BuiltIn,
912                        extras: FindingExtras::default(),
913});
914        }
915    }
916
917    findings
918}
919
920/// Rule: circular DelegatesTo chain — workflow calls itself transitively.
921///
922/// Iterative DFS over `DelegatesTo` edges. Detects back edges (gray → gray) and
923/// collects all nodes that participate in any cycle. If any cycles exist, emits
924/// a single High-severity finding listing all cycle members.
925pub fn authority_cycle(graph: &AuthorityGraph) -> Vec<Finding> {
926    let n = graph.nodes.len();
927    if n == 0 {
928        return Vec::new();
929    }
930
931    // Pre-build adjacency list for DelegatesTo edges only.
932    let mut delegates_to: Vec<Vec<NodeId>> = vec![Vec::new(); n];
933    for edge in &graph.edges {
934        if edge.kind == EdgeKind::DelegatesTo && edge.from < n && edge.to < n {
935            delegates_to[edge.from].push(edge.to);
936        }
937    }
938
939    let mut color: Vec<u8> = vec![0u8; n]; // 0=white, 1=gray, 2=black
940    let mut cycle_nodes: std::collections::BTreeSet<NodeId> = std::collections::BTreeSet::new();
941
942    for start in 0..n {
943        if color[start] != 0 {
944            continue;
945        }
946        color[start] = 1;
947        let mut stack: Vec<(NodeId, usize)> = vec![(start, 0)];
948
949        loop {
950            let len = stack.len();
951            if len == 0 {
952                break;
953            }
954            let (node_id, edge_idx) = stack[len - 1];
955            if edge_idx < delegates_to[node_id].len() {
956                stack[len - 1].1 += 1;
957                let neighbor = delegates_to[node_id][edge_idx];
958                if color[neighbor] == 1 {
959                    // Back edge: cycle found. Collect every node between `neighbor`
960                    // (the cycle start) and `node_id` (the cycle end) along the
961                    // current DFS stack. All stack entries are gray by construction,
962                    // so we walk the stack from `neighbor` to the top.
963                    let cycle_start_idx =
964                        stack.iter().position(|&(n, _)| n == neighbor).unwrap_or(0);
965                    for &(n, _) in &stack[cycle_start_idx..] {
966                        cycle_nodes.insert(n);
967                    }
968                } else if color[neighbor] == 0 {
969                    color[neighbor] = 1;
970                    stack.push((neighbor, 0));
971                }
972            } else {
973                color[node_id] = 2;
974                stack.pop();
975            }
976        }
977    }
978
979    if cycle_nodes.is_empty() {
980        return Vec::new();
981    }
982
983    vec![Finding {
984        severity: Severity::High,
985        category: FindingCategory::AuthorityCycle,
986        path: None,
987        nodes_involved: cycle_nodes.into_iter().collect(),
988        message:
989            "Circular delegation detected — workflow calls itself transitively, creating unbounded privilege escalation paths"
990                .into(),
991        recommendation: Recommendation::Manual {
992            action: "Break the delegation cycle — a workflow must not directly or transitively call itself".into(),
993        },
994        source: FindingSource::BuiltIn,
995        extras: FindingExtras::default(),
996}]
997}
998
999/// Rule: privileged workflow (OIDC/federated identity) with no provenance attestation step.
1000///
1001/// Scoped to workflows that actually use OIDC/federated identity (an Identity node with
1002/// `META_OIDC = "true"` is present). If no node in the graph has `META_ATTESTS = "true"`,
1003/// emit one Info-severity finding listing the steps with HasAccessTo an OIDC identity.
1004pub fn uplift_without_attestation(graph: &AuthorityGraph) -> Vec<Finding> {
1005    // Scope: only fire when the graph has at least one OIDC-capable Identity
1006    let oidc_identity_ids: Vec<NodeId> = graph
1007        .nodes_of_kind(NodeKind::Identity)
1008        .filter(|n| {
1009            n.metadata
1010                .get(META_OIDC)
1011                .map(|v| v == "true")
1012                .unwrap_or(false)
1013        })
1014        .map(|n| n.id)
1015        .collect();
1016
1017    if oidc_identity_ids.is_empty() {
1018        return Vec::new();
1019    }
1020
1021    // Bail if any node already has META_ATTESTS = true
1022    let has_attestation = graph.nodes.iter().any(|n| {
1023        n.metadata
1024            .get(META_ATTESTS)
1025            .map(|v| v == "true")
1026            .unwrap_or(false)
1027    });
1028    if has_attestation {
1029        return Vec::new();
1030    }
1031
1032    // Collect steps that have HasAccessTo an OIDC identity
1033    let mut steps_using_oidc: Vec<NodeId> = Vec::new();
1034    for edge in &graph.edges {
1035        if edge.kind != EdgeKind::HasAccessTo {
1036            continue;
1037        }
1038        if oidc_identity_ids.contains(&edge.to) && !steps_using_oidc.contains(&edge.from) {
1039            steps_using_oidc.push(edge.from);
1040        }
1041    }
1042
1043    if steps_using_oidc.is_empty() {
1044        return Vec::new();
1045    }
1046
1047    let n = steps_using_oidc.len();
1048    let mut nodes_involved = steps_using_oidc.clone();
1049    nodes_involved.extend(oidc_identity_ids);
1050
1051    vec![Finding {
1052        severity: Severity::Info,
1053        category: FindingCategory::UpliftWithoutAttestation,
1054        path: None,
1055        nodes_involved,
1056        message: format!(
1057            "{n} step(s) use OIDC/federated identity but no provenance attestation step was detected — artifact integrity cannot be verified"
1058        ),
1059        recommendation: Recommendation::Manual {
1060            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(),
1061        },
1062        source: FindingSource::BuiltIn,
1063        extras: FindingExtras::default(),
1064}]
1065}
1066
1067/// Rule: step writes to the environment gate ($GITHUB_ENV / ##vso[task.setvariable]).
1068///
1069/// Authority leaking through the environment gate propagates to subsequent steps
1070/// outside the explicit graph edges. Severity:
1071/// - Untrusted step: Critical (attacker-controlled values inject into pipeline env)
1072/// - Step with secret/identity access: High (secrets may leak into env)
1073/// - Otherwise: Medium (still a propagation risk)
1074pub fn self_mutating_pipeline(graph: &AuthorityGraph) -> Vec<Finding> {
1075    let mut findings = Vec::new();
1076
1077    for step in graph.nodes_of_kind(NodeKind::Step) {
1078        let writes_gate = step
1079            .metadata
1080            .get(META_WRITES_ENV_GATE)
1081            .map(|v| v == "true")
1082            .unwrap_or(false);
1083        if !writes_gate {
1084            continue;
1085        }
1086
1087        // Collect authority targets the step has HasAccessTo
1088        let authority_nodes: 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| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
1093            .collect();
1094
1095        let is_untrusted = step.trust_zone == TrustZone::Untrusted;
1096        let has_authority = !authority_nodes.is_empty();
1097
1098        let severity = if is_untrusted {
1099            Severity::Critical
1100        } else if has_authority {
1101            Severity::High
1102        } else {
1103            Severity::Medium
1104        };
1105
1106        let mut nodes_involved = vec![step.id];
1107        nodes_involved.extend(authority_nodes.iter().map(|n| n.id));
1108
1109        let message = if is_untrusted {
1110            format!(
1111                "Untrusted step '{}' writes to the environment gate — attacker-controlled values can inject into subsequent steps' environment",
1112                step.name
1113            )
1114        } else if has_authority {
1115            let authority_label: Vec<String> =
1116                authority_nodes.iter().map(|n| n.name.clone()).collect();
1117            format!(
1118                "Step '{}' writes to the environment gate while holding authority ({}) — secrets may leak into pipeline environment",
1119                step.name,
1120                authority_label.join(", ")
1121            )
1122        } else {
1123            format!(
1124                "Step '{}' writes to the environment gate — values can propagate into subsequent steps' environment",
1125                step.name
1126            )
1127        };
1128
1129        findings.push(Finding {
1130            severity,
1131            category: FindingCategory::SelfMutatingPipeline,
1132            path: None,
1133            nodes_involved,
1134            message,
1135            recommendation: Recommendation::Manual {
1136                action: "Avoid writing secrets or attacker-controlled values to $GITHUB_ENV / $GITHUB_PATH / pipeline variables. Use explicit step outputs with narrow scoping instead.".into(),
1137            },
1138            source: FindingSource::BuiltIn,
1139                extras: FindingExtras::default(),
1140});
1141    }
1142
1143    findings
1144}
1145
1146/// Rule: PR-triggered pipeline performs a self checkout.
1147///
1148/// When a PR/PRT-triggered pipeline checks out the repository, attacker-controlled
1149/// code from the fork lands on the runner. Any subsequent step that reads workspace
1150/// files (which is almost all of them) can exfiltrate secrets or tamper with build
1151/// artifacts. Fires only when the graph has a PR-class trigger.
1152pub fn checkout_self_pr_exposure(graph: &AuthorityGraph) -> Vec<Finding> {
1153    // Only fires when the graph has a PR/PRT trigger
1154    let trigger = graph.metadata.get(META_TRIGGER).map(|s| s.as_str());
1155    let is_pr_context = matches!(trigger, Some("pr") | Some("pull_request_target"));
1156    if !is_pr_context {
1157        return vec![];
1158    }
1159
1160    let mut findings = Vec::new();
1161    for step in graph.nodes_of_kind(NodeKind::Step) {
1162        let is_checkout_self = step
1163            .metadata
1164            .get(META_CHECKOUT_SELF)
1165            .map(|v| v == "true")
1166            .unwrap_or(false);
1167        if !is_checkout_self {
1168            continue;
1169        }
1170        findings.push(Finding {
1171            category: FindingCategory::CheckoutSelfPrExposure,
1172            severity: Severity::High,
1173            message: format!(
1174                "PR-triggered pipeline checks out the repository at step '{}' — \
1175                 attacker-controlled code from the fork lands on the runner and is \
1176                 readable by all subsequent steps",
1177                step.name
1178            ),
1179            path: None,
1180            nodes_involved: vec![step.id],
1181            recommendation: Recommendation::Manual {
1182                action: "Use `persist-credentials: false` and avoid reading workspace \
1183                         files in subsequent privileged steps. Consider `checkout: none` \
1184                         for jobs that only need pipeline config, not source code."
1185                    .into(),
1186            },
1187            source: FindingSource::BuiltIn,
1188            // Splitting privileged from PR-checkout jobs is a meaningful
1189            // restructure — Medium effort.
1190            extras: FindingExtras {
1191                time_to_fix: Some(crate::finding::FixEffort::Medium),
1192                ..FindingExtras::default()
1193            },
1194        });
1195    }
1196    findings
1197}
1198
1199/// Rule: ADO variable group consumed by a PR-triggered job.
1200///
1201/// Variable groups hold secrets scoped to pipelines. When a PR-triggered job has
1202/// `HasAccessTo` a Secret/Identity carrying `META_VARIABLE_GROUP = "true"`, those
1203/// secrets cross into an untrusted-contributor execution context.
1204pub fn variable_group_in_pr_job(graph: &AuthorityGraph) -> Vec<Finding> {
1205    // Only fires when the pipeline has a PR trigger
1206    let trigger = graph
1207        .metadata
1208        .get(META_TRIGGER)
1209        .map(|s| s.as_str())
1210        .unwrap_or("");
1211    if trigger != "pull_request_target" && trigger != "pr" {
1212        return Vec::new();
1213    }
1214
1215    let mut findings = Vec::new();
1216
1217    for step in graph.nodes_of_kind(NodeKind::Step) {
1218        let accessed_var_groups: Vec<&_> = graph
1219            .edges_from(step.id)
1220            .filter(|e| e.kind == EdgeKind::HasAccessTo)
1221            .filter_map(|e| graph.node(e.to))
1222            .filter(|n| {
1223                (n.kind == NodeKind::Secret || n.kind == NodeKind::Identity)
1224                    && n.metadata
1225                        .get(META_VARIABLE_GROUP)
1226                        .map(|v| v == "true")
1227                        .unwrap_or(false)
1228            })
1229            .collect();
1230
1231        if !accessed_var_groups.is_empty() {
1232            let group_names: Vec<_> = accessed_var_groups
1233                .iter()
1234                .map(|n| n.name.as_str())
1235                .collect();
1236            findings.push(Finding {
1237                severity: Severity::Critical,
1238                category: FindingCategory::VariableGroupInPrJob,
1239                path: None,
1240                nodes_involved: std::iter::once(step.id)
1241                    .chain(accessed_var_groups.iter().map(|n| n.id))
1242                    .collect(),
1243                message: format!(
1244                    "PR-triggered step '{}' accesses variable group(s) [{}] — secrets cross into untrusted PR execution context",
1245                    step.name,
1246                    group_names.join(", ")
1247                ),
1248                recommendation: Recommendation::CellosRemediation {
1249                    reason: format!(
1250                        "PR-triggered step '{}' can exfiltrate variable group secrets via untrusted code",
1251                        step.name
1252                    ),
1253                    spec_hint: "cellos run --network deny-all --policy requireEgressDeclared,requireRuntimeSecretDelivery".into(),
1254                },
1255                source: FindingSource::BuiltIn,
1256                        extras: FindingExtras::default(),
1257});
1258        }
1259    }
1260
1261    findings
1262}
1263
1264/// Rule: self-hosted agent pool used by a PR-triggered pipeline that also checks out the repo.
1265///
1266/// All three factors present — self-hosted pool + PR trigger + `checkout:self` — combine to
1267/// allow an attacker to land malicious git hooks on the shared runner via a PR. Those hooks
1268/// persist across pipeline runs and execute with full pipeline authority.
1269pub fn self_hosted_pool_pr_hijack(graph: &AuthorityGraph) -> Vec<Finding> {
1270    let trigger = graph
1271        .metadata
1272        .get(META_TRIGGER)
1273        .map(|s| s.as_str())
1274        .unwrap_or("");
1275    if trigger != "pull_request_target" && trigger != "pr" {
1276        return Vec::new();
1277    }
1278
1279    // Check if any Image node is self-hosted
1280    let has_self_hosted_pool = graph.nodes_of_kind(NodeKind::Image).any(|n| {
1281        n.metadata
1282            .get(META_SELF_HOSTED)
1283            .map(|v| v == "true")
1284            .unwrap_or(false)
1285    });
1286
1287    if !has_self_hosted_pool {
1288        return Vec::new();
1289    }
1290
1291    // Check if any Step does checkout:self
1292    let checkout_steps: Vec<&_> = graph
1293        .nodes_of_kind(NodeKind::Step)
1294        .filter(|n| {
1295            n.metadata
1296                .get(META_CHECKOUT_SELF)
1297                .map(|v| v == "true")
1298                .unwrap_or(false)
1299        })
1300        .collect();
1301
1302    if checkout_steps.is_empty() {
1303        return Vec::new();
1304    }
1305
1306    // All three factors present: self-hosted + PR trigger + checkout:self.
1307    // Collect self-hosted pool nodes for the finding.
1308    let pool_nodes: Vec<&_> = graph
1309        .nodes_of_kind(NodeKind::Image)
1310        .filter(|n| {
1311            n.metadata
1312                .get(META_SELF_HOSTED)
1313                .map(|v| v == "true")
1314                .unwrap_or(false)
1315        })
1316        .collect();
1317
1318    let mut nodes_involved: Vec<NodeId> = pool_nodes.iter().map(|n| n.id).collect();
1319    nodes_involved.extend(checkout_steps.iter().map(|n| n.id));
1320
1321    vec![Finding {
1322        severity: Severity::Critical,
1323        category: FindingCategory::SelfHostedPoolPrHijack,
1324        path: None,
1325        nodes_involved,
1326        message:
1327            "PR-triggered pipeline uses self-hosted agent pool with checkout:self — enables git hook injection persisting across pipeline runs on the shared runner"
1328                .into(),
1329        recommendation: Recommendation::Manual {
1330            action: "Run PR pipelines on Microsoft-hosted (ephemeral) agents, or disable checkout:self for PR-triggered jobs on self-hosted pools".into(),
1331        },
1332        source: FindingSource::BuiltIn,
1333        extras: FindingExtras::default(),
1334}]
1335}
1336
1337/// Rule: ADO service connection with broad/unknown scope and no OIDC federation,
1338/// reachable from a PR-triggered job.
1339///
1340/// Static credentials backing broad-scope service connections can carry
1341/// subscription-wide Azure RBAC. When a PR-triggered step has `HasAccessTo` one of
1342/// these, PR-author-controlled code can move laterally into the Azure tenant.
1343pub fn service_connection_scope_mismatch(graph: &AuthorityGraph) -> Vec<Finding> {
1344    let trigger = graph
1345        .metadata
1346        .get(META_TRIGGER)
1347        .map(|s| s.as_str())
1348        .unwrap_or("");
1349    if trigger != "pull_request_target" && trigger != "pr" {
1350        return Vec::new();
1351    }
1352
1353    let mut findings = Vec::new();
1354
1355    for step in graph.nodes_of_kind(NodeKind::Step) {
1356        let broad_scs: Vec<&_> = graph
1357            .edges_from(step.id)
1358            .filter(|e| e.kind == EdgeKind::HasAccessTo)
1359            .filter_map(|e| graph.node(e.to))
1360            .filter(|n| {
1361                n.kind == NodeKind::Identity
1362                    && n.metadata
1363                        .get(META_SERVICE_CONNECTION)
1364                        .map(|v| v == "true")
1365                        .unwrap_or(false)
1366                    && n.metadata
1367                        .get(META_OIDC)
1368                        .map(|v| v != "true")
1369                        .unwrap_or(true) // not OIDC-federated
1370                    && matches!(
1371                        n.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
1372                        Some("broad") | Some("Broad") | None // unknown scope is also a risk
1373                    )
1374            })
1375            .collect();
1376
1377        for sc in &broad_scs {
1378            findings.push(Finding {
1379                severity: Severity::High,
1380                category: FindingCategory::ServiceConnectionScopeMismatch,
1381                path: None,
1382                nodes_involved: vec![step.id, sc.id],
1383                message: format!(
1384                    "PR-triggered step '{}' accesses service connection '{}' with broad/unknown scope and no OIDC federation — static credential may have subscription-wide Azure RBAC",
1385                    step.name, sc.name
1386                ),
1387                recommendation: Recommendation::CellosRemediation {
1388                    reason: "Broad-scope service connection reachable from PR code — CellOS egress isolation limits lateral movement even when connection cannot be immediately rescoped".into(),
1389                    spec_hint: "cellos run --network deny-all --policy requireEgressDeclared".into(),
1390                },
1391                source: FindingSource::BuiltIn,
1392                        extras: FindingExtras::default(),
1393});
1394        }
1395    }
1396
1397    findings
1398}
1399
1400/// ADO-only rule: a `resources.repositories[]` entry resolves against a
1401/// mutable target — no `ref:` field (default branch) or `refs/heads/<x>`
1402/// without a SHA. Whoever owns that branch can inject steps into every
1403/// consuming pipeline at the next run.
1404///
1405/// Pinned forms that do NOT fire:
1406///   - `refs/tags/<x>` — git tags (treated as immutable in practice)
1407///   - bare 40-char hex SHA — explicit commit pin
1408///   - `refs/heads/<sha>` where the trailing segment is a 40-char hex SHA
1409///
1410/// Mutable forms that DO fire:
1411///   - field absent — defaults to the repo's default branch
1412///   - `refs/heads/<branch>` with a normal branch name
1413///   - bare branch name (`main`, `master`, `develop`, ...)
1414///
1415/// Suppression: a repository entry declared with NO `ref:` field AND no
1416/// in-file consumer (`extends:`, `template: x@alias`, or `checkout: alias`)
1417/// is skipped. This catches purely vestigial declarations — a leftover
1418/// `resources.repositories[]` entry that no one references is not an active
1419/// attack surface. An entry with an explicit `ref: refs/heads/<x>` always
1420/// fires regardless of in-file usage, because the explicit branch ref
1421/// signals an intent to consume (the consumer is typically in an included
1422/// template file outside the per-file scan boundary).
1423pub fn template_extends_unpinned_branch(graph: &AuthorityGraph) -> Vec<Finding> {
1424    let raw = match graph.metadata.get(META_REPOSITORIES) {
1425        Some(s) => s,
1426        None => return Vec::new(),
1427    };
1428    let entries: Vec<serde_json::Value> = match serde_json::from_str(raw) {
1429        Ok(v) => v,
1430        Err(_) => return Vec::new(),
1431    };
1432
1433    let mut findings = Vec::new();
1434    for entry in entries {
1435        let alias = match entry.get("alias").and_then(|v| v.as_str()) {
1436            Some(a) => a,
1437            None => continue,
1438        };
1439        let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(alias);
1440        let repo_type = entry
1441            .get("repo_type")
1442            .and_then(|v| v.as_str())
1443            .unwrap_or("git");
1444        let ref_value = entry.get("ref").and_then(|v| v.as_str());
1445        let used = entry.get("used").and_then(|v| v.as_bool()).unwrap_or(false);
1446
1447        let classification = classify_repository_ref(ref_value);
1448        let resolved = match classification {
1449            RepositoryRefClass::Pinned => continue,
1450            RepositoryRefClass::DefaultBranch => {
1451                // Default-branch entries are only flagged when an in-file
1452                // consumer actually references the alias. Without an explicit
1453                // `ref:` and without a consumer there's no evidence the
1454                // declaration is active — likely vestigial.
1455                if !used {
1456                    continue;
1457                }
1458                "default branch (no ref:)".to_string()
1459            }
1460            RepositoryRefClass::MutableBranch(b) => format!("mutable branch '{b}'"),
1461        };
1462
1463        let pinned_example = format!("ref: <40-char-sha>  # commit on {name}");
1464        findings.push(Finding {
1465            severity: Severity::High,
1466            category: FindingCategory::TemplateExtendsUnpinnedBranch,
1467            path: None,
1468            nodes_involved: Vec::new(),
1469            message: format!(
1470                "ADO resources.repositories alias '{alias}' (type: {repo_type}, name: {name}) resolves to {resolved} — \
1471                 whoever owns that branch can inject steps at the next pipeline run"
1472            ),
1473            recommendation: Recommendation::PinAction {
1474                current: ref_value.unwrap_or("(default branch)").to_string(),
1475                pinned: pinned_example,
1476            },
1477            source: FindingSource::BuiltIn,
1478                extras: FindingExtras::default(),
1479});
1480    }
1481
1482    findings
1483}
1484
1485/// ADO-only rule: a `resources.repositories[]` entry pins to a *feature-class*
1486/// branch — anything outside the platform-blessed set
1487/// (`main`, `master`, `release/*`, `hotfix/*`).
1488///
1489/// Strictly stronger signal than [`template_extends_unpinned_branch`]:
1490///
1491/// * `template_extends_unpinned_branch` fires on *any* mutable branch ref
1492///   (including `main` and `master`) — the abstract "ref isn't pinned to a
1493///   SHA or tag" finding.
1494/// * This rule fires only on the subset that's *worse than main*: a developer
1495///   feature branch (`feature/*`, `topic/*`, `dev/*`, `wip/*`, `users/*`,
1496///   `develop`, …) where push protection is typically weaker than the trunk.
1497///
1498/// The two findings co-fire intentionally — they describe different angles of
1499/// the same risk class. `template_extends_unpinned_branch` says "this isn't
1500/// pinned"; this rule adds "and the branch it points to is one any developer
1501/// can push to without a code review gate".
1502///
1503/// Detection inputs are identical to `template_extends_unpinned_branch`:
1504/// `META_REPOSITORIES` JSON array, with the same `used` suppression for
1505/// `ref`-absent entries.
1506///
1507/// Pinned forms (40-char SHA, `refs/tags/<x>`, `refs/heads/<sha>`) do not
1508/// fire — same classification helper as the parent rule.
1509///
1510/// Default-branch (no-`ref:`) entries do not fire from this rule. The default
1511/// branch is conventionally `main`/`master`, and even when it's something
1512/// else the *implicit* default-branch contract carries less risk than an
1513/// explicit feature-branch pin (the default branch usually has the strongest
1514/// protection in the org). The plain "this isn't pinned" surface is left to
1515/// `template_extends_unpinned_branch`.
1516pub fn template_repo_ref_is_feature_branch(graph: &AuthorityGraph) -> Vec<Finding> {
1517    let raw = match graph.metadata.get(META_REPOSITORIES) {
1518        Some(s) => s,
1519        None => return Vec::new(),
1520    };
1521    let entries: Vec<serde_json::Value> = match serde_json::from_str(raw) {
1522        Ok(v) => v,
1523        Err(_) => return Vec::new(),
1524    };
1525
1526    let mut findings = Vec::new();
1527    for entry in entries {
1528        let alias = match entry.get("alias").and_then(|v| v.as_str()) {
1529            Some(a) => a,
1530            None => continue,
1531        };
1532        let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(alias);
1533        let repo_type = entry
1534            .get("repo_type")
1535            .and_then(|v| v.as_str())
1536            .unwrap_or("git");
1537        let ref_value = entry.get("ref").and_then(|v| v.as_str());
1538
1539        // Only explicit refs are candidates here — the parent rule covers the
1540        // ref-absent case via the default-branch path.
1541        let branch = match classify_repository_ref(ref_value) {
1542            RepositoryRefClass::MutableBranch(b) => b,
1543            RepositoryRefClass::Pinned | RepositoryRefClass::DefaultBranch => continue,
1544        };
1545
1546        if !is_feature_class_branch(&branch) {
1547            continue;
1548        }
1549
1550        let pinned_example = format!("ref: <40-char-sha>  # commit on {name}");
1551        findings.push(Finding {
1552            severity: Severity::High,
1553            category: FindingCategory::TemplateRepoRefIsFeatureBranch,
1554            path: None,
1555            nodes_involved: Vec::new(),
1556            message: format!(
1557                "ADO resources.repositories alias '{alias}' (type: {repo_type}, name: {name}) is pinned to feature-class branch '{branch}' — \
1558                 weaker than even an unpinned trunk pin: any developer with write access to that branch can inject pipeline steps without a code review on main"
1559            ),
1560            recommendation: Recommendation::PinAction {
1561                current: ref_value.unwrap_or("(default branch)").to_string(),
1562                pinned: pinned_example,
1563            },
1564            source: FindingSource::BuiltIn,
1565                extras: FindingExtras::default(),
1566});
1567    }
1568
1569    findings
1570}
1571
1572/// Returns `true` for ADO branch names that are *not* part of the
1573/// platform-blessed trunk/release set. The blessed set:
1574///
1575///   - `main`, `master`
1576///   - `release/*`, `releases/*`
1577///   - `hotfix/*`, `hotfixes/*`
1578///
1579/// Everything else — `feature/*`, `topic/*`, `dev/*`, `wip/*`, `users/*`,
1580/// `develop`, ad-hoc names — is treated as feature-class.
1581///
1582/// Comparison is case-insensitive and prefix-stripped of any leading
1583/// `refs/heads/` (the [`classify_repository_ref`] caller already strips it,
1584/// but defensive normalisation keeps this helper standalone-testable).
1585fn is_feature_class_branch(branch: &str) -> bool {
1586    let normalised = branch
1587        .trim()
1588        .trim_start_matches("refs/heads/")
1589        .to_ascii_lowercase();
1590
1591    if normalised.is_empty() {
1592        return false;
1593    }
1594
1595    // Exact-match trunk names.
1596    if matches!(normalised.as_str(), "main" | "master") {
1597        return false;
1598    }
1599
1600    // Prefix-match release / hotfix branches (with or without trailing slash).
1601    const TRUNK_PREFIXES: &[&str] = &["release/", "releases/", "hotfix/", "hotfixes/"];
1602    for p in TRUNK_PREFIXES {
1603        if normalised == p.trim_end_matches('/') || normalised.starts_with(p) {
1604            return false;
1605        }
1606    }
1607
1608    true
1609}
1610
1611// ── Command-line credential leakage helpers ─────────────
1612//
1613// These two rules (`vm_remote_exec_via_pipeline_secret`,
1614// `short_lived_sas_in_command_line`) inspect inline script bodies stamped on
1615// Step nodes by the parser as `META_SCRIPT_BODY`. They are intentionally
1616// heuristic — the goal is reliable detection of the corpus pattern, not 100%
1617// false-positive cleanliness. They're allowed to co-fire on the same step:
1618// each describes a different angle of the same risk class.
1619
1620/// Names of the Azure VM remote-execution primitives we care about.
1621/// Match is case-insensitive on the script body.
1622const VM_REMOTE_EXEC_TOKENS: &[&str] = &[
1623    "set-azvmextension",
1624    "invoke-azvmruncommand",
1625    "az vm run-command",
1626    "az vm extension set",
1627];
1628
1629/// Substrings that indicate a SAS token has just been minted in this script.
1630/// Match is case-insensitive on the script body.
1631const SAS_MINT_TOKENS: &[&str] = &[
1632    "new-azstoragecontainersastoken",
1633    "new-azstorageblobsastoken",
1634    "new-azstorageaccountsastoken",
1635    "az storage container generate-sas",
1636    "az storage blob generate-sas",
1637    "az storage account generate-sas",
1638];
1639
1640/// Argument-passing keywords that put a value on the process command line and
1641/// thus into ARM extension status / OS process logs.
1642const COMMAND_LINE_SINK_TOKENS: &[&str] = &[
1643    "commandtoexecute",
1644    "scriptarguments",
1645    "--arguments",
1646    "-argumentlist",
1647    "--scripts",
1648    "-scriptstring",
1649];
1650
1651/// Returns the names of pipeline secret/SAS variables (`$(NAME)`) that the
1652/// step references via `HasAccessTo` a Secret. Used to spot interpolation of
1653/// pipeline secrets into command-line strings.
1654fn step_secret_var_names(graph: &AuthorityGraph, step_id: NodeId) -> Vec<&str> {
1655    graph
1656        .edges_from(step_id)
1657        .filter(|e| e.kind == EdgeKind::HasAccessTo)
1658        .filter_map(|e| graph.node(e.to))
1659        .filter(|n| n.kind == NodeKind::Secret)
1660        .map(|n| n.name.as_str())
1661        .collect()
1662}
1663
1664/// Returns the names of all Secret nodes a step has `HasAccessTo`.
1665/// Used by the script-aware ADO rules to constrain pattern matches to
1666/// `$(VAR)` references that actually resolve to secrets in this graph.
1667fn step_secret_names(graph: &AuthorityGraph, step_id: NodeId) -> Vec<String> {
1668    graph
1669        .edges_from(step_id)
1670        .filter(|e| e.kind == EdgeKind::HasAccessTo)
1671        .filter_map(|e| graph.node(e.to))
1672        .filter(|n| n.kind == NodeKind::Secret)
1673        .map(|n| n.name.clone())
1674        .collect()
1675}
1676
1677/// Heuristic: returns true if a value-bearing variable named `var_name` appears
1678/// to be interpolated into `script_body` (PowerShell `$var` / `"$var"` /
1679/// `` `"$var`" `` form, or ADO `$(var)` form). Case-insensitive.
1680fn body_interpolates_var(script_body: &str, var_name: &str) -> bool {
1681    if var_name.is_empty() {
1682        return false;
1683    }
1684    let body = script_body.to_lowercase();
1685    let name = var_name.to_lowercase();
1686    // ADO macro form
1687    let dollar_paren = format!("$({name})");
1688    if body.contains(&dollar_paren) {
1689        return true;
1690    }
1691    // PowerShell variable form: must be followed by a non-identifier char to
1692    // avoid matching `$varSomething` as `$var`.
1693    let needle = format!("${name}");
1694    let mut search_from = 0usize;
1695    while let Some(pos) = body[search_from..].find(&needle) {
1696        let abs = search_from + pos;
1697        let end = abs + needle.len();
1698        let next = body.as_bytes().get(end).copied();
1699        let is_word = matches!(next, Some(c) if c.is_ascii_alphanumeric() || c == b'_');
1700        if !is_word {
1701            return true;
1702        }
1703        search_from = end;
1704    }
1705    false
1706}
1707
1708/// Returns true if `script` contains `$(secret)` and that occurrence sits on
1709/// a line whose left-hand side looks like a shell-variable assignment:
1710///   - `export FOO=$(SECRET)`
1711///   - `FOO="$(SECRET)"`
1712///   - `$X = "$(SECRET)"` / `$env:X = "$(SECRET)"`
1713///   - `set -a` followed by an assignment is a softer signal but still flagged
1714///
1715/// Returns false when `$(secret)` is part of a command-line argument
1716/// (e.g. `terraform plan -var "k=$(SECRET)"`) — that's covered by other rules.
1717fn script_assigns_secret_to_shell_var(script: &str, secret: &str) -> bool {
1718    let needle = format!("$({secret})");
1719    for line in script.lines() {
1720        if !line.contains(&needle) {
1721            continue;
1722        }
1723        // Strip everything from `$(secret)` rightward — we only inspect what
1724        // comes before it on this line.
1725        let lhs = match line.find(&needle) {
1726            Some(pos) => &line[..pos],
1727            None => continue,
1728        };
1729        let trimmed = lhs.trim_start();
1730
1731        // bash/sh: `export VAR=`, `VAR=`, `set VAR=`, `declare VAR=`
1732        // Look for `<word>=` (no space allowed before `=`) and no leading
1733        // command pipe / non-assignment indicator.
1734        if matches_bash_assignment(trimmed) {
1735            return true;
1736        }
1737
1738        // PowerShell: `$VAR = "..."`, `$env:VAR = "..."`, `${VAR} = "..."`,
1739        // `Set-Variable -Name X -Value "$(SECRET)"`.
1740        if matches_powershell_assignment(trimmed) {
1741            return true;
1742        }
1743    }
1744    false
1745}
1746
1747/// Returns true if `body` contains any of the SAS-mint token substrings.
1748fn body_mints_sas(body_lower: &str) -> bool {
1749    SAS_MINT_TOKENS.iter().any(|t| body_lower.contains(t))
1750}
1751
1752/// Returns true if `body` contains any of the VM remote-exec tool substrings.
1753fn body_uses_vm_remote_exec(body_lower: &str) -> bool {
1754    VM_REMOTE_EXEC_TOKENS.iter().any(|t| body_lower.contains(t))
1755}
1756
1757/// Returns true if `body` contains any command-line sink keyword.
1758fn body_has_cmdline_sink(body_lower: &str) -> bool {
1759    COMMAND_LINE_SINK_TOKENS
1760        .iter()
1761        .any(|t| body_lower.contains(t))
1762}
1763
1764/// Extract names of PowerShell variables that are bound to a SAS-mint result.
1765/// Pattern: `$<name> = New-AzStorage...SASToken ...` (case-insensitive).
1766/// Returns the variable names without the leading `$`.
1767fn powershell_sas_assignments(body: &str) -> Vec<String> {
1768    let mut out = Vec::new();
1769    let lower = body.to_lowercase();
1770    let bytes = lower.as_bytes();
1771    let mut i = 0usize;
1772    while i < bytes.len() {
1773        if bytes[i] != b'$' {
1774            i += 1;
1775            continue;
1776        }
1777        // Read identifier
1778        let name_start = i + 1;
1779        let mut j = name_start;
1780        while j < bytes.len() {
1781            let c = bytes[j];
1782            if c.is_ascii_alphanumeric() || c == b'_' {
1783                j += 1;
1784            } else {
1785                break;
1786            }
1787        }
1788        if j == name_start {
1789            i += 1;
1790            continue;
1791        }
1792        // Skip whitespace, then expect `=`
1793        let mut k = j;
1794        while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') {
1795            k += 1;
1796        }
1797        if k >= bytes.len() || bytes[k] != b'=' {
1798            i = j;
1799            continue;
1800        }
1801        // Skip `=` and whitespace
1802        k += 1;
1803        while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') {
1804            k += 1;
1805        }
1806        // Look at the rest of this logical line (until `\n`).
1807        let line_end = lower[k..].find('\n').map(|p| k + p).unwrap_or(bytes.len());
1808        let rhs = &lower[k..line_end];
1809        if SAS_MINT_TOKENS.iter().any(|t| rhs.contains(t)) {
1810            // Recover original-case variable name from `body` at the same byte
1811            // offsets — `lower` and `body` share UTF-8 byte layout for ASCII,
1812            // and identifiers in PowerShell are ASCII in the corpus.
1813            let name = body
1814                .get(name_start..j)
1815                .unwrap_or(&lower[name_start..j])
1816                .to_string();
1817            if !out.iter().any(|n: &String| n.eq_ignore_ascii_case(&name)) {
1818                out.push(name);
1819            }
1820        }
1821        i = j;
1822    }
1823    out
1824}
1825
1826/// Rule: pipeline step uses an Azure VM remote-execution primitive
1827/// (Set-AzVMExtension/CustomScriptExtension, Invoke-AzVMRunCommand,
1828/// `az vm run-command invoke`, `az vm extension set`) where the executed
1829/// command line is constructed from a pipeline secret or a freshly-minted
1830/// SAS token.
1831///
1832/// Pipeline-to-VM lateral movement primitive: every pipeline run can RCE every
1833/// VM in scope, and the SAS/secret embedded in the command line is logged in
1834/// plaintext on the VM and in the ARM extension status JSON.
1835///
1836/// Detection: read each Step's `META_SCRIPT_BODY`. If the body contains a
1837/// remote-exec tool name AND (it interpolates a known pipeline secret variable
1838/// OR it mints a SAS token in the same body), fire one finding per step.
1839pub fn vm_remote_exec_via_pipeline_secret(graph: &AuthorityGraph) -> Vec<Finding> {
1840    let mut findings = Vec::new();
1841
1842    for step in graph.nodes_of_kind(NodeKind::Step) {
1843        let body = match step.metadata.get(META_SCRIPT_BODY) {
1844            Some(b) if !b.is_empty() => b,
1845            _ => continue,
1846        };
1847        let body_lower = body.to_lowercase();
1848        if !body_uses_vm_remote_exec(&body_lower) {
1849            continue;
1850        }
1851
1852        let secret_names = step_secret_var_names(graph, step.id);
1853        let secret_interpolated = secret_names
1854            .iter()
1855            .any(|name| body_interpolates_var(body, name));
1856        let mints_sas = body_mints_sas(&body_lower);
1857
1858        if !secret_interpolated && !mints_sas {
1859            continue;
1860        }
1861
1862        // Pick a single tool name for the message.
1863        let tool = VM_REMOTE_EXEC_TOKENS
1864            .iter()
1865            .find(|t| body_lower.contains(*t))
1866            .copied()
1867            .unwrap_or("Set-AzVMExtension");
1868
1869        let trigger = if secret_interpolated {
1870            "interpolating a pipeline secret into the executed command line"
1871        } else {
1872            "embedding a freshly-minted SAS token into the executed command line"
1873        };
1874
1875        let mut nodes_involved = vec![step.id];
1876        // Include the secret nodes the step has access to so consumers can
1877        // attribute the finding to the leaked credential.
1878        for edge in graph.edges_from(step.id) {
1879            if edge.kind == EdgeKind::HasAccessTo {
1880                if let Some(n) = graph.node(edge.to) {
1881                    if n.kind == NodeKind::Secret {
1882                        nodes_involved.push(n.id);
1883                    }
1884                }
1885            }
1886        }
1887
1888        findings.push(Finding {
1889            severity: Severity::High,
1890            category: FindingCategory::VmRemoteExecViaPipelineSecret,
1891            path: None,
1892            nodes_involved,
1893            message: format!(
1894                "Step '{}' uses {} {} — pipeline-to-VM RCE primitive; credential is logged on the VM and in ARM extension status",
1895                step.name, tool, trigger
1896            ),
1897            recommendation: Recommendation::Manual {
1898                action: "Stage the script on the VM and pass the SAS via env var or protectedSettings (encrypted, not logged); avoid embedding secrets in commandToExecute".into(),
1899            },
1900            source: FindingSource::BuiltIn,
1901                extras: FindingExtras::default(),
1902});
1903    }
1904
1905    findings
1906}
1907
1908/// Heuristic: line prefix looks like a bash/sh assignment to an env var.
1909/// Conservative — only matches when the LHS contains `<keyword>? IDENT=` and
1910/// nothing after the `=` other than optional opening quote characters.
1911fn matches_bash_assignment(lhs: &str) -> bool {
1912    // `export FOO=`, `declare FOO=`, `local FOO=`, `readonly FOO=`, plain `FOO=`
1913    let after_keyword = strip_one_of(lhs, &["export ", "declare ", "local ", "readonly "])
1914        .unwrap_or(lhs)
1915        .trim_start();
1916    // Allow trailing opening-quote characters between `=` and the secret ref.
1917    let trimmed = after_keyword.trim_end_matches(['"', '\'']);
1918    let Some(ident) = trimmed.strip_suffix('=') else {
1919        return false;
1920    };
1921    !ident.is_empty()
1922        && ident.chars().all(is_shell_var_char)
1923        && !ident.starts_with(|c: char| c.is_ascii_digit())
1924}
1925
1926/// Heuristic: line prefix looks like a PowerShell assignment.
1927fn matches_powershell_assignment(lhs: &str) -> bool {
1928    // Strip trailing opening quote and whitespace so `$x = "$(SECRET)` matches.
1929    let trimmed = lhs.trim_end().trim_end_matches(['"', '\'']).trim_end();
1930    if let Some(before_eq) = trimmed.strip_suffix('=') {
1931        let before_eq = before_eq.trim_end();
1932        if before_eq.starts_with('$') {
1933            return true;
1934        }
1935    }
1936    // `Set-Variable ... -Value`
1937    if trimmed.contains("Set-Variable") && trimmed.contains("-Value") {
1938        return true;
1939    }
1940    false
1941}
1942
1943fn is_shell_var_char(c: char) -> bool {
1944    c.is_ascii_alphanumeric() || c == '_'
1945}
1946
1947fn strip_one_of<'a>(s: &'a str, prefixes: &[&str]) -> Option<&'a str> {
1948    for p in prefixes {
1949        if let Some(rest) = s.strip_prefix(p) {
1950            return Some(rest);
1951        }
1952    }
1953    None
1954}
1955
1956/// Rule: pipeline secret exported via shell variable inside an inline script.
1957///
1958/// Severity: High. ADO masks the literal token `$(SECRET)` when it appears in
1959/// log output, but masking happens on the rendered command string before the
1960/// shell runs. Once the value is bound to a shell variable, downstream
1961/// transcripts (`Start-Transcript`, `bash -x`, terraform `TF_LOG=DEBUG`,
1962/// `az --debug`) print the cleartext.
1963pub fn secret_to_inline_script_env_export(graph: &AuthorityGraph) -> Vec<Finding> {
1964    let mut findings = Vec::new();
1965
1966    for step in graph.nodes_of_kind(NodeKind::Step) {
1967        let Some(script) = step.metadata.get(META_SCRIPT_BODY) else {
1968            continue;
1969        };
1970        if script.is_empty() {
1971            continue;
1972        }
1973        let secrets = step_secret_names(graph, step.id);
1974        let exposed: Vec<String> = secrets
1975            .into_iter()
1976            .filter(|s| script_assigns_secret_to_shell_var(script, s))
1977            .collect();
1978
1979        if exposed.is_empty() {
1980            continue;
1981        }
1982
1983        let n = exposed.len();
1984        let preview: String = exposed
1985            .iter()
1986            .take(3)
1987            .map(|s| format!("$({s})"))
1988            .collect::<Vec<_>>()
1989            .join(", ");
1990        let suffix = if n > 3 {
1991            format!(", and {} more", n - 3)
1992        } else {
1993            String::new()
1994        };
1995        let secret_node_ids: Vec<NodeId> = graph
1996            .edges_from(step.id)
1997            .filter(|e| e.kind == EdgeKind::HasAccessTo)
1998            .filter_map(|e| graph.node(e.to))
1999            .filter(|n| n.kind == NodeKind::Secret && exposed.contains(&n.name))
2000            .map(|n| n.id)
2001            .collect();
2002
2003        let mut nodes_involved = vec![step.id];
2004        nodes_involved.extend(secret_node_ids);
2005
2006        findings.push(Finding {
2007            severity: Severity::High,
2008            category: FindingCategory::SecretToInlineScriptEnvExport,
2009            path: None,
2010            nodes_involved,
2011            message: format!(
2012                "Step '{}' assigns pipeline secret(s) {preview}{suffix} to shell variables inside an inline script — once bound to a variable the value bypasses ADO's $(SECRET) log mask and will appear in any transcript (Start-Transcript, bash -x, terraform/az --debug)",
2013                step.name
2014            ),
2015            recommendation: Recommendation::TsafeRemediation {
2016                command: "tsafe exec --ns <scoped-namespace> -- <command>".to_string(),
2017                explanation: "Inject the secret as an env var on the step itself (ADO `env:` block) instead of materialising it inside the script body. The value still reaches the process but never travels through a shell variable assignment that transcripts can capture.".to_string(),
2018            },
2019            source: FindingSource::BuiltIn,
2020                extras: FindingExtras::default(),
2021});
2022    }
2023
2024    findings
2025}
2026
2027/// How a `resources.repositories[].ref` value resolves for the purposes of
2028/// the `template_extends_unpinned_branch` rule.
2029enum RepositoryRefClass {
2030    /// SHA-pinned, tag-pinned — code at the consumer is immutable.
2031    Pinned,
2032    /// No `ref:` field — resolves to the repo's default branch.
2033    DefaultBranch,
2034    /// `refs/heads/<name>` or bare branch — mutable.
2035    MutableBranch(String),
2036}
2037
2038fn classify_repository_ref(ref_value: Option<&str>) -> RepositoryRefClass {
2039    let raw = match ref_value {
2040        None => return RepositoryRefClass::DefaultBranch,
2041        Some(s) if s.trim().is_empty() => return RepositoryRefClass::DefaultBranch,
2042        Some(s) => s.trim(),
2043    };
2044
2045    // Bare 40+ hex SHA — pinned.
2046    if is_hex_sha(raw) {
2047        return RepositoryRefClass::Pinned;
2048    }
2049
2050    // refs/tags/<x> — pinned.
2051    if let Some(tag) = raw.strip_prefix("refs/tags/") {
2052        if !tag.is_empty() {
2053            return RepositoryRefClass::Pinned;
2054        }
2055    }
2056
2057    // refs/heads/<x> — mutable, unless trailing segment is a SHA.
2058    if let Some(branch) = raw.strip_prefix("refs/heads/") {
2059        if is_hex_sha(branch) {
2060            return RepositoryRefClass::Pinned;
2061        }
2062        return RepositoryRefClass::MutableBranch(branch.to_string());
2063    }
2064
2065    // Bare value — treat as a branch name.
2066    RepositoryRefClass::MutableBranch(raw.to_string())
2067}
2068
2069fn is_hex_sha(s: &str) -> bool {
2070    s.len() >= 40 && s.chars().all(|c| c.is_ascii_hexdigit())
2071}
2072
2073/// Rule: a SAS token minted in-pipeline is passed as a CLI argument or
2074/// interpolated into `commandToExecute` / `scriptArguments` / `--arguments` /
2075/// `-ArgumentList` rather than via env var or stdin.
2076///
2077/// Even short-lived SAS tokens in argv hit Linux `/proc/*/cmdline`, Windows
2078/// ETW process-create events, and ARM extension status — logged for the
2079/// SAS lifetime.
2080///
2081/// Detection: read each Step's `META_SCRIPT_BODY`. Body must (a) mint a SAS
2082/// token AND (b) reference a command-line sink keyword. Heuristic acceptable:
2083/// the goal is to catch the corpus pattern, not perfect specificity.
2084pub fn short_lived_sas_in_command_line(graph: &AuthorityGraph) -> Vec<Finding> {
2085    let mut findings = Vec::new();
2086
2087    for step in graph.nodes_of_kind(NodeKind::Step) {
2088        let body = match step.metadata.get(META_SCRIPT_BODY) {
2089            Some(b) if !b.is_empty() => b,
2090            _ => continue,
2091        };
2092        let body_lower = body.to_lowercase();
2093
2094        if !body_mints_sas(&body_lower) {
2095            continue;
2096        }
2097        if !body_has_cmdline_sink(&body_lower) {
2098            continue;
2099        }
2100
2101        // Tighten precision: at least one minted-SAS variable must actually
2102        // appear interpolated somewhere in the script body. This filters out
2103        // scripts that mint a SAS purely for upload-to-blob and never put it
2104        // on argv.
2105        let sas_vars = powershell_sas_assignments(body);
2106        let mut interpolated_var: Option<String> = None;
2107        for v in &sas_vars {
2108            if body_interpolates_var(body, v) {
2109                interpolated_var = Some(v.clone());
2110                break;
2111            }
2112        }
2113        // If we couldn't bind a SAS var (e.g. inline `az`-CLI subshell), fall
2114        // back to "mint+sink in same script" — still better than no signal.
2115        let evidence = interpolated_var
2116            .as_deref()
2117            .map(|v| format!("$ {v} interpolated into argv"))
2118            .unwrap_or_else(|| "SAS-mint and command-line sink in same script".to_string());
2119
2120        findings.push(Finding {
2121            severity: Severity::Medium,
2122            category: FindingCategory::ShortLivedSasInCommandLine,
2123            path: None,
2124            nodes_involved: vec![step.id],
2125            message: format!(
2126                "Step '{}' mints a SAS token and passes it on the command line ({}) — argv lands in /proc, ETW, and ARM extension status for the token's lifetime",
2127                step.name, evidence
2128            ),
2129            recommendation: Recommendation::Manual {
2130                action: "Pass the SAS via env var, stdin, or VM-extension protectedSettings; never put SAS tokens in commandToExecute / --arguments / -ArgumentList".into(),
2131            },
2132            source: FindingSource::BuiltIn,
2133                extras: FindingExtras::default(),
2134});
2135    }
2136
2137    findings
2138}
2139
2140/// Returns true if `line` contains a sink that writes its left-hand-side
2141/// content to a file path. Recognises the common bash and PowerShell
2142/// "write to file" idioms.
2143fn line_writes_to_file(line: &str) -> bool {
2144    // bash: `>`, `>>`, `tee`, `cat <<`/`<<-` heredoc redirected with `>`
2145    if line.contains(" > ")
2146        || line.contains(" >> ")
2147        || line.contains(">/")
2148        || line.contains(">>/")
2149        || line.contains("| tee ")
2150        || line.contains("| tee -")
2151        || line.starts_with("tee ")
2152    {
2153        return true;
2154    }
2155    // PowerShell: Out-File, Set-Content, Add-Content, [IO.File]::WriteAllText
2156    let lower = line.to_lowercase();
2157    if lower.contains("out-file")
2158        || lower.contains("set-content")
2159        || lower.contains("add-content")
2160        || lower.contains("writealltext")
2161        || lower.contains("writealllines")
2162    {
2163        return true;
2164    }
2165    false
2166}
2167
2168/// Returns true if `line` references a workspace path or a config-file
2169/// extension we consider risky for secret materialisation.
2170fn line_references_workspace_path(line: &str) -> bool {
2171    let lower = line.to_lowercase();
2172    if lower.contains("$(system.defaultworkingdirectory)")
2173        || lower.contains("$(build.sourcesdirectory)")
2174        || lower.contains("$(pipeline.workspace)")
2175        || lower.contains("$(agent.builddirectory)")
2176        || lower.contains("$(agent.tempdirectory)")
2177    {
2178        return true;
2179    }
2180    // Common credential / config file extensions
2181    const RISKY_EXT: &[&str] = &[
2182        ".tfvars",
2183        ".env",
2184        ".hcl",
2185        ".pfx",
2186        ".key",
2187        ".pem",
2188        ".crt",
2189        ".p12",
2190        ".kubeconfig",
2191        ".jks",
2192        ".keystore",
2193    ];
2194    RISKY_EXT.iter().any(|ext| lower.contains(ext))
2195}
2196
2197/// Heuristic: returns true if `script` materialises `secret` to a workspace
2198/// file. Looks for a single line that contains the secret reference AND a
2199/// "write to file" sink AND a workspace/credfile path target.
2200///
2201/// Also detects the heredoc + Out-File pattern across multiple lines:
2202/// the secret appears inside a `@" ... "@` block whose final pipe is
2203/// `Out-File <workspace-path>`.
2204fn script_materialises_secret_to_file(script: &str, secret: &str) -> bool {
2205    let needle = format!("$({secret})");
2206
2207    // Pass 1: single-line write. Catches `echo $(SECRET) > /tmp/x.env`,
2208    // `Out-File ... $(SECRET) ...`, etc.
2209    for line in script.lines() {
2210        if line.contains(&needle)
2211            && line_writes_to_file(line)
2212            && line_references_workspace_path(line)
2213        {
2214            return true;
2215        }
2216    }
2217
2218    // Pass 2: PowerShell pattern `$X = "$(SECRET)"` followed by the variable
2219    // being piped into Out-File / Set-Content with a workspace path. We
2220    // detect this conservatively: if any line assigns `$x = "$(SECRET)"`
2221    // AND any *later* line both writes-to-file and references a workspace
2222    // path, we flag it. False-positive risk is low because the ASLR-style
2223    // `$x` typically won't be reused for unrelated content within the same
2224    // inline block.
2225    let mut secret_bound_to_var = false;
2226    for line in script.lines() {
2227        let trimmed = line.trim();
2228        if !secret_bound_to_var
2229            && trimmed.contains(&needle)
2230            && trimmed.starts_with('$')
2231            && trimmed.contains('=')
2232        {
2233            secret_bound_to_var = true;
2234            continue;
2235        }
2236        if secret_bound_to_var && line_writes_to_file(line) && line_references_workspace_path(line)
2237        {
2238            return true;
2239        }
2240    }
2241
2242    false
2243}
2244
2245/// Rule: pipeline secret materialised to a file under the agent workspace.
2246///
2247/// Severity: High. Files written under `$(System.DefaultWorkingDirectory)` /
2248/// `$(Build.SourcesDirectory)` survive the writing step's lifetime, are
2249/// uploaded by `PublishPipelineArtifact` tasks (sometimes accidentally), and
2250/// remain readable by every subsequent step in the same job.
2251pub fn secret_materialised_to_workspace_file(graph: &AuthorityGraph) -> Vec<Finding> {
2252    let mut findings = Vec::new();
2253
2254    for step in graph.nodes_of_kind(NodeKind::Step) {
2255        let Some(script) = step.metadata.get(META_SCRIPT_BODY) else {
2256            continue;
2257        };
2258        if script.is_empty() {
2259            continue;
2260        }
2261        let secrets = step_secret_names(graph, step.id);
2262        let materialised: Vec<String> = secrets
2263            .into_iter()
2264            .filter(|s| script_materialises_secret_to_file(script, s))
2265            .collect();
2266
2267        if materialised.is_empty() {
2268            continue;
2269        }
2270
2271        let n = materialised.len();
2272        let preview: String = materialised
2273            .iter()
2274            .take(3)
2275            .map(|s| format!("$({s})"))
2276            .collect::<Vec<_>>()
2277            .join(", ");
2278        let suffix = if n > 3 {
2279            format!(", and {} more", n - 3)
2280        } else {
2281            String::new()
2282        };
2283
2284        let secret_node_ids: Vec<NodeId> = graph
2285            .edges_from(step.id)
2286            .filter(|e| e.kind == EdgeKind::HasAccessTo)
2287            .filter_map(|e| graph.node(e.to))
2288            .filter(|n| n.kind == NodeKind::Secret && materialised.contains(&n.name))
2289            .map(|n| n.id)
2290            .collect();
2291
2292        let mut nodes_involved = vec![step.id];
2293        nodes_involved.extend(secret_node_ids);
2294
2295        findings.push(Finding {
2296            severity: Severity::High,
2297            category: FindingCategory::SecretMaterialisedToWorkspaceFile,
2298            path: None,
2299            nodes_involved,
2300            message: format!(
2301                "Step '{}' writes pipeline secret(s) {preview}{suffix} to a file under the agent workspace — the file persists for the rest of the job, is readable by every subsequent step, and may be uploaded by PublishPipelineArtifact",
2302                step.name
2303            ),
2304            recommendation: Recommendation::Manual {
2305                action: "Replace inline secret materialisation with the `secureFile` task (downloaded to a temp dir with 0600 perms and auto-deleted), or pass the secret to the consuming tool over stdin / an env var instead of via a workspace file. If a file is unavoidable, write under `$(Agent.TempDirectory)` and `chmod 600` immediately.".into(),
2306            },
2307            source: FindingSource::BuiltIn,
2308                extras: FindingExtras::default(),
2309});
2310    }
2311
2312    findings
2313}
2314
2315/// Returns true if `script` contains a Key Vault → plaintext extraction
2316/// pattern that lands the secret in a non-`SecureString` variable.
2317fn script_extracts_keyvault_to_plaintext(script: &str) -> bool {
2318    let lower = script.to_lowercase();
2319    // New syntax: Get-AzKeyVaultSecret ... -AsPlainText
2320    if lower.contains("get-azkeyvaultsecret") && lower.contains("-asplaintext") {
2321        return true;
2322    }
2323    // ConvertFrom-SecureString ... -AsPlainText (PS 7+) — flat plaintext extraction
2324    if lower.contains("convertfrom-securestring") && lower.contains("-asplaintext") {
2325        return true;
2326    }
2327    // Old syntax: ($x = (Get-AzKeyVaultSecret ...).SecretValueText)
2328    if lower.contains("get-azkeyvaultsecret") && lower.contains(".secretvaluetext") {
2329        return true;
2330    }
2331    // Even older: BSTR pattern — ConvertToString on PtrToStringAuto
2332    if lower.contains("get-azkeyvaultsecret") && lower.contains("ptrtostringauto") {
2333        return true;
2334    }
2335    false
2336}
2337
2338/// Rule: PowerShell pulls a Key Vault secret as plaintext inside an inline
2339/// script. The value never crosses the ADO variable-group boundary so
2340/// pipeline log masking does not apply — verbose `Az` / PowerShell logging
2341/// (`Set-PSDebug -Trace`, `$VerbosePreference = "Continue"`, error stack
2342/// traces) will print the cleartext credential.
2343///
2344/// Severity: Medium. Lower than the materialisation rules because the value
2345/// is at least kept in process memory (vs. on disk), but still a real
2346/// exposure path that pipeline-level secret rotation alone does not fix.
2347pub fn keyvault_secret_to_plaintext(graph: &AuthorityGraph) -> Vec<Finding> {
2348    let mut findings = Vec::new();
2349
2350    for step in graph.nodes_of_kind(NodeKind::Step) {
2351        let Some(script) = step.metadata.get(META_SCRIPT_BODY) else {
2352            continue;
2353        };
2354        if script.is_empty() {
2355            continue;
2356        }
2357        if !script_extracts_keyvault_to_plaintext(script) {
2358            continue;
2359        }
2360
2361        findings.push(Finding {
2362            severity: Severity::Medium,
2363            category: FindingCategory::KeyVaultSecretToPlaintext,
2364            path: None,
2365            nodes_involved: vec![step.id],
2366            message: format!(
2367                "Step '{}' extracts a Key Vault secret as plaintext inside an inline script (-AsPlainText / .SecretValueText) — value bypasses ADO variable-group masking and is printed by Az verbose logging or any error stack trace",
2368                step.name
2369            ),
2370            recommendation: Recommendation::Manual {
2371                action: "Keep the secret as a `SecureString`: drop `-AsPlainText`, pass the SecureString directly to cmdlets that accept it (e.g. `New-PSCredential`, `Connect-AzAccount -ServicePrincipal -Credential ...`), and only convert to plaintext at the moment of consumption, scoped to a single expression. For values that must be plaintext (REST calls, env vars) prefer ADO variable groups linked to Key Vault — the value then participates in pipeline log masking.".into(),
2372            },
2373            source: FindingSource::BuiltIn,
2374                extras: FindingExtras::default(),
2375});
2376    }
2377
2378    findings
2379}
2380
2381/// Returns true when `name` (case-insensitive) looks like a production
2382/// service-connection name. Matches `prod` / `production` / `prd` either as
2383/// the entire name, a token surrounded by `-`/`_`, or a leading/trailing
2384/// segment (`prod-foo`, `foo-prd`). Conservative: avoids matching
2385/// substrings like "approver" or "reproduce".
2386fn looks_like_prod_connection(name: &str) -> bool {
2387    let lower = name.to_lowercase();
2388    let token_match = |s: &str| {
2389        lower == s
2390            || lower.contains(&format!("-{s}-"))
2391            || lower.contains(&format!("_{s}_"))
2392            || lower.ends_with(&format!("-{s}"))
2393            || lower.ends_with(&format!("_{s}"))
2394            || lower.starts_with(&format!("{s}-"))
2395            || lower.starts_with(&format!("{s}_"))
2396    };
2397    token_match("prod") || token_match("production") || token_match("prd")
2398}
2399
2400/// Returns true when an inline script body looks like it laundering federated
2401/// SPN/OIDC token material into a pipeline variable via
2402/// `##vso[task.setvariable]`. Used to escalate addspn_with_inline_script's
2403/// message wording when explicit laundering is detected.
2404fn script_launders_spn_token(s: &str) -> bool {
2405    let lower = s.to_lowercase();
2406    if !lower.contains("##vso[task.setvariable") {
2407        return false;
2408    }
2409    let token_markers = [
2410        "$env:idtoken",
2411        "$env:serviceprincipalkey",
2412        "$env:serviceprincipalid",
2413        "$env:tenantid",
2414        "arm_oidc_token",
2415        "arm_client_id",
2416        "arm_client_secret",
2417        "arm_tenant_id",
2418    ];
2419    token_markers.iter().any(|m| lower.contains(m))
2420}
2421
2422/// Rule: `terraform apply -auto-approve` against a production service
2423/// connection without an environment approval gate.
2424///
2425/// Combines three signals on a Step node:
2426///   1. `META_TERRAFORM_AUTO_APPROVE` = "true" (set by the parser when an
2427///      inline script runs `terraform apply --auto-approve`, or a
2428///      `TerraformCLI@N` task has `command: apply` + commandOptions
2429///      containing `auto-approve`).
2430///   2. `META_SERVICE_CONNECTION_NAME` matches a production-named pattern
2431///      (`prod`, `production`, `prd`), OR the step is linked via
2432///      `HasAccessTo` to an Identity service-connection node whose name
2433///      matches that pattern.
2434///   3. The step is NOT inside an `environment:`-bound deployment job
2435///      (parser sets `META_ENV_APPROVAL` for those steps).
2436///
2437/// Severity: Critical. Bypasses the only ADO-side change-control on
2438/// infra rewrites.
2439pub fn terraform_auto_approve_in_prod(graph: &AuthorityGraph) -> Vec<Finding> {
2440    let mut findings = Vec::new();
2441
2442    for step in graph.nodes_of_kind(NodeKind::Step) {
2443        let auto_approve = step
2444            .metadata
2445            .get(META_TERRAFORM_AUTO_APPROVE)
2446            .map(|v| v == "true")
2447            .unwrap_or(false);
2448        if !auto_approve {
2449            continue;
2450        }
2451
2452        // Step's own service-connection name (set by parser from
2453        // azureSubscription / connectedServiceName / etc).
2454        let direct_conn = step.metadata.get(META_SERVICE_CONNECTION_NAME).cloned();
2455
2456        // Walk HasAccessTo edges to find a service-connection Identity. This
2457        // catches steps that don't carry the name on themselves but inherit
2458        // an Identity node via the parser's edge.
2459        let edge_conn = graph
2460            .edges_from(step.id)
2461            .filter(|e| e.kind == EdgeKind::HasAccessTo)
2462            .filter_map(|e| graph.node(e.to))
2463            .find(|n| {
2464                n.kind == NodeKind::Identity
2465                    && n.metadata
2466                        .get(META_SERVICE_CONNECTION)
2467                        .map(|v| v == "true")
2468                        .unwrap_or(false)
2469            })
2470            .map(|n| n.name.clone());
2471
2472        let conn_name = match direct_conn.or(edge_conn) {
2473            Some(n) if looks_like_prod_connection(&n) => n,
2474            _ => continue,
2475        };
2476
2477        // Compensating control: an `environment:` binding routes the apply
2478        // through ADO's approval / check pipeline. Whether that environment
2479        // *actually* has approvers configured is invisible from YAML — so
2480        // downgrade Critical → Medium instead of skipping outright (the
2481        // previous behaviour silently dropped the finding even when the
2482        // environment was a CI-only approval-free passthrough).
2483        let env_gated = step
2484            .metadata
2485            .get(META_ENV_APPROVAL)
2486            .map(|v| v == "true")
2487            .unwrap_or(false);
2488        let (severity, suffix) = if env_gated {
2489            (
2490                Severity::Medium,
2491                " — `environment:` binding present (verify approvers are configured in the ADO Environments UI)",
2492            )
2493        } else {
2494            (
2495                Severity::Critical,
2496                " — any committer can rewrite prod infrastructure",
2497            )
2498        };
2499
2500        findings.push(Finding {
2501            severity,
2502            category: FindingCategory::TerraformAutoApproveInProd,
2503            path: None,
2504            nodes_involved: vec![step.id],
2505            message: format!(
2506                "Step '{}' runs `terraform apply -auto-approve` against production service connection '{}'{}",
2507                step.name, conn_name, suffix
2508            ),
2509            recommendation: Recommendation::Manual {
2510                action: "Move the apply step into a deployment job whose `environment:` is configured with required approvers in ADO, OR remove `-auto-approve` and run apply behind a manual checkpoint task. Combine with a non-shared agent pool so committers cannot pre-stage payloads.".into(),
2511            },
2512            source: FindingSource::BuiltIn,
2513                extras: FindingExtras::default(),
2514});
2515    }
2516
2517    findings
2518}
2519
2520/// Rule: `AzureCLI@2` task with `addSpnToEnvironment: true` AND an inline
2521/// script body. The inline script can launder federated SPN material
2522/// (`$env:idToken`, `$env:servicePrincipalKey`, `$env:tenantId`) into normal
2523/// pipeline variables via `##vso[task.setvariable]`, leaking OIDC tokens to
2524/// downstream tasks/artifacts un-masked.
2525///
2526/// Severity: High. Escalates message wording when the script body contains
2527/// explicit laundering patterns (`##vso[task.setvariable ...]` writing one
2528/// of the well-known token env vars or `ARM_OIDC_TOKEN`).
2529pub fn addspn_with_inline_script(graph: &AuthorityGraph) -> Vec<Finding> {
2530    let mut findings = Vec::new();
2531
2532    for step in graph.nodes_of_kind(NodeKind::Step) {
2533        let add_spn = step
2534            .metadata
2535            .get(META_ADD_SPN_TO_ENV)
2536            .map(|v| v == "true")
2537            .unwrap_or(false);
2538        if !add_spn {
2539            continue;
2540        }
2541
2542        let body = match step.metadata.get(META_SCRIPT_BODY) {
2543            Some(b) if !b.trim().is_empty() => b,
2544            _ => continue,
2545        };
2546
2547        let launders = script_launders_spn_token(body);
2548        let suffix = if launders {
2549            " — explicit token laundering detected (##vso[task.setvariable] writes federated token material)"
2550        } else {
2551            ""
2552        };
2553
2554        findings.push(Finding {
2555            severity: Severity::High,
2556            category: FindingCategory::AddSpnWithInlineScript,
2557            path: None,
2558            nodes_involved: vec![step.id],
2559            message: format!(
2560                "Step '{}' runs an inline script with addSpnToEnvironment:true — the federated SPN (idToken/servicePrincipalKey/tenantId) is exposed to script-controlled code and can be exfiltrated via setvariable{}",
2561                step.name, suffix
2562            ),
2563            recommendation: Recommendation::Manual {
2564                action: "Replace the inline script with `scriptPath:` pointing to a reviewed file in-repo, OR drop `addSpnToEnvironment: true` and use the task's first-class auth surface. Never emit federated token material via `##vso[task.setvariable]` — those values are inherited by every downstream task and may appear in logs.".into(),
2565            },
2566            source: FindingSource::BuiltIn,
2567                extras: FindingExtras::default(),
2568});
2569    }
2570
2571    findings
2572}
2573
2574/// Rule: free-form `type: string` parameter (no `values:` allowlist)
2575/// interpolated via `${{ parameters.<name> }}` directly into an inline
2576/// shell/PowerShell script body. ADO does not escape parameter values in
2577/// YAML emission, so any user with "queue build" can inject shell.
2578///
2579/// Detection requires the parser to populate
2580/// `AuthorityGraph::parameters` (currently ADO only) and to stamp Step
2581/// nodes with `META_SCRIPT_BODY`.
2582///
2583/// Severity: Medium.
2584pub fn parameter_interpolation_into_shell(graph: &AuthorityGraph) -> Vec<Finding> {
2585    if graph.parameters.is_empty() {
2586        return Vec::new();
2587    }
2588
2589    // Free-form string parameters: type is `string` (or unspecified — ADO's
2590    // default) AND no `values:` allowlist.
2591    let free_form: Vec<&str> = graph
2592        .parameters
2593        .iter()
2594        .filter(|(_, spec)| {
2595            !spec.has_values_allowlist
2596                && (spec.param_type.is_empty() || spec.param_type.eq_ignore_ascii_case("string"))
2597        })
2598        .map(|(name, _)| name.as_str())
2599        .collect();
2600
2601    if free_form.is_empty() {
2602        return Vec::new();
2603    }
2604
2605    let mut findings = Vec::new();
2606
2607    for step in graph.nodes_of_kind(NodeKind::Step) {
2608        let body = match step.metadata.get(META_SCRIPT_BODY) {
2609            Some(b) if !b.is_empty() => b,
2610            _ => continue,
2611        };
2612
2613        // Find every free-form parameter that appears interpolated in the
2614        // script body. Match both `${{ parameters.X }}` and `${{parameters.X}}`.
2615        let mut hits: Vec<&str> = Vec::new();
2616        for &name in &free_form {
2617            let needle_a = format!("${{{{ parameters.{name} }}}}");
2618            let needle_b = format!("${{{{parameters.{name}}}}}");
2619            if body.contains(&needle_a) || body.contains(&needle_b) {
2620                hits.push(name);
2621            }
2622        }
2623
2624        if hits.is_empty() {
2625            continue;
2626        }
2627
2628        hits.sort();
2629        hits.dedup();
2630        let names = hits.join(", ");
2631
2632        findings.push(Finding {
2633            severity: Severity::Medium,
2634            category: FindingCategory::ParameterInterpolationIntoShell,
2635            path: None,
2636            nodes_involved: vec![step.id],
2637            message: format!(
2638                "Step '{}' interpolates free-form string parameter(s) [{}] into an inline script — anyone with 'queue build' permission can inject shell commands",
2639                step.name, names
2640            ),
2641            recommendation: Recommendation::Manual {
2642                action: "Add a `values:` allowlist to the parameter declaration to constrain accepted inputs, OR pass the parameter through the step's `env:` block so the runtime quotes it as a shell variable instead of YAML-interpolating raw text.".into(),
2643            },
2644            source: FindingSource::BuiltIn,
2645                extras: FindingExtras::default(),
2646});
2647    }
2648
2649    findings
2650}
2651
2652/// Run all rules against a graph.
2653// ── runtime_script_fetched_from_floating_url ──────────────────
2654//
2655// Detect `run:` blocks that download a remote script from a non-pinned URL
2656// and pipe it directly to a shell interpreter. This is a pure HTTP supply-chain
2657// vector — neither `unpinned_action` (which inspects `uses:`) nor
2658// `floating_image` (containers) covers it.
2659//
2660// Detection primitive (URL must be both):
2661//   1. shell-style fetch+execute: `curl … | bash`, `wget … | sh`,
2662//      `bash <(curl …)`, or `deno run https://…`
2663//   2. URL is mutable: contains `refs/heads/`, `/main/`, `/master/`,
2664//      `/develop/`, `/HEAD/`, OR is a raw `git clone`/`fetch` from a
2665//      branch URL with no version pin.
2666//
2667// Severity: High (one upstream commit lands code on every consumer).
2668fn body_has_pipe_to_shell_with_floating_url(body: &str) -> bool {
2669    // Cheap pre-filter to keep the regex-free scan fast.
2670    let lower = body;
2671    let has_curl_or_wget = lower.contains("curl") || lower.contains("wget");
2672    let has_pipe_shell = lower.contains("| bash")
2673        || lower.contains("|bash")
2674        || lower.contains("| sh")
2675        || lower.contains("|sh")
2676        || lower.contains("<(curl")
2677        || lower.contains("<(wget");
2678    let has_deno_remote = lower.contains("deno run http://") || lower.contains("deno run https://");
2679
2680    if !((has_curl_or_wget && has_pipe_shell) || has_deno_remote) {
2681        return false;
2682    }
2683
2684    // For each line that contains a fetch+pipe or a deno-remote run, check
2685    // whether the URL on that line is mutable.
2686    for line in body.lines() {
2687        let line_has_pipe_shell = line.contains("| bash")
2688            || line.contains("|bash")
2689            || line.contains("| sh")
2690            || line.contains("|sh")
2691            || line.contains("<(curl")
2692            || line.contains("<(wget");
2693        let line_has_deno_remote =
2694            line.contains("deno run http://") || line.contains("deno run https://");
2695
2696        if !(line_has_pipe_shell || line_has_deno_remote) {
2697            continue;
2698        }
2699
2700        if line_url_is_mutable(line) {
2701            return true;
2702        }
2703    }
2704    false
2705}
2706
2707fn line_url_is_mutable(line: &str) -> bool {
2708    // Mutable URL markers.
2709    const MUTABLE_PATHS: &[&str] = &[
2710        "refs/heads/",
2711        "/HEAD/",
2712        "/main/",
2713        "/master/",
2714        "/develop/",
2715        "/trunk/",
2716        "/latest/",
2717    ];
2718    for marker in MUTABLE_PATHS {
2719        if line.contains(marker) {
2720            return true;
2721        }
2722    }
2723    // Bare `raw.githubusercontent.com/<owner>/<repo>/<ref>/...` where <ref>
2724    // is the literal `main`/`master` segment was caught above. We could be
2725    // looser and flag any URL with no version-like segment, but that
2726    // sacrifices precision — the marker list above is the conservative core.
2727    false
2728}
2729
2730/// Rule: a `run:` step pipes a remotely-fetched script into a shell, where
2731/// the URL is pinned to a mutable branch ref. The remote host's branch tip
2732/// becomes a write-anywhere primitive on the runner.
2733///
2734/// Severity: High.
2735pub fn runtime_script_fetched_from_floating_url(graph: &AuthorityGraph) -> Vec<Finding> {
2736    let mut findings = Vec::new();
2737
2738    for step in graph.nodes_of_kind(NodeKind::Step) {
2739        let body = match step.metadata.get(META_SCRIPT_BODY) {
2740            Some(b) if !b.is_empty() => b,
2741            _ => continue,
2742        };
2743
2744        if !body_has_pipe_to_shell_with_floating_url(body) {
2745            continue;
2746        }
2747
2748        findings.push(Finding {
2749            severity: Severity::High,
2750            category: FindingCategory::RuntimeScriptFetchedFromFloatingUrl,
2751            path: None,
2752            nodes_involved: vec![step.id],
2753            message: format!(
2754                "Step '{}' downloads and executes a script from a mutable URL (curl|bash, wget|sh, or `deno run` against a branch ref) — whoever controls that branch executes arbitrary code on the runner",
2755                step.name
2756            ),
2757            recommendation: Recommendation::Manual {
2758                action: "Pin the URL to a release tag or commit SHA (e.g. .../v1.2.3/install.sh) and verify the download against a known checksum before executing it. Avoid `curl … | bash` entirely where possible — fetch to a file, inspect, then run.".into(),
2759            },
2760            source: FindingSource::BuiltIn,
2761                extras: FindingExtras::default(),
2762});
2763    }
2764
2765    findings
2766}
2767
2768// ── pr_trigger_with_floating_action_ref ────────────────────────
2769//
2770// Detect the high-severity conjunction: workflow runs in privileged base-repo
2771// context (`pull_request_target` / `issue_comment` / `workflow_run`) AND uses
2772// at least one action by mutable ref (not SHA). Either condition alone is a
2773// finding from another rule; the conjunction is critical because the trigger
2774// grants write-token authority *and* the floating action lets an attacker
2775// substitute the executed code.
2776fn trigger_is_privileged_pr_class(trigger: &str) -> bool {
2777    // META_TRIGGER may be a single trigger or a comma-separated list.
2778    trigger.split(',').any(|t| {
2779        let t = t.trim();
2780        matches!(t, "pull_request_target" | "issue_comment" | "workflow_run")
2781    })
2782}
2783
2784/// Rule: privileged PR-class trigger combined with a non-SHA-pinned action ref.
2785///
2786/// Severity: Critical (full repo write token + attacker-controlled action code).
2787pub fn pr_trigger_with_floating_action_ref(graph: &AuthorityGraph) -> Vec<Finding> {
2788    let trigger = match graph.metadata.get(META_TRIGGER) {
2789        Some(t) => t.as_str(),
2790        None => return Vec::new(),
2791    };
2792    if !trigger_is_privileged_pr_class(trigger) {
2793        return Vec::new();
2794    }
2795
2796    let mut findings = Vec::new();
2797    let mut seen = std::collections::HashSet::new();
2798
2799    for image in graph.nodes_of_kind(NodeKind::Image) {
2800        // Skip first-party (local actions, self-hosted runner labels).
2801        if image.trust_zone == TrustZone::FirstParty {
2802            continue;
2803        }
2804        // Skip container images (covered by floating_image).
2805        if image
2806            .metadata
2807            .get(META_CONTAINER)
2808            .map(|v| v == "true")
2809            .unwrap_or(false)
2810        {
2811            continue;
2812        }
2813        // Skip self-hosted-runner Image nodes (those are FirstParty anyway,
2814        // but be defensive against future refactors).
2815        if image.metadata.contains_key(META_SELF_HOSTED) {
2816            continue;
2817        }
2818        // Already SHA-pinned → safe.
2819        if is_sha_pinned(&image.name) {
2820            continue;
2821        }
2822        // Dedupe per action reference.
2823        if !seen.insert(&image.name) {
2824            continue;
2825        }
2826
2827        findings.push(Finding {
2828            severity: Severity::Critical,
2829            category: FindingCategory::PrTriggerWithFloatingActionRef,
2830            path: None,
2831            nodes_involved: vec![image.id],
2832            message: format!(
2833                "Workflow trigger '{trigger}' runs in privileged base-repo context and step uses unpinned action '{}' — anyone who can push to that action's branch executes arbitrary code with full repo write token",
2834                image.name
2835            ),
2836            recommendation: Recommendation::PinAction {
2837                current: image.name.clone(),
2838                pinned: format!(
2839                    "{}@<sha256-digest>",
2840                    image.name.split('@').next().unwrap_or(&image.name)
2841                ),
2842            },
2843            source: FindingSource::BuiltIn,
2844                extras: FindingExtras::default(),
2845});
2846    }
2847
2848    findings
2849}
2850
2851// ── untrusted_api_response_to_env_sink ────────────────────────
2852//
2853// Detect `workflow_run` consumer workflows that capture an external API
2854// response (gh CLI, curl against api.github.com) and write it into the GHA
2855// environment file. A poisoned API field (branch name, PR title, commit
2856// message) injects environment variables into every subsequent step in the
2857// same job.
2858fn body_writes_api_response_to_env_sink(body: &str) -> bool {
2859    // First, the sink: a redirect to one of the GHA gate files.
2860    let writes_env_sink = body.contains("$GITHUB_ENV")
2861        || body.contains("${GITHUB_ENV}")
2862        || body.contains("$GITHUB_OUTPUT")
2863        || body.contains("${GITHUB_OUTPUT}")
2864        || body.contains("$GITHUB_PATH")
2865        || body.contains("${GITHUB_PATH}");
2866    if !writes_env_sink {
2867        return false;
2868    }
2869
2870    // Then, an API source on the same body: gh CLI or a direct REST call.
2871    let calls_api = body.contains("gh pr view")
2872        || body.contains("gh pr list")
2873        || body.contains("gh api ")
2874        || body.contains("gh issue view")
2875        || body.contains("api.github.com");
2876    if !calls_api {
2877        return false;
2878    }
2879
2880    // Tier-1 precision: same-line conjunction (the canonical case in corpus,
2881    // e.g. `gh pr view --jq '"PR_NUMBER=\(.number)"' >> $GITHUB_ENV`).
2882    let lines: Vec<&str> = body.lines().collect();
2883    for line in &lines {
2884        let line_calls_api = line.contains("gh pr view")
2885            || line.contains("gh pr list")
2886            || line.contains("gh api ")
2887            || line.contains("gh issue view")
2888            || line.contains("api.github.com");
2889        let line_writes_sink = line.contains("$GITHUB_ENV")
2890            || line.contains("${GITHUB_ENV}")
2891            || line.contains("$GITHUB_OUTPUT")
2892            || line.contains("${GITHUB_OUTPUT}")
2893            || line.contains("$GITHUB_PATH")
2894            || line.contains("${GITHUB_PATH}");
2895        if line_calls_api && line_writes_sink {
2896            return true;
2897        }
2898    }
2899
2900    // Tier-2 precision: API call captures into a variable, and a *nearby*
2901    // line redirects that same variable to the env sink. Without dataflow,
2902    // we approximate "nearby" as: an API line and a sink line within 6 lines
2903    // of each other. This catches multi-step capture-then-write idioms while
2904    // keeping false-positive risk acceptable.
2905    let mut last_api_line: Option<usize> = None;
2906    for (i, line) in lines.iter().enumerate() {
2907        let line_calls_api = line.contains("gh pr view")
2908            || line.contains("gh pr list")
2909            || line.contains("gh api ")
2910            || line.contains("gh issue view")
2911            || line.contains("api.github.com");
2912        if line_calls_api {
2913            last_api_line = Some(i);
2914        }
2915        let line_writes_sink = line.contains("$GITHUB_ENV")
2916            || line.contains("${GITHUB_ENV}")
2917            || line.contains("$GITHUB_OUTPUT")
2918            || line.contains("${GITHUB_OUTPUT}")
2919            || line.contains("$GITHUB_PATH")
2920            || line.contains("${GITHUB_PATH}");
2921        if line_writes_sink {
2922            if let Some(api_idx) = last_api_line {
2923                if i.saturating_sub(api_idx) <= 6 {
2924                    return true;
2925                }
2926            }
2927        }
2928    }
2929
2930    false
2931}
2932
2933/// Rule: workflow_run-triggered workflow writes an API response value to the
2934/// GHA environment gate. Branch name / PR title in the response can carry
2935/// newline-injected env-var assignments.
2936///
2937/// Severity: High.
2938pub fn untrusted_api_response_to_env_sink(graph: &AuthorityGraph) -> Vec<Finding> {
2939    let trigger = match graph.metadata.get(META_TRIGGER) {
2940        Some(t) => t.as_str(),
2941        None => return Vec::new(),
2942    };
2943    let trigger_in_scope = trigger.split(',').any(|t| {
2944        let t = t.trim();
2945        matches!(t, "workflow_run" | "pull_request_target" | "issue_comment")
2946    });
2947    if !trigger_in_scope {
2948        return Vec::new();
2949    }
2950
2951    let mut findings = Vec::new();
2952
2953    for step in graph.nodes_of_kind(NodeKind::Step) {
2954        let body = match step.metadata.get(META_SCRIPT_BODY) {
2955            Some(b) if !b.is_empty() => b,
2956            _ => continue,
2957        };
2958
2959        if !body_writes_api_response_to_env_sink(body) {
2960            continue;
2961        }
2962
2963        findings.push(Finding {
2964            severity: Severity::High,
2965            category: FindingCategory::UntrustedApiResponseToEnvSink,
2966            path: None,
2967            nodes_involved: vec![step.id],
2968            message: format!(
2969                "Step '{}' captures a GitHub API response (gh CLI or api.github.com) into the GHA env gate ($GITHUB_ENV/$GITHUB_OUTPUT/$GITHUB_PATH) under trigger '{trigger}' — attacker-influenced fields (branch name, PR title) can inject environment variables for every subsequent step in the same job",
2970                step.name
2971            ),
2972            recommendation: Recommendation::Manual {
2973                action: "Validate the API field with a strict regex before redirecting (e.g. only `[0-9]+` for a PR number), or write only known-numeric fields. Never pipe free-form fields like branch name or PR title directly into $GITHUB_ENV.".into(),
2974            },
2975            source: FindingSource::BuiltIn,
2976                extras: FindingExtras::default(),
2977});
2978    }
2979
2980    findings
2981}
2982
2983// ── pr_build_pushes_image_with_floating_credentials ────────────
2984//
2985// Detect: workflow triggered by a PR-class event uses a container-registry
2986// login action that is NOT SHA-pinned. The login action receives credentials
2987// (OIDC token or static registry secret) — a compromise of the action's
2988// branch lets an attacker exfiltrate them.
2989fn is_registry_login_action(action: &str) -> bool {
2990    let bare = action.split('@').next().unwrap_or(action);
2991    matches!(
2992        bare,
2993        "docker/login-action"
2994            | "aws-actions/amazon-ecr-login"
2995            | "aws-actions/configure-aws-credentials"
2996            | "azure/docker-login"
2997            | "azure/login"
2998            | "google-github-actions/auth"
2999            | "google-github-actions/setup-gcloud"
3000    ) || bare.ends_with("/login-to-gar")
3001        || bare.ends_with("/dockerhub-login")
3002        || bare.ends_with("/login-to-ecr")
3003        || bare.ends_with("/login-to-acr")
3004}
3005
3006fn trigger_includes_pull_request(trigger: &str) -> bool {
3007    trigger.split(',').any(|t| {
3008        let t = t.trim();
3009        // Match `pull_request` and `pull_request_target` — both are PR-class.
3010        t == "pull_request" || t == "pull_request_target"
3011    })
3012}
3013
3014/// Rule: PR-triggered workflow uses a non-SHA-pinned container-registry login
3015/// action. Compound vector: floating action holds registry creds + PR-controlled
3016/// image content reaches a shared registry.
3017///
3018/// Severity: High.
3019pub fn pr_build_pushes_image_with_floating_credentials(graph: &AuthorityGraph) -> Vec<Finding> {
3020    let trigger = match graph.metadata.get(META_TRIGGER) {
3021        Some(t) => t.as_str(),
3022        None => return Vec::new(),
3023    };
3024    if !trigger_includes_pull_request(trigger) {
3025        return Vec::new();
3026    }
3027
3028    let mut findings = Vec::new();
3029    let mut seen = std::collections::HashSet::new();
3030
3031    for image in graph.nodes_of_kind(NodeKind::Image) {
3032        if image.trust_zone == TrustZone::FirstParty {
3033            continue;
3034        }
3035        if image
3036            .metadata
3037            .get(META_CONTAINER)
3038            .map(|v| v == "true")
3039            .unwrap_or(false)
3040        {
3041            continue;
3042        }
3043        if !is_registry_login_action(&image.name) {
3044            continue;
3045        }
3046        if is_sha_pinned(&image.name) {
3047            continue;
3048        }
3049        if !seen.insert(&image.name) {
3050            continue;
3051        }
3052
3053        findings.push(Finding {
3054            severity: Severity::High,
3055            category: FindingCategory::PrBuildPushesImageWithFloatingCredentials,
3056            path: None,
3057            nodes_involved: vec![image.id],
3058            message: format!(
3059                "PR-triggered workflow ('{trigger}') uses unpinned registry-login action '{}' — a compromise of that action's branch exfiltrates registry credentials or OIDC tokens, and any PR-controlled image content then reaches a shared registry",
3060                image.name
3061            ),
3062            recommendation: Recommendation::PinAction {
3063                current: image.name.clone(),
3064                pinned: format!(
3065                    "{}@<sha256-digest>",
3066                    image.name.split('@').next().unwrap_or(&image.name)
3067                ),
3068            },
3069            source: FindingSource::BuiltIn,
3070                extras: FindingExtras::default(),
3071});
3072    }
3073
3074    findings
3075}
3076
3077/// Rule: secret laundered through `$GITHUB_ENV` reaches an untrusted consumer
3078/// in the same job — composition gap between `self_mutating_pipeline` (the
3079/// gate-write detector) and `untrusted_with_authority` (the direct-access
3080/// detector).
3081///
3082/// **Pattern (R2 attack #3):**
3083/// ```yaml
3084/// jobs:
3085///   build:
3086///     steps:
3087///       - name: setup
3088///         run: echo "CLOUD_KEY=${{ secrets.CLOUD_KEY }}" >> $GITHUB_ENV   # writer
3089///       - uses: some-org/deploy@main                                        # untrusted
3090///         with:
3091///           key: ${{ env.CLOUD_KEY }}                                       # consumer
3092/// ```
3093/// The writer trips `self_mutating_pipeline`. The consumer never gets a
3094/// `HasAccessTo` edge to `CLOUD_KEY` (the value is sourced from the runner
3095/// env, not the secrets store) so neither `untrusted_with_authority` nor
3096/// `authority_propagation` fire — the env-gate launders the trust zone.
3097///
3098/// **Detection:** for every Step in the same job:
3099///   - Writer: `META_WRITES_ENV_GATE = "true"` AND has `HasAccessTo` to a
3100///     Secret/Identity (the value being laundered must derive from authority)
3101///   - Consumer: appears later in the job (NodeId order tracks declaration
3102///     order), trust zone is `Untrusted` or `ThirdParty`, and carries
3103///     `META_READS_ENV = "true"` (stamped by the parser when the step
3104///     references `${{ env.X }}` in `with:` / `run:`)
3105///
3106/// Same-job constraint enforced via `META_JOB_NAME` — the env gate only
3107/// propagates within a job, so cross-job pairs are not flagged.
3108pub fn secret_via_env_gate_to_untrusted_consumer(graph: &AuthorityGraph) -> Vec<Finding> {
3109    let mut findings = Vec::new();
3110
3111    // Step 1: enumerate writer-with-secret nodes, paired with the laundered
3112    // authority names so the finding message can name them. We capture the
3113    // node id in declaration order so the same-job ordering check below is a
3114    // simple comparison rather than an O(n²) scan.
3115    struct Writer<'a> {
3116        id: NodeId,
3117        job: &'a str,
3118        name: &'a str,
3119        secrets: Vec<&'a str>,
3120    }
3121    let writers: Vec<Writer<'_>> = graph
3122        .nodes_of_kind(NodeKind::Step)
3123        .filter(|step| {
3124            step.metadata
3125                .get(META_WRITES_ENV_GATE)
3126                .map(|v| v == "true")
3127                .unwrap_or(false)
3128        })
3129        .filter_map(|step| {
3130            let job = step.metadata.get(META_JOB_NAME)?.as_str();
3131            // Must hold authority — collect Secret/Identity names reachable
3132            // via HasAccessTo. An env-gate write that doesn't carry any
3133            // authority is the harmless "ECHO ROUTE=/api >> $GITHUB_ENV"
3134            // case; not in scope for this rule.
3135            let secrets: Vec<&str> = graph
3136                .edges_from(step.id)
3137                .filter(|e| e.kind == EdgeKind::HasAccessTo)
3138                .filter_map(|e| graph.node(e.to))
3139                .filter(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
3140                .map(|n| n.name.as_str())
3141                .collect();
3142            if secrets.is_empty() {
3143                return None;
3144            }
3145            Some(Writer {
3146                id: step.id,
3147                job,
3148                name: step.name.as_str(),
3149                secrets,
3150            })
3151        })
3152        .collect();
3153
3154    if writers.is_empty() {
3155        return findings;
3156    }
3157
3158    // Step 2: for every consumer step that reads env, find the writer(s) it
3159    // could be laundering from.
3160    for consumer in graph.nodes_of_kind(NodeKind::Step) {
3161        // Consumer must read the runner env.
3162        let reads_env = consumer
3163            .metadata
3164            .get(META_READS_ENV)
3165            .map(|v| v == "true")
3166            .unwrap_or(false);
3167        if !reads_env {
3168            continue;
3169        }
3170
3171        // Consumer must run with reduced trust — first-party readers are
3172        // already accounted for elsewhere and would be a high-FP class.
3173        if !matches!(
3174            consumer.trust_zone,
3175            TrustZone::Untrusted | TrustZone::ThirdParty
3176        ) {
3177            continue;
3178        }
3179
3180        let consumer_job = match consumer.metadata.get(META_JOB_NAME) {
3181            Some(j) => j.as_str(),
3182            None => continue,
3183        };
3184
3185        // Find writers in the same job that appear earlier (NodeId order
3186        // mirrors declaration order — see GHA parser, ADO parser).
3187        let upstream: Vec<&Writer<'_>> = writers
3188            .iter()
3189            .filter(|w| w.job == consumer_job && w.id < consumer.id)
3190            .collect();
3191
3192        if upstream.is_empty() {
3193            continue;
3194        }
3195
3196        // Aggregate the laundered authority names across all writers so
3197        // operators see the full set of credentials potentially reaching
3198        // the untrusted step. Stable ordering, dedup'd.
3199        let mut secret_labels: Vec<&str> = upstream
3200            .iter()
3201            .flat_map(|w| w.secrets.iter().copied())
3202            .collect();
3203        secret_labels.sort_unstable();
3204        secret_labels.dedup();
3205        let writer_names: Vec<&str> = upstream.iter().map(|w| w.name).collect();
3206
3207        let mut nodes_involved = vec![consumer.id];
3208        nodes_involved.extend(upstream.iter().map(|w| w.id));
3209        // Include the laundered Secret/Identity nodes themselves so the
3210        // fingerprint and downstream consumers can attribute the finding
3211        // to a specific credential.
3212        for w in &upstream {
3213            for e in graph.edges_from(w.id) {
3214                if e.kind == EdgeKind::HasAccessTo
3215                    && graph
3216                        .node(e.to)
3217                        .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
3218                        .unwrap_or(false)
3219                    && !nodes_involved.contains(&e.to)
3220                {
3221                    nodes_involved.push(e.to);
3222                }
3223            }
3224        }
3225
3226        findings.push(Finding {
3227            severity: Severity::Critical,
3228            category: FindingCategory::SecretViaEnvGateToUntrustedConsumer,
3229            path: None,
3230            nodes_involved,
3231            message: format!(
3232                "Untrusted consumer '{}' in job '{}' reads from $GITHUB_ENV after step(s) [{}] laundered authority [{}] through the env gate — secret reaches untrusted code without ever appearing in a HasAccessTo edge",
3233                consumer.name,
3234                consumer_job,
3235                writer_names.join(", "),
3236                secret_labels.join(", "),
3237            ),
3238            recommendation: Recommendation::Manual {
3239                action: "Pass the secret to the consuming step via an explicit `env:` mapping on that step (so the relationship is graph-visible) instead of writing it to `$GITHUB_ENV` for ambient pickup. If the consumer is a third-party action, pin it to a 40-char SHA before exposing any secret-derived value to it.".into(),
3240            },
3241            source: FindingSource::BuiltIn,
3242            extras: FindingExtras::default(),
3243        });
3244    }
3245
3246    findings
3247}
3248
3249// ── Positive invariants (negative-space rules) ───────────────────
3250//
3251// These rules fire on the ABSENCE of an expected defensive control rather
3252// than on the presence of a misconfigured one. They are derived from the
3253// blue-team corpus defense report — patterns observed across thousands of
3254// pipelines where the well-defended workflows had a control the others were
3255// missing.
3256//
3257// Each function gates strictly on `META_PLATFORM` so a single pipeline file
3258// is only evaluated by the rules that apply to its source platform.
3259
3260/// Returns true when a graph belongs to the named platform. Falls back to
3261/// false (rule no-ops) when no platform stamp is present — keeps existing
3262/// hand-built test graphs from accidentally tripping platform-scoped rules.
3263fn graph_is_platform(graph: &AuthorityGraph, platform: &str) -> bool {
3264    graph
3265        .metadata
3266        .get(META_PLATFORM)
3267        .map(|p| p == platform)
3268        .unwrap_or(false)
3269}
3270
3271/// Rule: GHA workflow declares no top-level `permissions:` block AND no
3272/// per-job permissions block. With nothing declared, `GITHUB_TOKEN` falls
3273/// back to the broad platform default (`contents: write`, `packages: write`,
3274/// metadata read, etc.) on every trigger. Explicit declarations make the
3275/// blast radius legible to the next reviewer; absence makes it invisible.
3276///
3277/// Detection:
3278///   * `META_PLATFORM == "github-actions"` (gates ADO/GitLab out)
3279///   * Graph carries `META_NO_WORKFLOW_PERMISSIONS == "true"` (parser-set
3280///     when `workflow.permissions` is absent)
3281///   * No Identity node whose name starts with `GITHUB_TOKEN (` (those are
3282///     the per-job override identities the parser creates when a job
3283///     declares its own permissions block)
3284///
3285/// Severity: Medium. Not a direct exploit path on its own but compounds
3286/// every other finding in the same workflow.
3287pub fn no_workflow_level_permissions_block(graph: &AuthorityGraph) -> Vec<Finding> {
3288    if !graph_is_platform(graph, "github-actions") {
3289        return Vec::new();
3290    }
3291    let no_workflow_perms = graph
3292        .metadata
3293        .get(META_NO_WORKFLOW_PERMISSIONS)
3294        .map(|v| v == "true")
3295        .unwrap_or(false);
3296    if !no_workflow_perms {
3297        return Vec::new();
3298    }
3299    // Empty graphs (variable-only YAML files mis-detected as GHA, parse
3300    // failures that left the graph empty, etc.) carry no real authority
3301    // surface to be over-broad over. Skip them. A real workflow always
3302    // produces at least one Step node.
3303    if graph.nodes_of_kind(NodeKind::Step).next().is_none() {
3304        return Vec::new();
3305    }
3306    // Per-job permissions blocks create Identity nodes named
3307    // `GITHUB_TOKEN (<job_name>)`. If any exists, the workflow has at least
3308    // one job-scoped permissions block — don't fire.
3309    let has_job_level_perms = graph.nodes_of_kind(NodeKind::Identity).any(|n| {
3310        n.name.starts_with("GITHUB_TOKEN (")
3311            || (n.name == "GITHUB_TOKEN" && n.metadata.contains_key(META_PERMISSIONS))
3312    });
3313    if has_job_level_perms {
3314        return Vec::new();
3315    }
3316    vec![Finding {
3317        severity: Severity::Medium,
3318        category: FindingCategory::NoWorkflowLevelPermissionsBlock,
3319        path: None,
3320        nodes_involved: Vec::new(),
3321        message: "Workflow declares no top-level or per-job `permissions:` block — GITHUB_TOKEN \
3322             falls back to the broad platform default (contents: write, packages: write, …) \
3323             on every trigger. Explicit permissions make the blast radius legible to triage."
3324            .into(),
3325        recommendation: Recommendation::ReducePermissions {
3326            current: "platform default (broad)".into(),
3327            minimum: "permissions: {} at top level, then add the minimum per-job — e.g. \
3328                      `permissions: { contents: read }`"
3329                .into(),
3330        },
3331        source: FindingSource::BuiltIn,
3332        extras: FindingExtras::default(),
3333    }]
3334}
3335
3336/// Rule: ADO job referencing a production-named service connection has no
3337/// `environment:` binding. Strictly broader than
3338/// `terraform_auto_approve_in_prod` — fires on any prod-SC step (Terraform,
3339/// ARM, AzureCLI, AzurePowerShell, custom) whose enclosing job lacks the
3340/// approval gate, regardless of whether `-auto-approve` is set.
3341///
3342/// Detection (per Step):
3343///   * `META_PLATFORM == "azure-devops"`
3344///   * Step carries `META_SERVICE_CONNECTION_NAME` matching prod pattern,
3345///     OR an `Identity` connected via `HasAccessTo` whose name matches
3346///     the same pattern AND carries `META_SERVICE_CONNECTION == "true"`.
3347///   * Step does NOT carry `META_ENV_APPROVAL` (parser tags every step
3348///     inside an environment-bound deployment job).
3349///
3350/// One finding per matching step (matching `terraform_auto_approve_in_prod`
3351/// granularity). Severity: High.
3352pub fn prod_deploy_job_no_environment_gate(graph: &AuthorityGraph) -> Vec<Finding> {
3353    if !graph_is_platform(graph, "azure-devops") {
3354        return Vec::new();
3355    }
3356    let mut findings = Vec::new();
3357    for step in graph.nodes_of_kind(NodeKind::Step) {
3358        let env_gated = step
3359            .metadata
3360            .get(META_ENV_APPROVAL)
3361            .map(|v| v == "true")
3362            .unwrap_or(false);
3363        if env_gated {
3364            continue;
3365        }
3366        let direct = step.metadata.get(META_SERVICE_CONNECTION_NAME).cloned();
3367        let edge_conn = graph
3368            .edges_from(step.id)
3369            .filter(|e| e.kind == EdgeKind::HasAccessTo)
3370            .filter_map(|e| graph.node(e.to))
3371            .find(|n| {
3372                n.kind == NodeKind::Identity
3373                    && n.metadata
3374                        .get(META_SERVICE_CONNECTION)
3375                        .map(|v| v == "true")
3376                        .unwrap_or(false)
3377            })
3378            .map(|n| n.name.clone());
3379        let conn_name = match direct.or(edge_conn) {
3380            Some(n) if looks_like_prod_connection(&n) => n,
3381            _ => continue,
3382        };
3383        findings.push(Finding {
3384            severity: Severity::High,
3385            category: FindingCategory::ProdDeployJobNoEnvironmentGate,
3386            path: None,
3387            nodes_involved: vec![step.id],
3388            message: format!(
3389                "Step '{}' targets production service connection '{}' but its job has no \
3390                 `environment:` binding — every pipeline trigger applies changes with no \
3391                 approval queue and no entry in the ADO Environments audit trail",
3392                step.name, conn_name
3393            ),
3394            recommendation: Recommendation::Manual {
3395                action: "Move the step into a deployment job whose `environment:` is configured \
3396                         with required approvers in ADO. Even if `-auto-approve` is acceptable \
3397                         (e.g. `terraform apply tfplan`), the environment binding gives the \
3398                         platform a chokepoint for approvals, audit, and concurrency limits."
3399                    .into(),
3400            },
3401            source: FindingSource::BuiltIn,
3402            extras: FindingExtras::default(),
3403        });
3404    }
3405    findings
3406}
3407
3408/// Rule: long-lived static credential in scope but the graph has no OIDC
3409/// identity. Advisory uplift on top of `long_lived_credential` that wires
3410/// the existing `Recommendation::FederateIdentity` variant — emits one Info
3411/// finding per static credential whose name suggests a cloud provider that
3412/// supports OIDC (AWS / GCP / Azure).
3413///
3414/// Heuristic: AWS / GCP / Azure tokens usually carry the provider name in
3415/// the variable identifier (`AWS_*`, `GCP_*`, `GCLOUD_*`, `GOOGLE_*`,
3416/// `AZURE_*`, `ARM_*`). When such a name appears AND no OIDC identity
3417/// exists in the graph, the migration to federation is the actionable
3418/// remediation. The recommendation enum has carried `FederateIdentity` for
3419/// two releases without any rule emitting it.
3420///
3421/// Severity: Info (advisory). The underlying credential is already flagged
3422/// at higher severity by `long_lived_credential`.
3423pub fn long_lived_secret_without_oidc_recommendation(graph: &AuthorityGraph) -> Vec<Finding> {
3424    // Skip if any OIDC identity already exists — the workflow is already on
3425    // a federated path; the static credential it carries is presumably a
3426    // legacy artifact unrelated to the OIDC integration.
3427    let has_oidc = graph.nodes_of_kind(NodeKind::Identity).any(|n| {
3428        n.metadata
3429            .get(META_OIDC)
3430            .map(|v| v == "true")
3431            .unwrap_or(false)
3432    });
3433    if has_oidc {
3434        return Vec::new();
3435    }
3436    let mut findings = Vec::new();
3437    for secret in graph.nodes_of_kind(NodeKind::Secret) {
3438        let upper = secret.name.to_uppercase();
3439        let provider: Option<(&str, &str)> = if upper.starts_with("AWS_")
3440            || upper.contains("AWS_ACCESS_KEY")
3441            || upper.contains("AWS_SECRET")
3442        {
3443            Some(("AWS", "GitHub Actions OIDC + sts:AssumeRoleWithWebIdentity (id-token: write + aws-actions/configure-aws-credentials)"))
3444        } else if upper.starts_with("GCP_")
3445            || upper.starts_with("GCLOUD_")
3446            || upper.starts_with("GOOGLE_")
3447            || upper.contains("GCP_SERVICE_ACCOUNT")
3448            || upper.contains("GOOGLE_CREDENTIALS")
3449        {
3450            Some(("GCP", "GCP Workload Identity Federation (google-github-actions/auth with workload_identity_provider)"))
3451        } else if upper.starts_with("AZURE_")
3452            || upper.starts_with("ARM_")
3453            || upper.contains("AZURE_CLIENT_SECRET")
3454        {
3455            Some((
3456                "Azure",
3457                "Azure federated credential (azure/login with client-id, no client-secret)",
3458            ))
3459        } else {
3460            None
3461        };
3462        let Some((cloud, oidc_provider)) = provider else {
3463            continue;
3464        };
3465        findings.push(Finding {
3466            severity: Severity::Info,
3467            category: FindingCategory::LongLivedSecretWithoutOidcRecommendation,
3468            path: None,
3469            nodes_involved: vec![secret.id],
3470            message: format!(
3471                "Long-lived {cloud} credential '{}' is in scope and no OIDC identity exists \
3472                 in this workflow — {cloud} supports OIDC federation, so this credential could \
3473                 be replaced with a short-lived token issued at runtime",
3474                secret.name
3475            ),
3476            recommendation: Recommendation::FederateIdentity {
3477                static_secret: secret.name.clone(),
3478                oidc_provider: oidc_provider.into(),
3479            },
3480            source: FindingSource::BuiltIn,
3481            extras: FindingExtras::default(),
3482        });
3483    }
3484    findings
3485}
3486
3487/// Rule: GHA workflow with multiple privileged jobs where SOME steps carry
3488/// the standard fork-check `if:` and OTHERS do not — intra-file
3489/// inconsistency in defensive posture. The org has the right instinct
3490/// (some jobs are guarded) but applied it unevenly. Surfaces the unguarded
3491/// privileged jobs by name so a reviewer can fix the gap in one PR.
3492///
3493/// Detection:
3494///   * `META_PLATFORM == "github-actions"`
3495///   * Trigger contains `pull_request` or `pull_request_target`
3496///   * Multiple jobs hold authority (steps with `HasAccessTo` to a Secret
3497///     or Identity)
3498///   * At least one such job's privileged steps ALL carry
3499///     `META_FORK_CHECK == "true"`
3500///   * AND at least one OTHER privileged job has NO step carrying that
3501///     marker
3502///
3503/// Severity: High. Severity floors at Medium when the inconsistency is
3504/// limited to a single unguarded job (one-off oversight) vs. multiple
3505/// (systemic gap).
3506pub fn pull_request_workflow_inconsistent_fork_check(graph: &AuthorityGraph) -> Vec<Finding> {
3507    if !graph_is_platform(graph, "github-actions") {
3508        return Vec::new();
3509    }
3510    let trigger = match graph.metadata.get(META_TRIGGER) {
3511        Some(t) => t.as_str(),
3512        None => return Vec::new(),
3513    };
3514    let in_pr_context = trigger.split(',').any(|t| {
3515        let t = t.trim();
3516        matches!(t, "pull_request" | "pull_request_target")
3517    });
3518    if !in_pr_context {
3519        return Vec::new();
3520    }
3521
3522    // For each privileged step, record (job_name, has_fork_check). A job is
3523    // "guarded" iff every privileged step in it carries the marker.
3524    use std::collections::BTreeMap;
3525    let mut per_job: BTreeMap<String, (bool, bool)> = BTreeMap::new(); // job -> (any_guarded, any_unguarded)
3526
3527    for step in graph.nodes_of_kind(NodeKind::Step) {
3528        let holds_authority = graph.edges_from(step.id).any(|e| {
3529            e.kind == EdgeKind::HasAccessTo
3530                && graph
3531                    .node(e.to)
3532                    .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
3533                    .unwrap_or(false)
3534        });
3535        if !holds_authority {
3536            continue;
3537        }
3538        let job = step
3539            .metadata
3540            .get(META_JOB_NAME)
3541            .cloned()
3542            .unwrap_or_else(|| step.name.clone());
3543        let guarded = step
3544            .metadata
3545            .get(META_FORK_CHECK)
3546            .map(|v| v == "true")
3547            .unwrap_or(false);
3548        let entry = per_job.entry(job).or_insert((false, false));
3549        if guarded {
3550            entry.0 = true;
3551        } else {
3552            entry.1 = true;
3553        }
3554    }
3555
3556    // Need >= 2 distinct privileged jobs; >= 1 fully-guarded job and >= 1
3557    // job with at least one unguarded privileged step.
3558    if per_job.len() < 2 {
3559        return Vec::new();
3560    }
3561    let fully_guarded: Vec<&String> = per_job
3562        .iter()
3563        .filter(|(_, (g, u))| *g && !*u)
3564        .map(|(k, _)| k)
3565        .collect();
3566    let unguarded: Vec<&String> = per_job
3567        .iter()
3568        .filter(|(_, (_, u))| *u)
3569        .map(|(k, _)| k)
3570        .collect();
3571    if fully_guarded.is_empty() || unguarded.is_empty() {
3572        return Vec::new();
3573    }
3574    let severity = if unguarded.len() >= 2 {
3575        Severity::High
3576    } else {
3577        Severity::Medium
3578    };
3579    let guarded_label = fully_guarded
3580        .iter()
3581        .map(|s| s.as_str())
3582        .collect::<Vec<_>>()
3583        .join(", ");
3584    let unguarded_label = unguarded
3585        .iter()
3586        .map(|s| s.as_str())
3587        .collect::<Vec<_>>()
3588        .join(", ");
3589    vec![Finding {
3590        severity,
3591        category: FindingCategory::PullRequestWorkflowInconsistentForkCheck,
3592        path: None,
3593        nodes_involved: Vec::new(),
3594        message: format!(
3595            "PR-triggered workflow ('{trigger}') applies the standard fork-check \
3596             (`github.event.pull_request.head.repo.fork == false` or equivalent) on \
3597             privileged jobs [{guarded_label}] but NOT on [{unguarded_label}] — the \
3598             unguarded jobs hold authority that fork PRs can reach"
3599        ),
3600        recommendation: Recommendation::Manual {
3601            action: format!(
3602                "Add `if: github.event.pull_request.head.repo.fork == false` (or \
3603                 `github.event.pull_request.head.repo.full_name == github.repository`) to the \
3604                 privileged steps in [{unguarded_label}]. Match the pattern already used by \
3605                 [{guarded_label}] in the same workflow."
3606            ),
3607        },
3608        source: FindingSource::BuiltIn,
3609        extras: FindingExtras::default(),
3610    }]
3611}
3612
3613/// Rule: GitLab job with a production-named `environment:` binding has no
3614/// `rules:` / `only:` clause restricting it to protected branches. The job
3615/// runs (or attempts to run) on every pipeline trigger; if branch
3616/// protection is later relaxed the deploy becomes runnable from
3617/// unprotected branches without any code change.
3618///
3619/// Detection (per Step in a GitLab graph):
3620///   * `META_PLATFORM == "gitlab"`
3621///   * Step carries `environment_name` matching a production token
3622///     (`prod`, `production`, `prd`)
3623///   * Step does NOT carry `META_RULES_PROTECTED_ONLY`
3624///
3625/// Severity: Medium.
3626pub fn gitlab_deploy_job_missing_protected_branch_only(graph: &AuthorityGraph) -> Vec<Finding> {
3627    if !graph_is_platform(graph, "gitlab") {
3628        return Vec::new();
3629    }
3630    let mut findings = Vec::new();
3631    for step in graph.nodes_of_kind(NodeKind::Step) {
3632        let env_name = match step.metadata.get("environment_name") {
3633            Some(n) => n.clone(),
3634            None => continue,
3635        };
3636        if !looks_like_prod_connection(&env_name) {
3637            continue;
3638        }
3639        let protected = step
3640            .metadata
3641            .get(META_RULES_PROTECTED_ONLY)
3642            .map(|v| v == "true")
3643            .unwrap_or(false);
3644        if protected {
3645            continue;
3646        }
3647        findings.push(Finding {
3648            severity: Severity::Medium,
3649            category: FindingCategory::GitlabDeployJobMissingProtectedBranchOnly,
3650            path: None,
3651            nodes_involved: vec![step.id],
3652            message: format!(
3653                "GitLab deploy job '{}' targets production environment '{}' but has no \
3654                 `rules:` / `only:` clause restricting it to protected branches — every MR \
3655                 and every push will attempt to run the deploy",
3656                step.name, env_name
3657            ),
3658            recommendation: Recommendation::Manual {
3659                action: "Add `rules: - if: '$CI_COMMIT_REF_PROTECTED == \"true\"'` to the job, \
3660                         or `only: [main]` for the simplest case. This survives future \
3661                         changes to branch-protection settings."
3662                    .into(),
3663            },
3664            source: FindingSource::BuiltIn,
3665            extras: FindingExtras::default(),
3666        });
3667    }
3668    findings
3669}
3670
3671// ── Compensating-control suppressions ────────────────────────
3672//
3673// These suppressions DOWNGRADE or REMOVE existing-rule findings when the
3674// graph carries a control that neutralises (or substantially mitigates)
3675// the underlying risk. Applied as a post-processing pass so each
3676// suppression can see both the finding and the surrounding graph state.
3677//
3678// Design intent (from the blue-team corpus defense report):
3679//   * downgrade > suppress: keep the finding visible at a lower severity
3680//     so it still surfaces in audits, but stop competing for triage time
3681//     with un-mitigated criticals
3682//   * never *delete* a finding silently — every suppression appends an
3683//     explanation suffix to the message describing the compensating
3684//     control taudit credited
3685//
3686// Suppressions implemented here:
3687//   1. `checkout_self_pr_exposure` downgraded when the same job has no
3688//      privileged steps (no Secret/Identity access and no env-gate writes).
3689//   2. `trigger_context_mismatch` downgraded when every privileged step
3690//      in the workflow carries the standard fork-check `if:`.
3691//   3. `over_privileged_identity` suppressed when the workflow-level
3692//      identity is broad but at least one job-level override narrows the
3693//      scope (job-level wins at runtime).
3694//   4. `terraform_auto_approve_in_prod` downgraded — not skipped — when an
3695//      `environment:` gate is present (replaces the previous early-skip
3696//      which discarded the finding entirely).
3697fn apply_compensating_controls(graph: &AuthorityGraph, findings: &mut [Finding]) {
3698    // Pre-compute graph-level signals once so the per-finding loop stays
3699    // O(N findings) rather than O(N findings × M nodes).
3700    let mut all_authority_steps_have_fork_check = true;
3701    let mut any_authority_step_seen = false;
3702    for step in graph.nodes_of_kind(NodeKind::Step) {
3703        let holds_authority = graph.edges_from(step.id).any(|e| {
3704            e.kind == EdgeKind::HasAccessTo
3705                && graph
3706                    .node(e.to)
3707                    .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
3708                    .unwrap_or(false)
3709        });
3710        if !holds_authority {
3711            continue;
3712        }
3713        any_authority_step_seen = true;
3714        let guarded = step
3715            .metadata
3716            .get(META_FORK_CHECK)
3717            .map(|v| v == "true")
3718            .unwrap_or(false);
3719        if !guarded {
3720            all_authority_steps_have_fork_check = false;
3721        }
3722    }
3723    let fork_check_universal = any_authority_step_seen && all_authority_steps_have_fork_check;
3724
3725    // For Suppression 1, build per-job: does any step in the job have
3726    // access to a Secret/Identity OR write to the env gate?
3727    use std::collections::{BTreeMap, BTreeSet};
3728    let mut job_has_privileged_step: BTreeMap<String, bool> = BTreeMap::new();
3729    for step in graph.nodes_of_kind(NodeKind::Step) {
3730        let job = match step.metadata.get(META_JOB_NAME) {
3731            Some(j) => j.clone(),
3732            None => continue,
3733        };
3734        let privileged = graph.edges_from(step.id).any(|e| {
3735            e.kind == EdgeKind::HasAccessTo
3736                && graph
3737                    .node(e.to)
3738                    .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
3739                    .unwrap_or(false)
3740        }) || step
3741            .metadata
3742            .get(META_WRITES_ENV_GATE)
3743            .map(|v| v == "true")
3744            .unwrap_or(false);
3745        let entry = job_has_privileged_step.entry(job).or_insert(false);
3746        if privileged {
3747            *entry = true;
3748        }
3749    }
3750
3751    // For Suppression 3 — over_privileged_identity — collect the names of
3752    // narrower per-job identity overrides so we can credit them when the
3753    // broad workflow-level identity fires.
3754    let job_level_narrow_overrides: BTreeSet<String> = graph
3755        .nodes_of_kind(NodeKind::Identity)
3756        .filter(|n| {
3757            n.name.starts_with("GITHUB_TOKEN (")
3758                && n.metadata
3759                    .get(META_IDENTITY_SCOPE)
3760                    .map(|s| s == "constrained")
3761                    .unwrap_or(false)
3762        })
3763        .map(|n| n.name.clone())
3764        .collect();
3765
3766    for finding in findings.iter_mut() {
3767        match finding.category {
3768            // ── Suppression 1: checkout_self_pr_exposure
3769            FindingCategory::CheckoutSelfPrExposure => {
3770                // Identify the checkout step (first node in nodes_involved)
3771                // and look up its job. If the job has no privileged steps,
3772                // the checkout is read-only — downgrade to Info.
3773                let job = finding
3774                    .nodes_involved
3775                    .first()
3776                    .and_then(|id| graph.node(*id))
3777                    .and_then(|n| n.metadata.get(META_JOB_NAME).cloned());
3778                let job_privileged = job
3779                    .as_ref()
3780                    .and_then(|j| job_has_privileged_step.get(j).copied())
3781                    .unwrap_or(true); // unknown → conservative: keep High
3782                if !job_privileged {
3783                    finding.severity = Severity::Info;
3784                    finding.message.push_str(
3785                        " (downgraded: no privileged steps in same job — \
3786                                   checkout is read-only for lint/test/analysis)",
3787                    );
3788                }
3789            }
3790            // ── Suppression 2: trigger_context_mismatch
3791            FindingCategory::TriggerContextMismatch => {
3792                if fork_check_universal {
3793                    // Critical → Medium (not Info — the trigger choice itself
3794                    // is still risky enough to keep visible for audit).
3795                    finding.severity = match finding.severity {
3796                        Severity::Critical => Severity::Medium,
3797                        s => downgrade_one_step(s),
3798                    };
3799                    finding.message.push_str(
3800                        " (downgraded: every privileged job in this workflow carries the \
3801                         standard fork-check `if:` — fork PRs cannot reach the privileged steps)",
3802                    );
3803                }
3804            }
3805            // ── Suppression 3: over_privileged_identity
3806            FindingCategory::OverPrivilegedIdentity => {
3807                // Only relevant when the firing identity IS the
3808                // workflow-level GITHUB_TOKEN AND at least one job has its
3809                // own narrower override.
3810                let firing_node_name = finding
3811                    .nodes_involved
3812                    .first()
3813                    .and_then(|id| graph.node(*id))
3814                    .map(|n| n.name.clone());
3815                let is_workflow_level_token = firing_node_name.as_deref() == Some("GITHUB_TOKEN");
3816                if is_workflow_level_token && !job_level_narrow_overrides.is_empty() {
3817                    // Suppress by reducing to Info — the runtime identity
3818                    // any job actually uses is the narrower job-level one.
3819                    finding.severity = Severity::Info;
3820                    let mut narrower: Vec<&str> = job_level_narrow_overrides
3821                        .iter()
3822                        .map(|s| s.as_str())
3823                        .collect();
3824                    narrower.sort_unstable();
3825                    finding.message.push_str(&format!(
3826                        " (suppressed: job-level permissions narrow this scope at runtime — \
3827                         see {})",
3828                        narrower.join(", ")
3829                    ));
3830                }
3831            }
3832            // ── Suppression 4: terraform_auto_approve_in_prod
3833            //
3834            // The pre-existing rule already early-skipped
3835            // env-gated steps, so it never emits a finding to downgrade.
3836            // Downgrade is wired into the rule body itself (search for
3837            // `env_gated`) — kept as a no-op match arm here so future
3838            // contributors can find the suppression-pass alongside the
3839            // others.
3840            FindingCategory::TerraformAutoApproveInProd => { /* see rule body */ }
3841            _ => {}
3842        }
3843    }
3844}
3845
3846pub fn run_all_rules(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
3847    let mut findings = Vec::new();
3848    // MVP rules
3849    findings.extend(authority_propagation(graph, max_hops));
3850    findings.extend(over_privileged_identity(graph));
3851    findings.extend(unpinned_action(graph));
3852    findings.extend(untrusted_with_authority(graph));
3853    findings.extend(artifact_boundary_crossing(graph));
3854    // Stretch rules
3855    findings.extend(long_lived_credential(graph));
3856    findings.extend(floating_image(graph));
3857    findings.extend(persisted_credential(graph));
3858    findings.extend(trigger_context_mismatch(graph));
3859    findings.extend(cross_workflow_authority_chain(graph));
3860    findings.extend(authority_cycle(graph));
3861    findings.extend(uplift_without_attestation(graph));
3862    findings.extend(self_mutating_pipeline(graph));
3863    findings.extend(checkout_self_pr_exposure(graph));
3864    findings.extend(variable_group_in_pr_job(graph));
3865    findings.extend(self_hosted_pool_pr_hijack(graph));
3866    findings.extend(service_connection_scope_mismatch(graph));
3867    findings.extend(template_extends_unpinned_branch(graph));
3868    findings.extend(template_repo_ref_is_feature_branch(graph));
3869    findings.extend(vm_remote_exec_via_pipeline_secret(graph));
3870    findings.extend(short_lived_sas_in_command_line(graph));
3871    // ADO inline-script secret-leak rules
3872    findings.extend(secret_to_inline_script_env_export(graph));
3873    findings.extend(secret_materialised_to_workspace_file(graph));
3874    findings.extend(keyvault_secret_to_plaintext(graph));
3875    findings.extend(terraform_auto_approve_in_prod(graph));
3876    findings.extend(addspn_with_inline_script(graph));
3877    findings.extend(parameter_interpolation_into_shell(graph));
3878    // GHA red-team-derived rules
3879    findings.extend(runtime_script_fetched_from_floating_url(graph));
3880    findings.extend(pr_trigger_with_floating_action_ref(graph));
3881    findings.extend(untrusted_api_response_to_env_sink(graph));
3882    findings.extend(pr_build_pushes_image_with_floating_credentials(graph));
3883    // Composition-gap rule: env-gate laundering into untrusted consumer.
3884    findings.extend(secret_via_env_gate_to_untrusted_consumer(graph));
3885    // Blue-team positive invariants (negative-space rules — fire on absence
3886    // of expected defenses)
3887    findings.extend(no_workflow_level_permissions_block(graph));
3888    findings.extend(prod_deploy_job_no_environment_gate(graph));
3889    findings.extend(long_lived_secret_without_oidc_recommendation(graph));
3890    findings.extend(pull_request_workflow_inconsistent_fork_check(graph));
3891    findings.extend(gitlab_deploy_job_missing_protected_branch_only(graph));
3892
3893    // Blue-team compensating-control suppressions (downgrade or suppress
3894    // existing-rule findings when a control elsewhere in the graph
3895    // neutralises the risk). Applied AFTER all rules emit so the
3896    // suppressions can see every finding alongside the graph.
3897    apply_compensating_controls(graph, &mut findings);
3898
3899    apply_confidence_cap(graph, &mut findings);
3900
3901    findings.sort_by_key(|f| f.severity);
3902
3903    findings
3904}
3905
3906#[cfg(test)]
3907mod tests {
3908    use super::*;
3909    use crate::graph::*;
3910
3911    fn source(file: &str) -> PipelineSource {
3912        PipelineSource {
3913            file: file.into(),
3914            repo: None,
3915            git_ref: None,
3916            commit_sha: None,
3917        }
3918    }
3919
3920    #[test]
3921    fn unpinned_third_party_action_flagged() {
3922        let mut g = AuthorityGraph::new(source("ci.yml"));
3923        g.add_node(
3924            NodeKind::Image,
3925            "actions/checkout@v4",
3926            TrustZone::ThirdParty,
3927        );
3928
3929        let findings = unpinned_action(&g);
3930        assert_eq!(findings.len(), 1);
3931        assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
3932    }
3933
3934    #[test]
3935    fn pinned_action_not_flagged() {
3936        let mut g = AuthorityGraph::new(source("ci.yml"));
3937        g.add_node(
3938            NodeKind::Image,
3939            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
3940            TrustZone::ThirdParty,
3941        );
3942
3943        let findings = unpinned_action(&g);
3944        assert!(findings.is_empty());
3945    }
3946
3947    #[test]
3948    fn untrusted_step_with_secret_is_critical() {
3949        let mut g = AuthorityGraph::new(source("ci.yml"));
3950        let step = g.add_node(NodeKind::Step, "evil-action", TrustZone::Untrusted);
3951        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
3952        g.add_edge(step, secret, EdgeKind::HasAccessTo);
3953
3954        let findings = untrusted_with_authority(&g);
3955        assert_eq!(findings.len(), 1);
3956        assert_eq!(findings[0].severity, Severity::Critical);
3957    }
3958
3959    #[test]
3960    fn implicit_identity_downgrades_to_info() {
3961        let mut g = AuthorityGraph::new(source("ci.yml"));
3962        let step = g.add_node(NodeKind::Step, "AzureCLI@2", TrustZone::Untrusted);
3963        let mut meta = std::collections::HashMap::new();
3964        meta.insert(META_IMPLICIT.into(), "true".into());
3965        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
3966        let token = g.add_node_with_metadata(
3967            NodeKind::Identity,
3968            "System.AccessToken",
3969            TrustZone::FirstParty,
3970            meta,
3971        );
3972        g.add_edge(step, token, EdgeKind::HasAccessTo);
3973
3974        let findings = untrusted_with_authority(&g);
3975        assert_eq!(findings.len(), 1);
3976        assert_eq!(
3977            findings[0].severity,
3978            Severity::Info,
3979            "implicit token must be Info not Critical"
3980        );
3981        assert!(findings[0].message.contains("platform-injected"));
3982    }
3983
3984    #[test]
3985    fn explicit_secret_remains_critical_despite_implicit_token() {
3986        let mut g = AuthorityGraph::new(source("ci.yml"));
3987        let step = g.add_node(NodeKind::Step, "AzureCLI@2", TrustZone::Untrusted);
3988        // implicit token → Info
3989        let mut meta = std::collections::HashMap::new();
3990        meta.insert(META_IMPLICIT.into(), "true".into());
3991        let token = g.add_node_with_metadata(
3992            NodeKind::Identity,
3993            "System.AccessToken",
3994            TrustZone::FirstParty,
3995            meta,
3996        );
3997        // explicit secret → Critical
3998        let secret = g.add_node(NodeKind::Secret, "ARM_CLIENT_SECRET", TrustZone::FirstParty);
3999        g.add_edge(step, token, EdgeKind::HasAccessTo);
4000        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4001
4002        let findings = untrusted_with_authority(&g);
4003        assert_eq!(findings.len(), 2);
4004        let info = findings
4005            .iter()
4006            .find(|f| f.severity == Severity::Info)
4007            .unwrap();
4008        let crit = findings
4009            .iter()
4010            .find(|f| f.severity == Severity::Critical)
4011            .unwrap();
4012        assert!(info.message.contains("platform-injected"));
4013        assert!(crit.message.contains("ARM_CLIENT_SECRET"));
4014    }
4015
4016    #[test]
4017    fn artifact_crossing_detected() {
4018        let mut g = AuthorityGraph::new(source("ci.yml"));
4019        let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
4020        let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4021        let artifact = g.add_node(NodeKind::Artifact, "dist.zip", TrustZone::FirstParty);
4022        let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
4023
4024        g.add_edge(build, secret, EdgeKind::HasAccessTo);
4025        g.add_edge(build, artifact, EdgeKind::Produces);
4026        g.add_edge(artifact, deploy, EdgeKind::Consumes);
4027
4028        let findings = artifact_boundary_crossing(&g);
4029        assert_eq!(findings.len(), 1);
4030        assert_eq!(
4031            findings[0].category,
4032            FindingCategory::ArtifactBoundaryCrossing
4033        );
4034    }
4035
4036    #[test]
4037    fn propagation_to_sha_pinned_is_high_not_critical() {
4038        let mut g = AuthorityGraph::new(source("ci.yml"));
4039        let mut meta = std::collections::HashMap::new();
4040        meta.insert(
4041            "digest".into(),
4042            "a5ac7e51b41094c92402da3b24376905380afc29".into(),
4043        );
4044        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
4045        let step = g.add_node(NodeKind::Step, "checkout", TrustZone::ThirdParty);
4046        let image = g.add_node_with_metadata(
4047            NodeKind::Image,
4048            "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
4049            TrustZone::ThirdParty,
4050            meta,
4051        );
4052
4053        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4054        g.add_edge(step, image, EdgeKind::UsesImage);
4055
4056        let findings = authority_propagation(&g, 4);
4057        // Should find propagation to the SHA-pinned image
4058        let image_findings: Vec<_> = findings
4059            .iter()
4060            .filter(|f| f.nodes_involved.contains(&image))
4061            .collect();
4062        assert!(!image_findings.is_empty());
4063        // SHA-pinned targets get High, not Critical (non-OIDC source)
4064        assert_eq!(image_findings[0].severity, Severity::High);
4065    }
4066
4067    #[test]
4068    fn oidc_identity_to_pinned_third_party_is_critical() {
4069        let mut g = AuthorityGraph::new(source("ci.yml"));
4070
4071        // OIDC-federated cloud identity — token itself is the threat
4072        let mut id_meta = std::collections::HashMap::new();
4073        id_meta.insert(META_OIDC.into(), "true".into());
4074        let identity = g.add_node_with_metadata(
4075            NodeKind::Identity,
4076            "AWS_OIDC_ROLE",
4077            TrustZone::FirstParty,
4078            id_meta,
4079        );
4080
4081        // SHA-pinned ThirdParty image — would normally be High without OIDC
4082        let mut img_meta = std::collections::HashMap::new();
4083        img_meta.insert(
4084            META_DIGEST.into(),
4085            "a5ac7e51b41094c92402da3b24376905380afc29".into(),
4086        );
4087        let image = g.add_node_with_metadata(
4088            NodeKind::Image,
4089            "aws-actions/configure-aws-credentials@a5ac7e51b41094c92402da3b24376905380afc29",
4090            TrustZone::ThirdParty,
4091            img_meta,
4092        );
4093
4094        // Step in ThirdParty zone holds the OIDC identity and uses the pinned image
4095        let step = g.add_node(
4096            NodeKind::Step,
4097            "configure-aws-credentials",
4098            TrustZone::ThirdParty,
4099        );
4100        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4101        g.add_edge(step, image, EdgeKind::UsesImage);
4102
4103        let findings = authority_propagation(&g, 4);
4104        let image_findings: Vec<_> = findings
4105            .iter()
4106            .filter(|f| f.nodes_involved.contains(&image))
4107            .collect();
4108        assert!(
4109            !image_findings.is_empty(),
4110            "expected OIDC→pinned propagation finding"
4111        );
4112        // OIDC source escalates pinned ThirdParty from High → Critical
4113        assert_eq!(image_findings[0].severity, Severity::Critical);
4114    }
4115
4116    #[test]
4117    fn propagation_to_untrusted_is_critical() {
4118        let mut g = AuthorityGraph::new(source("ci.yml"));
4119        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
4120        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
4121        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
4122
4123        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4124        g.add_edge(step, image, EdgeKind::UsesImage);
4125
4126        let findings = authority_propagation(&g, 4);
4127        let image_findings: Vec<_> = findings
4128            .iter()
4129            .filter(|f| f.nodes_involved.contains(&image))
4130            .collect();
4131        assert!(!image_findings.is_empty());
4132        assert_eq!(image_findings[0].severity, Severity::Critical);
4133    }
4134
4135    #[test]
4136    fn long_lived_credential_detected() {
4137        let mut g = AuthorityGraph::new(source("ci.yml"));
4138        g.add_node(NodeKind::Secret, "AWS_ACCESS_KEY_ID", TrustZone::FirstParty);
4139        g.add_node(NodeKind::Secret, "NPM_TOKEN", TrustZone::FirstParty);
4140        g.add_node(NodeKind::Secret, "DEPLOY_API_KEY", TrustZone::FirstParty);
4141        // Non-matching names
4142        g.add_node(NodeKind::Secret, "CACHE_TTL", TrustZone::FirstParty);
4143
4144        let findings = long_lived_credential(&g);
4145        assert_eq!(findings.len(), 2); // AWS_ACCESS_KEY_ID + DEPLOY_API_KEY
4146        assert!(findings
4147            .iter()
4148            .all(|f| f.category == FindingCategory::LongLivedCredential));
4149    }
4150
4151    #[test]
4152    fn duplicate_unpinned_actions_deduplicated() {
4153        let mut g = AuthorityGraph::new(source("ci.yml"));
4154        // Same action used in two jobs — two Image nodes, same name
4155        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
4156        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
4157        g.add_node(
4158            NodeKind::Image,
4159            "actions/setup-node@v3",
4160            TrustZone::Untrusted,
4161        );
4162
4163        let findings = unpinned_action(&g);
4164        // Should get 2 findings (checkout + setup-node), not 3
4165        assert_eq!(findings.len(), 2);
4166    }
4167
4168    #[test]
4169    fn broad_identity_scope_flagged_as_high() {
4170        let mut g = AuthorityGraph::new(source("ci.yml"));
4171        let mut meta = std::collections::HashMap::new();
4172        meta.insert(META_PERMISSIONS.into(), "write-all".into());
4173        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
4174        let identity = g.add_node_with_metadata(
4175            NodeKind::Identity,
4176            "GITHUB_TOKEN",
4177            TrustZone::FirstParty,
4178            meta,
4179        );
4180        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4181        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4182
4183        let findings = over_privileged_identity(&g);
4184        assert_eq!(findings.len(), 1);
4185        assert_eq!(findings[0].severity, Severity::High);
4186        assert!(findings[0].message.contains("broad"));
4187    }
4188
4189    #[test]
4190    fn unknown_identity_scope_flagged_as_medium() {
4191        let mut g = AuthorityGraph::new(source("ci.yml"));
4192        let mut meta = std::collections::HashMap::new();
4193        meta.insert(META_PERMISSIONS.into(), "custom-scope".into());
4194        meta.insert(META_IDENTITY_SCOPE.into(), "unknown".into());
4195        let identity = g.add_node_with_metadata(
4196            NodeKind::Identity,
4197            "GITHUB_TOKEN",
4198            TrustZone::FirstParty,
4199            meta,
4200        );
4201        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4202        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4203
4204        let findings = over_privileged_identity(&g);
4205        assert_eq!(findings.len(), 1);
4206        assert_eq!(findings[0].severity, Severity::Medium);
4207        assert!(findings[0].message.contains("unknown"));
4208    }
4209
4210    #[test]
4211    fn floating_image_unpinned_container_flagged() {
4212        let mut g = AuthorityGraph::new(source("ci.yml"));
4213        let mut meta = std::collections::HashMap::new();
4214        meta.insert(META_CONTAINER.into(), "true".into());
4215        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
4216
4217        let findings = floating_image(&g);
4218        assert_eq!(findings.len(), 1);
4219        assert_eq!(findings[0].category, FindingCategory::FloatingImage);
4220        assert_eq!(findings[0].severity, Severity::Medium);
4221    }
4222
4223    #[test]
4224    fn partial_graph_caps_critical_findings_at_high() {
4225        let mut g = AuthorityGraph::new(source("ci.yml"));
4226        g.mark_partial("matrix strategy hides some authority paths");
4227
4228        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
4229        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
4230        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
4231
4232        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4233        g.add_edge(step, image, EdgeKind::UsesImage);
4234
4235        let findings = run_all_rules(&g, 4);
4236        assert!(findings
4237            .iter()
4238            .any(|f| f.category == FindingCategory::AuthorityPropagation));
4239        assert!(findings
4240            .iter()
4241            .any(|f| f.category == FindingCategory::UntrustedWithAuthority));
4242        assert!(findings.iter().all(|f| f.severity >= Severity::High));
4243        assert!(!findings.iter().any(|f| f.severity == Severity::Critical));
4244    }
4245
4246    #[test]
4247    fn complete_graph_keeps_critical_findings() {
4248        let mut g = AuthorityGraph::new(source("ci.yml"));
4249
4250        let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
4251        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
4252        let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
4253
4254        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4255        g.add_edge(step, image, EdgeKind::UsesImage);
4256
4257        let findings = run_all_rules(&g, 4);
4258        assert!(findings.iter().any(|f| f.severity == Severity::Critical));
4259    }
4260
4261    #[test]
4262    fn floating_image_digest_pinned_container_not_flagged() {
4263        let mut g = AuthorityGraph::new(source("ci.yml"));
4264        let mut meta = std::collections::HashMap::new();
4265        meta.insert(META_CONTAINER.into(), "true".into());
4266        g.add_node_with_metadata(
4267            NodeKind::Image,
4268            "ubuntu@sha256:a5ac7e51b41094c92402da3b24376905380afc29a5ac7e51b41094c92402da3b",
4269            TrustZone::ThirdParty,
4270            meta,
4271        );
4272
4273        let findings = floating_image(&g);
4274        assert!(
4275            findings.is_empty(),
4276            "digest-pinned container should not be flagged"
4277        );
4278    }
4279
4280    #[test]
4281    fn unpinned_action_does_not_flag_container_images() {
4282        // Regression: container Image nodes are handled by floating_image, not unpinned_action.
4283        // The same node must not generate findings from both rules.
4284        let mut g = AuthorityGraph::new(source("ci.yml"));
4285        let mut meta = std::collections::HashMap::new();
4286        meta.insert(META_CONTAINER.into(), "true".into());
4287        g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
4288
4289        let findings = unpinned_action(&g);
4290        assert!(
4291            findings.is_empty(),
4292            "unpinned_action must skip container images to avoid double-flagging"
4293        );
4294    }
4295
4296    #[test]
4297    fn floating_image_ignores_action_images() {
4298        let mut g = AuthorityGraph::new(source("ci.yml"));
4299        // Image node without META_CONTAINER — this is a step uses: action, not a container
4300        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
4301
4302        let findings = floating_image(&g);
4303        assert!(
4304            findings.is_empty(),
4305            "floating_image should not flag step actions"
4306        );
4307    }
4308
4309    #[test]
4310    fn persisted_credential_rule_fires_on_persists_to_edge() {
4311        let mut g = AuthorityGraph::new(source("ci.yml"));
4312        let token = g.add_node(
4313            NodeKind::Identity,
4314            "System.AccessToken",
4315            TrustZone::FirstParty,
4316        );
4317        let checkout = g.add_node(NodeKind::Step, "checkout", TrustZone::FirstParty);
4318        g.add_edge(checkout, token, EdgeKind::PersistsTo);
4319
4320        let findings = persisted_credential(&g);
4321        assert_eq!(findings.len(), 1);
4322        assert_eq!(findings[0].category, FindingCategory::PersistedCredential);
4323        assert_eq!(findings[0].severity, Severity::High);
4324        assert!(findings[0].message.contains("persistCredentials"));
4325    }
4326
4327    #[test]
4328    fn untrusted_with_cli_flag_exposed_secret_notes_log_exposure() {
4329        let mut g = AuthorityGraph::new(source("ci.yml"));
4330        let step = g.add_node(NodeKind::Step, "TerraformCLI@0", TrustZone::Untrusted);
4331        let mut meta = std::collections::HashMap::new();
4332        meta.insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
4333        let secret =
4334            g.add_node_with_metadata(NodeKind::Secret, "db_password", TrustZone::FirstParty, meta);
4335        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4336
4337        let findings = untrusted_with_authority(&g);
4338        assert_eq!(findings.len(), 1);
4339        assert!(
4340            findings[0].message.contains("-var flag"),
4341            "message should note -var flag log exposure"
4342        );
4343        assert!(matches!(
4344            findings[0].recommendation,
4345            Recommendation::Manual { .. }
4346        ));
4347    }
4348
4349    #[test]
4350    fn constrained_identity_scope_not_flagged() {
4351        let mut g = AuthorityGraph::new(source("ci.yml"));
4352        let mut meta = std::collections::HashMap::new();
4353        meta.insert(META_PERMISSIONS.into(), "{ contents: read }".into());
4354        meta.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
4355        let identity = g.add_node_with_metadata(
4356            NodeKind::Identity,
4357            "GITHUB_TOKEN",
4358            TrustZone::FirstParty,
4359            meta,
4360        );
4361        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4362        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4363
4364        let findings = over_privileged_identity(&g);
4365        assert!(
4366            findings.is_empty(),
4367            "constrained scope should not be flagged"
4368        );
4369    }
4370
4371    #[test]
4372    fn trigger_context_mismatch_fires_on_pull_request_target_with_secret() {
4373        let mut g = AuthorityGraph::new(source("ci.yml"));
4374        g.metadata
4375            .insert(META_TRIGGER.into(), "pull_request_target".into());
4376        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4377        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4378        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4379
4380        let findings = trigger_context_mismatch(&g);
4381        assert_eq!(findings.len(), 1);
4382        assert_eq!(findings[0].severity, Severity::Critical);
4383        assert_eq!(
4384            findings[0].category,
4385            FindingCategory::TriggerContextMismatch
4386        );
4387    }
4388
4389    #[test]
4390    fn trigger_context_mismatch_no_fire_without_trigger_metadata() {
4391        let mut g = AuthorityGraph::new(source("ci.yml"));
4392        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4393        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4394        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4395
4396        let findings = trigger_context_mismatch(&g);
4397        assert!(findings.is_empty(), "no trigger metadata → no finding");
4398    }
4399
4400    #[test]
4401    fn cross_workflow_authority_chain_detected() {
4402        let mut g = AuthorityGraph::new(source("ci.yml"));
4403        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
4404        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4405        let external = g.add_node(
4406            NodeKind::Image,
4407            "evil/workflow.yml@main",
4408            TrustZone::Untrusted,
4409        );
4410        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4411        g.add_edge(step, external, EdgeKind::DelegatesTo);
4412
4413        let findings = cross_workflow_authority_chain(&g);
4414        assert_eq!(findings.len(), 1);
4415        assert_eq!(findings[0].severity, Severity::Critical);
4416        assert_eq!(
4417            findings[0].category,
4418            FindingCategory::CrossWorkflowAuthorityChain
4419        );
4420    }
4421
4422    #[test]
4423    fn cross_workflow_authority_chain_no_fire_if_local_delegation() {
4424        let mut g = AuthorityGraph::new(source("ci.yml"));
4425        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
4426        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4427        let local = g.add_node(NodeKind::Image, "./local-action", TrustZone::FirstParty);
4428        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4429        g.add_edge(step, local, EdgeKind::DelegatesTo);
4430
4431        let findings = cross_workflow_authority_chain(&g);
4432        assert!(
4433            findings.is_empty(),
4434            "FirstParty delegation should not be flagged"
4435        );
4436    }
4437
4438    #[test]
4439    fn authority_cycle_detected() {
4440        let mut g = AuthorityGraph::new(source("ci.yml"));
4441        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
4442        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
4443        g.add_edge(a, b, EdgeKind::DelegatesTo);
4444        g.add_edge(b, a, EdgeKind::DelegatesTo);
4445
4446        let findings = authority_cycle(&g);
4447        assert_eq!(findings.len(), 1);
4448        assert_eq!(findings[0].category, FindingCategory::AuthorityCycle);
4449        assert_eq!(findings[0].severity, Severity::High);
4450    }
4451
4452    #[test]
4453    fn authority_cycle_no_fire_for_acyclic_graph() {
4454        let mut g = AuthorityGraph::new(source("ci.yml"));
4455        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
4456        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
4457        let c = g.add_node(NodeKind::Step, "C", TrustZone::FirstParty);
4458        g.add_edge(a, b, EdgeKind::DelegatesTo);
4459        g.add_edge(b, c, EdgeKind::DelegatesTo);
4460
4461        let findings = authority_cycle(&g);
4462        assert!(findings.is_empty(), "acyclic graph must not fire");
4463    }
4464
4465    #[test]
4466    fn uplift_without_attestation_fires_when_oidc_no_attests() {
4467        let mut g = AuthorityGraph::new(source("ci.yml"));
4468        let mut meta = std::collections::HashMap::new();
4469        meta.insert(META_OIDC.into(), "true".into());
4470        let identity = g.add_node_with_metadata(
4471            NodeKind::Identity,
4472            "AWS/deploy-role",
4473            TrustZone::FirstParty,
4474            meta,
4475        );
4476        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4477        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4478
4479        let findings = uplift_without_attestation(&g);
4480        assert_eq!(findings.len(), 1);
4481        assert_eq!(findings[0].severity, Severity::Info);
4482        assert_eq!(
4483            findings[0].category,
4484            FindingCategory::UpliftWithoutAttestation
4485        );
4486    }
4487
4488    #[test]
4489    fn uplift_without_attestation_no_fire_when_attests_present() {
4490        let mut g = AuthorityGraph::new(source("ci.yml"));
4491        let mut id_meta = std::collections::HashMap::new();
4492        id_meta.insert(META_OIDC.into(), "true".into());
4493        let identity = g.add_node_with_metadata(
4494            NodeKind::Identity,
4495            "AWS/deploy-role",
4496            TrustZone::FirstParty,
4497            id_meta,
4498        );
4499        let mut step_meta = std::collections::HashMap::new();
4500        step_meta.insert(META_ATTESTS.into(), "true".into());
4501        let attest_step =
4502            g.add_node_with_metadata(NodeKind::Step, "attest", TrustZone::FirstParty, step_meta);
4503        let build_step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4504        g.add_edge(build_step, identity, EdgeKind::HasAccessTo);
4505        // Touch attest_step so the variable is used (avoid unused warning)
4506        let _ = attest_step;
4507
4508        let findings = uplift_without_attestation(&g);
4509        assert!(findings.is_empty(), "attestation present → no finding");
4510    }
4511
4512    #[test]
4513    fn uplift_without_attestation_no_fire_without_oidc() {
4514        let mut g = AuthorityGraph::new(source("ci.yml"));
4515        let mut meta = std::collections::HashMap::new();
4516        meta.insert(META_PERMISSIONS.into(), "write-all".into());
4517        meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
4518        // Note: no META_OIDC
4519        let identity = g.add_node_with_metadata(
4520            NodeKind::Identity,
4521            "GITHUB_TOKEN",
4522            TrustZone::FirstParty,
4523            meta,
4524        );
4525        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4526        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4527
4528        let findings = uplift_without_attestation(&g);
4529        assert!(
4530            findings.is_empty(),
4531            "broad identity without OIDC must not fire"
4532        );
4533    }
4534
4535    #[test]
4536    fn self_mutating_pipeline_untrusted_is_critical() {
4537        let mut g = AuthorityGraph::new(source("ci.yml"));
4538        let mut meta = std::collections::HashMap::new();
4539        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
4540        g.add_node_with_metadata(NodeKind::Step, "fork-step", TrustZone::Untrusted, meta);
4541
4542        let findings = self_mutating_pipeline(&g);
4543        assert_eq!(findings.len(), 1);
4544        assert_eq!(findings[0].severity, Severity::Critical);
4545        assert_eq!(findings[0].category, FindingCategory::SelfMutatingPipeline);
4546    }
4547
4548    #[test]
4549    fn self_mutating_pipeline_privileged_step_is_high() {
4550        let mut g = AuthorityGraph::new(source("ci.yml"));
4551        let mut meta = std::collections::HashMap::new();
4552        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
4553        let step = g.add_node_with_metadata(NodeKind::Step, "build", TrustZone::FirstParty, meta);
4554        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4555        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4556
4557        let findings = self_mutating_pipeline(&g);
4558        assert_eq!(findings.len(), 1);
4559        assert_eq!(findings[0].severity, Severity::High);
4560    }
4561
4562    #[test]
4563    fn trigger_context_mismatch_fires_on_ado_pr_with_secret_as_high() {
4564        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4565        g.metadata.insert(META_TRIGGER.into(), "pr".into());
4566        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4567        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
4568        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4569
4570        let findings = trigger_context_mismatch(&g);
4571        assert_eq!(findings.len(), 1);
4572        assert_eq!(findings[0].severity, Severity::High);
4573        assert_eq!(
4574            findings[0].category,
4575            FindingCategory::TriggerContextMismatch
4576        );
4577    }
4578
4579    #[test]
4580    fn cross_workflow_authority_chain_third_party_is_high() {
4581        let mut g = AuthorityGraph::new(source("ci.yml"));
4582        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
4583        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4584        // ThirdParty target (SHA-pinned external workflow)
4585        let external = g.add_node(
4586            NodeKind::Image,
4587            "org/repo/.github/workflows/deploy.yml@a5ac7e51b41094c92402da3b24376905380afc29",
4588            TrustZone::ThirdParty,
4589        );
4590        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4591        g.add_edge(step, external, EdgeKind::DelegatesTo);
4592
4593        let findings = cross_workflow_authority_chain(&g);
4594        assert_eq!(findings.len(), 1);
4595        assert_eq!(
4596            findings[0].severity,
4597            Severity::High,
4598            "ThirdParty delegation target should be High (Critical reserved for Untrusted)"
4599        );
4600        assert_eq!(
4601            findings[0].category,
4602            FindingCategory::CrossWorkflowAuthorityChain
4603        );
4604    }
4605
4606    #[test]
4607    fn self_mutating_pipeline_first_party_no_authority_is_medium() {
4608        let mut g = AuthorityGraph::new(source("ci.yml"));
4609        let mut meta = std::collections::HashMap::new();
4610        meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
4611        // FirstParty step writes the gate but holds no secret/identity access.
4612        g.add_node_with_metadata(NodeKind::Step, "set-version", TrustZone::FirstParty, meta);
4613
4614        let findings = self_mutating_pipeline(&g);
4615        assert_eq!(findings.len(), 1);
4616        assert_eq!(findings[0].severity, Severity::Medium);
4617        assert_eq!(findings[0].category, FindingCategory::SelfMutatingPipeline);
4618    }
4619
4620    #[test]
4621    fn authority_cycle_3node_cycle_includes_all_members() {
4622        // A → B → C → A should produce one finding whose nodes_involved
4623        // contains all three node IDs, not just the back-edge endpoints.
4624        let mut g = AuthorityGraph::new(source("test.yml"));
4625        let a = g.add_node(NodeKind::Step, "A", TrustZone::FirstParty);
4626        let b = g.add_node(NodeKind::Step, "B", TrustZone::FirstParty);
4627        let c = g.add_node(NodeKind::Step, "C", TrustZone::FirstParty);
4628        g.add_edge(a, b, EdgeKind::DelegatesTo);
4629        g.add_edge(b, c, EdgeKind::DelegatesTo);
4630        g.add_edge(c, a, EdgeKind::DelegatesTo);
4631
4632        let findings = authority_cycle(&g);
4633        assert_eq!(findings.len(), 1);
4634        assert_eq!(findings[0].category, FindingCategory::AuthorityCycle);
4635        assert!(
4636            findings[0].nodes_involved.contains(&a),
4637            "A must be in nodes_involved"
4638        );
4639        assert!(
4640            findings[0].nodes_involved.contains(&b),
4641            "B must be in nodes_involved — middle of A→B→C→A cycle"
4642        );
4643        assert!(
4644            findings[0].nodes_involved.contains(&c),
4645            "C must be in nodes_involved"
4646        );
4647    }
4648
4649    #[test]
4650    fn variable_group_in_pr_job_fires_on_pr_trigger_with_var_group() {
4651        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4652        g.metadata.insert(META_TRIGGER.into(), "pr".into());
4653        let mut secret_meta = std::collections::HashMap::new();
4654        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
4655        let secret = g.add_node_with_metadata(
4656            NodeKind::Secret,
4657            "prod-deploy-secrets",
4658            TrustZone::FirstParty,
4659            secret_meta,
4660        );
4661        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
4662        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4663
4664        let findings = variable_group_in_pr_job(&g);
4665        assert_eq!(findings.len(), 1);
4666        assert_eq!(findings[0].severity, Severity::Critical);
4667        assert_eq!(findings[0].category, FindingCategory::VariableGroupInPrJob);
4668        assert!(findings[0].message.contains("prod-deploy-secrets"));
4669    }
4670
4671    #[test]
4672    fn variable_group_in_pr_job_no_fire_without_pr_trigger() {
4673        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4674        // No trigger metadata — should not fire
4675        let mut secret_meta = std::collections::HashMap::new();
4676        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
4677        let secret = g.add_node_with_metadata(
4678            NodeKind::Secret,
4679            "prod-deploy-secrets",
4680            TrustZone::FirstParty,
4681            secret_meta,
4682        );
4683        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
4684        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4685
4686        let findings = variable_group_in_pr_job(&g);
4687        assert!(
4688            findings.is_empty(),
4689            "no PR trigger → variable_group_in_pr_job must not fire"
4690        );
4691    }
4692
4693    #[test]
4694    fn self_hosted_pool_pr_hijack_fires_when_all_three_factors_present() {
4695        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4696        g.metadata.insert(META_TRIGGER.into(), "pr".into());
4697
4698        let mut pool_meta = std::collections::HashMap::new();
4699        pool_meta.insert(META_SELF_HOSTED.into(), "true".into());
4700        g.add_node_with_metadata(
4701            NodeKind::Image,
4702            "self-hosted-pool",
4703            TrustZone::FirstParty,
4704            pool_meta,
4705        );
4706
4707        let mut step_meta = std::collections::HashMap::new();
4708        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
4709        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
4710
4711        let findings = self_hosted_pool_pr_hijack(&g);
4712        assert_eq!(findings.len(), 1);
4713        assert_eq!(findings[0].severity, Severity::Critical);
4714        assert_eq!(
4715            findings[0].category,
4716            FindingCategory::SelfHostedPoolPrHijack
4717        );
4718        assert!(findings[0].message.contains("self-hosted"));
4719    }
4720
4721    #[test]
4722    fn self_hosted_pool_pr_hijack_no_fire_without_pr_trigger() {
4723        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4724        // No trigger metadata
4725
4726        let mut pool_meta = std::collections::HashMap::new();
4727        pool_meta.insert(META_SELF_HOSTED.into(), "true".into());
4728        g.add_node_with_metadata(
4729            NodeKind::Image,
4730            "self-hosted-pool",
4731            TrustZone::FirstParty,
4732            pool_meta,
4733        );
4734
4735        let mut step_meta = std::collections::HashMap::new();
4736        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
4737        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
4738
4739        let findings = self_hosted_pool_pr_hijack(&g);
4740        assert!(
4741            findings.is_empty(),
4742            "no PR trigger → self_hosted_pool_pr_hijack must not fire"
4743        );
4744    }
4745
4746    #[test]
4747    fn service_connection_scope_mismatch_fires_on_pr_broad_non_oidc() {
4748        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4749        g.metadata.insert(META_TRIGGER.into(), "pr".into());
4750
4751        let mut sc_meta = std::collections::HashMap::new();
4752        sc_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
4753        sc_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
4754        // No META_OIDC → treated as not OIDC-federated
4755        let sc = g.add_node_with_metadata(
4756            NodeKind::Identity,
4757            "prod-azure-sc",
4758            TrustZone::FirstParty,
4759            sc_meta,
4760        );
4761        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
4762        g.add_edge(step, sc, EdgeKind::HasAccessTo);
4763
4764        let findings = service_connection_scope_mismatch(&g);
4765        assert_eq!(findings.len(), 1);
4766        assert_eq!(findings[0].severity, Severity::High);
4767        assert_eq!(
4768            findings[0].category,
4769            FindingCategory::ServiceConnectionScopeMismatch
4770        );
4771        assert!(findings[0].message.contains("prod-azure-sc"));
4772    }
4773
4774    #[test]
4775    fn service_connection_scope_mismatch_no_fire_without_pr_trigger() {
4776        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4777        // No trigger metadata
4778        let mut sc_meta = std::collections::HashMap::new();
4779        sc_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
4780        sc_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
4781        let sc = g.add_node_with_metadata(
4782            NodeKind::Identity,
4783            "prod-azure-sc",
4784            TrustZone::FirstParty,
4785            sc_meta,
4786        );
4787        let step = g.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
4788        g.add_edge(step, sc, EdgeKind::HasAccessTo);
4789
4790        let findings = service_connection_scope_mismatch(&g);
4791        assert!(
4792            findings.is_empty(),
4793            "no PR trigger → service_connection_scope_mismatch must not fire"
4794        );
4795    }
4796
4797    #[test]
4798    fn checkout_self_pr_exposure_fires_on_pr_trigger() {
4799        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4800        g.metadata.insert(META_TRIGGER.into(), "pr".into());
4801        let mut step_meta = std::collections::HashMap::new();
4802        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
4803        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
4804
4805        let findings = checkout_self_pr_exposure(&g);
4806        assert_eq!(findings.len(), 1);
4807        assert_eq!(
4808            findings[0].category,
4809            FindingCategory::CheckoutSelfPrExposure
4810        );
4811        assert_eq!(findings[0].severity, Severity::High);
4812    }
4813
4814    #[test]
4815    fn checkout_self_pr_exposure_no_fire_without_pr_trigger() {
4816        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4817        // No META_TRIGGER set
4818        let mut step_meta = std::collections::HashMap::new();
4819        step_meta.insert(META_CHECKOUT_SELF.into(), "true".into());
4820        g.add_node_with_metadata(NodeKind::Step, "checkout", TrustZone::FirstParty, step_meta);
4821
4822        let findings = checkout_self_pr_exposure(&g);
4823        assert!(
4824            findings.is_empty(),
4825            "no PR trigger → checkout_self_pr_exposure must not fire"
4826        );
4827    }
4828
4829    #[test]
4830    fn variable_group_in_pr_job_uses_cellos_remediation() {
4831        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4832        g.metadata.insert(META_TRIGGER.into(), "pr".into());
4833
4834        let mut secret_meta = std::collections::HashMap::new();
4835        secret_meta.insert(META_VARIABLE_GROUP.into(), "true".into());
4836        let secret = g.add_node_with_metadata(
4837            NodeKind::Secret,
4838            "prod-secret",
4839            TrustZone::FirstParty,
4840            secret_meta,
4841        );
4842        let step = g.add_node(NodeKind::Step, "deploy step", TrustZone::Untrusted);
4843        g.add_edge(step, secret, EdgeKind::HasAccessTo);
4844
4845        let findings = variable_group_in_pr_job(&g);
4846        assert!(!findings.is_empty());
4847        assert!(
4848            matches!(
4849                findings[0].recommendation,
4850                Recommendation::CellosRemediation { .. }
4851            ),
4852            "variable_group_in_pr_job must recommend CellosRemediation"
4853        );
4854    }
4855
4856    #[test]
4857    fn service_connection_scope_mismatch_uses_cellos_remediation() {
4858        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4859        g.metadata.insert(META_TRIGGER.into(), "pr".into());
4860
4861        let mut id_meta = std::collections::HashMap::new();
4862        id_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
4863        id_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
4864        // No META_OIDC → treated as not OIDC-federated
4865        let identity = g.add_node_with_metadata(
4866            NodeKind::Identity,
4867            "sub-conn",
4868            TrustZone::FirstParty,
4869            id_meta,
4870        );
4871        let step = g.add_node(NodeKind::Step, "azure deploy", TrustZone::Untrusted);
4872        g.add_edge(step, identity, EdgeKind::HasAccessTo);
4873
4874        let findings = service_connection_scope_mismatch(&g);
4875        assert!(!findings.is_empty());
4876        assert!(
4877            matches!(
4878                findings[0].recommendation,
4879                Recommendation::CellosRemediation { .. }
4880            ),
4881            "service_connection_scope_mismatch must recommend CellosRemediation"
4882        );
4883    }
4884
4885    /// Build a propagation graph with an optional approval-gated middle step:
4886    ///   Secret → middle Step (FirstParty) → Artifact → ThirdParty Step.
4887    /// When `gated` is true the middle step carries META_ENV_APPROVAL.
4888    fn build_env_approval_graph(gated: bool) -> AuthorityGraph {
4889        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4890
4891        let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
4892        let mut middle_meta = std::collections::HashMap::new();
4893        if gated {
4894            middle_meta.insert(META_ENV_APPROVAL.into(), "true".into());
4895        }
4896        let middle = g.add_node_with_metadata(
4897            NodeKind::Step,
4898            "deploy-prod",
4899            TrustZone::FirstParty,
4900            middle_meta,
4901        );
4902        let artifact = g.add_node(NodeKind::Artifact, "release.tar", TrustZone::FirstParty);
4903        let third = g.add_node(
4904            NodeKind::Step,
4905            "third-party/uploader",
4906            TrustZone::ThirdParty,
4907        );
4908
4909        g.add_edge(middle, secret, EdgeKind::HasAccessTo);
4910        g.add_edge(middle, artifact, EdgeKind::Produces);
4911        g.add_edge(artifact, third, EdgeKind::Consumes);
4912
4913        g
4914    }
4915
4916    #[test]
4917    fn env_approval_gate_reduces_propagation_severity() {
4918        // Baseline: no gate → Critical (third-party sink, not SHA-pinned)
4919        let baseline = authority_propagation(&build_env_approval_graph(false), 4);
4920        let baseline_finding = baseline
4921            .iter()
4922            .find(|f| f.category == FindingCategory::AuthorityPropagation)
4923            .expect("baseline must produce an AuthorityPropagation finding");
4924        assert_eq!(baseline_finding.severity, Severity::Critical);
4925        assert!(!baseline_finding
4926            .message
4927            .contains("environment approval gate"));
4928
4929        // Gated: same shape, middle step tagged → severity drops one step to High
4930        let gated = authority_propagation(&build_env_approval_graph(true), 4);
4931        let gated_finding = gated
4932            .iter()
4933            .find(|f| f.category == FindingCategory::AuthorityPropagation)
4934            .expect("gated must produce an AuthorityPropagation finding");
4935        assert_eq!(
4936            gated_finding.severity,
4937            Severity::High,
4938            "Critical must downgrade to High when path crosses an env-approval gate"
4939        );
4940        assert!(
4941            gated_finding
4942                .message
4943                .contains("(mitigated: environment approval gate)"),
4944            "gated finding must annotate the mitigation in its message"
4945        );
4946    }
4947
4948    #[test]
4949    fn downgrade_one_step_table() {
4950        assert_eq!(downgrade_one_step(Severity::Critical), Severity::High);
4951        assert_eq!(downgrade_one_step(Severity::High), Severity::Medium);
4952        assert_eq!(downgrade_one_step(Severity::Medium), Severity::Low);
4953        assert_eq!(downgrade_one_step(Severity::Low), Severity::Low);
4954        assert_eq!(downgrade_one_step(Severity::Info), Severity::Info);
4955    }
4956
4957    // ── template_extends_unpinned_branch ──────────────────────
4958
4959    /// Build a graph whose META_REPOSITORIES carries a single repo descriptor.
4960    /// `git_ref` of `None` encodes the "no `ref:` field" case (default branch).
4961    fn graph_with_repo(
4962        alias: &str,
4963        repo_type: &str,
4964        name: &str,
4965        git_ref: Option<&str>,
4966        used: bool,
4967    ) -> AuthorityGraph {
4968        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
4969        let mut obj = serde_json::Map::new();
4970        obj.insert("alias".into(), serde_json::Value::String(alias.into()));
4971        obj.insert(
4972            "repo_type".into(),
4973            serde_json::Value::String(repo_type.into()),
4974        );
4975        obj.insert("name".into(), serde_json::Value::String(name.into()));
4976        if let Some(r) = git_ref {
4977            obj.insert("ref".into(), serde_json::Value::String(r.into()));
4978        }
4979        obj.insert("used".into(), serde_json::Value::Bool(used));
4980        let arr = serde_json::Value::Array(vec![serde_json::Value::Object(obj)]);
4981        g.metadata.insert(
4982            META_REPOSITORIES.into(),
4983            serde_json::to_string(&arr).unwrap(),
4984        );
4985        g
4986    }
4987
4988    // ── vm_remote_exec_via_pipeline_secret ──────────────
4989
4990    /// Helper: build a graph with one Step that has the given inline script
4991    /// body and (optionally) a HasAccessTo edge to a Secret named `sas_var`.
4992    fn graph_with_script_step(body: &str, secret_name: Option<&str>) -> AuthorityGraph {
4993        let mut g = AuthorityGraph::new(source("ado.yml"));
4994        let mut meta = std::collections::HashMap::new();
4995        meta.insert(META_SCRIPT_BODY.into(), body.into());
4996        let step_id =
4997            g.add_node_with_metadata(NodeKind::Step, "deploy-vm", TrustZone::FirstParty, meta);
4998        if let Some(name) = secret_name {
4999            let sec = g.add_node(NodeKind::Secret, name, TrustZone::FirstParty);
5000            g.add_edge(step_id, sec, EdgeKind::HasAccessTo);
5001        }
5002        g
5003    }
5004
5005    // ── secret_to_inline_script_env_export ────────────────────
5006
5007    /// Build a graph with one Step that has access to `secret_name` and
5008    /// stamps `script` as the META_SCRIPT_BODY.
5009    fn build_step_with_script(secret_name: &str, script: &str) -> AuthorityGraph {
5010        let mut g = AuthorityGraph::new(source("ado.yml"));
5011        let secret = g.add_node(NodeKind::Secret, secret_name, TrustZone::FirstParty);
5012        let mut meta = std::collections::HashMap::new();
5013        meta.insert(META_SCRIPT_BODY.into(), script.into());
5014        let step = g.add_node_with_metadata(NodeKind::Step, "deploy", TrustZone::FirstParty, meta);
5015        g.add_edge(step, secret, EdgeKind::HasAccessTo);
5016        g
5017    }
5018
5019    #[test]
5020    fn template_extends_unpinned_branch_fires_on_missing_ref() {
5021        let g = graph_with_repo(
5022            "template-library",
5023            "git",
5024            "Template Library/Library",
5025            None,
5026            true,
5027        );
5028        let findings = template_extends_unpinned_branch(&g);
5029        assert_eq!(findings.len(), 1);
5030        assert_eq!(
5031            findings[0].category,
5032            FindingCategory::TemplateExtendsUnpinnedBranch
5033        );
5034        assert_eq!(findings[0].severity, Severity::High);
5035        assert!(findings[0].message.contains("default branch"));
5036    }
5037
5038    #[test]
5039    fn template_extends_unpinned_branch_fires_on_refs_heads_main() {
5040        let g = graph_with_repo(
5041            "templates",
5042            "git",
5043            "org/templates",
5044            Some("refs/heads/main"),
5045            true,
5046        );
5047        let findings = template_extends_unpinned_branch(&g);
5048        assert_eq!(findings.len(), 1);
5049        assert!(findings[0].message.contains("mutable branch 'main'"));
5050    }
5051
5052    #[test]
5053    fn template_extends_unpinned_branch_skips_tag_pinned() {
5054        let g = graph_with_repo(
5055            "templates",
5056            "github",
5057            "org/templates",
5058            Some("refs/tags/v1.0.0"),
5059            true,
5060        );
5061        let findings = template_extends_unpinned_branch(&g);
5062        assert!(
5063            findings.is_empty(),
5064            "refs/tags/v1.0.0 must be treated as pinned"
5065        );
5066    }
5067
5068    #[test]
5069    fn template_extends_unpinned_branch_skips_sha_pinned() {
5070        let sha = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
5071        assert_eq!(sha.len(), 40);
5072        let g = graph_with_repo("templates", "git", "org/templates", Some(sha), true);
5073        let findings = template_extends_unpinned_branch(&g);
5074        assert!(
5075            findings.is_empty(),
5076            "40-char hex SHA must be treated as pinned"
5077        );
5078    }
5079
5080    #[test]
5081    fn template_extends_unpinned_branch_skips_unreferenced_repo_with_no_ref() {
5082        // Spec edge: "repo declared but not referenced anywhere → does not fire
5083        // (no consumer = no risk)". Applies when the declaration carries no
5084        // explicit `ref:` field — the entry is purely vestigial in that case.
5085        let g = graph_with_repo(
5086            "templates",
5087            "git",
5088            "org/templates",
5089            None,  // no explicit ref
5090            false, // and no consumer
5091        );
5092        let findings = template_extends_unpinned_branch(&g);
5093        assert!(
5094            findings.is_empty(),
5095            "repo declared with no ref and no consumer must not fire"
5096        );
5097    }
5098
5099    #[test]
5100    fn template_extends_unpinned_branch_fires_on_explicit_branch_even_without_in_file_consumer() {
5101        // An explicit `ref: refs/heads/<branch>` signals intent to consume —
5102        // the consumer is typically inside an included template file outside
5103        // the per-file scan boundary (mirrors the msigeurope corpus shape).
5104        let g = graph_with_repo(
5105            "adf_publish",
5106            "git",
5107            "org/finance-reporting",
5108            Some("refs/heads/adf_publish"),
5109            false, // no in-file consumer
5110        );
5111        let findings = template_extends_unpinned_branch(&g);
5112        assert_eq!(findings.len(), 1);
5113        assert!(findings[0].message.contains("mutable branch 'adf_publish'"));
5114    }
5115
5116    #[test]
5117    fn template_extends_unpinned_branch_skips_when_metadata_absent() {
5118        let g = AuthorityGraph::new(source("ci.yml"));
5119        assert!(template_extends_unpinned_branch(&g).is_empty());
5120    }
5121
5122    #[test]
5123    fn template_extends_unpinned_branch_handles_bare_branch_name() {
5124        // `ref: main` (no `refs/heads/` prefix) is a valid ADO shorthand for a branch.
5125        let g = graph_with_repo(
5126            "template-library",
5127            "git",
5128            "Template Library/Library",
5129            Some("main"),
5130            true,
5131        );
5132        let findings = template_extends_unpinned_branch(&g);
5133        assert_eq!(findings.len(), 1);
5134        assert!(findings[0].message.contains("mutable branch 'main'"));
5135    }
5136
5137    // ── template_repo_ref_is_feature_branch ───────────────────
5138
5139    #[test]
5140    fn template_repo_ref_is_feature_branch_fires_on_bare_feature_branch() {
5141        // Mirrors the corpus shape: `ref: feature/maps-network` (no
5142        // `refs/heads/` prefix) on the Template Library checkout.
5143        let g = graph_with_repo(
5144            "templateLibRepo",
5145            "git",
5146            "Template Library/Template Library",
5147            Some("feature/maps-network"),
5148            true,
5149        );
5150        let findings = template_repo_ref_is_feature_branch(&g);
5151        assert_eq!(findings.len(), 1);
5152        assert_eq!(
5153            findings[0].category,
5154            FindingCategory::TemplateRepoRefIsFeatureBranch
5155        );
5156        assert_eq!(findings[0].severity, Severity::High);
5157        assert!(findings[0].message.contains("feature/maps-network"));
5158        assert!(findings[0].message.contains("feature-class"));
5159    }
5160
5161    #[test]
5162    fn template_repo_ref_is_feature_branch_fires_on_refs_heads_feature() {
5163        // Same attack via the fully-qualified `refs/heads/feature/...` form.
5164        let g = graph_with_repo(
5165            "templates",
5166            "git",
5167            "org/templates",
5168            Some("refs/heads/feature/wip"),
5169            true,
5170        );
5171        let findings = template_repo_ref_is_feature_branch(&g);
5172        assert_eq!(findings.len(), 1);
5173        assert!(findings[0].message.contains("feature/wip"));
5174    }
5175
5176    #[test]
5177    fn template_repo_ref_is_feature_branch_fires_on_develop_branch() {
5178        // `develop` is not in the trunk set — it's a feature-class branch.
5179        let g = graph_with_repo(
5180            "templates",
5181            "git",
5182            "org/templates",
5183            Some("refs/heads/develop"),
5184            true,
5185        );
5186        let findings = template_repo_ref_is_feature_branch(&g);
5187        assert_eq!(findings.len(), 1);
5188    }
5189
5190    #[test]
5191    fn template_repo_ref_is_feature_branch_skips_main_branch() {
5192        // `template_extends_unpinned_branch` still fires on this — but the
5193        // feature-branch refinement does not, because main is the trunk.
5194        let g = graph_with_repo(
5195            "templates",
5196            "git",
5197            "org/templates",
5198            Some("refs/heads/main"),
5199            true,
5200        );
5201        assert!(template_repo_ref_is_feature_branch(&g).is_empty());
5202        // Sanity: the parent rule still fires on the same input.
5203        assert_eq!(template_extends_unpinned_branch(&g).len(), 1);
5204    }
5205
5206    #[test]
5207    fn template_repo_ref_is_feature_branch_skips_master_release_hotfix() {
5208        for ref_value in [
5209            "master",
5210            "refs/heads/master",
5211            "release/v1.4",
5212            "refs/heads/release/2026-q2",
5213            "releases/2026-04",
5214            "hotfix/CVE-2026-0001",
5215            "refs/heads/hotfix/CVE-2026-0002",
5216        ] {
5217            let g = graph_with_repo("t", "git", "org/t", Some(ref_value), true);
5218            assert!(
5219                template_repo_ref_is_feature_branch(&g).is_empty(),
5220                "ref {ref_value:?} must not fire as feature-class"
5221            );
5222        }
5223    }
5224
5225    #[test]
5226    fn template_repo_ref_is_feature_branch_skips_pinned_refs() {
5227        // SHA, tag, and refs/heads/<sha> are all pinned — the feature-branch
5228        // rule must not fire on any of them, regardless of the alias name.
5229        let sha = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
5230        for ref_value in [
5231            sha.to_string(),
5232            "refs/tags/v1.4.2".to_string(),
5233            format!("refs/heads/{sha}"),
5234        ] {
5235            let g = graph_with_repo("templates", "git", "org/t", Some(&ref_value), true);
5236            assert!(
5237                template_repo_ref_is_feature_branch(&g).is_empty(),
5238                "pinned ref {ref_value:?} must not fire"
5239            );
5240        }
5241    }
5242
5243    #[test]
5244    fn template_repo_ref_is_feature_branch_skips_when_ref_absent() {
5245        // The "no ref:" (default-branch) case is left to
5246        // `template_extends_unpinned_branch`. The feature-branch rule only
5247        // fires on explicit feature-class refs.
5248        let g = graph_with_repo("templates", "git", "org/templates", None, true);
5249        assert!(template_repo_ref_is_feature_branch(&g).is_empty());
5250    }
5251
5252    #[test]
5253    fn template_repo_ref_is_feature_branch_cofires_with_parent_rule() {
5254        // Both rules should fire together on the corpus shape — the parent
5255        // says "not pinned", the refinement says "and it's a feature branch".
5256        let g = graph_with_repo(
5257            "templateLibRepo",
5258            "git",
5259            "Template Library/Template Library",
5260            Some("feature/maps-network"),
5261            true,
5262        );
5263        let parent = template_extends_unpinned_branch(&g);
5264        let refinement = template_repo_ref_is_feature_branch(&g);
5265        assert_eq!(parent.len(), 1, "parent rule must still fire");
5266        assert_eq!(refinement.len(), 1, "refinement must fire alongside");
5267        assert_ne!(parent[0].category, refinement[0].category);
5268    }
5269
5270    #[test]
5271    fn is_feature_class_branch_classification() {
5272        // Trunk-class — must return false.
5273        for b in [
5274            "main",
5275            "MAIN",
5276            "master",
5277            "refs/heads/main",
5278            "release/v1",
5279            "release/",
5280            "release",
5281            "releases/2026",
5282            "hotfix/x",
5283            "hotfix",
5284            "hotfixes/y",
5285            "  refs/heads/main  ",
5286        ] {
5287            assert!(!is_feature_class_branch(b), "{b:?} must be trunk");
5288        }
5289        // Feature-class — must return true.
5290        for b in [
5291            "feature/foo",
5292            "topic/bar",
5293            "dev/wip",
5294            "wip/x",
5295            "develop",
5296            "users/alice/spike",
5297            "personal-branch",
5298            "refs/heads/feature/x",
5299            "main-staging", // not exact main, prefix-only — feature-class
5300        ] {
5301            assert!(is_feature_class_branch(b), "{b:?} must be feature-class");
5302        }
5303        // Empty / whitespace.
5304        assert!(!is_feature_class_branch(""));
5305        assert!(!is_feature_class_branch("   "));
5306    }
5307
5308    #[test]
5309    fn template_extends_unpinned_branch_skips_refs_heads_with_sha() {
5310        // ADO accepts `ref: refs/heads/<sha>` to lock onto a commit on a branch.
5311        // The trailing segment is what determines mutability.
5312        let sha = "0123456789abcdef0123456789abcdef01234567";
5313        let g = graph_with_repo(
5314            "templates",
5315            "git",
5316            "org/templates",
5317            Some(&format!("refs/heads/{sha}")),
5318            true,
5319        );
5320        let findings = template_extends_unpinned_branch(&g);
5321        assert!(findings.is_empty());
5322    }
5323
5324    // ── vm_remote_exec_via_pipeline_secret ──────────────
5325
5326    #[test]
5327    fn vm_remote_exec_fires_on_set_azvmextension_with_minted_sas() {
5328        let body = r#"
5329            $sastokenpackages = New-AzStorageContainerSASToken -Container $packagecontainer -Context $ctx -Permission r -ExpiryTime (Get-Date).AddHours(3)
5330            Set-AzVMExtension -ResourceGroupName $vmRG -VMName $vm.name -Name 'customScript' `
5331                -Publisher 'Microsoft.Compute' -ExtensionType 'CustomScriptExtension' `
5332                -Settings @{ "commandToExecute" = "powershell -File install.ps1 -saskey `"$sastokenpackages`"" }
5333        "#;
5334        let g = graph_with_script_step(body, None);
5335        let findings = vm_remote_exec_via_pipeline_secret(&g);
5336        assert_eq!(findings.len(), 1, "should fire once");
5337        assert_eq!(
5338            findings[0].category,
5339            FindingCategory::VmRemoteExecViaPipelineSecret
5340        );
5341        assert_eq!(findings[0].severity, Severity::High);
5342    }
5343
5344    #[test]
5345    fn vm_remote_exec_fires_on_invoke_azvmruncommand_with_pipeline_secret() {
5346        let body = r#"
5347            Invoke-AzVMRunCommand -ResourceGroupName rg -VMName vm `
5348                -CommandId RunPowerShellScript -ScriptString "Add-LocalGroupMember -Member admin -Password $(DOMAIN_JOIN_PASSWORD)"
5349        "#;
5350        let g = graph_with_script_step(body, Some("DOMAIN_JOIN_PASSWORD"));
5351        let findings = vm_remote_exec_via_pipeline_secret(&g);
5352        assert_eq!(findings.len(), 1);
5353        assert!(findings[0]
5354            .message
5355            .contains("interpolating a pipeline secret"));
5356    }
5357
5358    #[test]
5359    fn vm_remote_exec_does_not_fire_without_remote_exec_call() {
5360        // Has a SAS mint, but no VM remote-exec primitive — should not fire.
5361        let body = r#"
5362            $sas = New-AzStorageContainerSASToken -Container c -Context $ctx -Permission r -ExpiryTime (Get-Date).AddHours(1)
5363            Write-Host "sas length is $($sas.Length)"
5364        "#;
5365        let g = graph_with_script_step(body, None);
5366        let findings = vm_remote_exec_via_pipeline_secret(&g);
5367        assert!(findings.is_empty());
5368    }
5369
5370    #[test]
5371    fn vm_remote_exec_does_not_fire_when_remote_exec_has_no_secret_or_sas() {
5372        // Set-AzVMExtension with a static command line, no SAS, no secret —
5373        // should not fire (no exposed credential).
5374        let body = r#"
5375            Set-AzVMExtension -ResourceGroupName rg -VMName vm -Name diag `
5376                -Publisher Microsoft.Azure.Diagnostics -ExtensionType IaaSDiagnostics `
5377                -Settings @{ "xmlCfg" = "<wadcfg/>" }
5378        "#;
5379        let g = graph_with_script_step(body, None);
5380        let findings = vm_remote_exec_via_pipeline_secret(&g);
5381        assert!(
5382            findings.is_empty(),
5383            "no SAS-mint and no secret interpolation → no finding"
5384        );
5385    }
5386
5387    #[test]
5388    fn vm_remote_exec_fires_on_az_cli_run_command() {
5389        let body = r#"
5390            az vm run-command invoke --resource-group rg --name vm `
5391                --command-id RunShellScript --scripts "echo $(DB_PASSWORD) > /tmp/x"
5392        "#;
5393        let g = graph_with_script_step(body, Some("DB_PASSWORD"));
5394        let findings = vm_remote_exec_via_pipeline_secret(&g);
5395        assert_eq!(findings.len(), 1);
5396        assert!(findings[0].message.contains("az vm run-command"));
5397    }
5398
5399    // ── short_lived_sas_in_command_line ─────────────────
5400
5401    #[test]
5402    fn sas_in_cmdline_fires_on_minted_sas_interpolated_into_command_to_execute() {
5403        let body = r#"
5404            $sastokenpackages = New-AzStorageContainerSASToken -Container c -Context $ctx -Permission r -ExpiryTime (Get-Date).AddHours(3)
5405            $settings = @{ "commandToExecute" = "powershell install.ps1 -sas `"$sastokenpackages`"" }
5406        "#;
5407        let g = graph_with_script_step(body, None);
5408        let findings = short_lived_sas_in_command_line(&g);
5409        assert_eq!(findings.len(), 1);
5410        assert_eq!(
5411            findings[0].category,
5412            FindingCategory::ShortLivedSasInCommandLine
5413        );
5414        assert_eq!(findings[0].severity, Severity::Medium);
5415        assert!(findings[0].message.contains("sastokenpackages"));
5416    }
5417
5418    #[test]
5419    fn sas_in_cmdline_does_not_fire_when_sas_is_only_uploaded_to_blob() {
5420        // SAS minted but never put on argv — only used to build a URL.
5421        let body = r#"
5422            $sas = New-AzStorageContainerSASToken -Container c -Context $ctx -Permission r -ExpiryTime (Get-Date).AddHours(1)
5423            $url = "https://acct.blob.core.windows.net/c/?" + $sas
5424            Invoke-WebRequest -Uri $url -OutFile foo.zip
5425        "#;
5426        let g = graph_with_script_step(body, None);
5427        let findings = short_lived_sas_in_command_line(&g);
5428        assert!(findings.is_empty(), "no command-line sink → no finding");
5429    }
5430
5431    #[test]
5432    fn sas_in_cmdline_does_not_fire_without_sas_mint() {
5433        let body = r#"
5434            $settings = @{ "commandToExecute" = "powershell -File foo.ps1" }
5435        "#;
5436        let g = graph_with_script_step(body, None);
5437        let findings = short_lived_sas_in_command_line(&g);
5438        assert!(findings.is_empty());
5439    }
5440
5441    #[test]
5442    fn sas_in_cmdline_fires_on_az_cli_generate_sas_with_arguments() {
5443        let body = r#"
5444            sas=$(az storage container generate-sas --name c --account-name acct --permissions r --expiry 2099-01-01 -o tsv)
5445            az vm extension set --vm-name vm --resource-group rg --name CustomScript --publisher Microsoft.Compute \
5446                --settings "{ \"commandToExecute\": \"curl https://acct.blob.core.windows.net/c/foo?$sas\" }"
5447        "#;
5448        let g = graph_with_script_step(body, None);
5449        let findings = short_lived_sas_in_command_line(&g);
5450        // mint + sink in same script → fires (fallback evidence path).
5451        assert_eq!(findings.len(), 1);
5452    }
5453
5454    #[test]
5455    fn co_fire_on_solarwinds_pattern() {
5456        // Mirrors the corpus solarwinds shape: SAS minted, embedded in
5457        // CustomScriptExtension commandToExecute. Both rules must fire.
5458        let body = r#"
5459            $sastokenpackages = New-AzStorageContainerSASToken -Container $pc -Context $ctx -Permission r -ExpiryTime (Get-Date).AddHours(3)
5460            Set-AzVMExtension -ResourceGroupName $rg -VMName $vm `
5461                -Publisher 'Microsoft.Compute' -ExtensionType 'CustomScriptExtension' `
5462                -Settings @{ "commandToExecute" = "powershell -File install.ps1 -sas `"$sastokenpackages`"" }
5463        "#;
5464        let g = graph_with_script_step(body, None);
5465        let r6 = vm_remote_exec_via_pipeline_secret(&g);
5466        let r7 = short_lived_sas_in_command_line(&g);
5467        assert_eq!(r6.len(), 1, "rule 6 must fire on solarwinds shape");
5468        assert_eq!(r7.len(), 1, "rule 7 must fire on solarwinds shape");
5469    }
5470
5471    #[test]
5472    fn body_interpolates_var_does_not_match_prefix() {
5473        // `$sas` should not match `$sastokenpackages`.
5474        assert!(!body_interpolates_var(
5475            "Write-Host $sastokenpackages",
5476            "sas"
5477        ));
5478        assert!(body_interpolates_var(
5479            "Write-Host $sastokenpackages",
5480            "sastokenpackages"
5481        ));
5482        assert!(body_interpolates_var("echo $(SECRET)", "SECRET"));
5483    }
5484
5485    #[test]
5486    fn powershell_sas_assignments_extracts_var_names() {
5487        let body = r#"
5488            $a = New-AzStorageContainerSASToken -Container c -Context $ctx -Permission r
5489            $b = Get-Date
5490            $sasBlob = New-AzStorageBlobSASToken -Container c -Blob foo -Context $ctx -Permission r
5491        "#;
5492        let names = powershell_sas_assignments(body);
5493        assert!(names.iter().any(|n| n.eq_ignore_ascii_case("a")));
5494        assert!(names.iter().any(|n| n.eq_ignore_ascii_case("sasBlob")));
5495        assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("b")));
5496    }
5497
5498    #[test]
5499    fn bash_export_of_pipeline_secret_flagged() {
5500        let g = build_step_with_script(
5501            "TF_TOKEN",
5502            "echo init\nexport TF_TOKEN_app_terraform_io=\"$(TF_TOKEN)\"\nterraform init",
5503        );
5504        let findings = secret_to_inline_script_env_export(&g);
5505        assert_eq!(findings.len(), 1);
5506        assert_eq!(findings[0].severity, Severity::High);
5507        assert!(findings[0].message.contains("$(TF_TOKEN)"));
5508    }
5509
5510    #[test]
5511    fn powershell_assignment_of_pipeline_secret_flagged() {
5512        let g = build_step_with_script(
5513            "AppContainerDBPassword",
5514            "$AppContainerDBPassword = \"$(AppContainerDBPassword)\"\n$x = 1",
5515        );
5516        let findings = secret_to_inline_script_env_export(&g);
5517        assert_eq!(findings.len(), 1);
5518        assert!(findings[0].message.contains("$(AppContainerDBPassword)"));
5519    }
5520
5521    #[test]
5522    fn secret_passed_as_command_argument_not_flagged() {
5523        // Secret used as a CLI argument, not assigned to a variable. This is
5524        // covered by the separate META_CLI_FLAG_EXPOSED detection — env_export
5525        // should NOT also fire here.
5526        let g = build_step_with_script("TF_TOKEN", "terraform plan -var \"token=$(TF_TOKEN)\"");
5527        let findings = secret_to_inline_script_env_export(&g);
5528        assert!(
5529            findings.is_empty(),
5530            "command-arg use of $(SECRET) must not trip env-export rule"
5531        );
5532    }
5533
5534    #[test]
5535    fn step_without_script_body_not_flagged() {
5536        let mut g = AuthorityGraph::new(source("ado.yml"));
5537        let secret = g.add_node(NodeKind::Secret, "TF_TOKEN", TrustZone::FirstParty);
5538        let step = g.add_node(NodeKind::Step, "task", TrustZone::FirstParty);
5539        g.add_edge(step, secret, EdgeKind::HasAccessTo);
5540        let findings = secret_to_inline_script_env_export(&g);
5541        assert!(findings.is_empty());
5542    }
5543
5544    // ── secret_materialised_to_workspace_file ────────────────
5545
5546    #[test]
5547    fn powershell_outfile_of_secret_to_workspace_flagged() {
5548        // Mirrors Azure_Landing_Zone/userapp-n8nx pattern: secret bound to
5549        // $var, then $var written via Out-File to $(System.DefaultWorkingDirectory).
5550        let script = "$AppContainerDBPassword = \"$(AppContainerDBPassword)\"\n\
5551                      $TFfile = Get-Content $(System.DefaultWorkingDirectory)/in.tfvars\n\
5552                      $TFfile = $TFfile.Replace(\"x\", $AppContainerDBPassword)\n\
5553                      $TFfile | Out-File $(System.DefaultWorkingDirectory)/envVars/tffile.tfvars";
5554        let g = build_step_with_script("AppContainerDBPassword", script);
5555        let findings = secret_materialised_to_workspace_file(&g);
5556        assert_eq!(
5557            findings.len(),
5558            1,
5559            "Out-File of bound secret to workspace must fire"
5560        );
5561        assert_eq!(findings[0].severity, Severity::High);
5562    }
5563
5564    #[test]
5565    fn bash_redirect_of_secret_to_tfvars_flagged() {
5566        let script =
5567            "echo \"token = \\\"$(TF_TOKEN)\\\"\" > $(Build.SourcesDirectory)/secrets.tfvars";
5568        let g = build_step_with_script("TF_TOKEN", script);
5569        let findings = secret_materialised_to_workspace_file(&g);
5570        assert_eq!(findings.len(), 1);
5571    }
5572
5573    #[test]
5574    fn echoing_secret_to_stdout_not_flagged_by_materialisation_rule() {
5575        let g = build_step_with_script("TF_TOKEN", "echo using $(TF_TOKEN)\nterraform init");
5576        let findings = secret_materialised_to_workspace_file(&g);
5577        assert!(
5578            findings.is_empty(),
5579            "stdout echo (no file sink) must not trip materialisation rule"
5580        );
5581    }
5582
5583    #[test]
5584    fn write_to_unrelated_path_not_flagged() {
5585        // No workspace-path keyword, no risky extension — should not fire.
5586        let script = "echo $(MY_SECRET) > /var/tmp/ignore.log";
5587        let g = build_step_with_script("MY_SECRET", script);
5588        let findings = secret_materialised_to_workspace_file(&g);
5589        assert!(findings.is_empty());
5590    }
5591
5592    // ── keyvault_secret_to_plaintext ─────────────────────────
5593
5594    #[test]
5595    fn keyvault_asplaintext_flagged() {
5596        let script = "$pass = Get-AzKeyVaultSecret -VaultName foo -Name bar -AsPlainText\n\
5597                      Write-Host done";
5598        let g = build_step_with_script("UNUSED", script);
5599        let findings = keyvault_secret_to_plaintext(&g);
5600        assert_eq!(findings.len(), 1);
5601        assert_eq!(findings[0].severity, Severity::Medium);
5602    }
5603
5604    #[test]
5605    fn keyvault_secretvaluetext_legacy_pattern_flagged() {
5606        let script = "$pwd = (Get-AzKeyVaultSecret -VaultName foo -Name bar).SecretValueText";
5607        let g = build_step_with_script("UNUSED", script);
5608        let findings = keyvault_secret_to_plaintext(&g);
5609        assert_eq!(findings.len(), 1);
5610    }
5611
5612    #[test]
5613    fn convertfrom_securestring_asplaintext_flagged() {
5614        let script = "$plain = ConvertFrom-SecureString $sec -AsPlainText";
5615        let g = build_step_with_script("UNUSED", script);
5616        let findings = keyvault_secret_to_plaintext(&g);
5617        assert_eq!(findings.len(), 1);
5618    }
5619
5620    #[test]
5621    fn keyvault_securestring_handling_not_flagged() {
5622        // Using the secret as SecureString (no -AsPlainText) is the safe pattern.
5623        let script = "$sec = Get-AzKeyVaultSecret -VaultName foo -Name bar\n\
5624                      $cred = New-Object PSCredential 'svc', $sec.SecretValue";
5625        let g = build_step_with_script("UNUSED", script);
5626        let findings = keyvault_secret_to_plaintext(&g);
5627        assert!(
5628            findings.is_empty(),
5629            "SecureString-only handling is the recommended pattern and must not fire"
5630        );
5631    }
5632
5633    // ── terraform_auto_approve_in_prod ──────────────────────
5634
5635    fn step_with_meta(g: &mut AuthorityGraph, name: &str, meta: &[(&str, &str)]) -> NodeId {
5636        let mut m = std::collections::HashMap::new();
5637        for (k, v) in meta {
5638            m.insert((*k).to_string(), (*v).to_string());
5639        }
5640        g.add_node_with_metadata(NodeKind::Step, name, TrustZone::FirstParty, m)
5641    }
5642
5643    #[test]
5644    fn terraform_auto_approve_against_prod_connection_fires() {
5645        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5646        step_with_meta(
5647            &mut g,
5648            "Terraform : Apply",
5649            &[
5650                (META_TERRAFORM_AUTO_APPROVE, "true"),
5651                (META_SERVICE_CONNECTION_NAME, "sharedservice-w365-prod-sc"),
5652            ],
5653        );
5654
5655        let findings = terraform_auto_approve_in_prod(&g);
5656        assert_eq!(findings.len(), 1);
5657        assert_eq!(findings[0].severity, Severity::Critical);
5658        assert_eq!(
5659            findings[0].category,
5660            FindingCategory::TerraformAutoApproveInProd
5661        );
5662        assert!(
5663            findings[0].message.contains("sharedservice-w365-prod-sc"),
5664            "message should name the connection, got: {}",
5665            findings[0].message
5666        );
5667    }
5668
5669    #[test]
5670    fn terraform_auto_approve_via_edge_to_service_connection_identity() {
5671        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5672        let step = step_with_meta(
5673            &mut g,
5674            "Terraform : Apply",
5675            &[(META_TERRAFORM_AUTO_APPROVE, "true")],
5676        );
5677        let mut id_meta = std::collections::HashMap::new();
5678        id_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
5679        let conn = g.add_node_with_metadata(
5680            NodeKind::Identity,
5681            "alz-infra-sc-prd-uks",
5682            TrustZone::FirstParty,
5683            id_meta,
5684        );
5685        g.add_edge(step, conn, EdgeKind::HasAccessTo);
5686
5687        let findings = terraform_auto_approve_in_prod(&g);
5688        assert_eq!(findings.len(), 1);
5689        assert!(findings[0].message.contains("alz-infra-sc-prd-uks"));
5690    }
5691
5692    #[test]
5693    fn terraform_auto_approve_with_env_gate_downgrades_to_medium() {
5694        // Per blue-team CC-4: env gate is a partial control (the gate's
5695        // approver list is invisible from YAML), so the finding stays
5696        // visible at Medium rather than disappearing entirely.
5697        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5698        step_with_meta(
5699            &mut g,
5700            "Terraform : Apply",
5701            &[
5702                (META_TERRAFORM_AUTO_APPROVE, "true"),
5703                (META_SERVICE_CONNECTION_NAME, "platform-prod-sc"),
5704                (META_ENV_APPROVAL, "true"),
5705            ],
5706        );
5707
5708        let findings = terraform_auto_approve_in_prod(&g);
5709        assert_eq!(
5710            findings.len(),
5711            1,
5712            "env-gated apply must still emit a finding"
5713        );
5714        assert_eq!(
5715            findings[0].severity,
5716            Severity::Medium,
5717            "env-gated apply downgrades Critical → Medium (compensating control credit)"
5718        );
5719        assert!(findings[0]
5720            .message
5721            .contains("`environment:` binding present"));
5722    }
5723
5724    #[test]
5725    fn terraform_auto_approve_against_non_prod_does_not_fire() {
5726        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5727        step_with_meta(
5728            &mut g,
5729            "Terraform : Apply",
5730            &[
5731                (META_TERRAFORM_AUTO_APPROVE, "true"),
5732                (META_SERVICE_CONNECTION_NAME, "platform-dev-sc"),
5733            ],
5734        );
5735
5736        let findings = terraform_auto_approve_in_prod(&g);
5737        assert!(findings.is_empty(), "dev connection must not match prod");
5738    }
5739
5740    #[test]
5741    fn terraform_apply_without_auto_approve_does_not_fire() {
5742        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5743        step_with_meta(
5744            &mut g,
5745            "Terraform : Apply",
5746            &[(META_SERVICE_CONNECTION_NAME, "platform-prod-sc")],
5747        );
5748
5749        let findings = terraform_auto_approve_in_prod(&g);
5750        assert!(findings.is_empty());
5751    }
5752
5753    #[test]
5754    fn looks_like_prod_connection_matches_real_world_names() {
5755        assert!(looks_like_prod_connection("sharedservice-w365-prod-sc"));
5756        assert!(looks_like_prod_connection("alz-infra-sc-prd"));
5757        assert!(looks_like_prod_connection("prod-tenant-arm"));
5758        assert!(looks_like_prod_connection("PROD"));
5759        assert!(looks_like_prod_connection("my_prod_arm"));
5760        // Negatives — substrings inside other words must not match
5761        assert!(!looks_like_prod_connection("approver-sc"));
5762        assert!(!looks_like_prod_connection("reproducer-sc"));
5763        assert!(!looks_like_prod_connection("dev-sc"));
5764        assert!(!looks_like_prod_connection("staging"));
5765    }
5766
5767    // ── addspn_with_inline_script ───────────────────────────
5768
5769    #[test]
5770    fn addspn_with_inline_script_fires_with_basic_body() {
5771        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5772        step_with_meta(
5773            &mut g,
5774            "ado : azure : login (federated)",
5775            &[
5776                (META_ADD_SPN_TO_ENV, "true"),
5777                (META_SCRIPT_BODY, "az account show --query id -o tsv"),
5778            ],
5779        );
5780
5781        let findings = addspn_with_inline_script(&g);
5782        assert_eq!(findings.len(), 1);
5783        assert_eq!(findings[0].severity, Severity::High);
5784        assert!(!findings[0]
5785            .message
5786            .contains("explicit token laundering detected"));
5787    }
5788
5789    #[test]
5790    fn addspn_with_inline_script_escalates_message_on_token_laundering() {
5791        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5792        step_with_meta(
5793            &mut g,
5794            "ado : azure : login (federated)",
5795            &[
5796                (META_ADD_SPN_TO_ENV, "true"),
5797                (
5798                    META_SCRIPT_BODY,
5799                    "Write-Output \"##vso[task.setvariable variable=ARM_OIDC_TOKEN]$env:idToken\"",
5800                ),
5801            ],
5802        );
5803
5804        let findings = addspn_with_inline_script(&g);
5805        assert_eq!(findings.len(), 1);
5806        assert!(
5807            findings[0]
5808                .message
5809                .contains("explicit token laundering detected"),
5810            "message should escalate, got: {}",
5811            findings[0].message
5812        );
5813    }
5814
5815    #[test]
5816    fn addspn_without_inline_script_does_not_fire() {
5817        // No META_SCRIPT_BODY → scriptPath form, not inline
5818        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5819        step_with_meta(
5820            &mut g,
5821            "AzureCLI scriptPath",
5822            &[(META_ADD_SPN_TO_ENV, "true")],
5823        );
5824
5825        let findings = addspn_with_inline_script(&g);
5826        assert!(findings.is_empty());
5827    }
5828
5829    #[test]
5830    fn inline_script_without_addspn_does_not_fire() {
5831        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5832        step_with_meta(
5833            &mut g,
5834            "az account show",
5835            &[(META_SCRIPT_BODY, "az account show")],
5836        );
5837
5838        let findings = addspn_with_inline_script(&g);
5839        assert!(findings.is_empty());
5840    }
5841
5842    #[test]
5843    fn script_launders_spn_token_recognises_known_markers() {
5844        assert!(script_launders_spn_token(
5845            "Write-Output \"##vso[task.setvariable variable=ARM_OIDC_TOKEN]$env:idToken\""
5846        ));
5847        assert!(script_launders_spn_token(
5848            "echo \"##vso[task.setvariable variable=X]$env:servicePrincipalKey\""
5849        ));
5850        // setvariable without token material → not laundering, just env mutation
5851        assert!(!script_launders_spn_token(
5852            "echo \"##vso[task.setvariable variable=X]hello\""
5853        ));
5854        // No setvariable at all
5855        assert!(!script_launders_spn_token("$env:idToken"));
5856    }
5857
5858    // ── parameter_interpolation_into_shell ──────────────────
5859
5860    fn graph_with_param(spec: ParamSpec, name: &str) -> AuthorityGraph {
5861        let mut g = AuthorityGraph::new(source("azure-pipelines.yml"));
5862        g.parameters.insert(name.to_string(), spec);
5863        g
5864    }
5865
5866    #[test]
5867    fn parameter_interpolation_fires_on_free_form_string_in_inline_script() {
5868        let mut g = graph_with_param(
5869            ParamSpec {
5870                param_type: "string".into(),
5871                has_values_allowlist: false,
5872            },
5873            "appName",
5874        );
5875        step_with_meta(
5876            &mut g,
5877            "terraform workspace",
5878            &[(
5879                META_SCRIPT_BODY,
5880                "terraform workspace select -or-create ${{ parameters.appName }}",
5881            )],
5882        );
5883
5884        let findings = parameter_interpolation_into_shell(&g);
5885        assert_eq!(findings.len(), 1);
5886        assert_eq!(findings[0].severity, Severity::Medium);
5887        assert!(findings[0].message.contains("appName"));
5888    }
5889
5890    #[test]
5891    fn parameter_interpolation_with_values_allowlist_does_not_fire() {
5892        let mut g = graph_with_param(
5893            ParamSpec {
5894                param_type: "string".into(),
5895                has_values_allowlist: true,
5896            },
5897            "location",
5898        );
5899        step_with_meta(
5900            &mut g,
5901            "Terraform Plan",
5902            &[(
5903                META_SCRIPT_BODY,
5904                "terraform plan -var=\"location=${{ parameters.location }}\"",
5905            )],
5906        );
5907
5908        let findings = parameter_interpolation_into_shell(&g);
5909        assert!(
5910            findings.is_empty(),
5911            "values: allowlist must suppress the finding"
5912        );
5913    }
5914
5915    #[test]
5916    fn parameter_interpolation_default_type_is_treated_as_string() {
5917        let mut g = graph_with_param(
5918            ParamSpec {
5919                // ADO defaults missing `type:` to string — same risk
5920                param_type: "".into(),
5921                has_values_allowlist: false,
5922            },
5923            "appName",
5924        );
5925        step_with_meta(
5926            &mut g,
5927            "Terraform : Plan",
5928            &[(
5929                META_SCRIPT_BODY,
5930                "terraform plan -var \"appName=${{ parameters.appName }}\"",
5931            )],
5932        );
5933
5934        let findings = parameter_interpolation_into_shell(&g);
5935        assert_eq!(findings.len(), 1, "missing type: must default to string");
5936    }
5937
5938    #[test]
5939    fn parameter_interpolation_skips_non_string_params() {
5940        let mut g = graph_with_param(
5941            ParamSpec {
5942                param_type: "boolean".into(),
5943                has_values_allowlist: false,
5944            },
5945            "enabled",
5946        );
5947        step_with_meta(
5948            &mut g,
5949            "step",
5950            &[(META_SCRIPT_BODY, "echo ${{ parameters.enabled }}")],
5951        );
5952
5953        let findings = parameter_interpolation_into_shell(&g);
5954        assert!(findings.is_empty(), "boolean params can't carry shell");
5955    }
5956
5957    #[test]
5958    fn parameter_interpolation_no_spaces_form_also_matches() {
5959        let mut g = graph_with_param(
5960            ParamSpec {
5961                param_type: "string".into(),
5962                has_values_allowlist: false,
5963            },
5964            "x",
5965        );
5966        step_with_meta(
5967            &mut g,
5968            "step",
5969            &[(META_SCRIPT_BODY, "echo ${{parameters.x}}")],
5970        );
5971
5972        let findings = parameter_interpolation_into_shell(&g);
5973        assert_eq!(findings.len(), 1);
5974    }
5975
5976    #[test]
5977    fn parameter_interpolation_skips_step_without_script_body() {
5978        let mut g = graph_with_param(
5979            ParamSpec {
5980                param_type: "string".into(),
5981                has_values_allowlist: false,
5982            },
5983            "appName",
5984        );
5985        // Step has no META_SCRIPT_BODY (e.g. a typed task without an inline script)
5986        g.add_node(NodeKind::Step, "task-step", TrustZone::Untrusted);
5987
5988        let findings = parameter_interpolation_into_shell(&g);
5989        assert!(findings.is_empty());
5990    }
5991
5992    // ── runtime_script_fetched_from_floating_url ───────────────
5993
5994    fn step_with_body(body: &str) -> AuthorityGraph {
5995        let mut g = AuthorityGraph::new(source("ci.yml"));
5996        let id = g.add_node(NodeKind::Step, "install", TrustZone::FirstParty);
5997        if let Some(node) = g.nodes.get_mut(id) {
5998            node.metadata
5999                .insert(META_SCRIPT_BODY.into(), body.to_string());
6000        }
6001        g
6002    }
6003
6004    #[test]
6005    fn floating_curl_pipe_bash_master_is_flagged() {
6006        let g = step_with_body(
6007            "curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash",
6008        );
6009        let findings = runtime_script_fetched_from_floating_url(&g);
6010        assert_eq!(findings.len(), 1);
6011        assert_eq!(findings[0].severity, Severity::High);
6012        assert_eq!(
6013            findings[0].category,
6014            FindingCategory::RuntimeScriptFetchedFromFloatingUrl
6015        );
6016    }
6017
6018    #[test]
6019    fn floating_deno_run_main_is_flagged() {
6020        let g = step_with_body(
6021            "deno run https://raw.githubusercontent.com/denoland/deno/refs/heads/main/tools/verify_pr_title.js \"$PR_TITLE\"",
6022        );
6023        let findings = runtime_script_fetched_from_floating_url(&g);
6024        assert_eq!(findings.len(), 1);
6025    }
6026
6027    #[test]
6028    fn pinned_curl_url_with_tag_not_flagged() {
6029        let g = step_with_body(
6030            "curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/v0.33.10/scripts/install.sh | bash",
6031        );
6032        let findings = runtime_script_fetched_from_floating_url(&g);
6033        assert!(findings.is_empty(), "tag-pinned URL must not fire");
6034    }
6035
6036    #[test]
6037    fn curl_without_pipe_to_shell_not_flagged() {
6038        // `curl -O` writes to disk; the script isn't executed inline.
6039        let g = step_with_body(
6040            "curl -sSLO https://raw.githubusercontent.com/rust-lang/rust/master/src/tools/linkchecker/linkcheck.sh",
6041        );
6042        let findings = runtime_script_fetched_from_floating_url(&g);
6043        assert!(findings.is_empty(), "download-only must not fire");
6044    }
6045
6046    #[test]
6047    fn bash_process_substitution_curl_main_is_flagged() {
6048        let g = step_with_body(
6049            "bash <(curl -s https://raw.githubusercontent.com/some/repo/main/install.sh)",
6050        );
6051        let findings = runtime_script_fetched_from_floating_url(&g);
6052        assert_eq!(findings.len(), 1);
6053    }
6054
6055    // ── pr_trigger_with_floating_action_ref ────────────────────
6056
6057    fn graph_with_trigger_and_action(trigger: &str, action: &str) -> AuthorityGraph {
6058        let mut g = AuthorityGraph::new(source("pr.yml"));
6059        g.metadata.insert(META_TRIGGER.into(), trigger.into());
6060        g.add_node(NodeKind::Image, action, TrustZone::ThirdParty);
6061        g
6062    }
6063
6064    #[test]
6065    fn pull_request_target_with_floating_main_action_flagged_critical() {
6066        let g = graph_with_trigger_and_action("pull_request_target", "actions/checkout@main");
6067        let findings = pr_trigger_with_floating_action_ref(&g);
6068        assert_eq!(findings.len(), 1);
6069        assert_eq!(findings[0].severity, Severity::Critical);
6070        assert_eq!(
6071            findings[0].category,
6072            FindingCategory::PrTriggerWithFloatingActionRef
6073        );
6074    }
6075
6076    #[test]
6077    fn pull_request_target_with_sha_pinned_action_not_flagged() {
6078        let g = graph_with_trigger_and_action(
6079            "pull_request_target",
6080            "denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282",
6081        );
6082        let findings = pr_trigger_with_floating_action_ref(&g);
6083        assert!(findings.is_empty());
6084    }
6085
6086    #[test]
6087    fn issue_comment_with_floating_action_flagged() {
6088        let g = graph_with_trigger_and_action("issue_comment", "foo/bar@v1");
6089        let findings = pr_trigger_with_floating_action_ref(&g);
6090        assert_eq!(findings.len(), 1);
6091    }
6092
6093    #[test]
6094    fn pull_request_only_does_not_trigger_critical_compound_rule() {
6095        // `pull_request` (without `_target`) is the safe trigger — no base
6096        // repo write. Rule 4 must not fire on it.
6097        let g = graph_with_trigger_and_action("pull_request", "foo/bar@main");
6098        let findings = pr_trigger_with_floating_action_ref(&g);
6099        assert!(
6100            findings.is_empty(),
6101            "pull_request alone must not produce a critical compound finding"
6102        );
6103    }
6104
6105    #[test]
6106    fn comma_separated_trigger_with_pull_request_target_flagged() {
6107        let g = graph_with_trigger_and_action(
6108            "pull_request_target,push,workflow_dispatch",
6109            "foo/bar@main",
6110        );
6111        let findings = pr_trigger_with_floating_action_ref(&g);
6112        assert_eq!(findings.len(), 1);
6113    }
6114
6115    // ── untrusted_api_response_to_env_sink ─────────────────────
6116
6117    fn graph_with_trigger_and_step_body(trigger: &str, body: &str) -> AuthorityGraph {
6118        let mut g = AuthorityGraph::new(source("consumer.yml"));
6119        g.metadata.insert(META_TRIGGER.into(), trigger.into());
6120        let id = g.add_node(NodeKind::Step, "capture", TrustZone::FirstParty);
6121        if let Some(node) = g.nodes.get_mut(id) {
6122            node.metadata
6123                .insert(META_SCRIPT_BODY.into(), body.to_string());
6124        }
6125        g
6126    }
6127
6128    #[test]
6129    fn workflow_run_gh_pr_view_to_github_env_flagged() {
6130        let body = "gh pr view --repo \"$REPO\" \"$PR_BRANCH\" --json 'number' --jq '\"PR_NUMBER=\\(.number)\"' >> $GITHUB_ENV";
6131        let g = graph_with_trigger_and_step_body("workflow_run", body);
6132        let findings = untrusted_api_response_to_env_sink(&g);
6133        assert_eq!(findings.len(), 1);
6134        assert_eq!(findings[0].severity, Severity::High);
6135    }
6136
6137    #[test]
6138    fn workflow_run_without_env_sink_not_flagged() {
6139        let body = "gh pr view --repo \"$REPO\" \"$PR_BRANCH\" --json number";
6140        let g = graph_with_trigger_and_step_body("workflow_run", body);
6141        let findings = untrusted_api_response_to_env_sink(&g);
6142        assert!(findings.is_empty());
6143    }
6144
6145    #[test]
6146    fn push_trigger_writing_to_env_not_flagged() {
6147        // Trigger is not in scope (push isn't a cross-workflow trust boundary)
6148        let body = "gh pr view --json number --jq .number >> $GITHUB_ENV";
6149        let g = graph_with_trigger_and_step_body("push", body);
6150        let findings = untrusted_api_response_to_env_sink(&g);
6151        assert!(findings.is_empty());
6152    }
6153
6154    #[test]
6155    fn workflow_run_multiline_capture_then_write_flagged() {
6156        let body = "VAL=$(gh api repos/foo/bar/pulls/$PR --jq .head.ref)\necho \"BRANCH=$VAL\" >> $GITHUB_ENV";
6157        let g = graph_with_trigger_and_step_body("workflow_run", body);
6158        let findings = untrusted_api_response_to_env_sink(&g);
6159        assert_eq!(findings.len(), 1);
6160    }
6161
6162    // ── pr_build_pushes_image_with_floating_credentials ────────
6163
6164    fn graph_pr_with_login_action(trigger: &str, action: &str) -> AuthorityGraph {
6165        let mut g = AuthorityGraph::new(source("pr-build.yml"));
6166        g.metadata.insert(META_TRIGGER.into(), trigger.into());
6167        g.add_node(NodeKind::Image, action, TrustZone::ThirdParty);
6168        g
6169    }
6170
6171    #[test]
6172    fn pr_with_floating_login_to_gar_flagged() {
6173        let g = graph_pr_with_login_action(
6174            "pull_request",
6175            "grafana/shared-workflows/actions/login-to-gar@main",
6176        );
6177        let findings = pr_build_pushes_image_with_floating_credentials(&g);
6178        assert_eq!(findings.len(), 1);
6179        assert_eq!(findings[0].severity, Severity::High);
6180        assert_eq!(
6181            findings[0].category,
6182            FindingCategory::PrBuildPushesImageWithFloatingCredentials
6183        );
6184    }
6185
6186    #[test]
6187    fn pr_with_floating_docker_login_action_flagged() {
6188        let g = graph_pr_with_login_action("pull_request", "docker/login-action@v3");
6189        let findings = pr_build_pushes_image_with_floating_credentials(&g);
6190        assert_eq!(findings.len(), 1);
6191    }
6192
6193    #[test]
6194    fn pr_with_sha_pinned_docker_login_not_flagged() {
6195        let g = graph_pr_with_login_action(
6196            "pull_request",
6197            "docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d",
6198        );
6199        let findings = pr_build_pushes_image_with_floating_credentials(&g);
6200        assert!(findings.is_empty());
6201    }
6202
6203    #[test]
6204    fn push_trigger_with_floating_login_action_not_flagged() {
6205        // Outside PR context — different rule (unpinned_action) covers it.
6206        let g = graph_pr_with_login_action("push", "docker/login-action@v3");
6207        let findings = pr_build_pushes_image_with_floating_credentials(&g);
6208        assert!(findings.is_empty());
6209    }
6210
6211    #[test]
6212    fn pr_with_unrelated_unpinned_action_not_flagged() {
6213        // Rule scopes itself to registry-login actions only; generic actions
6214        // are covered by `unpinned_action` and `pr_trigger_with_floating_action_ref`.
6215        let g = graph_pr_with_login_action("pull_request", "actions/checkout@v4");
6216        let findings = pr_build_pushes_image_with_floating_credentials(&g);
6217        assert!(findings.is_empty());
6218    }
6219
6220    // ── unpinned_action severity tiering ─────────────────────────
6221
6222    #[test]
6223    fn unpinned_action_well_known_first_party_is_medium() {
6224        // `actions/checkout@v4` — owner is the GitHub-maintained `actions`
6225        // org. The supply-chain surface is real but operationally narrow,
6226        // so the rule emits Medium rather than the default High.
6227        let mut g = AuthorityGraph::new(source("ci.yml"));
6228        g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
6229
6230        let findings = unpinned_action(&g);
6231        assert_eq!(findings.len(), 1);
6232        assert_eq!(findings[0].severity, Severity::Medium);
6233        assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
6234    }
6235
6236    #[test]
6237    fn unpinned_action_same_repo_composite_is_info() {
6238        // `./.github/actions/setup` — same-repo composite action. No
6239        // external supply-chain surface, so the rule emits Info as a
6240        // hygiene-only signal rather than a security finding.
6241        let mut g = AuthorityGraph::new(source("ci.yml"));
6242        g.add_node(
6243            NodeKind::Image,
6244            "./.github/actions/setup",
6245            TrustZone::FirstParty,
6246        );
6247
6248        let findings = unpinned_action(&g);
6249        assert_eq!(findings.len(), 1);
6250        assert_eq!(findings[0].severity, Severity::Info);
6251        assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
6252    }
6253
6254    #[test]
6255    fn unpinned_action_unknown_owner_is_high() {
6256        // `random-org/foo@v1` — unknown owner, full unbounded supply-chain
6257        // surface. This is the case the rule was originally designed for
6258        // and the only severity tier that still emits at High.
6259        let mut g = AuthorityGraph::new(source("ci.yml"));
6260        g.add_node(NodeKind::Image, "random-org/foo@v1", TrustZone::Untrusted);
6261
6262        let findings = unpinned_action(&g);
6263        assert_eq!(findings.len(), 1);
6264        assert_eq!(findings[0].severity, Severity::High);
6265        assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
6266    }
6267
6268    #[test]
6269    fn unpinned_action_self_hosted_runner_label_not_flagged() {
6270        // Self-hosted runner labels are FirstParty Image nodes too — but
6271        // they aren't action references and have no @version to pin. The
6272        // rule must skip them (META_SELF_HOSTED is the marker).
6273        let mut g = AuthorityGraph::new(source("ci.yml"));
6274        let mut meta = std::collections::HashMap::new();
6275        meta.insert(META_SELF_HOSTED.into(), "true".into());
6276        g.add_node_with_metadata(NodeKind::Image, "self-hosted", TrustZone::FirstParty, meta);
6277
6278        let findings = unpinned_action(&g);
6279        assert!(
6280            findings.is_empty(),
6281            "self-hosted runner labels must not be flagged as unpinned actions: {findings:#?}"
6282        );
6283    }
6284
6285    // ── authority_propagation clustering ─────────────────────────
6286
6287    #[test]
6288    fn authority_propagation_clusters_one_secret_to_three_sinks() {
6289        // One secret, three different untrusted sinks reached via separate
6290        // propagation paths. After clustering, the rule must emit ONE
6291        // finding listing all three sinks in `nodes_involved`.
6292        let mut g = AuthorityGraph::new(source("ci.yml"));
6293        let secret = g.add_node(NodeKind::Secret, "GITHUB_TOKEN", TrustZone::FirstParty);
6294        let trampoline = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
6295        let sink_a = g.add_node(NodeKind::Step, "deploy[0]", TrustZone::Untrusted);
6296        let sink_b = g.add_node(NodeKind::Step, "deploy[1]", TrustZone::Untrusted);
6297        let sink_c = g.add_node(NodeKind::Step, "deploy[2]", TrustZone::Untrusted);
6298        g.add_edge(trampoline, secret, EdgeKind::HasAccessTo);
6299        g.add_edge(trampoline, sink_a, EdgeKind::DelegatesTo);
6300        g.add_edge(trampoline, sink_b, EdgeKind::DelegatesTo);
6301        g.add_edge(trampoline, sink_c, EdgeKind::DelegatesTo);
6302
6303        let findings = authority_propagation(&g, 4);
6304        assert_eq!(
6305            findings.len(),
6306            1,
6307            "three propagation paths from one secret must collapse to one finding, got: {findings:#?}"
6308        );
6309        let f = &findings[0];
6310        assert_eq!(f.category, FindingCategory::AuthorityPropagation);
6311        assert_eq!(f.severity, Severity::Critical);
6312        // [source, sink_a, sink_b, sink_c] — order preserved by insertion.
6313        assert_eq!(f.nodes_involved.len(), 4);
6314        assert_eq!(f.nodes_involved[0], secret);
6315        assert!(f.nodes_involved.contains(&sink_a));
6316        assert!(f.nodes_involved.contains(&sink_b));
6317        assert!(f.nodes_involved.contains(&sink_c));
6318        assert!(
6319            f.message.contains("3 sinks")
6320                || f.message.contains("deploy[0]") && f.message.contains("deploy[2]"),
6321            "cluster message must mention the multiple sinks: {}",
6322            f.message
6323        );
6324    }
6325
6326    #[test]
6327    fn authority_propagation_does_not_cluster_separate_secrets() {
6328        // Three independent secrets, each reaching one sink. The clustering
6329        // is keyed on the source node, so each secret's path becomes its own
6330        // finding — three findings total, not one.
6331        let mut g = AuthorityGraph::new(source("ci.yml"));
6332        let s1 = g.add_node(NodeKind::Secret, "TOKEN_A", TrustZone::FirstParty);
6333        let s2 = g.add_node(NodeKind::Secret, "TOKEN_B", TrustZone::FirstParty);
6334        let s3 = g.add_node(NodeKind::Secret, "TOKEN_C", TrustZone::FirstParty);
6335        let step1 = g.add_node(NodeKind::Step, "step_a", TrustZone::FirstParty);
6336        let step2 = g.add_node(NodeKind::Step, "step_b", TrustZone::FirstParty);
6337        let step3 = g.add_node(NodeKind::Step, "step_c", TrustZone::FirstParty);
6338        let sink1 = g.add_node(NodeKind::Step, "sink_a", TrustZone::Untrusted);
6339        let sink2 = g.add_node(NodeKind::Step, "sink_b", TrustZone::Untrusted);
6340        let sink3 = g.add_node(NodeKind::Step, "sink_c", TrustZone::Untrusted);
6341        g.add_edge(step1, s1, EdgeKind::HasAccessTo);
6342        g.add_edge(step1, sink1, EdgeKind::DelegatesTo);
6343        g.add_edge(step2, s2, EdgeKind::HasAccessTo);
6344        g.add_edge(step2, sink2, EdgeKind::DelegatesTo);
6345        g.add_edge(step3, s3, EdgeKind::HasAccessTo);
6346        g.add_edge(step3, sink3, EdgeKind::DelegatesTo);
6347
6348        let findings = authority_propagation(&g, 4);
6349        assert_eq!(
6350            findings.len(),
6351            3,
6352            "one finding per distinct source secret, got: {findings:#?}"
6353        );
6354        let sources: std::collections::HashSet<_> =
6355            findings.iter().map(|f| f.nodes_involved[0]).collect();
6356        assert!(sources.contains(&s1));
6357        assert!(sources.contains(&s2));
6358        assert!(sources.contains(&s3));
6359    }
6360
6361    // ── secret_via_env_gate_to_untrusted_consumer ──────────────────────
6362
6363    /// Build a graph with one job containing a configurable sequence of
6364    /// steps. Each tuple is (name, trust_zone, writes_env_gate, reads_env,
6365    /// secret_to_link). Returns the graph plus the assigned NodeIds in
6366    /// declaration order so tests can assert on specific nodes.
6367    fn job_with_steps(
6368        job: &str,
6369        steps: &[(&str, TrustZone, bool, bool, Option<&str>)],
6370    ) -> (AuthorityGraph, Vec<NodeId>) {
6371        let mut g = AuthorityGraph::new(source("ci.yml"));
6372        let mut secret_ids: std::collections::HashMap<String, NodeId> =
6373            std::collections::HashMap::new();
6374        let mut step_ids = Vec::new();
6375        for (name, zone, writes, reads, secret) in steps {
6376            let mut meta = std::collections::HashMap::new();
6377            meta.insert(META_JOB_NAME.into(), job.into());
6378            if *writes {
6379                meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
6380            }
6381            if *reads {
6382                meta.insert(META_READS_ENV.into(), "true".into());
6383            }
6384            let id = g.add_node_with_metadata(NodeKind::Step, *name, *zone, meta);
6385            if let Some(sname) = secret {
6386                let secret_id = *secret_ids
6387                    .entry((*sname).to_string())
6388                    .or_insert_with(|| g.add_node(NodeKind::Secret, *sname, TrustZone::FirstParty));
6389                g.add_edge(id, secret_id, EdgeKind::HasAccessTo);
6390            }
6391            step_ids.push(id);
6392        }
6393        (g, step_ids)
6394    }
6395
6396    #[test]
6397    fn env_gate_writer_then_untrusted_reader_fires() {
6398        let (g, _ids) = job_with_steps(
6399            "build",
6400            &[
6401                (
6402                    "setup",
6403                    TrustZone::FirstParty,
6404                    true,
6405                    false,
6406                    Some("CLOUD_KEY"),
6407                ),
6408                ("deploy", TrustZone::Untrusted, false, true, None),
6409            ],
6410        );
6411        let findings = secret_via_env_gate_to_untrusted_consumer(&g);
6412        assert_eq!(findings.len(), 1, "writer + untrusted reader must fire");
6413        assert_eq!(findings[0].severity, Severity::Critical);
6414        assert!(
6415            findings[0].message.contains("CLOUD_KEY"),
6416            "message must name the laundered secret"
6417        );
6418        assert!(
6419            findings[0].message.contains("deploy"),
6420            "message must name the consumer step"
6421        );
6422    }
6423
6424    #[test]
6425    fn env_gate_writer_then_first_party_reader_does_not_fire() {
6426        // First-party consumer is the legitimate use of $GITHUB_ENV — the
6427        // entire point of the gate. Only flagged when the consumer's trust
6428        // zone is reduced.
6429        let (g, _) = job_with_steps(
6430            "build",
6431            &[
6432                (
6433                    "setup",
6434                    TrustZone::FirstParty,
6435                    true,
6436                    false,
6437                    Some("CLOUD_KEY"),
6438                ),
6439                ("use-it", TrustZone::FirstParty, false, true, None),
6440            ],
6441        );
6442        let findings = secret_via_env_gate_to_untrusted_consumer(&g);
6443        assert!(
6444            findings.is_empty(),
6445            "first-party reader is the intended use; must not fire"
6446        );
6447    }
6448
6449    #[test]
6450    fn env_gate_write_of_non_secret_value_does_not_fire() {
6451        // Writer step doesn't hold any Secret/Identity — it's writing a
6452        // benign value (build version, config flag) into the env. Out of
6453        // scope: the env gate isn't laundering authority across a trust
6454        // boundary because there's no authority to launder.
6455        let (g, _) = job_with_steps(
6456            "build",
6457            &[
6458                ("setup", TrustZone::FirstParty, true, false, None),
6459                ("deploy", TrustZone::Untrusted, false, true, None),
6460            ],
6461        );
6462        let findings = secret_via_env_gate_to_untrusted_consumer(&g);
6463        assert!(
6464            findings.is_empty(),
6465            "env-gate write of non-authority value must not fire"
6466        );
6467    }
6468
6469    #[test]
6470    fn writer_in_different_job_does_not_fire() {
6471        // The env gate only propagates within a job — a writer in job A
6472        // cannot reach a consumer in job B even if both jobs run on the
6473        // same runner. Same-job constraint enforced via META_JOB_NAME.
6474        let mut g = AuthorityGraph::new(source("ci.yml"));
6475        let secret = g.add_node(NodeKind::Secret, "CLOUD_KEY", TrustZone::FirstParty);
6476
6477        let mut writer_meta = std::collections::HashMap::new();
6478        writer_meta.insert(META_JOB_NAME.into(), "build".into());
6479        writer_meta.insert(META_WRITES_ENV_GATE.into(), "true".into());
6480        let writer =
6481            g.add_node_with_metadata(NodeKind::Step, "setup", TrustZone::FirstParty, writer_meta);
6482        g.add_edge(writer, secret, EdgeKind::HasAccessTo);
6483
6484        let mut consumer_meta = std::collections::HashMap::new();
6485        consumer_meta.insert(META_JOB_NAME.into(), "deploy".into()); // DIFFERENT job
6486        consumer_meta.insert(META_READS_ENV.into(), "true".into());
6487        g.add_node_with_metadata(
6488            NodeKind::Step,
6489            "remote-deploy",
6490            TrustZone::Untrusted,
6491            consumer_meta,
6492        );
6493
6494        let findings = secret_via_env_gate_to_untrusted_consumer(&g);
6495        assert!(
6496            findings.is_empty(),
6497            "cross-job writer/consumer pair must not fire — same-job constraint"
6498        );
6499    }
6500
6501    #[test]
6502    fn writer_after_consumer_in_same_job_does_not_fire() {
6503        // Declaration order matters: a writer that comes AFTER the
6504        // consumer can't have populated the env the consumer read. Without
6505        // this ordering check the rule would over-fire on any same-job
6506        // write/read pair.
6507        let (g, _) = job_with_steps(
6508            "build",
6509            &[
6510                ("deploy", TrustZone::Untrusted, false, true, None),
6511                (
6512                    "setup",
6513                    TrustZone::FirstParty,
6514                    true,
6515                    false,
6516                    Some("CLOUD_KEY"),
6517                ),
6518            ],
6519        );
6520        let findings = secret_via_env_gate_to_untrusted_consumer(&g);
6521        assert!(
6522            findings.is_empty(),
6523            "writer that runs after the consumer cannot launder into it"
6524        );
6525    }
6526
6527    #[test]
6528    fn third_party_consumer_also_fires() {
6529        // ThirdParty (SHA-pinned marketplace action) is still in scope —
6530        // the action's code is immutable but it can still receive and
6531        // exfiltrate the laundered secret.
6532        let (g, _) = job_with_steps(
6533            "build",
6534            &[
6535                (
6536                    "setup",
6537                    TrustZone::FirstParty,
6538                    true,
6539                    false,
6540                    Some("CLOUD_KEY"),
6541                ),
6542                (
6543                    "third-party-deploy",
6544                    TrustZone::ThirdParty,
6545                    false,
6546                    true,
6547                    None,
6548                ),
6549            ],
6550        );
6551        let findings = secret_via_env_gate_to_untrusted_consumer(&g);
6552        assert_eq!(findings.len(), 1);
6553    }
6554
6555    #[test]
6556    fn rule_appears_in_run_all_rules() {
6557        // run_all_rules wires every rule in the catalogue — assert the
6558        // new one is hooked up so it actually fires from the CLI scan path.
6559        let (g, _) = job_with_steps(
6560            "build",
6561            &[
6562                (
6563                    "setup",
6564                    TrustZone::FirstParty,
6565                    true,
6566                    false,
6567                    Some("CLOUD_KEY"),
6568                ),
6569                ("deploy", TrustZone::Untrusted, false, true, None),
6570            ],
6571        );
6572        let findings = run_all_rules(&g, 4);
6573        assert!(
6574            findings
6575                .iter()
6576                .any(|f| f.category == FindingCategory::SecretViaEnvGateToUntrustedConsumer),
6577            "secret_via_env_gate_to_untrusted_consumer must run via run_all_rules"
6578        );
6579    }
6580
6581    // ── no_workflow_level_permissions_block ──────────────────
6582
6583    fn graph_with_platform(platform: &str, file: &str) -> AuthorityGraph {
6584        let mut g = AuthorityGraph::new(source(file));
6585        g.metadata.insert(META_PLATFORM.into(), platform.into());
6586        g
6587    }
6588
6589    #[test]
6590    fn no_workflow_perms_fires_on_gha_when_marker_present_and_no_token_identity() {
6591        let mut g = graph_with_platform("github-actions", ".github/workflows/ci.yml");
6592        g.metadata
6593            .insert(META_NO_WORKFLOW_PERMISSIONS.into(), "true".into());
6594        // A real workflow always has at least one Step. The empty-graph
6595        // guard inside the rule excludes mis-classified variable-only YAML.
6596        g.add_node(NodeKind::Step, "build[0]", TrustZone::FirstParty);
6597        // No GITHUB_TOKEN identity nodes at all (parser would skip creating
6598        // them when there's no permissions block anywhere).
6599
6600        let findings = no_workflow_level_permissions_block(&g);
6601        assert_eq!(findings.len(), 1);
6602        assert_eq!(findings[0].severity, Severity::Medium);
6603        assert_eq!(
6604            findings[0].category,
6605            FindingCategory::NoWorkflowLevelPermissionsBlock
6606        );
6607    }
6608
6609    #[test]
6610    fn no_workflow_perms_does_not_fire_on_empty_graph() {
6611        // Empty graph (variable-only YAML mis-detected as GHA, parse
6612        // failure, etc.) has no real authority surface — must skip.
6613        let mut g = graph_with_platform("github-actions", "vars.yml");
6614        g.metadata
6615            .insert(META_NO_WORKFLOW_PERMISSIONS.into(), "true".into());
6616        assert!(no_workflow_level_permissions_block(&g).is_empty());
6617    }
6618
6619    #[test]
6620    fn no_workflow_perms_does_not_fire_when_a_job_declares_permissions() {
6621        // Workflow has no top-level permissions, but one job does — the rule
6622        // must not fire because the per-job override is what runs.
6623        let mut g = graph_with_platform("github-actions", ".github/workflows/ci.yml");
6624        g.metadata
6625            .insert(META_NO_WORKFLOW_PERMISSIONS.into(), "true".into());
6626        let mut meta = std::collections::HashMap::new();
6627        meta.insert(META_PERMISSIONS.into(), "{ contents: read }".into());
6628        meta.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
6629        g.add_node_with_metadata(
6630            NodeKind::Identity,
6631            "GITHUB_TOKEN (build)",
6632            TrustZone::FirstParty,
6633            meta,
6634        );
6635
6636        let findings = no_workflow_level_permissions_block(&g);
6637        assert!(findings.is_empty());
6638    }
6639
6640    #[test]
6641    fn no_workflow_perms_does_not_fire_on_ado_or_gitlab() {
6642        let mut g = graph_with_platform("azure-devops", "azure-pipelines.yml");
6643        g.metadata
6644            .insert(META_NO_WORKFLOW_PERMISSIONS.into(), "true".into());
6645        assert!(no_workflow_level_permissions_block(&g).is_empty());
6646
6647        let mut g = graph_with_platform("gitlab", ".gitlab-ci.yml");
6648        g.metadata
6649            .insert(META_NO_WORKFLOW_PERMISSIONS.into(), "true".into());
6650        assert!(no_workflow_level_permissions_block(&g).is_empty());
6651    }
6652
6653    // ── prod_deploy_job_no_environment_gate ───────────────────
6654
6655    #[test]
6656    fn prod_deploy_no_env_gate_fires_on_ado_prod_sc_without_env_marker() {
6657        let mut g = graph_with_platform("azure-devops", "azure-pipelines.yml");
6658        step_with_meta(
6659            &mut g,
6660            "AzureCLI : Deploy",
6661            &[(META_SERVICE_CONNECTION_NAME, "platform-prod-sc")],
6662        );
6663        let findings = prod_deploy_job_no_environment_gate(&g);
6664        assert_eq!(findings.len(), 1);
6665        assert_eq!(findings[0].severity, Severity::High);
6666        assert_eq!(
6667            findings[0].category,
6668            FindingCategory::ProdDeployJobNoEnvironmentGate
6669        );
6670        assert!(findings[0].message.contains("platform-prod-sc"));
6671    }
6672
6673    #[test]
6674    fn prod_deploy_no_env_gate_skips_when_env_marker_present() {
6675        let mut g = graph_with_platform("azure-devops", "azure-pipelines.yml");
6676        step_with_meta(
6677            &mut g,
6678            "AzureCLI : Deploy",
6679            &[
6680                (META_SERVICE_CONNECTION_NAME, "platform-prod-sc"),
6681                (META_ENV_APPROVAL, "true"),
6682            ],
6683        );
6684        assert!(prod_deploy_job_no_environment_gate(&g).is_empty());
6685    }
6686
6687    #[test]
6688    fn prod_deploy_no_env_gate_skips_dev_connection() {
6689        let mut g = graph_with_platform("azure-devops", "azure-pipelines.yml");
6690        step_with_meta(
6691            &mut g,
6692            "AzureCLI : Deploy",
6693            &[(META_SERVICE_CONNECTION_NAME, "platform-dev-sc")],
6694        );
6695        assert!(prod_deploy_job_no_environment_gate(&g).is_empty());
6696    }
6697
6698    #[test]
6699    fn prod_deploy_no_env_gate_via_edge_to_prod_identity() {
6700        let mut g = graph_with_platform("azure-devops", "azure-pipelines.yml");
6701        let step = step_with_meta(&mut g, "AzureCLI : Deploy", &[]);
6702        let mut id_meta = std::collections::HashMap::new();
6703        id_meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
6704        let conn = g.add_node_with_metadata(
6705            NodeKind::Identity,
6706            "alz-infra-sc-prd-uks",
6707            TrustZone::FirstParty,
6708            id_meta,
6709        );
6710        g.add_edge(step, conn, EdgeKind::HasAccessTo);
6711        let findings = prod_deploy_job_no_environment_gate(&g);
6712        assert_eq!(findings.len(), 1);
6713        assert!(findings[0].message.contains("alz-infra-sc-prd-uks"));
6714    }
6715
6716    // ── long_lived_secret_without_oidc_recommendation ─────────
6717
6718    #[test]
6719    fn ll_secret_without_oidc_emits_for_aws_secret_with_no_oidc_in_graph() {
6720        let mut g = graph_with_platform("github-actions", ".github/workflows/ci.yml");
6721        g.add_node(NodeKind::Secret, "AWS_ACCESS_KEY_ID", TrustZone::FirstParty);
6722
6723        let findings = long_lived_secret_without_oidc_recommendation(&g);
6724        assert_eq!(findings.len(), 1);
6725        assert_eq!(findings[0].severity, Severity::Info);
6726        assert!(matches!(
6727            findings[0].recommendation,
6728            Recommendation::FederateIdentity { .. }
6729        ));
6730    }
6731
6732    #[test]
6733    fn ll_secret_without_oidc_skips_when_oidc_identity_present() {
6734        let mut g = graph_with_platform("github-actions", ".github/workflows/ci.yml");
6735        g.add_node(NodeKind::Secret, "AWS_ACCESS_KEY_ID", TrustZone::FirstParty);
6736        let mut meta = std::collections::HashMap::new();
6737        meta.insert(META_OIDC.into(), "true".into());
6738        g.add_node_with_metadata(
6739            NodeKind::Identity,
6740            "AWS/deploy-role",
6741            TrustZone::FirstParty,
6742            meta,
6743        );
6744
6745        assert!(long_lived_secret_without_oidc_recommendation(&g).is_empty());
6746    }
6747
6748    #[test]
6749    fn ll_secret_without_oidc_skips_unrecognised_secret_names() {
6750        let mut g = graph_with_platform("github-actions", ".github/workflows/ci.yml");
6751        g.add_node(NodeKind::Secret, "INTERNAL_KEY", TrustZone::FirstParty);
6752        // Not AWS/GCP/Azure-shaped — no actionable OIDC migration path.
6753        assert!(long_lived_secret_without_oidc_recommendation(&g).is_empty());
6754    }
6755
6756    // ── pull_request_workflow_inconsistent_fork_check ─────────
6757
6758    #[test]
6759    fn inconsistent_fork_check_fires_when_one_job_guarded_one_unguarded() {
6760        let mut g = graph_with_platform("github-actions", ".github/workflows/pr.yml");
6761        g.metadata
6762            .insert(META_TRIGGER.into(), "pull_request".into());
6763        let secret = g.add_node(NodeKind::Secret, "DEPLOY", TrustZone::FirstParty);
6764        let s_guarded = step_with_meta(
6765            &mut g,
6766            "build[0]",
6767            &[(META_JOB_NAME, "build"), (META_FORK_CHECK, "true")],
6768        );
6769        let s_unguarded = step_with_meta(&mut g, "deploy[0]", &[(META_JOB_NAME, "deploy")]);
6770        g.add_edge(s_guarded, secret, EdgeKind::HasAccessTo);
6771        g.add_edge(s_unguarded, secret, EdgeKind::HasAccessTo);
6772
6773        let findings = pull_request_workflow_inconsistent_fork_check(&g);
6774        assert_eq!(findings.len(), 1);
6775        assert_eq!(
6776            findings[0].category,
6777            FindingCategory::PullRequestWorkflowInconsistentForkCheck
6778        );
6779        assert!(findings[0].message.contains("deploy"));
6780        assert!(findings[0].message.contains("build"));
6781    }
6782
6783    #[test]
6784    fn inconsistent_fork_check_skips_when_all_jobs_guarded() {
6785        let mut g = graph_with_platform("github-actions", ".github/workflows/pr.yml");
6786        g.metadata
6787            .insert(META_TRIGGER.into(), "pull_request".into());
6788        let secret = g.add_node(NodeKind::Secret, "DEPLOY", TrustZone::FirstParty);
6789        let s1 = step_with_meta(
6790            &mut g,
6791            "build[0]",
6792            &[(META_JOB_NAME, "build"), (META_FORK_CHECK, "true")],
6793        );
6794        let s2 = step_with_meta(
6795            &mut g,
6796            "deploy[0]",
6797            &[(META_JOB_NAME, "deploy"), (META_FORK_CHECK, "true")],
6798        );
6799        g.add_edge(s1, secret, EdgeKind::HasAccessTo);
6800        g.add_edge(s2, secret, EdgeKind::HasAccessTo);
6801        assert!(pull_request_workflow_inconsistent_fork_check(&g).is_empty());
6802    }
6803
6804    #[test]
6805    fn inconsistent_fork_check_skips_when_no_job_guarded() {
6806        // Both unguarded → not "inconsistent" (the org never tried). Other
6807        // rules cover the underlying risk.
6808        let mut g = graph_with_platform("github-actions", ".github/workflows/pr.yml");
6809        g.metadata
6810            .insert(META_TRIGGER.into(), "pull_request".into());
6811        let secret = g.add_node(NodeKind::Secret, "DEPLOY", TrustZone::FirstParty);
6812        let s1 = step_with_meta(&mut g, "build[0]", &[(META_JOB_NAME, "build")]);
6813        let s2 = step_with_meta(&mut g, "deploy[0]", &[(META_JOB_NAME, "deploy")]);
6814        g.add_edge(s1, secret, EdgeKind::HasAccessTo);
6815        g.add_edge(s2, secret, EdgeKind::HasAccessTo);
6816        assert!(pull_request_workflow_inconsistent_fork_check(&g).is_empty());
6817    }
6818
6819    #[test]
6820    fn inconsistent_fork_check_skips_non_pr_trigger() {
6821        let mut g = graph_with_platform("github-actions", ".github/workflows/push.yml");
6822        g.metadata.insert(META_TRIGGER.into(), "push".into());
6823        let secret = g.add_node(NodeKind::Secret, "DEPLOY", TrustZone::FirstParty);
6824        let s1 = step_with_meta(
6825            &mut g,
6826            "build[0]",
6827            &[(META_JOB_NAME, "build"), (META_FORK_CHECK, "true")],
6828        );
6829        let s2 = step_with_meta(&mut g, "deploy[0]", &[(META_JOB_NAME, "deploy")]);
6830        g.add_edge(s1, secret, EdgeKind::HasAccessTo);
6831        g.add_edge(s2, secret, EdgeKind::HasAccessTo);
6832        assert!(pull_request_workflow_inconsistent_fork_check(&g).is_empty());
6833    }
6834
6835    // ── gitlab_deploy_job_missing_protected_branch_only ────────
6836
6837    #[test]
6838    fn gitlab_deploy_no_protected_only_fires_on_prod_env_without_marker() {
6839        let mut g = graph_with_platform("gitlab", ".gitlab-ci.yml");
6840        step_with_meta(&mut g, "deploy-prod", &[("environment_name", "production")]);
6841        let findings = gitlab_deploy_job_missing_protected_branch_only(&g);
6842        assert_eq!(findings.len(), 1);
6843        assert_eq!(findings[0].severity, Severity::Medium);
6844        assert_eq!(
6845            findings[0].category,
6846            FindingCategory::GitlabDeployJobMissingProtectedBranchOnly
6847        );
6848    }
6849
6850    #[test]
6851    fn gitlab_deploy_no_protected_only_skips_when_marker_present() {
6852        let mut g = graph_with_platform("gitlab", ".gitlab-ci.yml");
6853        step_with_meta(
6854            &mut g,
6855            "deploy-prod",
6856            &[
6857                ("environment_name", "production"),
6858                (META_RULES_PROTECTED_ONLY, "true"),
6859            ],
6860        );
6861        assert!(gitlab_deploy_job_missing_protected_branch_only(&g).is_empty());
6862    }
6863
6864    #[test]
6865    fn gitlab_deploy_no_protected_only_skips_dev_environment() {
6866        let mut g = graph_with_platform("gitlab", ".gitlab-ci.yml");
6867        step_with_meta(&mut g, "deploy-staging", &[("environment_name", "staging")]);
6868        assert!(gitlab_deploy_job_missing_protected_branch_only(&g).is_empty());
6869    }
6870
6871    // ── compensating-control suppressions ─────────────────────
6872
6873    #[test]
6874    fn suppression_checkout_pr_downgraded_when_no_privileged_steps_in_job() {
6875        // Build a graph where checkout_self_pr_exposure would fire BUT the
6876        // job has no secret access and no env-gate writes.
6877        let mut g = graph_with_platform("github-actions", ".github/workflows/lint.yml");
6878        g.metadata
6879            .insert(META_TRIGGER.into(), "pull_request_target".into());
6880        let _checkout = step_with_meta(
6881            &mut g,
6882            "lint[0]",
6883            &[(META_JOB_NAME, "lint"), (META_CHECKOUT_SELF, "true")],
6884        );
6885        // A second non-privileged step in the same job.
6886        step_with_meta(&mut g, "lint[1]", &[(META_JOB_NAME, "lint")]);
6887
6888        let mut findings = checkout_self_pr_exposure(&g);
6889        assert_eq!(findings.len(), 1);
6890        assert_eq!(findings[0].severity, Severity::High); // pre-suppression
6891        apply_compensating_controls(&g, &mut findings);
6892        assert_eq!(
6893            findings[0].severity,
6894            Severity::Info,
6895            "checkout in a job with no privileged steps must downgrade to Info"
6896        );
6897        assert!(findings[0].message.contains("downgraded"));
6898    }
6899
6900    #[test]
6901    fn suppression_checkout_pr_unchanged_when_job_has_privileged_step() {
6902        let mut g = graph_with_platform("github-actions", ".github/workflows/build.yml");
6903        g.metadata
6904            .insert(META_TRIGGER.into(), "pull_request_target".into());
6905        let secret = g.add_node(NodeKind::Secret, "DEPLOY_TOKEN", TrustZone::FirstParty);
6906        let checkout = step_with_meta(
6907            &mut g,
6908            "build[0]",
6909            &[(META_JOB_NAME, "build"), (META_CHECKOUT_SELF, "true")],
6910        );
6911        let priv_step = step_with_meta(&mut g, "build[1]", &[(META_JOB_NAME, "build")]);
6912        g.add_edge(priv_step, secret, EdgeKind::HasAccessTo);
6913        // checkout step itself has no edges
6914        let _ = checkout;
6915
6916        let mut findings = checkout_self_pr_exposure(&g);
6917        assert_eq!(findings.len(), 1);
6918        let pre = findings[0].severity;
6919        apply_compensating_controls(&g, &mut findings);
6920        assert_eq!(
6921            findings[0].severity, pre,
6922            "must NOT downgrade when same job has privileged steps"
6923        );
6924    }
6925
6926    #[test]
6927    fn suppression_trigger_context_downgraded_when_all_priv_jobs_fork_checked() {
6928        // pull_request_target trigger + every privileged step has fork-check.
6929        let mut g = graph_with_platform("github-actions", ".github/workflows/prt.yml");
6930        g.metadata
6931            .insert(META_TRIGGER.into(), "pull_request_target".into());
6932        let secret = g.add_node(NodeKind::Secret, "DEPLOY", TrustZone::FirstParty);
6933        let s = step_with_meta(
6934            &mut g,
6935            "build[0]",
6936            &[(META_JOB_NAME, "build"), (META_FORK_CHECK, "true")],
6937        );
6938        g.add_edge(s, secret, EdgeKind::HasAccessTo);
6939
6940        let mut findings = trigger_context_mismatch(&g);
6941        assert_eq!(findings.len(), 1);
6942        assert_eq!(findings[0].severity, Severity::Critical);
6943        apply_compensating_controls(&g, &mut findings);
6944        assert_eq!(
6945            findings[0].severity,
6946            Severity::Medium,
6947            "trigger_context_mismatch must downgrade Critical → Medium when fork-check universal"
6948        );
6949        assert!(findings[0].message.contains("downgraded"));
6950    }
6951
6952    #[test]
6953    fn suppression_trigger_context_unchanged_when_some_priv_steps_unguarded() {
6954        let mut g = graph_with_platform("github-actions", ".github/workflows/prt.yml");
6955        g.metadata
6956            .insert(META_TRIGGER.into(), "pull_request_target".into());
6957        let secret = g.add_node(NodeKind::Secret, "DEPLOY", TrustZone::FirstParty);
6958        let s_guard = step_with_meta(
6959            &mut g,
6960            "build[0]",
6961            &[(META_JOB_NAME, "build"), (META_FORK_CHECK, "true")],
6962        );
6963        let s_no_guard = step_with_meta(&mut g, "deploy[0]", &[(META_JOB_NAME, "deploy")]);
6964        g.add_edge(s_guard, secret, EdgeKind::HasAccessTo);
6965        g.add_edge(s_no_guard, secret, EdgeKind::HasAccessTo);
6966
6967        let mut findings = trigger_context_mismatch(&g);
6968        let pre = findings[0].severity;
6969        apply_compensating_controls(&g, &mut findings);
6970        assert_eq!(findings[0].severity, pre);
6971    }
6972
6973    #[test]
6974    fn suppression_overpriv_identity_demoted_when_job_has_narrow_override() {
6975        // Workflow-level GITHUB_TOKEN is broad; one job has constrained override.
6976        let mut g = graph_with_platform("github-actions", ".github/workflows/ci.yml");
6977        let mut wf_meta = std::collections::HashMap::new();
6978        wf_meta.insert(META_PERMISSIONS.into(), "write-all".into());
6979        wf_meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
6980        let wf_token = g.add_node_with_metadata(
6981            NodeKind::Identity,
6982            "GITHUB_TOKEN",
6983            TrustZone::FirstParty,
6984            wf_meta,
6985        );
6986        let mut job_meta = std::collections::HashMap::new();
6987        job_meta.insert(META_PERMISSIONS.into(), "{ contents: read }".into());
6988        job_meta.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
6989        g.add_node_with_metadata(
6990            NodeKind::Identity,
6991            "GITHUB_TOKEN (build)",
6992            TrustZone::FirstParty,
6993            job_meta,
6994        );
6995        let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
6996        g.add_edge(step, wf_token, EdgeKind::HasAccessTo);
6997
6998        let mut findings = over_privileged_identity(&g);
6999        // Filter to only the workflow-level finding (the constrained job-level
7000        // override won't fire over_privileged_identity by itself).
7001        let wf_findings_count = findings
7002            .iter()
7003            .filter(|f| {
7004                f.nodes_involved
7005                    .first()
7006                    .and_then(|id| g.node(*id))
7007                    .map(|n| n.name == "GITHUB_TOKEN")
7008                    .unwrap_or(false)
7009            })
7010            .count();
7011        assert_eq!(wf_findings_count, 1);
7012        apply_compensating_controls(&g, &mut findings);
7013        let demoted = findings.iter().find(|f| {
7014            f.nodes_involved
7015                .first()
7016                .and_then(|id| g.node(*id))
7017                .map(|n| n.name == "GITHUB_TOKEN")
7018                .unwrap_or(false)
7019        });
7020        let demoted = demoted.expect("workflow-level token finding still present");
7021        assert_eq!(
7022            demoted.severity,
7023            Severity::Info,
7024            "workflow-level over_priv must downgrade to Info when narrower job override exists"
7025        );
7026        assert!(demoted.message.contains("suppressed"));
7027    }
7028}