1use std::collections::{HashMap, HashSet};
2
3use serde::Deserialize;
4use taudit_core::error::TauditError;
5use taudit_core::graph::*;
6use taudit_core::ports::PipelineParser;
7
8fn script_does_terraform_auto_apply(s: &str) -> bool {
16 let lines: Vec<&str> = s.lines().collect();
17 for (i, raw_line) in lines.iter().enumerate() {
18 let line = raw_line.split('#').next().unwrap_or("");
20 if !(line.contains("terraform apply") || line.contains("terraform\tapply")) {
21 continue;
22 }
23 if line.contains("auto-approve") {
24 return true;
25 }
26 let mut continuing = line.trim_end().ends_with('\\') || line.trim_end().ends_with('`');
28 let mut j = i + 1;
29 while continuing && j < lines.len() && j < i + 4 {
30 let next = lines[j].split('#').next().unwrap_or("");
31 if next.contains("auto-approve") {
32 return true;
33 }
34 continuing = next.trim_end().ends_with('\\') || next.trim_end().ends_with('`');
35 j += 1;
36 }
37 }
38 false
39}
40
41pub struct AdoParser;
43
44impl PipelineParser for AdoParser {
45 fn platform(&self) -> &str {
46 "azure-devops"
47 }
48
49 fn parse(&self, content: &str, source: &PipelineSource) -> Result<AuthorityGraph, TauditError> {
50 let mut de = serde_yaml::Deserializer::from_str(content);
51 let doc = de
52 .next()
53 .ok_or_else(|| TauditError::Parse("empty YAML document".into()))?;
54 let pipeline: AdoPipeline = match AdoPipeline::deserialize(doc) {
55 Ok(p) => p,
56 Err(e) => {
57 let msg = e.to_string();
65 let looks_like_template_fragment = (msg.contains("did not find expected key")
66 || (msg.contains("parameters")
67 && msg.contains("invalid type: map")
68 && msg.contains("expected a sequence")))
69 && has_root_parameter_conditional(content);
70 if looks_like_template_fragment {
71 let mut graph = AuthorityGraph::new(source.clone());
72 graph.mark_partial(
73 "ADO template fragment with top-level parameter conditional — root structure depends on parent pipeline context".to_string(),
74 );
75 return Ok(graph);
76 }
77 return Err(TauditError::Parse(format!("YAML parse error: {e}")));
78 }
79 };
80 let extra_docs = de.next().is_some();
81
82 let mut graph = AuthorityGraph::new(source.clone());
83 if extra_docs {
84 graph.mark_partial(
85 "file contains multiple YAML documents (--- separator) — only the first was analyzed".to_string(),
86 );
87 }
88
89 let has_pr_trigger = pipeline.pr.is_some();
91 if has_pr_trigger {
92 graph.metadata.insert(META_TRIGGER.into(), "pr".into());
93 }
94
95 process_repositories(&pipeline, content, &mut graph);
100
101 if let Some(ref params) = pipeline.parameters {
105 for p in params {
106 let name = match p.name.as_ref() {
107 Some(n) if !n.is_empty() => n.clone(),
108 _ => continue,
109 };
110 let param_type = p.param_type.clone().unwrap_or_default();
111 let has_values_allowlist =
112 p.values.as_ref().map(|v| !v.is_empty()).unwrap_or(false);
113 graph.parameters.insert(
114 name,
115 ParamSpec {
116 param_type,
117 has_values_allowlist,
118 },
119 );
120 }
121 }
122
123 let mut secret_ids: HashMap<String, NodeId> = HashMap::new();
124
125 let mut meta = HashMap::new();
129 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
130 meta.insert(META_IMPLICIT.into(), "true".into());
131 let token_id = graph.add_node_with_metadata(
132 NodeKind::Identity,
133 "System.AccessToken",
134 TrustZone::FirstParty,
135 meta,
136 );
137
138 process_pool(&pipeline.pool, &mut graph);
140
141 let mut plain_vars: HashSet<String> = HashSet::new();
145 let pipeline_secret_ids = process_variables(
146 &pipeline.variables,
147 &mut graph,
148 &mut secret_ids,
149 "pipeline",
150 &mut plain_vars,
151 );
152
153 if let Some(ref stages) = pipeline.stages {
155 for stage in stages {
156 if let Some(ref tpl) = stage.template {
158 let stage_name = stage.stage.as_deref().unwrap_or("stage");
159 add_template_delegation(stage_name, tpl, token_id, None, &mut graph);
160 continue;
161 }
162
163 let stage_name = stage.stage.as_deref().unwrap_or("stage").to_string();
164 let stage_secret_ids = process_variables(
165 &stage.variables,
166 &mut graph,
167 &mut secret_ids,
168 &stage_name,
169 &mut plain_vars,
170 );
171
172 for job in &stage.jobs {
173 let job_name = job.effective_name();
174 let job_secret_ids = process_variables(
175 &job.variables,
176 &mut graph,
177 &mut secret_ids,
178 &job_name,
179 &mut plain_vars,
180 );
181
182 process_pool(&job.pool, &mut graph);
183
184 let all_secrets: Vec<NodeId> = pipeline_secret_ids
185 .iter()
186 .chain(&stage_secret_ids)
187 .chain(&job_secret_ids)
188 .copied()
189 .collect();
190
191 let steps_start = graph.nodes.len();
192
193 let job_steps = job.all_steps();
194 process_steps(
195 &job_steps,
196 &job_name,
197 token_id,
198 &all_secrets,
199 &plain_vars,
200 &mut graph,
201 &mut secret_ids,
202 );
203
204 if let Some(ref tpl) = job.template {
205 add_template_delegation(
206 &job_name,
207 tpl,
208 token_id,
209 Some(&job_name),
210 &mut graph,
211 );
212 }
213
214 if job.has_environment_binding() {
215 tag_job_steps_env_approval(&mut graph, steps_start);
216 }
217 }
218 }
219 } else if let Some(ref jobs) = pipeline.jobs {
220 for job in jobs {
221 let job_name = job.effective_name();
222 let job_secret_ids = process_variables(
223 &job.variables,
224 &mut graph,
225 &mut secret_ids,
226 &job_name,
227 &mut plain_vars,
228 );
229
230 process_pool(&job.pool, &mut graph);
231
232 let all_secrets: Vec<NodeId> = pipeline_secret_ids
233 .iter()
234 .chain(&job_secret_ids)
235 .copied()
236 .collect();
237
238 let steps_start = graph.nodes.len();
239
240 let job_steps = job.all_steps();
241 process_steps(
242 &job_steps,
243 &job_name,
244 token_id,
245 &all_secrets,
246 &plain_vars,
247 &mut graph,
248 &mut secret_ids,
249 );
250
251 if let Some(ref tpl) = job.template {
252 add_template_delegation(&job_name, tpl, token_id, Some(&job_name), &mut graph);
253 }
254
255 if job.has_environment_binding() {
256 tag_job_steps_env_approval(&mut graph, steps_start);
257 }
258 }
259 } else if let Some(ref steps) = pipeline.steps {
260 process_steps(
261 steps,
262 "pipeline",
263 token_id,
264 &pipeline_secret_ids,
265 &plain_vars,
266 &mut graph,
267 &mut secret_ids,
268 );
269 }
270
271 Ok(graph)
272 }
273}
274
275fn process_pool(pool: &Option<serde_yaml::Value>, graph: &mut AuthorityGraph) {
284 let Some(pool_val) = pool else {
285 return;
286 };
287
288 let (image_name, is_self_hosted) = match pool_val {
289 serde_yaml::Value::String(s) => (s.clone(), true),
290 serde_yaml::Value::Mapping(map) => {
291 let name = map.get("name").and_then(|v| v.as_str());
292 let vm_image = map.get("vmImage").and_then(|v| v.as_str());
293 match (name, vm_image) {
294 (_, Some(vm)) => (vm.to_string(), false),
295 (Some(n), None) => (n.to_string(), true),
296 (None, None) => return,
297 }
298 }
299 _ => return,
300 };
301
302 let mut meta = HashMap::new();
303 if is_self_hosted {
304 meta.insert(META_SELF_HOSTED.into(), "true".into());
305 }
306 graph.add_node_with_metadata(NodeKind::Image, image_name, TrustZone::FirstParty, meta);
307}
308
309fn process_repositories(pipeline: &AdoPipeline, raw_content: &str, graph: &mut AuthorityGraph) {
322 let resources = match pipeline.resources.as_ref() {
323 Some(r) if !r.repositories.is_empty() => r,
324 _ => return,
325 };
326
327 let mut used_aliases: HashSet<String> = HashSet::new();
333
334 if let Some(ref ext) = pipeline.extends {
335 collect_template_alias_refs(ext, &mut used_aliases);
336 }
337 if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(raw_content) {
338 collect_template_alias_refs(&value, &mut used_aliases);
339 collect_checkout_alias_refs(&value, &mut used_aliases);
340 }
341
342 let mut entries: Vec<serde_json::Value> = Vec::with_capacity(resources.repositories.len());
344 for repo in &resources.repositories {
345 let used = used_aliases.contains(&repo.repository);
346 let mut obj = serde_json::Map::new();
347 obj.insert(
348 "alias".into(),
349 serde_json::Value::String(repo.repository.clone()),
350 );
351 if let Some(ref t) = repo.repo_type {
352 obj.insert("repo_type".into(), serde_json::Value::String(t.clone()));
353 }
354 if let Some(ref n) = repo.name {
355 obj.insert("name".into(), serde_json::Value::String(n.clone()));
356 }
357 if let Some(ref r) = repo.git_ref {
358 obj.insert("ref".into(), serde_json::Value::String(r.clone()));
359 }
360 obj.insert("used".into(), serde_json::Value::Bool(used));
361 entries.push(serde_json::Value::Object(obj));
362 }
363
364 if let Ok(json) = serde_json::to_string(&serde_json::Value::Array(entries)) {
365 graph.metadata.insert(META_REPOSITORIES.into(), json);
366 }
367}
368
369fn collect_template_alias_refs(value: &serde_yaml::Value, sink: &mut HashSet<String>) {
373 match value {
374 serde_yaml::Value::Mapping(map) => {
375 for (k, v) in map {
376 if k.as_str() == Some("template") {
377 if let Some(s) = v.as_str() {
378 if let Some(alias) = parse_template_alias(s) {
379 sink.insert(alias);
380 }
381 }
382 }
383 collect_template_alias_refs(v, sink);
384 }
385 }
386 serde_yaml::Value::Sequence(seq) => {
387 for v in seq {
388 collect_template_alias_refs(v, sink);
389 }
390 }
391 _ => {}
392 }
393}
394
395fn collect_checkout_alias_refs(value: &serde_yaml::Value, sink: &mut HashSet<String>) {
398 match value {
399 serde_yaml::Value::Mapping(map) => {
400 for (k, v) in map {
401 if k.as_str() == Some("checkout") {
402 if let Some(s) = v.as_str() {
403 if s != "self" && s != "none" && !s.is_empty() {
404 sink.insert(s.to_string());
405 }
406 }
407 }
408 collect_checkout_alias_refs(v, sink);
409 }
410 }
411 serde_yaml::Value::Sequence(seq) => {
412 for v in seq {
413 collect_checkout_alias_refs(v, sink);
414 }
415 }
416 _ => {}
417 }
418}
419
420fn parse_template_alias(template_ref: &str) -> Option<String> {
424 let at = template_ref.rfind('@')?;
425 let alias = &template_ref[at + 1..];
426 if alias.is_empty() {
427 None
428 } else {
429 Some(alias.to_string())
430 }
431}
432
433fn tag_job_steps_env_approval(graph: &mut AuthorityGraph, start_idx: usize) {
438 for node in graph.nodes.iter_mut().skip(start_idx) {
439 if node.kind == NodeKind::Step {
440 node.metadata
441 .insert(META_ENV_APPROVAL.into(), "true".into());
442 }
443 }
444}
445
446fn process_variables(
451 variables: &Option<AdoVariables>,
452 graph: &mut AuthorityGraph,
453 cache: &mut HashMap<String, NodeId>,
454 scope: &str,
455 plain_vars: &mut HashSet<String>,
456) -> Vec<NodeId> {
457 let mut ids = Vec::new();
458
459 let vars = match variables.as_ref() {
460 Some(v) => v,
461 None => return ids,
462 };
463
464 for var in &vars.0 {
465 match var {
466 AdoVariable::Group { group } => {
467 if group.contains("${{") {
471 graph.mark_partial(format!(
472 "variable group in {scope} uses template expression — group name unresolvable at parse time"
473 ));
474 continue;
475 }
476 let mut meta = HashMap::new();
477 meta.insert(META_VARIABLE_GROUP.into(), "true".into());
478 let id = graph.add_node_with_metadata(
479 NodeKind::Secret,
480 group.as_str(),
481 TrustZone::FirstParty,
482 meta,
483 );
484 cache.insert(group.clone(), id);
485 ids.push(id);
486 graph.mark_partial(format!(
487 "variable group '{group}' in {scope} — contents unresolvable without ADO API access"
488 ));
489 }
490 AdoVariable::Named {
491 name, is_secret, ..
492 } => {
493 if *is_secret {
494 let id = find_or_create_secret(graph, cache, name);
495 ids.push(id);
496 } else {
497 plain_vars.insert(name.clone());
498 }
499 }
500 }
501 }
502
503 ids
504}
505
506fn process_steps(
508 steps: &[AdoStep],
509 job_name: &str,
510 token_id: NodeId,
511 inherited_secrets: &[NodeId],
512 plain_vars: &HashSet<String>,
513 graph: &mut AuthorityGraph,
514 cache: &mut HashMap<String, NodeId>,
515) {
516 for (idx, step) in steps.iter().enumerate() {
517 if let Some(ref tpl) = step.template {
519 let step_name = step
520 .display_name
521 .as_deref()
522 .or(step.name.as_deref())
523 .map(|s| s.to_string())
524 .unwrap_or_else(|| format!("{job_name}[{idx}]"));
525 add_template_delegation(&step_name, tpl, token_id, Some(job_name), graph);
526 continue;
527 }
528
529 let (step_name, trust_zone, mut inline_script) = classify_step(step, job_name, idx);
531
532 if inline_script.is_none() {
537 if let Some(ref inputs) = step.inputs {
538 let candidate_keys = ["inlineScript", "script", "InlineScript", "Inline"];
539 for key in candidate_keys {
540 if let Some(v) = inputs.get(key).and_then(yaml_value_as_str) {
541 if !v.is_empty() {
542 inline_script = Some(v.to_string());
543 break;
544 }
545 }
546 }
547 }
548 }
549
550 let step_id = graph.add_node(NodeKind::Step, &step_name, trust_zone);
551
552 if let Some(node) = graph.nodes.get_mut(step_id) {
555 node.metadata.insert(META_JOB_NAME.into(), job_name.into());
556 if let Some(ref body) = inline_script {
561 node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
562 }
563 }
564
565 if let Some(ref body) = inline_script {
569 if let Some(node) = graph.nodes.get_mut(step_id) {
570 node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
571 }
572 }
573
574 if let Some(ref body) = inline_script {
579 if let Some(node) = graph.nodes.get_mut(step_id) {
580 node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
581 }
582 }
583
584 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
586
587 if step.checkout.is_some() && step.persist_credentials == Some(true) {
590 graph.add_edge(step_id, token_id, EdgeKind::PersistsTo);
591 }
592
593 if let Some(ref ck) = step.checkout {
597 if ck == "self" {
598 if let Some(node) = graph.nodes.get_mut(step_id) {
599 node.metadata
600 .insert(META_CHECKOUT_SELF.into(), "true".into());
601 }
602 }
603 }
604
605 for &secret_id in inherited_secrets {
607 graph.add_edge(step_id, secret_id, EdgeKind::HasAccessTo);
608 }
609
610 if let Some(ref inputs) = step.inputs {
612 let service_conn_keys = [
613 "azuresubscription",
614 "connectedservicename",
615 "connectedservicenamearm",
616 "kubernetesserviceconnection",
617 "environmentservicename",
618 "backendservicearm",
619 ];
620 for (raw_key, val) in inputs {
621 let lower = raw_key.to_lowercase();
622 if !service_conn_keys.contains(&lower.as_str()) {
623 continue;
624 }
625 let conn_name = yaml_value_as_str(val).unwrap_or(raw_key.as_str());
626 if !conn_name.starts_with("$(") {
627 if let Some(node) = graph.nodes.get_mut(step_id) {
631 node.metadata
632 .insert(META_SERVICE_CONNECTION_NAME.into(), conn_name.to_string());
633 }
634
635 let mut meta = HashMap::new();
636 meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
637 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
638 meta.insert(META_OIDC.into(), "true".into());
643 let conn_id = graph.add_node_with_metadata(
644 NodeKind::Identity,
645 conn_name,
646 TrustZone::FirstParty,
647 meta,
648 );
649 graph.add_edge(step_id, conn_id, EdgeKind::HasAccessTo);
650 }
651 }
652
653 if let Some(val) = inputs.get("addSpnToEnvironment") {
658 let truthy = match val {
659 serde_yaml::Value::Bool(b) => *b,
660 serde_yaml::Value::String(s) => s.eq_ignore_ascii_case("true"),
661 _ => false,
662 };
663 if truthy {
664 if let Some(node) = graph.nodes.get_mut(step_id) {
665 node.metadata
666 .insert(META_ADD_SPN_TO_ENV.into(), "true".into());
667 }
668 }
669 }
670
671 let task_lower = step
676 .task
677 .as_deref()
678 .map(|t| t.to_lowercase())
679 .unwrap_or_default();
680 let is_terraform_task = task_lower.starts_with("terraformcli@")
681 || task_lower.starts_with("terraformtask@")
682 || task_lower.starts_with("terraformtaskv");
683 if is_terraform_task {
684 let cmd_lower = inputs
685 .get("command")
686 .and_then(yaml_value_as_str)
687 .map(|s| s.to_lowercase())
688 .unwrap_or_default();
689 let opts = inputs
690 .get("commandOptions")
691 .and_then(yaml_value_as_str)
692 .unwrap_or("");
693 if cmd_lower == "apply" && opts.contains("auto-approve") {
694 if let Some(node) = graph.nodes.get_mut(step_id) {
695 node.metadata
696 .insert(META_TERRAFORM_AUTO_APPROVE.into(), "true".into());
697 }
698 }
699 }
700
701 for val in inputs.values() {
703 if let Some(s) = yaml_value_as_str(val) {
704 extract_dollar_paren_secrets(s, step_id, plain_vars, graph, cache);
705 }
706 }
707 }
708
709 if let Some(ref body) = inline_script {
713 if script_does_terraform_auto_apply(body) {
714 if let Some(node) = graph.nodes.get_mut(step_id) {
715 node.metadata
716 .insert(META_TERRAFORM_AUTO_APPROVE.into(), "true".into());
717 }
718 }
719 }
720
721 if let Some(ref env) = step.env {
723 for val in env.values() {
724 extract_dollar_paren_secrets(val, step_id, plain_vars, graph, cache);
725 }
726 }
727
728 if let Some(ref script) = inline_script {
730 extract_dollar_paren_secrets(script, step_id, plain_vars, graph, cache);
731 }
732
733 if let Some(ref script) = inline_script {
735 let lower = script.to_lowercase();
736 if lower.contains("##vso[task.setvariable") {
737 if let Some(node) = graph.nodes.get_mut(step_id) {
738 node.metadata
739 .insert(META_WRITES_ENV_GATE.into(), "true".into());
740 }
741 }
742 }
743 }
744}
745
746fn classify_step(
755 step: &AdoStep,
756 job_name: &str,
757 idx: usize,
758) -> (String, TrustZone, Option<String>) {
759 let default_name = || format!("{job_name}[{idx}]");
760
761 let name = step
762 .display_name
763 .as_deref()
764 .or(step.name.as_deref())
765 .map(|s| s.to_string())
766 .unwrap_or_else(default_name);
767
768 if step.task.is_some() {
769 let inline = extract_task_inline_script(step.inputs.as_ref());
771 (name, TrustZone::Untrusted, inline)
772 } else if let Some(ref s) = step.script {
773 (name, TrustZone::FirstParty, Some(s.clone()))
774 } else if let Some(ref s) = step.bash {
775 (name, TrustZone::FirstParty, Some(s.clone()))
776 } else if let Some(ref s) = step.powershell {
777 (name, TrustZone::FirstParty, Some(s.clone()))
778 } else if let Some(ref s) = step.pwsh {
779 (name, TrustZone::FirstParty, Some(s.clone()))
780 } else {
781 (name, TrustZone::FirstParty, None)
782 }
783}
784
785fn extract_task_inline_script(
794 inputs: Option<&HashMap<String, serde_yaml::Value>>,
795) -> Option<String> {
796 let inputs = inputs?;
797 const KEYS: &[&str] = &["script", "inlinescript", "inline"];
798 for (raw_key, val) in inputs {
799 let lower = raw_key.to_lowercase();
800 if KEYS.contains(&lower.as_str()) {
801 if let Some(s) = val.as_str() {
802 if !s.is_empty() {
803 return Some(s.to_string());
804 }
805 }
806 }
807 }
808 None
809}
810
811fn add_template_delegation(
822 step_name: &str,
823 template_path: &str,
824 token_id: NodeId,
825 job_name: Option<&str>,
826 graph: &mut AuthorityGraph,
827) {
828 let tpl_trust_zone = if template_path.contains('@') {
829 TrustZone::Untrusted
830 } else {
831 TrustZone::FirstParty
832 };
833 let step_id = graph.add_node(NodeKind::Step, step_name, TrustZone::FirstParty);
834 if let Some(jn) = job_name {
835 if let Some(node) = graph.nodes.get_mut(step_id) {
836 node.metadata.insert(META_JOB_NAME.into(), jn.into());
837 }
838 }
839 let tpl_id = graph.add_node(NodeKind::Image, template_path, tpl_trust_zone);
840 graph.add_edge(step_id, tpl_id, EdgeKind::DelegatesTo);
841 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
842 graph.mark_partial(format!(
843 "template '{template_path}' cannot be resolved inline — authority within the template is unknown"
844 ));
845}
846
847fn extract_dollar_paren_secrets(
854 text: &str,
855 step_id: NodeId,
856 plain_vars: &HashSet<String>,
857 graph: &mut AuthorityGraph,
858 cache: &mut HashMap<String, NodeId>,
859) {
860 let mut pos = 0;
861 let bytes = text.as_bytes();
862 while pos < bytes.len() {
863 if pos + 2 < bytes.len() && bytes[pos] == b'$' && bytes[pos + 1] == b'(' {
864 let start = pos + 2;
865 if let Some(end_offset) = text[start..].find(')') {
866 let var_name = &text[start..start + end_offset];
867 if is_valid_ado_identifier(var_name)
868 && !is_predefined_ado_var(var_name)
869 && !plain_vars.contains(var_name)
870 {
871 let id = find_or_create_secret(graph, cache, var_name);
872 if is_in_terraform_var_flag(text, pos) {
876 if let Some(node) = graph.nodes.get_mut(id) {
877 node.metadata
878 .insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
879 }
880 }
881 graph.add_edge(step_id, id, EdgeKind::HasAccessTo);
882 }
883 pos = start + end_offset + 1;
884 continue;
885 }
886 }
887 pos += 1;
888 }
889}
890
891fn is_in_terraform_var_flag(text: &str, var_pos: usize) -> bool {
894 let line_start = text[..var_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
895 let line_before = &text[line_start..var_pos];
896 line_before.contains("-var") && line_before.contains('=')
898}
899
900fn is_valid_ado_identifier(name: &str) -> bool {
906 let mut chars = name.chars();
907 match chars.next() {
908 Some(first) if first.is_ascii_alphabetic() => {
909 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
910 }
911 _ => false,
912 }
913}
914
915fn is_predefined_ado_var(name: &str) -> bool {
918 let prefixes = [
919 "Build.",
920 "Agent.",
921 "System.",
922 "Pipeline.",
923 "Release.",
924 "Environment.",
925 "Strategy.",
926 "Deployment.",
927 "Resources.",
928 "TF_BUILD",
929 ];
930 prefixes.iter().any(|p| name.starts_with(p)) || name == "TF_BUILD"
931}
932
933fn find_or_create_secret(
934 graph: &mut AuthorityGraph,
935 cache: &mut HashMap<String, NodeId>,
936 name: &str,
937) -> NodeId {
938 if let Some(&id) = cache.get(name) {
939 return id;
940 }
941 let id = graph.add_node(NodeKind::Secret, name, TrustZone::FirstParty);
942 cache.insert(name.to_string(), id);
943 id
944}
945
946fn yaml_value_as_str(val: &serde_yaml::Value) -> Option<&str> {
947 val.as_str()
948}
949
950#[derive(Debug, Deserialize)]
958pub struct AdoPipeline {
959 #[serde(default)]
960 pub trigger: Option<serde_yaml::Value>,
961 #[serde(default)]
962 pub pr: Option<serde_yaml::Value>,
963 #[serde(default)]
964 pub variables: Option<AdoVariables>,
965 #[serde(default)]
966 pub stages: Option<Vec<AdoStage>>,
967 #[serde(default)]
968 pub jobs: Option<Vec<AdoJob>>,
969 #[serde(default)]
970 pub steps: Option<Vec<AdoStep>>,
971 #[serde(default)]
972 pub pool: Option<serde_yaml::Value>,
973 #[serde(default)]
976 pub resources: Option<AdoResources>,
977 #[serde(default)]
981 pub extends: Option<serde_yaml::Value>,
982 #[serde(default)]
986 pub parameters: Option<Vec<AdoParameter>>,
987}
988
989#[derive(Debug, Default, Deserialize)]
991pub struct AdoResources {
992 #[serde(default)]
993 pub repositories: Vec<AdoRepository>,
994}
995
996#[derive(Debug, Deserialize)]
1000pub struct AdoRepository {
1001 pub repository: String,
1003 #[serde(default, rename = "type")]
1005 pub repo_type: Option<String>,
1006 #[serde(default)]
1008 pub name: Option<String>,
1009 #[serde(default, rename = "ref")]
1012 pub git_ref: Option<String>,
1013}
1014
1015#[derive(Debug, Deserialize)]
1018pub struct AdoParameter {
1019 #[serde(default)]
1020 pub name: Option<String>,
1021 #[serde(rename = "type", default)]
1022 pub param_type: Option<String>,
1023 #[serde(default)]
1024 pub values: Option<Vec<serde_yaml::Value>>,
1025}
1026
1027#[derive(Debug, Deserialize)]
1028pub struct AdoStage {
1029 #[serde(default)]
1031 pub stage: Option<String>,
1032 #[serde(default)]
1034 pub template: Option<String>,
1035 #[serde(default)]
1036 pub variables: Option<AdoVariables>,
1037 #[serde(default)]
1038 pub jobs: Vec<AdoJob>,
1039}
1040
1041#[derive(Debug, Deserialize)]
1042pub struct AdoJob {
1043 #[serde(default)]
1045 pub job: Option<String>,
1046 #[serde(default)]
1048 pub deployment: Option<String>,
1049 #[serde(default)]
1050 pub variables: Option<AdoVariables>,
1051 #[serde(default)]
1052 pub steps: Option<Vec<AdoStep>>,
1053 #[serde(default)]
1057 pub strategy: Option<AdoStrategy>,
1058 #[serde(default)]
1059 pub pool: Option<serde_yaml::Value>,
1060 #[serde(default)]
1062 pub template: Option<String>,
1063 #[serde(default)]
1075 pub environment: Option<serde_yaml::Value>,
1076}
1077
1078impl AdoJob {
1079 pub fn effective_name(&self) -> String {
1080 self.job
1081 .as_deref()
1082 .or(self.deployment.as_deref())
1083 .unwrap_or("job")
1084 .to_string()
1085 }
1086
1087 pub fn all_steps(&self) -> Vec<AdoStep> {
1096 let mut out: Vec<AdoStep> = Vec::new();
1097 if let Some(ref s) = self.steps {
1098 out.extend(s.iter().cloned());
1099 }
1100 if let Some(ref strat) = self.strategy {
1101 for phase in strat.phases() {
1102 if let Some(ref s) = phase.steps {
1103 out.extend(s.iter().cloned());
1104 }
1105 }
1106 }
1107 out
1108 }
1109
1110 pub fn has_environment_binding(&self) -> bool {
1114 match self.environment.as_ref() {
1115 None => false,
1116 Some(serde_yaml::Value::String(s)) => !s.trim().is_empty(),
1117 Some(serde_yaml::Value::Mapping(m)) => m
1118 .get("name")
1119 .and_then(|v| v.as_str())
1120 .map(|s| !s.trim().is_empty())
1121 .unwrap_or(false),
1122 _ => false,
1123 }
1124 }
1125}
1126
1127#[derive(Debug, Default, Deserialize, Clone)]
1132pub struct AdoStrategy {
1133 #[serde(default, rename = "runOnce")]
1134 pub run_once: Option<AdoStrategyRunOnce>,
1135 #[serde(default)]
1136 pub rolling: Option<AdoStrategyRunOnce>,
1137 #[serde(default)]
1138 pub canary: Option<AdoStrategyRunOnce>,
1139}
1140
1141impl AdoStrategy {
1142 pub fn phases(&self) -> Vec<&AdoStrategyPhase> {
1144 let mut out: Vec<&AdoStrategyPhase> = Vec::new();
1145 for runner in [&self.run_once, &self.rolling, &self.canary]
1146 .iter()
1147 .copied()
1148 .flatten()
1149 {
1150 for phase in [
1151 &runner.deploy,
1152 &runner.pre_deploy,
1153 &runner.post_deploy,
1154 &runner.route_traffic,
1155 ]
1156 .into_iter()
1157 .flatten()
1158 {
1159 out.push(phase);
1160 }
1161 if let Some(ref on) = runner.on {
1162 if let Some(ref s) = on.success {
1163 out.push(s);
1164 }
1165 if let Some(ref f) = on.failure {
1166 out.push(f);
1167 }
1168 }
1169 }
1170 out
1171 }
1172}
1173
1174#[derive(Debug, Default, Deserialize, Clone)]
1178pub struct AdoStrategyRunOnce {
1179 #[serde(default)]
1180 pub deploy: Option<AdoStrategyPhase>,
1181 #[serde(default, rename = "preDeploy")]
1182 pub pre_deploy: Option<AdoStrategyPhase>,
1183 #[serde(default, rename = "postDeploy")]
1184 pub post_deploy: Option<AdoStrategyPhase>,
1185 #[serde(default, rename = "routeTraffic")]
1186 pub route_traffic: Option<AdoStrategyPhase>,
1187 #[serde(default)]
1188 pub on: Option<AdoStrategyOn>,
1189}
1190
1191#[derive(Debug, Default, Deserialize, Clone)]
1192pub struct AdoStrategyOn {
1193 #[serde(default)]
1194 pub success: Option<AdoStrategyPhase>,
1195 #[serde(default)]
1196 pub failure: Option<AdoStrategyPhase>,
1197}
1198
1199#[derive(Debug, Default, Deserialize, Clone)]
1200pub struct AdoStrategyPhase {
1201 #[serde(default)]
1202 pub steps: Option<Vec<AdoStep>>,
1203}
1204
1205#[derive(Debug, Deserialize, Clone)]
1206pub struct AdoStep {
1207 #[serde(default)]
1209 pub task: Option<String>,
1210 #[serde(default)]
1212 pub script: Option<String>,
1213 #[serde(default)]
1215 pub bash: Option<String>,
1216 #[serde(default)]
1218 pub powershell: Option<String>,
1219 #[serde(default)]
1221 pub pwsh: Option<String>,
1222 #[serde(default)]
1224 pub template: Option<String>,
1225 #[serde(rename = "displayName", default)]
1226 pub display_name: Option<String>,
1227 #[serde(default)]
1229 pub name: Option<String>,
1230 #[serde(default)]
1231 pub env: Option<HashMap<String, String>>,
1232 #[serde(default)]
1234 pub inputs: Option<HashMap<String, serde_yaml::Value>>,
1235 #[serde(default)]
1237 pub checkout: Option<String>,
1238 #[serde(rename = "persistCredentials", default)]
1240 pub persist_credentials: Option<bool>,
1241}
1242
1243#[derive(Debug, Default)]
1246pub struct AdoVariables(pub Vec<AdoVariable>);
1247
1248impl<'de> serde::Deserialize<'de> for AdoVariables {
1249 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1250 where
1251 D: serde::Deserializer<'de>,
1252 {
1253 let raw = serde_yaml::Value::deserialize(deserializer)?;
1254 let mut vars = Vec::new();
1255
1256 match raw {
1257 serde_yaml::Value::Sequence(seq) => {
1258 for item in seq {
1259 if let Some(map) = item.as_mapping() {
1260 if let Some(group_val) = map.get("group") {
1261 if let Some(group) = group_val.as_str() {
1262 vars.push(AdoVariable::Group {
1263 group: group.to_string(),
1264 });
1265 continue;
1266 }
1267 }
1268 let name = map
1269 .get("name")
1270 .and_then(|v| v.as_str())
1271 .unwrap_or("")
1272 .to_string();
1273 let value = map
1274 .get("value")
1275 .and_then(|v| v.as_str())
1276 .unwrap_or("")
1277 .to_string();
1278 let is_secret = map
1279 .get("isSecret")
1280 .and_then(|v| v.as_bool())
1281 .unwrap_or(false);
1282 vars.push(AdoVariable::Named {
1283 name,
1284 value,
1285 is_secret,
1286 });
1287 }
1288 }
1289 }
1290 serde_yaml::Value::Mapping(map) => {
1291 for (k, v) in map {
1292 let name = k.as_str().unwrap_or("").to_string();
1293 let value = v.as_str().unwrap_or("").to_string();
1294 vars.push(AdoVariable::Named {
1295 name,
1296 value,
1297 is_secret: false,
1298 });
1299 }
1300 }
1301 _ => {}
1302 }
1303
1304 Ok(AdoVariables(vars))
1305 }
1306}
1307
1308#[derive(Debug)]
1309pub enum AdoVariable {
1310 Group {
1311 group: String,
1312 },
1313 Named {
1314 name: String,
1315 value: String,
1316 is_secret: bool,
1317 },
1318}
1319
1320fn has_root_parameter_conditional(content: &str) -> bool {
1325 for line in content.lines() {
1326 let trimmed = line.trim_start();
1327 let candidate = trimmed.strip_prefix("- ").unwrap_or(trimmed);
1330 if candidate.starts_with("${{")
1331 && (candidate.contains("if ") || candidate.contains("if("))
1332 && candidate.trim_end().ends_with(":")
1333 {
1334 return true;
1335 }
1336 }
1337 false
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343
1344 fn parse(yaml: &str) -> AuthorityGraph {
1345 let parser = AdoParser;
1346 let source = PipelineSource {
1347 file: "azure-pipelines.yml".into(),
1348 repo: None,
1349 git_ref: None,
1350 commit_sha: None,
1351 };
1352 parser.parse(yaml, &source).unwrap()
1353 }
1354
1355 #[test]
1356 fn parses_simple_pipeline() {
1357 let yaml = r#"
1358trigger:
1359 - main
1360
1361jobs:
1362 - job: Build
1363 steps:
1364 - script: echo hello
1365 displayName: Say hello
1366"#;
1367 let graph = parse(yaml);
1368 assert!(graph.nodes.len() >= 2); }
1370
1371 #[test]
1372 fn system_access_token_created() {
1373 let yaml = r#"
1374steps:
1375 - script: echo hi
1376"#;
1377 let graph = parse(yaml);
1378 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1379 assert_eq!(identities.len(), 1);
1380 assert_eq!(identities[0].name, "System.AccessToken");
1381 assert_eq!(
1382 identities[0].metadata.get(META_IDENTITY_SCOPE),
1383 Some(&"broad".to_string())
1384 );
1385 }
1386
1387 #[test]
1388 fn variable_group_creates_secret_and_marks_partial() {
1389 let yaml = r#"
1390variables:
1391 - group: MySecretGroup
1392
1393steps:
1394 - script: echo hi
1395"#;
1396 let graph = parse(yaml);
1397 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1398 assert_eq!(secrets.len(), 1);
1399 assert_eq!(secrets[0].name, "MySecretGroup");
1400 assert_eq!(
1401 secrets[0].metadata.get(META_VARIABLE_GROUP),
1402 Some(&"true".to_string())
1403 );
1404 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
1405 assert!(
1406 graph
1407 .completeness_gaps
1408 .iter()
1409 .any(|g| g.contains("MySecretGroup")),
1410 "completeness gap should name the variable group"
1411 );
1412 }
1413
1414 #[test]
1415 fn task_with_azure_subscription_creates_service_connection_identity() {
1416 let yaml = r#"
1417steps:
1418 - task: AzureCLI@2
1419 displayName: Deploy to Azure
1420 inputs:
1421 azureSubscription: MyServiceConnection
1422 scriptType: bash
1423 inlineScript: az group list
1424"#;
1425 let graph = parse(yaml);
1426 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1427 assert_eq!(identities.len(), 2);
1429 let conn = identities
1430 .iter()
1431 .find(|i| i.name == "MyServiceConnection")
1432 .unwrap();
1433 assert_eq!(
1434 conn.metadata.get(META_SERVICE_CONNECTION),
1435 Some(&"true".to_string())
1436 );
1437 assert_eq!(
1438 conn.metadata.get(META_IDENTITY_SCOPE),
1439 Some(&"broad".to_string())
1440 );
1441 }
1442
1443 #[test]
1444 fn task_with_connected_service_name_creates_identity() {
1445 let yaml = r#"
1446steps:
1447 - task: SqlAzureDacpacDeployment@1
1448 inputs:
1449 ConnectedServiceNameARM: MySqlConnection
1450"#;
1451 let graph = parse(yaml);
1452 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1453 assert!(
1454 identities.iter().any(|i| i.name == "MySqlConnection"),
1455 "connectedServiceNameARM should create identity"
1456 );
1457 }
1458
1459 #[test]
1460 fn script_step_classified_as_first_party() {
1461 let yaml = r#"
1462steps:
1463 - script: echo hi
1464 displayName: Say hi
1465"#;
1466 let graph = parse(yaml);
1467 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1468 assert_eq!(steps.len(), 1);
1469 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
1470 }
1471
1472 #[test]
1473 fn bash_step_classified_as_first_party() {
1474 let yaml = r#"
1475steps:
1476 - bash: echo hi
1477"#;
1478 let graph = parse(yaml);
1479 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1480 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
1481 }
1482
1483 #[test]
1484 fn task_step_classified_as_untrusted() {
1485 let yaml = r#"
1486steps:
1487 - task: DotNetCoreCLI@2
1488 inputs:
1489 command: build
1490"#;
1491 let graph = parse(yaml);
1492 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1493 assert_eq!(steps.len(), 1);
1494 assert_eq!(steps[0].trust_zone, TrustZone::Untrusted);
1495 }
1496
1497 #[test]
1498 fn dollar_paren_var_in_script_creates_secret() {
1499 let yaml = r#"
1500steps:
1501 - script: |
1502 curl -H "Authorization: $(MY_API_TOKEN)" https://api.example.com
1503 displayName: Call API
1504"#;
1505 let graph = parse(yaml);
1506 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1507 assert_eq!(secrets.len(), 1);
1508 assert_eq!(secrets[0].name, "MY_API_TOKEN");
1509 }
1510
1511 #[test]
1512 fn predefined_ado_var_not_treated_as_secret() {
1513 let yaml = r#"
1514steps:
1515 - script: |
1516 echo $(Build.BuildId)
1517 echo $(Agent.WorkFolder)
1518 echo $(System.DefaultWorkingDirectory)
1519 displayName: Print vars
1520"#;
1521 let graph = parse(yaml);
1522 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1523 assert!(
1524 secrets.is_empty(),
1525 "predefined ADO vars should not be treated as secrets, got: {:?}",
1526 secrets.iter().map(|s| &s.name).collect::<Vec<_>>()
1527 );
1528 }
1529
1530 #[test]
1531 fn template_reference_creates_delegates_to_and_marks_partial() {
1532 let yaml = r#"
1533steps:
1534 - template: steps/deploy.yml
1535 parameters:
1536 env: production
1537"#;
1538 let graph = parse(yaml);
1539 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1540 assert_eq!(steps.len(), 1);
1541
1542 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
1543 assert_eq!(images.len(), 1);
1544 assert_eq!(images[0].name, "steps/deploy.yml");
1545
1546 let delegates: Vec<_> = graph
1547 .edges_from(steps[0].id)
1548 .filter(|e| e.kind == EdgeKind::DelegatesTo)
1549 .collect();
1550 assert_eq!(delegates.len(), 1);
1551
1552 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
1553 }
1554
1555 #[test]
1556 fn top_level_steps_no_jobs() {
1557 let yaml = r#"
1558steps:
1559 - script: echo a
1560 - script: echo b
1561"#;
1562 let graph = parse(yaml);
1563 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1564 assert_eq!(steps.len(), 2);
1565 }
1566
1567 #[test]
1568 fn top_level_jobs_no_stages() {
1569 let yaml = r#"
1570jobs:
1571 - job: JobA
1572 steps:
1573 - script: echo a
1574 - job: JobB
1575 steps:
1576 - script: echo b
1577"#;
1578 let graph = parse(yaml);
1579 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1580 assert_eq!(steps.len(), 2);
1581 }
1582
1583 #[test]
1584 fn stages_with_nested_jobs_parsed() {
1585 let yaml = r#"
1586stages:
1587 - stage: Build
1588 jobs:
1589 - job: Compile
1590 steps:
1591 - script: cargo build
1592 - stage: Test
1593 jobs:
1594 - job: UnitTest
1595 steps:
1596 - script: cargo test
1597"#;
1598 let graph = parse(yaml);
1599 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1600 assert_eq!(steps.len(), 2);
1601 }
1602
1603 #[test]
1604 fn all_steps_linked_to_system_access_token() {
1605 let yaml = r#"
1606steps:
1607 - script: echo a
1608 - task: SomeTask@1
1609 inputs: {}
1610"#;
1611 let graph = parse(yaml);
1612 let token: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1613 assert_eq!(token.len(), 1);
1614 let token_id = token[0].id;
1615
1616 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1617 for step in &steps {
1618 let links: Vec<_> = graph
1619 .edges_from(step.id)
1620 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == token_id)
1621 .collect();
1622 assert_eq!(
1623 links.len(),
1624 1,
1625 "step '{}' must link to System.AccessToken",
1626 step.name
1627 );
1628 }
1629 }
1630
1631 #[test]
1632 fn named_secret_variable_creates_secret_node() {
1633 let yaml = r#"
1634variables:
1635 - name: MY_PASSWORD
1636 value: dummy
1637 isSecret: true
1638
1639steps:
1640 - script: echo hi
1641"#;
1642 let graph = parse(yaml);
1643 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1644 assert_eq!(secrets.len(), 1);
1645 assert_eq!(secrets[0].name, "MY_PASSWORD");
1646 }
1647
1648 #[test]
1649 fn variables_as_mapping_parsed() {
1650 let yaml = r#"
1651variables:
1652 MY_VAR: hello
1653 ANOTHER_VAR: world
1654
1655steps:
1656 - script: echo hi
1657"#;
1658 let graph = parse(yaml);
1659 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1661 assert!(
1662 secrets.is_empty(),
1663 "plain mapping vars should not create secret nodes"
1664 );
1665 }
1666
1667 #[test]
1668 fn persist_credentials_creates_persists_to_edge() {
1669 let yaml = r#"
1670steps:
1671 - checkout: self
1672 persistCredentials: true
1673 - script: git push
1674"#;
1675 let graph = parse(yaml);
1676 let token_id = graph
1677 .nodes_of_kind(NodeKind::Identity)
1678 .find(|n| n.name == "System.AccessToken")
1679 .expect("System.AccessToken must exist")
1680 .id;
1681
1682 let persists_edges: Vec<_> = graph
1683 .edges
1684 .iter()
1685 .filter(|e| e.kind == EdgeKind::PersistsTo && e.to == token_id)
1686 .collect();
1687 assert_eq!(
1688 persists_edges.len(),
1689 1,
1690 "checkout with persistCredentials: true must produce exactly one PersistsTo edge"
1691 );
1692 }
1693
1694 #[test]
1695 fn checkout_without_persist_credentials_no_persists_to_edge() {
1696 let yaml = r#"
1697steps:
1698 - checkout: self
1699 - script: echo hi
1700"#;
1701 let graph = parse(yaml);
1702 let persists_edges: Vec<_> = graph
1703 .edges
1704 .iter()
1705 .filter(|e| e.kind == EdgeKind::PersistsTo)
1706 .collect();
1707 assert!(
1708 persists_edges.is_empty(),
1709 "checkout without persistCredentials should not produce PersistsTo edge"
1710 );
1711 }
1712
1713 #[test]
1714 fn var_flag_secret_marked_as_cli_flag_exposed() {
1715 let yaml = r#"
1716steps:
1717 - script: |
1718 terraform apply \
1719 -var "db_password=$(db_password)" \
1720 -var "api_key=$(api_key)"
1721 displayName: Terraform apply
1722"#;
1723 let graph = parse(yaml);
1724 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1725 assert!(!secrets.is_empty(), "should detect secrets from -var flags");
1726 for secret in &secrets {
1727 assert_eq!(
1728 secret.metadata.get(META_CLI_FLAG_EXPOSED),
1729 Some(&"true".to_string()),
1730 "secret '{}' passed via -var flag should be marked cli_flag_exposed",
1731 secret.name
1732 );
1733 }
1734 }
1735
1736 #[test]
1737 fn non_var_flag_secret_not_marked_as_cli_flag_exposed() {
1738 let yaml = r#"
1739steps:
1740 - script: |
1741 curl -H "Authorization: $(MY_TOKEN)" https://api.example.com
1742"#;
1743 let graph = parse(yaml);
1744 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1745 assert_eq!(secrets.len(), 1);
1746 assert!(
1747 !secrets[0].metadata.contains_key(META_CLI_FLAG_EXPOSED),
1748 "non -var secret should not be marked as cli_flag_exposed"
1749 );
1750 }
1751
1752 #[test]
1753 fn step_linked_to_variable_group_secret() {
1754 let yaml = r#"
1755variables:
1756 - group: ProdSecrets
1757
1758steps:
1759 - script: deploy.sh
1760"#;
1761 let graph = parse(yaml);
1762 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1763 assert_eq!(secrets.len(), 1);
1764 let secret_id = secrets[0].id;
1765
1766 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1767 let links: Vec<_> = graph
1768 .edges_from(steps[0].id)
1769 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == secret_id)
1770 .collect();
1771 assert_eq!(
1772 links.len(),
1773 1,
1774 "step should be linked to variable group secret"
1775 );
1776 }
1777
1778 #[test]
1779 fn pr_trigger_sets_meta_trigger_on_graph() {
1780 let yaml = r#"
1781pr:
1782 - '*'
1783
1784steps:
1785 - script: echo hi
1786"#;
1787 let graph = parse(yaml);
1788 assert_eq!(
1789 graph.metadata.get(META_TRIGGER),
1790 Some(&"pr".to_string()),
1791 "ADO pr: trigger should set graph META_TRIGGER"
1792 );
1793 }
1794
1795 #[test]
1796 fn self_hosted_pool_by_name_creates_image_with_self_hosted_metadata() {
1797 let yaml = r#"
1798pool:
1799 name: my-self-hosted-pool
1800
1801steps:
1802 - script: echo hi
1803"#;
1804 let graph = parse(yaml);
1805 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
1806 assert_eq!(images.len(), 1);
1807 assert_eq!(images[0].name, "my-self-hosted-pool");
1808 assert_eq!(
1809 images[0].metadata.get(META_SELF_HOSTED),
1810 Some(&"true".to_string()),
1811 "pool.name without vmImage must be tagged self-hosted"
1812 );
1813 }
1814
1815 #[test]
1816 fn vm_image_pool_is_not_tagged_self_hosted() {
1817 let yaml = r#"
1818pool:
1819 vmImage: ubuntu-latest
1820
1821steps:
1822 - script: echo hi
1823"#;
1824 let graph = parse(yaml);
1825 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
1826 assert_eq!(images.len(), 1);
1827 assert_eq!(images[0].name, "ubuntu-latest");
1828 assert!(
1829 !images[0].metadata.contains_key(META_SELF_HOSTED),
1830 "pool.vmImage is Microsoft-hosted — must not be tagged self-hosted"
1831 );
1832 }
1833
1834 #[test]
1835 fn checkout_self_step_tagged_with_meta_checkout_self() {
1836 let yaml = r#"
1837steps:
1838 - checkout: self
1839 - script: echo hi
1840"#;
1841 let graph = parse(yaml);
1842 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1843 assert_eq!(steps.len(), 2);
1844 let checkout_step = steps
1845 .iter()
1846 .find(|s| s.metadata.contains_key(META_CHECKOUT_SELF))
1847 .expect("one step must be tagged META_CHECKOUT_SELF");
1848 assert_eq!(
1849 checkout_step.metadata.get(META_CHECKOUT_SELF),
1850 Some(&"true".to_string())
1851 );
1852 }
1853
1854 #[test]
1855 fn vso_setvariable_sets_meta_writes_env_gate() {
1856 let yaml = r###"
1857steps:
1858 - script: |
1859 echo "##vso[task.setvariable variable=FOO]bar"
1860 displayName: Set variable
1861"###;
1862 let graph = parse(yaml);
1863 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1864 assert_eq!(steps.len(), 1);
1865 assert_eq!(
1866 steps[0].metadata.get(META_WRITES_ENV_GATE),
1867 Some(&"true".to_string()),
1868 "##vso[task.setvariable] must mark META_WRITES_ENV_GATE"
1869 );
1870 }
1871
1872 #[test]
1873 fn environment_key_tags_job_with_env_approval() {
1874 let yaml_string_form = r#"
1876jobs:
1877 - deployment: DeployWeb
1878 environment: production
1879 steps:
1880 - script: echo deploying
1881 displayName: Deploy
1882"#;
1883 let g1 = parse(yaml_string_form);
1884 let tagged: Vec<_> = g1
1885 .nodes_of_kind(NodeKind::Step)
1886 .filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
1887 .collect();
1888 assert!(
1889 !tagged.is_empty(),
1890 "string-form `environment:` must tag job's step nodes with META_ENV_APPROVAL"
1891 );
1892
1893 let yaml_mapping_form = r#"
1895jobs:
1896 - deployment: DeployAPI
1897 environment:
1898 name: staging
1899 resourceType: VirtualMachine
1900 steps:
1901 - script: echo deploying
1902 displayName: Deploy
1903"#;
1904 let g2 = parse(yaml_mapping_form);
1905 let tagged2: Vec<_> = g2
1906 .nodes_of_kind(NodeKind::Step)
1907 .filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
1908 .collect();
1909 assert!(
1910 !tagged2.is_empty(),
1911 "mapping-form `environment: {{ name: ... }}` must tag job's step nodes"
1912 );
1913
1914 let yaml_no_env = r#"
1916jobs:
1917 - job: Build
1918 steps:
1919 - script: echo building
1920"#;
1921 let g3 = parse(yaml_no_env);
1922 let any_tagged = g3
1923 .nodes_of_kind(NodeKind::Step)
1924 .any(|s| s.metadata.contains_key(META_ENV_APPROVAL));
1925 assert!(
1926 !any_tagged,
1927 "jobs without `environment:` must not carry META_ENV_APPROVAL"
1928 );
1929 }
1930
1931 #[test]
1932 fn root_parameter_conditional_template_fragment_does_not_crash_and_marks_partial() {
1933 let yaml = r#"
1939parameters:
1940 msabs_ws2022: false
1941
1942- ${{ if eq(parameters.msabs_ws2022, true) }}:
1943 - job: packer_ws2022
1944 displayName: Build WS2022 Gold Image
1945 steps:
1946 - task: PackerTool@0
1947"#;
1948 let parser = AdoParser;
1949 let source = PipelineSource {
1950 file: "fragment.yml".into(),
1951 repo: None,
1952 git_ref: None,
1953 commit_sha: None,
1954 };
1955 let result = parser.parse(yaml, &source);
1956 let graph = result.expect("template fragment must not crash the parser");
1957 assert!(
1958 matches!(graph.completeness, AuthorityCompleteness::Partial),
1959 "template-fragment graph must be marked Partial"
1960 );
1961 let saw_fragment_gap = graph
1962 .completeness_gaps
1963 .iter()
1964 .any(|g| g.contains("template fragment") && g.contains("parent pipeline"));
1965 assert!(
1966 saw_fragment_gap,
1967 "completeness_gaps must mention the template-fragment reason, got: {:?}",
1968 graph.completeness_gaps
1969 );
1970 }
1971
1972 #[test]
1973 fn environment_tag_isolated_to_gated_job_only() {
1974 let yaml = r#"
1977jobs:
1978 - job: Build
1979 steps:
1980 - script: echo build
1981 displayName: build-step
1982 - deployment: DeployProd
1983 environment: production
1984 steps:
1985 - script: echo deploy
1986 displayName: deploy-step
1987"#;
1988 let g = parse(yaml);
1989 let build_step = g
1990 .nodes_of_kind(NodeKind::Step)
1991 .find(|s| s.name == "build-step")
1992 .expect("build-step must exist");
1993 let deploy_step = g
1994 .nodes_of_kind(NodeKind::Step)
1995 .find(|s| s.name == "deploy-step")
1996 .expect("deploy-step must exist");
1997 assert!(
1998 !build_step.metadata.contains_key(META_ENV_APPROVAL),
1999 "non-gated job's step must not be tagged"
2000 );
2001 assert_eq!(
2002 deploy_step.metadata.get(META_ENV_APPROVAL),
2003 Some(&"true".to_string()),
2004 "gated deployment job's step must be tagged"
2005 );
2006 }
2007
2008 fn repos_meta(graph: &AuthorityGraph) -> Vec<serde_json::Value> {
2011 let raw = graph
2012 .metadata
2013 .get(META_REPOSITORIES)
2014 .expect("META_REPOSITORIES must be set");
2015 serde_json::from_str(raw).expect("META_REPOSITORIES must be valid JSON")
2016 }
2017
2018 #[test]
2019 fn resources_repositories_captured_with_used_flag_when_referenced_by_extends() {
2020 let yaml = r#"
2021resources:
2022 repositories:
2023 - repository: shared-templates
2024 type: git
2025 name: Platform/shared-templates
2026 ref: refs/heads/main
2027
2028extends:
2029 template: pipeline.yml@shared-templates
2030"#;
2031 let graph = parse(yaml);
2032 let entries = repos_meta(&graph);
2033 assert_eq!(entries.len(), 1);
2034 let e = &entries[0];
2035 assert_eq!(e["alias"], "shared-templates");
2036 assert_eq!(e["repo_type"], "git");
2037 assert_eq!(e["name"], "Platform/shared-templates");
2038 assert_eq!(e["ref"], "refs/heads/main");
2039 assert_eq!(e["used"], true);
2040 }
2041
2042 #[test]
2043 fn resources_repositories_used_via_checkout_alias() {
2044 let yaml = r#"
2046resources:
2047 repositories:
2048 - repository: adf_publish
2049 type: git
2050 name: org/adf-finance-reporting
2051 ref: refs/heads/adf_publish
2052
2053jobs:
2054 - job: deploy
2055 steps:
2056 - checkout: adf_publish
2057"#;
2058 let graph = parse(yaml);
2059 let entries = repos_meta(&graph);
2060 assert_eq!(entries.len(), 1);
2061 assert_eq!(entries[0]["alias"], "adf_publish");
2062 assert_eq!(entries[0]["used"], true);
2063 }
2064
2065 #[test]
2066 fn resources_repositories_unreferenced_alias_is_marked_not_used() {
2067 let yaml = r#"
2069resources:
2070 repositories:
2071 - repository: orphan-templates
2072 type: git
2073 name: Platform/orphan
2074 ref: main
2075
2076jobs:
2077 - job: build
2078 steps:
2079 - script: echo hi
2080"#;
2081 let graph = parse(yaml);
2082 let entries = repos_meta(&graph);
2083 assert_eq!(entries.len(), 1);
2084 assert_eq!(entries[0]["alias"], "orphan-templates");
2085 assert_eq!(entries[0]["used"], false);
2086 }
2087
2088 #[test]
2089 fn resources_repositories_absent_when_no_resources_block() {
2090 let yaml = r#"
2091jobs:
2092 - job: build
2093 steps:
2094 - script: echo hi
2095"#;
2096 let graph = parse(yaml);
2097 assert!(!graph.metadata.contains_key(META_REPOSITORIES));
2098 }
2099
2100 #[test]
2101 fn parse_template_alias_extracts_segment_after_at() {
2102 assert_eq!(
2103 parse_template_alias("steps/deploy.yml@templates"),
2104 Some("templates".to_string())
2105 );
2106 assert_eq!(parse_template_alias("local/path.yml"), None);
2107 assert_eq!(parse_template_alias("path@"), None);
2108 }
2109}