Summary
While reading through malt I found six issues that let a third-party tap reach outside the prefix on a single mt install. Each was reproduced by executing malt's own functions against Zig 0.16.0 — they are not static-analysis guesses. Fixes and regression tests are up as six PRs against this issue — see What I'm proposing at the bottom for the mapping.
Opening this publicly because the repo has no SECURITY.md and private vulnerability reporting is disabled, so there is no private channel. If you'd rather coordinate privately, enable Settings → Security → Private vulnerability reporting and I'll move the detail there.
Threat model
malt downloads and unpacks third-party code as the invoking user into a user-owned prefix that mt shellenv puts on PATH. Taps on GitHub/GitLab/Gitea/Gogs control cask JSON, formula post_install, and the archive bytes. The bar I'm applying: mt install <tap>/<pkg> should not be able to touch anything outside the prefix.
Findings
1. Writing through an archive's own symlink (src/fs/archive.zig)
isSafeSymlinkTarget allows a target to climb one level above the extraction root — correctly, because bottles are built for their installed location and reach sibling formulas that way (rust's ../../../../../../../opt/llvm/bin/llvm-objcopy). The allowance is sound for a leaf link, but the depth arithmetic counts every entry-name component as a real directory, so once a exists as a symlink it stops describing the filesystem and "one level up" composes:
a -> ".." (accepted, at the budget)
a/b -> ".." (accepted; `a` is already a symlink)
a/b/bin/EVIL (lands in a sibling of the extraction root)
In the real layout that reaches <prefix>/bin. extractTarGz returned success. std.tar uses O_EXCL so nothing is overwritten, but creating a new command name on PATH is enough.
2. Hard links resolved through a planted symlink (applyHardLinks)
follow_symlinks = false only governs the final component; the kernel still resolves intermediate directories. A hard link is a second name for the inode, so a target routed through a planted symlink imports a file the archive never shipped into the keg — where the linker can expose it and a later chmod can widen it. Reproduced; returned success.
3. Cask app artifact reaches deleteTree() unvalidated (src/core/cask.zig)
parseCask screens token/version through fs/path_component.zig and calls itself "the one ingestion choke point". Artifact strings go straight through. app_name is interpolated into <app_dir>/<name> and passed to deleteTree before and independently of the copy, so {"app":["../PRECIOUS"]} recursively deleted a directory outside the Applications dir even though the install then failed.
4. Cask binary → target escapes <prefix>/bin
parseBinaryTarget returns the raw JSON string, which becomes <prefix>/bin/<target> for a deleteFile + symLinkAbsolute pair. ../../../../Users/x/.zshenv deletes that file and points it at a binary the cask fully controls. The basename(src_name) fallback is already safe; only the explicit target is unguarded.
5. Utils.safe_popen_read bypasses the post_install sandbox (src/core/dsl/builtins/process.zig)
system is gated by validateArgv and wrapped in sandbox-exec by fenceArgv. safe_popen_read — same DSL surface, reachable from any formula — does neither. Side by side with the same command: system → Operation not permitted; safe_popen_read → wrote outside the keg, unconfined, network allowed.
6. ln_s + rm_rf deletes arbitrary trees (src/core/dsl/builtins/fileutils.zig)
ln_s never validated its target. rm_r/mkdir_p validated only lexically while cp/cp_r already resolve intermediate symlinks via validateWriteDir. A planted directory symlink turned a keg-local path into an out-of-keg recursive delete. These run in malt's own process, so the sandbox-exec fence does not apply.
Also worth a look
- A cask with no
sha256 verifies as if it opted out. verifyFileSha256 treats null like no_check. Homebrew always emits the field, and a pkg artifact goes to sudo installer -target /.
resolveCaskBinaryPath joins a /-bearing src to the root with no traversal check, then opens read-write and chmod 0755.
- No URL scheme allowlist. The redirect logic is careful (credentials dropped cross-domain, https→http refused mid-chain), but an
http:// origin is followed as-is.
term_sanitize.zig is only wired into the --use-system-ruby path, not the native interpreter, which is the default. Its doc comment claims it wraps post_install output generally.
- Release workflow pins actions by mutable tag. Job permissions are properly minimal, but a compromised
mlugg/setup-zig@v2 runs inside the job whose output cosign then signs — which cuts against the README's "a leaked GitHub token is not enough to ship a malicious binary".
cosign is resolved via PATH, which includes the user-writable prefix that packages write into.
- The test suite is not hermetic. Three tests make live requests to
formulae.brew.sh/httpbin.org, and one built its client from the process environ while exercising credential injection — githubApiToken prefers MALT_GITHUB_TOKEN over the var that test sets, so on a machine with malt's own token exported zig build test sent a live credential to a third party. Separately, atomic.maltPrefixOrAbort() reads the ambient environment and defaults to /opt/malt, so a test that forgets a fixture prefix operates on the developer's real install.
What I'm proposing
Six PRs, split so each can be judged — and rejected — on its own. Roughly in
descending order of how much I think they matter:
| PR |
Covers |
Size |
Notes |
| #790 |
findings 3–6, cask hash policy, resolveCaskBinaryPath, test-suite hygiene |
+542/−30 |
one deliberate behaviour change: absent cask sha256 now fails closed |
| #791 |
findings 1–2 |
+210/−2 |
kept separate; see below |
| #792 |
cleartext origins, prefix-resident cosign, install.sh checksum lookup |
+225/−1 |
|
| #793 |
terminal sanitizer missing from the native path |
+112/−15 |
piping means post_install children stop seeing a TTY |
| #794 |
unpinned CI actions |
+31/−31 |
31 refs, only uses: lines touched |
| #795 |
vendored SQLite 3.49.1 → 3.53.4 |
+16k/−7k |
hygiene, not a CVE fix; entirely reasonable to close |
Why #791 is on its own. The obvious fix — forbid symlink targets that
escape the extraction root — breaks every bottle that reaches a sibling
formula via opt/, because bottles are built for their installed location.
Your own tests/archive_test.zig pins the rust case, and it caught my first
attempt. The PR instead enforces "no entry is reached through a symlink this
archive created", which leaves those leaf links untouched. That reasoning is
worth reviewing separately from the mechanical fixes.
Merge-order note. #790 and #792 both touch src/net/client.zig from
independent branches off main, so whichever lands second needs a trivial
conflict resolution. Nothing else overlaps.
One item has no PR. The tar tf / unzip -Z1 listing validation gap is
real but I could not fix it without making things worse — the xz -dc
approach introduces a dependency on a binary macOS does not ship, resolved
from a package-writable directory. Reasoning in
this comment.
Happy to reshape any of these if you'd prefer a different cut — including
folding them back together if six is more review overhead than it's worth.
Summary
While reading through malt I found six issues that let a third-party tap reach outside the prefix on a single
mt install. Each was reproduced by executing malt's own functions against Zig 0.16.0 — they are not static-analysis guesses. Fixes and regression tests are up as six PRs against this issue — see What I'm proposing at the bottom for the mapping.Opening this publicly because the repo has no
SECURITY.mdand private vulnerability reporting is disabled, so there is no private channel. If you'd rather coordinate privately, enable Settings → Security → Private vulnerability reporting and I'll move the detail there.Threat model
malt downloads and unpacks third-party code as the invoking user into a user-owned prefix that
mt shellenvputs onPATH. Taps on GitHub/GitLab/Gitea/Gogs control cask JSON, formulapost_install, and the archive bytes. The bar I'm applying:mt install <tap>/<pkg>should not be able to touch anything outside the prefix.Findings
1. Writing through an archive's own symlink (
src/fs/archive.zig)isSafeSymlinkTargetallows a target to climb one level above the extraction root — correctly, because bottles are built for their installed location and reach sibling formulas that way (rust's../../../../../../../opt/llvm/bin/llvm-objcopy). The allowance is sound for a leaf link, but the depth arithmetic counts every entry-name component as a real directory, so onceaexists as a symlink it stops describing the filesystem and "one level up" composes:In the real layout that reaches
<prefix>/bin.extractTarGzreturned success.std.tarusesO_EXCLso nothing is overwritten, but creating a new command name onPATHis enough.2. Hard links resolved through a planted symlink (
applyHardLinks)follow_symlinks = falseonly governs the final component; the kernel still resolves intermediate directories. A hard link is a second name for the inode, so a target routed through a planted symlink imports a file the archive never shipped into the keg — where the linker can expose it and a later chmod can widen it. Reproduced; returned success.3. Cask
appartifact reachesdeleteTree()unvalidated (src/core/cask.zig)parseCaskscreenstoken/versionthroughfs/path_component.zigand calls itself "the one ingestion choke point". Artifact strings go straight through.app_nameis interpolated into<app_dir>/<name>and passed todeleteTreebefore and independently of the copy, so{"app":["../PRECIOUS"]}recursively deleted a directory outside the Applications dir even though the install then failed.4. Cask
binary→targetescapes<prefix>/binparseBinaryTargetreturns the raw JSON string, which becomes<prefix>/bin/<target>for adeleteFile+symLinkAbsolutepair.../../../../Users/x/.zshenvdeletes that file and points it at a binary the cask fully controls. Thebasename(src_name)fallback is already safe; only the explicittargetis unguarded.5.
Utils.safe_popen_readbypasses the post_install sandbox (src/core/dsl/builtins/process.zig)systemis gated byvalidateArgvand wrapped insandbox-execbyfenceArgv.safe_popen_read— same DSL surface, reachable from any formula — does neither. Side by side with the same command:system→Operation not permitted;safe_popen_read→ wrote outside the keg, unconfined, network allowed.6.
ln_s+rm_rfdeletes arbitrary trees (src/core/dsl/builtins/fileutils.zig)ln_snever validated its target.rm_r/mkdir_pvalidated only lexically whilecp/cp_ralready resolve intermediate symlinks viavalidateWriteDir. A planted directory symlink turned a keg-local path into an out-of-keg recursive delete. These run in malt's own process, so thesandbox-execfence does not apply.Also worth a look
sha256verifies as if it opted out.verifyFileSha256treatsnulllikeno_check. Homebrew always emits the field, and apkgartifact goes tosudo installer -target /.resolveCaskBinaryPathjoins a/-bearingsrcto the root with no traversal check, then opens read-write andchmod 0755.http://origin is followed as-is.term_sanitize.zigis only wired into the--use-system-rubypath, not the native interpreter, which is the default. Its doc comment claims it wraps post_install output generally.mlugg/setup-zig@v2runs inside the job whose output cosign then signs — which cuts against the README's "a leaked GitHub token is not enough to ship a malicious binary".cosignis resolved viaPATH, which includes the user-writable prefix that packages write into.formulae.brew.sh/httpbin.org, and one built its client from the process environ while exercising credential injection —githubApiTokenprefersMALT_GITHUB_TOKENover the var that test sets, so on a machine with malt's own token exportedzig build testsent a live credential to a third party. Separately,atomic.maltPrefixOrAbort()reads the ambient environment and defaults to/opt/malt, so a test that forgets a fixture prefix operates on the developer's real install.What I'm proposing
Six PRs, split so each can be judged — and rejected — on its own. Roughly in
descending order of how much I think they matter:
resolveCaskBinaryPath, test-suite hygienesha256now fails closedcosign,install.shchecksum lookupuses:lines touchedWhy #791 is on its own. The obvious fix — forbid symlink targets that
escape the extraction root — breaks every bottle that reaches a sibling
formula via
opt/, because bottles are built for their installed location.Your own
tests/archive_test.zigpins the rust case, and it caught my firstattempt. The PR instead enforces "no entry is reached through a symlink this
archive created", which leaves those leaf links untouched. That reasoning is
worth reviewing separately from the mechanical fixes.
Merge-order note. #790 and #792 both touch
src/net/client.zigfromindependent branches off
main, so whichever lands second needs a trivialconflict resolution. Nothing else overlaps.
One item has no PR. The
tar tf/unzip -Z1listing validation gap isreal but I could not fix it without making things worse — the
xz -dcapproach introduces a dependency on a binary macOS does not ship, resolved
from a package-writable directory. Reasoning in
this comment.
Happy to reshape any of these if you'd prefer a different cut — including
folding them back together if six is more review overhead than it's worth.