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