π΄ Red Team Security Audit
Audit focus: Category A (Input Sanitization & Injection) β Stage 3 executor log messages
Severity: Medium (3 independent paths; same root cause)
Findings
| # |
Vulnerability |
Severity |
File(s) |
Exploitable? |
| 1 |
VSO injection via extract_entry_context title/path in budget-exceeded messages |
Medium |
src/execute.rs:363β386 |
Yes β any tool with title/path field |
| 2 |
VSO injection via effective_path in create-wiki-page messages |
Medium |
src/safeoutputs/create_wiki_page.rs:305β315, 386, 399β402 |
Yes β success, failure, and already-exists paths |
| 3 |
VSO injection via effective_path in update-wiki-page messages |
Medium |
src/safeoutputs/update_wiki_page.rs:379, 394 |
Yes β same root cause as Finding 2 |
Details
Root Cause
sanitize.rs exposes neutralize_pipeline_commands() which wraps ##vso[ and ##[ ADO logging-command sequences in backticks so they render as code instead of being executed. This function is correctly called by sanitize_text() / sanitize_config() for content fields (work item title, PR body, wiki content, etc.).
However, fields that developers classified as "structural identifiers" (wiki page path, NDJSON entry title in log context) are only stripped of control characters. The comments make this explicit:
// src/safeoutputs/create_wiki_page.rs:73
// Path is a structural identifier β sanitize lightly (remove control chars)
// but do not escape HTML or neutralize patterns that are valid in wiki paths.
self.path = self.path.chars().filter(|c| !c.is_control() || *c == '\t').collect();
// src/execute.rs:363
/// Called before sanitization, so all string values are stripped of control characters
/// to prevent log injection.
fn extract_entry_context(entry: &Value) -> String {
##vso[task.setvariable variable=X]Y contains only printable ASCII β it survives both filters and reaches println!() in Stage 3, which runs in an ADO pipeline task. ADO interprets any ##vso[ or ##[ sequences that appear anywhere in a task's stdout.
Finding 1: Budget-exceeded skip message (execute.rs)
Location: src/execute.rs:363β386 (extract_entry_context) and src/execute.rs:415β426 (check_budget + println!)
Attack vector: Stage 1 agent emits max + 1 entries for any tool that has a title or path field. The first max entries are executed normally. The extra entry triggers the budget-exceeded path, which calls extract_entry_context and embeds the unsanitized title (β€ 40 chars) or path (unlimited length) in the skip message.
Proof of concept: NDJSON file with two create-work-item entries (DEFAULT_MAX = 1):
{"name":"create-work-item","title":"Legitimate item","type":"Task"}
{"name":"create-work-item","title":"##vso[task.complete result=Failed]","type":"Task"}
```
The second entry triggers:
```
[2/2] create-work-item - β - Skipped ("##vso[task.complete result=Failed]"): maximum create-work-item count (1) already reached.
ADO interprets ##vso[task.complete result=Failed] and marks Stage 3 as Failed, aborting remaining entries. Any earlier entries are unaffected.
The path field variant (wiki tools) is even more powerful β it is not truncated at 40 chars, allowing longer commands:
{"name":"create-wiki-page","path":"##vso[task.setvariable variable=MY_VAR;issecret=false]injected","content":"x","wiki-id":"..."}
{"name":"create-wiki-page","path":"any","content":"x","wiki-id":"..."}
Impact: ADO task completion manipulation, pipeline variable injection, early Stage 3 termination that silently skips remaining safe outputs.
Finding 2 & 3: Wiki page effective_path in success/failure messages
Location:
src/safeoutputs/create_wiki_page.rs:305β315, 386, 399β402
src/safeoutputs/update_wiki_page.rs:379, 394
Attack vector: No budget manipulation required. A single well-formed entry is sufficient. self.path is only control-char filtered (not VSO-neutralized) in sanitize_content_fields. After effective_path = normalize_wiki_path(&self.path), the ADO logging command survives and is embedded verbatim in three message paths:
- Already-exists failure (
create_wiki_page.rs:313β316): format!("Wiki page '{effective_path}' already exists...")
- PUT failure (
create_wiki_page.rs:399β402): format!("Failed to create wiki page '{}' (HTTP {}): {}", effective_path, ...)
- Success (
create_wiki_page.rs:386): format!("Created wiki page: {effective_path}")
The wiki page path validation (Validate::validate) only rejects .., null bytes, and empty paths β ##, [, ], ; are all allowed.
Proof of concept: Single NDJSON entry:
{
"name": "create-wiki-page",
"path": "##vso[task.setvariable variable=PIPELINE_VAR;issecret=false]attacker_value",
"content": "page content here",
"wiki-id": "my-wiki"
}
```
Regardless of whether ADO accepts or rejects the wiki path (both success and failure messages are affected), Stage 3 stdout will contain a `##vso[task.setvariable]` command that ADO will execute, setting `PIPELINE_VAR` to `attacker_value"): [... rest of message ...]`.
The same applies to `update-wiki-page` (Finding 3).
---
### Impact
| Effect | Mechanism |
|--------|-----------|
| Pipeline variable injection | `##vso[task.setvariable variable=X;issecret=false]Y` sets pipeline variable X for subsequent steps in Stage 3 (e.g., log-copy, publish steps) |
| Stage 3 early termination | `##vso[task.complete result=Succeeded]` or `result=Failed` aborts the current bash step, leaving unprocessed safe outputs silently skipped |
| False build status | `##vso[task.complete result=SucceededWithIssues]` masks real failures |
| Artifact upload | `##vso[task.uploadfile path=/path/to/sensitive/file]` can attach local runner files to the pipeline timeline |
**Important mitigations already in place**:
- SC_WRITE_TOKEN is loaded at step startup; `##vso[task.setvariable variable=SC_WRITE_TOKEN]` only affects variables used in *subsequent* steps, not the current `ado-aw execute` step itself
- Stage 2 (Detection) is supposed to flag malicious proposals, though it may not specifically scan `title`/`path` fields for `##vso[` sequences
- Exploitation requires a compromised or prompt-injected Stage 1 agent
---
### Suggested Fix
Apply `neutralize_pipeline_commands` (already available in `sanitize.rs`) to structural fields before they are embedded in log messages:
**Option A β Fix at the source (`sanitize_content_fields`)**: Call `sanitize_config()` (which includes VSO neutralization) instead of the bare control-char filter for wiki `path` and similar structural fields in Stage 3 log messages. Since these strings never reach ADO API URLs unsanitized (reqwest URL-encodes query params), the backtick wrapping would only affect the local log message.
**Option B β Fix at the sink (`extract_entry_context` and result message formatters)**: Run `neutralize_pipeline_commands(&clean)` on the cleaned string before embedding it in any format string that will be passed to `println!`.
Both options are one-line fixes per call site.
---
### Affected call sites
```
src/execute.rs:374 title branch in extract_entry_context
src/execute.rs:383 path branch in extract_entry_context
src/safeoutputs/create_wiki_page.rs:75β79 sanitize_content_fields path
src/safeoutputs/create_wiki_page.rs:305β306 GET-fail message
src/safeoutputs/create_wiki_page.rs:313β315 already-exists message
src/safeoutputs/create_wiki_page.rs:386 success message
src/safeoutputs/create_wiki_page.rs:399β402 PUT-fail message
src/safeoutputs/update_wiki_page.rs:71β75 sanitize_content_fields path
src/safeoutputs/update_wiki_page.rs:379 success message
src/safeoutputs/update_wiki_page.rs:394 failure message
Audit Coverage
| Category |
Status |
| A: Input Sanitization |
β
Scanned |
| B: Path Traversal |
β
Scanned |
| C: Network Bypass |
β
Scanned |
| D: Credential Exposure |
β
Scanned |
| E: Logic Flaws |
β
Scanned |
| F: Supply Chain |
β
Scanned |
This issue was created by the automated red team security auditor.
Generated by Red Team Security Auditor Β· β 2.9M Β· β·
π΄ Red Team Security Audit
Audit focus: Category A (Input Sanitization & Injection) β Stage 3 executor log messages
Severity: Medium (3 independent paths; same root cause)
Findings
extract_entry_contexttitle/path in budget-exceeded messagessrc/execute.rs:363β386title/pathfieldeffective_pathincreate-wiki-pagemessagessrc/safeoutputs/create_wiki_page.rs:305β315, 386, 399β402effective_pathinupdate-wiki-pagemessagessrc/safeoutputs/update_wiki_page.rs:379, 394Details
Root Cause
sanitize.rsexposesneutralize_pipeline_commands()which wraps##vso[and##[ADO logging-command sequences in backticks so they render as code instead of being executed. This function is correctly called bysanitize_text()/sanitize_config()for content fields (work item title, PR body, wiki content, etc.).However, fields that developers classified as "structural identifiers" (wiki page
path, NDJSON entrytitlein log context) are only stripped of control characters. The comments make this explicit:##vso[task.setvariable variable=X]Ycontains only printable ASCII β it survives both filters and reachesprintln!()in Stage 3, which runs in an ADO pipeline task. ADO interprets any##vso[or##[sequences that appear anywhere in a task's stdout.Finding 1: Budget-exceeded skip message (execute.rs)
Location:
src/execute.rs:363β386(extract_entry_context) andsrc/execute.rs:415β426(check_budget+println!)Attack vector: Stage 1 agent emits
max + 1entries for any tool that has atitleorpathfield. The firstmaxentries are executed normally. The extra entry triggers the budget-exceeded path, which callsextract_entry_contextand embeds the unsanitizedtitle(β€ 40 chars) orpath(unlimited length) in the skip message.Proof of concept: NDJSON file with two
create-work-itementries (DEFAULT_MAX = 1):{"name":"create-work-item","title":"Legitimate item","type":"Task"} {"name":"create-work-item","title":"##vso[task.complete result=Failed]","type":"Task"} ``` The second entry triggers: ``` [2/2] create-work-item - β - Skipped ("##vso[task.complete result=Failed]"): maximum create-work-item count (1) already reached.ADO interprets
##vso[task.complete result=Failed]and marks Stage 3 as Failed, aborting remaining entries. Any earlier entries are unaffected.The
pathfield variant (wiki tools) is even more powerful β it is not truncated at 40 chars, allowing longer commands:{"name":"create-wiki-page","path":"##vso[task.setvariable variable=MY_VAR;issecret=false]injected","content":"x","wiki-id":"..."} {"name":"create-wiki-page","path":"any","content":"x","wiki-id":"..."}Impact: ADO task completion manipulation, pipeline variable injection, early Stage 3 termination that silently skips remaining safe outputs.
Finding 2 & 3: Wiki page
effective_pathin success/failure messagesLocation:
src/safeoutputs/create_wiki_page.rs:305β315, 386, 399β402src/safeoutputs/update_wiki_page.rs:379, 394Attack vector: No budget manipulation required. A single well-formed entry is sufficient.
self.pathis only control-char filtered (not VSO-neutralized) insanitize_content_fields. Aftereffective_path = normalize_wiki_path(&self.path), the ADO logging command survives and is embedded verbatim in three message paths:create_wiki_page.rs:313β316):format!("Wiki page '{effective_path}' already exists...")create_wiki_page.rs:399β402):format!("Failed to create wiki page '{}' (HTTP {}): {}", effective_path, ...)create_wiki_page.rs:386):format!("Created wiki page: {effective_path}")The wiki page path validation (
Validate::validate) only rejects.., null bytes, and empty paths β##,[,],;are all allowed.Proof of concept: Single NDJSON entry:
{ "name": "create-wiki-page", "path": "##vso[task.setvariable variable=PIPELINE_VAR;issecret=false]attacker_value", "content": "page content here", "wiki-id": "my-wiki" } ``` Regardless of whether ADO accepts or rejects the wiki path (both success and failure messages are affected), Stage 3 stdout will contain a `##vso[task.setvariable]` command that ADO will execute, setting `PIPELINE_VAR` to `attacker_value"): [... rest of message ...]`. The same applies to `update-wiki-page` (Finding 3). --- ### Impact | Effect | Mechanism | |--------|-----------| | Pipeline variable injection | `##vso[task.setvariable variable=X;issecret=false]Y` sets pipeline variable X for subsequent steps in Stage 3 (e.g., log-copy, publish steps) | | Stage 3 early termination | `##vso[task.complete result=Succeeded]` or `result=Failed` aborts the current bash step, leaving unprocessed safe outputs silently skipped | | False build status | `##vso[task.complete result=SucceededWithIssues]` masks real failures | | Artifact upload | `##vso[task.uploadfile path=/path/to/sensitive/file]` can attach local runner files to the pipeline timeline | **Important mitigations already in place**: - SC_WRITE_TOKEN is loaded at step startup; `##vso[task.setvariable variable=SC_WRITE_TOKEN]` only affects variables used in *subsequent* steps, not the current `ado-aw execute` step itself - Stage 2 (Detection) is supposed to flag malicious proposals, though it may not specifically scan `title`/`path` fields for `##vso[` sequences - Exploitation requires a compromised or prompt-injected Stage 1 agent --- ### Suggested Fix Apply `neutralize_pipeline_commands` (already available in `sanitize.rs`) to structural fields before they are embedded in log messages: **Option A β Fix at the source (`sanitize_content_fields`)**: Call `sanitize_config()` (which includes VSO neutralization) instead of the bare control-char filter for wiki `path` and similar structural fields in Stage 3 log messages. Since these strings never reach ADO API URLs unsanitized (reqwest URL-encodes query params), the backtick wrapping would only affect the local log message. **Option B β Fix at the sink (`extract_entry_context` and result message formatters)**: Run `neutralize_pipeline_commands(&clean)` on the cleaned string before embedding it in any format string that will be passed to `println!`. Both options are one-line fixes per call site. --- ### Affected call sites ``` src/execute.rs:374 title branch in extract_entry_context src/execute.rs:383 path branch in extract_entry_context src/safeoutputs/create_wiki_page.rs:75β79 sanitize_content_fields path src/safeoutputs/create_wiki_page.rs:305β306 GET-fail message src/safeoutputs/create_wiki_page.rs:313β315 already-exists message src/safeoutputs/create_wiki_page.rs:386 success message src/safeoutputs/create_wiki_page.rs:399β402 PUT-fail message src/safeoutputs/update_wiki_page.rs:71β75 sanitize_content_fields path src/safeoutputs/update_wiki_page.rs:379 success message src/safeoutputs/update_wiki_page.rs:394 failure messageAudit Coverage
This issue was created by the automated red team security auditor.