diff --git a/docs/reference/ax-task-catalog.md b/docs/reference/ax-task-catalog.md index 3ae7bac0..cfdf156f 100644 --- a/docs/reference/ax-task-catalog.md +++ b/docs/reference/ax-task-catalog.md @@ -76,7 +76,35 @@ is not implemented here. `doas -s`, `git -c`/`--config`/`--exec`/`--upload-pack`/ `--receive-pack`/`--cmd`/`clone -u`/`submodule foreach`/`bisect`/ `filter-branch`, a Git `!` alias value such as - `git config alias.x '!cmd'`, `make --eval`, `nix eval`). A dashed Git + `git config alias.x '!cmd'`, `make --eval`, `nix eval`). Git porcelain + options that run a command are refused under their own subcommand: + `send-email --to-cmd`/`--cc-cmd`/`--header-cmd`/`--sendmail-cmd` and + `--smtp-server` (an absolute path there is a sendmail program), + `grep -O`/`--open-files-in-pager`, `daemon --access-hook`, + `instaweb -d`/`--httpd` and `svn --authors-prog`. So are the + `remote-ext` helper, an `ext::` remote URL (Git runs it when + `protocol.ext.allow` permits) and `archimport`, which pastes its + operands into a shell pipeline. `git send-email` reads options with + Perl's Getopt::Long, which also takes a one-dash or `+` prefix, any + letter case and any unique prefix, so `-to-cmd=cmd`, `+to-cmd=cmd`, + `--CC-CMD=cmd` and `--cc-cm=cmd` count too. The `sendemail.*Cmd` config + equivalents are refused through `git -c`/`--config-env`, whatever the + key. A subcommand name is matched in any letter case, because Git finds + a script subcommand as a file and a case-insensitive filesystem runs + `git-send-email` for `git Send-Email`. Such a filesystem folds Unicode + too: on APFS `git ſend-email` (long s) runs `git-send-email` and + `git filter-branch` (the `fi` ligature) runs `git-filter-branch`. So + program, subcommand and module names are compared after NFKC + normalization, removal of invisible (default-ignorable) characters and + full case folding (the Kelvin sign is `k`, dotless `ı` is `i`, capital + sharp `ẞ` is `ss`, so `ẞh` is `ssh`). The program a recipe runs + (argv[0]) is refused when its file name has any non-ASCII character, + listed or not (`ſh`, `ẞh`, `./ünïcode.sh`): a folding table this check + does not predict could still map it to a listed program, so reviewed + scripts must use ASCII file names. A listed program or a Git subcommand + spelled with a non-ASCII character later in argv (`git-ſend-email` after + a wrapper, `git ſtatus`) is refused whatever follows. + A dashed Git builtin, by basename or absolute path (`git-clone`, `/usr/lib/git-core/git-config`), is read as `git `, so `git-clone -u cmd`, `git-config alias.x '!cmd'`, @@ -109,8 +137,10 @@ is not implemented here. `duckdb -storage-version v1.2.0 data.duckdb`, `python -m pytest`, `python -mpytest`, `python -m unittest discover -s tests`, `psql -o out.txt -f script.sql`, `git-clone url dir`, - `git clone url dir`, `git push -u origin main`, `git add -u` and - `git config alias.st status`. + `git clone url dir`, `git push -u origin main`, `git add -u`, + `git config alias.st status`, + `git send-email --to=a@b.example --cc c@d.example 0001.patch`, + `git rev-list --header HEAD` and `git branch -d topic`. 3. **Repository and log text grant no authority.** A recipe that passes every check is `reviewed`: its pins still match what a reviewer approved. Results still say `authority: "none"` and `execution: "unavailable"`. A recipe that @@ -361,8 +391,11 @@ until its recipes are re-reviewed. | `psql -o`/`--output` piped to a command (`-o '\|cmd'`, `-o\|cmd`, `-qo\|cmd`, `--output=\|cmd`, `--out=\|cmd`) | `inline_command`; `psql -o out.txt`, `psql -F'\|'`, `psql -L '\|log'` (a file name) still pass | domain test | | Dashed Git builtins (`/usr/lib/git-core/git-clone -u cmd`, `git-config alias.x '!cmd'`, `git-ls-remote --upload-pack=cmd`, `git-fetch-pack --exec=cmd`) | `inline_command`; `git-clone url dir`, `git-push -u origin main`, `git-config alias.st status` still pass | domain test | | Git plumbing helpers read as their porcelain (`git-bisect--helper --bisect-run cmd`, `/usr/lib/git-core/git-bisect--helper.exe run cmd`, `git bisect--helper --bisect-run cmd`, `git-submodule--helper foreach cmd`, `git-config alias.x bisect--helper`, older Git's `git rebase--helper --add-exec-commands cmd` in any letter case) | `inline_command`; `git-submodule--helper status`, `git-difftool--helper a b`, `git add bisect--helper.txt`, `git config --add alias.st status` still pass | domain test | +| `git send-email --to-cmd`/`--cc-cmd`/`--header-cmd`/`--sendmail-cmd`/`--smtp-server` in every Getopt::Long spelling (`--opt cmd`, `--opt=cmd`, `--cc-cm`, `--CC-CMD`, `-to-cmd`, `+to-cmd`), dashed or mixed-case (`git-send-email`, `git Send-Email`), and `-c sendemail.ccCmd=…`/`toCmd`/`headerCmd`/`sendmailCmd` in any key case | `inline_command`; `git send-email --to=… --cc …`, `--suppress-cc=all`, `--no-header-cmd`, `--smtp-server-port=587`, `git rev-list --header`, `git status -s`, `git format-patch --to=…` still pass | domain test | +| Names a case-insensitive filesystem folds to a listed one (`git ſend-email --to-cmd=cmd`, `git-ſend-email`, `git inſtaweb --httpd=cmd`, `git ſvn clone --authors-prog=cmd`, `git filter-branch`, `git log bıſect`, `git log filter-branch`, the Kelvin sign in `ksh -c`, dotless `ı` in `iex`), capital sharp `ẞ` in `ẞh` (runs `ssh`), any program run as argv[0] with a non-ASCII file name, listed or not (`ſh script.sh`, `./ünïcode.sh`), and any listed program or Git subcommand with a non-ASCII character (`git-ſtatus`, `git ſtatus`) | `inline_command`; `git status`, `sh scripts/x.sh`, `git commit -m 'café'`, `git log --author=Zoë`, `cat ſend-email` still pass | domain test | +| Other Git porcelain command runners (`git grep -Ocmd`/`--open-files-in-pager=cmd`, `git daemon --access-hook=cmd`, `git instaweb -d`/`--httpd`, `git svn --authors-prog`, `git remote-ext`, `ext::` URLs, `git archimport`) | `inline_command`; `git grep -n`, `git branch -d`, `git svn fetch --authors-file=…`, `git daemon --export-all`, an `https://` remote still pass | domain test | | Git 2.20-2.33's `rebase--interactive --cmd cmd` (`git rebase--interactive --cmd=cmd`, `git-rebase--interactive --cmd cmd`, `--cm cmd`, a repository alias such as `git ri --cmd cmd`) and its `--add-exec-commands` mode | `inline_command`; `git rebase--interactive --continue`, `git-rebase--interactive --edit-todo` still pass | domain test | -| A subcommand-scoped Git flag before the subcommand's name (`git push -u origin clone`, `git remote set-branches --add origin rebase`, `git config --add alias.r rebase`) | accepted; the flag after the name (`git clone -u cmd url`, `git --unknown v clone -u cmd url`, `git rebase--helper --add cmd`) is still refused | domain test | +| A subcommand-scoped Git flag before the subcommand's name (`git push -u origin clone`, `git remote set-branches --add origin rebase`, `git config --add alias.r rebase`, `git rev-list --header send-email`, `git push -d origin instaweb`, `git log -o -- Grep`) | accepted; the flag after the name (`git clone -u cmd url`, `git --unknown v clone -u cmd url`, `git rebase--helper --add cmd`, `git log -- Grep -o`) is still refused | domain test | | Detached linked worktree with no named instance, on CLI `task list`/`task inspect` and MCP `hack.project.task.list`/`inspect` | `detached_checkout`; naming the instance (`--branch`, MCP `branch`) passes | paired fixture, domain test | | `duckdb -storage-version VER db` | accepted; a SQL operand after the database is still refused | domain test | | `git clone -u` (also after `--attr-source ` or an unknown global option), abbreviated or attached `--upload-pack`/`--receive-pack`/`--exec`, `clone --config`, and `!` alias values (`git config alias.x '!cmd'`, `git -c alias.x=!cmd`) | `inline_command`; `git clone url dir`, `git push -u origin main`, `git add -u`, `git config alias.st status` still pass | domain test | @@ -434,14 +467,36 @@ until its recipes are re-reviewed. `git grep -e --cmd`, is refused with it. Any whole-word `--helper` or `--interactive` argument is read as `` (fail-closed), so `git add bisect--helper` (a file of that name) is refused. A subcommand-scoped flag (`clone -u`, - `rebase --add-exec-commands`) counts after any operand that names the - subcommand, and Git abbreviates, so an earlier word `clone` or `rebase` - scopes it even when that word is another option's value: - `git commit -m rebase --add` and `git log -S clone -u` (`-u` is - `--patch`) are refused. The same flag before the word passes + `rebase --add-exec-commands`, `send-email --to-cmd`, `grep -O`) counts + after any operand that names the subcommand, in any letter case or + Unicode spelling that folds to it (`git ſend-email --to-cmd`), and Git + abbreviates, so an earlier word `clone` or `rebase` scopes it even when + that word is another option's value: `git commit -m rebase --add` and + `git log -S clone -u` (`-u` is `--patch`) are refused, and so is + `git log -- Grep -o`. The same flag before the word passes (`git config --add alias.r rebase`, - `git remote set-branches --add origin rebase`). Git config writes other than `!` aliases (for example - `git config core.pager cmd` or `include.path`) are not detected. Nor are + `git remote set-branches --add origin rebase`, + `git log -o -- Grep`). Any prefix of a refused + `send-email` option is refused, even one Getopt::Long finds ambiguous + and passes on to `format-patch` (`git send-email -s`, `--smtp`), and + `--smtp-server` is refused whatever its value, so a host name too. + `grep -O` is case-folded like every flag, so `git grep -o` is refused; + use `--only-matching`. A repository alias still hides a subcommand from + its scoped flags (`git se --cc-cmd cmd` with `alias.se = send-email`), + and so does Git's `help.autocorrect` setting: with it set to run the + correction (`immediate`, a delay, or `prompt` answered yes), a mistyped + subcommand such as `git send-emai --to-cmd=cmd` runs `send-email` with + the flag, but the catalog sees an unknown subcommand and does not scope + the flag to it. The check does not read Git config. + Git config writes other than `!` aliases (for example + `git config core.pager cmd`, `git config sendemail.ccCmd cmd` or + `include.path`) are not detected. Nor are Git options that run a + program file rather than a string: `git --exec-path=dir` (non-builtin + subcommands and remote helpers are looked up there), + `git clone --template=dir` (its hooks, such as `post-checkout`, run + during the clone), `git merge -s name` (runs `git-merge-name` from + `PATH`), `git hook run` and a `transport::address` URL (runs + `git-remote-transport` from `PATH`). Nor are `mysql`/`mariadb` option files named in argv (`--defaults-extra-file`, which can set `init-command`) or `--pager`, which runs a command only in interactive mode. diff --git a/src/lib/task-catalog-recipe.ts b/src/lib/task-catalog-recipe.ts index 78a9fde1..1a2558aa 100644 --- a/src/lib/task-catalog-recipe.ts +++ b/src/lib/task-catalog-recipe.ts @@ -268,7 +268,8 @@ type Evaluator = { readonly stdin: readonly string[]; /** * Words that make the program run a string as a command wherever they - * appear after it (`git submodule foreach 'cmd'`). Matched lowercased. + * appear after it (`git submodule foreach 'cmd'`). Matched folded + * (`foldName`), so `git filter-branch` counts. */ readonly words: readonly string[]; /** @@ -375,15 +376,47 @@ type Evaluator = { * hide it (fail-closed). They do not count before it: Git reads a * subcommand's options only after the subcommand's name and rejects an * unknown option before it, so `git push -u origin clone` (a branch named - * `clone`) passes. + * `clone`) passes. Keys are lowercase and operands are folded + * (`foldName`) before lookup, because Git finds a non-builtin subcommand + * (`git-send-email`, `git-instaweb`) as a file in its exec path, and on a + * case-insensitive filesystem `git Send-Email` and `git ſend-email` run + * it. */ readonly subcommandInline: Readonly>; + /** + * Inline options of a subcommand that is a Perl script reading its + * options with Getopt::Long's defaults (`git send-email`), keyed like + * `subcommandInline` and scoped the same way. Getopt::Long's grammar + * differs from Git's, so these are matched by `getoptLongSelects`. + */ + readonly subcommandGetoptLong: Readonly>; /** * True when a value starting with `!` (a whole argument, or after `=`) * is a shell command: a Git alias defined as `!cmd`, through * `git config alias.x '!cmd'` or `git -c alias.x=!cmd`. */ readonly bangCommands: boolean; + /** + * True when an `ext::` remote URL (a whole argument, or after `=`) is a + * command: Git's `ext` remote helper runs the rest of the URL + * (`git ls-remote 'ext::sh -c cmd'`) when `protocol.ext.allow` permits. + */ + readonly extUrls: boolean; +}; + +/** + * Inline options of a Getopt::Long subcommand, as option names without a + * prefix, lowercase. + */ +type GetoptLongOptions = { + /** Options whose value is a command the script runs. */ + readonly inline: readonly string[]; + /** + * Other options of the same script whose whole name is a prefix of an + * inline option (`to` for `to-cmd`). Getopt::Long takes an exact name + * as that option, so `--to` is not an abbreviation of `--to-cmd`. + */ + readonly exact: readonly string[]; }; function evaluator(spec: Partial): Evaluator { @@ -407,7 +440,9 @@ function evaluator(spec: Partial): Evaluator { trampolineModules: [], pipeOptions: [], subcommandInline: {}, + subcommandGetoptLong: {}, bangCommands: false, + extUrls: false, ...spec, }; } @@ -675,6 +710,13 @@ const EVALUATORS: Readonly> = { // `rebase--interactive --cmd cmd` option is not scoped: no other Git // option starts with `--cm`, and a repository alias for the helper // (`git ri --cmd cmd`) would hide the subcommand. + // HACK-1438 adds the remaining porcelain options that run a command, each + // scoped to its subcommand: `send-email --to-cmd`/`--cc-cmd`/ + // `--header-cmd`/`--sendmail-cmd` and `--smtp-server` (an absolute path + // there is a sendmail program), `grep -O`/`--open-files-in-pager`, + // `daemon --access-hook`, `instaweb -d`/`--httpd` and + // `svn --authors-prog`; the `remote-ext` helper and its `ext::` URLs; + // and `archimport`, which pastes its operands into a shell pipeline. git: evaluator({ inline: [ "-c", @@ -687,7 +729,7 @@ const EVALUATORS: Readonly> = { "--receive-pack", "--cmd", ], - words: ["foreach", "bisect", "filter-branch"], + words: ["foreach", "bisect", "filter-branch", "remote-ext", "archimport"], // Global options before the subcommand that consume the next argument. valueOptions: [ "-C", @@ -705,8 +747,33 @@ const EVALUATORS: Readonly> = { // cluster: `git log -Scache` and `git commit -mcleanup` are refused // (fail-closed); pass the value as its own argument instead. shortValueOptions: [], - subcommandInline: { clone: ["-u"], rebase: ["--add-exec-commands"] }, + subcommandInline: { + clone: ["-u"], + rebase: ["--add-exec-commands"], + // `-O` and `--open-files-in-pager=` run the pager + // through the shell with the matching file names. + grep: ["-O", "--open-files-in-pager"], + // Run through the shell for each connection. + daemon: ["--access-hook"], + // The HTTP daemon command line instaweb starts. + instaweb: ["-d", "--httpd"], + // git-svn runs it in backticks for each unknown SVN author. + svn: ["--authors-prog"], + }, + subcommandGetoptLong: { + "send-email": { + inline: [ + "to-cmd", + "cc-cmd", + "header-cmd", + "sendmail-cmd", + "smtp-server", + ], + exact: ["to", "cc", "h"], + }, + }, bangCommands: true, + extUrls: true, }), // SQL clients given a statement string. A statement can also reach the // host shell (`psql` `\!`, `mysql` `system`, the `sqlite3` `.shell`). @@ -848,14 +915,48 @@ const MY_GETOPT_LOOSE = /^loose[-_]/i; /** A `data:` URL argument or option value (`--import=data:…`). */ const DATA_URL = /(?:^|=)data:/i; +/** A character outside ASCII. */ +const NON_ASCII = /[^\u0000-\u007f]/; +/** Code points that render as nothing, so a name can hide one inside. */ +const DEFAULT_IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu; + /** - * Program basename, lowercased, without `.exe`, a build-variant suffix or a - * version suffix. + * A program, subcommand or module name folded for comparison with the + * lowercase ASCII tables (HACK-1461). The default macOS filesystem (APFS) + * and NTFS look names up case-insensitively with Unicode case folding, not + * JavaScript's `toLowerCase()`: on APFS `git ſend-email` (U+017F LONG S) + * runs `git-send-email` and `git filter-branch` (U+FB01 LIGATURE FI) runs + * `git-filter-branch`. Rather than reproduce one filesystem's table, this + * over-folds (fail-closed): NFKC (`fi` is `fi`, `ſ` is `s`, the Kelvin + * sign is `K`), default-ignorable code points removed + * (`send​-email`), then lower, upper and lower case again, which also + * folds `ß` and capital `ẞ` (U+1E9E) to `ss` and dotless `ı` to `i`. Folding can only turn more names into + * listed ones, so it only refuses more. A non-ASCII spelling of a listed + * program or of a Git subcommand is also refused outright (see + * `evaluatesCode`), for any folding this does not predict. Option names + * are not folded this way: Git's parse-options and Getopt::Long compare + * them bytewise apart from ASCII letter case. */ -function programName(arg: string): string { - const base = arg.split("/").at(-1) ?? arg; - const name = base +function foldName(name: string): string { + return name + .normalize("NFKC") + .replace(DEFAULT_IGNORABLE, "") .toLowerCase() + .toUpperCase() + .toLowerCase(); +} + +/** The last path segment of an argument (a program's file name). */ +function baseName(arg: string): string { + return arg.split("/").at(-1) ?? arg; +} + +/** + * Program basename, folded (`foldName`), without `.exe`, a build-variant + * suffix or a version suffix. + */ +function programName(arg: string): string { + const name = foldName(baseName(arg)) .replace(EXE_SUFFIX, "") .replace(VARIANT_SUFFIX, ""); return name.replace(VERSION_SUFFIX, "") || name; @@ -877,14 +978,14 @@ const GIT_HELPER = /^([a-z0-9][a-z0-9-]*?)--(?:helper|interactive)$/i; * and `rebase--interactive --add-exec-commands` the `rebase` rule. Any other argument is * unchanged. A matching argument starts with a letter or digit and holds no * `=` or `!`, so no inline flag, word or `!` value is lost by the rewrite. - * The porcelain is lowercased, like a program name, so a mixed-case helper - * (`git Rebase--Helper`) still meets the subcommand-scoped flags, which are - * keyed by lowercase name. Git itself matches case-sensitively, so this - * only refuses more (fail-closed). + * The porcelain is folded like a program name (`foldName`), so a + * mixed-case helper (`git Rebase--Helper`) still meets the + * subcommand-scoped flags, which are keyed by lowercase name. Git itself + * matches case-sensitively, so this only refuses more (fail-closed). */ function gitSubcommand(arg: string): string { const helper = GIT_HELPER.exec(arg); - return helper === null ? arg : (helper[1] ?? arg).toLowerCase(); + return helper === null ? arg : foldName(helper[1] ?? arg); } /** @@ -1126,9 +1227,9 @@ function pythonClusterEnd( return "flags"; } -/** True when a module's first dotted segment, lowercased, is in `list`. */ +/** True when a module's first dotted segment, folded, is in `list`. */ function isListedModule(module: string, list: readonly string[]): boolean { - return list.includes((module.split(".", 1)[0] ?? module).toLowerCase()); + return list.includes(foldName(module.split(".", 1)[0] ?? module)); } /** @@ -1252,35 +1353,76 @@ function operands(opts: { return out; } +/** A value that is a shell command to Git: `!cmd` or `key=!cmd`. */ +const BANG_COMMAND = /(?:^|=)\s*!/; +/** An `ext::` remote URL, whole or after `=`, in any letter case. */ +const EXT_URL = /(?:^|=)ext::/i; +/** Getopt::Long's default option prefixes (without `POSIXLY_CORRECT`). */ +const GETOPT_LONG_PREFIX = /^(?:--|-|\+)/; + /** - * The subcommand-scoped inline flags in force at argument `at`: those of - * every subcommand named by an operand before it (`git clone -u`, not - * `git push -u origin clone`). + * True when an argument selects one of `options.inline` the way + * Getopt::Long reads it with its defaults, as `git send-email` does: the + * name may follow `--`, `-` or `+` (`-to-cmd`, `+to-cmd`), letter case is + * ignored (`--CC-CMD`), a value may follow `=`, and any prefix of a name + * is accepted as an abbreviation (`--cc-cm`). A prefix that names more + * than one option is ambiguous to Getopt::Long, but it is refused here + * (fail-closed), unless it is the whole name of another option listed in + * `options.exact` (`--to`, `--cc`). */ -function scopedFlags(opts: { +function getoptLongSelects(opts: { + readonly arg: string; + readonly options: GetoptLongOptions; +}): boolean { + const prefix = GETOPT_LONG_PREFIX.exec(opts.arg)?.[0]; + if (prefix === undefined) { + return false; + } + const body = opts.arg.slice(prefix.length); + const name = (body.split("=", 1)[0] ?? body).toLowerCase(); + if (name === "" || opts.options.exact.includes(name)) { + return false; + } + return opts.options.inline.some((option) => option.startsWith(name)); +} + +/** + * The entries of a subcommand-keyed table in force at argument `at`: one + * for every operand before it that names a subcommand, compared folded + * (`git clone -u`, `git Send-Email --to-cmd`, `git ſend-email --to-cmd`, + * not `git push -u origin clone`). + */ +function scopedEntries(opts: { + readonly table: Readonly>; readonly positional: readonly Operand[]; readonly at: number; - readonly spec: Evaluator; -}): readonly string[] { - return opts.positional.flatMap(({ word, index }) => - index < opts.at && Object.hasOwn(opts.spec.subcommandInline, word) - ? (opts.spec.subcommandInline[word] ?? []) - : [] - ); +}): T[] { + return opts.positional.flatMap(({ word, index }) => { + if (index >= opts.at) { + return []; + } + const key = foldName(word); + const entry = Object.hasOwn(opts.table, key) ? opts.table[key] : undefined; + return entry === undefined ? [] : [entry]; + }); } -/** A value that is a shell command to Git: `!cmd` or `key=!cmd`. */ -const BANG_COMMAND = /(?:^|=)\s*!/; - function isShellSyntax(arg: string): boolean { return ( SHELL_SYNTAX_TOKENS.has(arg) || arg.includes("$(") || arg.includes("`") ); } -/** True when argv[index], taken as a program, could evaluate code. */ +/** + * True when argv[index], taken as a program, could evaluate code. Names + * are compared through `foldName`, and a listed program with a non-ASCII + * spelling (`env ſh`), like a Git subcommand with one (`git ſtatus`), is + * refused whatever follows. (`isInlineCommandArgv` also refuses any argv[0] + * with a non-ASCII file name, HACK-1461.) + */ function evaluatesCode(argv: readonly string[], index: number): boolean { - const name = programName(argv[index] ?? ""); + const raw = argv[index] ?? ""; + const name = programName(raw); if (ALWAYS_EVALUATES.has(name)) { return true; } @@ -1289,23 +1431,42 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { if (spec === undefined) { return false; } + if (NON_ASCII.test(baseName(raw))) { + return true; + } if (spec.evaluatesOperands) { return rest.length > 0; } const positional = operands({ rest, spec }); const operand = positional[0]?.word ?? null; + if (key === "git" && operand !== null && NON_ASCII.test(operand)) { + return true; + } const flags = [...spec.inline, ...spec.stdin]; if ( rest.some( (arg, at) => matchesFlag({ arg, - flags: [...flags, ...scopedFlags({ positional, at, spec })], + flags: [ + ...flags, + ...scopedEntries({ + table: spec.subcommandInline, + positional, + at, + }).flat(), + ], spec, }) || - spec.words.includes(arg.toLowerCase()) || + scopedEntries({ + table: spec.subcommandGetoptLong, + positional, + at, + }).some((options) => getoptLongSelects({ arg, options })) || + spec.words.includes(foldName(arg)) || (spec.dataUrlModules && DATA_URL.test(arg)) || - (spec.bangCommands && BANG_COMMAND.test(arg)) + (spec.bangCommands && BANG_COMMAND.test(arg)) || + (spec.extUrls && EXT_URL.test(arg)) ) || pipesToCommand({ rest, spec }) ) { @@ -1336,7 +1497,7 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { !(spec.replModules.length > 0 && selectsModule({ rest, spec })) ); } - return operand === "-" || spec.subcommands.includes(operand.toLowerCase()); + return operand === "-" || spec.subcommands.includes(foldName(operand)); } /** @@ -1347,16 +1508,23 @@ function evaluatesCode(argv: readonly string[], index: number): boolean { * (`python -m code`, also behind a trampoline: `python -m runpy code`); a * SQL client given a statement string (`psql -c`, `sqlite3 db 'SQL'`) or * an output pipe (`psql -o '|cmd'`); a package runner, privilege wrapper - * or tool given its string-to-shell option, word or `!` alias value (also - * as a dashed Git builtin, `git-clone -u cmd`); + * or tool given its string-to-shell option, word, `!` alias value or + * `ext::` URL (also as a dashed Git builtin, `git-clone -u cmd`, and in + * Getopt::Long spellings for `git send-email -to-cmd=cmd`); * `eval`, `env`, `xargs`, `su`, `ssh`, awk, sed or another always-refused * program anywhere; or bare shell syntax copied from a log line. Recipes * must run a reviewed script file instead; there is no shell endpoint. - * Programs missing from the tables are not inspected (a denylist). + * Programs missing from the tables are not inspected (a denylist), but a + * program run as argv[0] with a non-ASCII file name is refused, listed or + * not, since a case-insensitive filesystem may run it as a listed one + * (HACK-1461). */ export function isInlineCommandArgv(argv: readonly string[]): boolean { return argv.some( - (arg, index) => isShellSyntax(arg) || evaluatesCode(argv, index) + (arg, index) => + isShellSyntax(arg) || + (index === 0 && NON_ASCII.test(baseName(arg))) || + evaluatesCode(argv, index) ); } diff --git a/tests/project-task-catalog.test.ts b/tests/project-task-catalog.test.ts index 7668217d..caf799c5 100644 --- a/tests/project-task-catalog.test.ts +++ b/tests/project-task-catalog.test.ts @@ -1705,6 +1705,147 @@ describe("recipe grammar", () => { ["git", "rebase--helper", "--continue"], ], }, + { + // `git send-email` is a Perl script that reads its options with + // Getopt::Long's defaults: `--`, `-` or `+` before a name, any letter + // case, `=` or a separate value, and unique prefixes. Each spelling + // here ran a marker command under Git 2.56. + issue: "HACK-1438", + rule: "git send-email options that run a command", + refused: [ + ["git", "send-email", "--to-cmd", "id", "0001.patch"], + ["git", "send-email", "--to-cmd=id", "0001.patch"], + ["git", "send-email", "--cc-cmd", "id", "0001.patch"], + ["git", "send-email", "--cc-cmd=id", "0001.patch"], + ["git", "send-email", "--header-cmd", "id", "0001.patch"], + ["git", "send-email", "--header-cmd=id", "0001.patch"], + ["git", "send-email", "--sendmail-cmd", "id", "0001.patch"], + ["git", "send-email", "--sendmail-cmd=id", "0001.patch"], + // An absolute path is a sendmail-like program, not a host. + ["git", "send-email", "--smtp-server", "/tmp/x.sh", "0001.patch"], + ["git", "send-email", "--smtp-server=/tmp/x.sh", "0001.patch"], + // Abbreviations. + ["git", "send-email", "--cc-cm=id", "0001.patch"], + ["git", "send-email", "--to-cm", "id", "0001.patch"], + ["git", "send-email", "--hea=id", "0001.patch"], + ["git", "send-email", "--sendm=id", "0001.patch"], + // Letter case, and the one-dash and `+` prefixes. + ["git", "send-email", "--CC-CMD=id", "0001.patch"], + ["git", "send-email", "-to-cmd=id", "0001.patch"], + ["git", "send-email", "-header-cmd", "id", "0001.patch"], + ["git", "send-email", "-sendmail-cmd=id", "0001.patch"], + ["git", "send-email", "-smtp-server=/tmp/x.sh", "0001.patch"], + ["git", "send-email", "+to-cmd=id", "0001.patch"], + // The dashed form, by name or absolute path, and a global option + // or mixed-case subcommand (a case-insensitive filesystem finds + // `git-send-email` for `git Send-Email`). + ["git-send-email", "--to-cmd=id", "0001.patch"], + ["/usr/lib/git-core/git-send-email", "--cc-cmd", "id", "0001.patch"], + ["git", "--git-dir", "x", "send-email", "--header-cmd=id", "p"], + ["git", "Send-Email", "--sendmail-cmd=id", "0001.patch"], + // The `sendemail.*` config equivalents (keys are case-insensitive; + // `-c` and `--config-env` are refused whatever the key). + ["git", "-c", "sendemail.ccCmd=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.toCmd=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.headerCmd=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.sendmailCmd=id", "send-email", "0001.patch"], + ["git", "-c", "SENDEMAIL.CCCMD=id", "send-email", "0001.patch"], + ["git", "-c", "sendemail.work.ccCmd=id", "send-email", "p"], + ["git", "-c", "sendemail.smtpServer=/tmp/x.sh", "send-email", "p"], + ["git", "--config-env=sendemail.toCmd=CMD", "send-email", "p"], + // The `-c` rule alone refuses it when an alias hides `send-email` + // (the Getopt::Long rule also reads `-c` as a prefix of `cc-cmd`). + [ + "git", + "-c", + "sendemail.ccCmd=id", + "-c", + "alias.se=send-email", + "se", + "p", + ], + ], + accepted: [ + [ + "git", + "send-email", + "--dry-run", + "--confirm=never", + "--to=a@b.example", + "--cc", + "c@d.example", + "0001.patch", + ], + ["git", "send-email", "--from=a@b.example", "--suppress-cc=all", "p"], + ["git", "send-email", "--sender=a@b.example", "--no-header-cmd", "p"], + ["git", "send-email", "--smtp-server-port=587", "--no-to", "p"], + ["git", "send-email", "-h"], + // `--` and `-` name no option, so they select none. + ["git", "send-email", "--to=a@b.example", "--", "0001.patch"], + ["git", "send-email", "--to=a@b.example", "-"], + // Scoped to `send-email`: the same names elsewhere pass. + ["git", "rev-list", "--header", "HEAD"], + ["git", "status", "-s"], + ["git", "diff-tree", "-t", "HEAD"], + ["git", "format-patch", "--to=a@b.example", "-1"], + ["git", "log", "--cc"], + // Before the subcommand's name they pass (HACK-1423): Git reads + // send-email's options only after `send-email`, so a branch of + // that name does not scope `--header` to it. + ["git", "rev-list", "--header", "send-email"], + ["git", "log", "-s", "--", "Send-Email"], + ], + }, + { + // HACK-1438 sweep: other porcelain options that run a command, each + // scoped to its subcommand. + issue: "HACK-1438", + rule: "Git porcelain options that run a command", + refused: [ + ["git", "grep", "-Oid", "x"], + ["git", "grep", "-iOid", "x"], + ["git", "grep", "--open-files-in-pager=id", "x"], + ["git", "grep", "--open-files=id", "x"], + ["git", "daemon", "--inetd", "--access-hook=id"], + ["git-daemon", "--access-hook=/tmp/x.sh", "--export-all"], + ["git", "instaweb", "--httpd=/tmp/httpd", "--start"], + ["git", "instaweb", "--htt=/tmp/httpd", "--start"], + ["git", "instaweb", "-d", "/tmp/httpd", "--start"], + ["git-instaweb", "-d/tmp/httpd"], + ["git", "svn", "clone", "--authors-prog=id", "url"], + ["git", "svn", "fetch", "--authors-p", "id"], + // The `ext` remote helper runs its URL; Git refuses the URL unless + // `protocol.ext.allow` permits it, which config can. + ["git", "remote-ext", "origin", "id"], + ["git-remote-ext", "origin", "id"], + ["git", "ls-remote", "ext::sh -c id"], + ["git", "clone", "EXT::id", "dir"], + ["git", "remote", "add", "origin", "ext::id"], + ["git", "fetch", "--negotiation-tip=x", "--", "ext::id"], + // An option value after `=` is a URL too. + ["git", "push", "--repo=ext::id"], + // archimport pastes each operand into a `tla abrowse` shell + // pipeline, so `x/y;cmd #` runs `cmd` even without tla. + ["git", "archimport", "x/y"], + ["git-archimport", "-v", "x/y"], + ], + accepted: [ + ["git", "grep", "-n", "pattern"], + ["git", "grep", "--only-matching", "--or", "pattern"], + ["git", "branch", "-d", "topic"], + ["git", "log", "--author", "me"], + ["git", "svn", "fetch", "--authors-file=authors.txt"], + ["git", "daemon", "--export-all", "--base-path=/srv/git"], + ["git", "instaweb", "--stop"], + ["git", "remote", "add", "origin", "https://example.test/x.git"], + ["git", "add", "extension.txt"], + // `ext::` counts only at the start of an argument or after `=`. + ["git", "add", "docs/ext::notes.md"], + // Before the subcommand's name they pass (HACK-1423). + ["git", "diff", "-Oorderfile", "--", "grep"], + ["git", "push", "-d", "origin", "instaweb"], + ], + }, { // Git 2.20-2.33 ship `rebase--interactive`, whose `--cmd ` adds // an `exec ` line after each pick and runs it. @@ -1824,6 +1965,94 @@ describe("recipe grammar", () => { expect(isInlineCommandArgv(["git", "grep", "-e", "--cmd"])).toBe(true); }); + test("HACK-1438: documented send-email and grep fail-closed collateral stays refused", () => { + // Any prefix of a refused send-email option is refused, even one that + // Getopt::Long finds ambiguous and passes to format-patch (`-s`, `-t`). + expect(isInlineCommandArgv(["git", "send-email", "-s", "p"])).toBe(true); + expect(isInlineCommandArgv(["git", "send-email", "--smtp", "h"])).toBe( + true + ); + // `--smtp-server` is refused whatever its value, so a host name too. + expect( + isInlineCommandArgv(["git", "send-email", "--smtp-server=mail", "p"]) + ).toBe(true); + // `-O` is case-folded like every flag, so `git grep -o` is refused. + expect(isInlineCommandArgv(["git", "grep", "-o", "x"])).toBe(true); + // A subcommand name is case-folded and scopes its flags from any + // operand that names it, not only the first, so a path named `Grep` + // scopes `-O` to the arguments after it (HACK-1423); the same flag + // before that operand passes. + expect(isInlineCommandArgv(["git", "log", "--", "Grep", "-o"])).toBe(true); + expect(isInlineCommandArgv(["git", "log", "-o", "--", "Grep"])).toBe(false); + }); + + test("HACK-1461: names that a case-insensitive filesystem folds to a listed name are refused", () => { + const refused = (argv: string[]) => ({ + argv, + refused: isInlineCommandArgv(argv), + }); + const cases: string[][] = [ + // U+017F LONG S folds to `s`: APFS runs `git-send-email` for these. + ["git", "ſend-email", "--to-cmd=id", "p"], + ["git-ſend-email", "--to-cmd=id", "p"], + ["git", "inſtaweb", "--httpd=id"], + ["git", "ſvn", "clone", "--authors-prog=id", "u"], + // U+FB01 LIGATURE FI folds to `fi`. + ["git", "filter-branch", "--tree-filter", "id", "HEAD"], + // U+212A KELVIN SIGN folds to `k`. + ["Ksh", "-c", "id"], + ["awK", "BEGIN{}"], + // U+0131 DOTLESS I is folded to `i` too (fail-closed). + ["git", "ınstaweb", "--httpd=id"], + ["ıex"], + // A listed name after the first operand is folded too: the `words` + // check, the subcommand-scoped flags and Python's REPL modules. + ["git", "log", "filter-branch"], + ["git", "log", "bıſect"], + ["git", "help", "ſend-email", "--to-cmd=id"], + ["git", "log", "filter​-branch"], + ["python3", "-m", "code"], + // U+1E9E CAPITAL SHARP S folds to `ss` on APFS, so `ẞh` runs ssh; + // lower case alone gives `ß`. + ["ẞh", "host", "id"], + ["/usr/bin/ẞh", "-o", "ProxyCommand=id", "x"], + // As a later argument it is caught by folding alone, like `cat ssh`. + ["cat", "ẞh"], + // Any program spelled with a non-ASCII character is refused whatever + // follows, listed or not, and so is a Git subcommand with one. + ["ſh", "scripts/x.sh"], + ["./scripts/ünïcode.sh"], + ["café", "--version"], + ["git-ſtatus"], + ["git", "ſtatus"], + ["git", "--work-tree", "dir", "pack-refſ"], + ]; + expect(cases.map(refused)).toEqual( + cases.map((argv) => ({ argv, refused: true })) + ); + // Their ASCII controls behave as before, and non-ASCII operands and + // programs outside the tables pass. + const accepted: string[][] = [ + ["git", "status"], + ["git-status"], + ["sh", "scripts/x.sh"], + ["git", "commit", "-m", "café ſ"], + ["git", "log", "--author=Zoë"], + ["git", "--work-tree", "répertoire", "status"], + ["cat", "ſend-email"], + ["python3", "-m", "pytest", "tests/ünï"], + ]; + expect(accepted.map(refused)).toEqual( + accepted.map((argv) => ({ argv, refused: false })) + ); + expect( + isInlineCommandArgv(["git", "filter-branch", "--tree-filter", "id"]) + ).toBe(true); + expect(isInlineCommandArgv(["git", "send-email", "--to-cmd=id"])).toBe( + true + ); + }); + test("effects must be covered by required capabilities", () => { const base = recipe({ id: "ok" }); const pinAccepted = (path: string): boolean =>