1use std::collections::{HashMap, HashSet};
2
3use serde::Deserialize;
4use taudit_core::error::TauditError;
5use taudit_core::graph::*;
6use taudit_core::ports::PipelineParser;
7
8pub struct AdoParser;
10
11impl PipelineParser for AdoParser {
12 fn platform(&self) -> &str {
13 "azure-devops"
14 }
15
16 fn parse(&self, content: &str, source: &PipelineSource) -> Result<AuthorityGraph, TauditError> {
17 let pipeline: AdoPipeline = serde_yaml::from_str(content)
18 .map_err(|e| TauditError::Parse(format!("YAML parse error: {e}")))?;
19
20 let mut graph = AuthorityGraph::new(source.clone());
21 let mut secret_ids: HashMap<String, NodeId> = HashMap::new();
22
23 let mut meta = HashMap::new();
25 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
26 let token_id = graph.add_node_with_metadata(
27 NodeKind::Identity,
28 "System.AccessToken",
29 TrustZone::FirstParty,
30 meta,
31 );
32
33 let mut plain_vars: HashSet<String> = HashSet::new();
37 let pipeline_secret_ids = process_variables(
38 &pipeline.variables,
39 &mut graph,
40 &mut secret_ids,
41 "pipeline",
42 &mut plain_vars,
43 );
44
45 if let Some(ref stages) = pipeline.stages {
47 for stage in stages {
48 if let Some(ref tpl) = stage.template {
50 let stage_name = stage.stage.as_deref().unwrap_or("stage");
51 add_template_delegation(stage_name, tpl, token_id, &mut graph);
52 continue;
53 }
54
55 let stage_name = stage.stage.as_deref().unwrap_or("stage").to_string();
56 let stage_secret_ids = process_variables(
57 &stage.variables,
58 &mut graph,
59 &mut secret_ids,
60 &stage_name,
61 &mut plain_vars,
62 );
63
64 for job in &stage.jobs {
65 let job_name = job.effective_name();
66 let job_secret_ids = process_variables(
67 &job.variables,
68 &mut graph,
69 &mut secret_ids,
70 &job_name,
71 &mut plain_vars,
72 );
73
74 let all_secrets: Vec<NodeId> = pipeline_secret_ids
75 .iter()
76 .chain(&stage_secret_ids)
77 .chain(&job_secret_ids)
78 .copied()
79 .collect();
80
81 process_steps(
82 job.steps.as_deref().unwrap_or(&[]),
83 &job_name,
84 token_id,
85 &all_secrets,
86 &plain_vars,
87 &mut graph,
88 &mut secret_ids,
89 );
90
91 if let Some(ref tpl) = job.template {
92 add_template_delegation(&job_name, tpl, token_id, &mut graph);
93 }
94 }
95 }
96 } else if let Some(ref jobs) = pipeline.jobs {
97 for job in jobs {
98 let job_name = job.effective_name();
99 let job_secret_ids = process_variables(
100 &job.variables,
101 &mut graph,
102 &mut secret_ids,
103 &job_name,
104 &mut plain_vars,
105 );
106
107 let all_secrets: Vec<NodeId> = pipeline_secret_ids
108 .iter()
109 .chain(&job_secret_ids)
110 .copied()
111 .collect();
112
113 process_steps(
114 job.steps.as_deref().unwrap_or(&[]),
115 &job_name,
116 token_id,
117 &all_secrets,
118 &plain_vars,
119 &mut graph,
120 &mut secret_ids,
121 );
122
123 if let Some(ref tpl) = job.template {
124 add_template_delegation(&job_name, tpl, token_id, &mut graph);
125 }
126 }
127 } else if let Some(ref steps) = pipeline.steps {
128 process_steps(
129 steps,
130 "pipeline",
131 token_id,
132 &pipeline_secret_ids,
133 &plain_vars,
134 &mut graph,
135 &mut secret_ids,
136 );
137 }
138
139 Ok(graph)
140 }
141}
142
143fn process_variables(
148 variables: &Option<AdoVariables>,
149 graph: &mut AuthorityGraph,
150 cache: &mut HashMap<String, NodeId>,
151 scope: &str,
152 plain_vars: &mut HashSet<String>,
153) -> Vec<NodeId> {
154 let mut ids = Vec::new();
155
156 let vars = match variables.as_ref() {
157 Some(v) => v,
158 None => return ids,
159 };
160
161 for var in &vars.0 {
162 match var {
163 AdoVariable::Group { group } => {
164 if group.contains("${{") {
168 graph.mark_partial(format!(
169 "variable group in {scope} uses template expression — group name unresolvable at parse time"
170 ));
171 continue;
172 }
173 let mut meta = HashMap::new();
174 meta.insert("variable_group".into(), "true".into());
175 let id = graph.add_node_with_metadata(
176 NodeKind::Secret,
177 group.as_str(),
178 TrustZone::FirstParty,
179 meta,
180 );
181 cache.insert(group.clone(), id);
182 ids.push(id);
183 graph.mark_partial(format!(
184 "variable group '{group}' in {scope} — contents unresolvable without ADO API access"
185 ));
186 }
187 AdoVariable::Named { name, is_secret, .. } => {
188 if *is_secret {
189 let id = find_or_create_secret(graph, cache, name);
190 ids.push(id);
191 } else {
192 plain_vars.insert(name.clone());
193 }
194 }
195 }
196 }
197
198 ids
199}
200
201fn process_steps(
203 steps: &[AdoStep],
204 job_name: &str,
205 token_id: NodeId,
206 inherited_secrets: &[NodeId],
207 plain_vars: &HashSet<String>,
208 graph: &mut AuthorityGraph,
209 cache: &mut HashMap<String, NodeId>,
210) {
211 for (idx, step) in steps.iter().enumerate() {
212 if let Some(ref tpl) = step.template {
214 let step_name = step
215 .display_name
216 .as_deref()
217 .or(step.name.as_deref())
218 .map(|s| s.to_string())
219 .unwrap_or_else(|| format!("{job_name}[{idx}]"));
220 add_template_delegation(&step_name, tpl, token_id, graph);
221 continue;
222 }
223
224 let (step_name, trust_zone, inline_script) = classify_step(step, job_name, idx);
226
227 let step_id = graph.add_node(NodeKind::Step, &step_name, trust_zone);
228
229 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
231
232 if step.checkout.is_some() && step.persist_credentials == Some(true) {
235 graph.add_edge(step_id, token_id, EdgeKind::PersistsTo);
236 }
237
238 for &secret_id in inherited_secrets {
240 graph.add_edge(step_id, secret_id, EdgeKind::HasAccessTo);
241 }
242
243 if let Some(ref inputs) = step.inputs {
245 let service_conn_keys = [
246 "azuresubscription",
247 "connectedservicename",
248 "connectedservicenamearm",
249 "kubernetesserviceconnection",
250 ];
251 for (raw_key, val) in inputs {
252 let lower = raw_key.to_lowercase();
253 if !service_conn_keys.contains(&lower.as_str()) {
254 continue;
255 }
256 let conn_name = yaml_value_as_str(val).unwrap_or(raw_key.as_str());
257 if !conn_name.starts_with("$(") {
258 let mut meta = HashMap::new();
259 meta.insert("service_connection".into(), "true".into());
260 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
261 let conn_id = graph.add_node_with_metadata(
262 NodeKind::Identity,
263 conn_name,
264 TrustZone::FirstParty,
265 meta,
266 );
267 graph.add_edge(step_id, conn_id, EdgeKind::HasAccessTo);
268 }
269 }
270
271 for val in inputs.values() {
273 if let Some(s) = yaml_value_as_str(val) {
274 extract_dollar_paren_secrets(s, step_id, plain_vars, graph, cache);
275 }
276 }
277 }
278
279 if let Some(ref env) = step.env {
281 for val in env.values() {
282 extract_dollar_paren_secrets(val, step_id, plain_vars, graph, cache);
283 }
284 }
285
286 if let Some(ref script) = inline_script {
288 extract_dollar_paren_secrets(script, step_id, plain_vars, graph, cache);
289 }
290 }
291}
292
293fn classify_step(step: &AdoStep, job_name: &str, idx: usize) -> (String, TrustZone, Option<String>) {
295 let default_name = || format!("{job_name}[{idx}]");
296
297 let name = step
298 .display_name
299 .as_deref()
300 .or(step.name.as_deref())
301 .map(|s| s.to_string())
302 .unwrap_or_else(default_name);
303
304 if step.task.is_some() {
305 (name, TrustZone::Untrusted, None)
306 } else if let Some(ref s) = step.script {
307 (name, TrustZone::FirstParty, Some(s.clone()))
308 } else if let Some(ref s) = step.bash {
309 (name, TrustZone::FirstParty, Some(s.clone()))
310 } else if let Some(ref s) = step.powershell {
311 (name, TrustZone::FirstParty, Some(s.clone()))
312 } else if let Some(ref s) = step.pwsh {
313 (name, TrustZone::FirstParty, Some(s.clone()))
314 } else {
315 (name, TrustZone::FirstParty, None)
316 }
317}
318
319fn add_template_delegation(
321 step_name: &str,
322 template_path: &str,
323 token_id: NodeId,
324 graph: &mut AuthorityGraph,
325) {
326 let step_id = graph.add_node(NodeKind::Step, step_name, TrustZone::FirstParty);
327 let tpl_id = graph.add_node(NodeKind::Image, template_path, TrustZone::Untrusted);
328 graph.add_edge(step_id, tpl_id, EdgeKind::DelegatesTo);
329 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
330 graph.mark_partial(format!(
331 "template '{template_path}' cannot be resolved inline — authority within the template is unknown"
332 ));
333}
334
335fn extract_dollar_paren_secrets(
342 text: &str,
343 step_id: NodeId,
344 plain_vars: &HashSet<String>,
345 graph: &mut AuthorityGraph,
346 cache: &mut HashMap<String, NodeId>,
347) {
348 let mut pos = 0;
349 let bytes = text.as_bytes();
350 while pos < bytes.len() {
351 if pos + 2 < bytes.len() && bytes[pos] == b'$' && bytes[pos + 1] == b'(' {
352 let start = pos + 2;
353 if let Some(end_offset) = text[start..].find(')') {
354 let var_name = &text[start..start + end_offset];
355 if is_valid_ado_identifier(var_name)
356 && !is_predefined_ado_var(var_name)
357 && !plain_vars.contains(var_name)
358 {
359 let id = find_or_create_secret(graph, cache, var_name);
360 if is_in_terraform_var_flag(text, pos) {
364 if let Some(node) = graph.nodes.get_mut(id) {
365 node.metadata.insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
366 }
367 }
368 graph.add_edge(step_id, id, EdgeKind::HasAccessTo);
369 }
370 pos = start + end_offset + 1;
371 continue;
372 }
373 }
374 pos += 1;
375 }
376}
377
378fn is_in_terraform_var_flag(text: &str, var_pos: usize) -> bool {
381 let line_start = text[..var_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
382 let line_before = &text[line_start..var_pos];
383 line_before.contains("-var") && line_before.contains('=')
385}
386
387fn is_valid_ado_identifier(name: &str) -> bool {
393 let mut chars = name.chars();
394 match chars.next() {
395 Some(first) if first.is_ascii_alphabetic() => {
396 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
397 }
398 _ => false,
399 }
400}
401
402fn is_predefined_ado_var(name: &str) -> bool {
405 let prefixes = [
406 "Build.",
407 "Agent.",
408 "System.",
409 "Pipeline.",
410 "Release.",
411 "Environment.",
412 "Strategy.",
413 "Deployment.",
414 "Resources.",
415 "TF_BUILD",
416 ];
417 prefixes.iter().any(|p| name.starts_with(p)) || name == "TF_BUILD"
418}
419
420fn find_or_create_secret(
421 graph: &mut AuthorityGraph,
422 cache: &mut HashMap<String, NodeId>,
423 name: &str,
424) -> NodeId {
425 if let Some(&id) = cache.get(name) {
426 return id;
427 }
428 let id = graph.add_node(NodeKind::Secret, name, TrustZone::FirstParty);
429 cache.insert(name.to_string(), id);
430 id
431}
432
433fn yaml_value_as_str(val: &serde_yaml::Value) -> Option<&str> {
434 val.as_str()
435}
436
437#[derive(Debug, Deserialize)]
445pub struct AdoPipeline {
446 #[serde(default)]
447 pub trigger: Option<serde_yaml::Value>,
448 #[serde(default)]
449 pub pr: Option<serde_yaml::Value>,
450 #[serde(default)]
451 pub variables: Option<AdoVariables>,
452 #[serde(default)]
453 pub stages: Option<Vec<AdoStage>>,
454 #[serde(default)]
455 pub jobs: Option<Vec<AdoJob>>,
456 #[serde(default)]
457 pub steps: Option<Vec<AdoStep>>,
458 #[serde(default)]
459 pub pool: Option<serde_yaml::Value>,
460}
461
462#[derive(Debug, Deserialize)]
463pub struct AdoStage {
464 #[serde(default)]
466 pub stage: Option<String>,
467 #[serde(default)]
469 pub template: Option<String>,
470 #[serde(default)]
471 pub variables: Option<AdoVariables>,
472 #[serde(default)]
473 pub jobs: Vec<AdoJob>,
474}
475
476#[derive(Debug, Deserialize)]
477pub struct AdoJob {
478 #[serde(default)]
480 pub job: Option<String>,
481 #[serde(default)]
483 pub deployment: Option<String>,
484 #[serde(default)]
485 pub variables: Option<AdoVariables>,
486 #[serde(default)]
487 pub steps: Option<Vec<AdoStep>>,
488 #[serde(default)]
489 pub pool: Option<serde_yaml::Value>,
490 #[serde(default)]
492 pub template: Option<String>,
493}
494
495impl AdoJob {
496 pub fn effective_name(&self) -> String {
497 self.job
498 .as_deref()
499 .or(self.deployment.as_deref())
500 .unwrap_or("job")
501 .to_string()
502 }
503}
504
505#[derive(Debug, Deserialize)]
506pub struct AdoStep {
507 #[serde(default)]
509 pub task: Option<String>,
510 #[serde(default)]
512 pub script: Option<String>,
513 #[serde(default)]
515 pub bash: Option<String>,
516 #[serde(default)]
518 pub powershell: Option<String>,
519 #[serde(default)]
521 pub pwsh: Option<String>,
522 #[serde(default)]
524 pub template: Option<String>,
525 #[serde(rename = "displayName", default)]
526 pub display_name: Option<String>,
527 #[serde(default)]
529 pub name: Option<String>,
530 #[serde(default)]
531 pub env: Option<HashMap<String, String>>,
532 #[serde(default)]
534 pub inputs: Option<HashMap<String, serde_yaml::Value>>,
535 #[serde(default)]
537 pub checkout: Option<String>,
538 #[serde(rename = "persistCredentials", default)]
540 pub persist_credentials: Option<bool>,
541}
542
543#[derive(Debug, Default)]
546pub struct AdoVariables(pub Vec<AdoVariable>);
547
548impl<'de> serde::Deserialize<'de> for AdoVariables {
549 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
550 where
551 D: serde::Deserializer<'de>,
552 {
553 let raw = serde_yaml::Value::deserialize(deserializer)?;
554 let mut vars = Vec::new();
555
556 match raw {
557 serde_yaml::Value::Sequence(seq) => {
558 for item in seq {
559 if let Some(map) = item.as_mapping() {
560 if let Some(group_val) = map.get("group") {
561 if let Some(group) = group_val.as_str() {
562 vars.push(AdoVariable::Group {
563 group: group.to_string(),
564 });
565 continue;
566 }
567 }
568 let name = map.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
569 let value = map.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
570 let is_secret = map
571 .get("isSecret")
572 .and_then(|v| v.as_bool())
573 .unwrap_or(false);
574 vars.push(AdoVariable::Named { name, value, is_secret });
575 }
576 }
577 }
578 serde_yaml::Value::Mapping(map) => {
579 for (k, v) in map {
580 let name = k.as_str().unwrap_or("").to_string();
581 let value = v.as_str().unwrap_or("").to_string();
582 vars.push(AdoVariable::Named { name, value, is_secret: false });
583 }
584 }
585 _ => {}
586 }
587
588 Ok(AdoVariables(vars))
589 }
590}
591
592#[derive(Debug)]
593pub enum AdoVariable {
594 Group { group: String },
595 Named { name: String, value: String, is_secret: bool },
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 fn parse(yaml: &str) -> AuthorityGraph {
603 let parser = AdoParser;
604 let source = PipelineSource {
605 file: "azure-pipelines.yml".into(),
606 repo: None,
607 git_ref: None,
608 };
609 parser.parse(yaml, &source).unwrap()
610 }
611
612 #[test]
613 fn parses_simple_pipeline() {
614 let yaml = r#"
615trigger:
616 - main
617
618jobs:
619 - job: Build
620 steps:
621 - script: echo hello
622 displayName: Say hello
623"#;
624 let graph = parse(yaml);
625 assert!(graph.nodes.len() >= 2); }
627
628 #[test]
629 fn system_access_token_created() {
630 let yaml = r#"
631steps:
632 - script: echo hi
633"#;
634 let graph = parse(yaml);
635 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
636 assert_eq!(identities.len(), 1);
637 assert_eq!(identities[0].name, "System.AccessToken");
638 assert_eq!(
639 identities[0].metadata.get(META_IDENTITY_SCOPE),
640 Some(&"broad".to_string())
641 );
642 }
643
644 #[test]
645 fn variable_group_creates_secret_and_marks_partial() {
646 let yaml = r#"
647variables:
648 - group: MySecretGroup
649
650steps:
651 - script: echo hi
652"#;
653 let graph = parse(yaml);
654 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
655 assert_eq!(secrets.len(), 1);
656 assert_eq!(secrets[0].name, "MySecretGroup");
657 assert_eq!(
658 secrets[0].metadata.get("variable_group"),
659 Some(&"true".to_string())
660 );
661 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
662 assert!(
663 graph.completeness_gaps.iter().any(|g| g.contains("MySecretGroup")),
664 "completeness gap should name the variable group"
665 );
666 }
667
668 #[test]
669 fn task_with_azure_subscription_creates_service_connection_identity() {
670 let yaml = r#"
671steps:
672 - task: AzureCLI@2
673 displayName: Deploy to Azure
674 inputs:
675 azureSubscription: MyServiceConnection
676 scriptType: bash
677 inlineScript: az group list
678"#;
679 let graph = parse(yaml);
680 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
681 assert_eq!(identities.len(), 2);
683 let conn = identities.iter().find(|i| i.name == "MyServiceConnection").unwrap();
684 assert_eq!(
685 conn.metadata.get("service_connection"),
686 Some(&"true".to_string())
687 );
688 assert_eq!(
689 conn.metadata.get(META_IDENTITY_SCOPE),
690 Some(&"broad".to_string())
691 );
692 }
693
694 #[test]
695 fn task_with_connected_service_name_creates_identity() {
696 let yaml = r#"
697steps:
698 - task: SqlAzureDacpacDeployment@1
699 inputs:
700 ConnectedServiceNameARM: MySqlConnection
701"#;
702 let graph = parse(yaml);
703 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
704 assert!(
705 identities.iter().any(|i| i.name == "MySqlConnection"),
706 "connectedServiceNameARM should create identity"
707 );
708 }
709
710 #[test]
711 fn script_step_classified_as_first_party() {
712 let yaml = r#"
713steps:
714 - script: echo hi
715 displayName: Say hi
716"#;
717 let graph = parse(yaml);
718 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
719 assert_eq!(steps.len(), 1);
720 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
721 }
722
723 #[test]
724 fn bash_step_classified_as_first_party() {
725 let yaml = r#"
726steps:
727 - bash: echo hi
728"#;
729 let graph = parse(yaml);
730 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
731 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
732 }
733
734 #[test]
735 fn task_step_classified_as_untrusted() {
736 let yaml = r#"
737steps:
738 - task: DotNetCoreCLI@2
739 inputs:
740 command: build
741"#;
742 let graph = parse(yaml);
743 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
744 assert_eq!(steps.len(), 1);
745 assert_eq!(steps[0].trust_zone, TrustZone::Untrusted);
746 }
747
748 #[test]
749 fn dollar_paren_var_in_script_creates_secret() {
750 let yaml = r#"
751steps:
752 - script: |
753 curl -H "Authorization: $(MY_API_TOKEN)" https://api.example.com
754 displayName: Call API
755"#;
756 let graph = parse(yaml);
757 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
758 assert_eq!(secrets.len(), 1);
759 assert_eq!(secrets[0].name, "MY_API_TOKEN");
760 }
761
762 #[test]
763 fn predefined_ado_var_not_treated_as_secret() {
764 let yaml = r#"
765steps:
766 - script: |
767 echo $(Build.BuildId)
768 echo $(Agent.WorkFolder)
769 echo $(System.DefaultWorkingDirectory)
770 displayName: Print vars
771"#;
772 let graph = parse(yaml);
773 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
774 assert!(
775 secrets.is_empty(),
776 "predefined ADO vars should not be treated as secrets, got: {:?}",
777 secrets.iter().map(|s| &s.name).collect::<Vec<_>>()
778 );
779 }
780
781 #[test]
782 fn template_reference_creates_delegates_to_and_marks_partial() {
783 let yaml = r#"
784steps:
785 - template: steps/deploy.yml
786 parameters:
787 env: production
788"#;
789 let graph = parse(yaml);
790 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
791 assert_eq!(steps.len(), 1);
792
793 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
794 assert_eq!(images.len(), 1);
795 assert_eq!(images[0].name, "steps/deploy.yml");
796
797 let delegates: Vec<_> = graph
798 .edges_from(steps[0].id)
799 .filter(|e| e.kind == EdgeKind::DelegatesTo)
800 .collect();
801 assert_eq!(delegates.len(), 1);
802
803 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
804 }
805
806 #[test]
807 fn top_level_steps_no_jobs() {
808 let yaml = r#"
809steps:
810 - script: echo a
811 - script: echo b
812"#;
813 let graph = parse(yaml);
814 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
815 assert_eq!(steps.len(), 2);
816 }
817
818 #[test]
819 fn top_level_jobs_no_stages() {
820 let yaml = r#"
821jobs:
822 - job: JobA
823 steps:
824 - script: echo a
825 - job: JobB
826 steps:
827 - script: echo b
828"#;
829 let graph = parse(yaml);
830 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
831 assert_eq!(steps.len(), 2);
832 }
833
834 #[test]
835 fn stages_with_nested_jobs_parsed() {
836 let yaml = r#"
837stages:
838 - stage: Build
839 jobs:
840 - job: Compile
841 steps:
842 - script: cargo build
843 - stage: Test
844 jobs:
845 - job: UnitTest
846 steps:
847 - script: cargo test
848"#;
849 let graph = parse(yaml);
850 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
851 assert_eq!(steps.len(), 2);
852 }
853
854 #[test]
855 fn all_steps_linked_to_system_access_token() {
856 let yaml = r#"
857steps:
858 - script: echo a
859 - task: SomeTask@1
860 inputs: {}
861"#;
862 let graph = parse(yaml);
863 let token: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
864 assert_eq!(token.len(), 1);
865 let token_id = token[0].id;
866
867 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
868 for step in &steps {
869 let links: Vec<_> = graph
870 .edges_from(step.id)
871 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == token_id)
872 .collect();
873 assert_eq!(links.len(), 1, "step '{}' must link to System.AccessToken", step.name);
874 }
875 }
876
877 #[test]
878 fn named_secret_variable_creates_secret_node() {
879 let yaml = r#"
880variables:
881 - name: MY_PASSWORD
882 value: dummy
883 isSecret: true
884
885steps:
886 - script: echo hi
887"#;
888 let graph = parse(yaml);
889 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
890 assert_eq!(secrets.len(), 1);
891 assert_eq!(secrets[0].name, "MY_PASSWORD");
892 }
893
894 #[test]
895 fn variables_as_mapping_parsed() {
896 let yaml = r#"
897variables:
898 MY_VAR: hello
899 ANOTHER_VAR: world
900
901steps:
902 - script: echo hi
903"#;
904 let graph = parse(yaml);
905 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
907 assert!(secrets.is_empty(), "plain mapping vars should not create secret nodes");
908 }
909
910 #[test]
911 fn persist_credentials_creates_persists_to_edge() {
912 let yaml = r#"
913steps:
914 - checkout: self
915 persistCredentials: true
916 - script: git push
917"#;
918 let graph = parse(yaml);
919 let token_id = graph
920 .nodes_of_kind(NodeKind::Identity)
921 .find(|n| n.name == "System.AccessToken")
922 .expect("System.AccessToken must exist")
923 .id;
924
925 let persists_edges: Vec<_> = graph
926 .edges
927 .iter()
928 .filter(|e| e.kind == EdgeKind::PersistsTo && e.to == token_id)
929 .collect();
930 assert_eq!(
931 persists_edges.len(),
932 1,
933 "checkout with persistCredentials: true must produce exactly one PersistsTo edge"
934 );
935 }
936
937 #[test]
938 fn checkout_without_persist_credentials_no_persists_to_edge() {
939 let yaml = r#"
940steps:
941 - checkout: self
942 - script: echo hi
943"#;
944 let graph = parse(yaml);
945 let persists_edges: Vec<_> = graph
946 .edges
947 .iter()
948 .filter(|e| e.kind == EdgeKind::PersistsTo)
949 .collect();
950 assert!(
951 persists_edges.is_empty(),
952 "checkout without persistCredentials should not produce PersistsTo edge"
953 );
954 }
955
956 #[test]
957 fn var_flag_secret_marked_as_cli_flag_exposed() {
958 let yaml = r#"
959steps:
960 - script: |
961 terraform apply \
962 -var "db_password=$(db_password)" \
963 -var "api_key=$(api_key)"
964 displayName: Terraform apply
965"#;
966 let graph = parse(yaml);
967 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
968 assert!(!secrets.is_empty(), "should detect secrets from -var flags");
969 for secret in &secrets {
970 assert_eq!(
971 secret.metadata.get(META_CLI_FLAG_EXPOSED),
972 Some(&"true".to_string()),
973 "secret '{}' passed via -var flag should be marked cli_flag_exposed",
974 secret.name
975 );
976 }
977 }
978
979 #[test]
980 fn non_var_flag_secret_not_marked_as_cli_flag_exposed() {
981 let yaml = r#"
982steps:
983 - script: |
984 curl -H "Authorization: $(MY_TOKEN)" https://api.example.com
985"#;
986 let graph = parse(yaml);
987 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
988 assert_eq!(secrets.len(), 1);
989 assert!(
990 secrets[0].metadata.get(META_CLI_FLAG_EXPOSED).is_none(),
991 "non -var secret should not be marked as cli_flag_exposed"
992 );
993 }
994
995 #[test]
996 fn step_linked_to_variable_group_secret() {
997 let yaml = r#"
998variables:
999 - group: ProdSecrets
1000
1001steps:
1002 - script: deploy.sh
1003"#;
1004 let graph = parse(yaml);
1005 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1006 assert_eq!(secrets.len(), 1);
1007 let secret_id = secrets[0].id;
1008
1009 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1010 let links: Vec<_> = graph
1011 .edges_from(steps[0].id)
1012 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == secret_id)
1013 .collect();
1014 assert_eq!(links.len(), 1, "step should be linked to variable group secret");
1015 }
1016}