Skip to content

Commit 2e0a0bb

Browse files
fix(secrets): preserve masked ADO secrets on definition PUT (#604)
Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/82cb744f-2971-4d38-abb1-d81eddf5d41f Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 096b7a4 commit 2e0a0bb

3 files changed

Lines changed: 64 additions & 40 deletions

File tree

docs/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
3939

4040
- `secrets set <name> [<value>] [PATH]` - Set a pipeline variable (with `isSecret=true`) on every matched ADO definition. Value resolution: positional `<value>``--value-stdin` (one line) → interactive tty prompt with echo off.
4141
- `--allow-override` - Force `allowOverride=true` on the set variable. When omitted, `allowOverride` is **preserved** on existing variables (so secret rotation does not silently downgrade an existing `allowOverride=true`) and defaults to `false` for new variables.
42+
- When ado-aw round-trips a definition through the ADO GET→PUT API, unchanged secret siblings returned by ADO as masked `***` are normalized to `null` before the PUT so their stored values are preserved instead of being overwritten by the literal mask.
4243
- `--value-stdin` - Read the value from a single line on stdin.
4344
- `--dry-run` - Print the planned set without calling the ADO API.
4445
- `--org / --project / --pat` - ADO context overrides (same semantics as the other lifecycle commands).
@@ -102,4 +103,3 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
102103
- `--poll-interval <secs>` - Polling period when `--wait` is set (default 10).
103104
- `--timeout <secs>` - Hard cap on the polling loop when `--wait` is set (default 1800).
104105
- `--dry-run` - Print the planned `templateParameters` body without calling the ADO API.
105-

src/ado/mod.rs

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,29 @@ pub async fn get_definition_name(
547547
/// Note: The GET→PUT cycle is not atomic. Concurrent callers against
548548
/// the same definition could overwrite each other's variables. This is
549549
/// acceptable for a CLI tool typically run by a single operator.
550+
///
551+
/// ADO returns existing secret variables from definition GETs as masked
552+
/// `***`. For definition PUTs, `null` preserves the stored secret value,
553+
/// while the literal mask would overwrite it. Normalize the masked form
554+
/// before mutating the definition and PUTting it back.
555+
pub(crate) fn normalize_masked_secret_variable_values(definition: &mut serde_json::Value) {
556+
let Some(vars) = definition.get_mut("variables").and_then(|v| v.as_object_mut()) else {
557+
return;
558+
};
559+
560+
for var in vars.values_mut() {
561+
let is_masked_secret = var.get("isSecret").and_then(|v| v.as_bool()) == Some(true)
562+
&& var.get("value").and_then(|v| v.as_str()) == Some("***");
563+
if !is_masked_secret {
564+
continue;
565+
}
566+
567+
if let Some(obj) = var.as_object_mut() {
568+
obj.insert("value".to_string(), serde_json::Value::Null);
569+
}
570+
}
571+
}
572+
550573
pub async fn update_pipeline_variable(
551574
client: &reqwest::Client,
552575
ctx: &AdoContext,
@@ -555,43 +578,10 @@ pub async fn update_pipeline_variable(
555578
variable_name: &str,
556579
variable_value: &str,
557580
) -> Result<()> {
558-
let get_url = format!(
559-
"{}/{}/_apis/build/definitions/{}?api-version=7.1",
560-
ctx.org_url.trim_end_matches('/'),
561-
percent_encoding::utf8_percent_encode(&ctx.project, PATH_SEGMENT),
562-
definition_id
563-
);
564-
565-
debug!("Fetching definition {}: {}", definition_id, get_url);
566-
567-
let resp = auth
568-
.apply(client.get(&get_url))
569-
.send()
581+
let mut definition = get_definition_full(client, ctx, auth, definition_id)
570582
.await
571-
.context("Failed to get pipeline definition")?;
572-
573-
let status = resp.status();
574-
if !status.is_success() {
575-
let body = resp.text().await.unwrap_or_default();
576-
anyhow::bail!(
577-
"ADO API returned {} when getting definition {}: {}",
578-
status,
579-
definition_id,
580-
body
581-
);
582-
}
583-
584-
let body = resp.text().await.context("Failed to read definition response body")?;
585-
let mut definition: serde_json::Value = serde_json::from_str(&body)
586-
.with_context(|| {
587-
let snippet: String = body.chars().take(500).collect();
588-
format!(
589-
"Failed to parse definition {} as JSON. \
590-
This usually means the PAT is invalid or expired. \
591-
Response body (first 500 chars):\n{snippet}",
592-
definition_id
593-
)
594-
})?;
583+
.with_context(|| format!("Failed to fetch definition {} before updating", definition_id))?;
584+
normalize_masked_secret_variable_values(&mut definition);
595585

596586
// Ensure variables object exists
597587
if definition.get("variables").is_none() {
@@ -964,6 +954,7 @@ pub async fn patch_queue_status(
964954
let mut definition = get_definition_full(client, ctx, auth, id)
965955
.await
966956
.with_context(|| format!("Failed to fetch definition {} before patching", id))?;
957+
normalize_masked_secret_variable_values(&mut definition);
967958

968959
definition["queueStatus"] = serde_json::Value::String(status.to_string());
969960

@@ -1236,6 +1227,36 @@ pub async fn get_latest_build(
12361227
mod tests {
12371228
use super::*;
12381229

1230+
#[test]
1231+
fn normalize_masked_secret_variable_values_rewrites_masked_secret_to_null() {
1232+
let mut def = serde_json::json!({
1233+
"variables": {
1234+
"SECRET": { "value": "***", "isSecret": true, "allowOverride": false },
1235+
"PLAIN": { "value": "visible", "isSecret": false, "allowOverride": false }
1236+
}
1237+
});
1238+
1239+
normalize_masked_secret_variable_values(&mut def);
1240+
1241+
assert!(def["variables"]["SECRET"]["value"].is_null());
1242+
assert_eq!(def["variables"]["PLAIN"]["value"], "visible");
1243+
}
1244+
1245+
#[test]
1246+
fn normalize_masked_secret_variable_values_leaves_non_secret_mask_alone() {
1247+
let mut def = serde_json::json!({
1248+
"variables": {
1249+
"LITERAL": { "value": "***", "isSecret": false, "allowOverride": false },
1250+
"SECRET": { "value": "new-value", "isSecret": true, "allowOverride": false }
1251+
}
1252+
});
1253+
1254+
normalize_masked_secret_variable_values(&mut def);
1255+
1256+
assert_eq!(def["variables"]["LITERAL"]["value"], "***");
1257+
assert_eq!(def["variables"]["SECRET"]["value"], "new-value");
1258+
}
1259+
12391260
#[test]
12401261
fn test_parse_ado_remote_https() {
12411262
let url = "https://dev.azure.com/myorg/myproject/_git/myrepo";

src/secrets.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
2020

2121
use crate::ado::{
2222
AdoAuth, AdoContext, MatchedDefinition, PATH_SEGMENT, get_definition_full,
23-
resolve_ado_context, resolve_auth, resolve_definitions,
23+
normalize_masked_secret_variable_values, resolve_ado_context, resolve_auth,
24+
resolve_definitions,
2425
};
2526

2627
/// Description of one pipeline variable, for listing only.
@@ -285,7 +286,8 @@ async fn apply_set_one(
285286
value: &str,
286287
allow_override: Option<bool>,
287288
) -> Result<()> {
288-
let definition = get_definition_full(client, ctx, auth, id).await?;
289+
let mut definition = get_definition_full(client, ctx, auth, id).await?;
290+
normalize_masked_secret_variable_values(&mut definition);
289291
let updated = apply_variable_set(definition, name, value, allow_override);
290292
put_definition(client, ctx, auth, id, &updated).await
291293
}
@@ -497,7 +499,8 @@ async fn apply_delete_one(
497499
id: u64,
498500
name: &str,
499501
) -> Result<()> {
500-
let definition = get_definition_full(client, ctx, auth, id).await?;
502+
let mut definition = get_definition_full(client, ctx, auth, id).await?;
503+
normalize_masked_secret_variable_values(&mut definition);
501504
let updated = apply_variable_delete(definition, name);
502505
put_definition(client, ctx, auth, id, &updated).await
503506
}

0 commit comments

Comments
 (0)