Skip to content

Commit b7ac7a1

Browse files
jamesadevineCopilot
andcommitted
fix: address injection vulnerabilities from red team audit (#171)
- Validate engine.model against [A-Za-z0-9._:-]+ to prevent shell injection via single-quote breakout in AWF command (Finding 1, High) - Validate tools.bash entries reject single quotes (same attack surface) - Reject ADO template expressions (${{ and $() in front matter name and description fields to prevent secret disclosure (Finding 2, Medium) - Reject newlines in name/description to prevent YAML structure injection - Escape single quotes in pipeline trigger config by doubling them per YAML spec (Finding 3, Medium) - Quote {{ allowed_domains }} in base.yml template to prevent AWF argument injection via spaces in domain patterns (Finding 4, Medium) - Clarify network.blocked docs: exact-match removal, not wildcard-aware blocking (Finding 6, Low) - Add 9 security validation test cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cc3355f commit b7ac7a1

6 files changed

Lines changed: 192 additions & 33 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ teardown: # separate job AFTER safe outputs processing
189189
network: # optional network policy (standalone target only)
190190
allow: # additional allowed host patterns
191191
- "*.mycompany.com"
192-
blocked: # blocked host patterns (takes precedence over allow)
192+
blocked: # blocked host patterns (removes exact entries from the allow list)
193193
- "evil.example.com"
194194
permissions: # optional ADO access token configuration
195195
read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ reachable. The allowlist is built from:
378378
1. **Core domains** — Azure DevOps, GitHub, Microsoft auth, Azure storage
379379
2. **MCP domains** — automatically added per enabled MCP
380380
3. **User domains** — from `network.allow` in front matter
381-
4. **Minus blocked** — `network.blocked` entries are removed
381+
4. **Minus blocked** — `network.blocked` entries are removed by exact match (wildcard patterns like `*.example.com` are not affected by blocking a specific subdomain)
382382

383383
```yaml
384384
network:

src/compile/common.rs

Lines changed: 176 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -145,21 +145,48 @@ fn is_valid_parameter_name(name: &str) -> bool {
145145
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
146146
}
147147

148-
/// Reject ADO template expressions (`${{`) and macro expressions (`$(`) in a string value.
149-
/// Parameter definitions should only contain literal values — expressions could enable
150-
/// information disclosure or logic manipulation in the generated pipeline.
148+
/// Reject ADO template expressions (`${{`), macro expressions (`$(`), and runtime
149+
/// expressions (`$[`) in a string value. Parameter definitions should only contain
150+
/// literal values — expressions could enable information disclosure or logic manipulation
151+
/// in the generated pipeline.
151152
fn reject_ado_expressions(value: &str, param_name: &str, field_name: &str) -> Result<()> {
152-
if value.contains("${{") || value.contains("$(") {
153+
if value.contains("${{") || value.contains("$(") || value.contains("$[") {
153154
anyhow::bail!(
154-
"Parameter '{}' field '{}' contains an ADO expression ('${{{{' or '$(') which is not \
155-
allowed in parameter definitions. Use literal values only.",
155+
"Parameter '{}' field '{}' contains an ADO expression ('${{{{', '$(', or '$[') which \
156+
is not allowed in parameter definitions. Use literal values only.",
156157
param_name,
157158
field_name,
158159
);
159160
}
160161
Ok(())
161162
}
162163

164+
/// Validate front matter `name` and `description` fields.
165+
///
166+
/// These values are substituted directly into the pipeline YAML template and must not
167+
/// contain ADO expressions (`${{`, `$(`, `$[`) which could disclose secrets or manipulate
168+
/// pipeline logic. Newlines are also rejected to prevent YAML structure injection.
169+
pub fn validate_front_matter_identity(front_matter: &FrontMatter) -> Result<()> {
170+
for (field, value) in [("name", &front_matter.name), ("description", &front_matter.description)] {
171+
if value.contains("${{") || value.contains("$(") || value.contains("$[") {
172+
anyhow::bail!(
173+
"Front matter '{}' contains an ADO expression ('${{{{', '$(', or '$[') which is not allowed. \
174+
Use literal values only. Found: '{}'",
175+
field,
176+
value,
177+
);
178+
}
179+
if value.contains('\n') || value.contains('\r') {
180+
anyhow::bail!(
181+
"Front matter '{}' must be a single line (no newlines). \
182+
Multi-line values could inject YAML structure into the generated pipeline.",
183+
field,
184+
);
185+
}
186+
}
187+
Ok(())
188+
}
189+
163190
/// Reject ADO expressions in a serde_yaml::Value, recursing into strings within sequences.
164191
fn reject_ado_expressions_in_value(
165192
value: &serde_yaml::Value,
@@ -265,10 +292,10 @@ pub fn generate_pipeline_resources(triggers: &Option<TriggerConfig>) -> Result<S
265292
let mut yaml = String::from("pipelines:\n");
266293

267294
yaml.push_str(&format!(" - pipeline: {}\n", resource_id));
268-
yaml.push_str(&format!(" source: '{}'\n", pipeline.name));
295+
yaml.push_str(&format!(" source: '{}'\n", pipeline.name.replace('\'', "''")));
269296

270297
if let Some(project) = &pipeline.project {
271-
yaml.push_str(&format!(" project: '{}'\n", project));
298+
yaml.push_str(&format!(" project: '{}'\n", project.replace('\'', "''")));
272299
}
273300

274301
// If no branches specified, trigger on any branch
@@ -279,7 +306,7 @@ pub fn generate_pipeline_resources(triggers: &Option<TriggerConfig>) -> Result<S
279306
yaml.push_str(" branches:\n");
280307
yaml.push_str(" include:\n");
281308
for branch in &pipeline.branches {
282-
yaml.push_str(&format!(" - {}\n", branch));
309+
yaml.push_str(&format!(" - '{}'\n", branch.replace('\'', "''")));
283310
}
284311
}
285312

@@ -388,7 +415,7 @@ const DEFAULT_BASH_COMMANDS: &[&str] = &[
388415
];
389416

390417
/// Generate copilot CLI params from front matter configuration
391-
pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
418+
pub fn generate_copilot_params(front_matter: &FrontMatter) -> Result<String> {
392419
let mut allowed_tools: Vec<String> = vec!["github".to_string(), "safeoutputs".to_string()];
393420

394421
// Edit tool: enabled by default, can be disabled with `edit: false`
@@ -422,12 +449,34 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
422449
}
423450
};
424451
for cmd in bash_commands {
452+
// Reject single quotes in bash commands — copilot_params are embedded inside
453+
// a single-quoted bash string in the AWF command.
454+
if cmd.contains('\'') {
455+
anyhow::bail!(
456+
"Bash command '{}' contains a single quote, which is not allowed \
457+
(would break AWF shell quoting).",
458+
cmd
459+
);
460+
}
425461
allowed_tools.push(format!("shell({})", cmd));
426462
}
427463

428464
let mut params = Vec::new();
429465

430-
params.push(format!("--model {}", front_matter.engine.model()));
466+
// Validate model name to prevent shell injection — copilot_params are embedded
467+
// inside a single-quoted bash string in the AWF command.
468+
let model = front_matter.engine.model();
469+
if !model
470+
.chars()
471+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-'))
472+
{
473+
anyhow::bail!(
474+
"Model name '{}' contains invalid characters. \
475+
Only ASCII alphanumerics, '.', '_', ':', and '-' are allowed.",
476+
model
477+
);
478+
}
479+
params.push(format!("--model {}", model));
431480
if front_matter.engine.max_turns().is_some() {
432481
eprintln!(
433482
"Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \
@@ -458,7 +507,7 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
458507
}
459508
}
460509

461-
params.join(" ")
510+
Ok(params.join(" "))
462511
}
463512

464513
/// Compute the effective workspace based on explicit setting and checkout configuration.
@@ -1194,7 +1243,7 @@ mod tests {
11941243
cache_memory: None,
11951244
azure_devops: None,
11961245
});
1197-
let params = generate_copilot_params(&fm);
1246+
let params = generate_copilot_params(&fm).unwrap();
11981247
assert!(params.contains("--allow-tool \"shell(:*)\""));
11991248
}
12001249

@@ -1207,7 +1256,7 @@ mod tests {
12071256
cache_memory: None,
12081257
azure_devops: None,
12091258
});
1210-
let params = generate_copilot_params(&fm);
1259+
let params = generate_copilot_params(&fm).unwrap();
12111260
assert!(!params.contains("shell("));
12121261
}
12131262

@@ -1221,7 +1270,7 @@ mod tests {
12211270
..Default::default()
12221271
}),
12231272
);
1224-
let params = generate_copilot_params(&fm);
1273+
let params = generate_copilot_params(&fm).unwrap();
12251274
assert!(!params.contains("--mcp my-tool"));
12261275
}
12271276

@@ -1230,7 +1279,7 @@ mod tests {
12301279
let mut fm = minimal_front_matter();
12311280
fm.mcp_servers
12321281
.insert("ado".to_string(), McpConfig::Enabled(true));
1233-
let params = generate_copilot_params(&fm);
1282+
let params = generate_copilot_params(&fm).unwrap();
12341283
// Copilot CLI has no built-in MCPs — all MCPs are handled via the MCP firewall
12351284
assert!(!params.contains("--mcp ado"));
12361285
}
@@ -1241,14 +1290,14 @@ mod tests {
12411290
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 50\n---\n",
12421291
)
12431292
.unwrap();
1244-
let params = generate_copilot_params(&fm);
1293+
let params = generate_copilot_params(&fm).unwrap();
12451294
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
12461295
}
12471296

12481297
#[test]
12491298
fn test_copilot_params_no_max_turns_when_simple_engine() {
12501299
let fm = minimal_front_matter();
1251-
let params = generate_copilot_params(&fm);
1300+
let params = generate_copilot_params(&fm).unwrap();
12521301
assert!(!params.contains("--max-turns"));
12531302
}
12541303

@@ -1258,14 +1307,14 @@ mod tests {
12581307
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n",
12591308
)
12601309
.unwrap();
1261-
let params = generate_copilot_params(&fm);
1310+
let params = generate_copilot_params(&fm).unwrap();
12621311
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
12631312
}
12641313

12651314
#[test]
12661315
fn test_copilot_params_no_max_timeout_when_simple_engine() {
12671316
let fm = minimal_front_matter();
1268-
let params = generate_copilot_params(&fm);
1317+
let params = generate_copilot_params(&fm).unwrap();
12691318
assert!(!params.contains("--max-timeout"));
12701319
}
12711320

@@ -1275,7 +1324,7 @@ mod tests {
12751324
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 0\n---\n",
12761325
)
12771326
.unwrap();
1278-
let params = generate_copilot_params(&fm);
1327+
let params = generate_copilot_params(&fm).unwrap();
12791328
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
12801329
}
12811330

@@ -1285,7 +1334,7 @@ mod tests {
12851334
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n",
12861335
)
12871336
.unwrap();
1288-
let params = generate_copilot_params(&fm);
1337+
let params = generate_copilot_params(&fm).unwrap();
12891338
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
12901339
}
12911340

@@ -2200,4 +2249,109 @@ mod tests {
22002249
"None service connection should produce empty env block"
22012250
);
22022251
}
2252+
2253+
// ─── Security validation tests ────────────────────────────────────────────
2254+
2255+
#[test]
2256+
fn test_model_name_rejects_single_quote() {
2257+
let (mut fm, _) = parse_markdown(
2258+
"---\nname: test\ndescription: test\nengine: claude-opus-4.5\n---\n",
2259+
)
2260+
.unwrap();
2261+
fm.engine = crate::compile::types::EngineConfig::Simple("model' && echo pwned".to_string());
2262+
let result = generate_copilot_params(&fm);
2263+
assert!(result.is_err());
2264+
assert!(result.unwrap_err().to_string().contains("invalid characters"));
2265+
}
2266+
2267+
#[test]
2268+
fn test_model_name_rejects_space() {
2269+
let mut fm = minimal_front_matter();
2270+
fm.engine = crate::compile::types::EngineConfig::Simple("model && curl evil.com".to_string());
2271+
let result = generate_copilot_params(&fm);
2272+
assert!(result.is_err());
2273+
}
2274+
2275+
#[test]
2276+
fn test_model_name_allows_valid_names() {
2277+
for name in &["claude-opus-4.5", "gpt-5.2-codex", "gemini-3-pro-preview", "my_model:latest"] {
2278+
let mut fm = minimal_front_matter();
2279+
fm.engine = crate::compile::types::EngineConfig::Simple(name.to_string());
2280+
let result = generate_copilot_params(&fm);
2281+
assert!(result.is_ok(), "Model name '{}' should be valid", name);
2282+
}
2283+
}
2284+
2285+
#[test]
2286+
fn test_bash_command_rejects_single_quote() {
2287+
let mut fm = minimal_front_matter();
2288+
fm.tools = Some(crate::compile::types::ToolsConfig {
2289+
bash: Some(vec!["cat'".to_string()]),
2290+
edit: None,
2291+
cache_memory: None,
2292+
azure_devops: None,
2293+
});
2294+
let result = generate_copilot_params(&fm);
2295+
assert!(result.is_err());
2296+
assert!(result.unwrap_err().to_string().contains("single quote"));
2297+
}
2298+
2299+
#[test]
2300+
fn test_validate_front_matter_identity_rejects_ado_expression_in_name() {
2301+
let mut fm = minimal_front_matter();
2302+
fm.name = "My Agent ${{ variables['System.AccessToken'] }}".to_string();
2303+
let result = validate_front_matter_identity(&fm);
2304+
assert!(result.is_err());
2305+
assert!(result.unwrap_err().to_string().contains("ADO expression"));
2306+
}
2307+
2308+
#[test]
2309+
fn test_validate_front_matter_identity_rejects_macro_in_description() {
2310+
let mut fm = minimal_front_matter();
2311+
fm.description = "Agent $(System.AccessToken)".to_string();
2312+
let result = validate_front_matter_identity(&fm);
2313+
assert!(result.is_err());
2314+
assert!(result.unwrap_err().to_string().contains("ADO expression"));
2315+
}
2316+
2317+
#[test]
2318+
fn test_validate_front_matter_identity_rejects_newline_in_name() {
2319+
let mut fm = minimal_front_matter();
2320+
fm.name = "My Agent\ninjected: true".to_string();
2321+
let result = validate_front_matter_identity(&fm);
2322+
assert!(result.is_err());
2323+
assert!(result.unwrap_err().to_string().contains("single line"));
2324+
}
2325+
2326+
#[test]
2327+
fn test_validate_front_matter_identity_allows_valid_values() {
2328+
let mut fm = minimal_front_matter();
2329+
fm.name = "Daily Code Review Agent".to_string();
2330+
fm.description = "Reviews code daily for quality issues".to_string();
2331+
let result = validate_front_matter_identity(&fm);
2332+
assert!(result.is_ok());
2333+
}
2334+
2335+
#[test]
2336+
fn test_validate_front_matter_identity_rejects_runtime_expression() {
2337+
let mut fm = minimal_front_matter();
2338+
fm.name = "Agent $[variables['System.AccessToken']]".to_string();
2339+
let result = validate_front_matter_identity(&fm);
2340+
assert!(result.is_err());
2341+
assert!(result.unwrap_err().to_string().contains("ADO expression"));
2342+
}
2343+
2344+
#[test]
2345+
fn test_pipeline_resources_escapes_single_quotes() {
2346+
let triggers = Some(TriggerConfig {
2347+
pipeline: Some(crate::compile::types::PipelineTrigger {
2348+
name: "Build's Pipeline".to_string(),
2349+
project: Some("My'Project".to_string()),
2350+
branches: vec!["main".to_string()],
2351+
}),
2352+
});
2353+
let result = generate_pipeline_resources(&triggers).unwrap();
2354+
assert!(result.contains("source: 'Build''s Pipeline'"));
2355+
assert!(result.contains("project: 'My''Project'"));
2356+
}
22032357
}

src/compile/onees.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ use super::common::{
2424
generate_job_timeout, generate_parameters, generate_pipeline_path,
2525
generate_pipeline_resources, generate_pr_trigger, generate_repositories, generate_schedule,
2626
generate_source_path, generate_working_directory, is_custom_mcp, replace_with_indent,
27-
validate_comment_target, validate_resolve_pr_thread_statuses,
28-
validate_submit_pr_review_events, validate_update_pr_votes,
29-
validate_update_work_item_target, validate_write_permissions,
27+
validate_comment_target, validate_front_matter_identity,
28+
validate_resolve_pr_thread_statuses, validate_submit_pr_review_events,
29+
validate_update_pr_votes, validate_update_work_item_target, validate_write_permissions,
3030
};
3131
use super::types::{FrontMatter, McpConfig};
3232

@@ -61,7 +61,7 @@ impl Compiler for OneESCompiler {
6161
let repositories = generate_repositories(&front_matter.repositories);
6262
let checkout_steps = generate_checkout_steps(&front_matter.checkout);
6363
let checkout_self = generate_checkout_self();
64-
let copilot_params = generate_copilot_params(front_matter);
64+
let copilot_params = generate_copilot_params(front_matter)?;
6565
let has_memory = front_matter
6666
.tools
6767
.as_ref()
@@ -156,6 +156,8 @@ displayName: "Finalize""#,
156156

157157
// Validate that write-requiring safe-outputs have a write service connection
158158
validate_write_permissions(front_matter)?;
159+
// Validate name/description don't contain ADO expressions or newlines
160+
validate_front_matter_identity(front_matter)?;
159161
// Validate comment-on-work-item has required target field
160162
validate_comment_target(front_matter)?;
161163
// Validate update-work-item has required target field

0 commit comments

Comments
 (0)