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