Skip to content

πŸ”΄ Red Team Audit β€” High: VSO command injection via unsanitized repository alias in Stage 3 safe output executorsΒ #479

@github-actions

Description

@github-actions

πŸ”΄ Red Team Security Audit

Audit focus: Category A β€” Input Sanitization & Injection (Stage 3 executor)
Severity: High

Findings

# Vulnerability Severity File(s) Exploitable?
1 repository alias not sanitized for ##vso[ in reply_to_pr_comment.rs High src/safeoutputs/reply_to_pr_comment.rs:62-65, 144-147, 160-163 Yes β€” triggered by any non-existent alias
2 repository alias strips control chars only (not ##vso[) in add_pr_comment.rs High src/safeoutputs/add_pr_comment.rs:112-120, 238-241, 292-295 Yes β€” triggered by any non-existent alias
3 repository alias not sanitized for ##vso[ in create_branch.rs High src/safeoutputs/create_branch.rs:92-95, 254-258 Yes β€” when allowed-repositories is configured
4 repository alias strips control chars only (not ##vso[) in create_git_tag.rs High src/safeoutputs/create_git_tag.rs:97-112, 289-293 Yes β€” when allowed-repositories is configured

Details

Root Cause

The SanitizeContent::sanitize_content_fields() implementations for these tools fully sanitize "content" text fields (agent-supplied prose via sanitize_text()) but incompletely sanitize structural identifier fields like repository. The repository field receives either:

  • No sanitization (reply_to_pr_comment.rs, create_branch.rs) β€” field is used raw
  • Control-character stripping only (add_pr_comment.rs line 116, create_git_tag.rs line 109-111) β€” strips \n/\r but ##vso[ contains no control characters

Stage 1 (MCP server AddPrCommentParams::validate(), ReplyToPrCommentParams::validate(), CreateBranchParams::validate()) does not validate the repository field for ADO pipeline command sequences.

These unsanitized values are embedded in ExecutionResult::failure() messages that are printed to stdout by src/execute.rs:217:

println!("[{}/{}] {} - {} - {}", i + 1, total, tool_name, symbol, result.message);

Azure DevOps processes ##vso[...] commands from all subprocess stdout output in a step, including from ado-aw execute.

Finding 1 & 2: add_pr_comment.rs and reply_to_pr_comment.rs

Attack vector: A Stage 1 agent calls either tool with:

{
  "pull_request_id": 1,
  "thread_id": 1,
  "content": "legitimate content here for the PR",
  "repository": "##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen"
}
```

**Trigger path** (no special operator config required): `execute_impl()` at `reply_to_pr_comment.rs:157-163` always tries `lookup_allowed_repository()` for any non-"self" alias. Since the malicious alias won't exist in the checkout list, it returns `None` and emits:

```
[1/1] reply-to-pr-review-comment - βœ— - Repository alias '##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen' not found in allowed repositories
```

ADO runner interprets `##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen` from stdout.

For `add_pr_comment.rs`, the same path exists at lines 292-295 (`lookup_allowed_repository` failure), plus an additional path at lines 238-241 triggered when `allowed_repositories` is configured.

#### Finding 3 & 4: `create_branch.rs` and `create_git_tag.rs`

**Attack vector**: A Stage 1 agent calls either tool with `repository: "##vso[task.complete result=SucceededWithIssues;done=true]task done"`.

**Trigger path**: `execute_impl()` at `create_branch.rs:254-258` returns an `ExecutionResult::failure` (bypassing the `neutralize_pipeline_commands` applied in the `Err` path) when `allowed_repositories` is configured and the alias doesn't match:

```
[1/1] create-branch - βœ— - Repository '##vso[task.complete result=SucceededWithIssues;done=true]task done' is not in the allowed-repositories list: [self, main-repo]

This only fires when the operator has set safe-outputs.create-branch.allowed-repositories (or create-git-tag).

Impact

An agent (operating in Stage 1 AWF read-only sandbox) can inject arbitrary ADO VSO commands into the Stage 3 Execution job's stdout, which ADO processes:

  • ##vso[task.setvariable variable=X;issecret=false]value β€” override or expose pipeline variables set by Stage 3 steps
  • ##vso[task.complete result=Failed;done=true] β€” forcibly mark the Execution task as failed mid-run, potentially abandoning remaining safe outputs
  • ##vso[task.complete result=SucceededWithIssues;done=true] β€” mask actual failures
  • ##vso[task.logissue type=error]fake error β€” inject false error entries into the audit log
  • Other ADO VSO commands available in the execution context

This violates the Stage 1 β†’ Stage 3 security boundary: the agent can influence Stage 3 pipeline behavior beyond the safe-output NDJSON mechanism.

Proof of concept:

{"name":"reply-to-pr-review-comment","pull_request_id":1,"thread_id":1,"content":"legitimate content for reviewers","repository":"##vso[task.setvariable variable=ADO_PAT;issecret=false]exfiltrated","timestamp":"2026-05-09T00:00:00Z"}

The agent writes this to the NDJSON (Stage 1 Validate passes β€” no repository format check). Stage 3 prints the failure message to stdout. ADO processes the VSO command.

Suggested fix:

Apply sanitize_config() (which calls neutralize_pipeline_commands()) to self.repository in sanitize_content_fields() for all four tools:

// reply_to_pr_comment.rs
impl SanitizeContent for ReplyToPrCommentResult {
    fn sanitize_content_fields(&mut self) {
        self.content = sanitize_text(&self.content);
        self.repository = self.repository.as_deref().map(sanitize_config).or(self.repository.clone());
        // or more simply:
        if let Some(ref r) = self.repository {
            self.repository = Some(sanitize_config(r));
        }
    }
}

Additionally, add reject_pipeline_injection() in Stage 1 Validate::validate() for the repository field across all affected tools, consistent with how resolve_pr_thread.rs and submit_pr_review.rs already sanitize self.repository via sanitize_config() in their sanitize_content_fields().

Comparison β€” tools that already handle this correctly:

  • resolve_pr_thread.rs:91-94 β€” calls sanitize_config(repo) βœ“
  • submit_pr_review.rs:95-98 β€” calls sanitize_config βœ“
  • update_pr.rs:152-153 β€” calls sanitize_config βœ“

Audit Coverage

Category Status
A: Input Sanitization βœ… Scanned
B: Path Traversal βœ… Scanned (prior run)
C: Network Bypass βœ… Scanned (prior run)
D: Credential Exposure βœ… Scanned (prior run)
E: Logic Flaws βœ… Scanned (prior run)
F: Supply Chain βœ… Scanned (prior run)

This issue was created by the automated red team security auditor.

Generated by Red Team Security Auditor Β· ● 6.5M Β· β—·

Metadata

Metadata

Labels

bugSomething isn't workingsecurity

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions