Skip to main content

safe_chains/handlers/
mod.rs

1macro_rules! handler_module {
2    ($($sub:ident),+ $(,)?) => {
3        $(mod $sub;)+
4
5        pub(crate) fn dispatch(cmd: &str, tokens: &[crate::parse::Token]) -> Option<crate::verdict::Verdict> {
6            None$(.or_else(|| $sub::dispatch(cmd, tokens)))+
7        }
8
9        pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
10            let mut docs = Vec::new();
11            $(docs.extend($sub::command_docs());)+
12            docs
13        }
14
15        #[cfg(test)]
16        pub(super) fn full_registry() -> Vec<&'static super::CommandEntry> {
17            let mut v = Vec::new();
18            $(v.extend($sub::REGISTRY);)+
19            v
20        }
21    };
22}
23
24pub mod android;
25pub mod coreutils;
26pub mod forges;
27pub mod fuzzy;
28pub mod jvm;
29pub mod magick;
30pub mod network;
31pub mod node;
32pub mod perl;
33pub mod php;
34pub mod ruby;
35pub mod shell;
36pub mod system;
37pub mod vcs;
38pub mod wrappers;
39
40use std::collections::HashMap;
41
42use crate::parse::Token;
43use crate::verdict::Verdict;
44
45type HandlerFn = fn(&[Token]) -> Verdict;
46
47pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
48    HashMap::from([
49        ("magick", magick::is_safe_magick as HandlerFn),
50        ("php", php::is_safe_php as HandlerFn),
51        ("sysctl", system::sysctl::is_safe_sysctl as HandlerFn),
52    ])
53}
54
55pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
56    HashMap::from([
57        ("bun_x", node::bun::check_bun_x as HandlerFn),
58        ("bundle_config", ruby::bundle::check_bundle_config as HandlerFn),
59        ("bundle_exec", ruby::bundle::check_bundle_exec as HandlerFn),
60        ("git_remote", vcs::git::check_git_remote as HandlerFn),
61        ("laravel_cache_clear", php::check_laravel_cache_clear as HandlerFn),
62        ("plutil_convert", system::plutil::check_plutil_convert as HandlerFn),
63    ])
64}
65
66pub fn dispatch(tokens: &[Token]) -> Verdict {
67    let cmd = tokens[0].command_name();
68    None
69        .or_else(|| shell::dispatch(cmd, tokens))
70        .or_else(|| wrappers::dispatch(cmd, tokens))
71        .or_else(|| forges::dispatch(cmd, tokens))
72        .or_else(|| node::dispatch(cmd, tokens))
73        .or_else(|| jvm::dispatch(cmd, tokens))
74        .or_else(|| android::dispatch(cmd, tokens))
75        .or_else(|| network::dispatch(cmd, tokens))
76        .or_else(|| system::dispatch(cmd, tokens))
77        .or_else(|| perl::dispatch(cmd, tokens))
78        .or_else(|| coreutils::dispatch(cmd, tokens))
79        .or_else(|| fuzzy::dispatch(cmd, tokens))
80        .or_else(|| vcs::dispatch(cmd, tokens))
81        .or_else(|| crate::registry::toml_dispatch(tokens))
82        .unwrap_or(Verdict::Denied)
83}
84
85#[cfg(test)]
86const HANDLED_CMDS: &[&str] = &[
87    "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv", "jai",
88    "git", "jj", "gh", "glab", "jjpr", "tea", "basecamp",
89    "jira", "linear", "notion", "td", "todoist", "trello",
90    "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta", "mocha",
91    "ruby", "ri", "bundle", "gem", "importmap", "rails", "rbenv",
92    "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
93    "cargo", "rustup",
94    "go",
95    "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
96    "javap", "jar", "keytool", "jarsigner",
97    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
98    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
99    "fastlane", "firebase",
100    "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
101    "swift",
102    "dotnet",
103    "curl",
104    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
105    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
106    "ddev", "dcli",
107    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
108    "pg_dump", "bazel", "meson", "ninja",
109    "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "wrangler", "cf", "newrelic",
110    "aws", "gcloud", "az",
111    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
112    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
113    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
114    "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
115    "monolith",
116    "cloudflared", "ngrok", "ssh",
117    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
118    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
119    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
120    "perl",
121    "R", "Rscript",
122    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
123    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
124    "diff", "comm", "paste", "tac", "rev", "nl",
125    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
126    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
127    "arch", "command", "hostname",
128    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
129    "magick", "convert", "frames",
130    "fd", "eza", "exa", "ls", "delta", "colordiff",
131    "dirname", "basename", "realpath", "readlink",
132    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
133    "true", "false", ":", "shopt",
134    "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
135    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
136    "who", "w", "last", "lastlog",
137    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free",
138    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
139    "base64", "xxd", "getconf", "uuidgen",
140    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
141    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
142    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
143    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
144    "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
145    "xv",
146    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
147    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
148    "tldr", "ldd", "objdump", "readelf", "just",
149    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
150    "rubocop", "eslint", "biome", "stylelint", "zoxide",
151    "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
152    "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
153    "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
154    "@astrojs/check", "@changesets/cli",
155    "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
156    "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
157    "@microsoft/api-extractor", "@asyncapi/cli",
158    "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
159    "depcheck", "madge", "license-checker",
160    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
161    "direnv", "make", "packer", "vagrant",
162    "node", "python3", "python", "rustc", "java", "php",
163    "gcc", "g++", "cc", "c++", "clang", "clang++",
164    "elixir", "erl", "mix", "zig", "lua", "tsc",
165    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
166    "git-cliff", "git-lfs", "tig",
167    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
168];
169
170pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
171    let mut docs = Vec::new();
172    docs.extend(forges::command_docs());
173    docs.extend(node::command_docs());
174    docs.extend(jvm::command_docs());
175    docs.extend(android::command_docs());
176    docs.extend(network::command_docs());
177    docs.extend(system::command_docs());
178    docs.extend(perl::command_docs());
179    docs.extend(coreutils::command_docs());
180    docs.extend(fuzzy::command_docs());
181    docs.extend(shell::command_docs());
182    docs.extend(wrappers::command_docs());
183    docs.extend(vcs::command_docs());
184    docs.extend(crate::registry::toml_command_docs());
185    docs
186}
187
188#[cfg(test)]
189#[derive(Debug)]
190pub(crate) enum CommandEntry {
191    Positional { cmd: &'static str },
192    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
193    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
194    Delegation { cmd: &'static str },
195}
196
197pub fn all_opencode_patterns() -> Vec<String> {
198    let mut patterns = Vec::new();
199    patterns.sort();
200    patterns.dedup();
201    patterns
202}
203
204#[cfg(test)]
205fn full_registry() -> Vec<&'static CommandEntry> {
206    let mut entries = Vec::new();
207    entries.extend(shell::REGISTRY);
208    entries.extend(wrappers::REGISTRY);
209    entries.extend(forges::full_registry());
210    entries.extend(node::full_registry());
211    entries.extend(jvm::full_registry());
212    entries.extend(android::full_registry());
213    entries.extend(network::REGISTRY);
214    entries.extend(system::full_registry());
215    entries.extend(perl::REGISTRY);
216    entries.extend(coreutils::full_registry());
217    entries.extend(fuzzy::full_registry());
218    entries
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use std::collections::HashSet;
225
226    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
227    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
228
229    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
230        match entry {
231            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
232            CommandEntry::Custom { cmd, valid_prefix } => {
233                let base = valid_prefix.unwrap_or(cmd);
234                let test = format!("{base} {UNKNOWN_FLAG}");
235                if crate::is_safe_command(&test) {
236                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
237                }
238            }
239            CommandEntry::Paths { cmd, bare_ok, paths } => {
240                if !bare_ok && crate::is_safe_command(cmd) {
241                    failures.push(format!("{cmd}: accepted bare invocation"));
242                }
243                let test = format!("{cmd} {UNKNOWN_SUB}");
244                if crate::is_safe_command(&test) {
245                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
246                }
247                for path in *paths {
248                    let test = format!("{path} {UNKNOWN_FLAG}");
249                    if crate::is_safe_command(&test) {
250                        failures.push(format!("{path}: accepted unknown flag: {test}"));
251                    }
252                }
253            }
254        }
255    }
256
257    #[test]
258    fn all_commands_reject_unknown() {
259        let registry = full_registry();
260        let mut failures = Vec::new();
261        for entry in &registry {
262            check_entry(entry, &mut failures);
263        }
264        assert!(
265            failures.is_empty(),
266            "unknown flags/subcommands accepted:\n{}",
267            failures.join("\n")
268        );
269    }
270
271    #[test]
272    fn process_substitution_safe_inner() {
273        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
274        for cmd in &safe {
275            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
276        }
277    }
278
279    #[test]
280    fn process_substitution_unsafe_inner() {
281        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
282        for cmd in &unsafe_cmds {
283            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
284        }
285    }
286
287    #[test]
288    fn registry_covers_handled_commands() {
289        let registry = full_registry();
290        let mut all_cmds: HashSet<&str> = registry
291            .iter()
292            .map(|e| match e {
293                CommandEntry::Positional { cmd }
294                | CommandEntry::Custom { cmd, .. }
295                | CommandEntry::Paths { cmd, .. }
296                | CommandEntry::Delegation { cmd } => *cmd,
297            })
298            .collect();
299        for name in crate::registry::toml_command_names() {
300            all_cmds.insert(name);
301        }
302        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
303
304        let missing: Vec<_> = handled.difference(&all_cmds).collect();
305        assert!(missing.is_empty(), "not in registry: {missing:?}");
306
307        let extra: Vec<_> = all_cmds.difference(&handled).collect();
308        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
309    }
310
311}