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", "rvm", "brakeman", "rspec",
92    "standardrb", "erb_lint", "erblint", "herb",
93    "reek", "flay", "flog", "fasterer", "haml-lint", "slim-lint",
94    "bundler-audit", "bundle-audit", "ruby-audit", "rdoc", "yard", "yardoc", "rubycritic",
95    "annotaterb", "annotate", "jekyll", "bridgetown", "middleman", "foreman", "guard",
96    "spring", "overcommit", "pry", "byebug", "thor", "m", "rake", "sdoc", "license_finder",
97    "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
98    "cargo", "rustup",
99    "go",
100    "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
101    "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
102    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
103    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
104    "fastlane", "firebase",
105    "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
106    "swift",
107    "dotnet",
108    "curl",
109    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
110    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
111    "ddev", "dcli",
112    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
113    "pg_dump", "bazel", "meson", "ninja",
114    "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
115    "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
116    "cx", "hey", "wrangler", "cf", "newrelic",
117    "aws", "gcloud", "az",
118    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
119    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
120    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
121    "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
122    "monolith",
123    "cloudflared", "ngrok", "ssh",
124    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
125    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
126    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
127    "perl",
128    "R", "Rscript",
129    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
130    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
131    "diff", "comm", "paste", "tac", "rev", "nl",
132    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
133    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
134    "arch", "command", "hostname",
135    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
136    "magick", "convert", "frames",
137    "fd", "eza", "exa", "ls", "delta", "colordiff",
138    "dirname", "basename", "realpath", "readlink",
139    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
140    "true", "false", ":", "shopt",
141    "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
142    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
143    "who", "w", "last", "lastlog",
144    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
145    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
146    "base64", "xxd", "getconf", "uuidgen",
147    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
148    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
149    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
150    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
151    "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
152    "xv",
153    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
154    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
155    "tldr", "ldd", "objdump", "readelf", "just",
156    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
157    "rubocop", "eslint", "biome", "stylelint", "zoxide",
158    "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
159    "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
160    "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
161    "@astrojs/check", "@changesets/cli",
162    "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
163    "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
164    "@microsoft/api-extractor", "@asyncapi/cli",
165    "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
166    "depcheck", "madge", "license-checker",
167    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
168    "direnv", "make", "packer", "vagrant",
169    "node", "python3", "python", "rustc", "java", "php",
170    "gcc", "g++", "cc", "c++", "clang", "clang++",
171    "elixir", "erl", "mix", "zig", "lua", "tsc",
172    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
173    "git-cliff", "git-lfs", "tig",
174    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
175];
176
177pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
178    let mut docs = Vec::new();
179    docs.extend(forges::command_docs());
180    docs.extend(node::command_docs());
181    docs.extend(jvm::command_docs());
182    docs.extend(android::command_docs());
183    docs.extend(network::command_docs());
184    docs.extend(system::command_docs());
185    docs.extend(perl::command_docs());
186    docs.extend(coreutils::command_docs());
187    docs.extend(fuzzy::command_docs());
188    docs.extend(shell::command_docs());
189    docs.extend(wrappers::command_docs());
190    docs.extend(vcs::command_docs());
191    docs.extend(crate::registry::toml_command_docs());
192    docs
193}
194
195#[cfg(test)]
196#[derive(Debug)]
197pub(crate) enum CommandEntry {
198    Positional { cmd: &'static str },
199    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
200    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
201    Delegation { cmd: &'static str },
202}
203
204pub fn all_opencode_patterns() -> Vec<String> {
205    let mut patterns = Vec::new();
206    patterns.sort();
207    patterns.dedup();
208    patterns
209}
210
211#[cfg(test)]
212fn full_registry() -> Vec<&'static CommandEntry> {
213    let mut entries = Vec::new();
214    entries.extend(shell::REGISTRY);
215    entries.extend(wrappers::REGISTRY);
216    entries.extend(forges::full_registry());
217    entries.extend(node::full_registry());
218    entries.extend(jvm::full_registry());
219    entries.extend(android::full_registry());
220    entries.extend(network::REGISTRY);
221    entries.extend(system::full_registry());
222    entries.extend(perl::REGISTRY);
223    entries.extend(coreutils::full_registry());
224    entries.extend(fuzzy::full_registry());
225    entries
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::collections::HashSet;
232
233    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
234    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
235
236    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
237        match entry {
238            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
239            CommandEntry::Custom { cmd, valid_prefix } => {
240                let base = valid_prefix.unwrap_or(cmd);
241                let test = format!("{base} {UNKNOWN_FLAG}");
242                if crate::is_safe_command(&test) {
243                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
244                }
245            }
246            CommandEntry::Paths { cmd, bare_ok, paths } => {
247                if !bare_ok && crate::is_safe_command(cmd) {
248                    failures.push(format!("{cmd}: accepted bare invocation"));
249                }
250                let test = format!("{cmd} {UNKNOWN_SUB}");
251                if crate::is_safe_command(&test) {
252                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
253                }
254                for path in *paths {
255                    let test = format!("{path} {UNKNOWN_FLAG}");
256                    if crate::is_safe_command(&test) {
257                        failures.push(format!("{path}: accepted unknown flag: {test}"));
258                    }
259                }
260            }
261        }
262    }
263
264    #[test]
265    fn all_commands_reject_unknown() {
266        let registry = full_registry();
267        let mut failures = Vec::new();
268        for entry in &registry {
269            check_entry(entry, &mut failures);
270        }
271        assert!(
272            failures.is_empty(),
273            "unknown flags/subcommands accepted:\n{}",
274            failures.join("\n")
275        );
276    }
277
278    #[test]
279    fn process_substitution_safe_inner() {
280        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
281        for cmd in &safe {
282            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
283        }
284    }
285
286    #[test]
287    fn process_substitution_unsafe_inner() {
288        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
289        for cmd in &unsafe_cmds {
290            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
291        }
292    }
293
294    #[test]
295    fn registry_covers_handled_commands() {
296        let registry = full_registry();
297        let mut all_cmds: HashSet<&str> = registry
298            .iter()
299            .map(|e| match e {
300                CommandEntry::Positional { cmd }
301                | CommandEntry::Custom { cmd, .. }
302                | CommandEntry::Paths { cmd, .. }
303                | CommandEntry::Delegation { cmd } => *cmd,
304            })
305            .collect();
306        for name in crate::registry::toml_command_names() {
307            all_cmds.insert(name);
308        }
309        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
310
311        let missing: Vec<_> = handled.difference(&all_cmds).collect();
312        assert!(missing.is_empty(), "not in registry: {missing:?}");
313
314        let extra: Vec<_> = all_cmds.difference(&handled).collect();
315        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
316    }
317
318}