use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use taudit_core::error::TauditError;
use taudit_core::graph::*;
use taudit_core::ports::PipelineParser;
fn script_does_terraform_auto_apply(s: &str) -> bool {
let lines: Vec<&str> = s.lines().collect();
for (i, raw_line) in lines.iter().enumerate() {
let line = raw_line.split('#').next().unwrap_or("");
if !(line.contains("terraform apply") || line.contains("terraform\tapply")) {
continue;
}
if line.contains("auto-approve") {
return true;
}
let mut continuing = line.trim_end().ends_with('\\') || line.trim_end().ends_with('`');
let mut j = i + 1;
while continuing && j < lines.len() && j < i + 4 {
let next = lines[j].split('#').next().unwrap_or("");
if next.contains("auto-approve") {
return true;
}
continuing = next.trim_end().ends_with('\\') || next.trim_end().ends_with('`');
j += 1;
}
}
false
}
pub struct AdoParser;
impl PipelineParser for AdoParser {
fn platform(&self) -> &str {
"azure-devops"
}
fn parse(&self, content: &str, source: &PipelineSource) -> Result<AuthorityGraph, TauditError> {
let mut de = serde_yaml::Deserializer::from_str(content);
let doc = de
.next()
.ok_or_else(|| TauditError::Parse("empty YAML document".into()))?;
let pipeline: AdoPipeline = match AdoPipeline::deserialize(doc) {
Ok(p) => p,
Err(e) => {
let msg = e.to_string();
let looks_like_template_fragment = (msg.contains("did not find expected key")
|| (msg.contains("parameters")
&& msg.contains("invalid type: map")
&& msg.contains("expected a sequence")))
&& has_root_parameter_conditional(content);
if looks_like_template_fragment {
let mut graph = AuthorityGraph::new(source.clone());
graph
.metadata
.insert(META_PLATFORM.into(), "azure-devops".into());
graph.mark_partial(
"ADO template fragment with top-level parameter conditional — root structure depends on parent pipeline context".to_string(),
);
return Ok(graph);
}
return Err(TauditError::Parse(format!("YAML parse error: {e}")));
}
};
let extra_docs = de.next().is_some();
let mut graph = AuthorityGraph::new(source.clone());
graph
.metadata
.insert(META_PLATFORM.into(), "azure-devops".into());
if extra_docs {
graph.mark_partial(
"file contains multiple YAML documents (--- separator) — only the first was analyzed".to_string(),
);
}
let has_pr_trigger = pipeline
.pr
.as_ref()
.map(|v| v.is_mapping() || v.is_sequence())
.unwrap_or(false);
if has_pr_trigger {
graph.metadata.insert(META_TRIGGER.into(), "pr".into());
}
process_repositories(&pipeline, content, &mut graph);
if let Some(ref params) = pipeline.parameters {
for p in params {
let name = match p.name.as_ref() {
Some(n) if !n.is_empty() => n.clone(),
_ => continue,
};
let param_type = p.param_type.clone().unwrap_or_default();
let has_values_allowlist =
p.values.as_ref().map(|v| !v.is_empty()).unwrap_or(false);
graph.parameters.insert(
name,
ParamSpec {
param_type,
has_values_allowlist,
},
);
}
}
let mut secret_ids: HashMap<String, NodeId> = HashMap::new();
let mut meta = HashMap::new();
meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
meta.insert(META_IMPLICIT.into(), "true".into());
let token_id = graph.add_node_with_metadata(
NodeKind::Identity,
"System.AccessToken",
TrustZone::FirstParty,
meta,
);
if let Some(ref perms_val) = pipeline.permissions {
if !ado_permissions_are_broad(perms_val) {
let perms_str = ado_permissions_display(perms_val);
graph.nodes[token_id]
.metadata
.insert(META_IDENTITY_SCOPE.into(), "constrained".into());
graph.nodes[token_id]
.metadata
.insert(META_PERMISSIONS.into(), perms_str);
}
}
process_pool(&pipeline.pool, &pipeline.workspace, &mut graph);
let mut plain_vars: HashSet<String> = HashSet::new();
let pipeline_secret_ids = process_variables(
&pipeline.variables,
&mut graph,
&mut secret_ids,
"pipeline",
&mut plain_vars,
);
if let Some(ref stages) = pipeline.stages {
for stage in stages {
if let Some(ref tpl) = stage.template {
let stage_name = stage.stage.as_deref().unwrap_or("stage");
add_template_delegation(stage_name, tpl, token_id, None, &mut graph);
continue;
}
let stage_name = stage.stage.as_deref().unwrap_or("stage").to_string();
let stage_secret_ids = process_variables(
&stage.variables,
&mut graph,
&mut secret_ids,
&stage_name,
&mut plain_vars,
);
for job in &stage.jobs {
let job_name = job.effective_name();
let job_secret_ids = process_variables(
&job.variables,
&mut graph,
&mut secret_ids,
&job_name,
&mut plain_vars,
);
let effective_workspace =
job.workspace.as_ref().or(pipeline.workspace.as_ref());
process_pool(&job.pool, &effective_workspace.cloned(), &mut graph);
let all_secrets: Vec<NodeId> = pipeline_secret_ids
.iter()
.chain(&stage_secret_ids)
.chain(&job_secret_ids)
.copied()
.collect();
let steps_start = graph.nodes.len();
let job_steps = job.all_steps();
process_steps(
&job_steps,
&job_name,
token_id,
&all_secrets,
&plain_vars,
&mut graph,
&mut secret_ids,
);
if let Some(ref tpl) = job.template {
add_template_delegation(
&job_name,
tpl,
token_id,
Some(&job_name),
&mut graph,
);
}
if job.has_environment_binding() {
tag_job_steps_env_approval(&mut graph, steps_start);
}
}
}
} else if let Some(ref jobs) = pipeline.jobs {
for job in jobs {
let job_name = job.effective_name();
let job_secret_ids = process_variables(
&job.variables,
&mut graph,
&mut secret_ids,
&job_name,
&mut plain_vars,
);
let effective_workspace = job.workspace.as_ref().or(pipeline.workspace.as_ref());
process_pool(&job.pool, &effective_workspace.cloned(), &mut graph);
let all_secrets: Vec<NodeId> = pipeline_secret_ids
.iter()
.chain(&job_secret_ids)
.copied()
.collect();
let steps_start = graph.nodes.len();
let job_steps = job.all_steps();
process_steps(
&job_steps,
&job_name,
token_id,
&all_secrets,
&plain_vars,
&mut graph,
&mut secret_ids,
);
if let Some(ref tpl) = job.template {
add_template_delegation(&job_name, tpl, token_id, Some(&job_name), &mut graph);
}
if job.has_environment_binding() {
tag_job_steps_env_approval(&mut graph, steps_start);
}
}
} else if let Some(ref steps) = pipeline.steps {
process_steps(
steps,
"pipeline",
token_id,
&pipeline_secret_ids,
&plain_vars,
&mut graph,
&mut secret_ids,
);
}
let step_count = graph
.nodes
.iter()
.filter(|n| n.kind == NodeKind::Step)
.count();
let had_step_carrier = pipeline.stages.as_ref().is_some_and(|s| !s.is_empty())
|| pipeline.jobs.as_ref().is_some_and(|j| !j.is_empty())
|| pipeline.steps.as_ref().is_some_and(|s| !s.is_empty());
if step_count == 0 && had_step_carrier {
graph.mark_partial(
"stages/jobs/steps parsed but produced 0 step nodes — possible non-ADO YAML wrong-platform-classified".to_string(),
);
}
Ok(graph)
}
}
fn ado_permissions_are_broad(perms: &serde_yaml::Value) -> bool {
if let Some(map) = perms.as_mapping() {
map.values().any(|v| v.as_str() == Some("write"))
} else {
matches!(perms.as_str(), Some("write"))
}
}
fn ado_permissions_display(perms: &serde_yaml::Value) -> String {
if let Some(map) = perms.as_mapping() {
map.iter()
.filter_map(|(k, v)| {
let key = k.as_str()?;
let val = v.as_str().unwrap_or("?");
Some(format!("{key}: {val}"))
})
.collect::<Vec<_>>()
.join(", ")
} else {
perms.as_str().unwrap_or("none").to_string()
}
}
fn process_pool(
pool: &Option<serde_yaml::Value>,
workspace: &Option<serde_yaml::Value>,
graph: &mut AuthorityGraph,
) {
let Some(pool_val) = pool else {
return;
};
let (image_name, is_self_hosted) = match pool_val {
serde_yaml::Value::String(s) => (s.clone(), true),
serde_yaml::Value::Mapping(map) => {
let name = map.get("name").and_then(|v| v.as_str());
let vm_image = map.get("vmImage").and_then(|v| v.as_str());
match (name, vm_image) {
(_, Some(vm)) => (vm.to_string(), false),
(Some(n), None) => (n.to_string(), true),
(None, None) => return,
}
}
_ => return,
};
let mut meta = HashMap::new();
if is_self_hosted {
meta.insert(META_SELF_HOSTED.into(), "true".into());
}
if has_workspace_clean(workspace) {
meta.insert(META_WORKSPACE_CLEAN.into(), "true".into());
}
graph.add_node_with_metadata(NodeKind::Image, image_name, TrustZone::FirstParty, meta);
}
fn has_workspace_clean(workspace: &Option<serde_yaml::Value>) -> bool {
let Some(ws) = workspace else {
return false;
};
let Some(map) = ws.as_mapping() else {
return false;
};
let Some(clean) = map.get("clean") else {
return false;
};
match clean {
serde_yaml::Value::Bool(b) => *b,
serde_yaml::Value::String(s) => {
let lower = s.to_ascii_lowercase();
matches!(lower.as_str(), "all" | "outputs" | "resources" | "true")
}
_ => false,
}
}
fn process_repositories(pipeline: &AdoPipeline, raw_content: &str, graph: &mut AuthorityGraph) {
let resources = match pipeline.resources.as_ref() {
Some(r) if !r.repositories.is_empty() => r,
_ => return,
};
let mut used_aliases: HashSet<String> = HashSet::new();
if let Some(ref ext) = pipeline.extends {
collect_template_alias_refs(ext, &mut used_aliases);
}
if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(raw_content) {
collect_template_alias_refs(&value, &mut used_aliases);
collect_checkout_alias_refs(&value, &mut used_aliases);
}
let mut entries: Vec<serde_json::Value> = Vec::with_capacity(resources.repositories.len());
for repo in &resources.repositories {
let used = used_aliases.contains(&repo.repository);
let mut obj = serde_json::Map::new();
obj.insert(
"alias".into(),
serde_json::Value::String(repo.repository.clone()),
);
if let Some(ref t) = repo.repo_type {
obj.insert("repo_type".into(), serde_json::Value::String(t.clone()));
}
if let Some(ref n) = repo.name {
obj.insert("name".into(), serde_json::Value::String(n.clone()));
}
if let Some(ref r) = repo.git_ref {
obj.insert("ref".into(), serde_json::Value::String(r.clone()));
}
obj.insert("used".into(), serde_json::Value::Bool(used));
entries.push(serde_json::Value::Object(obj));
}
if let Ok(json) = serde_json::to_string(&serde_json::Value::Array(entries)) {
graph.metadata.insert(META_REPOSITORIES.into(), json);
}
}
fn collect_template_alias_refs(value: &serde_yaml::Value, sink: &mut HashSet<String>) {
match value {
serde_yaml::Value::Mapping(map) => {
for (k, v) in map {
if k.as_str() == Some("template") {
if let Some(s) = v.as_str() {
if let Some(alias) = parse_template_alias(s) {
sink.insert(alias);
}
}
}
collect_template_alias_refs(v, sink);
}
}
serde_yaml::Value::Sequence(seq) => {
for v in seq {
collect_template_alias_refs(v, sink);
}
}
_ => {}
}
}
fn collect_checkout_alias_refs(value: &serde_yaml::Value, sink: &mut HashSet<String>) {
match value {
serde_yaml::Value::Mapping(map) => {
for (k, v) in map {
if k.as_str() == Some("checkout") {
if let Some(s) = v.as_str() {
if s != "self" && s != "none" && !s.is_empty() {
sink.insert(s.to_string());
}
}
}
collect_checkout_alias_refs(v, sink);
}
}
serde_yaml::Value::Sequence(seq) => {
for v in seq {
collect_checkout_alias_refs(v, sink);
}
}
_ => {}
}
}
fn parse_template_alias(template_ref: &str) -> Option<String> {
let at = template_ref.rfind('@')?;
let alias = &template_ref[at + 1..];
if alias.is_empty() {
None
} else {
Some(alias.to_string())
}
}
fn tag_job_steps_env_approval(graph: &mut AuthorityGraph, start_idx: usize) {
for node in graph.nodes.iter_mut().skip(start_idx) {
if node.kind == NodeKind::Step {
node.metadata
.insert(META_ENV_APPROVAL.into(), "true".into());
}
}
}
fn process_variables(
variables: &Option<AdoVariables>,
graph: &mut AuthorityGraph,
cache: &mut HashMap<String, NodeId>,
scope: &str,
plain_vars: &mut HashSet<String>,
) -> Vec<NodeId> {
let mut ids = Vec::new();
let vars = match variables.as_ref() {
Some(v) => v,
None => return ids,
};
for var in &vars.0 {
match var {
AdoVariable::Group { group } => {
if group.contains("${{") {
graph.mark_partial(format!(
"variable group in {scope} uses template expression — group name unresolvable at parse time"
));
continue;
}
let mut meta = HashMap::new();
meta.insert(META_VARIABLE_GROUP.into(), "true".into());
let id = graph.add_node_with_metadata(
NodeKind::Secret,
group.as_str(),
TrustZone::FirstParty,
meta,
);
cache.insert(group.clone(), id);
ids.push(id);
graph.mark_partial(format!(
"variable group '{group}' in {scope} — contents unresolvable without ADO API access"
));
}
AdoVariable::Named {
name, is_secret, ..
} => {
if *is_secret {
let id = find_or_create_secret(graph, cache, name);
ids.push(id);
} else {
plain_vars.insert(name.clone());
}
}
}
}
ids
}
fn process_steps(
steps: &[AdoStep],
job_name: &str,
token_id: NodeId,
inherited_secrets: &[NodeId],
plain_vars: &HashSet<String>,
graph: &mut AuthorityGraph,
cache: &mut HashMap<String, NodeId>,
) {
for (idx, step) in steps.iter().enumerate() {
if let Some(ref tpl) = step.template {
let step_name = step
.display_name
.as_deref()
.or(step.name.as_deref())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{job_name}[{idx}]"));
add_template_delegation(&step_name, tpl, token_id, Some(job_name), graph);
continue;
}
let (step_name, trust_zone, mut inline_script) = classify_step(step, job_name, idx);
if inline_script.is_none() {
if let Some(ref inputs) = step.inputs {
let candidate_keys = ["inlineScript", "script", "InlineScript", "Inline"];
for key in candidate_keys {
if let Some(v) = inputs.get(key).and_then(yaml_value_as_str) {
if !v.is_empty() {
inline_script = Some(v.to_string());
break;
}
}
}
}
}
let step_id = graph.add_node(NodeKind::Step, &step_name, trust_zone);
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata.insert(META_JOB_NAME.into(), job_name.into());
if let Some(ref body) = inline_script {
node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
}
}
if let Some(ref body) = inline_script {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
}
}
if let Some(ref body) = inline_script {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata.insert(META_SCRIPT_BODY.into(), body.clone());
}
}
graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
if step.checkout.is_some() && step.persist_credentials == Some(true) {
graph.add_edge(step_id, token_id, EdgeKind::PersistsTo);
}
if let Some(ref ck) = step.checkout {
if ck == "self" {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata
.insert(META_CHECKOUT_SELF.into(), "true".into());
}
}
}
for &secret_id in inherited_secrets {
graph.add_edge(step_id, secret_id, EdgeKind::HasAccessTo);
}
if let Some(ref inputs) = step.inputs {
let service_conn_keys = [
"azuresubscription",
"connectedservicename",
"connectedservicenamearm",
"kubernetesserviceconnection",
"environmentservicename",
"backendservicearm",
];
for (raw_key, val) in inputs {
let lower = raw_key.to_lowercase();
if !service_conn_keys.contains(&lower.as_str()) {
continue;
}
let conn_name = yaml_value_as_str(val).unwrap_or(raw_key.as_str());
if !conn_name.starts_with("$(") {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata
.insert(META_SERVICE_CONNECTION_NAME.into(), conn_name.to_string());
}
let mut meta = HashMap::new();
meta.insert(META_SERVICE_CONNECTION.into(), "true".into());
meta.insert(META_IDENTITY_SCOPE.into(), "broad".into());
let conn_id = graph.add_node_with_metadata(
NodeKind::Identity,
conn_name,
TrustZone::FirstParty,
meta,
);
graph.add_edge(step_id, conn_id, EdgeKind::HasAccessTo);
}
}
if let Some(val) = inputs.get("addSpnToEnvironment") {
let truthy = match val {
serde_yaml::Value::Bool(b) => *b,
serde_yaml::Value::String(s) => s.eq_ignore_ascii_case("true"),
_ => false,
};
if truthy {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata
.insert(META_ADD_SPN_TO_ENV.into(), "true".into());
}
}
}
let task_lower = step
.task
.as_deref()
.map(|t| t.to_lowercase())
.unwrap_or_default();
let is_terraform_task = task_lower.starts_with("terraformcli@")
|| task_lower.starts_with("terraformtask@")
|| task_lower.starts_with("terraformtaskv");
if is_terraform_task {
let cmd_lower = inputs
.get("command")
.and_then(yaml_value_as_str)
.map(|s| s.to_lowercase())
.unwrap_or_default();
let opts = inputs
.get("commandOptions")
.and_then(yaml_value_as_str)
.unwrap_or("");
if cmd_lower == "apply" && opts.contains("auto-approve") {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata
.insert(META_TERRAFORM_AUTO_APPROVE.into(), "true".into());
}
}
}
for val in inputs.values() {
if let Some(s) = yaml_value_as_str(val) {
extract_dollar_paren_secrets(s, step_id, plain_vars, graph, cache);
}
}
}
if let Some(ref body) = inline_script {
if script_does_terraform_auto_apply(body) {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata
.insert(META_TERRAFORM_AUTO_APPROVE.into(), "true".into());
}
}
}
if let Some(ref env) = step.env {
for val in env.values() {
extract_dollar_paren_secrets(val, step_id, plain_vars, graph, cache);
}
}
if let Some(ref script) = inline_script {
extract_dollar_paren_secrets(script, step_id, plain_vars, graph, cache);
}
if let Some(ref script) = inline_script {
let lower = script.to_lowercase();
if lower.contains("##vso[task.setvariable") {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata
.insert(META_WRITES_ENV_GATE.into(), "true".into());
}
}
}
}
}
fn classify_step(
step: &AdoStep,
job_name: &str,
idx: usize,
) -> (String, TrustZone, Option<String>) {
let default_name = || format!("{job_name}[{idx}]");
let name = step
.display_name
.as_deref()
.or(step.name.as_deref())
.map(|s| s.to_string())
.unwrap_or_else(default_name);
if step.task.is_some() {
let inline = extract_task_inline_script(step.inputs.as_ref());
(name, TrustZone::Untrusted, inline)
} else if let Some(ref s) = step.script {
(name, TrustZone::FirstParty, Some(s.clone()))
} else if let Some(ref s) = step.bash {
(name, TrustZone::FirstParty, Some(s.clone()))
} else if let Some(ref s) = step.powershell {
(name, TrustZone::FirstParty, Some(s.clone()))
} else if let Some(ref s) = step.pwsh {
(name, TrustZone::FirstParty, Some(s.clone()))
} else {
(name, TrustZone::FirstParty, None)
}
}
fn extract_task_inline_script(
inputs: Option<&HashMap<String, serde_yaml::Value>>,
) -> Option<String> {
let inputs = inputs?;
const KEYS: &[&str] = &["script", "inlinescript", "inline"];
for (raw_key, val) in inputs {
let lower = raw_key.to_lowercase();
if KEYS.contains(&lower.as_str()) {
if let Some(s) = val.as_str() {
if !s.is_empty() {
return Some(s.to_string());
}
}
}
}
None
}
fn add_template_delegation(
step_name: &str,
template_path: &str,
token_id: NodeId,
job_name: Option<&str>,
graph: &mut AuthorityGraph,
) {
let tpl_trust_zone = if template_path.contains('@') {
TrustZone::Untrusted
} else {
TrustZone::FirstParty
};
let step_id = graph.add_node(NodeKind::Step, step_name, TrustZone::FirstParty);
if let Some(jn) = job_name {
if let Some(node) = graph.nodes.get_mut(step_id) {
node.metadata.insert(META_JOB_NAME.into(), jn.into());
}
}
let tpl_id = graph.add_node(NodeKind::Image, template_path, tpl_trust_zone);
graph.add_edge(step_id, tpl_id, EdgeKind::DelegatesTo);
graph.add_edge(step_id, token_id, EdgeKind::HasAccessTo);
graph.mark_partial(format!(
"template '{template_path}' cannot be resolved inline — authority within the template is unknown"
));
}
fn extract_dollar_paren_secrets(
text: &str,
step_id: NodeId,
plain_vars: &HashSet<String>,
graph: &mut AuthorityGraph,
cache: &mut HashMap<String, NodeId>,
) {
let mut pos = 0;
let bytes = text.as_bytes();
while pos < bytes.len() {
if pos + 2 < bytes.len() && bytes[pos] == b'$' && bytes[pos + 1] == b'(' {
let start = pos + 2;
if let Some(end_offset) = text[start..].find(')') {
let var_name = &text[start..start + end_offset];
if is_valid_ado_identifier(var_name)
&& !is_predefined_ado_var(var_name)
&& !plain_vars.contains(var_name)
{
let id = find_or_create_secret(graph, cache, var_name);
if is_in_terraform_var_flag(text, pos) {
if let Some(node) = graph.nodes.get_mut(id) {
node.metadata
.insert(META_CLI_FLAG_EXPOSED.into(), "true".into());
}
}
graph.add_edge(step_id, id, EdgeKind::HasAccessTo);
}
pos = start + end_offset + 1;
continue;
}
}
pos += 1;
}
}
fn is_in_terraform_var_flag(text: &str, var_pos: usize) -> bool {
let line_start = text[..var_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
let line_before = &text[line_start..var_pos];
line_before.contains("-var") && line_before.contains('=')
}
fn is_valid_ado_identifier(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
_ => false,
}
}
fn is_predefined_ado_var(name: &str) -> bool {
let prefixes = [
"Build.",
"Agent.",
"System.",
"Pipeline.",
"Release.",
"Environment.",
"Strategy.",
"Deployment.",
"Resources.",
"TF_BUILD",
];
prefixes.iter().any(|p| name.starts_with(p)) || name == "TF_BUILD"
}
fn find_or_create_secret(
graph: &mut AuthorityGraph,
cache: &mut HashMap<String, NodeId>,
name: &str,
) -> NodeId {
if let Some(&id) = cache.get(name) {
return id;
}
let id = graph.add_node(NodeKind::Secret, name, TrustZone::FirstParty);
cache.insert(name.to_string(), id);
id
}
fn yaml_value_as_str(val: &serde_yaml::Value) -> Option<&str> {
val.as_str()
}
#[derive(Debug, Deserialize)]
pub struct AdoPipeline {
#[serde(default)]
pub trigger: Option<serde_yaml::Value>,
#[serde(default)]
pub pr: Option<serde_yaml::Value>,
#[serde(default)]
pub variables: Option<AdoVariables>,
#[serde(default, deserialize_with = "deserialize_optional_stages")]
pub stages: Option<Vec<AdoStage>>,
#[serde(default)]
pub jobs: Option<Vec<AdoJob>>,
#[serde(default)]
pub steps: Option<Vec<AdoStep>>,
#[serde(default)]
pub pool: Option<serde_yaml::Value>,
#[serde(default)]
pub workspace: Option<serde_yaml::Value>,
#[serde(default, deserialize_with = "deserialize_optional_resources")]
pub resources: Option<AdoResources>,
#[serde(default)]
pub extends: Option<serde_yaml::Value>,
#[serde(default, deserialize_with = "deserialize_optional_parameters")]
pub parameters: Option<Vec<AdoParameter>>,
#[serde(default)]
pub permissions: Option<serde_yaml::Value>,
}
fn deserialize_optional_parameters<'de, D>(
deserializer: D,
) -> Result<Option<Vec<AdoParameter>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{MapAccess, SeqAccess, Visitor};
use std::fmt;
struct ParamsVisitor;
impl<'de> Visitor<'de> for ParamsVisitor {
type Value = Option<Vec<AdoParameter>>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a sequence of parameter declarations, a mapping of name→default, null, or a template expression")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_some<D: serde::Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
d.deserialize_any(self)
}
fn visit_str<E: serde::de::Error>(self, _v: &str) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_string<E: serde::de::Error>(self, _v: String) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_bool<E: serde::de::Error>(self, _v: bool) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_i64<E: serde::de::Error>(self, _v: i64) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_u64<E: serde::de::Error>(self, _v: u64) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_f64<E: serde::de::Error>(self, _v: f64) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut out = Vec::new();
while let Some(item) = seq.next_element::<serde_yaml::Value>()? {
if let Ok(p) = serde_yaml::from_value::<AdoParameter>(item) {
out.push(p);
}
}
Ok(Some(out))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut out = Vec::new();
while let Some(key) = map.next_key::<serde_yaml::Value>()? {
let _ignore = map.next_value::<serde::de::IgnoredAny>()?;
let name = match key {
serde_yaml::Value::String(s) if !s.is_empty() => s,
_ => continue,
};
out.push(AdoParameter {
name: Some(name),
param_type: None,
values: None,
});
}
Ok(Some(out))
}
}
deserializer.deserialize_any(ParamsVisitor)
}
fn deserialize_optional_resources<'de, D>(deserializer: D) -> Result<Option<AdoResources>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{MapAccess, SeqAccess, Visitor};
use std::fmt;
struct ResourcesVisitor;
impl<'de> Visitor<'de> for ResourcesVisitor {
type Value = Option<AdoResources>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an AdoResources mapping or a legacy `- repo:` sequence")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_some<D: serde::Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
d.deserialize_any(self)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(Some(AdoResources::default()))
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
let r = AdoResources::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
Ok(Some(r))
}
}
deserializer.deserialize_any(ResourcesVisitor)
}
fn deserialize_optional_stages<'de, D>(deserializer: D) -> Result<Option<Vec<AdoStage>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{SeqAccess, Visitor};
use std::fmt;
struct StagesVisitor;
impl<'de> Visitor<'de> for StagesVisitor {
type Value = Option<Vec<AdoStage>>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a sequence of stages or a template expression")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_some<D: serde::Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
d.deserialize_any(self)
}
fn visit_str<E: serde::de::Error>(self, _v: &str) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_string<E: serde::de::Error>(self, _v: String) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
let stages =
Vec::<AdoStage>::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?;
Ok(Some(stages))
}
}
deserializer.deserialize_any(StagesVisitor)
}
#[derive(Debug, Default, Deserialize)]
pub struct AdoResources {
#[serde(default)]
pub repositories: Vec<AdoRepository>,
}
#[derive(Debug, Deserialize)]
pub struct AdoRepository {
pub repository: String,
#[serde(default, rename = "type")]
pub repo_type: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default, rename = "ref")]
pub git_ref: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AdoParameter {
#[serde(default)]
pub name: Option<String>,
#[serde(rename = "type", default)]
pub param_type: Option<String>,
#[serde(default)]
pub values: Option<Vec<serde_yaml::Value>>,
}
#[derive(Debug, Deserialize)]
pub struct AdoStage {
#[serde(default)]
pub stage: Option<String>,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub variables: Option<AdoVariables>,
#[serde(default)]
pub jobs: Vec<AdoJob>,
}
#[derive(Debug, Deserialize)]
pub struct AdoJob {
#[serde(default)]
pub job: Option<String>,
#[serde(default)]
pub deployment: Option<String>,
#[serde(default)]
pub variables: Option<AdoVariables>,
#[serde(default)]
pub steps: Option<Vec<AdoStep>>,
#[serde(default)]
pub strategy: Option<AdoStrategy>,
#[serde(default)]
pub pool: Option<serde_yaml::Value>,
#[serde(default)]
pub workspace: Option<serde_yaml::Value>,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub environment: Option<serde_yaml::Value>,
}
impl AdoJob {
pub fn effective_name(&self) -> String {
self.job
.as_deref()
.or(self.deployment.as_deref())
.unwrap_or("job")
.to_string()
}
pub fn all_steps(&self) -> Vec<AdoStep> {
let mut out: Vec<AdoStep> = Vec::new();
if let Some(ref s) = self.steps {
out.extend(s.iter().cloned());
}
if let Some(ref strat) = self.strategy {
for phase in strat.phases() {
if let Some(ref s) = phase.steps {
out.extend(s.iter().cloned());
}
}
}
out
}
pub fn has_environment_binding(&self) -> bool {
match self.environment.as_ref() {
None => false,
Some(serde_yaml::Value::String(s)) => !s.trim().is_empty(),
Some(serde_yaml::Value::Mapping(m)) => m
.get("name")
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false),
_ => false,
}
}
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct AdoStrategy {
#[serde(default, rename = "runOnce")]
pub run_once: Option<AdoStrategyRunOnce>,
#[serde(default)]
pub rolling: Option<AdoStrategyRunOnce>,
#[serde(default)]
pub canary: Option<AdoStrategyRunOnce>,
}
impl AdoStrategy {
pub fn phases(&self) -> Vec<&AdoStrategyPhase> {
let mut out: Vec<&AdoStrategyPhase> = Vec::new();
for runner in [&self.run_once, &self.rolling, &self.canary]
.iter()
.copied()
.flatten()
{
for phase in [
&runner.deploy,
&runner.pre_deploy,
&runner.post_deploy,
&runner.route_traffic,
]
.into_iter()
.flatten()
{
out.push(phase);
}
if let Some(ref on) = runner.on {
if let Some(ref s) = on.success {
out.push(s);
}
if let Some(ref f) = on.failure {
out.push(f);
}
}
}
out
}
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct AdoStrategyRunOnce {
#[serde(default)]
pub deploy: Option<AdoStrategyPhase>,
#[serde(default, rename = "preDeploy")]
pub pre_deploy: Option<AdoStrategyPhase>,
#[serde(default, rename = "postDeploy")]
pub post_deploy: Option<AdoStrategyPhase>,
#[serde(default, rename = "routeTraffic")]
pub route_traffic: Option<AdoStrategyPhase>,
#[serde(default)]
pub on: Option<AdoStrategyOn>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct AdoStrategyOn {
#[serde(default)]
pub success: Option<AdoStrategyPhase>,
#[serde(default)]
pub failure: Option<AdoStrategyPhase>,
}
#[derive(Debug, Default, Deserialize, Clone)]
pub struct AdoStrategyPhase {
#[serde(default)]
pub steps: Option<Vec<AdoStep>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AdoStep {
#[serde(default)]
pub task: Option<String>,
#[serde(default)]
pub script: Option<String>,
#[serde(default)]
pub bash: Option<String>,
#[serde(default)]
pub powershell: Option<String>,
#[serde(default)]
pub pwsh: Option<String>,
#[serde(default)]
pub template: Option<String>,
#[serde(rename = "displayName", default)]
pub display_name: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub env: Option<HashMap<String, String>>,
#[serde(default)]
pub inputs: Option<HashMap<String, serde_yaml::Value>>,
#[serde(default)]
pub checkout: Option<String>,
#[serde(rename = "persistCredentials", default)]
pub persist_credentials: Option<bool>,
}
#[derive(Debug, Default)]
pub struct AdoVariables(pub Vec<AdoVariable>);
impl<'de> serde::Deserialize<'de> for AdoVariables {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_yaml::Value::deserialize(deserializer)?;
let mut vars = Vec::new();
match raw {
serde_yaml::Value::Sequence(seq) => {
for item in seq {
if let Some(map) = item.as_mapping() {
if let Some(group_val) = map.get("group") {
if let Some(group) = group_val.as_str() {
vars.push(AdoVariable::Group {
group: group.to_string(),
});
continue;
}
}
let name = map
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let value = map
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let is_secret = map
.get("isSecret")
.and_then(|v| v.as_bool())
.unwrap_or(false);
vars.push(AdoVariable::Named {
name,
value,
is_secret,
});
}
}
}
serde_yaml::Value::Mapping(map) => {
for (k, v) in map {
let name = k.as_str().unwrap_or("").to_string();
let value = v.as_str().unwrap_or("").to_string();
vars.push(AdoVariable::Named {
name,
value,
is_secret: false,
});
}
}
_ => {}
}
Ok(AdoVariables(vars))
}
}
#[derive(Debug)]
pub enum AdoVariable {
Group {
group: String,
},
Named {
name: String,
value: String,
is_secret: bool,
},
}
fn has_root_parameter_conditional(content: &str) -> bool {
for line in content.lines() {
let trimmed = line.trim_start();
let candidate = trimmed.strip_prefix("- ").unwrap_or(trimmed);
if candidate.starts_with("${{")
&& (candidate.contains("if ") || candidate.contains("if("))
&& candidate.trim_end().ends_with(":")
{
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(yaml: &str) -> AuthorityGraph {
let parser = AdoParser;
let source = PipelineSource {
file: "azure-pipelines.yml".into(),
repo: None,
git_ref: None,
commit_sha: None,
};
parser.parse(yaml, &source).unwrap()
}
#[test]
fn parses_simple_pipeline() {
let yaml = r#"
trigger:
- main
jobs:
- job: Build
steps:
- script: echo hello
displayName: Say hello
"#;
let graph = parse(yaml);
assert!(graph.nodes.len() >= 2); }
#[test]
fn system_access_token_created() {
let yaml = r#"
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
assert_eq!(identities.len(), 1);
assert_eq!(identities[0].name, "System.AccessToken");
assert_eq!(
identities[0].metadata.get(META_IDENTITY_SCOPE),
Some(&"broad".to_string())
);
}
#[test]
fn variable_group_creates_secret_and_marks_partial() {
let yaml = r#"
variables:
- group: MySecretGroup
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].name, "MySecretGroup");
assert_eq!(
secrets[0].metadata.get(META_VARIABLE_GROUP),
Some(&"true".to_string())
);
assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
assert!(
graph
.completeness_gaps
.iter()
.any(|g| g.contains("MySecretGroup")),
"completeness gap should name the variable group"
);
}
#[test]
fn task_with_azure_subscription_creates_service_connection_identity() {
let yaml = r#"
steps:
- task: AzureCLI@2
displayName: Deploy to Azure
inputs:
azureSubscription: MyServiceConnection
scriptType: bash
inlineScript: az group list
"#;
let graph = parse(yaml);
let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
assert_eq!(identities.len(), 2);
let conn = identities
.iter()
.find(|i| i.name == "MyServiceConnection")
.unwrap();
assert_eq!(
conn.metadata.get(META_SERVICE_CONNECTION),
Some(&"true".to_string())
);
assert_eq!(
conn.metadata.get(META_IDENTITY_SCOPE),
Some(&"broad".to_string())
);
}
#[test]
fn service_connection_does_not_get_unconditional_oidc_tag() {
let yaml = r#"
steps:
- task: AzureCLI@2
displayName: Deploy to Azure
inputs:
azureSubscription: MyClassicSpnConnection
scriptType: bash
inlineScript: az group list
"#;
let graph = parse(yaml);
let conn = graph
.nodes_of_kind(NodeKind::Identity)
.find(|i| i.name == "MyClassicSpnConnection")
.expect("service connection identity should exist");
assert_eq!(
conn.metadata.get(META_OIDC),
None,
"service connections must not be tagged META_OIDC without a clear OIDC signal"
);
}
#[test]
fn task_with_connected_service_name_creates_identity() {
let yaml = r#"
steps:
- task: SqlAzureDacpacDeployment@1
inputs:
ConnectedServiceNameARM: MySqlConnection
"#;
let graph = parse(yaml);
let identities: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
assert!(
identities.iter().any(|i| i.name == "MySqlConnection"),
"connectedServiceNameARM should create identity"
);
}
#[test]
fn script_step_classified_as_first_party() {
let yaml = r#"
steps:
- script: echo hi
displayName: Say hi
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
}
#[test]
fn bash_step_classified_as_first_party() {
let yaml = r#"
steps:
- bash: echo hi
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps[0].trust_zone, TrustZone::FirstParty);
}
#[test]
fn task_step_classified_as_untrusted() {
let yaml = r#"
steps:
- task: DotNetCoreCLI@2
inputs:
command: build
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].trust_zone, TrustZone::Untrusted);
}
#[test]
fn dollar_paren_var_in_script_creates_secret() {
let yaml = r#"
steps:
- script: |
curl -H "Authorization: $(MY_API_TOKEN)" https://api.example.com
displayName: Call API
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].name, "MY_API_TOKEN");
}
#[test]
fn predefined_ado_var_not_treated_as_secret() {
let yaml = r#"
steps:
- script: |
echo $(Build.BuildId)
echo $(Agent.WorkFolder)
echo $(System.DefaultWorkingDirectory)
displayName: Print vars
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert!(
secrets.is_empty(),
"predefined ADO vars should not be treated as secrets, got: {:?}",
secrets.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
#[test]
fn template_reference_creates_delegates_to_and_marks_partial() {
let yaml = r#"
steps:
- template: steps/deploy.yml
parameters:
env: production
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 1);
let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
assert_eq!(images.len(), 1);
assert_eq!(images[0].name, "steps/deploy.yml");
let delegates: Vec<_> = graph
.edges_from(steps[0].id)
.filter(|e| e.kind == EdgeKind::DelegatesTo)
.collect();
assert_eq!(delegates.len(), 1);
assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
}
#[test]
fn top_level_steps_no_jobs() {
let yaml = r#"
steps:
- script: echo a
- script: echo b
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 2);
}
#[test]
fn top_level_jobs_no_stages() {
let yaml = r#"
jobs:
- job: JobA
steps:
- script: echo a
- job: JobB
steps:
- script: echo b
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 2);
}
#[test]
fn stages_with_nested_jobs_parsed() {
let yaml = r#"
stages:
- stage: Build
jobs:
- job: Compile
steps:
- script: cargo build
- stage: Test
jobs:
- job: UnitTest
steps:
- script: cargo test
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 2);
}
#[test]
fn all_steps_linked_to_system_access_token() {
let yaml = r#"
steps:
- script: echo a
- task: SomeTask@1
inputs: {}
"#;
let graph = parse(yaml);
let token: Vec<_> = graph.nodes_of_kind(NodeKind::Identity).collect();
assert_eq!(token.len(), 1);
let token_id = token[0].id;
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
for step in &steps {
let links: Vec<_> = graph
.edges_from(step.id)
.filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == token_id)
.collect();
assert_eq!(
links.len(),
1,
"step '{}' must link to System.AccessToken",
step.name
);
}
}
#[test]
fn named_secret_variable_creates_secret_node() {
let yaml = r#"
variables:
- name: MY_PASSWORD
value: dummy
isSecret: true
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].name, "MY_PASSWORD");
}
#[test]
fn variables_as_mapping_parsed() {
let yaml = r#"
variables:
MY_VAR: hello
ANOTHER_VAR: world
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert!(
secrets.is_empty(),
"plain mapping vars should not create secret nodes"
);
}
#[test]
fn persist_credentials_creates_persists_to_edge() {
let yaml = r#"
steps:
- checkout: self
persistCredentials: true
- script: git push
"#;
let graph = parse(yaml);
let token_id = graph
.nodes_of_kind(NodeKind::Identity)
.find(|n| n.name == "System.AccessToken")
.expect("System.AccessToken must exist")
.id;
let persists_edges: Vec<_> = graph
.edges
.iter()
.filter(|e| e.kind == EdgeKind::PersistsTo && e.to == token_id)
.collect();
assert_eq!(
persists_edges.len(),
1,
"checkout with persistCredentials: true must produce exactly one PersistsTo edge"
);
}
#[test]
fn checkout_without_persist_credentials_no_persists_to_edge() {
let yaml = r#"
steps:
- checkout: self
- script: echo hi
"#;
let graph = parse(yaml);
let persists_edges: Vec<_> = graph
.edges
.iter()
.filter(|e| e.kind == EdgeKind::PersistsTo)
.collect();
assert!(
persists_edges.is_empty(),
"checkout without persistCredentials should not produce PersistsTo edge"
);
}
#[test]
fn var_flag_secret_marked_as_cli_flag_exposed() {
let yaml = r#"
steps:
- script: |
terraform apply \
-var "db_password=$(db_password)" \
-var "api_key=$(api_key)"
displayName: Terraform apply
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert!(!secrets.is_empty(), "should detect secrets from -var flags");
for secret in &secrets {
assert_eq!(
secret.metadata.get(META_CLI_FLAG_EXPOSED),
Some(&"true".to_string()),
"secret '{}' passed via -var flag should be marked cli_flag_exposed",
secret.name
);
}
}
#[test]
fn non_var_flag_secret_not_marked_as_cli_flag_exposed() {
let yaml = r#"
steps:
- script: |
curl -H "Authorization: $(MY_TOKEN)" https://api.example.com
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert_eq!(secrets.len(), 1);
assert!(
!secrets[0].metadata.contains_key(META_CLI_FLAG_EXPOSED),
"non -var secret should not be marked as cli_flag_exposed"
);
}
#[test]
fn step_linked_to_variable_group_secret() {
let yaml = r#"
variables:
- group: ProdSecrets
steps:
- script: deploy.sh
"#;
let graph = parse(yaml);
let secrets: Vec<_> = graph.nodes_of_kind(NodeKind::Secret).collect();
assert_eq!(secrets.len(), 1);
let secret_id = secrets[0].id;
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
let links: Vec<_> = graph
.edges_from(steps[0].id)
.filter(|e| e.kind == EdgeKind::HasAccessTo && e.to == secret_id)
.collect();
assert_eq!(
links.len(),
1,
"step should be linked to variable group secret"
);
}
#[test]
fn pr_trigger_sets_meta_trigger_on_graph() {
let yaml = r#"
pr:
- '*'
steps:
- script: echo hi
"#;
let graph = parse(yaml);
assert_eq!(
graph.metadata.get(META_TRIGGER),
Some(&"pr".to_string()),
"ADO pr: trigger should set graph META_TRIGGER"
);
}
#[test]
fn self_hosted_pool_by_name_creates_image_with_self_hosted_metadata() {
let yaml = r#"
pool:
name: my-self-hosted-pool
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
assert_eq!(images.len(), 1);
assert_eq!(images[0].name, "my-self-hosted-pool");
assert_eq!(
images[0].metadata.get(META_SELF_HOSTED),
Some(&"true".to_string()),
"pool.name without vmImage must be tagged self-hosted"
);
}
#[test]
fn vm_image_pool_is_not_tagged_self_hosted() {
let yaml = r#"
pool:
vmImage: ubuntu-latest
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let images: Vec<_> = graph.nodes_of_kind(NodeKind::Image).collect();
assert_eq!(images.len(), 1);
assert_eq!(images[0].name, "ubuntu-latest");
assert!(
!images[0].metadata.contains_key(META_SELF_HOSTED),
"pool.vmImage is Microsoft-hosted — must not be tagged self-hosted"
);
}
#[test]
fn checkout_self_step_tagged_with_meta_checkout_self() {
let yaml = r#"
steps:
- checkout: self
- script: echo hi
"#;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 2);
let checkout_step = steps
.iter()
.find(|s| s.metadata.contains_key(META_CHECKOUT_SELF))
.expect("one step must be tagged META_CHECKOUT_SELF");
assert_eq!(
checkout_step.metadata.get(META_CHECKOUT_SELF),
Some(&"true".to_string())
);
}
#[test]
fn vso_setvariable_sets_meta_writes_env_gate() {
let yaml = r###"
steps:
- script: |
echo "##vso[task.setvariable variable=FOO]bar"
displayName: Set variable
"###;
let graph = parse(yaml);
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 1);
assert_eq!(
steps[0].metadata.get(META_WRITES_ENV_GATE),
Some(&"true".to_string()),
"##vso[task.setvariable] must mark META_WRITES_ENV_GATE"
);
}
#[test]
fn environment_key_tags_job_with_env_approval() {
let yaml_string_form = r#"
jobs:
- deployment: DeployWeb
environment: production
steps:
- script: echo deploying
displayName: Deploy
"#;
let g1 = parse(yaml_string_form);
let tagged: Vec<_> = g1
.nodes_of_kind(NodeKind::Step)
.filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
.collect();
assert!(
!tagged.is_empty(),
"string-form `environment:` must tag job's step nodes with META_ENV_APPROVAL"
);
let yaml_mapping_form = r#"
jobs:
- deployment: DeployAPI
environment:
name: staging
resourceType: VirtualMachine
steps:
- script: echo deploying
displayName: Deploy
"#;
let g2 = parse(yaml_mapping_form);
let tagged2: Vec<_> = g2
.nodes_of_kind(NodeKind::Step)
.filter(|s| s.metadata.get(META_ENV_APPROVAL) == Some(&"true".to_string()))
.collect();
assert!(
!tagged2.is_empty(),
"mapping-form `environment: {{ name: ... }}` must tag job's step nodes"
);
let yaml_no_env = r#"
jobs:
- job: Build
steps:
- script: echo building
"#;
let g3 = parse(yaml_no_env);
let any_tagged = g3
.nodes_of_kind(NodeKind::Step)
.any(|s| s.metadata.contains_key(META_ENV_APPROVAL));
assert!(
!any_tagged,
"jobs without `environment:` must not carry META_ENV_APPROVAL"
);
}
#[test]
fn root_parameter_conditional_template_fragment_does_not_crash_and_marks_partial() {
let yaml = r#"
parameters:
msabs_ws2022: false
- ${{ if eq(parameters.msabs_ws2022, true) }}:
- job: packer_ws2022
displayName: Build WS2022 Gold Image
steps:
- task: PackerTool@0
"#;
let parser = AdoParser;
let source = PipelineSource {
file: "fragment.yml".into(),
repo: None,
git_ref: None,
commit_sha: None,
};
let result = parser.parse(yaml, &source);
let graph = result.expect("template fragment must not crash the parser");
assert!(
matches!(graph.completeness, AuthorityCompleteness::Partial),
"template-fragment graph must be marked Partial"
);
let saw_fragment_gap = graph
.completeness_gaps
.iter()
.any(|g| g.contains("template fragment") && g.contains("parent pipeline"));
assert!(
saw_fragment_gap,
"completeness_gaps must mention the template-fragment reason, got: {:?}",
graph.completeness_gaps
);
}
#[test]
fn environment_tag_isolated_to_gated_job_only() {
let yaml = r#"
jobs:
- job: Build
steps:
- script: echo build
displayName: build-step
- deployment: DeployProd
environment: production
steps:
- script: echo deploy
displayName: deploy-step
"#;
let g = parse(yaml);
let build_step = g
.nodes_of_kind(NodeKind::Step)
.find(|s| s.name == "build-step")
.expect("build-step must exist");
let deploy_step = g
.nodes_of_kind(NodeKind::Step)
.find(|s| s.name == "deploy-step")
.expect("deploy-step must exist");
assert!(
!build_step.metadata.contains_key(META_ENV_APPROVAL),
"non-gated job's step must not be tagged"
);
assert_eq!(
deploy_step.metadata.get(META_ENV_APPROVAL),
Some(&"true".to_string()),
"gated deployment job's step must be tagged"
);
}
fn repos_meta(graph: &AuthorityGraph) -> Vec<serde_json::Value> {
let raw = graph
.metadata
.get(META_REPOSITORIES)
.expect("META_REPOSITORIES must be set");
serde_json::from_str(raw).expect("META_REPOSITORIES must be valid JSON")
}
#[test]
fn resources_repositories_captured_with_used_flag_when_referenced_by_extends() {
let yaml = r#"
resources:
repositories:
- repository: shared-templates
type: git
name: Platform/shared-templates
ref: refs/heads/main
extends:
template: pipeline.yml@shared-templates
"#;
let graph = parse(yaml);
let entries = repos_meta(&graph);
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e["alias"], "shared-templates");
assert_eq!(e["repo_type"], "git");
assert_eq!(e["name"], "Platform/shared-templates");
assert_eq!(e["ref"], "refs/heads/main");
assert_eq!(e["used"], true);
}
#[test]
fn resources_repositories_used_via_checkout_alias() {
let yaml = r#"
resources:
repositories:
- repository: adf_publish
type: git
name: org/adf-finance-reporting
ref: refs/heads/adf_publish
jobs:
- job: deploy
steps:
- checkout: adf_publish
"#;
let graph = parse(yaml);
let entries = repos_meta(&graph);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["alias"], "adf_publish");
assert_eq!(entries[0]["used"], true);
}
#[test]
fn resources_repositories_unreferenced_alias_is_marked_not_used() {
let yaml = r#"
resources:
repositories:
- repository: orphan-templates
type: git
name: Platform/orphan
ref: main
jobs:
- job: build
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let entries = repos_meta(&graph);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["alias"], "orphan-templates");
assert_eq!(entries[0]["used"], false);
}
#[test]
fn resources_repositories_absent_when_no_resources_block() {
let yaml = r#"
jobs:
- job: build
steps:
- script: echo hi
"#;
let graph = parse(yaml);
assert!(!graph.metadata.contains_key(META_REPOSITORIES));
}
#[test]
fn parse_template_alias_extracts_segment_after_at() {
assert_eq!(
parse_template_alias("steps/deploy.yml@templates"),
Some("templates".to_string())
);
assert_eq!(parse_template_alias("local/path.yml"), None);
assert_eq!(parse_template_alias("path@"), None);
}
#[test]
fn parameters_as_map_form_parses_as_named_parameters() {
let yaml = r#"
parameters:
name: ''
k8sRelease: ''
apimodel: 'examples/e2e-tests/kubernetes/release/default/definition.json'
createVNET: false
jobs:
- job: build
steps:
- script: echo $(name)
"#;
let graph = parse(yaml);
assert!(graph.parameters.contains_key("name"));
assert!(graph.parameters.contains_key("k8sRelease"));
assert!(graph.parameters.contains_key("apimodel"));
assert!(graph.parameters.contains_key("createVNET"));
assert_eq!(graph.parameters.len(), 4);
}
#[test]
fn parameters_as_typed_sequence_form_still_parses() {
let yaml = r#"
parameters:
- name: env
type: string
default: prod
values:
- prod
- staging
- name: skipTests
type: boolean
default: false
jobs:
- job: build
steps:
- script: echo hi
"#;
let graph = parse(yaml);
let env_param = graph.parameters.get("env").expect("env captured");
assert_eq!(env_param.param_type, "string");
assert!(env_param.has_values_allowlist);
let skip_param = graph
.parameters
.get("skipTests")
.expect("skipTests captured");
assert_eq!(skip_param.param_type, "boolean");
assert!(!skip_param.has_values_allowlist);
}
#[test]
fn resources_as_legacy_sequence_form_parses_to_empty_resources() {
let yaml = r#"
resources:
- repo: self
trigger:
- main
jobs:
- job: build
steps:
- script: echo hi
"#;
let graph = parse(yaml);
assert!(!graph.metadata.contains_key(META_REPOSITORIES));
let steps: Vec<_> = graph.nodes_of_kind(NodeKind::Step).collect();
assert_eq!(steps.len(), 1);
}
#[test]
fn stages_as_template_expression_parses_with_no_stages() {
let yaml = r#"
parameters:
- name: stages
type: stageList
stages: ${{ parameters.stages }}
"#;
let graph = parse(yaml);
assert!(graph.parameters.contains_key("stages"));
}
#[test]
fn jobs_carrier_without_steps_marks_partial() {
let yaml = r#"
jobs:
- job: build
pool:
vmImage: ubuntu-latest
"#;
let graph = parse(yaml);
let step_count = graph
.nodes
.iter()
.filter(|n| n.kind == NodeKind::Step)
.count();
assert_eq!(step_count, 0);
assert_eq!(graph.completeness, AuthorityCompleteness::Partial);
assert!(
graph
.completeness_gaps
.iter()
.any(|g| g.contains("0 step nodes")),
"completeness_gaps must mention 0 step nodes: {:?}",
graph.completeness_gaps
);
}
#[test]
fn jobs_carrier_with_empty_jobs_list_does_not_mark_partial() {
let yaml = r#"
jobs: []
"#;
let graph = parse(yaml);
let zero_step_gap = graph
.completeness_gaps
.iter()
.any(|g| g.contains("0 step nodes"));
assert!(
!zero_step_gap,
"empty jobs: list is not a carrier; got: {:?}",
graph.completeness_gaps
);
}
#[test]
fn pr_none_does_not_set_meta_trigger() {
let yaml = r#"
schedules:
- cron: "0 5 * * 1"
pr: none
trigger: none
steps:
- script: echo hello
"#;
let graph = parse(yaml);
assert!(
!graph.metadata.contains_key(META_TRIGGER),
"pr: none must not set META_TRIGGER; got: {:?}",
graph.metadata.get(META_TRIGGER)
);
}
#[test]
fn pr_tilde_does_not_set_meta_trigger() {
let yaml = "pr: ~\nsteps:\n - script: echo hello\n";
let graph = parse(yaml);
assert!(
!graph.metadata.contains_key(META_TRIGGER),
"pr: ~ must not set META_TRIGGER; got: {:?}",
graph.metadata.get(META_TRIGGER)
);
}
#[test]
fn pr_false_does_not_set_meta_trigger() {
let yaml = "pr: false\nsteps:\n - script: echo hello\n";
let graph = parse(yaml);
assert!(
!graph.metadata.contains_key(META_TRIGGER),
"pr: false must not set META_TRIGGER; got: {:?}",
graph.metadata.get(META_TRIGGER)
);
}
#[test]
fn pr_sequence_sets_meta_trigger() {
let yaml = "pr:\n - main\nsteps:\n - script: echo hello\n";
let graph = parse(yaml);
assert_eq!(
graph.metadata.get(META_TRIGGER).map(|s| s.as_str()),
Some("pr"),
"pr: [main] must set META_TRIGGER=pr"
);
}
#[test]
fn pr_with_branches_sets_meta_trigger() {
let yaml = r#"
pr:
branches:
include:
- main
steps:
- script: echo hello
"#;
let graph = parse(yaml);
assert_eq!(
graph.metadata.get(META_TRIGGER).map(|s| s.as_str()),
Some("pr"),
"real pr: block must set META_TRIGGER=pr"
);
}
#[test]
fn pipeline_level_permissions_none_constrains_token() {
let yaml = r#"
trigger: none
permissions:
contents: none
steps:
- script: echo hello
"#;
let graph = parse(yaml);
let token = graph
.nodes_of_kind(NodeKind::Identity)
.find(|n| n.name == "System.AccessToken")
.expect("System.AccessToken must always be present");
assert_eq!(
token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
Some("constrained"),
"permissions: contents: none must constrain the token; got: {:?}",
token.metadata.get(META_IDENTITY_SCOPE)
);
}
#[test]
fn pipeline_level_permissions_write_keeps_token_broad() {
let yaml = r#"
trigger: none
permissions:
contents: write
steps:
- script: echo hello
"#;
let graph = parse(yaml);
let token = graph
.nodes_of_kind(NodeKind::Identity)
.find(|n| n.name == "System.AccessToken")
.expect("System.AccessToken must always be present");
assert_eq!(
token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
Some("broad"),
"permissions: contents: write must keep the token broad; got: {:?}",
token.metadata.get(META_IDENTITY_SCOPE)
);
}
#[test]
fn pipeline_level_permissions_read_scalar_constrains_token() {
let yaml = "trigger: none\npermissions: read\nsteps:\n - script: echo hello\n";
let graph = parse(yaml);
let token = graph
.nodes_of_kind(NodeKind::Identity)
.find(|n| n.name == "System.AccessToken")
.expect("System.AccessToken must always be present");
assert_eq!(
token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
Some("constrained"),
"permissions: read must constrain the token; got: {:?}",
token.metadata.get(META_IDENTITY_SCOPE)
);
}
#[test]
fn pipeline_level_permissions_write_scalar_keeps_token_broad() {
let yaml = "trigger: none\npermissions: write\nsteps:\n - script: echo hello\n";
let graph = parse(yaml);
let token = graph
.nodes_of_kind(NodeKind::Identity)
.find(|n| n.name == "System.AccessToken")
.expect("System.AccessToken must always be present");
assert_eq!(
token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
Some("broad"),
"permissions: write scalar must keep token broad; got: {:?}",
token.metadata.get(META_IDENTITY_SCOPE)
);
}
#[test]
fn pipeline_level_permissions_contents_read_constrains_token() {
let yaml =
"trigger: none\npermissions:\n contents: read\nsteps:\n - script: echo hello\n";
let graph = parse(yaml);
let token = graph
.nodes_of_kind(NodeKind::Identity)
.find(|n| n.name == "System.AccessToken")
.expect("System.AccessToken must always be present");
assert_eq!(
token.metadata.get(META_IDENTITY_SCOPE).map(|s| s.as_str()),
Some("constrained"),
"permissions: contents: read must constrain; got: {:?}",
token.metadata.get(META_IDENTITY_SCOPE)
);
}
#[test]
fn empty_pipeline_does_not_mark_partial_for_zero_steps() {
let yaml = r#"
trigger:
- main
"#;
let graph = parse(yaml);
let zero_step_gap = graph
.completeness_gaps
.iter()
.any(|g| g.contains("0 step nodes"));
assert!(
!zero_step_gap,
"no carrier means no 0-step gap reason; got: {:?}",
graph.completeness_gaps
);
}
}