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
73 .metadata
74 .insert(META_PLATFORM.into(), "azure-devops".into());
75 graph.mark_partial(
76 "ADO template fragment with top-level parameter conditional — root structure depends on parent pipeline context".to_string(),
77 );
78 return Ok(graph);
79 }
80 return Err(TauditError::Parse(format!("YAML parse error: {e}")));
81 }
82 };
83 let extra_docs = de.next().is_some();
84
85 let mut graph = AuthorityGraph::new(source.clone());
86 graph
87 .metadata
88 .insert(META_PLATFORM.into(), "azure-devops".into());
89 if extra_docs {
90 graph.mark_partial(
91 "file contains multiple YAML documents (--- separator) — only the first was analyzed".to_string(),
92 );
93 }
94
95 let has_pr_trigger = pipeline
103 .pr
104 .as_ref()
105 .map(|v| v.is_mapping() || v.is_sequence())
106 .unwrap_or(false);
107 if has_pr_trigger {
108 graph.metadata.insert(META_TRIGGER.into(), "pr".into());
109 }
110
111 process_repositories(&pipeline, content, &mut graph);
116
117 if let Some(ref params) = pipeline.parameters {
121 for p in params {
122 let name = match p.name.as_ref() {
123 Some(n) if !n.is_empty() => n.clone(),
124 _ => continue,
125 };
126 let param_type = p.param_type.clone().unwrap_or_default();
127 let has_values_allowlist =
128 p.values.as_ref().map(|v| !v.is_empty()).unwrap_or(false);
129 graph.parameters.insert(
130 name,
131 ParamSpec {
132 param_type,
133 has_values_allowlist,
134 },
135 );
136 }
137 }
138
139 let mut secret_ids: HashMap<String, NodeId> = HashMap::new();
140
141 let mut meta = HashMap::new();
145 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
146 meta.insert(META_IMPLICIT.into(), "true".into());
147 let token_id = graph.add_node_with_metadata(
148 NodeKind::Identity,
149 "System.AccessToken",
150 TrustZone::FirstParty,
151 meta,
152 );
153
154 if let Some(ref perms_val) = pipeline.permissions {
158 if !ado_permissions_are_broad(perms_val) {
159 let perms_str = ado_permissions_display(perms_val);
160 graph.nodes[token_id]
161 .metadata
162 .insert(META_IDENTITY_SCOPE.into(), "constrained".into());
163 graph.nodes[token_id]
164 .metadata
165 .insert(META_PERMISSIONS.into(), perms_str);
166 }
167 }
168
169 process_pool(&pipeline.pool, &pipeline.workspace, &mut graph);
171
172 let mut plain_vars: HashSet<String> = HashSet::new();
176 let pipeline_secret_ids = process_variables(
177 &pipeline.variables,
178 &mut graph,
179 &mut secret_ids,
180 "pipeline",
181 &mut plain_vars,
182 );
183
184 if let Some(ref stages) = pipeline.stages {
186 for stage in stages {
187 if let Some(ref tpl) = stage.template {
189 let stage_name = stage.stage.as_deref().unwrap_or("stage");
190 add_template_delegation(stage_name, tpl, token_id, None, &mut graph);
191 continue;
192 }
193
194 let stage_name = stage.stage.as_deref().unwrap_or("stage").to_string();
195 let stage_secret_ids = process_variables(
196 &stage.variables,
197 &mut graph,
198 &mut secret_ids,
199 &stage_name,
200 &mut plain_vars,
201 );
202
203 for job in &stage.jobs {
204 let job_name = job.effective_name();
205 let job_secret_ids = process_variables(
206 &job.variables,
207 &mut graph,
208 &mut secret_ids,
209 &job_name,
210 &mut plain_vars,
211 );
212
213 let effective_workspace =
214 job.workspace.as_ref().or(pipeline.workspace.as_ref());
215 process_pool(&job.pool, &effective_workspace.cloned(), &mut graph);
216
217 let all_secrets: Vec<NodeId> = pipeline_secret_ids
218 .iter()
219 .chain(&stage_secret_ids)
220 .chain(&job_secret_ids)
221 .copied()
222 .collect();
223
224 let steps_start = graph.nodes.len();
225
226 let job_steps = job.all_steps();
227 process_steps(
228 &job_steps,
229 &job_name,
230 token_id,
231 &all_secrets,
232 &plain_vars,
233 &mut graph,
234 &mut secret_ids,
235 );
236
237 if let Some(ref tpl) = job.template {
238 add_template_delegation(
239 &job_name,
240 tpl,
241 token_id,
242 Some(&job_name),
243 &mut graph,
244 );
245 }
246
247 if job.has_environment_binding() {
248 tag_job_steps_env_approval(&mut graph, steps_start);
249 }
250 }
251 }
252 } else if let Some(ref jobs) = pipeline.jobs {
253 for job in jobs {
254 let job_name = job.effective_name();
255 let job_secret_ids = process_variables(
256 &job.variables,
257 &mut graph,
258 &mut secret_ids,
259 &job_name,
260 &mut plain_vars,
261 );
262
263 let effective_workspace = job.workspace.as_ref().or(pipeline.workspace.as_ref());
264 process_pool(&job.pool, &effective_workspace.cloned(), &mut graph);
265
266 let all_secrets: Vec<NodeId> = pipeline_secret_ids
267 .iter()
268 .chain(&job_secret_ids)
269 .copied()
270 .collect();
271
272 let steps_start = graph.nodes.len();
273
274 let job_steps = job.all_steps();
275 process_steps(
276 &job_steps,
277 &job_name,
278 token_id,
279 &all_secrets,
280 &plain_vars,
281 &mut graph,
282 &mut secret_ids,
283 );
284
285 if let Some(ref tpl) = job.template {
286 add_template_delegation(&job_name, tpl, token_id, Some(&job_name), &mut graph);
287 }
288
289 if job.has_environment_binding() {
290 tag_job_steps_env_approval(&mut graph, steps_start);
291 }
292 }
293 } else if let Some(ref steps) = pipeline.steps {
294 process_steps(
295 steps,
296 "pipeline",
297 token_id,
298 &pipeline_secret_ids,
299 &plain_vars,
300 &mut graph,
301 &mut secret_ids,
302 );
303 }
304
305 let step_count = graph
312 .nodes
313 .iter()
314 .filter(|n| n.kind == NodeKind::Step)
315 .count();
316 let had_step_carrier = pipeline.stages.as_ref().is_some_and(|s| !s.is_empty())
317 || pipeline.jobs.as_ref().is_some_and(|j| !j.is_empty())
318 || pipeline.steps.as_ref().is_some_and(|s| !s.is_empty());
319 if step_count == 0 && had_step_carrier {
320 graph.mark_partial(
321 "stages/jobs/steps parsed but produced 0 step nodes — possible non-ADO YAML wrong-platform-classified".to_string(),
322 );
323 }
324
325 Ok(graph)
326 }
327}
328
329fn ado_permissions_are_broad(perms: &serde_yaml::Value) -> bool {
343 if let Some(map) = perms.as_mapping() {
344 map.values().any(|v| v.as_str() == Some("write"))
345 } else {
346 matches!(perms.as_str(), Some("write"))
351 }
352}
353
354fn ado_permissions_display(perms: &serde_yaml::Value) -> String {
357 if let Some(map) = perms.as_mapping() {
358 map.iter()
359 .filter_map(|(k, v)| {
360 let key = k.as_str()?;
361 let val = v.as_str().unwrap_or("?");
362 Some(format!("{key}: {val}"))
363 })
364 .collect::<Vec<_>>()
365 .join(", ")
366 } else {
367 perms.as_str().unwrap_or("none").to_string()
368 }
369}
370
371fn process_pool(
377 pool: &Option<serde_yaml::Value>,
378 workspace: &Option<serde_yaml::Value>,
379 graph: &mut AuthorityGraph,
380) {
381 let Some(pool_val) = pool else {
382 return;
383 };
384
385 let (image_name, is_self_hosted) = match pool_val {
386 serde_yaml::Value::String(s) => (s.clone(), true),
387 serde_yaml::Value::Mapping(map) => {
388 let name = map.get("name").and_then(|v| v.as_str());
389 let vm_image = map.get("vmImage").and_then(|v| v.as_str());
390 match (name, vm_image) {
391 (_, Some(vm)) => (vm.to_string(), false),
392 (Some(n), None) => (n.to_string(), true),
393 (None, None) => return,
394 }
395 }
396 _ => return,
397 };
398
399 let mut meta = HashMap::new();
400 if is_self_hosted {
401 meta.insert(META_SELF_HOSTED.into(), "true".into());
402 }
403 if has_workspace_clean(workspace) {
404 meta.insert(META_WORKSPACE_CLEAN.into(), "true".into());
405 }
406 graph.add_node_with_metadata(NodeKind::Image, image_name, TrustZone::FirstParty, meta);
407}
408
409fn has_workspace_clean(workspace: &Option<serde_yaml::Value>) -> bool {
416 let Some(ws) = workspace else {
417 return false;
418 };
419 let Some(map) = ws.as_mapping() else {
420 return false;
421 };
422 let Some(clean) = map.get("clean") else {
423 return false;
424 };
425 match clean {
426 serde_yaml::Value::Bool(b) => *b,
427 serde_yaml::Value::String(s) => {
428 let lower = s.to_ascii_lowercase();
429 matches!(lower.as_str(), "all" | "outputs" | "resources" | "true")
430 }
431 _ => false,
432 }
433}
434
435fn process_repositories(pipeline: &AdoPipeline, raw_content: &str, graph: &mut AuthorityGraph) {
448 let resources = match pipeline.resources.as_ref() {
449 Some(r) if !r.repositories.is_empty() => r,
450 _ => return,
451 };
452
453 let mut used_aliases: HashSet<String> = HashSet::new();
459
460 if let Some(ref ext) = pipeline.extends {
461 collect_template_alias_refs(ext, &mut used_aliases);
462 }
463 if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(raw_content) {
464 collect_template_alias_refs(&value, &mut used_aliases);
465 collect_checkout_alias_refs(&value, &mut used_aliases);
466 }
467
468 let mut entries: Vec<serde_json::Value> = Vec::with_capacity(resources.repositories.len());
470 for repo in &resources.repositories {
471 let used = used_aliases.contains(&repo.repository);
472 let mut obj = serde_json::Map::new();
473 obj.insert(
474 "alias".into(),
475 serde_json::Value::String(repo.repository.clone()),
476 );
477 if let Some(ref t) = repo.repo_type {
478 obj.insert("repo_type".into(), serde_json::Value::String(t.clone()));
479 }
480 if let Some(ref n) = repo.name {
481 obj.insert("name".into(), serde_json::Value::String(n.clone()));
482 }
483 if let Some(ref r) = repo.git_ref {
484 obj.insert("ref".into(), serde_json::Value::String(r.clone()));
485 }
486 obj.insert("used".into(), serde_json::Value::Bool(used));
487 entries.push(serde_json::Value::Object(obj));
488 }
489
490 if let Ok(json) = serde_json::to_string(&serde_json::Value::Array(entries)) {
491 graph.metadata.insert(META_REPOSITORIES.into(), json);
492 }
493}
494
495fn collect_template_alias_refs(value: &serde_yaml::Value, sink: &mut HashSet<String>) {
499 match value {
500 serde_yaml::Value::Mapping(map) => {
501 for (k, v) in map {
502 if k.as_str() == Some("template") {
503 if let Some(s) = v.as_str() {
504 if let Some(alias) = parse_template_alias(s) {
505 sink.insert(alias);
506 }
507 }
508 }
509 collect_template_alias_refs(v, sink);
510 }
511 }
512 serde_yaml::Value::Sequence(seq) => {
513 for v in seq {
514 collect_template_alias_refs(v, sink);
515 }
516 }
517 _ => {}
518 }
519}
520
521fn collect_checkout_alias_refs(value: &serde_yaml::Value, sink: &mut HashSet<String>) {
524 match value {
525 serde_yaml::Value::Mapping(map) => {
526 for (k, v) in map {
527 if k.as_str() == Some("checkout") {
528 if let Some(s) = v.as_str() {
529 if s != "self" && s != "none" && !s.is_empty() {
530 sink.insert(s.to_string());
531 }
532 }
533 }
534 collect_checkout_alias_refs(v, sink);
535 }
536 }
537 serde_yaml::Value::Sequence(seq) => {
538 for v in seq {
539 collect_checkout_alias_refs(v, sink);
540 }
541 }
542 _ => {}
543 }
544}
545
546fn parse_template_alias(template_ref: &str) -> Option<String> {
550 let at = template_ref.rfind('@')?;
551 let alias = &template_ref[at + 1..];
552 if alias.is_empty() {
553 None
554 } else {
555 Some(alias.to_string())
556 }
557}
558
559fn tag_job_steps_env_approval(graph: &mut AuthorityGraph, start_idx: usize) {
564 for node in graph.nodes.iter_mut().skip(start_idx) {
565 if node.kind == NodeKind::Step {
566 node.metadata
567 .insert(META_ENV_APPROVAL.into(), "true".into());
568 }
569 }
570}
571
572fn process_variables(
577 variables: &Option<AdoVariables>,
578 graph: &mut AuthorityGraph,
579 cache: &mut HashMap<String, NodeId>,
580 scope: &str,
581 plain_vars: &mut HashSet<String>,
582) -> Vec<NodeId> {
583 let mut ids = Vec::new();
584
585 let vars = match variables.as_ref() {
586 Some(v) => v,
587 None => return ids,
588 };
589
590 for var in &vars.0 {
591 match var {
592 AdoVariable::Group { group } => {
593 if group.contains("${{") {
597 graph.mark_partial(format!(
598 "variable group in {scope} uses template expression — group name unresolvable at parse time"
599 ));
600 continue;
601 }
602 let mut meta = HashMap::new();
603 meta.insert(META_VARIABLE_GROUP.into(), "true".into());
604 let id = graph.add_node_with_metadata(
605 NodeKind::Secret,
606 group.as_str(),
607 TrustZone::FirstParty,
608 meta,
609 );
610 cache.insert(group.clone(), id);
611 ids.push(id);
612 graph.mark_partial(format!(
613 "variable group '{group}' in {scope} — contents unresolvable without ADO API access"
614 ));
615 }
616 AdoVariable::Named {
617 name, is_secret, ..
618 } => {
619 if *is_secret {
620 let id = find_or_create_secret(graph, cache, name);
621 ids.push(id);
622 } else {
623 plain_vars.insert(name.clone());
624 }
625 }
626 }
627 }
628
629 ids
630}
631
632fn process_steps(
634 steps: &[AdoStep],
635 job_name: &str,
636 token_id: NodeId,
637 inherited_secrets: &[NodeId],
638 plain_vars: &HashSet<String>,
639 graph: &mut AuthorityGraph,
640 cache: &mut HashMap<String, NodeId>,
641) {
642 for (idx, step) in steps.iter().enumerate() {
643 if let Some(ref tpl) = step.template {
645 let step_name = step
646 .display_name
647 .as_deref()
648 .or(step.name.as_deref())
649 .map(|s| s.to_string())
650 .unwrap_or_else(|| format!("{job_name}[{idx}]"));
651 add_template_delegation(&step_name, tpl, token_id, Some(job_name), graph);
652 continue;
653 }
654
655 let (step_name, trust_zone, mut inline_script) = classify_step(step, job_name, idx);
657
658 if inline_script.is_none() {
663 if let Some(ref inputs) = step.inputs {
664 let candidate_keys = ["inlineScript", "script", "InlineScript", "Inline"];
665 for key in candidate_keys {
666 if let Some(v) = inputs.get(key).and_then(yaml_value_as_str) {
667 if !v.is_empty() {
668 inline_script = Some(v.to_string());
669 break;
670 }
671 }
672 }
673 }
674 }
675
676 let step_id = graph.add_node(NodeKind::Step, &step_name, trust_zone);
677
678 if let Some(node) = graph.nodes.get_mut(step_id) {
681 node.metadata.insert(META_JOB_NAME.into(), job_name.into());
682 if let Some(ref body) = inline_script {
687 node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
688 }
689 }
690
691 if let Some(ref body) = inline_script {
695 if let Some(node) = graph.nodes.get_mut(step_id) {
696 node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
697 }
698 }
699
700 if let Some(ref body) = inline_script {
705 if let Some(node) = graph.nodes.get_mut(step_id) {
706 node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
707 }
708 }
709
710 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
712
713 if step.checkout.is_some() && step.persist_credentials == Some(true) {
716 graph.add_edge(step_id, token_id, EdgeKind::PersistsTo);
717 }
718
719 if let Some(ref ck) = step.checkout {
723 if ck == "self" {
724 if let Some(node) = graph.nodes.get_mut(step_id) {
725 node.metadata
726 .insert(META_CHECKOUT_SELF.into(), "true".into());
727 }
728 }
729 }
730
731 for &secret_id in inherited_secrets {
733 graph.add_edge(step_id, secret_id, EdgeKind::HasAccessTo);
734 }
735
736 if let Some(ref inputs) = step.inputs {
738 let service_conn_keys = [
739 "azuresubscription",
740 "connectedservicename",
741 "connectedservicenamearm",
742 "kubernetesserviceconnection",
743 "environmentservicename",
744 "backendservicearm",
745 ];
746 for (raw_key, val) in inputs {
747 let lower = raw_key.to_lowercase();
748 if !service_conn_keys.contains(&lower.as_str()) {
749 continue;
750 }
751 let conn_name = yaml_value_as_str(val).unwrap_or(raw_key.as_str());
752 if !conn_name.starts_with("$(") {
753 if let Some(node) = graph.nodes.get_mut(step_id) {
757 node.metadata
758 .insert(META_SERVICE_CONNECTION_NAME.into(), conn_name.to_string());
759 }
760
761 let mut meta = HashMap::new();
762 meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
763 meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
764 let conn_id = graph.add_node_with_metadata(
771 NodeKind::Identity,
772 conn_name,
773 TrustZone::FirstParty,
774 meta,
775 );
776 graph.add_edge(step_id, conn_id, EdgeKind::HasAccessTo);
777 }
778 }
779
780 if let Some(val) = inputs.get("addSpnToEnvironment") {
785 let truthy = match val {
786 serde_yaml::Value::Bool(b) => *b,
787 serde_yaml::Value::String(s) => s.eq_ignore_ascii_case("true"),
788 _ => false,
789 };
790 if truthy {
791 if let Some(node) = graph.nodes.get_mut(step_id) {
792 node.metadata
793 .insert(META_ADD_SPN_TO_ENV.into(), "true".into());
794 }
795 }
796 }
797
798 let task_lower = step
803 .task
804 .as_deref()
805 .map(|t| t.to_lowercase())
806 .unwrap_or_default();
807 let is_terraform_task = task_lower.starts_with("terraformcli@")
808 || task_lower.starts_with("terraformtask@")
809 || task_lower.starts_with("terraformtaskv");
810 if is_terraform_task {
811 let cmd_lower = inputs
812 .get("command")
813 .and_then(yaml_value_as_str)
814 .map(|s| s.to_lowercase())
815 .unwrap_or_default();
816 let opts = inputs
817 .get("commandOptions")
818 .and_then(yaml_value_as_str)
819 .unwrap_or("");
820 if cmd_lower == "apply" && opts.contains("auto-approve") {
821 if let Some(node) = graph.nodes.get_mut(step_id) {
822 node.metadata
823 .insert(META_TERRAFORM_AUTO_APPROVE.into(), "true".into());
824 }
825 }
826 }
827
828 for val in inputs.values() {
830 if let Some(s) = yaml_value_as_str(val) {
831 extract_dollar_paren_secrets(s, step_id, plain_vars, graph, cache);
832 }
833 }
834 }
835
836 if let Some(ref body) = inline_script {
840 if script_does_terraform_auto_apply(body) {
841 if let Some(node) = graph.nodes.get_mut(step_id) {
842 node.metadata
843 .insert(META_TERRAFORM_AUTO_APPROVE.into(), "true".into());
844 }
845 }
846 }
847
848 if let Some(ref env) = step.env {
850 for val in env.values() {
851 extract_dollar_paren_secrets(val, step_id, plain_vars, graph, cache);
852 }
853 }
854
855 if let Some(ref script) = inline_script {
857 extract_dollar_paren_secrets(script, step_id, plain_vars, graph, cache);
858 }
859
860 if let Some(ref script) = inline_script {
862 let lower = script.to_lowercase();
863 if lower.contains("##vso[task.setvariable") {
864 if let Some(node) = graph.nodes.get_mut(step_id) {
865 node.metadata
866 .insert(META_WRITES_ENV_GATE.into(), "true".into());
867 }
868 }
869 }
870 }
871}
872
873fn classify_step(
882 step: &AdoStep,
883 job_name: &str,
884 idx: usize,
885) -> (String, TrustZone, Option<String>) {
886 let default_name = || format!("{job_name}[{idx}]");
887
888 let name = step
889 .display_name
890 .as_deref()
891 .or(step.name.as_deref())
892 .map(|s| s.to_string())
893 .unwrap_or_else(default_name);
894
895 if step.task.is_some() {
896 let inline = extract_task_inline_script(step.inputs.as_ref());
898 (name, TrustZone::Untrusted, inline)
899 } else if let Some(ref s) = step.script {
900 (name, TrustZone::FirstParty, Some(s.clone()))
901 } else if let Some(ref s) = step.bash {
902 (name, TrustZone::FirstParty, Some(s.clone()))
903 } else if let Some(ref s) = step.powershell {
904 (name, TrustZone::FirstParty, Some(s.clone()))
905 } else if let Some(ref s) = step.pwsh {
906 (name, TrustZone::FirstParty, Some(s.clone()))
907 } else {
908 (name, TrustZone::FirstParty, None)
909 }
910}
911
912fn extract_task_inline_script(
921 inputs: Option<&HashMap<String, serde_yaml::Value>>,
922) -> Option<String> {
923 let inputs = inputs?;
924 const KEYS: &[&str] = &["script", "inlinescript", "inline"];
925 for (raw_key, val) in inputs {
926 let lower = raw_key.to_lowercase();
927 if KEYS.contains(&lower.as_str()) {
928 if let Some(s) = val.as_str() {
929 if !s.is_empty() {
930 return Some(s.to_string());
931 }
932 }
933 }
934 }
935 None
936}
937
938fn add_template_delegation(
949 step_name: &str,
950 template_path: &str,
951 token_id: NodeId,
952 job_name: Option<&str>,
953 graph: &mut AuthorityGraph,
954) {
955 let tpl_trust_zone = if template_path.contains('@') {
956 TrustZone::Untrusted
957 } else {
958 TrustZone::FirstParty
959 };
960 let step_id = graph.add_node(NodeKind::Step, step_name, TrustZone::FirstParty);
961 if let Some(jn) = job_name {
962 if let Some(node) = graph.nodes.get_mut(step_id) {
963 node.metadata.insert(META_JOB_NAME.into(), jn.into());
964 }
965 }
966 let tpl_id = graph.add_node(NodeKind::Image, template_path, tpl_trust_zone);
967 graph.add_edge(step_id, tpl_id, EdgeKind::DelegatesTo);
968 graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
969 graph.mark_partial(format!(
970 "template '{template_path}' cannot be resolved inline — authority within the template is unknown"
971 ));
972}
973
974fn extract_dollar_paren_secrets(
981 text: &str,
982 step_id: NodeId,
983 plain_vars: &HashSet<String>,
984 graph: &mut AuthorityGraph,
985 cache: &mut HashMap<String, NodeId>,
986) {
987 let mut pos = 0;
988 let bytes = text.as_bytes();
989 while pos < bytes.len() {
990 if pos + 2 < bytes.len() && bytes[pos] == b'$' && bytes[pos + 1] == b'(' {
991 let start = pos + 2;
992 if let Some(end_offset) = text[start..].find(')') {
993 let var_name = &text[start..start + end_offset];
994 if is_valid_ado_identifier(var_name)
995 && !is_predefined_ado_var(var_name)
996 && !plain_vars.contains(var_name)
997 {
998 let id = find_or_create_secret(graph, cache, var_name);
999 if is_in_terraform_var_flag(text, pos) {
1003 if let Some(node) = graph.nodes.get_mut(id) {
1004 node.metadata
1005 .insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
1006 }
1007 }
1008 graph.add_edge(step_id, id, EdgeKind::HasAccessTo);
1009 }
1010 pos = start + end_offset + 1;
1011 continue;
1012 }
1013 }
1014 pos += 1;
1015 }
1016}
1017
1018fn is_in_terraform_var_flag(text: &str, var_pos: usize) -> bool {
1021 let line_start = text[..var_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
1022 let line_before = &text[line_start..var_pos];
1023 line_before.contains("-var") && line_before.contains('=')
1025}
1026
1027fn is_valid_ado_identifier(name: &str) -> bool {
1033 let mut chars = name.chars();
1034 match chars.next() {
1035 Some(first) if first.is_ascii_alphabetic() => {
1036 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
1037 }
1038 _ => false,
1039 }
1040}
1041
1042fn is_predefined_ado_var(name: &str) -> bool {
1045 let prefixes = [
1046 "Build.",
1047 "Agent.",
1048 "System.",
1049 "Pipeline.",
1050 "Release.",
1051 "Environment.",
1052 "Strategy.",
1053 "Deployment.",
1054 "Resources.",
1055 "TF_BUILD",
1056 ];
1057 prefixes.iter().any(|p| name.starts_with(p)) || name == "TF_BUILD"
1058}
1059
1060fn find_or_create_secret(
1061 graph: &mut AuthorityGraph,
1062 cache: &mut HashMap<String, NodeId>,
1063 name: &str,
1064) -> NodeId {
1065 if let Some(&id) = cache.get(name) {
1066 return id;
1067 }
1068 let id = graph.add_node(NodeKind::Secret, name, TrustZone::FirstParty);
1069 cache.insert(name.to_string(), id);
1070 id
1071}
1072
1073fn yaml_value_as_str(val: &serde_yaml::Value) -> Option<&str> {
1074 val.as_str()
1075}
1076
1077#[derive(Debug, Deserialize)]
1085pub struct AdoPipeline {
1086 #[serde(default)]
1087 pub trigger: Option<serde_yaml::Value>,
1088 #[serde(default)]
1089 pub pr: Option<serde_yaml::Value>,
1090 #[serde(default)]
1091 pub variables: Option<AdoVariables>,
1092 #[serde(default, deserialize_with = "deserialize_optional_stages")]
1098 pub stages: Option<Vec<AdoStage>>,
1099 #[serde(default)]
1100 pub jobs: Option<Vec<AdoJob>>,
1101 #[serde(default)]
1102 pub steps: Option<Vec<AdoStep>>,
1103 #[serde(default)]
1104 pub pool: Option<serde_yaml::Value>,
1105 #[serde(default)]
1110 pub workspace: Option<serde_yaml::Value>,
1111 #[serde(default, deserialize_with = "deserialize_optional_resources")]
1117 pub resources: Option<AdoResources>,
1118 #[serde(default)]
1122 pub extends: Option<serde_yaml::Value>,
1123 #[serde(default, deserialize_with = "deserialize_optional_parameters")]
1131 pub parameters: Option<Vec<AdoParameter>>,
1132 #[serde(default)]
1138 pub permissions: Option<serde_yaml::Value>,
1139}
1140
1141fn deserialize_optional_parameters<'de, D>(
1153 deserializer: D,
1154) -> Result<Option<Vec<AdoParameter>>, D::Error>
1155where
1156 D: serde::Deserializer<'de>,
1157{
1158 use serde::de::{MapAccess, SeqAccess, Visitor};
1159 use std::fmt;
1160
1161 struct ParamsVisitor;
1162
1163 impl<'de> Visitor<'de> for ParamsVisitor {
1164 type Value = Option<Vec<AdoParameter>>;
1165
1166 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1167 f.write_str("a sequence of parameter declarations, a mapping of name→default, null, or a template expression")
1168 }
1169
1170 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1171 Ok(None)
1172 }
1173
1174 fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1175 Ok(None)
1176 }
1177
1178 fn visit_some<D: serde::Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
1179 d.deserialize_any(self)
1180 }
1181
1182 fn visit_str<E: serde::de::Error>(self, _v: &str) -> Result<Self::Value, E> {
1185 Ok(None)
1186 }
1187 fn visit_string<E: serde::de::Error>(self, _v: String) -> Result<Self::Value, E> {
1188 Ok(None)
1189 }
1190 fn visit_bool<E: serde::de::Error>(self, _v: bool) -> Result<Self::Value, E> {
1191 Ok(None)
1192 }
1193 fn visit_i64<E: serde::de::Error>(self, _v: i64) -> Result<Self::Value, E> {
1194 Ok(None)
1195 }
1196 fn visit_u64<E: serde::de::Error>(self, _v: u64) -> Result<Self::Value, E> {
1197 Ok(None)
1198 }
1199 fn visit_f64<E: serde::de::Error>(self, _v: f64) -> Result<Self::Value, E> {
1200 Ok(None)
1201 }
1202
1203 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
1204 let mut out = Vec::new();
1205 while let Some(item) = seq.next_element::<serde_yaml::Value>()? {
1206 if let Ok(p) = serde_yaml::from_value::<AdoParameter>(item) {
1207 out.push(p);
1208 }
1209 }
1210 Ok(Some(out))
1211 }
1212
1213 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1214 let mut out = Vec::new();
1218 while let Some(key) = map.next_key::<serde_yaml::Value>()? {
1219 let _ignore = map.next_value::<serde::de::IgnoredAny>()?;
1220 let name = match key {
1221 serde_yaml::Value::String(s) if !s.is_empty() => s,
1222 _ => continue,
1223 };
1224 out.push(AdoParameter {
1225 name: Some(name),
1226 param_type: None,
1227 values: None,
1228 });
1229 }
1230 Ok(Some(out))
1231 }
1232 }
1233
1234 deserializer.deserialize_any(ParamsVisitor)
1235}
1236
1237fn deserialize_optional_resources<'de, D>(deserializer: D) -> Result<Option<AdoResources>, D::Error>
1244where
1245 D: serde::Deserializer<'de>,
1246{
1247 use serde::de::{MapAccess, SeqAccess, Visitor};
1248 use std::fmt;
1249
1250 struct ResourcesVisitor;
1251
1252 impl<'de> Visitor<'de> for ResourcesVisitor {
1253 type Value = Option<AdoResources>;
1254
1255 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1256 f.write_str("an AdoResources mapping or a legacy `- repo:` sequence")
1257 }
1258
1259 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1260 Ok(None)
1261 }
1262 fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1263 Ok(None)
1264 }
1265 fn visit_some<D: serde::Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
1266 d.deserialize_any(self)
1267 }
1268
1269 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
1274 while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
1275 Ok(Some(AdoResources::default()))
1276 }
1277
1278 fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
1279 let r = AdoResources::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
1280 Ok(Some(r))
1281 }
1282 }
1283
1284 deserializer.deserialize_any(ResourcesVisitor)
1285}
1286
1287fn deserialize_optional_stages<'de, D>(deserializer: D) -> Result<Option<Vec<AdoStage>>, D::Error>
1293where
1294 D: serde::Deserializer<'de>,
1295{
1296 use serde::de::{SeqAccess, Visitor};
1297 use std::fmt;
1298
1299 struct StagesVisitor;
1300
1301 impl<'de> Visitor<'de> for StagesVisitor {
1302 type Value = Option<Vec<AdoStage>>;
1303
1304 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1305 f.write_str("a sequence of stages or a template expression")
1306 }
1307
1308 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1309 Ok(None)
1310 }
1311 fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1312 Ok(None)
1313 }
1314 fn visit_some<D: serde::Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
1315 d.deserialize_any(self)
1316 }
1317 fn visit_str<E: serde::de::Error>(self, _v: &str) -> Result<Self::Value, E> {
1318 Ok(None)
1319 }
1320 fn visit_string<E: serde::de::Error>(self, _v: String) -> Result<Self::Value, E> {
1321 Ok(None)
1322 }
1323
1324 fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
1325 let stages =
1326 Vec::<AdoStage>::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?;
1327 Ok(Some(stages))
1328 }
1329 }
1330
1331 deserializer.deserialize_any(StagesVisitor)
1332}
1333
1334#[derive(Debug, Default, Deserialize)]
1336pub struct AdoResources {
1337 #[serde(default)]
1338 pub repositories: Vec<AdoRepository>,
1339}
1340
1341#[derive(Debug, Deserialize)]
1345pub struct AdoRepository {
1346 pub repository: String,
1348 #[serde(default, rename = "type")]
1350 pub repo_type: Option<String>,
1351 #[serde(default)]
1353 pub name: Option<String>,
1354 #[serde(default, rename = "ref")]
1357 pub git_ref: Option<String>,
1358}
1359
1360#[derive(Debug, Deserialize)]
1363pub struct AdoParameter {
1364 #[serde(default)]
1365 pub name: Option<String>,
1366 #[serde(rename = "type", default)]
1367 pub param_type: Option<String>,
1368 #[serde(default)]
1369 pub values: Option<Vec<serde_yaml::Value>>,
1370}
1371
1372#[derive(Debug, Deserialize)]
1373pub struct AdoStage {
1374 #[serde(default)]
1376 pub stage: Option<String>,
1377 #[serde(default)]
1379 pub template: Option<String>,
1380 #[serde(default)]
1381 pub variables: Option<AdoVariables>,
1382 #[serde(default)]
1383 pub jobs: Vec<AdoJob>,
1384}
1385
1386#[derive(Debug, Deserialize)]
1387pub struct AdoJob {
1388 #[serde(default)]
1390 pub job: Option<String>,
1391 #[serde(default)]
1393 pub deployment: Option<String>,
1394 #[serde(default)]
1395 pub variables: Option<AdoVariables>,
1396 #[serde(default)]
1397 pub steps: Option<Vec<AdoStep>>,
1398 #[serde(default)]
1402 pub strategy: Option<AdoStrategy>,
1403 #[serde(default)]
1404 pub pool: Option<serde_yaml::Value>,
1405 #[serde(default)]
1408 pub workspace: Option<serde_yaml::Value>,
1409 #[serde(default)]
1411 pub template: Option<String>,
1412 #[serde(default)]
1424 pub environment: Option<serde_yaml::Value>,
1425}
1426
1427impl AdoJob {
1428 pub fn effective_name(&self) -> String {
1429 self.job
1430 .as_deref()
1431 .or(self.deployment.as_deref())
1432 .unwrap_or("job")
1433 .to_string()
1434 }
1435
1436 pub fn all_steps(&self) -> Vec<AdoStep> {
1445 let mut out: Vec<AdoStep> = Vec::new();
1446 if let Some(ref s) = self.steps {
1447 out.extend(s.iter().cloned());
1448 }
1449 if let Some(ref strat) = self.strategy {
1450 for phase in strat.phases() {
1451 if let Some(ref s) = phase.steps {
1452 out.extend(s.iter().cloned());
1453 }
1454 }
1455 }
1456 out
1457 }
1458
1459 pub fn has_environment_binding(&self) -> bool {
1463 match self.environment.as_ref() {
1464 None => false,
1465 Some(serde_yaml::Value::String(s)) => !s.trim().is_empty(),
1466 Some(serde_yaml::Value::Mapping(m)) => m
1467 .get("name")
1468 .and_then(|v| v.as_str())
1469 .map(|s| !s.trim().is_empty())
1470 .unwrap_or(false),
1471 _ => false,
1472 }
1473 }
1474}
1475
1476#[derive(Debug, Default, Deserialize, Clone)]
1481pub struct AdoStrategy {
1482 #[serde(default, rename = "runOnce")]
1483 pub run_once: Option<AdoStrategyRunOnce>,
1484 #[serde(default)]
1485 pub rolling: Option<AdoStrategyRunOnce>,
1486 #[serde(default)]
1487 pub canary: Option<AdoStrategyRunOnce>,
1488}
1489
1490impl AdoStrategy {
1491 pub fn phases(&self) -> Vec<&AdoStrategyPhase> {
1493 let mut out: Vec<&AdoStrategyPhase> = Vec::new();
1494 for runner in [&self.run_once, &self.rolling, &self.canary]
1495 .iter()
1496 .copied()
1497 .flatten()
1498 {
1499 for phase in [
1500 &runner.deploy,
1501 &runner.pre_deploy,
1502 &runner.post_deploy,
1503 &runner.route_traffic,
1504 ]
1505 .into_iter()
1506 .flatten()
1507 {
1508 out.push(phase);
1509 }
1510 if let Some(ref on) = runner.on {
1511 if let Some(ref s) = on.success {
1512 out.push(s);
1513 }
1514 if let Some(ref f) = on.failure {
1515 out.push(f);
1516 }
1517 }
1518 }
1519 out
1520 }
1521}
1522
1523#[derive(Debug, Default, Deserialize, Clone)]
1527pub struct AdoStrategyRunOnce {
1528 #[serde(default)]
1529 pub deploy: Option<AdoStrategyPhase>,
1530 #[serde(default, rename = "preDeploy")]
1531 pub pre_deploy: Option<AdoStrategyPhase>,
1532 #[serde(default, rename = "postDeploy")]
1533 pub post_deploy: Option<AdoStrategyPhase>,
1534 #[serde(default, rename = "routeTraffic")]
1535 pub route_traffic: Option<AdoStrategyPhase>,
1536 #[serde(default)]
1537 pub on: Option<AdoStrategyOn>,
1538}
1539
1540#[derive(Debug, Default, Deserialize, Clone)]
1541pub struct AdoStrategyOn {
1542 #[serde(default)]
1543 pub success: Option<AdoStrategyPhase>,
1544 #[serde(default)]
1545 pub failure: Option<AdoStrategyPhase>,
1546}
1547
1548#[derive(Debug, Default, Deserialize, Clone)]
1549pub struct AdoStrategyPhase {
1550 #[serde(default)]
1551 pub steps: Option<Vec<AdoStep>>,
1552}
1553
1554#[derive(Debug, Deserialize, Clone)]
1555pub struct AdoStep {
1556 #[serde(default)]
1558 pub task: Option<String>,
1559 #[serde(default)]
1561 pub script: Option<String>,
1562 #[serde(default)]
1564 pub bash: Option<String>,
1565 #[serde(default)]
1567 pub powershell: Option<String>,
1568 #[serde(default)]
1570 pub pwsh: Option<String>,
1571 #[serde(default)]
1573 pub template: Option<String>,
1574 #[serde(rename = "displayName", default)]
1575 pub display_name: Option<String>,
1576 #[serde(default)]
1578 pub name: Option<String>,
1579 #[serde(default)]
1580 pub env: Option<HashMap<String, String>>,
1581 #[serde(default)]
1583 pub inputs: Option<HashMap<String, serde_yaml::Value>>,
1584 #[serde(default)]
1586 pub checkout: Option<String>,
1587 #[serde(rename = "persistCredentials", default)]
1589 pub persist_credentials: Option<bool>,
1590}
1591
1592#[derive(Debug, Default)]
1595pub struct AdoVariables(pub Vec<AdoVariable>);
1596
1597impl<'de> serde::Deserialize<'de> for AdoVariables {
1598 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1599 where
1600 D: serde::Deserializer<'de>,
1601 {
1602 let raw = serde_yaml::Value::deserialize(deserializer)?;
1603 let mut vars = Vec::new();
1604
1605 match raw {
1606 serde_yaml::Value::Sequence(seq) => {
1607 for item in seq {
1608 if let Some(map) = item.as_mapping() {
1609 if let Some(group_val) = map.get("group") {
1610 if let Some(group) = group_val.as_str() {
1611 vars.push(AdoVariable::Group {
1612 group: group.to_string(),
1613 });
1614 continue;
1615 }
1616 }
1617 let name = map
1618 .get("name")
1619 .and_then(|v| v.as_str())
1620 .unwrap_or("")
1621 .to_string();
1622 let value = map
1623 .get("value")
1624 .and_then(|v| v.as_str())
1625 .unwrap_or("")
1626 .to_string();
1627 let is_secret = map
1628 .get("isSecret")
1629 .and_then(|v| v.as_bool())
1630 .unwrap_or(false);
1631 vars.push(AdoVariable::Named {
1632 name,
1633 value,
1634 is_secret,
1635 });
1636 }
1637 }
1638 }
1639 serde_yaml::Value::Mapping(map) => {
1640 for (k, v) in map {
1641 let name = k.as_str().unwrap_or("").to_string();
1642 let value = v.as_str().unwrap_or("").to_string();
1643 vars.push(AdoVariable::Named {
1644 name,
1645 value,
1646 is_secret: false,
1647 });
1648 }
1649 }
1650 _ => {}
1651 }
1652
1653 Ok(AdoVariables(vars))
1654 }
1655}
1656
1657#[derive(Debug)]
1658pub enum AdoVariable {
1659 Group {
1660 group: String,
1661 },
1662 Named {
1663 name: String,
1664 value: String,
1665 is_secret: bool,
1666 },
1667}
1668
1669fn has_root_parameter_conditional(content: &str) -> bool {
1674 for line in content.lines() {
1675 let trimmed = line.trim_start();
1676 let candidate = trimmed.strip_prefix("- ").unwrap_or(trimmed);
1679 if candidate.starts_with("${{")
1680 && (candidate.contains("if ") || candidate.contains("if("))
1681 && candidate.trim_end().ends_with(":")
1682 {
1683 return true;
1684 }
1685 }
1686 false
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691 use super::*;
1692
1693 fn parse(yaml: &str) -> AuthorityGraph {
1694 let parser = AdoParser;
1695 let source = PipelineSource {
1696 file: "azure-pipelines.yml".into(),
1697 repo: None,
1698 git_ref: None,
1699 commit_sha: None,
1700 };
1701 parser.parse(yaml, &source).unwrap()
1702 }
1703
1704 #[test]
1705 fn parses_simple_pipeline() {
1706 let yaml = r#"
1707trigger:
1708 - main
1709
1710jobs:
1711 - job: Build
1712 steps:
1713 - script: echo hello
1714 displayName: Say hello
1715"#;
1716 let graph = parse(yaml);
1717 assert!(graph.nodes.len() >= 2); }
1719
1720 #[test]
1721 fn system_access_token_created() {
1722 let yaml = r#"
1723steps:
1724 - script: echo hi
1725"#;
1726 let graph = parse(yaml);
1727 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1728 assert_eq!(identities.len(), 1);
1729 assert_eq!(identities[0].name, "System.AccessToken");
1730 assert_eq!(
1731 identities[0].metadata.get(META_IDENTITY_SCOPE),
1732 Some(&"broad".to_string())
1733 );
1734 }
1735
1736 #[test]
1737 fn variable_group_creates_secret_and_marks_partial() {
1738 let yaml = r#"
1739variables:
1740 - group: MySecretGroup
1741
1742steps:
1743 - script: echo hi
1744"#;
1745 let graph = parse(yaml);
1746 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1747 assert_eq!(secrets.len(), 1);
1748 assert_eq!(secrets[0].name, "MySecretGroup");
1749 assert_eq!(
1750 secrets[0].metadata.get(META_VARIABLE_GROUP),
1751 Some(&"true".to_string())
1752 );
1753 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
1754 assert!(
1755 graph
1756 .completeness_gaps
1757 .iter()
1758 .any(|g| g.contains("MySecretGroup")),
1759 "completeness gap should name the variable group"
1760 );
1761 }
1762
1763 #[test]
1764 fn task_with_azure_subscription_creates_service_connection_identity() {
1765 let yaml = r#"
1766steps:
1767 - task: AzureCLI@2
1768 displayName: Deploy to Azure
1769 inputs:
1770 azureSubscription: MyServiceConnection
1771 scriptType: bash
1772 inlineScript: az group list
1773"#;
1774 let graph = parse(yaml);
1775 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1776 assert_eq!(identities.len(), 2);
1778 let conn = identities
1779 .iter()
1780 .find(|i| i.name == "MyServiceConnection")
1781 .unwrap();
1782 assert_eq!(
1783 conn.metadata.get(META_SERVICE_CONNECTION),
1784 Some(&"true".to_string())
1785 );
1786 assert_eq!(
1787 conn.metadata.get(META_IDENTITY_SCOPE),
1788 Some(&"broad".to_string())
1789 );
1790 }
1791
1792 #[test]
1793 fn service_connection_does_not_get_unconditional_oidc_tag() {
1794 let yaml = r#"
1795steps:
1796 - task: AzureCLI@2
1797 displayName: Deploy to Azure
1798 inputs:
1799 azureSubscription: MyClassicSpnConnection
1800 scriptType: bash
1801 inlineScript: az group list
1802"#;
1803 let graph = parse(yaml);
1804 let conn = graph
1805 .nodes_of_kind(NodeKind::Identity)
1806 .find(|i| i.name == "MyClassicSpnConnection")
1807 .expect("service connection identity should exist");
1808 assert_eq!(
1809 conn.metadata.get(META_OIDC),
1810 None,
1811 "service connections must not be tagged META_OIDC without a clear OIDC signal"
1812 );
1813 }
1814
1815 #[test]
1816 fn task_with_connected_service_name_creates_identity() {
1817 let yaml = r#"
1818steps:
1819 - task: SqlAzureDacpacDeployment@1
1820 inputs:
1821 ConnectedServiceNameARM: MySqlConnection
1822"#;
1823 let graph = parse(yaml);
1824 let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1825 assert!(
1826 identities.iter().any(|i| i.name == "MySqlConnection"),
1827 "connectedServiceNameARM should create identity"
1828 );
1829 }
1830
1831 #[test]
1832 fn script_step_classified_as_first_party() {
1833 let yaml = r#"
1834steps:
1835 - script: echo hi
1836 displayName: Say hi
1837"#;
1838 let graph = parse(yaml);
1839 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1840 assert_eq!(steps.len(), 1);
1841 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
1842 }
1843
1844 #[test]
1845 fn bash_step_classified_as_first_party() {
1846 let yaml = r#"
1847steps:
1848 - bash: echo hi
1849"#;
1850 let graph = parse(yaml);
1851 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1852 assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
1853 }
1854
1855 #[test]
1856 fn task_step_classified_as_untrusted() {
1857 let yaml = r#"
1858steps:
1859 - task: DotNetCoreCLI@2
1860 inputs:
1861 command: build
1862"#;
1863 let graph = parse(yaml);
1864 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1865 assert_eq!(steps.len(), 1);
1866 assert_eq!(steps[0].trust_zone, TrustZone::Untrusted);
1867 }
1868
1869 #[test]
1870 fn dollar_paren_var_in_script_creates_secret() {
1871 let yaml = r#"
1872steps:
1873 - script: |
1874 curl -H "Authorization: $(MY_API_TOKEN)" https://api.example.com
1875 displayName: Call API
1876"#;
1877 let graph = parse(yaml);
1878 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1879 assert_eq!(secrets.len(), 1);
1880 assert_eq!(secrets[0].name, "MY_API_TOKEN");
1881 }
1882
1883 #[test]
1884 fn predefined_ado_var_not_treated_as_secret() {
1885 let yaml = r#"
1886steps:
1887 - script: |
1888 echo $(Build.BuildId)
1889 echo $(Agent.WorkFolder)
1890 echo $(System.DefaultWorkingDirectory)
1891 displayName: Print vars
1892"#;
1893 let graph = parse(yaml);
1894 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
1895 assert!(
1896 secrets.is_empty(),
1897 "predefined ADO vars should not be treated as secrets, got: {:?}",
1898 secrets.iter().map(|s| &s.name).collect::<Vec<_>>()
1899 );
1900 }
1901
1902 #[test]
1903 fn template_reference_creates_delegates_to_and_marks_partial() {
1904 let yaml = r#"
1905steps:
1906 - template: steps/deploy.yml
1907 parameters:
1908 env: production
1909"#;
1910 let graph = parse(yaml);
1911 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1912 assert_eq!(steps.len(), 1);
1913
1914 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
1915 assert_eq!(images.len(), 1);
1916 assert_eq!(images[0].name, "steps/deploy.yml");
1917
1918 let delegates: Vec<_> = graph
1919 .edges_from(steps[0].id)
1920 .filter(|e| e.kind == EdgeKind::DelegatesTo)
1921 .collect();
1922 assert_eq!(delegates.len(), 1);
1923
1924 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
1925 }
1926
1927 #[test]
1928 fn top_level_steps_no_jobs() {
1929 let yaml = r#"
1930steps:
1931 - script: echo a
1932 - script: echo b
1933"#;
1934 let graph = parse(yaml);
1935 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1936 assert_eq!(steps.len(), 2);
1937 }
1938
1939 #[test]
1940 fn top_level_jobs_no_stages() {
1941 let yaml = r#"
1942jobs:
1943 - job: JobA
1944 steps:
1945 - script: echo a
1946 - job: JobB
1947 steps:
1948 - script: echo b
1949"#;
1950 let graph = parse(yaml);
1951 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1952 assert_eq!(steps.len(), 2);
1953 }
1954
1955 #[test]
1956 fn stages_with_nested_jobs_parsed() {
1957 let yaml = r#"
1958stages:
1959 - stage: Build
1960 jobs:
1961 - job: Compile
1962 steps:
1963 - script: cargo build
1964 - stage: Test
1965 jobs:
1966 - job: UnitTest
1967 steps:
1968 - script: cargo test
1969"#;
1970 let graph = parse(yaml);
1971 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1972 assert_eq!(steps.len(), 2);
1973 }
1974
1975 #[test]
1976 fn all_steps_linked_to_system_access_token() {
1977 let yaml = r#"
1978steps:
1979 - script: echo a
1980 - task: SomeTask@1
1981 inputs: {}
1982"#;
1983 let graph = parse(yaml);
1984 let token: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
1985 assert_eq!(token.len(), 1);
1986 let token_id = token[0].id;
1987
1988 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
1989 for step in &steps {
1990 let links: Vec<_> = graph
1991 .edges_from(step.id)
1992 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == token_id)
1993 .collect();
1994 assert_eq!(
1995 links.len(),
1996 1,
1997 "step '{}' must link to System.AccessToken",
1998 step.name
1999 );
2000 }
2001 }
2002
2003 #[test]
2004 fn named_secret_variable_creates_secret_node() {
2005 let yaml = r#"
2006variables:
2007 - name: MY_PASSWORD
2008 value: dummy
2009 isSecret: true
2010
2011steps:
2012 - script: echo hi
2013"#;
2014 let graph = parse(yaml);
2015 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
2016 assert_eq!(secrets.len(), 1);
2017 assert_eq!(secrets[0].name, "MY_PASSWORD");
2018 }
2019
2020 #[test]
2021 fn variables_as_mapping_parsed() {
2022 let yaml = r#"
2023variables:
2024 MY_VAR: hello
2025 ANOTHER_VAR: world
2026
2027steps:
2028 - script: echo hi
2029"#;
2030 let graph = parse(yaml);
2031 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
2033 assert!(
2034 secrets.is_empty(),
2035 "plain mapping vars should not create secret nodes"
2036 );
2037 }
2038
2039 #[test]
2040 fn persist_credentials_creates_persists_to_edge() {
2041 let yaml = r#"
2042steps:
2043 - checkout: self
2044 persistCredentials: true
2045 - script: git push
2046"#;
2047 let graph = parse(yaml);
2048 let token_id = graph
2049 .nodes_of_kind(NodeKind::Identity)
2050 .find(|n| n.name == "System.AccessToken")
2051 .expect("System.AccessToken must exist")
2052 .id;
2053
2054 let persists_edges: Vec<_> = graph
2055 .edges
2056 .iter()
2057 .filter(|e| e.kind == EdgeKind::PersistsTo && e.to == token_id)
2058 .collect();
2059 assert_eq!(
2060 persists_edges.len(),
2061 1,
2062 "checkout with persistCredentials: true must produce exactly one PersistsTo edge"
2063 );
2064 }
2065
2066 #[test]
2067 fn checkout_without_persist_credentials_no_persists_to_edge() {
2068 let yaml = r#"
2069steps:
2070 - checkout: self
2071 - script: echo hi
2072"#;
2073 let graph = parse(yaml);
2074 let persists_edges: Vec<_> = graph
2075 .edges
2076 .iter()
2077 .filter(|e| e.kind == EdgeKind::PersistsTo)
2078 .collect();
2079 assert!(
2080 persists_edges.is_empty(),
2081 "checkout without persistCredentials should not produce PersistsTo edge"
2082 );
2083 }
2084
2085 #[test]
2086 fn var_flag_secret_marked_as_cli_flag_exposed() {
2087 let yaml = r#"
2088steps:
2089 - script: |
2090 terraform apply \
2091 -var "db_password=$(db_password)" \
2092 -var "api_key=$(api_key)"
2093 displayName: Terraform apply
2094"#;
2095 let graph = parse(yaml);
2096 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
2097 assert!(!secrets.is_empty(), "should detect secrets from -var flags");
2098 for secret in &secrets {
2099 assert_eq!(
2100 secret.metadata.get(META_CLI_FLAG_EXPOSED),
2101 Some(&"true".to_string()),
2102 "secret '{}' passed via -var flag should be marked cli_flag_exposed",
2103 secret.name
2104 );
2105 }
2106 }
2107
2108 #[test]
2109 fn non_var_flag_secret_not_marked_as_cli_flag_exposed() {
2110 let yaml = r#"
2111steps:
2112 - script: |
2113 curl -H "Authorization: $(MY_TOKEN)" https://api.example.com
2114"#;
2115 let graph = parse(yaml);
2116 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
2117 assert_eq!(secrets.len(), 1);
2118 assert!(
2119 !secrets[0].metadata.contains_key(META_CLI_FLAG_EXPOSED),
2120 "non -var secret should not be marked as cli_flag_exposed"
2121 );
2122 }
2123
2124 #[test]
2125 fn step_linked_to_variable_group_secret() {
2126 let yaml = r#"
2127variables:
2128 - group: ProdSecrets
2129
2130steps:
2131 - script: deploy.sh
2132"#;
2133 let graph = parse(yaml);
2134 let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
2135 assert_eq!(secrets.len(), 1);
2136 let secret_id = secrets[0].id;
2137
2138 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
2139 let links: Vec<_> = graph
2140 .edges_from(steps[0].id)
2141 .filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == secret_id)
2142 .collect();
2143 assert_eq!(
2144 links.len(),
2145 1,
2146 "step should be linked to variable group secret"
2147 );
2148 }
2149
2150 #[test]
2151 fn pr_trigger_sets_meta_trigger_on_graph() {
2152 let yaml = r#"
2153pr:
2154 - '*'
2155
2156steps:
2157 - script: echo hi
2158"#;
2159 let graph = parse(yaml);
2160 assert_eq!(
2161 graph.metadata.get(META_TRIGGER),
2162 Some(&"pr".to_string()),
2163 "ADO pr: trigger should set graph META_TRIGGER"
2164 );
2165 }
2166
2167 #[test]
2168 fn self_hosted_pool_by_name_creates_image_with_self_hosted_metadata() {
2169 let yaml = r#"
2170pool:
2171 name: my-self-hosted-pool
2172
2173steps:
2174 - script: echo hi
2175"#;
2176 let graph = parse(yaml);
2177 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
2178 assert_eq!(images.len(), 1);
2179 assert_eq!(images[0].name, "my-self-hosted-pool");
2180 assert_eq!(
2181 images[0].metadata.get(META_SELF_HOSTED),
2182 Some(&"true".to_string()),
2183 "pool.name without vmImage must be tagged self-hosted"
2184 );
2185 }
2186
2187 #[test]
2188 fn vm_image_pool_is_not_tagged_self_hosted() {
2189 let yaml = r#"
2190pool:
2191 vmImage: ubuntu-latest
2192
2193steps:
2194 - script: echo hi
2195"#;
2196 let graph = parse(yaml);
2197 let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
2198 assert_eq!(images.len(), 1);
2199 assert_eq!(images[0].name, "ubuntu-latest");
2200 assert!(
2201 !images[0].metadata.contains_key(META_SELF_HOSTED),
2202 "pool.vmImage is Microsoft-hosted — must not be tagged self-hosted"
2203 );
2204 }
2205
2206 #[test]
2207 fn checkout_self_step_tagged_with_meta_checkout_self() {
2208 let yaml = r#"
2209steps:
2210 - checkout: self
2211 - script: echo hi
2212"#;
2213 let graph = parse(yaml);
2214 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
2215 assert_eq!(steps.len(), 2);
2216 let checkout_step = steps
2217 .iter()
2218 .find(|s| s.metadata.contains_key(META_CHECKOUT_SELF))
2219 .expect("one step must be tagged META_CHECKOUT_SELF");
2220 assert_eq!(
2221 checkout_step.metadata.get(META_CHECKOUT_SELF),
2222 Some(&"true".to_string())
2223 );
2224 }
2225
2226 #[test]
2227 fn vso_setvariable_sets_meta_writes_env_gate() {
2228 let yaml = r###"
2229steps:
2230 - script: |
2231 echo "##vso[task.setvariable variable=FOO]bar"
2232 displayName: Set variable
2233"###;
2234 let graph = parse(yaml);
2235 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
2236 assert_eq!(steps.len(), 1);
2237 assert_eq!(
2238 steps[0].metadata.get(META_WRITES_ENV_GATE),
2239 Some(&"true".to_string()),
2240 "##vso[task.setvariable] must mark META_WRITES_ENV_GATE"
2241 );
2242 }
2243
2244 #[test]
2245 fn environment_key_tags_job_with_env_approval() {
2246 let yaml_string_form = r#"
2248jobs:
2249 - deployment: DeployWeb
2250 environment: production
2251 steps:
2252 - script: echo deploying
2253 displayName: Deploy
2254"#;
2255 let g1 = parse(yaml_string_form);
2256 let tagged: Vec<_> = g1
2257 .nodes_of_kind(NodeKind::Step)
2258 .filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
2259 .collect();
2260 assert!(
2261 !tagged.is_empty(),
2262 "string-form `environment:` must tag job's step nodes with META_ENV_APPROVAL"
2263 );
2264
2265 let yaml_mapping_form = r#"
2267jobs:
2268 - deployment: DeployAPI
2269 environment:
2270 name: staging
2271 resourceType: VirtualMachine
2272 steps:
2273 - script: echo deploying
2274 displayName: Deploy
2275"#;
2276 let g2 = parse(yaml_mapping_form);
2277 let tagged2: Vec<_> = g2
2278 .nodes_of_kind(NodeKind::Step)
2279 .filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
2280 .collect();
2281 assert!(
2282 !tagged2.is_empty(),
2283 "mapping-form `environment: {{ name: ... }}` must tag job's step nodes"
2284 );
2285
2286 let yaml_no_env = r#"
2288jobs:
2289 - job: Build
2290 steps:
2291 - script: echo building
2292"#;
2293 let g3 = parse(yaml_no_env);
2294 let any_tagged = g3
2295 .nodes_of_kind(NodeKind::Step)
2296 .any(|s| s.metadata.contains_key(META_ENV_APPROVAL));
2297 assert!(
2298 !any_tagged,
2299 "jobs without `environment:` must not carry META_ENV_APPROVAL"
2300 );
2301 }
2302
2303 #[test]
2304 fn root_parameter_conditional_template_fragment_does_not_crash_and_marks_partial() {
2305 let yaml = r#"
2311parameters:
2312 msabs_ws2022: false
2313
2314- ${{ if eq(parameters.msabs_ws2022, true) }}:
2315 - job: packer_ws2022
2316 displayName: Build WS2022 Gold Image
2317 steps:
2318 - task: PackerTool@0
2319"#;
2320 let parser = AdoParser;
2321 let source = PipelineSource {
2322 file: "fragment.yml".into(),
2323 repo: None,
2324 git_ref: None,
2325 commit_sha: None,
2326 };
2327 let result = parser.parse(yaml, &source);
2328 let graph = result.expect("template fragment must not crash the parser");
2329 assert!(
2330 matches!(graph.completeness, AuthorityCompleteness::Partial),
2331 "template-fragment graph must be marked Partial"
2332 );
2333 let saw_fragment_gap = graph
2334 .completeness_gaps
2335 .iter()
2336 .any(|g| g.contains("template fragment") && g.contains("parent pipeline"));
2337 assert!(
2338 saw_fragment_gap,
2339 "completeness_gaps must mention the template-fragment reason, got: {:?}",
2340 graph.completeness_gaps
2341 );
2342 }
2343
2344 #[test]
2345 fn environment_tag_isolated_to_gated_job_only() {
2346 let yaml = r#"
2349jobs:
2350 - job: Build
2351 steps:
2352 - script: echo build
2353 displayName: build-step
2354 - deployment: DeployProd
2355 environment: production
2356 steps:
2357 - script: echo deploy
2358 displayName: deploy-step
2359"#;
2360 let g = parse(yaml);
2361 let build_step = g
2362 .nodes_of_kind(NodeKind::Step)
2363 .find(|s| s.name == "build-step")
2364 .expect("build-step must exist");
2365 let deploy_step = g
2366 .nodes_of_kind(NodeKind::Step)
2367 .find(|s| s.name == "deploy-step")
2368 .expect("deploy-step must exist");
2369 assert!(
2370 !build_step.metadata.contains_key(META_ENV_APPROVAL),
2371 "non-gated job's step must not be tagged"
2372 );
2373 assert_eq!(
2374 deploy_step.metadata.get(META_ENV_APPROVAL),
2375 Some(&"true".to_string()),
2376 "gated deployment job's step must be tagged"
2377 );
2378 }
2379
2380 fn repos_meta(graph: &AuthorityGraph) -> Vec<serde_json::Value> {
2383 let raw = graph
2384 .metadata
2385 .get(META_REPOSITORIES)
2386 .expect("META_REPOSITORIES must be set");
2387 serde_json::from_str(raw).expect("META_REPOSITORIES must be valid JSON")
2388 }
2389
2390 #[test]
2391 fn resources_repositories_captured_with_used_flag_when_referenced_by_extends() {
2392 let yaml = r#"
2393resources:
2394 repositories:
2395 - repository: shared-templates
2396 type: git
2397 name: Platform/shared-templates
2398 ref: refs/heads/main
2399
2400extends:
2401 template: pipeline.yml@shared-templates
2402"#;
2403 let graph = parse(yaml);
2404 let entries = repos_meta(&graph);
2405 assert_eq!(entries.len(), 1);
2406 let e = &entries[0];
2407 assert_eq!(e["alias"], "shared-templates");
2408 assert_eq!(e["repo_type"], "git");
2409 assert_eq!(e["name"], "Platform/shared-templates");
2410 assert_eq!(e["ref"], "refs/heads/main");
2411 assert_eq!(e["used"], true);
2412 }
2413
2414 #[test]
2415 fn resources_repositories_used_via_checkout_alias() {
2416 let yaml = r#"
2418resources:
2419 repositories:
2420 - repository: adf_publish
2421 type: git
2422 name: org/adf-finance-reporting
2423 ref: refs/heads/adf_publish
2424
2425jobs:
2426 - job: deploy
2427 steps:
2428 - checkout: adf_publish
2429"#;
2430 let graph = parse(yaml);
2431 let entries = repos_meta(&graph);
2432 assert_eq!(entries.len(), 1);
2433 assert_eq!(entries[0]["alias"], "adf_publish");
2434 assert_eq!(entries[0]["used"], true);
2435 }
2436
2437 #[test]
2438 fn resources_repositories_unreferenced_alias_is_marked_not_used() {
2439 let yaml = r#"
2441resources:
2442 repositories:
2443 - repository: orphan-templates
2444 type: git
2445 name: Platform/orphan
2446 ref: main
2447
2448jobs:
2449 - job: build
2450 steps:
2451 - script: echo hi
2452"#;
2453 let graph = parse(yaml);
2454 let entries = repos_meta(&graph);
2455 assert_eq!(entries.len(), 1);
2456 assert_eq!(entries[0]["alias"], "orphan-templates");
2457 assert_eq!(entries[0]["used"], false);
2458 }
2459
2460 #[test]
2461 fn resources_repositories_absent_when_no_resources_block() {
2462 let yaml = r#"
2463jobs:
2464 - job: build
2465 steps:
2466 - script: echo hi
2467"#;
2468 let graph = parse(yaml);
2469 assert!(!graph.metadata.contains_key(META_REPOSITORIES));
2470 }
2471
2472 #[test]
2473 fn parse_template_alias_extracts_segment_after_at() {
2474 assert_eq!(
2475 parse_template_alias("steps/deploy.yml@templates"),
2476 Some("templates".to_string())
2477 );
2478 assert_eq!(parse_template_alias("local/path.yml"), None);
2479 assert_eq!(parse_template_alias("path@"), None);
2480 }
2481
2482 #[test]
2483 fn parameters_as_map_form_parses_as_named_parameters() {
2484 let yaml = r#"
2490parameters:
2491 name: ''
2492 k8sRelease: ''
2493 apimodel: 'examples/e2e-tests/kubernetes/release/default/definition.json'
2494 createVNET: false
2495
2496jobs:
2497 - job: build
2498 steps:
2499 - script: echo $(name)
2500"#;
2501 let graph = parse(yaml);
2502 assert!(graph.parameters.contains_key("name"));
2504 assert!(graph.parameters.contains_key("k8sRelease"));
2505 assert!(graph.parameters.contains_key("apimodel"));
2506 assert!(graph.parameters.contains_key("createVNET"));
2507 assert_eq!(graph.parameters.len(), 4);
2508 }
2509
2510 #[test]
2511 fn parameters_as_typed_sequence_form_still_parses() {
2512 let yaml = r#"
2515parameters:
2516 - name: env
2517 type: string
2518 default: prod
2519 values:
2520 - prod
2521 - staging
2522 - name: skipTests
2523 type: boolean
2524 default: false
2525
2526jobs:
2527 - job: build
2528 steps:
2529 - script: echo hi
2530"#;
2531 let graph = parse(yaml);
2532 let env_param = graph.parameters.get("env").expect("env captured");
2533 assert_eq!(env_param.param_type, "string");
2534 assert!(env_param.has_values_allowlist);
2535 let skip_param = graph
2536 .parameters
2537 .get("skipTests")
2538 .expect("skipTests captured");
2539 assert_eq!(skip_param.param_type, "boolean");
2540 assert!(!skip_param.has_values_allowlist);
2541 }
2542
2543 #[test]
2544 fn resources_as_legacy_sequence_form_parses_to_empty_resources() {
2545 let yaml = r#"
2551resources:
2552- repo: self
2553
2554trigger:
2555 - main
2556
2557jobs:
2558 - job: build
2559 steps:
2560 - script: echo hi
2561"#;
2562 let graph = parse(yaml);
2563 assert!(!graph.metadata.contains_key(META_REPOSITORIES));
2566 let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
2568 assert_eq!(steps.len(), 1);
2569 }
2570
2571 #[test]
2572 fn stages_as_template_expression_parses_with_no_stages() {
2573 let yaml = r#"
2579parameters:
2580 - name: stages
2581 type: stageList
2582
2583stages: ${{ parameters.stages }}
2584"#;
2585 let graph = parse(yaml);
2586 assert!(graph.parameters.contains_key("stages"));
2588 }
2589
2590 #[test]
2593 fn jobs_carrier_without_steps_marks_partial() {
2594 let yaml = r#"
2599jobs:
2600 - job: build
2601 pool:
2602 vmImage: ubuntu-latest
2603"#;
2604 let graph = parse(yaml);
2605 let step_count = graph
2606 .nodes
2607 .iter()
2608 .filter(|n| n.kind == NodeKind::Step)
2609 .count();
2610 assert_eq!(step_count, 0);
2611 assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
2612 assert!(
2613 graph
2614 .completeness_gaps
2615 .iter()
2616 .any(|g| g.contains("0 step nodes")),
2617 "completeness_gaps must mention 0 step nodes: {:?}",
2618 graph.completeness_gaps
2619 );
2620 }
2621
2622 #[test]
2623 fn jobs_carrier_with_empty_jobs_list_does_not_mark_partial() {
2624 let yaml = r#"
2627jobs: []
2628"#;
2629 let graph = parse(yaml);
2630 let zero_step_gap = graph
2631 .completeness_gaps
2632 .iter()
2633 .any(|g| g.contains("0 step nodes"));
2634 assert!(
2635 !zero_step_gap,
2636 "empty jobs: list is not a carrier; got: {:?}",
2637 graph.completeness_gaps
2638 );
2639 }
2640
2641 #[test]
2644 fn pr_none_does_not_set_meta_trigger() {
2645 let yaml = r#"
2648schedules:
2649 - cron: "0 5 * * 1"
2650pr: none
2651trigger: none
2652steps:
2653 - script: echo hello
2654"#;
2655 let graph = parse(yaml);
2656 assert!(
2657 !graph.metadata.contains_key(META_TRIGGER),
2658 "pr: none must not set META_TRIGGER; got: {:?}",
2659 graph.metadata.get(META_TRIGGER)
2660 );
2661 }
2662
2663 #[test]
2664 fn pr_tilde_does_not_set_meta_trigger() {
2665 let yaml = "pr: ~\nsteps:\n - script: echo hello\n";
2667 let graph = parse(yaml);
2668 assert!(
2669 !graph.metadata.contains_key(META_TRIGGER),
2670 "pr: ~ must not set META_TRIGGER; got: {:?}",
2671 graph.metadata.get(META_TRIGGER)
2672 );
2673 }
2674
2675 #[test]
2676 fn pr_false_does_not_set_meta_trigger() {
2677 let yaml = "pr: false\nsteps:\n - script: echo hello\n";
2679 let graph = parse(yaml);
2680 assert!(
2681 !graph.metadata.contains_key(META_TRIGGER),
2682 "pr: false must not set META_TRIGGER; got: {:?}",
2683 graph.metadata.get(META_TRIGGER)
2684 );
2685 }
2686
2687 #[test]
2688 fn pr_sequence_sets_meta_trigger() {
2689 let yaml = "pr:\n - main\nsteps:\n - script: echo hello\n";
2691 let graph = parse(yaml);
2692 assert_eq!(
2693 graph.metadata.get(META_TRIGGER).map(|s| s.as_str()),
2694 Some("pr"),
2695 "pr: [main] must set META_TRIGGER=pr"
2696 );
2697 }
2698
2699 #[test]
2700 fn pr_with_branches_sets_meta_trigger() {
2701 let yaml = r#"
2703pr:
2704 branches:
2705 include:
2706 - main
2707steps:
2708 - script: echo hello
2709"#;
2710 let graph = parse(yaml);
2711 assert_eq!(
2712 graph.metadata.get(META_TRIGGER).map(|s| s.as_str()),
2713 Some("pr"),
2714 "real pr: block must set META_TRIGGER=pr"
2715 );
2716 }
2717
2718 #[test]
2722 fn over_privileged_identity_does_not_fire_when_permissions_contents_none() {
2723 use taudit_core::rules::over_privileged_identity;
2727 let yaml = r#"
2728trigger: none
2729permissions:
2730 contents: none
2731steps:
2732 - script: echo hello
2733"#;
2734 let graph = parse(yaml);
2735 let findings = over_privileged_identity(&graph);
2736 let token_findings: Vec<_> = findings
2737 .iter()
2738 .filter(|f| {
2739 f.nodes_involved.iter().any(|&id| {
2740 graph
2741 .node(id)
2742 .map(|n| n.name == "System.AccessToken")
2743 .unwrap_or(false)
2744 })
2745 })
2746 .collect();
2747 assert!(
2748 token_findings.is_empty(),
2749 "over_privileged_identity must not fire on System.AccessToken when \
2750 permissions: contents: none is set; got: {token_findings:#?}"
2751 );
2752 }
2753
2754 #[test]
2755 fn pipeline_level_permissions_none_constrains_token() {
2756 let yaml = r#"
2760trigger: none
2761permissions:
2762 contents: none
2763steps:
2764 - script: echo hello
2765"#;
2766 let graph = parse(yaml);
2767 let token = graph
2768 .nodes_of_kind(NodeKind::Identity)
2769 .find(|n| n.name == "System.AccessToken")
2770 .expect("System.AccessToken must always be present");
2771 assert_eq!(
2772 token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
2773 Some("constrained"),
2774 "permissions: contents: none must constrain the token; got: {:?}",
2775 token.metadata.get(META_IDENTITY_SCOPE)
2776 );
2777 }
2778
2779 #[test]
2780 fn pipeline_level_permissions_write_keeps_token_broad() {
2781 let yaml = r#"
2783trigger: none
2784permissions:
2785 contents: write
2786steps:
2787 - script: echo hello
2788"#;
2789 let graph = parse(yaml);
2790 let token = graph
2791 .nodes_of_kind(NodeKind::Identity)
2792 .find(|n| n.name == "System.AccessToken")
2793 .expect("System.AccessToken must always be present");
2794 assert_eq!(
2795 token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
2796 Some("broad"),
2797 "permissions: contents: write must keep the token broad; got: {:?}",
2798 token.metadata.get(META_IDENTITY_SCOPE)
2799 );
2800 }
2801
2802 #[test]
2803 fn pipeline_level_permissions_read_scalar_constrains_token() {
2804 let yaml = "trigger: none\npermissions: read\nsteps:\n - script: echo hello\n";
2807 let graph = parse(yaml);
2808 let token = graph
2809 .nodes_of_kind(NodeKind::Identity)
2810 .find(|n| n.name == "System.AccessToken")
2811 .expect("System.AccessToken must always be present");
2812 assert_eq!(
2813 token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
2814 Some("constrained"),
2815 "permissions: read must constrain the token; got: {:?}",
2816 token.metadata.get(META_IDENTITY_SCOPE)
2817 );
2818 }
2819
2820 #[test]
2821 fn pipeline_level_permissions_write_scalar_keeps_token_broad() {
2822 let yaml = "trigger: none\npermissions: write\nsteps:\n - script: echo hello\n";
2824 let graph = parse(yaml);
2825 let token = graph
2826 .nodes_of_kind(NodeKind::Identity)
2827 .find(|n| n.name == "System.AccessToken")
2828 .expect("System.AccessToken must always be present");
2829 assert_eq!(
2830 token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
2831 Some("broad"),
2832 "permissions: write scalar must keep token broad; got: {:?}",
2833 token.metadata.get(META_IDENTITY_SCOPE)
2834 );
2835 }
2836
2837 #[test]
2838 fn pipeline_level_permissions_contents_read_constrains_token() {
2839 let yaml =
2841 "trigger: none\npermissions:\n contents: read\nsteps:\n - script: echo hello\n";
2842 let graph = parse(yaml);
2843 let token = graph
2844 .nodes_of_kind(NodeKind::Identity)
2845 .find(|n| n.name == "System.AccessToken")
2846 .expect("System.AccessToken must always be present");
2847 assert_eq!(
2848 token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
2849 Some("constrained"),
2850 "permissions: contents: read must constrain; got: {:?}",
2851 token.metadata.get(META_IDENTITY_SCOPE)
2852 );
2853 }
2854
2855 #[test]
2856 fn empty_pipeline_does_not_mark_partial_for_zero_steps() {
2857 let yaml = r#"
2861trigger:
2862 - main
2863"#;
2864 let graph = parse(yaml);
2865 let zero_step_gap = graph
2866 .completeness_gaps
2867 .iter()
2868 .any(|g| g.contains("0 step nodes"));
2869 assert!(
2870 !zero_step_gap,
2871 "no carrier means no 0-step gap reason; got: {:?}",
2872 graph.completeness_gaps
2873 );
2874 }
2875}