Skip to content
ericfitzPublic

About

Homebrew wrapper that adds soak times to formula and cask updates to defend against supply chain security compromises

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

122 Commits

Folders and files

Repository files navigation

brewsoak

A Homebrew wrapper that delays homebrew/core, homebrew/cask, and third-party tap updates for a soak window. This gives security researchers time to discover and yank a compromised package before you install it.

Every other brew subcommand passes through unchanged. Packages and taps you list under NO_SOAK skip soaking and end in the same state as brew upgrade. There is no soak bypass flag: run brew directly if you need HEAD now.

Install

Homebrew (macOS, recommended)

brew install ericfitz/tap/brewsoak

Installs a prebuilt, code-signed and notarized universal (Apple Silicon + Intel) binary from the GitHub release. Upgrade later with brew upgrade brewsoak. Add ericfitz/tap to NO_SOAK (below) so brewsoak upgrade keeps brewsoak itself current without a soak delay.

Cargo

From crates.io:

cargo install brewsoak

From this GitHub repo:

cargo install --git https://github.com/ericfitz/brewsoakr

From a local checkout:

git clone git@github.com:ericfitz/brewsoakr.git
cd brewsoakr
cargo install --path .

Or run target/debug/brewsoak after cargo build.

Configuration

Soak duration is hours, integer ≥ 1, default 24.

Source Name
CLI --soak-hours N
Environment BREWSOAK_SOAK_HOURS
File ~/.config/brewsoak/config.toml key SOAK_HOURS

Precedence: CLI > environment > file > 24.

--soak-hours is persisted only when used with a soaked command (update, upgrade, install, reinstall, outdated, info). N == 24 removes the SOAK_HOURS key; the file is deleted only if nothing else remains.

Per-tap soak hours and the no-soak list

# Top-level keys go first. TOML assigns any key after a [[TAP]] header to
# that table.
SOAK_HOURS = 48                  # default for every package, core and cask included
NO_SOAK = ["ericfitz/tap", "wget", "hashicorp/tap/terraform"]

[[TAP]]
name = "hashicorp/tap"
soak_hours = 72                  # optional; applies to every package in this tap

[[TAP]]
name = "cyclonedx/cyclonedx"     # no soak_hours: uses SOAK_HOURS

Effective soak hours for a package: a NO_SOAK match means no soak at all; else the origin tap's [[TAP]] soak_hours; else SOAK_HOURS. [[TAP]] and NO_SOAK have no flag and no environment variable: edit the file, or use brewsoak settings below. homebrew/core and homebrew/cask are valid tap names in both.

NO_SOAK entries:

Entry Matches
wget that formula or cask from any tap
ericfitz/tap every package in that tap
hashicorp/tap/terraform that one package from that tap

Matching is case-insensitive. Invalid entries are skipped and reported under -v; brewsoak settings repair removes them. A NO_SOAK key inside a [[TAP]] table is ignored, with a warning on stderr every run.

--soak-hours N edits only the SOAK_HOURS key; [[TAP]], NO_SOAK, and comments are kept, and the previous file is backed up as described under brewsoak settings. A config file that is not valid TOML is left alone with a warning.

brewsoak settings

brewsoak settings [show]
brewsoak settings soak-hours N|--clear
brewsoak settings no-soak add|remove TOKEN...
brewsoak settings tap-hours USER/REPO N|--clear
brewsoak settings repair

show prints the effective soak hours and their source (BREWSOAK_SOAK_HOURS, the file, or the default 24), NO_SOAK as written, every [[TAP]] with its effective hours, and every parse note and warning. It never writes.

Edits keep comments, key order, and unknown keys. New top-level keys go above the first [[TAP]]; a new [[TAP]] is appended after the last one. soak-hours 24 removes SOAK_HOURS (24 is the default). no-soak tokens are validated like the file's entries, compared case-insensitively, and written lowercased; adding a present token or removing an absent one is reported and is not an error. no-soak add and remove refuse a NO_SOAK list that holds a non-string entry; repair fixes it. tap-hours USER/REPO --clear removes the entry when only name would remain. When nothing is left in the file, the file is removed.

