Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions Library/Homebrew/cmd/vulns.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module Homebrew
module Cmd
class Vulns < AbstractCommand
SEVERITIES = %w[low medium high critical].freeze
FIX_TYPES = %w[released patch any none unreleased].freeze

cmd_args do
description <<~EOS
Expand All @@ -23,23 +24,25 @@ class Vulns < AbstractCommand
description: "Check formulae listed in a Brewfile. " \
"Defaults to `./Brewfile`; use `--brewfile=`<path> to specify another."
switch "--fix-available",
description: "Only report vulnerabilities that have a fix available. " \
"Note that this may exclude vulnerabilities with fixes available " \
"if we cannot determine that the fix is included in the version " \
"under consideration."
description: "Only report vulnerabilities that have a released version fix available. " \
"Shortcut for `--fix-type=released`."
switch "--no-fix-available",
description: "Only report vulnerabilities that do not have a fix available. " \
"Note that this may include vulnerabilities with fixes available " \
"if we cannot determine that the fix is included in the version " \
"under consideration."
description: "Only report vulnerabilities that do not have a released version fix available " \
"(includes unreleased commit SHA patches). Shortcut for `--fix-type=unreleased`."
flag "--fix-type=",
description: "Filter findings by fix type: `released` (official version release), " \
"`patch` (unreleased commit SHA), `any` (either), `none` (neither), " \
"`unreleased` (no released version fix)."
switch "--list-skipped",
Comment thread
kmarekspartz marked this conversation as resolved.
description: "List packages skipped due to missing or unsupported source URL."
flag "-s", "--severity=",
description: "Only report findings at or above: `low`, `medium`, `high`, `critical`."
flag "-m", "--max-summary=",
description: "Truncate summaries to <n> characters (default 60, 0 for no limit)."
switch "-j", "--json",
description: "Output JSON."

conflicts "--fix-available", "--no-fix-available"
conflicts "--fix-available", "--no-fix-available", "--fix-type"

named_args :formula
end
Expand All @@ -55,14 +58,17 @@ def run
formulae,
ignore_patches: !args.no_ignore_patches?,
min_severity: severity,
only_fixed: args.fix_available?,
except_fixed: args.no_fix_available?,
fix_type:,
).scan

if args.json?
Homebrew::Vulns::Output.json(results)
else
Homebrew::Vulns::Output.text(results, max_summary: summary_width)
Homebrew::Vulns::Output.text(
results,
max_summary: summary_width,
list_skipped: args.list_skipped?,
)
end

if untrusted_skipped.any?
Expand Down Expand Up @@ -145,6 +151,20 @@ def max_summary

raw.to_i
end

sig { returns(T.nilable(Symbol)) }
def fix_type
if args.fix_available?
:released
elsif args.no_fix_available?
:unreleased
elsif (raw = args.fix_type)
raw = raw.downcase
raise UsageError, "`--fix-type` must be one of: #{FIX_TYPES.join(", ")}" unless FIX_TYPES.include?(raw)

raw.to_sym
end
end
end
end
end
6 changes: 6 additions & 0 deletions Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/vulns.rbi

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 58 additions & 13 deletions Library/Homebrew/test/cmd/vulns_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@

describe "#run" do
def stub_scan(findings)
results = Homebrew::Vulns::Scanner::Results.new(findings:, checked: 1, skipped: 0)
results = Homebrew::Vulns::Scanner::Results.new(findings:, checked: 1, skipped_formulae: [])
allow_any_instance_of(Homebrew::Vulns::Scanner).to receive(:scan).and_return(results)
end

Expand Down Expand Up @@ -123,18 +123,28 @@ def stub_scan(findings)

it "emits JSON with --json" do
stub_scan([])
expect { described_class.new(["--json"]).run }.to output("[]\n").to_stdout
expect { described_class.new(["--json"]).run }.to output(<<~JSON).to_stdout
{
"findings": [],
"skipped_formulae": []
}
JSON
end

it "warns to stderr and fails when installed versions could not be checked, even with --json" do
results = Homebrew::Vulns::Scanner::Results.new(
findings: [], checked: 1, skipped: 0, outdated_without_sbom: ["openssl@3"],
findings: [], checked: 1, skipped_formulae: [], outdated_without_sbom: ["openssl@3"],
)
allow_any_instance_of(Homebrew::Vulns::Scanner).to receive(:scan).and_return(results)

expect { described_class.new(["--json"]).run }
.to output("[]\n").to_stdout
.and output(/openssl@3.*could not be determined.*brew upgrade/m).to_stderr
.to output(<<~JSON).to_stdout
{
"findings": [],
"skipped_formulae": []
}
JSON
.and output(/openssl@3.*could not be determined.*brew upgrade/m).to_stderr
expect(Homebrew.failed?).to be true
end

Expand All @@ -143,7 +153,8 @@ def stub_scan(findings)
.with(anything, hash_including(min_severity: :high))
.and_return(
instance_double(Homebrew::Vulns::Scanner,
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0, skipped: 0)),
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0,
skipped_formulae: [])),
)
described_class.new(["--severity=high", "--json"]).run
end
Expand All @@ -153,31 +164,50 @@ def stub_scan(findings)
.with(anything, hash_including(ignore_patches: false))
.and_return(
instance_double(Homebrew::Vulns::Scanner,
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0, skipped: 0)),
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0,
skipped_formulae: [])),
)
described_class.new(["--no-ignore-patches", "--json"]).run
end

it "passes --fix-available to the scanner" do
it "passes --fix-available to the scanner as fix_type: :released" do
expect(Homebrew::Vulns::Scanner).to receive(:new)
.with(anything, hash_including(only_fixed: true))
.with(anything, hash_including(fix_type: :released))
.and_return(
instance_double(Homebrew::Vulns::Scanner,
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0, skipped: 0)),
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0,
skipped_formulae: [])),
)
described_class.new(["--fix-available", "--json"]).run
end

it "passes --no-fix-available to the scanner" do
it "passes --no-fix-available to the scanner as fix_type: :unreleased" do
expect(Homebrew::Vulns::Scanner).to receive(:new)
.with(anything, hash_including(except_fixed: true))
.with(anything, hash_including(fix_type: :unreleased))
.and_return(
instance_double(Homebrew::Vulns::Scanner,
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0, skipped: 0)),
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0,
skipped_formulae: [])),
)
described_class.new(["--no-fix-available", "--json"]).run
end

it "passes --fix-type to the scanner" do
expect(Homebrew::Vulns::Scanner).to receive(:new)
.with(anything, hash_including(fix_type: :patch))
.and_return(
instance_double(Homebrew::Vulns::Scanner,
scan: Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 0,
skipped_formulae: [])),
)
described_class.new(["--fix-type=patch", "--json"]).run
end

it "rejects invalid --fix-type values" do
expect { described_class.new(["--fix-type=invalid"]).run }
.to raise_error(UsageError, /`--fix-type` must be one of/)
end

it "rejects passing both --fix-available and --no-fix-available" do
expect { described_class.new(["--fix-available", "--no-fix-available"]).run }
.to raise_error(
Expand All @@ -186,6 +216,21 @@ def stub_scan(findings)
)
end

it "rejects passing both --fix-available and --fix-type" do
expect { described_class.new(["--fix-available", "--fix-type=patch"]).run }
.to raise_error(
UsageError,
/mutually exclusive/,
)
end

it "passes list_skipped to Output.text when --list-skipped is given" do
stub_scan([])
expect(Homebrew::Vulns::Output).to receive(:text)
.with(anything, hash_including(list_skipped: true))
described_class.new(["--list-skipped"]).run
end

context "with an installed keg from an untrusted tap" do
let(:trusted_rack) { HOMEBREW_CELLAR/"act" }
let(:untrusted_rack) { HOMEBREW_CELLAR/"foo" }
Expand Down
59 changes: 37 additions & 22 deletions Library/Homebrew/test/vulns/output_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ def finding(name:, version:, tag: "v#{version}", repo_url: "https://github.com/x
Homebrew::Vulns::Scanner::Finding.new(name:, version:, tag:, repo_url:, open:, patched:)
end

def results(findings, checked: findings.size, skipped: 0)
Homebrew::Vulns::Scanner::Results.new(findings:, checked:, skipped:)
def results(findings, checked: findings.size, skipped_formulae: [])
Homebrew::Vulns::Scanner::Results.new(findings:, checked:, skipped_formulae:)
end

describe ".text" do
Expand All @@ -40,6 +40,13 @@ def render(res, **opts)
expect(out).not_to include "No vulnerabilities found.\n"
end

it "lists skipped package names when list_skipped is true" do
res = Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 1, skipped_formulae: ["aom", "libssh2"])
out = render(res, list_skipped: true)
expect(out).to include "(2 packages skipped - no supported source URL):"
expect(out).to include " aom\n libssh2"
end

it "prints formula, version, vuln id, severity and summary" do
f = finding(name: "vim", version: "9.1.2050",
open: [vuln("CVE-2024-1234", severity: "HIGH", summary: "Heap overflow")])
Expand Down Expand Up @@ -119,7 +126,7 @@ def render(res, **opts)
end

it "reports checked and skipped counts" do
out = render(results([], checked: 5, skipped: 2))
out = render(results([], checked: 5, skipped_formulae: ["a", "b"]))
expect(out).to include "Checking 5 packages for vulnerabilities"
expect(out).to include "(2 packages skipped - no supported source URL)"
end
Expand All @@ -132,8 +139,8 @@ def render(res)
JSON.parse(out)
end

it "emits an empty array when there are no findings" do
expect(render(results([]))).to eq []
it "emits an empty findings array and empty skipped_formulae when there are no findings" do
expect(render(results([]))).to eq("findings" => [], "skipped_formulae" => [])
end

it "emits one object per finding with vulnerabilities and patched arrays" do
Expand All @@ -143,27 +150,35 @@ def render(res)
aliases: ["GHSA-x"], fixed: ["v10.0.0"])],
patched: [vuln("CVE-2016-2399")])
data = render(results([f]))
expect(data).to eq [
{
"formula" => "vim",
"version" => "9.1.2050",
"tag" => "v9.1.2050",
"repo_url" => "https://github.com/vim/vim",
"vulnerabilities" => [
{ "id" => "CVE-2024-1234", "severity" => "HIGH", "summary" => "Heap overflow",
"aliases" => ["GHSA-x"], "fixed_versions" => ["v10.0.0"] },
],
"patched" => [
{ "id" => "CVE-2016-2399", "severity" => "HIGH", "summary" => nil,
"aliases" => [], "fixed_versions" => [] },
],
},
]
expect(data).to eq(
"findings" => [
{
"formula" => "vim",
"version" => "9.1.2050",
"tag" => "v9.1.2050",
"repo_url" => "https://github.com/vim/vim",
"vulnerabilities" => [
{ "id" => "CVE-2024-1234", "severity" => "HIGH", "summary" => "Heap overflow",
"aliases" => ["GHSA-x"], "fixed_versions" => ["v10.0.0"] },
],
"patched" => [
{ "id" => "CVE-2016-2399", "severity" => "HIGH", "summary" => nil,
"aliases" => [], "fixed_versions" => [] },
],
},
],
"skipped_formulae" => [],
)
end

it "emits an empty patched array when nothing is resolved" do
f = finding(name: "vim", version: "9.1", open: [vuln("CVE-2024-1234")])
expect(render(results([f])).first["patched"]).to eq []
expect(render(results([f])).dig("findings", 0, "patched")).to eq []
end

it "includes skipped_formulae in the JSON output" do
res = Homebrew::Vulns::Scanner::Results.new(findings: [], checked: 1, skipped_formulae: ["aom", "libssh2"])
expect(render(res)["skipped_formulae"]).to eq ["aom", "libssh2"]
end
end
end
Loading
Loading