π΄ Red Team Security Audit
Audit focus: Category E (Logic & Authorization Flaws) β post-refactor review of PR #368
Severity: High
Findings
| # |
Vulnerability |
Severity |
File(s) |
Exploitable? |
| 1 |
##vso[ injection via work item title in prefix-guard error message |
High |
src/safeoutputs/update_work_item.rs:277-279 |
Yes β requires ADO work item write access |
| 2 |
##vso[ injection via work item tags in prefix-guard error message |
High |
src/safeoutputs/update_work_item.rs:296-298 |
Yes β requires ADO work item write access |
Details
Finding 1 & 2: VSO Command Injection via Unsanitized ADO Data in Stage 3 println!
Description: The check_prefix_guards() function introduced in PR #368 fetches a work item from ADO and reads System.Title and System.Tags to enforce title-prefix / tag-prefix guards. When the guard fails, it embeds the fetched title/tags verbatim into an ExecutionResult::failure() message. That message is then printed to stdout via println! in the Stage 3 executor (execute.rs:200). Azure DevOps interprets ##vso[...] sequences in pipeline stdout as logging commands.
Vulnerable code (src/safeoutputs/update_work_item.rs):
// Line 271-279: current_title comes from ADO API β not sanitized
let current_title = wi.get("fields")
.and_then(|f| f.get("System.Title"))
.and_then(|t| t.as_str())
.unwrap_or("");
if !current_title.starts_with(prefix.as_str()) {
return Ok(Some(ExecutionResult::failure(format!(
"Work item #{id} title '{current_title}' does not start with the required prefix..."
// ^^^^^^^^^^^^^^ unsanitized ADO data in println! output
))));
}
// Line 286-298: raw_tags comes from ADO API β not sanitized
let raw_tags = wi.get("fields")
.and_then(|f| f.get("System.Tags"))
.and_then(|t| t.as_str())
.unwrap_or("");
if !has_matching_tag {
return Ok(Some(ExecutionResult::failure(format!(
"...Current tags: '{raw_tags}'"
// ^^^^^^^^ unsanitized ADO data in println! output
))));
}
Stage 3 output path (src/execute.rs:200):
println!("[{}/{}] {} - {} - {}", i + 1, total, tool_name, symbol, result.message);
// ^^^^^^^^^^^^^^ printed to stdout; ADO interprets ##vso[
```
**Attack vector**:
1. Attacker has write access to at least one ADO work item in the target project (typical for any project member).
2. Attacker sets the work item title to a VSO logging command payload, e.g.:
```
##vso[task.setvariable variable=SC_WRITE_TOKEN;issecret=false]attacker_logs_this
```
3. Operator has configured `update-work-item` with `title-prefix: "[bot]"` (or any non-matching prefix).
4. An agent proposes `update-work-item` targeting that work item (the agent reads it from context β e.g., it was passed in as a trigger).
5. Stage 3 executes: `check_prefix_guards` fetches the work item, title doesn't match `[bot]`, and **calls** `ExecutionResult::failure(...)` with the raw title embedded.
6. `println!` outputs to ADO pipeline stdout:
```
[1/1] update-work-item - β - Work item #42 title '##vso[task.setvariable variable=SC_WRITE_TOKEN;issecret=false]...' does not start with...
```
7. ADO interprets the `##vso[task.setvariable]` command and re-exposes the `SC_WRITE_TOKEN` as a non-secret variable, leaking it to pipeline logs.
**Impact**:
- **Secret variable exfiltration**: `##vso[task.setvariable variable=X;issecret=false]` re-marks a secret as non-secret, causing it to appear in logs.
- **Environment manipulation**: `##vso[task.prependpath]attacker-path` injects a malicious directory into `PATH` for subsequent steps.
- **Artifact smuggling**: `##vso[artifact.upload]` can upload attacker-controlled data as a pipeline artifact.
- This bypasses the AWF sandboxing intent: Stage 1 is read-only, but an attacker controlling a work item can influence Stage 3 via this channel.
**Proof of concept** (malicious work item title):
```
##vso[task.setvariable variable=SC_WRITE_TOKEN;issecret=false]token_now_visible_in_logs
Configure the pipeline with title-prefix: "anything-that-wont-match", then have the agent target this work item for an update β the title guard fails and the payload fires.
Same issue applies to tag-prefix with ADO tags containing ##vso[ sequences (since tags are operator-editable semicolon-separated strings).
Suggested fix:
Apply sanitize_text() (which calls neutralize_pipeline_commands()) to current_title and raw_tags before embedding them in error messages:
// In check_prefix_guards, after reading from the ADO response:
use crate::sanitize::sanitize as sanitize_text;
let current_title = ...unwrap_or("");
let safe_title = sanitize_text(current_title);
// use safe_title in the format! string
let raw_tags = ...unwrap_or("");
let safe_tags = sanitize_text(raw_tags);
// use safe_tags in the format! string
Alternatively, apply a lighter-weight sanitize_config() which also neutralizes ##vso[ without altering content semantics. The key is that any ADO-sourced string reaching println! must pass through neutralize_pipeline_commands() first.
Defense-in-depth note: The existing SanitizeContent machinery protects agent-provided fields (title, body, tags supplied by Stage 1). The gap is in data sourced from ADO itself (existing work item values fetched during validation), which has no sanitization before appearing in Stage 3 stdout.
Audit Coverage
| Category |
Status |
| A: Input Sanitization |
β
Scanned |
| B: Path Traversal |
β
Scanned |
| C: Network Bypass |
β
Scanned |
| D: Credential Exposure |
β
Scanned |
| E: Logic Flaws |
β
Scanned β new finding in PR #368 refactor |
| F: Supply Chain |
β
Scanned |
This issue was created by the automated red team security auditor.
Generated by Red Team Security Auditor Β· β 1M Β· β·
π΄ Red Team Security Audit
Audit focus: Category E (Logic & Authorization Flaws) β post-refactor review of PR #368
Severity: High
Findings
##vso[injection via work item title in prefix-guard error messagesrc/safeoutputs/update_work_item.rs:277-279##vso[injection via work item tags in prefix-guard error messagesrc/safeoutputs/update_work_item.rs:296-298Details
Finding 1 & 2: VSO Command Injection via Unsanitized ADO Data in Stage 3
println!Description: The
check_prefix_guards()function introduced in PR #368 fetches a work item from ADO and readsSystem.TitleandSystem.Tagsto enforcetitle-prefix/tag-prefixguards. When the guard fails, it embeds the fetched title/tags verbatim into anExecutionResult::failure()message. That message is then printed to stdout viaprintln!in the Stage 3 executor (execute.rs:200). Azure DevOps interprets##vso[...]sequences in pipeline stdout as logging commands.Vulnerable code (
src/safeoutputs/update_work_item.rs):Stage 3 output path (
src/execute.rs:200):Configure the pipeline with
title-prefix: "anything-that-wont-match", then have the agent target this work item for an update β the title guard fails and the payload fires.Same issue applies to
tag-prefixwith ADO tags containing##vso[sequences (since tags are operator-editable semicolon-separated strings).Suggested fix:
Apply
sanitize_text()(which callsneutralize_pipeline_commands()) tocurrent_titleandraw_tagsbefore embedding them in error messages:Alternatively, apply a lighter-weight
sanitize_config()which also neutralizes##vso[without altering content semantics. The key is that any ADO-sourced string reachingprintln!must pass throughneutralize_pipeline_commands()first.Defense-in-depth note: The existing
SanitizeContentmachinery protects agent-provided fields (title, body, tags supplied by Stage 1). The gap is in data sourced from ADO itself (existing work item values fetched during validation), which has no sanitization before appearing in Stage 3 stdout.Audit Coverage
This issue was created by the automated red team security auditor.