Skip to content

Commit 793a842

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 793a842

6 files changed

Lines changed: 210 additions & 37 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: 177 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,35 @@ 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.is_empty()
470+
|| !model
471+
.chars()
472+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-'))
473+
{
474+
anyhow::bail!(
475+
"Model name '{}' contains invalid characters. \
476+
Only ASCII alphanumerics, '.', '_', ':', and '-' are allowed.",
477+
model
478+
);
479+
}
480+
params.push(format!("--model {}", model));
431481
if front_matter.engine.max_turns().is_some() {
432482
eprintln!(
433483
"Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \
@@ -458,7 +508,7 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
458508
}
459509
}
460510

461-
params.join(" ")
511+
Ok(params.join(" "))
462512
}
463513

464514
/// Compute the effective workspace based on explicit setting and checkout configuration.
@@ -1194,7 +1244,7 @@ mod tests {
11941244
cache_memory: None,
11951245
azure_devops: None,
11961246
});
1197-
let params = generate_copilot_params(&fm);
1247+
let params = generate_copilot_params(&fm).unwrap();
11981248
assert!(params.contains("--allow-tool \"shell(:*)\""));
11991249
}
12001250

@@ -1207,7 +1257,7 @@ mod tests {
12071257
cache_memory: None,
12081258
azure_devops: None,
12091259
});
1210-
let params = generate_copilot_params(&fm);
1260+
let params = generate_copilot_params(&fm).unwrap();
12111261
assert!(!params.contains("shell("));
12121262
}
12131263

@@ -1221,7 +1271,7 @@ mod tests {
12211271
..Default::default()
12221272
}),
12231273
);
1224-
let params = generate_copilot_params(&fm);
1274+
let params = generate_copilot_params(&fm).unwrap();
12251275
assert!(!params.contains("--mcp my-tool"));
12261276
}
12271277

@@ -1230,7 +1280,7 @@ mod tests {
12301280
let mut fm = minimal_front_matter();
12311281
fm.mcp_servers
12321282
.insert("ado".to_string(), McpConfig::Enabled(true));
1233-
let params = generate_copilot_params(&fm);
1283+
let params = generate_copilot_params(&fm).unwrap();
12341284
// Copilot CLI has no built-in MCPs — all MCPs are handled via the MCP firewall
12351285
assert!(!params.contains("--mcp ado"));
12361286
}
@@ -1241,14 +1291,14 @@ mod tests {
12411291
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 50\n---\n",
12421292
)
12431293
.unwrap();
1244-
let params = generate_copilot_params(&fm);
1294+
let params = generate_copilot_params(&fm).unwrap();
12451295
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
12461296
}
12471297

12481298
#[test]
12491299
fn test_copilot_params_no_max_turns_when_simple_engine() {
12501300
let fm = minimal_front_matter();
1251-
let params = generate_copilot_params(&fm);
1301+
let params = generate_copilot_params(&fm).unwrap();
12521302
assert!(!params.contains("--max-turns"));
12531303
}
12541304

@@ -1258,14 +1308,14 @@ mod tests {
12581308
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n",
12591309
)
12601310
.unwrap();
1261-
let params = generate_copilot_params(&fm);
1311+
let params = generate_copilot_params(&fm).unwrap();
12621312
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
12631313
}
12641314

12651315
#[test]
12661316
fn test_copilot_params_no_max_timeout_when_simple_engine() {
12671317
let fm = minimal_front_matter();
1268-
let params = generate_copilot_params(&fm);
1318+
let params = generate_copilot_params(&fm).unwrap();
12691319
assert!(!params.contains("--max-timeout"));
12701320
}
12711321

@@ -1275,7 +1325,7 @@ mod tests {
12751325
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 0\n---\n",
12761326
)
12771327
.unwrap();
1278-
let params = generate_copilot_params(&fm);
1328+
let params = generate_copilot_params(&fm).unwrap();
12791329
assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg");
12801330
}
12811331

@@ -1285,7 +1335,7 @@ mod tests {
12851335
"---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n",
12861336
)
12871337
.unwrap();
1288-
let params = generate_copilot_params(&fm);
1338+
let params = generate_copilot_params(&fm).unwrap();
12891339
assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg");
12901340
}
12911341

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

src/compile/onees.rs

Lines changed: 7 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

@@ -48,6 +48,9 @@ impl Compiler for OneESCompiler {
4848
) -> Result<String> {
4949
info!("Compiling for 1ES target");
5050

51+
// Validate inputs early, before any values are used in template substitution
52+
validate_front_matter_identity(front_matter)?;
53+
5154
// Load 1ES template
5255
let template = include_str!("../../templates/1es-base.yml");
5356

@@ -61,7 +64,7 @@ impl Compiler for OneESCompiler {
6164
let repositories = generate_repositories(&front_matter.repositories);
6265
let checkout_steps = generate_checkout_steps(&front_matter.checkout);
6366
let checkout_self = generate_checkout_self();
64-
let copilot_params = generate_copilot_params(front_matter);
67+
let copilot_params = generate_copilot_params(front_matter)?;
6568
let has_memory = front_matter
6669
.tools
6770
.as_ref()

0 commit comments

Comments
 (0)