Skip to content

Commit c762d9a

Browse files
authored
fix(secrets): address second-round Rust PR review feedback on #624
- `AdoAwMarkerExtension`: neutralise `##vso[` and `##[` logging-command prefixes in the source path before embedding it in the marker step's runtime `echo` line. Without this, a markdown filename like `agents/##vso[task.setvariable variable=FOO]value.md` would echo a literal `##vso[...]` sequence that the ADO build agent's stdout scanner treats as a task command — a logging-command-injection primitive any attacker controlling a filename could trigger. New `sanitize_for_vso_logging` helper mirrors the existing convention in `crate::agent_stats::sanitize_for_markdown` (`[vso-filtered][` / `[filtered][`). The `# ado-aw-metadata:` JSON line keeps the raw value (it's a YAML comment, not echoed to stdout). Two new tests: the sanitiser unit test and an end-to-end attack-payload roundtrip asserting the echo line is neutralised. - `resolve_definitions_via_discovery`: the previous skip-counter implementation counted `UnknownRequiredParams` / `Forbidden` / `PreviewFailed` failures *before* applying `source_filter`, so under `--source agents/foo.md` the warnings would tell the user "N definitions skipped requiring template parameters" for definitions that had nothing to do with `agents/foo.md`. Split the counting: * without `--source`: per-status counts are honest (we're operating on every ado-aw pipeline) and the existing three warnings stand; * with `--source`: a single conservative `uninspectable` counter, surfaced as one warning that explicitly acknowledges we can't tell whether any of those skipped definitions would have been consumers of the filtered template. - `src/ado/discovery.rs`: drop the file-level `#![allow(dead_code)]`. `resolve_definitions_via_discovery` and `discovered_to_matched` are now wired into `secrets.rs`; the suppression was hiding future dead-code regressions. Build is clean without it. - `src/main.rs` (`SecretsCmd`): clarified `--source` help text — calls out that **without `--all-repos`, only the current repository is searched**. Saves the user-confusion case "I ran `secrets set GITHUB_TOKEN --source agents/foo.md` and got zero results" when they're in a different repo than the consumer pipelines. All 1743 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 08f8447 commit c762d9a

3 files changed

Lines changed: 140 additions & 24 deletions

File tree

