1use crate::finding::{Finding, FindingCategory, Recommendation, Severity};
2use crate::graph::{
3 is_docker_digest_pinned, is_sha_pinned, AuthorityCompleteness, AuthorityGraph, EdgeKind,
4 IdentityScope, NodeKind, TrustZone, META_CLI_FLAG_EXPOSED, META_CONTAINER, META_DIGEST,
5 META_IDENTITY_SCOPE, META_PERMISSIONS,
6};
7use crate::propagation;
8
9fn cap_severity(severity: Severity, max_severity: Severity) -> Severity {
10 if severity < max_severity {
11 max_severity
12 } else {
13 severity
14 }
15}
16
17fn apply_confidence_cap(graph: &AuthorityGraph, findings: &mut [Finding]) {
18 if graph.completeness != AuthorityCompleteness::Partial {
19 return;
20 }
21
22 for finding in findings {
23 finding.severity = cap_severity(finding.severity, Severity::High);
24 }
25}
26
27pub fn authority_propagation(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
35 let paths = propagation::propagation_analysis(graph, max_hops);
36
37 paths
38 .into_iter()
39 .filter(|p| p.crossed_boundary)
40 .map(|path| {
41 let source_name = graph
42 .node(path.source)
43 .map(|n| n.name.as_str())
44 .unwrap_or("?");
45 let sink_name = graph
46 .node(path.sink)
47 .map(|n| n.name.as_str())
48 .unwrap_or("?");
49
50 let sink_is_pinned = graph
52 .node(path.sink)
53 .map(|n| {
54 n.trust_zone == TrustZone::ThirdParty && n.metadata.contains_key(META_DIGEST)
55 })
56 .unwrap_or(false);
57
58 let source_is_constrained = graph
59 .node(path.source)
60 .and_then(|n| n.metadata.get(META_IDENTITY_SCOPE))
61 .map(|s| s == "constrained")
62 .unwrap_or(false);
63
64 let severity = if sink_is_pinned && source_is_constrained {
65 Severity::Medium
66 } else if sink_is_pinned {
67 Severity::High
68 } else {
69 Severity::Critical
70 };
71
72 Finding {
73 severity,
74 category: FindingCategory::AuthorityPropagation,
75 nodes_involved: vec![path.source, path.sink],
76 message: format!("{source_name} propagated to {sink_name} across trust boundary"),
77 recommendation: Recommendation::TsafeRemediation {
78 command: "tsafe exec --ns <scoped-namespace> -- <command>".to_string(),
79 explanation: format!("Scope {source_name} to only the steps that need it"),
80 },
81 path: Some(path),
82 }
83 })
84 .collect()
85}
86
87pub fn over_privileged_identity(graph: &AuthorityGraph) -> Vec<Finding> {
93 let mut findings = Vec::new();
94
95 for identity in graph.nodes_of_kind(NodeKind::Identity) {
96 let granted_scope = identity
97 .metadata
98 .get(META_PERMISSIONS)
99 .cloned()
100 .unwrap_or_default();
101
102 let scope = identity
104 .metadata
105 .get(META_IDENTITY_SCOPE)
106 .and_then(|s| match s.as_str() {
107 "broad" => Some(IdentityScope::Broad),
108 "constrained" => Some(IdentityScope::Constrained),
109 "unknown" => Some(IdentityScope::Unknown),
110 _ => None,
111 })
112 .unwrap_or_else(|| IdentityScope::from_permissions(&granted_scope));
113
114 let (should_flag, severity) = match scope {
116 IdentityScope::Broad => (true, Severity::High),
117 IdentityScope::Unknown => (true, Severity::Medium),
118 IdentityScope::Constrained => (false, Severity::Info),
119 };
120
121 if !should_flag {
122 continue;
123 }
124
125 let accessor_steps: Vec<_> = graph
126 .edges_to(identity.id)
127 .filter(|e| e.kind == EdgeKind::HasAccessTo)
128 .filter_map(|e| graph.node(e.from))
129 .collect();
130
131 if !accessor_steps.is_empty() {
132 let scope_label = match scope {
133 IdentityScope::Broad => "broad",
134 IdentityScope::Unknown => "unknown (treat as risky)",
135 IdentityScope::Constrained => "constrained",
136 };
137
138 findings.push(Finding {
139 severity,
140 category: FindingCategory::OverPrivilegedIdentity,
141 path: None,
142 nodes_involved: std::iter::once(identity.id)
143 .chain(accessor_steps.iter().map(|n| n.id))
144 .collect(),
145 message: format!(
146 "{} has {} scope (permissions: '{}') — likely broader than needed",
147 identity.name, scope_label, granted_scope
148 ),
149 recommendation: Recommendation::ReducePermissions {
150 current: granted_scope.clone(),
151 minimum: "{ contents: read }".into(),
152 },
153 });
154 }
155 }
156
157 findings
158}
159
160pub fn unpinned_action(graph: &AuthorityGraph) -> Vec<Finding> {
165 let mut findings = Vec::new();
166 let mut seen = std::collections::HashSet::new();
167
168 for image in graph.nodes_of_kind(NodeKind::Image) {
169 if image.trust_zone == TrustZone::FirstParty {
170 continue;
171 }
172
173 if image
176 .metadata
177 .get(META_CONTAINER)
178 .map(|v| v == "true")
179 .unwrap_or(false)
180 {
181 continue;
182 }
183
184 if !seen.insert(&image.name) {
186 continue;
187 }
188
189 let has_digest = image.metadata.contains_key(META_DIGEST);
190
191 if !has_digest && !is_sha_pinned(&image.name) {
192 findings.push(Finding {
193 severity: Severity::Medium,
194 category: FindingCategory::UnpinnedAction,
195 path: None,
196 nodes_involved: vec![image.id],
197 message: format!("{} is not pinned to a SHA digest", image.name),
198 recommendation: Recommendation::PinAction {
199 current: image.name.clone(),
200 pinned: format!(
201 "{}@<sha256-digest>",
202 image.name.split('@').next().unwrap_or(&image.name)
203 ),
204 },
205 });
206 }
207 }
208
209 findings
210}
211
212pub fn untrusted_with_authority(graph: &AuthorityGraph) -> Vec<Finding> {
214 let mut findings = Vec::new();
215
216 for step in graph.nodes_in_zone(TrustZone::Untrusted) {
217 if step.kind != NodeKind::Step {
218 continue;
219 }
220
221 for edge in graph.edges_from(step.id) {
223 if edge.kind != EdgeKind::HasAccessTo {
224 continue;
225 }
226
227 if let Some(target) = graph.node(edge.to) {
228 if matches!(target.kind, NodeKind::Secret | NodeKind::Identity) {
229 let cli_flag_exposed = target
230 .metadata
231 .get(META_CLI_FLAG_EXPOSED)
232 .map(|v| v == "true")
233 .unwrap_or(false);
234
235 let recommendation = if target.kind == NodeKind::Secret {
236 if cli_flag_exposed {
237 Recommendation::Manual {
238 action: format!(
239 "Move '{}' from -var flag to TF_VAR_{} env var — \
240 -var values appear in pipeline logs and Terraform plan output",
241 target.name, target.name
242 ),
243 }
244 } else {
245 Recommendation::CellosRemediation {
246 reason: format!(
247 "Untrusted step '{}' has direct access to secret '{}'",
248 step.name, target.name
249 ),
250 spec_hint: format!(
251 "cellos run --network deny-all --broker env:{}",
252 target.name
253 ),
254 }
255 }
256 } else {
257 Recommendation::ReducePermissions {
258 current: target
259 .metadata
260 .get(META_PERMISSIONS)
261 .cloned()
262 .unwrap_or_else(|| "unknown".into()),
263 minimum: "minimal required scope".into(),
264 }
265 };
266
267 let log_exposure_note = if cli_flag_exposed {
268 " (passed as -var flag — value visible in pipeline logs)"
269 } else {
270 ""
271 };
272
273 findings.push(Finding {
274 severity: Severity::Critical,
275 category: FindingCategory::UntrustedWithAuthority,
276 path: None,
277 nodes_involved: vec![step.id, target.id],
278 message: format!(
279 "Untrusted step '{}' has direct access to {} '{}'{}",
280 step.name,
281 if target.kind == NodeKind::Secret {
282 "secret"
283 } else {
284 "identity"
285 },
286 target.name,
287 log_exposure_note,
288 ),
289 recommendation,
290 });
291 }
292 }
293 }
294 }
295
296 findings
297}
298
299pub fn artifact_boundary_crossing(graph: &AuthorityGraph) -> Vec<Finding> {
301 let mut findings = Vec::new();
302
303 for artifact in graph.nodes_of_kind(NodeKind::Artifact) {
304 let producers: Vec<_> = graph
306 .edges_to(artifact.id)
307 .filter(|e| e.kind == EdgeKind::Produces)
308 .filter_map(|e| graph.node(e.from))
309 .collect();
310
311 let consumers: Vec<_> = graph
313 .edges_from(artifact.id)
314 .filter(|e| e.kind == EdgeKind::Consumes)
315 .filter_map(|e| graph.node(e.to))
316 .collect();
317
318 for producer in &producers {
319 let producer_has_authority = graph.edges_from(producer.id).any(|e| {
321 e.kind == EdgeKind::HasAccessTo
322 && graph
323 .node(e.to)
324 .map(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
325 .unwrap_or(false)
326 });
327
328 if !producer_has_authority {
329 continue;
330 }
331
332 for consumer in &consumers {
333 if consumer.trust_zone.is_lower_than(&producer.trust_zone) {
334 findings.push(Finding {
335 severity: Severity::High,
336 category: FindingCategory::ArtifactBoundaryCrossing,
337 path: None,
338 nodes_involved: vec![producer.id, artifact.id, consumer.id],
339 message: format!(
340 "Artifact '{}' produced by privileged step '{}' consumed by '{}' ({:?} -> {:?})",
341 artifact.name,
342 producer.name,
343 consumer.name,
344 producer.trust_zone,
345 consumer.trust_zone
346 ),
347 recommendation: Recommendation::TsafeRemediation {
348 command: format!(
349 "tsafe exec --ns {} -- <build-command>",
350 producer.name
351 ),
352 explanation: format!(
353 "Scope secrets to '{}' only; artifact '{}' should not carry authority",
354 producer.name, artifact.name
355 ),
356 },
357 });
358 }
359 }
360 }
361 }
362
363 findings
364}
365
366pub fn long_lived_credential(graph: &AuthorityGraph) -> Vec<Finding> {
371 const STATIC_PATTERNS: &[&str] = &[
372 "AWS_ACCESS_KEY",
373 "AWS_SECRET_ACCESS_KEY",
374 "_API_KEY",
375 "_APIKEY",
376 "_PASSWORD",
377 "_PASSWD",
378 "_PRIVATE_KEY",
379 "_SECRET_KEY",
380 "_SERVICE_ACCOUNT",
381 "_SIGNING_KEY",
382 ];
383
384 let mut findings = Vec::new();
385
386 for secret in graph.nodes_of_kind(NodeKind::Secret) {
387 let upper = secret.name.to_uppercase();
388 let is_static = STATIC_PATTERNS.iter().any(|p| upper.contains(p));
389
390 if is_static {
391 findings.push(Finding {
392 severity: Severity::Low,
393 category: FindingCategory::LongLivedCredential,
394 path: None,
395 nodes_involved: vec![secret.id],
396 message: format!(
397 "'{}' looks like a long-lived static credential",
398 secret.name
399 ),
400 recommendation: Recommendation::FederateIdentity {
401 static_secret: secret.name.clone(),
402 oidc_provider: "GitHub Actions OIDC (id-token: write)".into(),
403 },
404 });
405 }
406 }
407
408 findings
409}
410
411pub fn floating_image(graph: &AuthorityGraph) -> Vec<Finding> {
417 let mut findings = Vec::new();
418 let mut seen = std::collections::HashSet::new();
419
420 for image in graph.nodes_of_kind(NodeKind::Image) {
421 let is_container = image
422 .metadata
423 .get(META_CONTAINER)
424 .map(|v| v == "true")
425 .unwrap_or(false);
426
427 if !is_container {
428 continue;
429 }
430
431 if !seen.insert(image.name.as_str()) {
432 continue;
433 }
434
435 if !is_docker_digest_pinned(&image.name) {
436 findings.push(Finding {
437 severity: Severity::Medium,
438 category: FindingCategory::FloatingImage,
439 path: None,
440 nodes_involved: vec![image.id],
441 message: format!("Container image '{}' is not pinned to a digest", image.name),
442 recommendation: Recommendation::PinAction {
443 current: image.name.clone(),
444 pinned: format!(
445 "{}@sha256:<digest>",
446 image.name.split(':').next().unwrap_or(&image.name)
447 ),
448 },
449 });
450 }
451 }
452
453 findings
454}
455
456pub fn persisted_credential(graph: &AuthorityGraph) -> Vec<Finding> {
462 let mut findings = Vec::new();
463
464 for edge in &graph.edges {
465 if edge.kind != EdgeKind::PersistsTo {
466 continue;
467 }
468
469 let Some(step) = graph.node(edge.from) else {
470 continue;
471 };
472 let Some(target) = graph.node(edge.to) else {
473 continue;
474 };
475
476 findings.push(Finding {
477 severity: Severity::High,
478 category: FindingCategory::PersistedCredential,
479 path: None,
480 nodes_involved: vec![step.id, target.id],
481 message: format!(
482 "'{}' persists '{}' to disk via persistCredentials: true — \
483 credential remains in .git/config and is accessible to all subsequent steps",
484 step.name, target.name
485 ),
486 recommendation: Recommendation::Manual {
487 action: "Remove persistCredentials: true from the checkout step. \
488 Pass credentials explicitly only to steps that need them."
489 .into(),
490 },
491 });
492 }
493
494 findings
495}
496
497pub fn run_all_rules(graph: &AuthorityGraph, max_hops: usize) -> Vec<Finding> {
499 let mut findings = Vec::new();
500 findings.extend(authority_propagation(graph, max_hops));
502 findings.extend(over_privileged_identity(graph));
503 findings.extend(unpinned_action(graph));
504 findings.extend(untrusted_with_authority(graph));
505 findings.extend(artifact_boundary_crossing(graph));
506 findings.extend(long_lived_credential(graph));
508 findings.extend(floating_image(graph));
509 findings.extend(persisted_credential(graph));
510
511 apply_confidence_cap(graph, &mut findings);
512
513 findings.sort_by_key(|f| f.severity);
514
515 findings
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::graph::*;
522
523 fn source(file: &str) -> PipelineSource {
524 PipelineSource {
525 file: file.into(),
526 repo: None,
527 git_ref: None,
528 }
529 }
530
531 #[test]
532 fn unpinned_third_party_action_flagged() {
533 let mut g = AuthorityGraph::new(source("ci.yml"));
534 g.add_node(
535 NodeKind::Image,
536 "actions/checkout@v4",
537 TrustZone::ThirdParty,
538 );
539
540 let findings = unpinned_action(&g);
541 assert_eq!(findings.len(), 1);
542 assert_eq!(findings[0].category, FindingCategory::UnpinnedAction);
543 }
544
545 #[test]
546 fn pinned_action_not_flagged() {
547 let mut g = AuthorityGraph::new(source("ci.yml"));
548 g.add_node(
549 NodeKind::Image,
550 "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
551 TrustZone::ThirdParty,
552 );
553
554 let findings = unpinned_action(&g);
555 assert!(findings.is_empty());
556 }
557
558 #[test]
559 fn untrusted_step_with_secret_is_critical() {
560 let mut g = AuthorityGraph::new(source("ci.yml"));
561 let step = g.add_node(NodeKind::Step, "evil-action", TrustZone::Untrusted);
562 let secret = g.add_node(NodeKind::Secret, "DEPLOY_KEY", TrustZone::FirstParty);
563 g.add_edge(step, secret, EdgeKind::HasAccessTo);
564
565 let findings = untrusted_with_authority(&g);
566 assert_eq!(findings.len(), 1);
567 assert_eq!(findings[0].severity, Severity::Critical);
568 }
569
570 #[test]
571 fn artifact_crossing_detected() {
572 let mut g = AuthorityGraph::new(source("ci.yml"));
573 let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
574 let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
575 let artifact = g.add_node(NodeKind::Artifact, "dist.zip", TrustZone::FirstParty);
576 let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
577
578 g.add_edge(build, secret, EdgeKind::HasAccessTo);
579 g.add_edge(build, artifact, EdgeKind::Produces);
580 g.add_edge(artifact, deploy, EdgeKind::Consumes);
581
582 let findings = artifact_boundary_crossing(&g);
583 assert_eq!(findings.len(), 1);
584 assert_eq!(
585 findings[0].category,
586 FindingCategory::ArtifactBoundaryCrossing
587 );
588 }
589
590 #[test]
591 fn propagation_to_sha_pinned_is_high_not_critical() {
592 let mut g = AuthorityGraph::new(source("ci.yml"));
593 let mut meta = std::collections::HashMap::new();
594 meta.insert(
595 "digest".into(),
596 "a5ac7e51b41094c92402da3b24376905380afc29".into(),
597 );
598 let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
599 let step = g.add_node(NodeKind::Step, "checkout", TrustZone::ThirdParty);
600 let image = g.add_node_with_metadata(
601 NodeKind::Image,
602 "actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29",
603 TrustZone::ThirdParty,
604 meta,
605 );
606
607 g.add_edge(step, identity, EdgeKind::HasAccessTo);
608 g.add_edge(step, image, EdgeKind::UsesImage);
609
610 let findings = authority_propagation(&g, 4);
611 let image_findings: Vec<_> = findings
613 .iter()
614 .filter(|f| f.nodes_involved.contains(&image))
615 .collect();
616 assert!(!image_findings.is_empty());
617 assert_eq!(image_findings[0].severity, Severity::High);
619 }
620
621 #[test]
622 fn propagation_to_untrusted_is_critical() {
623 let mut g = AuthorityGraph::new(source("ci.yml"));
624 let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
625 let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
626 let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
627
628 g.add_edge(step, identity, EdgeKind::HasAccessTo);
629 g.add_edge(step, image, EdgeKind::UsesImage);
630
631 let findings = authority_propagation(&g, 4);
632 let image_findings: Vec<_> = findings
633 .iter()
634 .filter(|f| f.nodes_involved.contains(&image))
635 .collect();
636 assert!(!image_findings.is_empty());
637 assert_eq!(image_findings[0].severity, Severity::Critical);
638 }
639
640 #[test]
641 fn long_lived_credential_detected() {
642 let mut g = AuthorityGraph::new(source("ci.yml"));
643 g.add_node(NodeKind::Secret, "AWS_ACCESS_KEY_ID", TrustZone::FirstParty);
644 g.add_node(NodeKind::Secret, "NPM_TOKEN", TrustZone::FirstParty);
645 g.add_node(NodeKind::Secret, "DEPLOY_API_KEY", TrustZone::FirstParty);
646 g.add_node(NodeKind::Secret, "CACHE_TTL", TrustZone::FirstParty);
648
649 let findings = long_lived_credential(&g);
650 assert_eq!(findings.len(), 2); assert!(findings
652 .iter()
653 .all(|f| f.category == FindingCategory::LongLivedCredential));
654 }
655
656 #[test]
657 fn duplicate_unpinned_actions_deduplicated() {
658 let mut g = AuthorityGraph::new(source("ci.yml"));
659 g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
661 g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
662 g.add_node(
663 NodeKind::Image,
664 "actions/setup-node@v3",
665 TrustZone::Untrusted,
666 );
667
668 let findings = unpinned_action(&g);
669 assert_eq!(findings.len(), 2);
671 }
672
673 #[test]
674 fn broad_identity_scope_flagged_as_high() {
675 let mut g = AuthorityGraph::new(source("ci.yml"));
676 let mut meta = std::collections::HashMap::new();
677 meta.insert(META_PERMISSIONS.into(), "write-all".into());
678 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
679 let identity = g.add_node_with_metadata(
680 NodeKind::Identity,
681 "GITHUB_TOKEN",
682 TrustZone::FirstParty,
683 meta,
684 );
685 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
686 g.add_edge(step, identity, EdgeKind::HasAccessTo);
687
688 let findings = over_privileged_identity(&g);
689 assert_eq!(findings.len(), 1);
690 assert_eq!(findings[0].severity, Severity::High);
691 assert!(findings[0].message.contains("broad"));
692 }
693
694 #[test]
695 fn unknown_identity_scope_flagged_as_medium() {
696 let mut g = AuthorityGraph::new(source("ci.yml"));
697 let mut meta = std::collections::HashMap::new();
698 meta.insert(META_PERMISSIONS.into(), "custom-scope".into());
699 meta.insert(META_IDENTITY_SCOPE.into(), "unknown".into());
700 let identity = g.add_node_with_metadata(
701 NodeKind::Identity,
702 "GITHUB_TOKEN",
703 TrustZone::FirstParty,
704 meta,
705 );
706 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
707 g.add_edge(step, identity, EdgeKind::HasAccessTo);
708
709 let findings = over_privileged_identity(&g);
710 assert_eq!(findings.len(), 1);
711 assert_eq!(findings[0].severity, Severity::Medium);
712 assert!(findings[0].message.contains("unknown"));
713 }
714
715 #[test]
716 fn floating_image_unpinned_container_flagged() {
717 let mut g = AuthorityGraph::new(source("ci.yml"));
718 let mut meta = std::collections::HashMap::new();
719 meta.insert(META_CONTAINER.into(), "true".into());
720 g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
721
722 let findings = floating_image(&g);
723 assert_eq!(findings.len(), 1);
724 assert_eq!(findings[0].category, FindingCategory::FloatingImage);
725 assert_eq!(findings[0].severity, Severity::Medium);
726 }
727
728 #[test]
729 fn partial_graph_caps_critical_findings_at_high() {
730 let mut g = AuthorityGraph::new(source("ci.yml"));
731 g.mark_partial("matrix strategy hides some authority paths");
732
733 let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
734 let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
735 let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
736
737 g.add_edge(step, identity, EdgeKind::HasAccessTo);
738 g.add_edge(step, image, EdgeKind::UsesImage);
739
740 let findings = run_all_rules(&g, 4);
741 assert!(findings
742 .iter()
743 .any(|f| f.category == FindingCategory::AuthorityPropagation));
744 assert!(findings
745 .iter()
746 .any(|f| f.category == FindingCategory::UntrustedWithAuthority));
747 assert!(findings.iter().all(|f| f.severity >= Severity::High));
748 assert!(!findings.iter().any(|f| f.severity == Severity::Critical));
749 }
750
751 #[test]
752 fn complete_graph_keeps_critical_findings() {
753 let mut g = AuthorityGraph::new(source("ci.yml"));
754
755 let identity = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
756 let step = g.add_node(NodeKind::Step, "deploy", TrustZone::Untrusted);
757 let image = g.add_node(NodeKind::Image, "evil/action@main", TrustZone::Untrusted);
758
759 g.add_edge(step, identity, EdgeKind::HasAccessTo);
760 g.add_edge(step, image, EdgeKind::UsesImage);
761
762 let findings = run_all_rules(&g, 4);
763 assert!(findings.iter().any(|f| f.severity == Severity::Critical));
764 }
765
766 #[test]
767 fn floating_image_digest_pinned_container_not_flagged() {
768 let mut g = AuthorityGraph::new(source("ci.yml"));
769 let mut meta = std::collections::HashMap::new();
770 meta.insert(META_CONTAINER.into(), "true".into());
771 g.add_node_with_metadata(
772 NodeKind::Image,
773 "ubuntu@sha256:a5ac7e51b41094c92402da3b24376905380afc29a5ac7e51b41094c92402da3b",
774 TrustZone::ThirdParty,
775 meta,
776 );
777
778 let findings = floating_image(&g);
779 assert!(
780 findings.is_empty(),
781 "digest-pinned container should not be flagged"
782 );
783 }
784
785 #[test]
786 fn unpinned_action_does_not_flag_container_images() {
787 let mut g = AuthorityGraph::new(source("ci.yml"));
790 let mut meta = std::collections::HashMap::new();
791 meta.insert(META_CONTAINER.into(), "true".into());
792 g.add_node_with_metadata(NodeKind::Image, "ubuntu:22.04", TrustZone::Untrusted, meta);
793
794 let findings = unpinned_action(&g);
795 assert!(
796 findings.is_empty(),
797 "unpinned_action must skip container images to avoid double-flagging"
798 );
799 }
800
801 #[test]
802 fn floating_image_ignores_action_images() {
803 let mut g = AuthorityGraph::new(source("ci.yml"));
804 g.add_node(NodeKind::Image, "actions/checkout@v4", TrustZone::Untrusted);
806
807 let findings = floating_image(&g);
808 assert!(
809 findings.is_empty(),
810 "floating_image should not flag step actions"
811 );
812 }
813
814 #[test]
815 fn persisted_credential_rule_fires_on_persists_to_edge() {
816 let mut g = AuthorityGraph::new(source("ci.yml"));
817 let token = g.add_node(
818 NodeKind::Identity,
819 "System.AccessToken",
820 TrustZone::FirstParty,
821 );
822 let checkout = g.add_node(NodeKind::Step, "checkout", TrustZone::FirstParty);
823 g.add_edge(checkout, token, EdgeKind::PersistsTo);
824
825 let findings = persisted_credential(&g);
826 assert_eq!(findings.len(), 1);
827 assert_eq!(findings[0].category, FindingCategory::PersistedCredential);
828 assert_eq!(findings[0].severity, Severity::High);
829 assert!(findings[0].message.contains("persistCredentials"));
830 }
831
832 #[test]
833 fn untrusted_with_cli_flag_exposed_secret_notes_log_exposure() {
834 let mut g = AuthorityGraph::new(source("ci.yml"));
835 let step = g.add_node(NodeKind::Step, "TerraformCLI@0", TrustZone::Untrusted);
836 let mut meta = std::collections::HashMap::new();
837 meta.insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
838 let secret =
839 g.add_node_with_metadata(NodeKind::Secret, "db_password", TrustZone::FirstParty, meta);
840 g.add_edge(step, secret, EdgeKind::HasAccessTo);
841
842 let findings = untrusted_with_authority(&g);
843 assert_eq!(findings.len(), 1);
844 assert!(
845 findings[0].message.contains("-var flag"),
846 "message should note -var flag log exposure"
847 );
848 assert!(matches!(
849 findings[0].recommendation,
850 Recommendation::Manual { .. }
851 ));
852 }
853
854 #[test]
855 fn constrained_identity_scope_not_flagged() {
856 let mut g = AuthorityGraph::new(source("ci.yml"));
857 let mut meta = std::collections::HashMap::new();
858 meta.insert(META_PERMISSIONS.into(), "{ contents: read }".into());
859 meta.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
860 let identity = g.add_node_with_metadata(
861 NodeKind::Identity,
862 "GITHUB_TOKEN",
863 TrustZone::FirstParty,
864 meta,
865 );
866 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
867 g.add_edge(step, identity, EdgeKind::HasAccessTo);
868
869 let findings = over_privileged_identity(&g);
870 assert!(
871 findings.is_empty(),
872 "constrained scope should not be flagged"
873 );
874 }
875}