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    "danger", "kamal", "mutant", "whenever", "haml", "slimrb", "railroady", "erd",
98    "parallel_test", "parallel_rspec", "parallel_cucumber", "parallel_spinach",
99    "racc", "rex",
100    "steep", "srb", "rbs", "typeprof", "stree", "rufo", "packwerk", "debride",
101    "i18n-tasks", "asciidoctor", "kramdown", "dawn", "fpm", "stackprof",
102    "pipx", "pip-compile", "pip-sync", "pre-commit", "sphinx-build", "sphinx-quickstart", "sphinx-apidoc",
103    "mkdocs", "twine", "yapf", "autopep8", "autoflake", "pyupgrade", "vulture", "pyflakes",
104    "pycodestyle", "pydocstyle", "cookiecutter", "copier", "deptry", "safety",
105    "http", "https", "ipython", "scalene", "py-spy", "kernprof", "mprof", "dvc", "alembic", "hatch",
106    "husky", "lint-staged", "markdownlint-cli2", "markdownlint", "typedoc", "nodemon", "pm2",
107    "ncu", "depcruise", "dependency-cruise",
108    "rollup", "vite", "esbuild", "swc", "webpack", "parcel", "tsup",
109    "prisma", "drizzle-kit", "sequelize", "knex",
110    "ava", "tap", "c8", "nyc", "jasmine",
111    "http-server", "serve", "concurrently",
112    "npm-run-all", "run-p", "run-s",
113    "tsx", "ts-node", "cucumber-js", "@cucumber/cucumber",
114    "bacon", "sccache", "sqlx", "diesel", "starship", "atuin",
115    "gofmt", "goimports", "gofumpt", "gci", "revive", "errcheck",
116    "gotestsum", "goreleaser", "mage", "task", "buf", "gosec", "gomodifytags", "dlv",
117    "scala", "scalac", "sbt", "mill", "groovy", "lein", "clj", "clojure",
118    "kotlinc", "scalafmt", "scalafix", "jdeps", "jcmd", "jstack",
119    "ghc", "cabal", "stack", "hlint", "ormolu", "fourmolu",
120    "opam", "dune", "ocamlformat",
121    "credo", "iex",
122    "clang-format", "clang-tidy", "cppcheck", "doxygen", "autoconf", "automake", "cmake-format",
123    "crystal", "shards", "ameba",
124    "nim", "nimble",
125    "luarocks", "selene",
126    "julia",
127    "dart", "flutter",
128    "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
129    "cargo", "rustup",
130    "go",
131    "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
132    "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
133    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
134    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
135    "fastlane", "firebase",
136    "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
137    "swift",
138    "dotnet",
139    "curl",
140    "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
141    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
142    "ddev", "dcli",
143    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
144    "pg_dump", "bazel", "meson", "ninja",
145    "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
146    "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
147    "cx", "hey", "wrangler", "cf", "newrelic",
148    "aws", "gcloud", "az",
149    "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
150    "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
151    "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
152    "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
153    "monolith",
154    "cloudflared", "ngrok", "ssh",
155    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
156    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
157    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
158    "perl",
159    "R", "Rscript",
160    "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
161    "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
162    "diff", "comm", "paste", "tac", "rev", "nl",
163    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
164    "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
165    "arch", "command", "hostname",
166    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
167    "magick", "convert", "frames",
168    "fd", "eza", "exa", "ls", "delta", "colordiff",
169    "dirname", "basename", "realpath", "readlink",
170    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
171    "true", "false", ":", "shopt",
172    "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
173    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
174    "who", "w", "last", "lastlog",
175    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
176    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
177    "base64", "xxd", "getconf", "uuidgen",
178    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
179    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
180    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
181    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
182    "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
183    "xv",
184    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
185    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
186    "tldr", "ldd", "objdump", "readelf", "just",
187    "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
188    "rubocop", "eslint", "biome", "stylelint", "zoxide",
189    "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
190    "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
191    "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
192    "@astrojs/check", "@changesets/cli",
193    "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
194    "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
195    "@microsoft/api-extractor", "@asyncapi/cli",
196    "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
197    "depcheck", "madge", "license-checker",
198    "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
199    "direnv", "make", "packer", "vagrant",
200    "node", "python3", "python", "rustc", "java", "php",
201    "gcc", "g++", "cc", "c++", "clang", "clang++",
202    "elixir", "erl", "mix", "zig", "lua", "tsc",
203    "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
204    "git-cliff", "git-lfs", "tig",
205    "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
206];
207
208pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
209    let mut docs = Vec::new();
210    docs.extend(forges::command_docs());
211    docs.extend(node::command_docs());
212    docs.extend(jvm::command_docs());
213    docs.extend(android::command_docs());
214    docs.extend(network::command_docs());
215    docs.extend(system::command_docs());
216    docs.extend(perl::command_docs());
217    docs.extend(coreutils::command_docs());
218    docs.extend(fuzzy::command_docs());
219    docs.extend(shell::command_docs());
220    docs.extend(wrappers::command_docs());
221    docs.extend(vcs::command_docs());
222    docs.extend(crate::registry::toml_command_docs());
223    docs
224}
225
226#[cfg(test)]
227#[derive(Debug)]
228pub(crate) enum CommandEntry {
229    Positional { cmd: &'static str },
230    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
231    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
232    Delegation { cmd: &'static str },
233}
234
235pub fn all_opencode_patterns() -> Vec<String> {
236    let mut patterns = Vec::new();
237    patterns.sort();
238    patterns.dedup();
239    patterns
240}
241
242#[cfg(test)]
243fn full_registry() -> Vec<&'static CommandEntry> {
244    let mut entries = Vec::new();
245    entries.extend(shell::REGISTRY);
246    entries.extend(wrappers::REGISTRY);
247    entries.extend(forges::full_registry());
248    entries.extend(node::full_registry());
249    entries.extend(jvm::full_registry());
250    entries.extend(android::full_registry());
251    entries.extend(network::REGISTRY);
252    entries.extend(system::full_registry());
253    entries.extend(perl::REGISTRY);
254    entries.extend(coreutils::full_registry());
255    entries.extend(fuzzy::full_registry());
256    entries
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use std::collections::HashSet;
263
264    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
265    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
266
267    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
268        match entry {
269            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
270            CommandEntry::Custom { cmd, valid_prefix } => {
271                let base = valid_prefix.unwrap_or(cmd);
272                let test = format!("{base} {UNKNOWN_FLAG}");
273                if crate::is_safe_command(&test) {
274                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
275                }
276            }
277            CommandEntry::Paths { cmd, bare_ok, paths } => {
278                if !bare_ok && crate::is_safe_command(cmd) {
279                    failures.push(format!("{cmd}: accepted bare invocation"));
280                }
281                let test = format!("{cmd} {UNKNOWN_SUB}");
282                if crate::is_safe_command(&test) {
283                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
284                }
285                for path in *paths {
286                    let test = format!("{path} {UNKNOWN_FLAG}");
287                    if crate::is_safe_command(&test) {
288                        failures.push(format!("{path}: accepted unknown flag: {test}"));
289                    }
290                }
291            }
292        }
293    }
294
295    #[test]
296    fn all_commands_reject_unknown() {
297        let registry = full_registry();
298        let mut failures = Vec::new();
299        for entry in &registry {
300            check_entry(entry, &mut failures);
301        }
302        assert!(
303            failures.is_empty(),
304            "unknown flags/subcommands accepted:\n{}",
305            failures.join("\n")
306        );
307    }
308
309    #[test]
310    fn process_substitution_safe_inner() {
311        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
312        for cmd in &safe {
313            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
314        }
315    }
316
317    #[test]
318    fn process_substitution_unsafe_inner() {
319        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
320        for cmd in &unsafe_cmds {
321            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
322        }
323    }
324
325    #[test]
326    fn registry_covers_handled_commands() {
327        let registry = full_registry();
328        let mut all_cmds: HashSet<&str> = registry
329            .iter()
330            .map(|e| match e {
331                CommandEntry::Positional { cmd }
332                | CommandEntry::Custom { cmd, .. }
333                | CommandEntry::Paths { cmd, .. }
334                | CommandEntry::Delegation { cmd } => *cmd,
335            })
336            .collect();
337        for name in crate::registry::toml_command_names() {
338            all_cmds.insert(name);
339        }
340        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
341
342        let missing: Vec<_> = handled.difference(&all_cmds).collect();
343        assert!(missing.is_empty(), "not in registry: {missing:?}");
344
345        let extra: Vec<_> = all_cmds.difference(&handled).collect();
346        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
347    }
348
349}