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 mut de = serde_yaml::Deserializer::from_str(content);
18 let doc = de
19 .next()
20 .ok_or_else(|| TauditError::Parse("empty YAML document".into()))?;
21 let pipeline: AdoPipeline = AdoPipeline::deserialize(doc)
22 .map_err(|e| TauditError::Parse(format!("YAML parse error: {e}")))?;
23 let extra_docs = de.next().is_some();
24
25 let mut graph = AuthorityGraph::new(source.clone());
26 if extra_docs {
27 graph.mark_partial(
28 "file contains multiple YAML documents (--- separator) — only the first was analyzed".to_string(),
29 );
30 }
31
32 let has_pr_trigger = pipeline.pr.is_some();
34 if has_pr_trigger {
35 graph.metadata.insert(META_TRIGGER.into(), "pr".into());
36 }
37
38 let mut secret_ids: HashMap<String, NodeId> = HashMap::new();
39
40 let mut meta = HashMap::new();
44 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
45 meta.insert(META_IMPLICIT.into(), "true".into());
46 let token_id = graph.add_node_with_metadata(
47 NodeKind::Identity,
48 "System.AccessToken",
49 TrustZone::FirstParty,
50 meta,
51 );
52
53 process_pool(&pipeline.pool, &mut graph);
55
56 let mut plain_vars: HashSet<String> = HashSet::new();
60 let pipeline_secret_ids = process_variables(
61 &pipeline.variables,
62 &mut graph,
63 &mut secret_ids,
64 "pipeline",
65 &mut plain_vars,
66 );
67
68 if let Some(ref stages) = pipeline.stages {
70 for stage in stages {
71 if let Some(ref tpl) = stage.template {
73 let stage_name = stage.stage.as_deref().unwrap_or("stage");
74 add_template_delegation(stage_name, tpl, token_id, None, &mut graph);
75 continue;
76 }
77
78 let stage_name = stage.stage.as_deref().unwrap_or("stage").to_string();
79 let stage_secret_ids = process_variables(
80 &stage.variables,
81 &mut graph,
82 &mut secret_ids,
83 &stage_name,
84 &mut plain_vars,
85 );
86
87 for job in &stage.jobs {
88 let job_name = job.effective_name();
89 let job_secret_ids = process_variables(
90 &job.variables,
91 &mut graph,
92 &mut secret_ids,
93 &job_name,
94 &mut plain_vars,
95 );
96
97 process_pool(&job.pool, &mut graph);
98
99 let all_secrets: Vec<NodeId> = pipeline_secret_ids
100 .iter()
101 .chain(&stage_secret_ids)
102 .chain(&job_secret_ids)
103 .copied()
104 .collect();
105
106 let steps_start = graph.nodes.len();
107
108 process_steps(
109 job.steps.as_deref().unwrap_or(&[]),
110 &job_name,
111 token_id,
112 &all_secrets,
113 &plain_vars,
114 &mut graph,
115 &mut secret_ids,
116 );
117
118 if let Some(ref tpl) = job.template {
119 add_template_delegation(
120 &job_name,
121 tpl,
122 token_id,
123 Some(&job_name),
124 &mut graph,
125 );
126 }
127
128 if job.has_environment_binding() {
129 tag_job_steps_env_approval(&mut graph, steps_start);
130 }
131 }
132 }
133 } else if let Some(ref jobs) = pipeline.jobs {
134 for job in jobs {
135 let job_name = job.effective_name();
136 let job_secret_ids = process_variables(
137 &job.variables,
138 &mut graph,
139 &mut secret_ids,
140 &job_name,
141 &mut plain_vars,
142 );
143
144 process_pool(&job.pool, &mut graph);
145
146 let all_secrets: Vec<NodeId> = pipeline_secret_ids
147 .iter()
148 .chain(&job_secret_ids)
149 .copied()
150 .collect();
151
152 let steps_start = graph.nodes.len();
153
154 process_steps(
155 job.steps.as_deref().unwrap_or(&[]),
156 &job_name,
157 token_id,
158 &all_secrets,
159 &plain_vars,
160 &mut graph,
161 &mut secret_ids,
162 );
163
164 if let Some(ref tpl) = job.template {
165 add_template_delegation(&job_name, tpl, token_id, Some(&job_name), &mut graph);
166 }
167
168 if job.has_environment_binding() {
169 tag_job_steps_env_approval(&mut graph, steps_start);
170 }
171 }
172 } else if let Some(ref steps) = pipeline.steps {
173 process_steps(
174 steps,
175 "pipeline",
176 token_id,
177 &pipeline_secret_ids,
178 &plain_vars,
179 &mut graph,
180 &mut secret_ids,
181 );
182 }
183
184 Ok(graph)
185 }
186}
187
188fn process_pool(pool: &Option<serde_yaml::Value>, graph: &mut AuthorityGraph) {
197 let Some(pool_val) = pool else {
198 return;
199 };
200
201 let (image_name, is_self_hosted) = match pool_val {
202 serde_yaml::Value::String(s) => (s.clone(), true),
203 serde_yaml::Value::Mapping(map) => {
204 let name = map.get("name").and_then(|v| v.as_str());
205 let vm_image = map.get("vmImage").and_then(|v| v.as_str());
206 match (name, vm_image) {
207 (_, Some(vm)) => (vm.to_string(), false),
208 (Some(n), None) => (n.to_string(), true),
209 (None, None) => return,
210 }
211 }
212 _ => return,
213 };
214
215 let mut meta = HashMap::new();
216 if is_self_hosted {
217 meta.insert(META_SELF_HOSTED.into(), "true".into());
218 }
219 graph.add_node_with_metadata(NodeKind::Image, image_name, TrustZone::FirstParty, meta);
220}
221
222fn tag_job_steps_env_approval(graph: &mut AuthorityGraph, start_idx: usize) {
227 for node in graph.nodes.iter_mut().skip(start_idx) {
228 if node.kind == NodeKind::Step {
229 node.metadata
230 .insert(META_ENV_APPROVAL.into(), "true".into());
231 }
232 }
233}
234
235fn process_variables(
240 variables: &Option<AdoVariables>,
241 graph: &mut AuthorityGraph,
242 cache: &mut HashMap<String, NodeId>,
243 scope: &str,
244 plain_vars: &mut HashSet<String>,
245) -> Vec<NodeId> {
246 let mut ids = Vec::new();
247
248 let vars = match variables.as_ref() {
249 Some(v) => v,
250 None => return ids,
251 };
252
253 for var in &vars.0 {
254 match var {
255 AdoVariable::Group { group } => {
256 if group.contains("${{") {
260 graph.mark_partial(format!(
261 "variable group in {scope} uses template expression — group name unresolvable at parse time"
262 ));
263 continue;
264 }
265 let mut meta = HashMap::new();
266 meta.insert(META_VARIABLE_GROUP.into(), "true".into());
267 let id = graph.add_node_with_metadata(
268 NodeKind::Secret,
269 group.as_str(),
270 TrustZone::FirstParty,
271 meta,
272 );
273 cache.insert(group.clone(), id);
274 ids.push(id);
275 graph.mark_partial(format!(
276 "variable group '{group}' in {scope} — contents unresolvable without ADO API access"
277 ));
278 }
279 AdoVariable::Named {
280 name, is_secret, ..
281 } => {
282 if *is_secret {
283 let id = find_or_create_secret(graph, cache, name);
284 ids.push(id);
285 } else {
286 plain_vars.insert(name.clone());
287 }
288 }
289 }
290 }
291
292 ids
293}
294
295fn process_steps(
297 steps: &[AdoStep],
298 job_name: &str,
299 token_id: NodeId,
300 inherited_secrets: &[NodeId],
301 plain_vars: &HashSet<String>,
302 graph: &mut AuthorityGraph,
303 cache: &mut HashMap<String, NodeId>,
304) {
305 for (idx, step) in steps.iter().enumerate() {
306 if let Some(ref tpl) = step.template {
308 let step_name = step
309 .display_name
310 .as_deref()
311 .or(step.name.as_deref())
312 .map(|s| s.to_string())
313 .unwrap_or_else(|| format!("{job_name}[{idx}]"));
314 add_template_delegation(&step_name, tpl, token_id, Some(job_name), graph);
315 continue;
316 }
317
318 let (step_name, trust_zone, inline_script) = classify_step(step, job_name, idx);
320
321 let step_id = graph.add_node(NodeKind::Step, &step_name, trust_zone);
322
323 if let Some(node) = graph.nodes.get_mut(step_id) {
326 node.metadata.insert(META_JOB_NAME.into(), job_name.into());
327 }
328
329 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
331
332 if step.checkout.is_some() && step.persist_credentials == Some(true) {
335 graph.add_edge(step_id, token_id, EdgeKind::PersistsTo);
336 }
337
338 if let Some(ref ck) = step.checkout {
342 if ck == "self" {
343 if let Some(node) = graph.nodes.get_mut(step_id) {
344 node.metadata
345 .insert(META_CHECKOUT_SELF.into(), "true".into());
346 }
347 }
348 }
349
350 for &secret_id in inherited_secrets {
352 graph.add_edge(step_id, secret_id, EdgeKind::HasAccessTo);
353 }
354
355 if let Some(ref inputs) = step.inputs {
357 let service_conn_keys = [
358 "azuresubscription",
359 "connectedservicename",
360 "connectedservicenamearm",
361 "kubernetesserviceconnection",
362 ];
363 for (raw_key, val) in inputs {
364 let lower = raw_key.to_lowercase();
365 if !service_conn_keys.contains(&lower.as_str()) {
366 continue;
367 }
368 let conn_name = yaml_value_as_str(val).unwrap_or(raw_key.as_str());
369 if !conn_name.starts_with("$(") {
370 let mut meta = HashMap::new();
371 meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
372 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
373 meta.insert(META_OIDC.into(), "true".into());
378 let conn_id = graph.add_node_with_metadata(
379 NodeKind::Identity,
380 conn_name,
381 TrustZone::FirstParty,
382 meta,
383 );
384 graph.add_edge(step_id, conn_id, EdgeKind::HasAccessTo);
385 }
386 }
387
388 for val in inputs.values() {
390 if let Some(s) = yaml_value_as_str(val) {
391 extract_dollar_paren_secrets(s, step_id, plain_vars, graph, cache);
392 }
393 }
394 }
395
396 if let Some(ref env) = step.env {
398 for val in env.values() {
399 extract_dollar_paren_secrets(val, step_id, plain_vars, graph, cache);
400 }
401 }
402
403 if let Some(ref script) = inline_script {
405 extract_dollar_paren_secrets(script, step_id, plain_vars, graph, cache);
406 }
407
408 if let Some(ref script) = inline_script {
410 let lower = script.to_lowercase();
411 if lower.contains("##vso[task.setvariable") {
412 if let Some(node) = graph.nodes.get_mut(step_id) {
413 node.metadata
414 .insert(META_WRITES_ENV_GATE.into(), "true".into());
415 }
416 }
417 }
418 }
419}
420
421fn classify_step(
423 step: &AdoStep,
424 job_name: &str,
425 idx: usize,
426) -> (String, TrustZone, Option<String>) {
427 let default_name = || format!("{job_name}[{idx}]");
428
429 let name = step
430 .display_name
431 .as_deref()
432 .or(step.name.as_deref())
433 .map(|s| s.to_string())
434 .unwrap_or_else(default_name);
435
436 if step.task.is_some() {
437 (name, TrustZone::Untrusted, None)
438 } else if let Some(ref s) = step.script {
439 (name, TrustZone::FirstParty, Some(s.clone()))
440 } else if let Some(ref s) = step.bash {
441 (name, TrustZone::FirstParty, Some(s.clone()))
442 } else if let Some(ref s) = step.powershell {
443 (name, TrustZone::FirstParty, Some(s.clone()))
444 } else if let Some(ref s) = step.pwsh {
445 (name, TrustZone::FirstParty, Some(s.clone()))
446 } else {
447 (name, TrustZone::FirstParty, None)
448 }
449}
450
451fn add_template_delegation(
462 step_name: &str,
463 template_path: &str,
464 token_id: NodeId,
465 job_name: Option<&str>,
466 graph: &mut AuthorityGraph,
467) {
468 let tpl_trust_zone = if template_path.contains('@') {
469 TrustZone::Untrusted
470 } else {
471 TrustZone::FirstParty
472 };
473 let step_id = graph.add_node(NodeKind::Step, step_name, TrustZone::FirstParty);
474 if let Some(jn) = job_name {
475 if let Some(node) = graph.nodes.get_mut(step_id) {
476 node.metadata.insert(META_JOB_NAME.into(), jn.into());
477 }
478 }
479 let tpl_id = graph.add_node(NodeKind::Image, template_path, tpl_trust_zone);
480 graph.add_edge(step_id, tpl_id, EdgeKind::DelegatesTo);
481 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
482 graph.mark_partial(format!(
483 "template '{template_path}' cannot be resolved inline — authority within the template is unknown"
484 ));
485}
486
487fn extract_dollar_paren_secrets(
494 text: &str,
495 step_id: NodeId,
496 plain_vars: &HashSet<String>,
497 graph: &mut AuthorityGraph,
498 cache: &mut HashMap<String, NodeId>,
499) {
500 let mut pos = 0;
501 let bytes = text.as_bytes();
502 while pos < bytes.len() {
503 if pos + 2 < bytes.len() && bytes[pos] == b'$' && bytes[pos + 1] == b'(' {
504 let start = pos + 2;
505 if let Some(end_offset) = text[start..].find(')') {
506 let var_name = &text[start..start + end_offset];
507 if is_valid_ado_identifier(var_name)
508 && !is_predefined_ado_var(var_name)
509 && !plain_vars.contains(var_name)
510 {
511 let id = find_or_create_secret(graph, cache, var_name);
512 if is_in_terraform_var_flag(text, pos) {
516 if let Some(node) = graph.nodes.get_mut(id) {
517 node.metadata
518 .insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
519 }
520 }
521 graph.add_edge(step_id, id, EdgeKind::HasAccessTo);
522 }
523 pos = start + end_offset + 1;
524 continue;
525 }
526 }
527 pos += 1;
528 }
529}
530
531fn is_in_terraform_var_flag(text: &str, var_pos: usize) -> bool {
534 let line_start = text[..var_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
535 let line_before = &text[line_start..var_pos];
536 line_before.contains("-var") && line_before.contains('=')
538}
539
540fn is_valid_ado_identifier(name: &str) -> bool {
546 let mut chars = name.chars();
547 match chars.next() {
548 Some(first) if first.is_ascii_alphabetic() => {
549 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
550 }
551 _ => false,
552 }
553}
554
555fn is_predefined_ado_var(name: &str) -> bool {
558 let prefixes = [
559 "Build.",
560 "Agent.",
561 "System.",
562 "Pipeline.",
563 "Release.",
564 "Environment.",
565 "Strategy.",
566 "Deployment.",
567 "Resources.",
568 "TF_BUILD",
569 ];
570 prefixes.iter().any(|p| name.starts_with(p)) || name == "TF_BUILD"
571}
572
573fn find_or_create_secret(
574 graph: &mut AuthorityGraph,
575 cache: &mut HashMap<String, NodeId>,
576 name: &str,
577) -> NodeId {
578 if let Some(&id) = cache.get(name) {
579 return id;
580 }
581 let id = graph.add_node(NodeKind::Secret, name, TrustZone::FirstParty);
582 cache.insert(name.to_string(), id);
583 id
584}
585
586fn yaml_value_as_str(val: &serde_yaml::Value) -> Option<&str> {
587 val.as_str()
588}
589
590#[derive(Debug, Deserialize)]
598pub struct AdoPipeline {
599 #[serde(default)]
600 pub trigger: Option<serde_yaml::Value>,
601 #[serde(default)]
602 pub pr: Option<serde_yaml::Value>,
603 #[serde(default)]
604 pub variables: Option<AdoVariables>,
605 #[serde(default)]
606 pub stages: Option<Vec<AdoStage>>,
607 #[serde(default)]
608 pub jobs: Option<Vec<AdoJob>>,
609 #[serde(default)]
610 pub steps: Option<Vec<AdoStep>>,
611 #[serde(default)]
612 pub pool: Option<serde_yaml::Value>,
613}
614
615#[derive(Debug, Deserialize)]
616pub struct AdoStage {
617 #[serde(default)]
619 pub stage: Option<String>,
620 #[serde(default)]
622 pub template: Option<String>,
623 #[serde(default)]
624 pub variables: Option<AdoVariables>,
625 #[serde(default)]
626 pub jobs: Vec<AdoJob>,
627}
628
629#[derive(Debug, Deserialize)]
630pub struct AdoJob {
631 #[serde(default)]
633 pub job: Option<String>,
634 #[serde(default)]
636 pub deployment: Option<String>,
637 #[serde(default)]
638 pub variables: Option<AdoVariables>,
639 #[serde(default)]
640 pub steps: Option<Vec<AdoStep>>,
641 #[serde(default)]
642 pub pool: Option<serde_yaml::Value>,
643 #[serde(default)]
645 pub template: Option<String>,
646 #[serde(default)]
658 pub environment: Option<serde_yaml::Value>,
659}
660
661impl AdoJob {
662 pub fn effective_name(&self) -> String {
663 self.job
664 .as_deref()
665 .or(self.deployment.as_deref())
666 .unwrap_or("job")
667 .to_string()
668 }
669
670 pub fn has_environment_binding(&self) -> bool {
674 match self.environment.as_ref() {
675 None => false,
676 Some(serde_yaml::Value::String(s)) => !s.trim().is_empty(),
677 Some(serde_yaml::Value::Mapping(m)) => m
678 .get("name")
679 .and_then(|v| v.as_str())
680 .map(|s| !s.trim().is_empty())
681 .unwrap_or(false),
682 _ => false,
683 }
684 }
685}
686
687#[derive(Debug, Deserialize)]
688pub struct AdoStep {
689 #[serde(default)]
691 pub task: Option<String>,
692 #[serde(default)]
694 pub script: Option<String>,
695 #[serde(default)]
697 pub bash: Option<String>,
698 #[serde(default)]
700 pub powershell: Option<String>,
701 #[serde(default)]
703 pub pwsh: Option<String>,
704 #[serde(default)]
706 pub template: Option<String>,
707 #[serde(rename = "displayName", default)]
708 pub display_name: Option<String>,
709 #[serde(default)]
711 pub name: Option<String>,
712 #[serde(default)]
713 pub env: Option<HashMap<String, String>>,
714 #[serde(default)]
716 pub inputs: Option<HashMap<String, serde_yaml::Value>>,
717 #[serde(default)]
719 pub checkout: Option<String>,
720 #[serde(rename = "persistCredentials", default)]
722 pub persist_credentials: Option<bool>,
723}
724
725#[derive(Debug, Default)]
728pub struct AdoVariables(pub Vec<AdoVariable>);
729
730impl<'de> serde::Deserialize<'de> for AdoVariables {
731 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
732 where
733 D: serde::Deserializer<'de>,
734 {
735 let raw = serde_yaml::Value::deserialize(deserializer)?;
736 let mut vars = Vec::new();
737
738 match raw {
739 serde_yaml::Value::Sequence(seq) => {
740 for item in seq {
741 if let Some(map) = item.as_mapping() {
742 if let Some(group_val) = map.get("group") {
743 if let Some(group) = group_val.as_str() {
744 vars.push(AdoVariable::Group {
745 group: group.to_string(),
746 });
747 continue;
748 }
749 }
750 let name = map
751 .get("name")
752 .and_then(|v| v.as_str())
753 .unwrap_or("")
754 .to_string();
755 let value = map
756 .get("value")
757 .and_then(|v| v.as_str())
758 .unwrap_or("")
759 .to_string();
760 let is_secret = map
761 .get("isSecret")
762 .and_then(|v| v.as_bool())
763 .unwrap_or(false);
764 vars.push(AdoVariable::Named {
765 name,
766 value,
767 is_secret,
768 });
769 }
770 }
771 }
772 serde_yaml::Value::Mapping(map) => {
773 for (k, v) in map {
774 let name = k.as_str().unwrap_or("").to_string();
775 let value = v.as_str().unwrap_or("").to_string();
776 vars.push(AdoVariable::Named {
777 name,
778 value,
779 is_secret: false,
780 });
781 }
782 }
783 _ => {}
784 }
785
786 Ok(AdoVariables(vars))
787 }
788}
789
790#[derive(Debug)]
791pub enum AdoVariable {
792 Group {
793 group: String,
794 },
795 Named {
796 name: String,
797 value: String,
798 is_secret: bool,
799 },
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 fn parse(yaml: &str) -> AuthorityGraph {
807 let parser = AdoParser;
808 let source = PipelineSource {
809 file: "azure-pipelines.yml".into(),
810 repo: None,
811 git_ref: None,
812 };
813 parser.parse(yaml, &source).unwrap()
814 }
815
816 #[test]
817 fn parses_simple_pipeline() {
818 let yaml = r#"
819trigger:
820 - main
821
822jobs:
823 - job: Build
824 steps:
825 - script: echo hello
826 displayName: Say hello
827"#;
828 let graph = parse(yaml);
829 assert!(graph.nodes.len() >= 2); }
831
832 #[test]
833 fn system_access_token_created() {
834 let yaml = r#"
835steps:
836 - script: echo hi
837"#;
838 let graph = parse(yaml);
839 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
840 assert_eq!(identities.len(), 1);
841 assert_eq!(identities[0].name, "System.AccessToken");
842 assert_eq!(
843 identities[0].metadata.get(META_IDENTITY_SCOPE),
844 Some(&"broad".to_string())
845 );
846 }
847
848 #[test]
849 fn variable_group_creates_secret_and_marks_partial() {
850 let yaml = r#"
851variables:
852 - group: MySecretGroup
853
854steps:
855 - script: echo hi
856"#;
857 let graph = parse(yaml);
858 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
859 assert_eq!(secrets.len(), 1);
860 assert_eq!(secrets[0].name, "MySecretGroup");
861 assert_eq!(
862 secrets[0].metadata.get(META_VARIABLE_GROUP),
863 Some(&"true".to_string())
864 );
865 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
866 assert!(
867 graph
868 .completeness_gaps
869 .iter()
870 .any(|g| g.contains("MySecretGroup")),
871 "completeness gap should name the variable group"
872 );
873 }
874
875 #[test]
876 fn task_with_azure_subscription_creates_service_connection_identity() {
877 let yaml = r#"
878steps:
879 - task: AzureCLI@2
880 displayName: Deploy to Azure
881 inputs:
882 azureSubscription: MyServiceConnection
883 scriptType: bash
884 inlineScript: az group list
885"#;
886 let graph = parse(yaml);
887 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
888 assert_eq!(identities.len(), 2);
890 let conn = identities
891 .iter()
892 .find(|i| i.name == "MyServiceConnection")
893 .unwrap();
894 assert_eq!(
895 conn.metadata.get(META_SERVICE_CONNECTION),
896 Some(&"true".to_string())
897 );
898 assert_eq!(
899 conn.metadata.get(META_IDENTITY_SCOPE),
900 Some(&"broad".to_string())
901 );
902 }
903
904 #[test]
905 fn task_with_connected_service_name_creates_identity() {
906 let yaml = r#"
907steps:
908 - task: SqlAzureDacpacDeployment@1
909 inputs:
910 ConnectedServiceNameARM: MySqlConnection
911"#;
912 let graph = parse(yaml);
913 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
914 assert!(
915 identities.iter().any(|i| i.name == "MySqlConnection"),
916 "connectedServiceNameARM should create identity"
917 );
918 }
919
920 #[test]
921 fn script_step_classified_as_first_party() {
922 let yaml = r#"
923steps:
924 - script: echo hi
925 displayName: Say hi
926"#;
927 let graph = parse(yaml);
928 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
929 assert_eq!(steps.len(), 1);
930 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
931 }
932
933 #[test]
934 fn bash_step_classified_as_first_party() {
935 let yaml = r#"
936steps:
937 - bash: echo hi
938"#;
939 let graph = parse(yaml);
940 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
941 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
942 }
943
944 #[test]
945 fn task_step_classified_as_untrusted() {
946 let yaml = r#"
947steps:
948 - task: DotNetCoreCLI@2
949 inputs:
950 command: build
951"#;
952 let graph = parse(yaml);
953 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
954 assert_eq!(steps.len(), 1);
955 assert_eq!(steps[0].trust_zone, TrustZone::Untrusted);
956 }
957
958 #[test]
959 fn dollar_paren_var_in_script_creates_secret() {
960 let yaml = r#"
961steps:
962 - script: |
963 curl -H "Authorization: $(MY_API_TOKEN)" https://api.example.com
964 displayName: Call API
965"#;
966 let graph = parse(yaml);
967 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
968 assert_eq!(secrets.len(), 1);
969 assert_eq!(secrets[0].name, "MY_API_TOKEN");
970 }
971
972 #[test]
973 fn predefined_ado_var_not_treated_as_secret() {
974 let yaml = r#"
975steps:
976 - script: |
977 echo $(Build.BuildId)
978 echo $(Agent.WorkFolder)
979 echo $(System.DefaultWorkingDirectory)
980 displayName: Print vars
981"#;
982 let graph = parse(yaml);
983 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
984 assert!(
985 secrets.is_empty(),
986 "predefined ADO vars should not be treated as secrets, got: {:?}",
987 secrets.iter().map(|s| &s.name).collect::<Vec<_>>()
988 );
989 }
990
991 #[test]
992 fn template_reference_creates_delegates_to_and_marks_partial() {
993 let yaml = r#"
994steps:
995 - template: steps/deploy.yml
996 parameters:
997 env: production
998"#;
999 let graph = parse(yaml);
1000 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1001 assert_eq!(steps.len(), 1);
1002
1003 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
1004 assert_eq!(images.len(), 1);
1005 assert_eq!(images[0].name, "steps/deploy.yml");
1006
1007 let delegates: Vec<_> = graph
1008 .edges_from(steps[0].id)
1009 .filter(|e| e.kind == EdgeKind::DelegatesTo)
1010 .collect();
1011 assert_eq!(delegates.len(), 1);
1012
1013 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
1014 }
1015
1016 #[test]
1017 fn top_level_steps_no_jobs() {
1018 let yaml = r#"
1019steps:
1020 - script: echo a
1021 - script: echo b
1022"#;
1023 let graph = parse(yaml);
1024 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1025 assert_eq!(steps.len(), 2);
1026 }
1027
1028 #[test]
1029 fn top_level_jobs_no_stages() {
1030 let yaml = r#"
1031jobs:
1032 - job: JobA
1033 steps:
1034 - script: echo a
1035 - job: JobB
1036 steps:
1037 - script: echo b
1038"#;
1039 let graph = parse(yaml);
1040 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1041 assert_eq!(steps.len(), 2);
1042 }
1043
1044 #[test]
1045 fn stages_with_nested_jobs_parsed() {
1046 let yaml = r#"
1047stages:
1048 - stage: Build
1049 jobs:
1050 - job: Compile
1051 steps:
1052 - script: cargo build
1053 - stage: Test
1054 jobs:
1055 - job: UnitTest
1056 steps:
1057 - script: cargo test
1058"#;
1059 let graph = parse(yaml);
1060 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1061 assert_eq!(steps.len(), 2);
1062 }
1063
1064 #[test]
1065 fn all_steps_linked_to_system_access_token() {
1066 let yaml = r#"
1067steps:
1068 - script: echo a
1069 - task: SomeTask@1
1070 inputs: {}
1071"#;
1072 let graph = parse(yaml);
1073 let token: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1074 assert_eq!(token.len(), 1);
1075 let token_id = token[0].id;
1076
1077 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1078 for step in &steps {
1079 let links: Vec<_> = graph
1080 .edges_from(step.id)
1081 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == token_id)
1082 .collect();
1083 assert_eq!(
1084 links.len(),
1085 1,
1086 "step '{}' must link to System.AccessToken",
1087 step.name
1088 );
1089 }
1090 }
1091
1092 #[test]
1093 fn named_secret_variable_creates_secret_node() {
1094 let yaml = r#"
1095variables:
1096 - name: MY_PASSWORD
1097 value: dummy
1098 isSecret: true
1099
1100steps:
1101 - script: echo hi
1102"#;
1103 let graph = parse(yaml);
1104 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1105 assert_eq!(secrets.len(), 1);
1106 assert_eq!(secrets[0].name, "MY_PASSWORD");
1107 }
1108
1109 #[test]
1110 fn variables_as_mapping_parsed() {
1111 let yaml = r#"
1112variables:
1113 MY_VAR: hello
1114 ANOTHER_VAR: world
1115
1116steps:
1117 - script: echo hi
1118"#;
1119 let graph = parse(yaml);
1120 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1122 assert!(
1123 secrets.is_empty(),
1124 "plain mapping vars should not create secret nodes"
1125 );
1126 }
1127
1128 #[test]
1129 fn persist_credentials_creates_persists_to_edge() {
1130 let yaml = r#"
1131steps:
1132 - checkout: self
1133 persistCredentials: true
1134 - script: git push
1135"#;
1136 let graph = parse(yaml);
1137 let token_id = graph
1138 .nodes_of_kind(NodeKind::Identity)
1139 .find(|n| n.name == "System.AccessToken")
1140 .expect("System.AccessToken must exist")
1141 .id;
1142
1143 let persists_edges: Vec<_> = graph
1144 .edges
1145 .iter()
1146 .filter(|e| e.kind == EdgeKind::PersistsTo && e.to == token_id)
1147 .collect();
1148 assert_eq!(
1149 persists_edges.len(),
1150 1,
1151 "checkout with persistCredentials: true must produce exactly one PersistsTo edge"
1152 );
1153 }
1154
1155 #[test]
1156 fn checkout_without_persist_credentials_no_persists_to_edge() {
1157 let yaml = r#"
1158steps:
1159 - checkout: self
1160 - script: echo hi
1161"#;
1162 let graph = parse(yaml);
1163 let persists_edges: Vec<_> = graph
1164 .edges
1165 .iter()
1166 .filter(|e| e.kind == EdgeKind::PersistsTo)
1167 .collect();
1168 assert!(
1169 persists_edges.is_empty(),
1170 "checkout without persistCredentials should not produce PersistsTo edge"
1171 );
1172 }
1173
1174 #[test]
1175 fn var_flag_secret_marked_as_cli_flag_exposed() {
1176 let yaml = r#"
1177steps:
1178 - script: |
1179 terraform apply \
1180 -var "db_password=$(db_password)" \
1181 -var "api_key=$(api_key)"
1182 displayName: Terraform apply
1183"#;
1184 let graph = parse(yaml);
1185 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1186 assert!(!secrets.is_empty(), "should detect secrets from -var flags");
1187 for secret in &secrets {
1188 assert_eq!(
1189 secret.metadata.get(META_CLI_FLAG_EXPOSED),
1190 Some(&"true".to_string()),
1191 "secret '{}' passed via -var flag should be marked cli_flag_exposed",
1192 secret.name
1193 );
1194 }
1195 }
1196
1197 #[test]
1198 fn non_var_flag_secret_not_marked_as_cli_flag_exposed() {
1199 let yaml = r#"
1200steps:
1201 - script: |
1202 curl -H "Authorization: $(MY_TOKEN)" https://api.example.com
1203"#;
1204 let graph = parse(yaml);
1205 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1206 assert_eq!(secrets.len(), 1);
1207 assert!(
1208 !secrets[0].metadata.contains_key(META_CLI_FLAG_EXPOSED),
1209 "non -var secret should not be marked as cli_flag_exposed"
1210 );
1211 }
1212
1213 #[test]
1214 fn step_linked_to_variable_group_secret() {
1215 let yaml = r#"
1216variables:
1217 - group: ProdSecrets
1218
1219steps:
1220 - script: deploy.sh
1221"#;
1222 let graph = parse(yaml);
1223 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1224 assert_eq!(secrets.len(), 1);
1225 let secret_id = secrets[0].id;
1226
1227 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1228 let links: Vec<_> = graph
1229 .edges_from(steps[0].id)
1230 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == secret_id)
1231 .collect();
1232 assert_eq!(
1233 links.len(),
1234 1,
1235 "step should be linked to variable group secret"
1236 );
1237 }
1238
1239 #[test]
1240 fn pr_trigger_sets_meta_trigger_on_graph() {
1241 let yaml = r#"
1242pr:
1243 - '*'
1244
1245steps:
1246 - script: echo hi
1247"#;
1248 let graph = parse(yaml);
1249 assert_eq!(
1250 graph.metadata.get(META_TRIGGER),
1251 Some(&"pr".to_string()),
1252 "ADO pr: trigger should set graph META_TRIGGER"
1253 );
1254 }
1255
1256 #[test]
1257 fn self_hosted_pool_by_name_creates_image_with_self_hosted_metadata() {
1258 let yaml = r#"
1259pool:
1260 name: my-self-hosted-pool
1261
1262steps:
1263 - script: echo hi
1264"#;
1265 let graph = parse(yaml);
1266 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
1267 assert_eq!(images.len(), 1);
1268 assert_eq!(images[0].name, "my-self-hosted-pool");
1269 assert_eq!(
1270 images[0].metadata.get(META_SELF_HOSTED),
1271 Some(&"true".to_string()),
1272 "pool.name without vmImage must be tagged self-hosted"
1273 );
1274 }
1275
1276 #[test]
1277 fn vm_image_pool_is_not_tagged_self_hosted() {
1278 let yaml = r#"
1279pool:
1280 vmImage: ubuntu-latest
1281
1282steps:
1283 - script: echo hi
1284"#;
1285 let graph = parse(yaml);
1286 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
1287 assert_eq!(images.len(), 1);
1288 assert_eq!(images[0].name, "ubuntu-latest");
1289 assert!(
1290 !images[0].metadata.contains_key(META_SELF_HOSTED),
1291 "pool.vmImage is Microsoft-hosted — must not be tagged self-hosted"
1292 );
1293 }
1294
1295 #[test]
1296 fn checkout_self_step_tagged_with_meta_checkout_self() {
1297 let yaml = r#"
1298steps:
1299 - checkout: self
1300 - script: echo hi
1301"#;
1302 let graph = parse(yaml);
1303 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1304 assert_eq!(steps.len(), 2);
1305 let checkout_step = steps
1306 .iter()
1307 .find(|s| s.metadata.contains_key(META_CHECKOUT_SELF))
1308 .expect("one step must be tagged META_CHECKOUT_SELF");
1309 assert_eq!(
1310 checkout_step.metadata.get(META_CHECKOUT_SELF),
1311 Some(&"true".to_string())
1312 );
1313 }
1314
1315 #[test]
1316 fn vso_setvariable_sets_meta_writes_env_gate() {
1317 let yaml = r###"
1318steps:
1319 - script: |
1320 echo "##vso[task.setvariable variable=FOO]bar"
1321 displayName: Set variable
1322"###;
1323 let graph = parse(yaml);
1324 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1325 assert_eq!(steps.len(), 1);
1326 assert_eq!(
1327 steps[0].metadata.get(META_WRITES_ENV_GATE),
1328 Some(&"true".to_string()),
1329 "##vso[task.setvariable] must mark META_WRITES_ENV_GATE"
1330 );
1331 }
1332
1333 #[test]
1334 fn environment_key_tags_job_with_env_approval() {
1335 let yaml_string_form = r#"
1337jobs:
1338 - deployment: DeployWeb
1339 environment: production
1340 steps:
1341 - script: echo deploying
1342 displayName: Deploy
1343"#;
1344 let g1 = parse(yaml_string_form);
1345 let tagged: Vec<_> = g1
1346 .nodes_of_kind(NodeKind::Step)
1347 .filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
1348 .collect();
1349 assert!(
1350 !tagged.is_empty(),
1351 "string-form `environment:` must tag job's step nodes with META_ENV_APPROVAL"
1352 );
1353
1354 let yaml_mapping_form = r#"
1356jobs:
1357 - deployment: DeployAPI
1358 environment:
1359 name: staging
1360 resourceType: VirtualMachine
1361 steps:
1362 - script: echo deploying
1363 displayName: Deploy
1364"#;
1365 let g2 = parse(yaml_mapping_form);
1366 let tagged2: Vec<_> = g2
1367 .nodes_of_kind(NodeKind::Step)
1368 .filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
1369 .collect();
1370 assert!(
1371 !tagged2.is_empty(),
1372 "mapping-form `environment: {{ name: ... }}` must tag job's step nodes"
1373 );
1374
1375 let yaml_no_env = r#"
1377jobs:
1378 - job: Build
1379 steps:
1380 - script: echo building
1381"#;
1382 let g3 = parse(yaml_no_env);
1383 let any_tagged = g3
1384 .nodes_of_kind(NodeKind::Step)
1385 .any(|s| s.metadata.contains_key(META_ENV_APPROVAL));
1386 assert!(
1387 !any_tagged,
1388 "jobs without `environment:` must not carry META_ENV_APPROVAL"
1389 );
1390 }
1391
1392 #[test]
1393 fn environment_tag_isolated_to_gated_job_only() {
1394 let yaml = r#"
1397jobs:
1398 - job: Build
1399 steps:
1400 - script: echo build
1401 displayName: build-step
1402 - deployment: DeployProd
1403 environment: production
1404 steps:
1405 - script: echo deploy
1406 displayName: deploy-step
1407"#;
1408 let g = parse(yaml);
1409 let build_step = g
1410 .nodes_of_kind(NodeKind::Step)
1411 .find(|s| s.name == "build-step")
1412 .expect("build-step must exist");
1413 let deploy_step = g
1414 .nodes_of_kind(NodeKind::Step)
1415 .find(|s| s.name == "deploy-step")
1416 .expect("deploy-step must exist");
1417 assert!(
1418 !build_step.metadata.contains_key(META_ENV_APPROVAL),
1419 "non-gated job's step must not be tagged"
1420 );
1421 assert_eq!(
1422 deploy_step.metadata.get(META_ENV_APPROVAL),
1423 Some(&"true".to_string()),
1424 "gated deployment job's step must be tagged"
1425 );
1426 }
1427}