Skip to main content

taudit_parse_ado/
lib.rs

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