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