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