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