repair makes the file what brewsoak reads, so show and -v stop noting problems. A lone NO_SOAK = "wget" becomes ["wget"]. Elements of NO_SOAK that are not valid entries (or not strings) are removed. A NO_SOAK written inside a [[TAP]] table has its valid entries appended to the top-level list (created above the first [[TAP]] if missing), and the misplaced key is deleted. [[TAP]] entries with a missing or bad name are removed, as is a soak_hours that is not an integer >= 1 (the entry goes too if only name is left) and every earlier duplicate (the last one wins, as when reading). Valid entries keep their text, order, comments, and layout. Every removal is printed. A NO_SOAK that is neither an array nor a valid entry string (an invalid string, a number, a table), or a TAP that is not an array of tables, is refused and nothing is written. With nothing to fix it prints nothing to repair and writes nothing.

Every write goes to a temp file first, the previous config.toml is kept as config.toml.<YYYYMMDDTHHMMSSZ>.bak next to it (the 2 newest backups are kept), and the temp file is renamed into place, so another brewsoak never sees a missing or partial file. An unchanged edit writes nothing. A file that is not valid TOML is refused, with the parse error, and left untouched.

settings soak-hours warns on stderr when BREWSOAK_SOAK_HOURS is set, because the environment overrides the file; the edit is still written.

settings never runs brew or git and does not check whether a tap is installed. --soak-hours is not accepted with settings.

Third-party taps

Every installed tap with an HTTPS remote is soaked like core and cask, from a blobless clone under brewsoak's cache. brew's own tap checkouts are never touched. A tap with no remote, or a non-HTTPS remote, is not soakable: brewsoak notes its packages and leaves them to brew, unless they are in NO_SOAK.

No-soak packages are installed by brew itself from their real tap after all soaked work: one brew update, then one brew upgrade (or install / reinstall) naming every no-soak package. brew upgrade also upgrades a no-soak package's outdated dependencies to brew's latest, so no-soak extends to its dependencies. Soaked dependents of a no-soak package are not upgraded by that step.

Commands

Command What it does
update Refresh cutoff/HEAD snapshots. Runs brew update once when any installed package is no-soak.
outdated What upgrade would change, plus held / ahead / pinned.
upgrade Install soaked cutoff artifacts for eligible installed packages.
install Install the soaked cutoff artifact if eligible.
reinstall True repair via brew when installed == HEAD; otherwise cutoff.
info Installed / cutoff / HEAD and the action brewsoak would take.
--version / -V Print brewsoak <version>.
--help / -h brewsoak help. help install is soak-aware; help services is brew help.

Other flags (--formula, --cask, --debug, …) are forwarded to brew. upgrade and outdated honor brew's --greedy (-g), --greedy-auto-updates and --greedy-latest themselves (see below); the no-soak brew upgrade also receives them.

-v / --verbose prints the soak window, cutoff SHAs and times, and a line for every package evaluated (what happened and why).

--raw turns off output summarizing and forwards brew's output byte for byte.

Output

brewsoak summarizes brew's install output: one line announcing each package it changes, then a short line per download and install step. Bottle manifests, plan previews, cleanup, ==> markers, emoji, and already installed and up-to-date notices are dropped from the terminal.

Nothing is lost. Every byte brew writes is appended to a per-run log under $TMPDIR, and the path is printed at the end of the run.

A run prints headed sections (Fetching Homebrew history, Fetching tap history, Upgrading soaked packages, Updating Homebrew, Upgrading no-soak packages (brew decides)) with their lines indented beneath. A section with nothing in it is omitted, except the soaked section of a bare upgrade, which always prints with (N of M). A dry run reads Would upgrade …. Holds, skips, and deprecation warnings are collected into a Notes section after the Summary table, grouped by kind with one package per line, so they are not buried in the install scroll, and a Caveats section follows with the caveats worth reading — brew's "shell completions have been installed to ..." notices are dropped, anything telling you to run something is kept.

Deprecations more than a year away are not reported. A package that is deprecated or disabled upstream is never installed or upgraded, but one already at its cutoff (or ahead of soak) is left alone with a warning, not held.