src/ado/discovery.rs

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@
3131
//! `src/compile/extensions/ado_aw_marker.rs` solves the stripping
3232
//! problem by embedding the marker inside a bash heredoc.
3333
34-
#![allow(dead_code)] // Wired into CLI commands in workstream S; tests below cover it now.
35-
3634
use anyhow::{Context, Result};
3735
use log::{debug, warn};
3836
use serde::Deserialize;
@@ -509,11 +507,22 @@ pub fn discovered_to_matched(d: &DiscoveredPipeline) -> Option<MatchedDefinition
509507
/// markers reference that source path are kept. Match is by exact
510508
/// equality on the normalized source string in the marker JSON.
511509
///
512-
/// Definitions whose Preview call failed in a known-recoverable way
513-
/// (`UnknownRequiredParams` / `UnknownForbidden` / `PreviewFailed`) are
514-
/// counted and surfaced as a `warn!` so the operator can see that
515-
/// some pipelines were skipped — silently dropping them would be a
516-
/// nasty surprise for `secrets set --all-repos`.
510+
/// Skip-summary warnings are emitted differently depending on whether
511+
/// `source_filter` is active:
512+
///
513+
/// - **Unfiltered (`--all-repos` alone)**: every `UnknownRequiredParams`
514+
/// / `UnknownForbidden` / `PreviewFailed` definition is counted —
515+
/// under `--all-repos` the user is operating on every ado-aw pipeline
516+
/// in scope, so each failure represents a real skip.
517+
///
518+
/// - **Filtered (`--source <path>`)**: we can't tell whether a failed
519+
/// definition would have been a consumer of `path` because we never
520+
/// got markers out of it. Emitting per-status counts would mislead
521+
/// the user into thinking they're missing consumers of their
522+
/// template. Instead, emit a single conservative warning ("N
523+
/// definitions could not be inspected; consumers of `<path>` among
524+
/// them may have been silently skipped") so the operator is informed
525+
/// without being told false specifics.
517526
pub async fn resolve_definitions_via_discovery(
518527
client: &reqwest::Client,
519528
ctx: &AdoContext,
@@ -527,18 +536,39 @@ pub async fn resolve_definitions_via_discovery(
527536
let mut skipped_required_params = 0usize;
528537
let mut skipped_forbidden = 0usize;
529538
let mut skipped_failed = 0usize;
539+
let mut uninspectable = 0usize;
530540

531541
let kept: Vec<_> = discovered
532542
.into_iter()
533543
.filter(|d| {
534-
match &d.status {
535-
DiscoveryStatus::UnknownRequiredParams => skipped_required_params += 1,
536-
DiscoveryStatus::UnknownForbidden => skipped_forbidden += 1,
537-
DiscoveryStatus::PreviewFailed(_) => skipped_failed += 1,
538-
_ => {}
544+
let matches_filter = match source_filter {
545+
Some(src) => d.markers.iter().any(|m| m.source == src),
546+
None => true,
547+
};
548+
549+
// Count toward skip totals only when the failure is
550+
// relevant to the requested operation:
551+
// - unfiltered: every failure is relevant (we're operating
552+
// on every ado-aw pipeline in scope);
553+
// - filtered: we can't attribute uninspectable definitions
554+
// to a specific source, so use a single combined counter.
555+
if source_filter.is_none() {
556+
match &d.status {
557+
DiscoveryStatus::UnknownRequiredParams => skipped_required_params += 1,
558+
DiscoveryStatus::UnknownForbidden => skipped_forbidden += 1,
559+
DiscoveryStatus::PreviewFailed(_) => skipped_failed += 1,
560+
_ => {}
561+
}
562+
} else if matches!(
563+
d.status,
564+
DiscoveryStatus::UnknownRequiredParams
565+
| DiscoveryStatus::UnknownForbidden
566+
| DiscoveryStatus::PreviewFailed(_)
567+
) {
568+
uninspectable += 1;
539569
}
540-
let Some(src) = source_filter else { return true };
541-
d.markers.iter().any(|m| m.source == src)
570+
571+
matches_filter
542572
})
543573
.collect();
544574

@@ -561,6 +591,15 @@ pub async fn resolve_definitions_via_discovery(
561591
an unexpected error. Re-run with --debug to see details.",
562592
);
563593
}
594+
if uninspectable > 0
595+
&& let Some(src) = source_filter
596+
{
597+
warn!(
598+
"Discovery could not inspect {uninspectable} definition(s) (Preview failure, \
599+
forbidden, or required-parameters); any consumers of `{src}` among them have \
600+
been silently skipped. Re-run with --debug for per-definition reasons.",
601+
);
602+
}
564603

565604
Ok(kept.iter().filter_map(discovered_to_matched).collect())
566605
}

src/compile/extensions/ado_aw_marker.rs

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,21 @@ impl CompilerExtension for AdoAwMarkerExtension {
6969
// the build log at runtime, which is a free human-discoverability
7070
// bonus and costs nothing because the step runs in milliseconds.
7171
//
72-
// The echo uses single quotes to keep the literal intact at
73-
// runtime; we apply the bash `'\''` idiom to any `'` inside the
74-
// source path so a markdown filename like `agents/foo's.md`
75-
// doesn't produce broken bash. `version` and `target` are
76-
// controlled inputs and can't contain `'`.
77-
let echo_source = bash_single_quote_escape(&source);
72+
// The echo's source value goes through two sanitisations:
73+
//
74+
// 1. `sanitize_for_vso_logging` neutralises `##vso[` and `##[`
75+
// prefixes. The ADO build agent scans stdout for those
76+
// sequences and treats them as logging commands (e.g.
77+
// `task.setvariable`). An attacker who controls a markdown
78+
// filename could otherwise inject a logging command into
79+
// the build log via the echoed source path. Same convention
80+
// used by `agent_stats::sanitize_for_markdown`.
81+
//
82+
// 2. `bash_single_quote_escape` applies the `'\''` idiom so a
83+
// filename containing `'` (e.g. `agents/foo's.md`) doesn't
84+
// produce syntactically broken bash. `version` and `target`
85+
// are controlled inputs and can't contain either.
86+
let echo_source = bash_single_quote_escape(&sanitize_for_vso_logging(&source));
7887
let step = format!(
7988
"- bash: |\n \
8089
# ado-aw-metadata: {metadata}\n \
@@ -97,6 +106,15 @@ fn bash_single_quote_escape(s: &str) -> String {
97106
s.replace('\'', "'\\''")
98107
}
99108

109+
/// Neutralise ADO build-agent logging-command prefixes (`##vso[`, `##[`).
110+
/// Mirrors `crate::agent_stats::sanitize_for_markdown` so a malicious
111+
/// filename can't smuggle a `task.setvariable` (or similar) through the
112+
/// runtime `echo` line in the marker step.
113+
fn sanitize_for_vso_logging(s: &str) -> String {
114+
s.replace("##vso[", "[vso-filtered][")
115+
.replace("##[", "[filtered][")
116+
}
117+
100118
#[cfg(test)]
101119
mod tests {
102120
use super::*;
@@ -207,4 +225,60 @@ mod tests {
207225
"JSON marker should carry raw source unchanged:\n{step}",
208226
);
209227
}
228+
229+
#[test]
230+
fn sanitize_for_vso_logging_neutralises_known_prefixes() {
231+
assert_eq!(
232+
sanitize_for_vso_logging("##vso[task.setvariable variable=X]value"),
233+
"[vso-filtered][task.setvariable variable=X]value"
234+
);
235+
assert_eq!(
236+
sanitize_for_vso_logging("##[warning]ignore me"),
237+
"[filtered][warning]ignore me"
238+
);
239+
assert_eq!(sanitize_for_vso_logging("agents/foo.md"), "agents/foo.md");
240+
assert_eq!(sanitize_for_vso_logging(""), "");
241+
}
242+
243+
#[test]
244+
fn echo_line_neutralises_vso_injection_attempt() {
245+
// An attacker who controls a markdown filename must not be able
246+
// to inject ADO logging commands into the build log via the
247+
// echoed source path. The ADO agent scans stdout for `##vso[`
248+
// and `##[` prefixes and treats matching sequences as task
249+
// commands (setvariable, setoutput, etc.).
250+
let fm = parse_fm("name: t\ndescription: x\n");
251+
let input_path = Path::new("agents/##vso[task.setvariable variable=FOO]value.md");
252+
let ctx = CompileContext {
253+
agent_name: &fm.name,
254+
front_matter: &fm,
255+
ado_context: None,
256+
engine: crate::engine::Engine::Copilot,
257+
compile_dir: None,
258+
input_path: Some(input_path),
259+
};
260+
let steps = AdoAwMarkerExtension.setup_steps(&ctx).unwrap();
261+
assert_eq!(steps.len(), 1);
262+
let step = &steps[0];
263+
264+
// Find the `echo` line specifically — the `# ado-aw-metadata`
265+
// JSON line is allowed to carry the raw source (it's not echoed
266+
// to stdout by ADO; it's a comment in the bash heredoc, not
267+
// output at runtime). The JSON line *does* get written to the
268+
// build log when ADO renders the step body, but as `# ...`
269+
// comments inside the rendered yaml; those don't trip the
270+
// logging-command scanner.
271+
let echo_line = step
272+
.lines()
273+
.find(|l| l.trim_start().starts_with("echo 'ado-aw metadata:"))
274+
.expect("must have echo line");
275+
assert!(
276+
!echo_line.contains("##vso["),
277+
"raw ##vso[ leaked into echo line: {echo_line}"
278+
);
279+
assert!(
280+
echo_line.contains("[vso-filtered]["),
281+
"expected `##vso[` neutralised to `[vso-filtered][` in echo line: {echo_line}"
282+
);
283+
}
210284
}

src/main.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ enum SecretsCmd {
7070
all_repos: bool,
7171
/// Filter discovered definitions to consumers of one specific
7272
/// ado-aw template (e.g. `agents/security-scan.md`). Activates
73-
/// the discovery code path; pairs with `--all-repos` to scope
74-
/// across the project.
73+
/// the discovery code path. **Without `--all-repos`, only
74+
/// definitions in the current repository are searched** — pair
75+
/// with `--all-repos` to search the full project.
7576
#[arg(long, conflicts_with = "definition_ids")]
7677
source: Option<String>,
7778
},
@@ -93,7 +94,8 @@ enum SecretsCmd {
9394
#[arg(long, conflicts_with = "definition_ids")]
9495
all_repos: bool,
9596
/// Filter discovered definitions to consumers of one specific
96-
/// ado-aw template.
97+
/// ado-aw template. **Without `--all-repos`, only definitions
98+
/// in the current repository are searched.**
9799
#[arg(long, conflicts_with = "definition_ids")]
98100
source: Option<String>,
99101
},
@@ -116,7 +118,8 @@ enum SecretsCmd {
116118
#[arg(long, conflicts_with = "definition_ids")]
117119
all_repos: bool,
118120
/// Filter discovered definitions to consumers of one specific
119-
/// ado-aw template.
121+
/// ado-aw template. **Without `--all-repos`, only definitions
122+
/// in the current repository are searched.**
120123
#[arg(long, conflicts_with = "definition_ids")]
121124
source: Option<String>,
122125
},

0 commit comments

Comments
 (0)