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