vulns: scan installed kegs by default and report untrusted-tap skips

No-arg `brew vulns` was branching on `tap_trust_configured?`, which is
always true now that `HOMEBREW_REQUIRE_TAP_TRUST` defaults on, so it hit
`Formula.all` and scanned every core formula instead of what is
installed. Enumerate installed racks directly, catching
`UntrustedTapError` so kegs from taps that lost trust in the 6.0
migration are reported and fail the run rather than silently vanishing
via `Formula.installed`'s bare rescue.

Also merge `--brewfile` entries with named arguments instead of
dropping the named ones, and validate `--severity` before enumerating
formulae.
This commit is contained in:
Andrew Nesbitt committed 2026-07-16 11:03:32 -07:00
1 parent e8b9a64b59
commit be781f344e
4 files changed
+124 -46

No files matched your search

+41 -13
View File
@@ -13,8 +13,7 @@ module Homebrew
description <<~EOS
Check <formula> for known security vulnerabilities using the OSV.dev database.
With no arguments, installed formulae are checked unless tap trust is configured,
in which case all formulae permitted by the trust configuration are checked.
With no arguments, all installed formulae are checked.
EOS
switch "-d", "--deps",
description: "Also check the dependencies of named formulae."
@@ -38,11 +37,12 @@ module Homebrew
require "vulns"
summary_width = max_summary
severity = min_severity
results = Homebrew::Vulns::Scanner.new(
formulae,
ignore_patches: !args.no_ignore_patches?,
min_severity:,
min_severity: severity,
).scan
if args.json?
@@ -51,6 +51,20 @@ module Homebrew
Homebrew::Vulns::Output.text(results, max_summary: summary_width)
end
if untrusted_skipped.any?
count = untrusted_skipped.size
header = if count == 1
"1 installed keg from an untrusted tap was not scanned:"
else
"#{count} installed kegs from untrusted taps were not scanned:"
end
opoo <<~EOS
#{header}
#{untrusted_skipped.join("\n ")}
Run `brew trust` on the tap or formula to include it in future scans.
EOS
Homebrew.failed = true
end
if results.outdated_without_sbom.any?
opoo <<~EOS
The installed source of #{results.outdated_without_sbom.sort.join(", ")} could not be determined
@@ -64,22 +78,36 @@ module Homebrew
sig { returns(T::Array[Formula]) }
def formulae
list = if (brewfile = args.brewfile)
list = T.let([], T::Array[Formula])
if (brewfile = args.brewfile)
require "bundle/brewfile"
Homebrew::Bundle::Brewfile.read(file: brewfile_path(brewfile)).entries
.select { |e| e.type == :brew }
.map { |e| Formulary.resolve(e.name) }
elsif args.named.any?
args.named.to_resolved_formulae
elsif Homebrew::EnvConfig.tap_trust_configured?
Formula.all
else
Formula.installed
list += Homebrew::Bundle::Brewfile.read(file: brewfile_path(brewfile)).entries
.select { |e| e.type == :brew }
.map { |e| Formulary.resolve(e.name) }
end
list += args.named.to_resolved_formulae if args.named.any?
list = installed_formulae if !args.brewfile && args.no_named?
list += list.flat_map { |f| f.recursive_dependencies.map(&:to_formula) } if args.deps?
list.uniq(&:full_name)
end
sig { returns(T::Array[Formula]) }
def installed_formulae
Formula.racks.filter_map do |rack|
Formulary.from_rack(rack)
rescue Homebrew::UntrustedTapError => e
untrusted_skipped << e.message.lines.first.to_s.strip
nil
rescue
nil
end.uniq(&:name)
end
sig { returns(T::Array[String]) }
def untrusted_skipped
@untrusted_skipped ||= T.let([], T.nilable(T::Array[String]))
end
# A bare `--brewfile` (no `=path`) yields `true` from OptionParser at
# runtime; the generated RBI types it as `T.nilable(String)`, so accept
# the wider type here and normalise `true`/`""` to the `nil` default.
+81 -29
View File
@@ -6,18 +6,37 @@ require "vulns"
require "cmd/shared_examples/args_parse"
RSpec.describe Homebrew::Cmd::Vulns do
before do
allow(Homebrew::EnvConfig).to receive(:tap_trust_configured?).and_return(false)
end
it_behaves_like "parseable arguments"
it "checks all formulae allowed by tap trust when no formula is named" do
formula = instance_double(Formula, full_name: "act")
allow(Homebrew::EnvConfig).to receive(:tap_trust_configured?).and_return(true)
expect(Formula).to receive(:all).and_return([formula])
describe "#formulae" do
let(:installed) { instance_double(Formula, full_name: "curl", recursive_dependencies: []) }
expect(described_class.new([]).formulae).to eq [formula]
it "scans installed formulae when no arguments are given, never Formula.all" do
rack = HOMEBREW_CELLAR/"curl"
allow(Formula).to receive(:racks).and_return([rack])
allow(Formulary).to receive(:from_rack).with(rack).and_return(installed)
expect(Formula).not_to receive(:all)
expect(described_class.new([]).formulae).to eq [installed]
end
it "scans named formulae" do
named = instance_double(Formula, full_name: "act", recursive_dependencies: [])
allow_any_instance_of(Homebrew::CLI::NamedArgs).to receive(:to_resolved_formulae).and_return([named])
expect(described_class.new(["act"]).formulae).to eq [named]
end
it "scans the union of --brewfile entries and named arguments" do
require "bundle/brewfile"
entry = instance_double(Homebrew::Bundle::Dsl::Entry, type: :brew, name: "wget")
dsl = instance_double(Homebrew::Bundle::Dsl, entries: [entry])
from_brewfile = instance_double(Formula, full_name: "wget", recursive_dependencies: [])
named = instance_double(Formula, full_name: "act", recursive_dependencies: [])
allow(Homebrew::Bundle::Brewfile).to receive(:read).with(file: nil).and_return(dsl)
allow(Formulary).to receive(:resolve).with("wget").and_return(from_brewfile)
allow_any_instance_of(Homebrew::CLI::NamedArgs).to receive(:to_resolved_formulae).and_return([named])
expect(described_class.new(["act", "--brewfile"]).formulae).to contain_exactly(from_brewfile, named)
end
end
it "reads the default Brewfile when --brewfile is passed without a path" do
@@ -34,25 +53,10 @@ RSpec.describe Homebrew::Cmd::Vulns do
expect(described_class.new(["--brewfile=Brewfile.dev"]).formulae).to eq [formula]
end
it "rejects an unknown --severity value" do
expect { described_class.new(["--severity=urgent"]).run }
.to raise_error(UsageError, /`--severity` must be one of/)
end
it "rejects a non-numeric --max-summary value" do
expect { described_class.new(["--max-summary=lots"]).run }
.to raise_error(UsageError, /`--max-summary` must be a non-negative integer/)
end
it "rejects a negative --max-summary value" do
expect { described_class.new(["--max-summary=-5"]).run }
.to raise_error(UsageError, /`--max-summary` must be a non-negative integer/)
end
it "validates options before constructing the scanner" do
expect(Homebrew::Vulns::Scanner).not_to receive(:new)
expect { described_class.new(["--max-summary=bad", "--json"]).run }.to raise_error(UsageError)
expect { described_class.new(["--severity=urgent"]).run }.to raise_error(UsageError)
it "validates --severity before enumerating formulae" do
cmd = described_class.new(["--severity=urgent"])
expect(cmd).not_to receive(:formulae)
expect { cmd.run }.to raise_error(UsageError)
end
describe "#run" do
@@ -68,7 +72,28 @@ RSpec.describe Homebrew::Cmd::Vulns do
end
before do
allow(Formula).to receive(:installed).and_return([act])
allow_any_instance_of(described_class).to receive(:formulae).and_return([act])
end
it "rejects an unknown --severity value" do
expect { described_class.new(["--severity=urgent"]).run }
.to raise_error(UsageError, /`--severity` must be one of/)
end
it "rejects a non-numeric --max-summary value" do
expect { described_class.new(["--max-summary=lots"]).run }
.to raise_error(UsageError, /`--max-summary` must be a non-negative integer/)
end
it "rejects a negative --max-summary value" do
expect { described_class.new(["--max-summary=-5"]).run }
.to raise_error(UsageError, /`--max-summary` must be a non-negative integer/)
end
it "validates options before constructing the scanner" do
expect(Homebrew::Vulns::Scanner).not_to receive(:new)
expect { described_class.new(["--max-summary=bad", "--json"]).run }.to raise_error(UsageError)
expect { described_class.new(["--severity=urgent"]).run }.to raise_error(UsageError)
end
it "prints text output and does not set Homebrew.failed when nothing is found" do
@@ -131,5 +156,32 @@ RSpec.describe Homebrew::Cmd::Vulns do
)
described_class.new(["--no-ignore-patches", "--json"]).run
end
context "with an installed keg from an untrusted tap" do
let(:trusted_rack) { HOMEBREW_CELLAR/"act" }
let(:untrusted_rack) { HOMEBREW_CELLAR/"foo" }
before do
allow_any_instance_of(described_class).to receive(:formulae).and_call_original
allow(Formula).to receive(:racks).and_return([trusted_rack, untrusted_rack])
allow(Formulary).to receive(:from_rack).with(trusted_rack).and_return(act)
allow(Formulary).to receive(:from_rack).with(untrusted_rack).and_raise(
Homebrew::UntrustedTapError,
"Refusing to load formula someone/tap/foo from untrusted tap someone/tap.",
)
stub_scan([])
end
it "does not pass the untrusted formula to the scanner" do
expect(Homebrew::Vulns::Scanner).to receive(:new).with([act], anything).and_call_original
described_class.new(["--json"]).run
end
it "reports the skipped keg and fails" do
expect { described_class.new(["--json"]).run }
.to output(%r{untrusted tap.*not scanned.*someone/tap/foo.*brew trust}m).to_stderr
expect(Homebrew.failed?).to be true
end
end
end
end
+1 -3
View File
@@ -2536,9 +2536,7 @@ available and the local username otherwise.
Check *`formula`* for known security vulnerabilities using the OSV.dev database.
With no arguments, installed formulae are checked unless tap trust is
configured, in which case all formulae permitted by the trust configuration are
checked.
With no arguments, all installed formulae are checked.
`-d`, `--deps`
+1 -1
View File
@@ -1599,7 +1599,7 @@ Extract a specific \fIversion\fP of \fIformula\fP into a personal tap and instal
.SS "\fBvulns\fP \fR[\fIoptions\fP] \fR[\fIformula\fP \.\.\.]"
Check \fIformula\fP for known security vulnerabilities using the OSV\.dev database\.
.P
With no arguments, installed formulae are checked unless tap trust is configured, in which case all formulae permitted by the trust configuration are checked\.
With no arguments, all installed formulae are checked\.
.TP
\fB\-d\fP, \fB\-\-deps\fP
Also check the dependencies of named formulae\.