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