Skip to main content

taudit_core/
rules.rs

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