Example

brewsoak update
brewsoak outdated
brewsoak upgrade
brewsoak info wget
brewsoak upgrade -v

A typical upgrade with nothing to do (a dry run here, taken from a real run and trimmed) prints:

Fetching Homebrew history
  Homebrew/homebrew-core
  Homebrew/homebrew-cask
Fetching tap history
  anthropics/tap
  hashicorp/tap
Would upgrade soaked packages (0 of 177)
Updating Homebrew
  Already up-to-date.
Would upgrade no-soak packages (brew decides)
  ericfitz/tap/brewsoak 1.5.2
    Already up-to-date.
  ericfitz/tap/sqdist 0.6.0
    Already up-to-date.
Summary
  would upgrade    0
  already soaked 162
  soaking         10
  held             0
  ahead            1
  pinned           0
  skipped          0
  no-soak          2  (0 would upgrade, 2 already up-to-date)
  auto-updates     2
  installed       0B
  freed           0B
Notes
  Still soaking (left at the cutoff):
    awscli 2.38.3
    cats 15.0.3
  Ahead of soak (installed artifact left unchanged):
    uv
Full brew log: /var/folders/…/T/brewsoak-12345.log

A package already at its cutoff while a newer version is still inside the soak window is counted as soaking N instead of already soaked. A bare upgrade lists them under Still soaking (left at the cutoff) in Notes. A named one (brewsoak upgrade cats) says which version is soaking and for how long. outdated lists them under ==> Soaking, and info shows up to date (soaked); 15.0.0 soaking. brew's own "Outdated Formulae" list from brew update is dropped, because it counts versions that are still soaking, and so are its New and Deleted package lists. All of them stay in --raw output and the brew log.

Casks that update themselves (auto_updates true) are left to the app on a bare upgrade, as brew upgrade leaves them without --greedy; they are counted as auto-updates N and outdated lists them under their own heading. Name one (brewsoak upgrade alt-tab) to upgrade it anyway, or pass --greedy (or --greedy-auto-updates) to upgrade all of them to their soaked cutoff like any other cask. A version :latest cask is different: its contents change without the cask changing, so brewsoak reinstalls one under --greedy / --greedy-latest only when its cutoff definition differs from the installed one, and otherwise leaves it with a note that the contents of a :latest cask cannot be soaked (brew would reinstall it every time). Since Homebrew 4 an installed cask carries only a version (the Caskroom usually holds no cask source), so a cask is compared to its cutoff by version alone.

An upgrade with work to do looks like:

Upgrading soaked packages (26 of 153)
  [1/26] upgrading aws-c-auth 0.10.5 -> 1.0.0
    downloading aws-c-common 1.0.0
    installing aws-c-common 1.0.0
    installed to /opt/homebrew/Cellar/aws-c-common/1.0.0 (109 files, 1MB)
    ...
Updating Homebrew
  Already up-to-date.
Upgrading no-soak packages (brew decides)
  upgrading ericfitz/tap/agentbus 1.15.0 -> 1.16.0
    installing agentbus 1.16.0
  ericfitz/tap/brewsoak 1.5.2
    Already up-to-date.
Summary
  upgraded            26
  already soaked     124
  soaking              0
  held                 0
  ahead                0
  pinned               0
  skipped              1
  no-soak              2  (1 upgraded, 1 already up-to-date)
  auto-updates         0
  installed      989.3MB
  freed            1.2GB
Notes
  bash: unparseable identity; skipping
Caveats
  git-lfs:
    Update your git config to finish installation:
      $ git lfs install
Full brew log: /var/folders/…/T/brewsoak-53417.log

Refusals tell you to run brew upgrade <name> (or install/reinstall) to bypass brewsoak.

Releasing

Maintainer notes. release/ holds the scripts that build a universal binary, sign and notarize it, attach it to a GitHub release, and render the Homebrew formula for ericfitz/homebrew-tap. See release/README.md for the step-by-step; the crates.io release is a separate cargo publish.

About

Homebrew wrapper that adds soak times to formula and cask updates to defend against supply chain security compromises

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages