mirror of
https://github.com/Homebrew/brew.git
synced 2026-10-28 09:34:42 +08:00
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:
1 parent
e8b9a64b59
commit
be781f344e
4 files changed
+124
-46
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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\.
|
||||
|
||||
Reference in new issue
Block a user