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