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