基础设施 4.0 · 优秀 2026-08-25 · 文章

Hardening the Override Flag: 包管理器危险开关的防御设计

Andrew Nesbitt 梳理过去几年包管理器与 CLI 工具里危险开关的防御设计把 rmaptpipcargopacmanGoNixHomebrewDockergitCeph 摆到一张表里,按长名字无短选项不接环境变量必须写明目标会过期看状态必须离开通道六个属性归类关键结论:单纯把开关换成长名只挡得住眼睛扫到的脚本;挡住被劫持父进程的关键是拒绝环境变量通道真实事故记录:2026 年 6 月 140 个 @mastra/* npm 包被投毒因为 Node 无条件读 NODE_TLS_REJECT_UNAUTHORIZED=0;2024 年 needrestart CVE 是 root 进程继承普通进程的 PYTHONPATH...

打开原文回到归档

Hardening the Override Flag: 包管理器危险开关的防御设计

中文导读

Andrew Nesbitt 梳理过去几年包管理器与 CLI 工具里危险开关的防御设计。把 rm、apt、pip、cargo、pacman、Go、Nix、Homebrew、Docker、git、Ceph 摆到一张表里,按「长名字无短选项」「不接环境变量」「必须写明目标」「会过期」「看状态」「必须离开通道」六个属性归类。关键结论:单纯把开关换成长名只挡得住眼睛扫到的脚本;挡住被劫持父进程的关键是拒绝环境变量通道。真实事故记录:2026 年 6 月 140 个 @mastra/* npm 包被投毒因为 Node 无条件读 NODE_TLS_REJECT_UNAUTHORIZED=0;2024 年 needrestart CVE 是 root 进程继承普通进程的 PYTHONPATH;apt 2021 年那次「Yes, do as I say!」提示被用户在镜头前输入后两周换成硬错误。另一个关键观察:pip/apt 在错误信息里直接写出绕过命令,CI 脚本、build.rs、agent 解析 stderr 时会被诱导重试。

为什么值得关注

Andrew Nesbitt 梳理过去几年包管理器与 CLI 工具里危险开关的防御设计。

关键信息

Obsidian Notes

  • 正文由 opencli web read 抓取(参考当日 ClawFeed 24h / AK-RSS / 调研摘要),本页为元数据 + 摘要式存档。

原文存档(摘要)

Hardening the Override Flag

作者: Andrew Nesbitt
发布时间: 2026-08-25T10:00:00+00:00
原文链接: https://nesbitt.io/2026/08/25/hardening-the-override-flag.html

I’ve been reading through the recent run of incidents where package-manager infrastructure was the attack surface, as one does on a Sunday afternoon. One question I kept coming back to was whether the tools have any defences at the flag and config level. Every package manager has an override that turns off a check: --allow-unauthenticated, --break-system-packages, --ignore-scripts, an env var that points the resolver at a different registry. If a compromised install script can pass those as easily as a person at a keyboard can, the check does very little. So I went looking at how command-line tools more generally handle their dangerous overrides, sudo and curl and rm and the rest, and the catalogue got long enough to write up on its own.

Names and channels#

GNU rm goes back and re-reads argv after option parsing has finished. getopt\_long accepts any unambiguous prefix of a long option, which would make --no-p a valid spelling of --no-preserve-root, so rm checks the literal string and rejects anything shorter with “you may not abbreviate the –no-preserve-root option”. --preserve-root has been the default since 2006, and the override requires the full flag every time, on top of whatever the argument parser does. It’s easy to miss when reimplementing: uutils, the Rust coreutils rewrite, accepted `--n` until March 2026.

A long name with no short form is the most common hardening on an override flag, and the one most package managers reach for. apt has --allow-remove-essential, --allow-downgrades, --allow-change-held-packages and --allow-unauthenticated, all long-only, split out from a blanket `--force-yes` in apt 1.1. pnpm 10 disables install scripts by default and calls the global re-enable `dangerouslyAllowAllBuilds`. hdparm gates its drive-destroying operations behind `--yes-i-know-what-i-am-doing`, and the worst of them behind `--please-destroy-my-drive` as well. The name is the warning, and someone reviewing a script or a diff will read past -f but stop on --allow-remove-essential.

apt also used a typed confirmation phrase for essential-package removal until 2021: print the warning, require Yes, do as I say! character-for-character before proceeding. Typing the translated phrase in Chinese locales required an input method that was unavailable at a bare console, so apt 0.5.23 stopped translating it for zh\_\*. The check ran regardless of TTY, so echo 'Yes, do as I say!' | apt-get ... worked. In November 2021 a Pop!\_OS packaging conflict made apt install steam propose removing the desktop environment, the prompt appeared in a Linus Tech Tips video, and the phrase got typed anyway. apt 2.3.12 replaced the prompt with a hard error two weeks later; the NEWS entry credits Linus Tech Tips and System76 by name.

PEP 668 says the escape hatch for externally-managed environments “should not be something as simple as a --force flag”, and pip’s --break-system-packages, added in 23.0.1, is long-only with an error message that takes half a screen to steer users away. But pip maps every long option to both an environment variable and a config-file key automatically, with no per-option opt-out. PIP_BREAK_SYSTEM_PACKAGES=1 in .bashrc or break-system-packages = true in pip.conf sets it permanently, and the pip test suite itself has to clear the env var to test the un-overridden path.

Node.js tags each option individually in `src/node_options.cc` as either kAllowedInEnvvar or kDisallowedInEnvvar, and exits with an error if a disallowed one arrives through NODE_OPTIONS. --eval, --print, --interactive and anything that names a script to run are on the disallowed list, and the PR that introduced the mechanism put --tls-cipher-list there too with the one-line rationale “Disallowed because of security concerns”. cargo’s config reference marks registry [source] replacement and [patch] tables the same way, “Environment: not supported”, so pointing a build at a different crate source takes a file on disk rather than an exported variable. npm 12 restricts a different channel: passing --allow-scripts on the command line in a project-scoped install throws `EALLOWSCRIPTS`, forcing the policy into package.json where it’s checked in and reviewed.

Daniel Stenberg made the case against relying on names alone in 2017, when a curl user proposed deprecating -k and keeping only --insecure, on the grounds that a two-character flag is hard to spot in a script and easy to insert. Stenberg declined: the misuse comes from copy-paste, users transplant -k from a Stack Overflow answer without reading it, and they’d transplant --insecure just as readily. Adding a warning would produce fatigue rather than caution. curl 8.x still accepts -k, though the equivalent CURL_INSECURE environment variable is absent; every entry in the man page’s ENVIRONMENT section leaves verification on.

Preconditions and scope#

git push --force-with-lease attaches a precondition rather than relying on spelling: override only if the remote ref matches what I last fetched, so a force-push fails if someone else pushed in the meantime. That check turned out to have a hole: editors with background auto-fetch update the tracking ref without the user seeing the new commits, so the lease matches even though the user’s mental model is stale. git 2.30 added `--force-if-includes`, which additionally requires the remote tip to appear in the local branch’s reflog, so a fetched-but-unread commit still blocks the push.

go get -insecure applied to everything the command touched, and Go 1.17 removed it in favour of `GOINSECURE`, which takes a comma-separated list of module path globs so unverified fetches only apply to matching paths. pacman made the same move: the boolean --force that overrode file-conflict checks was removed in 5.1 and replaced with `--overwrite <glob>`, which has to name what it’s clobbering. Nix’s `permittedInsecurePackages` requires the versioned package name, openssl-1.1.1w rather than openssl, so when the version changes the exception stops matching and the build fails again until someone re-approves it. Composer 2.2’s `allow-plugins` is a per-plugin map in composer.json rather than a global switch. cargo’s [patch] table is per-crate.

systemctl reboot --force skips the orderly shutdown of units, and passing --force twice skips systemd itself, issuing the reboot(2) syscall directly from the systemctl process so it works even when PID 1 has hung.

Ceph’s pool-deletion command stacks three mechanisms: ceph osd pool delete NAME NAME --yes-i-really-really-mean-it, with the pool name given twice, and the monitor on the server side still refuses unless `mon_allow_pool_delete` is set to true in its configuration.

Out of band#

Docker keeps `insecure-registries` in the daemon config only, so pulling from an unverified registry means reconfiguring the daemon. A git server with `receive.denyNonFastForwards` or receive.denyDeletes set rejects a force-push regardless of what flags the client sent. Homebrew’s HOMEBREW_FORBIDDEN_FORMULAE and siblings let an admin block installs, and HOMEBREW_FORBIDDEN_OWNER names who set the policy so the error message tells the user who to ask rather than what to type. Set in /etc/homebrew/brew.env alongside `HOMEBREW_SYSTEM_ENV_TAKES_PRIORITY`, the system file is applied after the user’s shell environment and overrides it, so a value exported in the shell is discarded.

csrutil disable has always required booting to Recovery. spctl --master-disable used to turn off Gatekeeper from a normal terminal, but on macOS 15 it prints “This operation is no longer supported” and directs the user to System Settings; a persistent global disable now needs an MDM configuration profile or an interactive System Settings change rather than a scriptable command.

sudo’s credential cache expires instead of requiring a separate channel: five minutes by default per terminal. Set-ExecutionPolicy -Scope Process in PowerShell lasts for the shell session, and GitHub’s sudo mode for sensitive account settings re-prompts after a couple of hours. Ceph’s injectargs, which is how you set mon_allow_pool_delete without a monitor restart, is cleared when the monitor restarts. Putting the examples against the same six properties:

| | Long name, no short form | No env-var route | Must name target | Lapses | Checks state | Out of band | | --- | --- | --- | --- | --- | --- | --- | | rm --no-preserve-root | ✓ | ✓ | | | | | | pip --break-system-packages | ✓ | | | | | | | apt --allow-remove-essential | ✓ | ✓ | | | | | | pnpm dangerouslyAllowAllBuilds | ✓ | ✓ | | | | | | curl -k / --insecure | | ✓ | | | | | | cargo [source] replacement | | ✓ | ✓ | | | | | git --force-with-lease | ✓ | ✓ | | | ✓ | | | pacman --overwrite <glob> | ✓ | ✓ | ✓ | | | | | Go GOINSECURE | | | ✓ | | | | | Composer allow-plugins | | ✓ | ✓ | | | | | Nix permittedInsecurePackages | | | ✓ | ✓ | | | | sudo credential cache | | | | ✓ | | | | Docker insecure-registries | | | ✓ | | | ✓ | | git receive.denyNonFastForwards | | | | | | ✓ | | Homebrew FORBIDDEN_* + system priority | | | ✓ | | | ✓ | | macOS csrutil disable | ✓ | ✓ | | | | ✓ | | Ceph pool delete | ✓ | ✓ | ✓ | ✓ | | ✓ |

Threat models#

A long unabbreviatable flag catches a fat-fingered -f and stands out to a reviewer skimming a diff. A TTY check blocks yes | tool, server-side config ignores whatever flags the client sent, and a typed phrase, on apt’s evidence, stops very little. Refusing an env-var route stops a poisoned parent process, and for package managers specifically that parent is often something the tool itself just installed.

An npm postinstall script, a setup.py, a build.rs, a Homebrew formula’s install block: all of these run with the package manager’s environment and all of them can invoke the package manager again, or export variables that the next invocation will read. When 140 `@mastra/*` npm packages were compromised in June 2026 the injected payload set NODE_TLS_REJECT_UNAUTHORIZED=0 in its own process before phoning home, because Node reads that from the environment unconditionally. In CVE-2024-48990 needrestart, running as root, inherited PYTHONPATH from the unprivileged processes it was inspecting and executed attacker code with it. Homebrew’s bin/brew re-executes itself through `env -i` with a fixed allowlist before any formula code runs, and separately strips anything matching `token`, `key`, `password`, `cookie` or `auth` from the environment before evaluating tap Ruby. Node’s kDisallowedInEnvvar list, cargo’s env-unsupported [source] table, and npm’s EALLOWSCRIPTS all guard against the case where the package manager’s caller is itself a package.

A global NIXPKGS_ALLOW_INSECURE=1 or GOINSECURE=* applies to every dependency resolved from that point on, including transitive ones pulled in by other packages. Nix’s version-pinned entries mean an exception granted for openssl-1.1.1w stops applying when a dependency bumps to a newer vulnerable build, and an entry in Composer’s allow-plugins map for phpstan/extension-installer applies to that plugin alone. A boolean override reaches everything downstream of it; a named one reaches what it names.

pip’s PEP 668 error message ends with “You can override this, at the risk of breaking your Python installation or OS, by passing –break-system-packages.” apt’s error, when -y is passed, reads “Essential packages were removed and -y was used without –allow-remove-essential”, while the interactive error a human at a terminal sees omits the flag, so of apt’s two code paths the automated one is the one told how to bypass. pip’s message and apt’s -y message both spell out the next command for anything that parses error output and retries: a CI wrapper, a build.rs shelling out to the system package manager, a provisioning script, or increasingly an agent that reads stderr as instructions.

https://nesbitt.io/2026/08/25/hardening-the-override-flag.html