diff --git a/Library/Homebrew/dev-cmd/advisory-match.rb b/Library/Homebrew/dev-cmd/advisory-match.rb new file mode 100644 index 0000000000000..9d117c5c25f78 --- /dev/null +++ b/Library/Homebrew/dev-cmd/advisory-match.rb @@ -0,0 +1,257 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "fileutils" +require "formula" +require "vulns/match" + +module Homebrew + module DevCmd + class AdvisoryMatch < AbstractCommand + cmd_args do + description <<~EOS + Match against OSV.dev (GIT, language-registry and distro + ecosystems) and CPANSA to produce candidate `BREW-*` advisory records + for . + + This is authoring-time tooling for the advisory-database CI and the + `homebrew-core` PR bot; use `brew vulns` to scan installed formulae. + EOS + switch "--all", + description: "Match every formula in `homebrew/core`." + switch "--index", + description: "Emit the formula-identity index as JSON and exit." + switch "--json", + description: "Output candidate records as a JSON array." + flag "--output=", + description: "Write each record to as " \ + "`BREW--.json`, preserving existing " \ + "`published`/`ranges` fields." + switch "--no-history", + description: "Skip the `FormulaVersions` walk for the `fixed` " \ + "boundary; use the current `pkg_version` instead." + conflicts "--all", "--index" + conflicts "--all", "--json" + conflicts "--index", "--json" + conflicts "--index", "--output" + + named_args [:formula] + + hide_from_man_page! + end + + sig { override.void } + def run + Formulary.enable_factory_cache! + Homebrew.with_no_api_env do + latest_macos = MacOSVersion.new((HOMEBREW_MACOS_NEWEST_UNSUPPORTED.to_i - 1).to_s).to_sym + Homebrew::SimulateSystem.with(os: latest_macos, arch: :arm) do + matcher = Homebrew::Vulns::Match.new(bulk: args.all? || args.index?) + next emit_index(matcher) if args.index? + + emitter = build_emitter + begin + matcher.each_advisory_batch(each_formula) do |formula, hits| + report(matcher, formula, hits) if text_mode? + hits.each do |hit| + # A `:not_applicable` hit (below every `introduced`) emitted + # as `{introduced: 0}` with no `fixed` reads to OSV consumers + # as currently affected; drop it instead. + status, = matcher.range_status(hit) + next if status&.state == :not_applicable + + first_fixed = matcher.first_fixed_version(formula, hit) unless args.no_history? + next if first_fixed == :never_affected + + boundary = first_fixed if first_fixed.is_a?(String) + emitter << matcher.to_brew_record(formula, hit, first_fixed: boundary) + end + end + rescue Homebrew::Vulns::OSV::Error => e + onoe "OSV query failed: #{e.message}" + Homebrew.failed = true + end + emitter.finish + end + end + end + + sig { returns(T::Enumerator[Formula]) } + def each_formula + return args.named.to_resolved_formulae.each unless args.all? + + raise UsageError, "`--all` does not take named arguments" if args.named.any? + + tap = CoreTap.instance + raise TapUnavailableError, tap.name unless tap.installed? + + Enumerator.new do |y| + tap.formula_names.each do |name| + y << Formulary.factory(name) + rescue => e + onoe "Error loading formula '#{name}': #{e}" + end + end + end + + sig { returns(T::Boolean) } + def text_mode? + !args.json? && args.output.nil? + end + + sig { + params(matcher: Homebrew::Vulns::Match, formula: Formula, + hits: T::Array[Homebrew::Vulns::Match::Hit]).void + } + def report(matcher, formula, hits) + ohai "#{formula.name} #{formula.pkg_version}" + if hits.empty? + puts " No advisories matched." + return + end + hits.sort_by { |h| [-h.vulnerability.severity_level, h.canonical_id] }.each do |hit| + v = hit.vulnerability + status, = matcher.range_status(hit) + state = case status&.state + when nil then "uncomparable" + when :affected then "AFFECTED#{", upstream fix #{status&.fixed_in}" if status&.fixed_in}" + when :fixed then "fixed (upstream #{status&.fixed_in || "?"})" + else "not applicable" + end + summary = v.summary&.slice(0, 60) + puts " #{hit.canonical_id} [#{hit.strategy}, #{matcher.confidence_for(hit, status)}] " \ + "#{v.severity_display} #{state}" \ + "#{" (resource: #{hit.resource})" if hit.resource}" \ + "#{" — #{summary}" if summary}" + end + end + + # `--output` and text mode write per-record and only accumulate counts; + # `--json` accumulates the array (single-formula / PR-bot use, so bounded). + class Emitter + sig { params(record: T::Hash[Symbol, T.untyped]).void } + def <<(record); end + + sig { void } + def finish; end + end + + class DirEmitter < Emitter + sig { params(dir: String, verbose: T::Boolean).void } + def initialize(dir, verbose:) + super() + FileUtils.mkdir_p(dir) + @dir = dir + @verbose = verbose + @written = T.let(0, Integer) + @unchanged = T.let(0, Integer) + @skipped_generated = T.let(0, Integer) + end + + sig { override.params(record: T::Hash[Symbol, T.untyped]).void } + def <<(record) + path = File.join(@dir, "#{record.fetch(:id)}.json") + # A record already emitted by `generate-vulns-advisories` (a formula + # `resolves` patch annotation) is more authoritative than a matched + # candidate; overwriting it would drop `fix: "patch"` for a derived + # `fix: null`/`"bump"`. + if File.file?(path) && existing_source(path) == "generated" + @skipped_generated += 1 + return + end + merged = Homebrew::Vulns::OsvExport.merge_existing(path, record) + if merged.nil? + @unchanged += 1 + return + end + File.write(path, "#{JSON.pretty_generate(merged)}\n") + puts " wrote #{path}" if @verbose + @written += 1 + end + + sig { params(path: String).returns(T.nilable(String)) } + def existing_source(path) + JSON.parse(File.read(path)).dig("database_specific", "source") + rescue JSON::ParserError + nil + end + + sig { override.void } + def finish + Utils::Output.ohai "#{@written} records written to #{@dir} " \ + "(#{@unchanged} unchanged, #{@skipped_generated} generated left as-is)" + end + end + + class JsonEmitter < Emitter + sig { void } + def initialize + super + @records = T.let([], T::Array[T::Hash[Symbol, T.untyped]]) + end + + sig { override.params(record: T::Hash[Symbol, T.untyped]).void } + def <<(record) + @records << record + end + + sig { override.void } + def finish + puts JSON.pretty_generate(@records) + end + end + + class CountEmitter < Emitter + sig { void } + def initialize + super + @count = T.let(0, Integer) + end + + sig { override.params(_record: T::Hash[Symbol, T.untyped]).void } + def <<(_record) + @count += 1 + end + + sig { override.void } + def finish + Utils::Output.ohai "#{@count} candidate records" + end + end + + sig { returns(Emitter) } + def build_emitter + if (dir = args.output) + DirEmitter.new(dir, verbose: args.verbose?) + elsif args.json? + JsonEmitter.new + else + CountEmitter.new + end + end + + sig { params(matcher: Homebrew::Vulns::Match).void } + def emit_index(matcher) + tap = CoreTap.instance + raise TapUnavailableError, tap.name unless tap.installed? + + index = tap.formula_names.each_with_object({}) do |name, h| + identity = matcher.identify(Formulary.factory(name)) + next unless identity.identifiable? + + h[name] = { + git_repo: identity.git_repo, + git_tag: identity.git_tag, + primary_package: identity.primary_package&.to_h, + resource_packages: identity.resource_packages.transform_values(&:to_h), + distro_packages: identity.distro_packages, + }.compact + rescue => e + onoe "Error loading formula '#{name}': #{e}" + end + puts JSON.pretty_generate(index) + end + end + end +end diff --git a/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi new file mode 100644 index 0000000000000..724de13fb70a1 --- /dev/null +++ b/Library/Homebrew/sorbet/rbi/dsl/homebrew/dev_cmd/advisory_match.rbi @@ -0,0 +1,28 @@ +# typed: true + +# DO NOT EDIT MANUALLY +# This is an autogenerated file for dynamic methods in `Homebrew::DevCmd::AdvisoryMatch`. +# Please instead update this file by running `bin/tapioca dsl Homebrew::DevCmd::AdvisoryMatch`. + + +class Homebrew::DevCmd::AdvisoryMatch + sig { returns(Homebrew::DevCmd::AdvisoryMatch::Args) } + def args; end +end + +class Homebrew::DevCmd::AdvisoryMatch::Args < Homebrew::CLI::Args + sig { returns(T::Boolean) } + def all?; end + + sig { returns(T::Boolean) } + def index?; end + + sig { returns(T::Boolean) } + def json?; end + + sig { returns(T::Boolean) } + def no_history?; end + + sig { returns(T.nilable(String)) } + def output; end +end diff --git a/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb new file mode 100644 index 0000000000000..f284e8ed6fe24 --- /dev/null +++ b/Library/Homebrew/test/dev-cmd/advisory-match_spec.rb @@ -0,0 +1,164 @@ +# typed: true +# frozen_string_literal: true + +require "cmd/shared_examples/args_parse" +require "dev-cmd/advisory-match" + +RSpec.describe Homebrew::DevCmd::AdvisoryMatch do + let(:requests) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + head "https://github.com/psf/requests.git" + end + end + + before do + allow(Formulary).to receive(:enable_factory_cache!) + allow(Homebrew::Vulns::Repology).to receive_messages( + load: Homebrew::Vulns::Repology.new({ "meta" => {}, "formulae" => {} }), + lookup: {}, + ) + allow(Homebrew::Vulns::CPANSec).to receive(:load).and_return( + Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => {} }), + ) + end + + it_behaves_like "parseable arguments" + + def cmd_for(*argv, formulae: [requests]) + cmd = described_class.new(argv) + allow(cmd.args.named).to receive(:to_resolved_formulae).and_return(formulae) + cmd + end + + def stub_osv_hit(cve, fixed:) + allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([[{ "id" => cve }], []]) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with(cve).and_return( + { "id" => cve, "summary" => "s", + "affected" => [{ + "package" => { "ecosystem" => "GIT", "name" => "https://github.com/psf/requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => fixed }] }], + }] }, + ) + end + + it "writes matched records to --output= with merge_existing semantics" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + Dir.mktmpdir do |dir| + expect { cmd_for("requests", "--output", dir, "--no-history").run } + .to output(/1 records written/).to_stdout + + path = File.join(dir, "BREW-requests-CVE-2024-1234.json") + record = JSON.parse(File.read(path)) + expect(record.dig("affected", 0, "package")) + .to eq("ecosystem" => "Homebrew", "name" => "requests", "purl" => "pkg:brew/requests") + expect(record.dig("affected", 0, "ranges", 0, "events", 1)) + .to eq("fixed" => requests.pkg_version.to_s) + expect(record.dig("database_specific", "source")).to eq "matched" + expect(record.dig("database_specific", "strategy")).to eq "git" + + # A second run with the same output should report 0 written / 1 unchanged. + expect { cmd_for("requests", "--output", dir, "--no-history").run } + .to output(/0 records written to #{Regexp.escape(dir)} \(1 unchanged, 0 generated/).to_stdout + end + end + + it "drops :not_applicable hits instead of emitting them as open ranges" do + allow(Homebrew::Vulns::OSV).to receive(:query_batch).and_return([[{ "id" => "CVE-2024-1234" }], []]) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2024-1234").and_return( + { "id" => "CVE-2024-1234", "affected" => [{ + "package" => { "ecosystem" => "GIT", "name" => "https://github.com/psf/requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }], + }] }, + ) + + expect(JSON.parse(capture_stdout { cmd_for("requests", "--json", "--no-history").run })).to eq [] + end + + it "does not overwrite an existing source: generated record" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + Dir.mktmpdir do |dir| + path = File.join(dir, "BREW-requests-CVE-2024-1234.json") + File.write(path, JSON.generate({ "id" => "BREW-requests-CVE-2024-1234", + "database_specific" => { "source" => "generated" }, + "affected" => [{ "ecosystem_specific" => { "fix" => "patch" } }] })) + + expect { cmd_for("requests", "--output", dir, "--no-history").run } + .to output(/0 records written.*1 generated left as-is/).to_stdout + expect(JSON.parse(File.read(path)).dig("affected", 0, "ecosystem_specific", "fix")).to eq "patch" + end + end + + it "emits records as JSON with --json" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + records = JSON.parse(capture_stdout { cmd_for("requests", "--json", "--no-history").run }) + expect(records.length).to eq 1 + expect(records.first["id"]).to eq "BREW-requests-CVE-2024-1234" + end + + it "prints a per-hit summary in text mode" do + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + expect { cmd_for("requests", "--no-history").run } + .to output(/requests 2\.31\.0.*CVE-2024-1234 \[git, high\].*fixed \(upstream 2\.28\.1\).*1 candidate/m) + .to_stdout + end + + it "reports an OSV outage and finishes the emitter without raising" do + allow(Homebrew::Vulns::OSV).to receive(:query_batch) + .and_raise(Homebrew::Vulns::OSV::ApiError, "503") + + expect { cmd_for("requests", "--json").run } + .to output("[]\n").to_stdout.and output(/OSV query failed: 503/).to_stderr + expect(Homebrew.failed?).to be true + end + + it "iterates every core formula with --all and streams to --output" do + requests + core_tap = instance_double(CoreTap, installed?: true, name: "homebrew/core", + formula_names: ["requests", "broken"]) + allow(CoreTap).to receive(:instance).and_return(core_tap) + allow(Formulary).to receive(:factory).with("requests").and_return(requests) + allow(Formulary).to receive(:factory).with("broken").and_raise(RuntimeError, "boom") + stub_osv_hit("CVE-2024-1234", fixed: "2.28.1") + + Dir.mktmpdir do |dir| + expect { described_class.new(["--all", "--output", dir, "--no-history"]).run } + .to output(/1 records written/).to_stdout + .and output(/Error loading formula 'broken': boom/).to_stderr + expect(File).to exist(File.join(dir, "BREW-requests-CVE-2024-1234.json")) + end + end + + it "rejects --all with --json" do + expect { described_class.new(["--all", "--json"]) }.to raise_error(UsageError, /mutually exclusive/) + end + + it "emits the formula-identity index with --index" do + requests + core_tap = instance_double(CoreTap, installed?: true, name: "homebrew/core", formula_names: ["requests"]) + allow(CoreTap).to receive(:instance).and_return(core_tap) + allow(Formulary).to receive(:factory).with("requests").and_return(requests) + + output = capture_stdout { described_class.new(["--index"]).run } + index = JSON.parse(output) + expect(index.dig("requests", "git_repo")).to eq "https://github.com/psf/requests" + expect(index.dig("requests", "primary_package", "ecosystem")).to eq "PyPI" + end + + def capture_stdout + out = StringIO.new + old = $stdout + $stdout = out + yield + out.string + ensure + $stdout = old + end +end diff --git a/Library/Homebrew/test/support/fixtures/vulns/cpansa.json b/Library/Homebrew/test/support/fixtures/vulns/cpansa.json new file mode 100644 index 0000000000000..eab487e70ddc5 --- /dev/null +++ b/Library/Homebrew/test/support/fixtures/vulns/cpansa.json @@ -0,0 +1,56 @@ +{ + "meta": { + "repo": "https://github.com/briandfoy/cpan-security-advisory.git", + "commit": "abc123", + "epoch": 1784142497 + }, + "module2dist": { + "DBI": "DBI", + "Image::ExifTool": "Image-ExifTool" + }, + "dists": { + "DBI": { + "main_module": "DBI", + "versions": [{"version": "1.643", "date": "2020-01-31T18:02:00"}], + "advisories": [ + { + "id": "CPANSA-DBI-2020-01", + "cves": ["CVE-2020-14393"], + "affected_versions": ["<1.643"], + "fixed_versions": [">=1.643"], + "severity": "high", + "description": "Buffer overflow in DBI.xs.\n", + "references": ["https://metacpan.org/changes/distribution/DBI"], + "reported": "2020-09-16", + "distribution": "DBI" + }, + { + "id": "CPANSA-DBI-2014-01", + "cves": ["CVE-2014-10402", "CVE-2014-10401"], + "affected_versions": [">=0.64,<1.632"], + "fixed_versions": [">=1.632"], + "severity": "medium", + "distribution": "DBI" + } + ] + }, + "Image-ExifTool": { + "main_module": "Image::ExifTool", + "advisories": [ + { + "id": "CPANSA-Image-ExifTool-2021-22204", + "cves": ["CVE-2021-22204"], + "affected_versions": [">=7.44,<12.24"], + "fixed_versions": [">=12.24"], + "severity": "critical", + "description": "Improper neutralization in DjVu.\n", + "references": [ + "https://github.com/exiftool/exiftool/commit/cf0f4e7dcd024ca99615bfd1102a841a25dde031" + ], + "reported": "2021-04-23", + "distribution": "Image-ExifTool" + } + ] + } + } +} diff --git a/Library/Homebrew/test/support/fixtures/vulns/repology.json b/Library/Homebrew/test/support/fixtures/vulns/repology.json new file mode 100644 index 0000000000000..5b79f0f37e117 --- /dev/null +++ b/Library/Homebrew/test/support/fixtures/vulns/repology.json @@ -0,0 +1,30 @@ +{ + "meta": { + "source": "https://repology.org/api/v1", + "osv_distros": ["AlmaLinux", "Alpine", "Debian", "FreeBSD", "Mageia", "Red Hat", "Rocky Linux", "Ubuntu", "openEuler", "openSUSE"], + "ambiguous_projects": { + "antlr": ["antlr", "antlr4-cpp-runtime"] + }, + "colliding_formulae": {} + }, + "formulae": { + "curl": { + "Alpine": ["curl"], + "Debian": ["curl"], + "FreeBSD": ["curl"], + "Ubuntu": ["curl"], + "openSUSE": ["curl"] + }, + "libgee": { + "Alpine": ["libgee"], + "Debian": ["libgee-0.8"] + }, + "ack": { + "Ubuntu": ["ack", "ack-grep"] + }, + "postgresql": { + "Debian": ["postgresql-17"], + "Alpine": ["postgresql17"] + } + } +} diff --git a/Library/Homebrew/test/utils/repology_spec.rb b/Library/Homebrew/test/utils/repology_spec.rb index 97c22e8864f0a..df0de5a65dce2 100644 --- a/Library/Homebrew/test/utils/repology_spec.rb +++ b/Library/Homebrew/test/utils/repology_spec.rb @@ -2,3 +2,54 @@ # frozen_string_literal: true require "utils/repology" + +RSpec.describe Repology do + before do + allow(Utils::Curl).to receive(:curl_supports_tls13?).and_return(true) + allow(Homebrew::EnvConfig).to receive(:developer?).and_return(false) + end + + describe ".single_package_query" do + sig { + params(success: T::Boolean, stdout: String, stderr: String, exit_status: Integer) + .returns(T.untyped) + } + def stub_curl(success:, stdout: "", stderr: "", exit_status: 0) + instance_double(SystemCommand::Result, success?: success, stdout:, stderr:, exit_status:) + end + + it "URL-encodes the project name and passes --fail" do + expect(Utils::Curl).to receive(:curl_output) do |*args, **| + expect(args).to include("--fail", "#{Repology::API_BASE}/project/gtk%2B3") + stub_curl(success: true, stdout: "[]") + end + expect(described_class.single_package_query("gtk+3", repository: Repology::HOMEBREW_CORE)) + .to eq({ "gtk+3" => [] }) + end + + it "returns nil (rather than raising) on HTTP failure" do + allow(Utils::Curl).to receive(:curl_output).and_return( + stub_curl(success: false, exit_status: 22, stderr: "The requested URL returned error: 503"), + ) + expect(described_class.single_package_query("curl", repository: Repology::HOMEBREW_CORE)) + .to be_nil + end + + it "returns nil on invalid JSON" do + allow(Utils::Curl).to receive(:curl_output).and_return(stub_curl(success: true, stdout: "not json")) + expect(described_class.single_package_query("curl", repository: Repology::HOMEBREW_CORE)) + .to be_nil + end + end + + describe ".query_api" do + it "URL-encodes the pagination cursor" do + expect(Utils::Curl).to receive(:curl_output) do |*args, **| + expect(args.last).to eq "#{Repology::API_BASE}/projects/gtk%2B3/" \ + "?inrepo=#{Repology::HOMEBREW_CORE}&outdated=1" + instance_double(SystemCommand::Result, stdout: "{}") + end + described_class.query_api("gtk+3", repository: Repology::HOMEBREW_CORE) + end + end +end diff --git a/Library/Homebrew/test/vulns/cpan_sec_spec.rb b/Library/Homebrew/test/vulns/cpan_sec_spec.rb new file mode 100644 index 0000000000000..39f1ab03b6694 --- /dev/null +++ b/Library/Homebrew/test/vulns/cpan_sec_spec.rb @@ -0,0 +1,215 @@ +# typed: true +# frozen_string_literal: true + +require "vulns/cpan_sec" + +RSpec.describe Homebrew::Vulns::CPANSec do + let(:fixture) { TEST_FIXTURE_DIR/"vulns/cpansa.json" } + let(:cpansa) { described_class.from_file(fixture) } + + describe ".from_file" do + it "raises Error on unparseable JSON" do + Dir.mktmpdir do |dir| + bad = Pathname(dir)/"cpansa.json" + bad.write "not json" + expect { described_class.from_file(bad) } + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /Failed to parse cpansa\.json/) + end + end + end + + describe "#initialize" do + it "raises Error when the dists key is missing" do + expect { described_class.new({ "meta" => {} }) } + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /missing 'dists' key/) + end + + it "raises Error when the top-level value is not a JSON object" do + expect { described_class.new([]) }.to raise_error(Homebrew::Vulns::CachedFeed::Error, /not a JSON object/) + expect { described_class.new(nil) }.to raise_error(Homebrew::Vulns::CachedFeed::Error, /not a JSON object/) + end + + it "treats a null or absent meta as an empty hash" do + expect(described_class.new({ "dists" => {}, "meta" => nil }).meta).to eq({}) + expect(described_class.new({ "dists" => {} }).meta).to eq({}) + end + end + + describe "#meta" do + it "returns the upstream build metadata" do + expect(cpansa.meta).to include("commit" => "abc123", "epoch" => 1784142497) + end + end + + describe "#distributions" do + it "lists all distribution names" do + expect(cpansa.distributions).to contain_exactly("DBI", "Image-ExifTool") + end + end + + describe "#advisories_for" do + it "returns Advisory structs with all fields populated" do + first = cpansa.advisories_for("DBI").first + expect(first).to have_attributes( + id: "CPANSA-DBI-2020-01", + cves: ["CVE-2020-14393"], + affected_versions: ["<1.643"], + fixed_versions: [">=1.643"], + severity: "high", + description: "Buffer overflow in DBI.xs.\n", + references: ["https://metacpan.org/changes/distribution/DBI"], + reported: "2020-09-16", + ) + end + + it "coerces cves and affected_versions to string arrays and defaults absent optional fields" do + second = cpansa.advisories_for("DBI")[1] + expect(second.id).to eq "CPANSA-DBI-2014-01" + expect(second.cves).to eq ["CVE-2014-10402", "CVE-2014-10401"] + expect(second.affected_versions).to eq [">=0.64,<1.632"] + expect(second.description).to be_nil + expect(second.references).to eq [] + end + + it "returns all advisories for a distribution in file order" do + expect(cpansa.advisories_for("DBI").map(&:id)) + .to eq ["CPANSA-DBI-2020-01", "CPANSA-DBI-2014-01"] + end + + it "returns an empty array for an unknown distribution" do + expect(cpansa.advisories_for("No-Such-Dist")).to eq [] + end + + it "returns frozen advisories" do + expect(cpansa.advisories_for("Image-ExifTool").first).to be_frozen + end + end + + describe ".range_status" do + def adv(affected:, fixed:) + Homebrew::Vulns::CPANSec::Advisory.new(id: "CPANSA-X", cves: [], affected_versions: affected, + fixed_versions: fixed) + end + + it "reports affected with fixed_in when the version is inside a single-bound constraint" do + status = described_class.range_status(adv(affected: ["<12.24"], fixed: [">=12.24"]), "12.00") + expect(status).to have_attributes(affected?: true, fixed_in: "12.24") + end + + it "reports not-affected with fixed_in when the version is at or past the fix" do + status = described_class.range_status(adv(affected: ["<12.24"], fixed: [">=12.24"]), "13.55") + expect(status).to have_attributes(affected?: false, fixed_in: "12.24") + end + + it "evaluates comma-joined AND terms" do + status = described_class.range_status(adv(affected: [">=0.64,<1.632"], fixed: [">=1.632"]), "1.5") + expect(status.affected?).to be true + expect(described_class.range_status(adv(affected: [">=0.64,<1.632"], fixed: []), "0.5").affected?) + .to be false + end + + it "treats multiple array entries as OR" do + a = adv(affected: ["<1.0", ">=2.0,<2.5"], fixed: [">=1.0,<2.0", ">=2.5"]) + expect(described_class.range_status(a, "0.9").affected?).to be true + expect(described_class.range_status(a, "2.1")).to have_attributes(affected?: true, fixed_in: "2.5") + expect(described_class.range_status(a, "1.5").affected?).to be false + end + + it "treats a bare version term as equality and an empty affected_versions as always affected" do + expect(described_class.range_status(adv(affected: ["1.0"], fixed: []), "1.0").affected?).to be true + expect(described_class.range_status(adv(affected: ["1.0"], fixed: []), "1.1").affected?).to be false + expect(described_class.range_status(adv(affected: [], fixed: []), "1.0").affected?).to be true + end + + it "does not report a version in the gap between affected and a strict >fix as :fixed" do + status = described_class.range_status(adv(affected: ["<1.0"], fixed: [">1.0"]), "1.0") + expect(status.state).to eq :not_applicable + expect(described_class.range_status(adv(affected: ["<1.0"], fixed: [">1.0"]), "1.1").state).to eq :fixed + end + + it "reports affected with no fixed_in when there is no fixed_versions" do + expect(described_class.range_status(adv(affected: ["<12.24"], fixed: []), "12.00")) + .to have_attributes(affected?: true, fixed_in: nil) + end + end + + describe ".load" do + it "reads a fresh cache file without downloading" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + FileUtils.cp fixture, cache/"cpansa.json" + expect(Utils::Curl).not_to receive(:curl_download) + loaded = described_class.load(cache:) + expect(loaded.distributions).to include "DBI" + end + end + + it "downloads to a temp file and atomically replaces a stale cache" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"cpansa.json" + stale.write '{"dists": {}}' + FileUtils.touch stale, mtime: Time.now - 100_000 + expect(Utils::Curl).to receive(:curl_download) do |*_args, to:| + expect(to).not_to eq stale + FileUtils.cp fixture, to + end + expect(described_class.load(cache:).distributions).to include "DBI" + expect(stale.read).to eq fixture.read + expect(cache.children.map { |c| c.basename.to_s }).to eq ["cpansa.json"] + end + end + + it "downloads when the cache file is absent" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + expect(Utils::Curl).to receive(:curl_download) do |*_args, to:| + FileUtils.cp fixture, to + end + expect(described_class.load(cache:).advisories_for("Image-ExifTool").length).to eq 1 + end + end + + it "falls back to a stale cache when the download fails, leaving it intact" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"cpansa.json" + FileUtils.cp fixture, stale + FileUtils.touch stale, mtime: Time.now - 100_000 + original = stale.read + expect(Utils::Curl).to receive(:curl_download) + .and_raise(ErrorDuringExecution.new(["curl"], status: 22)) + loaded = T.let(nil, T.nilable(Homebrew::Vulns::CPANSec)) + expect { loaded = described_class.load(cache:) } + .to output(/Failed to refresh cpansa\.json/).to_stderr + expect(loaded&.distributions).to include "DBI" + expect(stale.read).to eq original + end + end + + it "falls back to a stale cache when the fetched file is invalid, leaving it intact" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"cpansa.json" + FileUtils.cp fixture, stale + FileUtils.touch stale, mtime: Time.now - 100_000 + original = stale.read + expect(Utils::Curl).to receive(:curl_download) { |*_args, to:| to.write "not json" } + loaded = T.let(nil, T.nilable(Homebrew::Vulns::CPANSec)) + expect { loaded = described_class.load(cache:) } + .to output(/Failed to refresh cpansa\.json/).to_stderr + expect(loaded&.distributions).to include "DBI" + expect(stale.read).to eq original + expect(cache.children).to eq [stale] + end + end + + it "raises when the download fails and no cache exists" do + Dir.mktmpdir do |dir| + expect(Utils::Curl).to receive(:curl_download) + .and_raise(ErrorDuringExecution.new(["curl"], status: 6)) + expect { described_class.load(cache: Pathname(dir)) }.to raise_error(ErrorDuringExecution) + end + end + end +end diff --git a/Library/Homebrew/test/vulns/identify_spec.rb b/Library/Homebrew/test/vulns/identify_spec.rb new file mode 100644 index 0000000000000..cb4d92f8096e7 --- /dev/null +++ b/Library/Homebrew/test/vulns/identify_spec.rb @@ -0,0 +1,462 @@ +# typed: strict +# frozen_string_literal: true + +require "vulns/identify" + +RSpec.describe Homebrew::Vulns::Identify do + describe ".repo_url" do + it "extracts a GitHub repo from an archive/refs/tags URL" do + url = "https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz" + expect(described_class.repo_url(url)).to eq "https://github.com/nektos/act" + end + + it "extracts a GitHub repo from a releases/download URL" do + url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" + expect(described_class.repo_url(url)).to eq "https://github.com/owner/repo" + end + + it "extracts a GitHub repo from a .git URL, lowercasing the path (OSV normalises github.com)" do + expect(described_class.repo_url("https://github.com/AomediaOrg/aom.git")) + .to eq "https://github.com/aomediaorg/aom" + expect(described_class.repo_url("https://github.com/FFmpeg/FFmpeg.git")) + .to eq "https://github.com/ffmpeg/ffmpeg" + end + + it "preserves path case for GitLab (case-sensitive host)" do + expect(described_class.repo_url("https://gitlab.gnome.org/GNOME/glib.git")) + .to eq "https://gitlab.gnome.org/GNOME/glib" + end + + it "extracts a GitLab repo, stripping the /-/ path segment" do + url = "https://gitlab.com/owner/repo/-/archive/v1.2.3/repo-v1.2.3.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.com/owner/repo" + end + + it "extracts a Codeberg repo" do + url = "https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz" + expect(described_class.repo_url(url)).to eq "https://codeberg.org/owner/repo" + end + + it "extracts a gitlab.gnome.org repo from an archive URL" do + url = "https://gitlab.gnome.org/Archive/pangox-compat/-/archive/0.0.2/pangox-compat-0.0.2.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.gnome.org/Archive/pangox-compat" + end + + it "extracts a gitlab.freedesktop.org repo with a nested subgroup path" do + url = "https://gitlab.freedesktop.org/xorg/lib/libx11/-/archive/libX11-1.8.7/" \ + "libx11-libX11-1.8.7.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.freedesktop.org/xorg/lib/libx11" + end + + it "extracts a gitlab.freedesktop.org repo from a bare .git URL" do + expect(described_class.repo_url("https://gitlab.freedesktop.org/cairo/cairo.git")) + .to eq "https://gitlab.freedesktop.org/cairo/cairo" + end + + it "extracts an invent.kde.org repo" do + expect(described_class.repo_url("https://invent.kde.org/frameworks/karchive.git")) + .to eq "https://invent.kde.org/frameworks/karchive" + end + + it "extracts a gitlab.com repo with a nested subgroup path" do + url = "https://gitlab.com/gitlab-org/security/gitlab/-/archive/v16.0.0/gitlab-v16.0.0.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.com/gitlab-org/security/gitlab" + end + + it "extracts a GitLab repo from a legacy /uploads/ URL" do + url = "https://gitlab.com/akkuscm/akku/uploads/9a82f6a11e35c67f0e0086/akku-1.1.0.tar.gz" + expect(described_class.repo_url(url)).to eq "https://gitlab.com/akkuscm/akku" + end + + it "extracts a GitLab repo from a /wikis/ URL" do + expect(described_class.repo_url("https://gitlab.gnome.org/GNOME/gjs/wikis/Home")) + .to eq "https://gitlab.gnome.org/GNOME/gjs" + end + + it "extracts a GitLab repo from a URL with a trailing slash" do + expect(described_class.repo_url("https://gitlab.com/gsasl/libntlm/")) + .to eq "https://gitlab.com/gsasl/libntlm" + end + + it "rejects a GitLab host-level /-/ route and falls back to a later URL" do + stable = "https://gitlab.freedesktop.org/-/project/62/uploads/54a0f9/spice-0.16.0.tar.bz2" + head = "https://gitlab.freedesktop.org/spice/spice.git" + expect(described_class.repo_url(stable, head)).to eq "https://gitlab.freedesktop.org/spice/spice" + end + + it "returns nil for a GitLab /api/ route" do + expect(described_class.repo_url("https://gitlab.freedesktop.org/api/v4/projects/1205/releases")) + .to be_nil + end + + it "unwraps a Wayback Machine snapshot URL" do + url = "https://web.archive.org/web/20180102081127/https://github.com/satori-com/tcpkali" + expect(described_class.repo_url(url)).to eq "https://github.com/satori-com/tcpkali" + end + + it "falls back to the head URL when the stable URL is not a supported forge" do + stable = "https://aomedia.googlesource.com/aom.git" + head = "https://github.com/AomediaOrg/aom.git" + expect(described_class.repo_url(stable, head)).to eq "https://github.com/aomediaorg/aom" + end + + it "falls back to the homepage when neither stable nor head is a supported forge" do + stable = "https://libssh2.org/download/libssh2-1.11.0.tar.gz" + homepage = "https://github.com/libssh2/libssh2" + expect(described_class.repo_url(stable, nil, homepage)).to eq "https://github.com/libssh2/libssh2" + end + + it "returns nil for unsupported hosts" do + expect(described_class.repo_url("https://example.com/source.tar.gz")).to be_nil + end + + it "returns nil for nil input" do + expect(described_class.repo_url(nil)).to be_nil + expect(described_class.repo_url(nil, nil)).to be_nil + end + end + + describe ".tag" do + it "extracts from archive/refs/tags .tar.gz" do + expect(described_class.tag("https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz")) + .to eq "v0.2.84" + end + + it "extracts a tag without a v prefix" do + url = "https://github.com/abseil/abseil-cpp/archive/refs/tags/20250814.1.tar.gz" + expect(described_class.tag(url)).to eq "20250814.1" + end + + it "extracts from archive/refs/tags .zip" do + expect(described_class.tag("https://github.com/owner/repo/archive/refs/tags/v1.0.0.zip")) + .to eq "v1.0.0" + end + + it "extracts from archive/.tar.gz" do + expect(described_class.tag("https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz")) + .to eq "v1.2.3" + end + + it "extracts from releases/download//" do + url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" + expect(described_class.tag(url)).to eq "v1.2.3" + end + + it "extracts from tarball/" do + expect(described_class.tag("https://github.com/owner/repo/tarball/v1.2.3")).to eq "v1.2.3" + end + + it "returns nil when no tag pattern matches" do + expect(described_class.tag("https://example.com/source.tar.gz")).to be_nil + expect(described_class.tag(nil)).to be_nil + end + end + + describe ".registry_package" do + sig { params(url: T.nilable(String)).returns(T.nilable(T::Hash[Symbol, T.untyped])) } + def result(url) + described_class.registry_package(url)&.to_h + end + + context "with a PyPI sdist URL" do + it "parses a simple package" do + url = "https://files.pythonhosted.org/packages/00/2a/e8/jmespath-1.0.1.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "jmespath", version: "1.0.1", + purl: "pkg:pypi/jmespath@1.0.1") + end + + it "normalises an underscored name" do + url = "https://files.pythonhosted.org/packages/00/07/d1/types_setuptools-80.9.0.20251223.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "types-setuptools", + version: "80.9.0.20251223", + purl: "pkg:pypi/types-setuptools@80.9.0.20251223") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://files.pythonhosted.org/packages/aa/bb/cc/iso-639-2025.2.18.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "iso-639", version: "2025.2.18", + purl: "pkg:pypi/iso-639@2025.2.18") + end + + it "PEP 503-normalises a dotted name for OSV while preserving the dot in the purl" do + url = "https://files.pythonhosted.org/packages/aa/bb/cc/ruamel.yaml-0.18.6.tar.gz" + expect(result(url)).to eq(ecosystem: "PyPI", name: "ruamel-yaml", version: "0.18.6", + purl: "pkg:pypi/ruamel.yaml@0.18.6") + end + + it "returns nil for a wheel" do + url = "https://files.pythonhosted.org/packages/aa/bb/cc/foo-1.0-py3-none-any.whl" + expect(result(url)).to be_nil + end + end + + context "with an npm tarball URL" do + it "parses a scoped package" do + url = "https://registry.npmjs.org/@angular/cli/-/cli-22.0.3.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "@angular/cli", version: "22.0.3", + purl: "pkg:npm/%40angular/cli@22.0.3") + end + + it "parses an unscoped package" do + url = "https://registry.npmjs.org/reveal-md/-/reveal-md-6.1.4.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "reveal-md", version: "6.1.4", + purl: "pkg:npm/reveal-md@6.1.4") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.7.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "es5-shim", version: "4.6.7", + purl: "pkg:npm/es5-shim@4.6.7") + end + + it "handles a semver prerelease version" do + url = "https://registry.npmjs.org/react/-/react-19.0.0-rc.1.tgz" + expect(result(url)) + .to eq(ecosystem: "npm", name: "react", version: "19.0.0-rc.1", + purl: "pkg:npm/react@19.0.0-rc.1") + end + + it "decodes a percent-encoded scope" do + url = "https://registry.npmjs.org/%40angular/cli/-/cli-22.0.3.tgz" + expect(result(url)).to eq(ecosystem: "npm", name: "@angular/cli", version: "22.0.3", + purl: "pkg:npm/%40angular/cli@22.0.3") + end + + it "decodes multi-byte percent escapes without an encoding error" do + expect(described_class.decode("caf%C3%A9")).to eq "café" + expect(described_class.decode("%80").bytes).to eq [0x80] + end + + it "returns nil when the tarball filename does not match the path name" do + expect(result("https://registry.npmjs.org/foo/-/bar-1.0.0.tgz")).to be_nil + end + end + + context "with a crates.io URL" do + it "parses the crate name from the path and version from the filename" do + url = "https://static.crates.io/crates/cargo-llvm-cov/cargo-llvm-cov-0.8.7.crate" + expect(result(url)).to eq(ecosystem: "crates.io", name: "cargo-llvm-cov", version: "0.8.7", + purl: "pkg:cargo/cargo-llvm-cov@0.8.7") + end + + it "returns nil when the filename does not match the path name" do + expect(result("https://static.crates.io/crates/foo/bar-1.0.0.crate")).to be_nil + end + end + + context "with a RubyGems URL" do + it "parses a /downloads/ URL" do + url = "https://rubygems.org/downloads/activesupport-8.1.1.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "activesupport", version: "8.1.1", + purl: "pkg:gem/activesupport@8.1.1") + end + + it "parses a /gems/ URL" do + url = "https://rubygems.org/gems/addressable-2.8.6.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "addressable", version: "2.8.6", + purl: "pkg:gem/addressable@2.8.6") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://rubygems.org/downloads/iso-639-0.3.6.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "iso-639", version: "0.3.6", + purl: "pkg:gem/iso-639@0.3.6") + end + + it "strips a trailing platform suffix" do + url = "https://rubygems.org/downloads/nokogiri-1.16.0-arm64-darwin.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "nokogiri", version: "1.16.0", + purl: "pkg:gem/nokogiri@1.16.0") + end + + it "strips a platform suffix ending in a numeric OS version" do + url = "https://rubygems.org/downloads/couchbase-3.5.1-arm64-darwin-22.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "couchbase", version: "3.5.1", + purl: "pkg:gem/couchbase@3.5.1") + end + + it "strips a bare-word platform suffix" do + url = "https://rubygems.org/downloads/jrubyfx-2.0.0-java.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "jrubyfx", version: "2.0.0", + purl: "pkg:gem/jrubyfx@2.0.0") + end + + it "strips a musl platform suffix" do + url = "https://rubygems.org/downloads/ffi-1.17.4-x86_64-linux-musl.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "ffi", version: "1.17.4", + purl: "pkg:gem/ffi@1.17.4") + end + + it "strips a mingw-ucrt platform suffix" do + url = "https://rubygems.org/downloads/ruby-prof-2.0.4-x64-mingw-ucrt.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "ruby-prof", version: "2.0.4", + purl: "pkg:gem/ruby-prof@2.0.4") + end + + it "strips a platform suffix with an unenumerated CPU" do + url = "https://rubygems.org/downloads/sass-embedded-1.97.2-riscv64-linux-gnu.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "sass-embedded", version: "1.97.2", + purl: "pkg:gem/sass-embedded@1.97.2") + end + + it "strips a platform suffix with a dotted OS version" do + url = "https://rubygems.org/downloads/concurrent-ruby-0.7.1-x86-solaris-2.11.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "concurrent-ruby", version: "0.7.1", + purl: "pkg:gem/concurrent-ruby@0.7.1") + end + + it "keeps a prerelease segment in the version" do + url = "https://rubygems.org/downloads/rails-8.0.0.beta1.gem" + expect(result(url)).to eq(ecosystem: "RubyGems", name: "rails", version: "8.0.0.beta1", + purl: "pkg:gem/rails@8.0.0.beta1") + end + end + + context "with a Hackage URL" do + it "parses a package identifier from the path" do + url = "https://hackage.haskell.org/package/Allure-0.11.0.0/Allure-0.11.0.0.tar.gz" + expect(result(url)).to eq(ecosystem: "Hackage", name: "Allure", version: "0.11.0.0", + purl: "pkg:hackage/Allure@0.11.0.0") + end + + it "handles a name containing a hyphen followed by digits" do + url = "https://hackage.haskell.org/package/base64-bytestring-1.2.1.0/" \ + "base64-bytestring-1.2.1.0.tar.gz" + expect(result(url)).to eq(ecosystem: "Hackage", name: "base64-bytestring", + version: "1.2.1.0", + purl: "pkg:hackage/base64-bytestring@1.2.1.0") + end + end + + context "with a Hex URL" do + it "parses name and version, keeping a semver prerelease" do + url = "https://repo.hex.pm/tarballs/phoenix-1.7.0-rc.0.tar" + expect(result(url)).to eq(ecosystem: "Hex", name: "phoenix", version: "1.7.0-rc.0", + purl: "pkg:hex/phoenix@1.7.0-rc.0") + end + end + + context "with a CPAN URL" do + it "uses the distribution alone as the CPANSA name and includes the author in the purl" do + url = "https://cpan.metacpan.org/authors/id/A/AB/ABIGAIL/Regexp-Common-2024080801.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Regexp-Common", + version: "2024080801", + purl: "pkg:cpan/ABIGAIL/Regexp-Common@2024080801") + end + + it "handles a distribution name containing a digit-led segment" do + url = "https://cpan.metacpan.org/authors/id/C/CF/CFRANKS/Perl6-Junction-1.60000.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Perl6-Junction", + version: "1.60000", + purl: "pkg:cpan/CFRANKS/Perl6-Junction@1.60000") + end + + it "handles a v-prefixed version" do + url = "https://cpan.metacpan.org/authors/id/L/LE/LEONT/ExtUtils-HasCompiler-v0.25.0.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "ExtUtils-HasCompiler", + version: "v0.25.0", + purl: "pkg:cpan/LEONT/ExtUtils-HasCompiler@v0.25.0") + end + + it "handles a subdirectory below the author directory" do + url = "https://cpan.metacpan.org/authors/id/A/AM/AMBS/BibTeX/Text-BibTeX-0.91.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Text-BibTeX", version: "0.91", + purl: "pkg:cpan/AMBS/Text-BibTeX@0.91") + end + + it "keeps a developer _NN suffix in the version" do + url = "https://cpan.metacpan.org/authors/id/E/ET/ETHER/Moose-2.2207_01.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Moose", version: "2.2207_01", + purl: "pkg:cpan/ETHER/Moose@2.2207_01") + end + + it "strips a -TRIAL suffix from the version" do + url = "https://cpan.metacpan.org/authors/id/E/ET/ETHER/Moose-2.2200-TRIAL.tar.gz" + expect(result(url)).to eq(ecosystem: "CPAN", name: "Moose", version: "2.2200", + purl: "pkg:cpan/ETHER/Moose@2.2200") + end + end + + context "with a Maven URL" do + it "parses groupId, artifactId and version from repo.maven.apache.org" do + url = "https://repo.maven.apache.org/maven2/com/github/spotbugs/spotbugs/4.10.2/" \ + "spotbugs-4.10.2.tgz" + expect(result(url)).to eq(ecosystem: "Maven", name: "com.github.spotbugs:spotbugs", + version: "4.10.2", + purl: "pkg:maven/com.github.spotbugs/spotbugs@4.10.2") + end + + it "parses a search.maven.org remotecontent URL" do + url = "https://search.maven.org/remotecontent?filepath=org/gradle/profiler/" \ + "gradle-profiler/0.24.0/gradle-profiler-0.24.0.zip" + expect(result(url)).to eq(ecosystem: "Maven", name: "org.gradle.profiler:gradle-profiler", + version: "0.24.0", + purl: "pkg:maven/org.gradle.profiler/gradle-profiler@0.24.0") + end + + it "returns nil for a maven-metadata.xml URL" do + url = "https://repo.maven.apache.org/maven2/com/madgag/bfg/maven-metadata.xml" + expect(result(url)).to be_nil + end + + it "returns nil for a third-party Maven repository (Central-only by design)" do + url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.1/" \ + "fabric-installer-1.1.1.jar" + expect(result(url)).to be_nil + end + + it "returns nil for a non-Central host with a /maven2/ path" do + url = "https://dl.google.com/dl/android/maven2/com/android/tools/build/gradle/" \ + "8.0.0/gradle-8.0.0.jar" + expect(result(url)).to be_nil + end + end + + context "with a CRAN URL" do + it "parses name and version from a src/contrib URL" do + url = "https://cran.r-project.org/src/contrib/data.table_1.15.4.tar.gz" + expect(result(url)).to eq(ecosystem: "CRAN", name: "data.table", version: "1.15.4", + purl: "pkg:cran/data.table@1.15.4") + end + + it "parses an Archive/ URL" do + url = "https://cran.r-project.org/src/contrib/Archive/rlang/rlang_1.1.3.tar.gz" + expect(result(url)).to eq(ecosystem: "CRAN", name: "rlang", version: "1.1.3", + purl: "pkg:cran/rlang@1.1.3") + end + + it "parses a cloud.r-project.org URL" do + url = "https://cloud.r-project.org/src/contrib/IRkernel_1.3.2.tar.gz" + expect(result(url)).to eq(ecosystem: "CRAN", name: "IRkernel", version: "1.3.2", + purl: "pkg:cran/IRkernel@1.3.2") + end + end + + context "with a NuGet URL" do + it "parses a v3 flatcontainer URL" do + url = "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/13.0.3/" \ + "newtonsoft.json.13.0.3.nupkg" + expect(result(url)).to eq(ecosystem: "NuGet", name: "newtonsoft.json", version: "13.0.3", + purl: "pkg:nuget/newtonsoft.json@13.0.3") + end + + it "parses a v2 API URL" do + url = "https://www.nuget.org/api/v2/package/Newtonsoft.Json/13.0.3" + expect(result(url)).to eq(ecosystem: "NuGet", name: "Newtonsoft.Json", version: "13.0.3", + purl: "pkg:nuget/Newtonsoft.Json@13.0.3") + end + end + + it "returns nil for a non-registry URL" do + expect(result("https://example.com/foo-1.0.tar.gz")).to be_nil + end + + it "returns nil for a supported forge URL" do + expect(result("https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz")).to be_nil + end + + it "returns nil for nil input" do + expect(result(nil)).to be_nil + end + end +end diff --git a/Library/Homebrew/test/vulns/match_spec.rb b/Library/Homebrew/test/vulns/match_spec.rb new file mode 100644 index 0000000000000..c7e79fb6b7844 --- /dev/null +++ b/Library/Homebrew/test/vulns/match_spec.rb @@ -0,0 +1,800 @@ +# typed: true +# frozen_string_literal: true + +require "vulns/match" + +RSpec.describe Homebrew::Vulns::Match do + let(:repology) do + Homebrew::Vulns::Repology.new({ "meta" => {}, "formulae" => { + "requests" => { "Debian" => ["requests"], "Alpine" => ["py3-requests"] }, + } }) + end + let(:cpan_sec) do + Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => { + "Image-ExifTool" => { "advisories" => [ + { "id" => "CPANSA-Image-ExifTool-2021-22204", "cves" => ["CVE-2021-22204"], + "affected_versions" => ["<12.24"], "fixed_versions" => [">=12.24"] }, + ] }, + } }) + end + let(:matcher) { described_class.new(repology:, cpan_sec:) } + + def stub_repology_lookup(result = {}) + allow(Homebrew::Vulns::Repology).to receive(:lookup).and_return(result) + end + + def vuln(data) + Homebrew::Vulns::Vulnerability.new(data) + end + + def ev(strategy, ecosystem: nil, name: nil, subject_version: nil, key: "k", resource: nil, advisory: nil) + Homebrew::Vulns::Match::Evidence.new(strategy:, ecosystem:, name:, subject_version:, key:, + resource:, advisory:) + end + + def make_hit(vulnerability, *evidence) + Homebrew::Vulns::Match::Hit.new(vulnerability:, evidence:) + end + + describe "#identify" do + it "derives git repo/tag, primary registry package, resources and distro packages" do + f = formula("requests") do + T.bind(self, T.class_of(Formula)) + homepage "https://requests.readthedocs.io" + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + head "https://github.com/psf/requests.git" + resource "certifi" do + url "https://files.pythonhosted.org/packages/11/22/33/certifi-2024.2.2.tar.gz" + end + resource "vendored-c" do + url "https://example.com/blob-1.0.tar.gz" + end + end + + identity = matcher.identify(f) + + expect(identity.git_repo).to eq "https://github.com/psf/requests" + expect(identity.git_tag).to eq "2.31.0" + expect(identity.primary_package.ecosystem).to eq "PyPI" + expect(identity.primary_package.name).to eq "requests" + expect(identity.resource_packages.keys).to eq ["certifi"] + expect(identity.resource_packages["certifi"].purl).to eq "pkg:pypi/certifi@2024.2.2" + expect(identity.distro_packages) + .to eq("Debian" => ["requests"], "Alpine" => ["py3-requests"]) + expect(identity.identifiable?).to be true + end + + it "falls back to Repology.lookup when the index has no entry (single-formula mode)" do + f = formula("newthing") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/newthing-1.0.tar.gz" + end + stub_repology_lookup({ "Debian" => ["newthing"] }) + + expect(matcher.identify(f).distro_packages).to eq("Debian" => ["newthing"]) + end + + it "does not fall back to Repology.lookup in bulk mode" do + f = formula("newthing") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/newthing-1.0.tar.gz" + end + expect(Homebrew::Vulns::Repology).not_to receive(:lookup) + + bulk = described_class.new(repology:, cpan_sec:, bulk: true) + expect(bulk.identify(f).distro_packages).to eq({}) + end + + it "swallows a Repology lookup error to an empty distro map" do + f = formula("newthing") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/newthing-1.0.tar.gz" + end + allow(Homebrew::Vulns::Repology).to receive(:lookup) + .and_raise(Homebrew::Vulns::CachedFeed::Error, "boom") + + expect(matcher.identify(f).distro_packages).to eq({}) + end + + it "reports identifiable? false when nothing is derivable" do + f = formula("mystery") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/mystery-1.0.tar.gz" + end + stub_repology_lookup + + expect(matcher.identify(f).identifiable?).to be false + end + end + + describe "#build_osv_queries" do + def pkg(ecosystem:, name:, version:, purl:) + Homebrew::Vulns::Identify::RegistryPackage.new(ecosystem:, name:, version:, purl:) + end + + it "emits versionless GIT/registry/distro queries with subject_version carried on the evidence" do + identity = Homebrew::Vulns::Match::Identity.new( + git_repo: "https://github.com/psf/requests", + git_tag: "v2.31.0", + primary_package: pkg(ecosystem: "PyPI", name: "requests", version: "2.31.0", + purl: "pkg:pypi/requests@2.31.0"), + resource_packages: { "certifi" => pkg(ecosystem: "PyPI", name: "certifi", version: "2024.2.2", + purl: "pkg:pypi/certifi@2024.2.2") }, + distro_packages: { "Debian" => ["requests"] }, + ) + + queries = matcher.build_osv_queries(identity, "2.31.0") + + expect(queries.map(&:first)).to eq [ + { ecosystem: "GIT", name: "https://github.com/psf/requests", version: nil }, + { ecosystem: "PyPI", name: "requests", version: nil }, + { ecosystem: "PyPI", name: "certifi", version: nil }, + { ecosystem: "Debian", name: "requests", version: nil }, + ] + expect(queries.map { |_, e| [e.strategy, e.ecosystem, e.name, e.subject_version, e.resource] }).to eq [ + [:git, "GIT", "https://github.com/psf/requests", "v2.31.0", nil], + [:registry, "PyPI", "requests", "2.31.0", nil], + [:registry, "PyPI", "certifi", "2024.2.2", "certifi"], + [:distro, "Debian", "requests", nil, nil], + ] + end + + it "excludes CPAN packages from OSV queries and omits GIT when no repo derived" do + identity = Homebrew::Vulns::Match::Identity.new( + git_repo: nil, + git_tag: "13.55", + primary_package: pkg(ecosystem: "CPAN", name: "Image-ExifTool", version: "13.55", + purl: "pkg:cpan/EXIFTOOL/Image-ExifTool@13.55"), + resource_packages: {}, distro_packages: {} + ) + + expect(matcher.build_osv_queries(identity, "13.55")).to eq [] + end + end + + describe "#range_status" do + it "returns the registry-entry status when GIT ranges are uncomparable" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] }, + ]) + hit = make_hit(v, + ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq", + subject_version: "1.8.1"), + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")) + + status, evidence = matcher.range_status(hit) + expect(status).to have_attributes(state: :fixed, fixed_in: "2.28.1") + expect(evidence.strategy).to eq :registry + end + + it "returns nil when the only matching entry has GIT-type ranges" do + v = vuln("id" => "CVE-2026-32316", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] }, + ]) + hit = make_hit(v, ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq", + subject_version: "1.8.1")) + + expect(matcher.range_status(hit)).to be_nil + end + + it "evaluates CPANSA constraint strings for :cpansa evidence" do + adv = Homebrew::Vulns::CPANSec::Advisory.new(id: "CPANSA-X", cves: ["CVE-1"], + affected_versions: ["<12.24"], + fixed_versions: [">=12.24"]) + hit = make_hit(vuln("id" => "CVE-1"), + ev(:cpansa, ecosystem: "CPAN", name: "Image-ExifTool", + subject_version: "13.55", advisory: adv)) + + expect(matcher.range_status(hit)&.first).to have_attributes(state: :fixed, fixed_in: "12.24") + end + + it "checks a distro-resolved upstream CVE against attached own-identity evidence" do + v = vuln("id" => "CVE-2015-8863", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "SEMVER", + "events" => [{ "introduced" => "0" }, { "fixed" => "1.6" }] }] }, + ]) + hit = make_hit(v, + ev(:distro, ecosystem: "Debian", name: "jq"), + ev(:distro, ecosystem: "GIT", name: "https://github.com/jqlang/jq", + subject_version: "1.8.1", key: "upstream:...")) + + expect(matcher.range_status(hit)&.first).to have_attributes(state: :fixed, fixed_in: "1.6") + end + + it "skips evidence with no subject_version" do + hit = make_hit(vuln("id" => "CVE-1"), ev(:distro, ecosystem: "Debian", name: "jq")) + expect(matcher.range_status(hit)).to be_nil + end + + it "checks each evidence against its own source record after dedup merges hits" do + # CVE record from GIT query: no PyPI affected entry. + cve = vuln("id" => "CVE-2024-47081", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/psf/requests" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "abc123" }] }] }, + ]) + # GHSA record from PyPI query: carries the PyPI range. + ghsa = vuln("id" => "GHSA-9hjg-9r4m-mvj7", "aliases" => ["CVE-2024-47081"], "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.32.4" }] }] }, + ]) + merged = matcher.dedup_by_cve([ + make_hit(cve, ev(:git, ecosystem: "GIT", name: "https://github.com/psf/requests", + subject_version: "2.31.0")), + make_hit(ghsa, ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")), + ]) + + expect(merged.length).to eq 1 + status, evidence = matcher.range_status(merged.first) + expect(status).to have_attributes(state: :affected, fixed_in: "2.32.4") + expect(evidence.source_record.id).to eq "GHSA-9hjg-9r4m-mvj7" + end + + it "reports :affected when a resource subject is affected even if the primary is :not_applicable" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "certifi" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2025.1.1" }] }] }, + ]) + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "2024.2.2", + resource: "certifi")) + + status, evidence = matcher.range_status(hit) + expect(status).to have_attributes(state: :affected, fixed_in: "2025.1.1") + expect(evidence.resource).to eq "certifi" + end + + it "reports :affected when a resource is affected even if the primary is :fixed" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "certifi" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2025.1.1" }] }] }, + ]) + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "2024.2.2", + resource: "certifi")) + + expect(matcher.range_status(hit)&.first).to have_attributes(state: :affected) + end + + it "reports :not_applicable only when every comparable subject is not_applicable" do + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }] }] }, + ]) + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ev(:distro, ecosystem: "Debian", name: "requests")) + + expect(matcher.range_status(hit)&.first&.state).to eq :not_applicable + end + end + + describe "#hits_from" do + let(:cpan_sec) do + Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => { + "No-CVE-Dist" => { "advisories" => [ + { "id" => "CPANSA-No-CVE-Dist-2020-01", "cves" => [], + "affected_versions" => ["<1.0"], "fixed_versions" => [">=1.0"], + "description" => "d", "references" => ["https://x"] }, + ] }, + } }) + end + + it "scopes a synthesised fallback to the CVE being handled when OSV lacks it" do + cpan_sec = Homebrew::Vulns::CPANSec.new({ "meta" => {}, "dists" => { + "Multi" => { "advisories" => [ + { "id" => "CPANSA-Multi-1", "cves" => ["CVE-2022-4988", "CVE-2022-4989"], + "affected_versions" => ["<1.0"], "fixed_versions" => [">=1.0"] }, + ] }, + } }) + m = described_class.new(repology:, cpan_sec:) + identity = Homebrew::Vulns::Match::Identity.new( + git_repo: nil, git_tag: nil, + primary_package: Homebrew::Vulns::Identify::RegistryPackage.new( + ecosystem: "CPAN", name: "Multi", version: "0.9", purl: "pkg:cpan/X/Multi@0.9", + ), + resource_packages: {}, distro_packages: {} + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2022-4988") + .and_raise(Homebrew::Vulns::OSV::ApiError, "404") + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2022-4989") + .and_return({ "id" => "CVE-2022-4989" }) + + hits = m.hits_from({}, identity) + + expect(hits.map(&:canonical_id).sort).to eq ["CVE-2022-4988", "CVE-2022-4989"] + end + + it "builds a hit directly from a CPANSA advisory that has no CVE alias" do + identity = Homebrew::Vulns::Match::Identity.new( + git_repo: nil, git_tag: nil, + primary_package: Homebrew::Vulns::Identify::RegistryPackage.new( + ecosystem: "CPAN", name: "No-CVE-Dist", version: "0.9", purl: "pkg:cpan/X/No-CVE-Dist@0.9", + ), + resource_packages: {}, distro_packages: {} + ) + expect(Homebrew::Vulns::OSV).not_to receive(:vulnerability) + + hits = matcher.hits_from({}, identity) + + expect(hits.length).to eq 1 + expect(hits.first.vulnerability.id).to eq "CPANSA-No-CVE-Dist-2020-01" + expect(hits.first.vulnerability.references).to eq [{ "type" => "WEB", "url" => "https://x" }] + expect(matcher.range_status(hits.first)&.first) + .to have_attributes(state: :affected, fixed_in: "1.0") + end + end + + describe "#resolve_upstream" do + let(:identity) do + Homebrew::Vulns::Match::Identity.new( + git_repo: "https://github.com/jqlang/jq", git_tag: "1.8.1", + primary_package: nil, resource_packages: {}, distro_packages: {} + ) + end + + it "splits a multi-CVE distro advisory into one hit per upstream CVE with own-identity evidence" do + allow(matcher).to receive(:fetch_vulnerability).with("RHSA-2026:1").and_return( + vuln("id" => "RHSA-2026:1", "upstream" => ["CVE-2026-0001", "CVE-2026-0002"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2026-0001") + .and_return(vuln("id" => "CVE-2026-0001")) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2026-0002") + .and_return(vuln("id" => "CVE-2026-0002")) + + hits = matcher.resolve_upstream( + { "RHSA-2026:1" => [ev(:distro, ecosystem: "Red Hat", name: "jq")] }, identity + ) + + expect(hits.map { |h| h.vulnerability.id }.sort).to eq ["CVE-2026-0001", "CVE-2026-0002"] + expect(hits.first.evidence.map(&:ecosystem)).to include("Red Hat", "GIT") + end + + it "follows upstream transitively (USN -> UBUNTU-CVE-* -> CVE-*) with cycle protection" do + allow(matcher).to receive(:fetch_vulnerability).with("USN-8202-1").and_return( + vuln("id" => "USN-8202-1", "upstream" => ["UBUNTU-CVE-2024-0001"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("UBUNTU-CVE-2024-0001").and_return( + vuln("id" => "UBUNTU-CVE-2024-0001", "upstream" => ["CVE-2024-0001", "USN-8202-1"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001") + .and_return(vuln("id" => "CVE-2024-0001")) + + hits = matcher.resolve_upstream({ "USN-8202-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"] + end + + it "consults related for bare CVE ids only for ALSA-* records with no upstream" do + allow(matcher).to receive(:fetch_vulnerability).with("ALSA-1").and_return( + vuln("id" => "ALSA-1", "related" => ["CVE-2024-0001", "RHSA-2024:1"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001") + .and_return(vuln("id" => "CVE-2024-0001")) + + hits = matcher.resolve_upstream({ "ALSA-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"] + end + + it "does not consult related for a non-ALSA record with no upstream" do + allow(matcher).to receive(:fetch_vulnerability).with("MGASA-1").and_return( + vuln("id" => "MGASA-1", "related" => ["CVE-2024-9999"]), + ) + hits = matcher.resolve_upstream({ "MGASA-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["MGASA-1"] + end + + it "ignores related when upstream is present" do + allow(matcher).to receive(:fetch_vulnerability).with("DSA-1").and_return( + vuln("id" => "DSA-1", "upstream" => ["CVE-2024-0001"], "related" => ["CVE-9999-9999"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001") + .and_return(vuln("id" => "CVE-2024-0001")) + + hits = matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"] + end + + it "keeps a record whose id/aliases already include a CVE as-is" do + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0001").and_return( + vuln("id" => "CVE-2024-0001", "upstream" => ["CVE-2024-0099"]), + ) + hits = matcher.resolve_upstream({ "CVE-2024-0001" => [ev(:git)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["CVE-2024-0001"] + end + + it "keeps a record with no CVE anywhere as a low-confidence hit rather than dropping it" do + allow(matcher).to receive(:fetch_vulnerability).with("ALBA-2022:1788").and_return( + vuln("id" => "ALBA-2022:1788", "upstream" => [], "related" => ["RHBA-2022:1788"]), + ) + hits = matcher.resolve_upstream({ "ALBA-2022:1788" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["ALBA-2022:1788"] + end + + it "keeps a record as-is when its upstream CVE cannot be fetched" do + allow(matcher).to receive(:fetch_vulnerability).with("DSA-1").and_return( + vuln("id" => "DSA-1", "upstream" => ["CVE-2024-0404"]), + ) + allow(matcher).to receive(:fetch_vulnerability).with("CVE-2024-0404").and_return(nil) + hits = matcher.resolve_upstream({ "DSA-1" => [ev(:distro)] }, identity) + expect(hits.map { |h| h.vulnerability.id }).to eq ["DSA-1"] + end + end + + describe "#each_advisory_batch" do + it "sends every formula's queries through one OSV.query_batch and yields per-formula hits" do + a = formula("aa") do + T.bind(self, T.class_of(Formula)) + url "https://github.com/owner/aa/archive/refs/tags/v1.0.tar.gz" + end + b = formula("bb") do + T.bind(self, T.class_of(Formula)) + url "https://github.com/owner/bb/archive/refs/tags/v2.0.tar.gz" + end + bulk = described_class.new(repology:, cpan_sec:, bulk: true) + + expect(Homebrew::Vulns::OSV).to receive(:query_batch).once.with( + [ + { ecosystem: "GIT", name: "https://github.com/owner/aa", version: nil }, + { ecosystem: "GIT", name: "https://github.com/owner/bb", version: nil }, + ], + ).and_return([[{ "id" => "CVE-2024-0001" }], []]) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2024-0001") + .and_return({ "id" => "CVE-2024-0001" }) + + yielded = T.let([], T::Array[[String, T::Array[String]]]) + bulk.each_advisory_batch([a, b]) { |f, hits| yielded << [f.name, hits.map(&:canonical_id)] } + + expect(yielded).to eq [["aa", ["CVE-2024-0001"]], ["bb", []]] + end + end + + describe "#advisories_for" do + let(:exiftool) do + formula("exiftool") do + T.bind(self, T.class_of(Formula)) + url "https://cpan.metacpan.org/authors/id/E/EX/EXIFTOOL/Image-ExifTool-13.55.tar.gz" + head "https://github.com/exiftool/exiftool.git" + end + end + + before { stub_repology_lookup({ "Debian" => ["libimage-exiftool-perl"] }) } + + it "queries versionlessly, resolves distro upstream to CVEs, and dedups by CVE alias" do + expect(Homebrew::Vulns::OSV).to receive(:query_batch).with( + [ + { ecosystem: "GIT", name: "https://github.com/exiftool/exiftool", version: nil }, + { ecosystem: "Debian", name: "libimage-exiftool-perl", version: nil }, + ], + ).and_return( + [ + [{ "id" => "CVE-2021-22204" }], + [{ "id" => "DEBIAN-CVE-2021-22204" }, { "id" => "DSA-4910-1" }], + ], + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2021-22204").and_return( + { "id" => "CVE-2021-22204", "aliases" => ["GHSA-xxxx"] }, + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DEBIAN-CVE-2021-22204").and_return( + { "id" => "DEBIAN-CVE-2021-22204", "upstream" => ["CVE-2021-22204"] }, + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("DSA-4910-1").and_return( + { "id" => "DSA-4910-1", "upstream" => ["CVE-2021-22204", "CVE-2021-99999"] }, + ) + allow(Homebrew::Vulns::OSV).to receive(:vulnerability).with("CVE-2021-99999").and_return( + { "id" => "CVE-2021-99999" }, + ) + + hits = matcher.advisories_for(exiftool) + + expect(hits.map(&:canonical_id).sort).to eq ["CVE-2021-22204", "CVE-2021-99999"] + merged = hits.find { |h| h.canonical_id == "CVE-2021-22204" } + expect(T.must(merged).strategy).to eq :git + expect(T.must(merged).evidence.map(&:strategy).uniq.sort).to eq [:cpansa, :distro, :git] + expect(T.must(merged).evidence.find { |e| e.strategy == :cpansa }&.advisory).not_to be_nil + end + + it "returns [] without hitting OSV when nothing is identifiable" do + f = formula("mystery") do + T.bind(self, T.class_of(Formula)) + url "https://example.com/mystery-1.0.tar.gz" + end + stub_repology_lookup + expect(Homebrew::Vulns::OSV).not_to receive(:query_batch) + + expect(matcher.advisories_for(f)).to eq [] + end + + it "caches OSV.vulnerability lookups across calls" do + allow(Homebrew::Vulns::OSV).to receive(:query_batch) + .and_return([[{ "id" => "CVE-2021-22204" }], []]) + expect(Homebrew::Vulns::OSV).to receive(:vulnerability).once + .and_return({ "id" => "CVE-2021-22204" }) + + matcher.advisories_for(exiftool) + matcher.advisories_for(exiftool) + end + end + + describe "#to_brew_record" do + let(:requests) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + resource "certifi" do + url "https://files.pythonhosted.org/packages/11/22/33/certifi-2024.2.2.tar.gz" + end + end + end + let(:now) { Time.utc(2026, 7, 27, 12, 0, 0) } + + def registry_hit(affected_events:, subject_version: "2.31.0", resource: nil, name: "requests") + make_hit( + vuln("id" => "CVE-2024-1234", "aliases" => ["GHSA-abcd"], "summary" => "s", + "severity" => [{ "type" => "CVSS_V3", "score" => "..." }], + "references" => [{ "type" => "ADVISORY", "url" => "https://x" }], + "affected" => [{ "package" => { "ecosystem" => "PyPI", "name" => name }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => affected_events }] }]), + ev(:registry, ecosystem: "PyPI", name:, subject_version:, + key: "pkg:pypi/#{name}@#{subject_version}", resource:), + ) + end + + it "emits fixed=pkg_version and fix: bump when the range says the shipped version is not affected" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.28.1" }]) + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record[:id]).to eq "BREW-requests-CVE-2024-1234" + expect(record[:upstream]).to eq ["CVE-2024-1234", "GHSA-abcd"] + expect(record[:severity]).to eq [{ "type" => "CVSS_V3", "score" => "..." }] + expect(record[:references]).to eq [{ "type" => "ADVISORY", "url" => "https://x" }] + aff = record[:affected].first + expect(aff[:package]).to eq(ecosystem: "Homebrew", name: "requests", purl: "pkg:brew/requests") + expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", + events: [{ introduced: "0" }, { fixed: requests.pkg_version.to_s }] }] + expect(aff[:ecosystem_specific]).to eq(fix: "bump", range_state: "fixed", upstream_fixed_in: "2.28.1") + expect(record.dig(:database_specific, :source)).to eq "matched" + expect(record.dig(:database_specific, :strategy)).to eq "registry" + expect(record.dig(:database_specific, :confidence)).to eq "high" + end + + it "emits no fixed event and fix: nil when the range says the shipped version is still affected" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.32.0" }]) + + record = matcher.to_brew_record(requests, hit, now:) + + aff = record[:affected].first + expect(aff[:ranges]).to eq [{ type: "ECOSYSTEM", events: [{ introduced: "0" }] }] + expect(aff[:ecosystem_specific]).to eq(fix: nil, range_state: "affected", upstream_fixed_in: "2.32.0") + end + + it "emits fix: nil and demotes confidence when no comparable range exists (GIT-only)" do + hit = make_hit( + vuln("id" => "CVE-2026-32316", "affected" => [ + { "package" => { "ecosystem" => "GIT", "name" => "https://github.com/jqlang/jq" }, + "ranges" => [{ "type" => "GIT", "events" => [{ "fixed" => "e47e56d" }] }] }, + ]), + ev(:git, ecosystem: "GIT", name: "https://github.com/jqlang/jq", subject_version: "1.8.1"), + ) + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }] + expect(record.dig(:affected, 0, :ecosystem_specific)).to eq(fix: nil) + expect(record.dig(:database_specific, :confidence)).to eq "medium" + end + + it "records not_applicable and does not emit fixed for a version below every introduced" do + hit = registry_hit(affected_events: [{ "introduced" => "3.0.0" }, { "fixed" => "3.0.4" }]) + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }] + expect(record.dig(:affected, 0, :ecosystem_specific)).to eq(fix: nil, range_state: "not_applicable") + end + + it "prefers an explicit first_fixed over the derived value" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2.28.1" }]) + + record = matcher.to_brew_record(requests, hit, first_fixed: "2.28.1_1", now:) + + expect(record.dig(:affected, 0, :ranges, 0, :events)).to eq [{ introduced: "0" }, { fixed: "2.28.1_1" }] + end + + it "records resource name and purl and evaluates against the resource's pinned version" do + hit = registry_hit(affected_events: [{ "introduced" => "0" }, { "fixed" => "2024.2.2" }], + subject_version: "2024.2.2", resource: "certifi", name: "certifi") + + record = matcher.to_brew_record(requests, hit, now:) + + expect(record.dig(:affected, 0, :ecosystem_specific)) + .to eq(fix: "bump", range_state: "fixed", upstream_fixed_in: "2024.2.2", + resource: "certifi", resource_purl: "pkg:pypi/certifi@2024.2.2") + expect(record.dig(:affected, 0, :ranges, 0, :events).last).to eq(fixed: requests.pkg_version.to_s) + end + end + + describe "#first_fixed_version" do + let(:requests) do + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-2.31.0.tar.gz" + end + end + + def stub_history(versions_newest_first) + fv = instance_double(FormulaVersions) + revs = versions_newest_first.each_with_index.map { |_, i| ["r#{i}", "Formula/r/requests.rb"] } + allow(fv).to receive(:rev_list) { |_, &b| revs.each { |rev, entry| b.call(rev, entry) } } + versions_newest_first.each_with_index do |entry, i| + primary, res = Array(entry) + old = if primary + formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-#{primary}.tar.gz" + if res + resource "certifi" do + url "https://files.pythonhosted.org/packages/11/22/33/certifi-#{res}.tar.gz" + end + end + end + end + allow(fv).to receive(:formula_at_revision).with("r#{i}", anything) do |&b| + old && b.call(old) + end + end + allow(FormulaVersions).to receive(:new).and_return(fv) + end + + def hit_with_range(*events) + make_hit( + vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => events }] }, + ]), + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0"), + ) + end + + def hit_fixed_at(fixed) + hit_with_range({ "introduced" => "0" }, { "fixed" => fixed }) + end + + it "returns the pkg_version at the oldest revision still at or past upstream fixed_in" do + stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0", "2.27.0"]) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.28.1" + end + + it "honours last_affected inclusivity by re-running the range per revision" do + stub_history(["2.31.0", "2.1", "2.0", "1.9"]) + hit = hit_with_range({ "introduced" => "0" }, { "last_affected" => "2.0" }) + # 2.0 is the last *affected* version so 2.1 is the first fixed pkg_version. + expect(matcher.first_fixed_version(requests, hit)).to eq "2.1" + end + + it "returns :never_affected when Homebrew jumped from below introduced straight past fixed" do + # Advisory {introduced: 2.0, fixed: 3.0}; Homebrew went 1.0 -> 4.0 and + # never shipped a 2.x, so no BREW record should be emitted. + stub_history(["4.0", "1.0"]) + current = formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-4.0.tar.gz" + end + hit = make_hit( + vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "2.0" }, { "fixed" => "3.0" }] }] }, + ]), + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "4.0"), + ) + + expect(matcher.first_fixed_version(current, hit)).to eq :never_affected + end + + it "returns :never_affected when the formula was already past fixed at its first revision" do + stub_history(["2.31.0"]) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq :never_affected + end + + it "keeps versionless (distro) evidence uncheckable at historical revisions too" do + stub_history(["2.31.0", "2.30.0", "2.28.1", "2.28.0"]) + # A distro record whose Debian range would spuriously match our formula + # version if it were compared: ensure it stays skipped in the walk. + distro_record = vuln("id" => "DEBIAN-CVE-1", "affected" => [ + { "package" => { "ecosystem" => "Debian", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "999+deb12u1" }] }] }, + ]) + registry_record = vuln("id" => "GHSA-x", "aliases" => ["CVE-1"], "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", + "events" => [{ "introduced" => "0" }, { "fixed" => "2.28.1" }] }] }, + ]) + hit = matcher.dedup_by_cve([ + make_hit(registry_record, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "2.31.0")), + make_hit(distro_record, + ev(:distro, ecosystem: "Debian", name: "requests", subject_version: nil)), + ]).first + + expect(matcher.first_fixed_version(requests, hit)).to eq "2.28.1" + end + + it "aggregates every subject per revision so a fixed primary does not mask a later-fixed resource" do + # Primary requests fixed upstream in 2.0; resource certifi fixed upstream in 100.0. + # History (formula pkg_version => [primary, certifi]): the resource crossed its + # threshold at formula 3.0; the primary crossed at 2.0. Aggregate is only :fixed + # from 3.0 onward. + stub_history([["4.0", "101.0"], ["3.0", "100.0"], ["2.5", "99.0"], ["2.0", "98.0"], ["1.0", "97.0"]]) + v = vuln("id" => "CVE-1", "affected" => [ + { "package" => { "ecosystem" => "PyPI", "name" => "requests" }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => "2.0" }] }] }, + { "package" => { "ecosystem" => "PyPI", "name" => "certifi" }, + "ranges" => [{ "type" => "ECOSYSTEM", "events" => [{ "introduced" => "0" }, { "fixed" => "100.0" }] }] }, + ]) + current = formula("requests") do + T.bind(self, T.class_of(Formula)) + url "https://files.pythonhosted.org/packages/aa/bb/cc/requests-4.0.tar.gz" + resource("certifi") { url "https://files.pythonhosted.org/packages/11/22/33/certifi-101.0.tar.gz" } + end + hit = make_hit(v, + ev(:registry, ecosystem: "PyPI", name: "requests", subject_version: "4.0"), + ev(:registry, ecosystem: "PyPI", name: "certifi", subject_version: "101.0", + resource: "certifi")) + + expect(matcher.first_fixed_version(current, hit)).to eq "3.0" + end + + it "stops at an unloadable revision and returns the last known fixed pkg_version" do + stub_history(["2.31.0", "2.30.0", nil, "2.28.0"]) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.28.1"))).to eq "2.30.0" + end + + it "returns nil when the current version is still affected" do + expect(FormulaVersions).not_to receive(:new) + expect(matcher.first_fixed_version(requests, hit_fixed_at("2.32.0"))).to be_nil + end + + it "returns nil when there is no comparable range" do + hit = make_hit(vuln("id" => "CVE-1"), ev(:distro, ecosystem: "Debian", name: "requests")) + expect(matcher.first_fixed_version(requests, hit)).to be_nil + end + end + + describe Homebrew::Vulns::Match::Hit do + it "sorts evidence by descending strategy precision and reports the highest as #strategy" do + hit = make_hit(vuln("id" => "CVE-1"), ev(:distro), ev(:git), ev(:registry)) + expect(hit.evidence.map(&:strategy)).to eq [:git, :registry, :distro] + expect(hit.strategy).to eq :git + end + + it "uses the lowest CVE alias as canonical_id, or the record id when there is none" do + expect(make_hit(vuln("id" => "GHSA-x", "aliases" => ["CVE-2024-2", "CVE-2024-1"]), + ev(:git)).canonical_id).to eq "CVE-2024-1" + expect(make_hit(vuln("id" => "GHSA-y"), ev(:git)).canonical_id).to eq "GHSA-y" + end + + it "rejects empty evidence" do + expect { described_class.new(vulnerability: vuln("id" => "CVE-1"), evidence: []) } + .to raise_error(ArgumentError) + end + end +end diff --git a/Library/Homebrew/test/vulns/osv_spec.rb b/Library/Homebrew/test/vulns/osv_spec.rb index 2c07a93cde228..a9175b6687e18 100644 --- a/Library/Homebrew/test/vulns/osv_spec.rb +++ b/Library/Homebrew/test/vulns/osv_spec.rb @@ -15,9 +15,9 @@ def stub_curl(*results) describe ".query_batch" do let(:packages) do [ - { repo_url: "https://github.com/a/a", version: "v1" }, - { repo_url: "https://github.com/b/b", version: "v2" }, - { repo_url: "https://github.com/c/c", version: "v3" }, + { ecosystem: "GIT", name: "https://github.com/a/a", version: "v1" }, + { ecosystem: "GIT", name: "https://github.com/b/b", version: "v2" }, + { ecosystem: "GIT", name: "https://github.com/c/c", version: "v3" }, ] end @@ -39,7 +39,12 @@ def stub_curl(*results) expect(results[2].map { |v| v["id"] }).to eq ["CVE-2024-2222", "CVE-2024-3333"] end - it "posts each package as a GIT-ecosystem query" do + it "posts each package under its given ecosystem, omitting version when nil" do + mixed = [ + { ecosystem: "GIT", name: "https://github.com/a/a", version: "v1" }, + { ecosystem: "PyPI", name: "requests", version: "2.31.0" }, + { ecosystem: "Debian", name: "curl", version: nil }, + ] posted = nil expect(Utils::Curl).to receive(:curl_output) do |*args| expect(args.last).to eq "https://api.osv.dev/v1/querybatch" @@ -47,12 +52,12 @@ def stub_curl(*results) curl_result(stdout: { results: [{}, {}, {}] }.to_json) end - described_class.query_batch(packages) + described_class.query_batch(mixed) expect(posted["queries"]).to eq [ { "package" => { "name" => "https://github.com/a/a", "ecosystem" => "GIT" }, "version" => "v1" }, - { "package" => { "name" => "https://github.com/b/b", "ecosystem" => "GIT" }, "version" => "v2" }, - { "package" => { "name" => "https://github.com/c/c", "ecosystem" => "GIT" }, "version" => "v3" }, + { "package" => { "name" => "requests", "ecosystem" => "PyPI" }, "version" => "2.31.0" }, + { "package" => { "name" => "curl", "ecosystem" => "Debian" } }, ] end diff --git a/Library/Homebrew/test/vulns/purl_spec.rb b/Library/Homebrew/test/vulns/purl_spec.rb new file mode 100644 index 0000000000000..1630dae0c9726 --- /dev/null +++ b/Library/Homebrew/test/vulns/purl_spec.rb @@ -0,0 +1,160 @@ +# typed: strict +# frozen_string_literal: true + +require "vulns/purl" + +RSpec.describe Homebrew::Vulns::Purl do + describe "#initialize" do + it "raises when type is empty" do + expect { described_class.new(type: "", name: "rails") }.to raise_error(ArgumentError, /type/) + end + + it "raises when name is empty" do + expect { described_class.new(type: "gem", name: "") }.to raise_error(ArgumentError, /name/) + end + + it "lowercases the type and treats empty namespace/version as absent" do + purl = described_class.new(type: "PyPI", name: "requests", namespace: "", version: "") + expect(purl.type).to eq "pypi" + expect(purl.namespace).to be_nil + expect(purl.version).to be_nil + end + + it "freezes the stored components" do + purl = described_class.new(type: "npm", namespace: (+"@babel"), name: (+"core"), version: (+"7.0.0")) + expect([purl.type, purl.namespace, purl.name, purl.version]).to all be_frozen + end + end + + describe "per-type normalisation" do + it "lowercases a PyPI name and replaces underscores with hyphens" do + purl = described_class.new(type: "pypi", name: "Types_Setuptools") + expect(purl.name).to eq "types-setuptools" + end + + it "leaves PyPI dots and existing hyphens intact" do + purl = described_class.new(type: "pypi", name: "backports.zoneinfo") + expect(purl.name).to eq "backports.zoneinfo" + end + + it "lowercases a Hex name and namespace" do + purl = described_class.new(type: "hex", namespace: "Acme", name: "Phoenix") + expect(purl.namespace).to eq "acme" + expect(purl.name).to eq "phoenix" + end + + it "uppercases a CPAN namespace and preserves the distribution name" do + purl = described_class.new(type: "cpan", namespace: "abigail", name: "Regexp-Common") + expect(purl.namespace).to eq "ABIGAIL" + expect(purl.name).to eq "Regexp-Common" + end + + it "does not alter case for cargo, gem, hackage, cran or npm" do + %w[cargo gem hackage cran npm].each do |type| + expect(described_class.new(type:, name: "MixedCase").name).to eq "MixedCase" + end + end + end + + describe ".encode" do + it "leaves the RFC 3986 unreserved set and : untouched" do + expect(described_class.encode("Az09-._~:")).to eq "Az09-._~:" + end + + it "percent-encodes @, /, + and space per the purl spec" do + expect(described_class.encode("@a/b+c d")).to eq "%40a%2Fb%2Bc%20d" + end + + it "percent-encodes each byte of a multibyte UTF-8 character" do + expect(described_class.encode("café")).to eq "caf%C3%A9" + end + end + + describe "#to_s" do + it "builds pkg:gem with and without a version, returning a frozen string" do + bare = described_class.new(type: "gem", name: "rails").to_s + expect(bare).to eq "pkg:gem/rails" + expect(bare).to be_frozen + expect(described_class.new(type: "gem", name: "rails", version: "7.0.0").to_s) + .to eq "pkg:gem/rails@7.0.0" + end + + it "builds pkg:npm with an encoded scope namespace" do + purl = described_class.new(type: "npm", namespace: "@angular", name: "cli", version: "22.0.3") + expect(purl.to_s).to eq "pkg:npm/%40angular/cli@22.0.3" + end + + it "builds pkg:pypi with the normalised name" do + purl = described_class.new(type: "pypi", name: "types_setuptools", version: "80.9.0.20251223") + expect(purl.to_s).to eq "pkg:pypi/types-setuptools@80.9.0.20251223" + end + + it "builds pkg:cargo" do + purl = described_class.new(type: "cargo", name: "cargo-llvm-cov", version: "0.8.7") + expect(purl.to_s).to eq "pkg:cargo/cargo-llvm-cov@0.8.7" + end + + it "builds pkg:hackage preserving case" do + purl = described_class.new(type: "hackage", name: "Allure", version: "0.11.0.0") + expect(purl.to_s).to eq "pkg:hackage/Allure@0.11.0.0" + end + + it "builds pkg:hex with a lowercased name" do + purl = described_class.new(type: "hex", name: "Phoenix", version: "1.7.0-rc.0") + expect(purl.to_s).to eq "pkg:hex/phoenix@1.7.0-rc.0" + end + + it "builds pkg:cpan with an uppercased author namespace" do + purl = described_class.new(type: "cpan", namespace: "ABIGAIL", name: "Regexp-Common", + version: "2024080801") + expect(purl.to_s).to eq "pkg:cpan/ABIGAIL/Regexp-Common@2024080801" + end + + it "builds pkg:maven with a groupId namespace" do + purl = described_class.new(type: "maven", namespace: "com.github.spotbugs", name: "spotbugs", + version: "4.10.2") + expect(purl.to_s).to eq "pkg:maven/com.github.spotbugs/spotbugs@4.10.2" + end + + it "builds pkg:cran" do + purl = described_class.new(type: "cran", name: "data.table", version: "1.15.4") + expect(purl.to_s).to eq "pkg:cran/data.table@1.15.4" + end + + it "builds pkg:nuget" do + purl = described_class.new(type: "nuget", name: "Newtonsoft.Json", version: "13.0.3") + expect(purl.to_s).to eq "pkg:nuget/Newtonsoft.Json@13.0.3" + end + + it "encodes semver build metadata + in the version" do + purl = described_class.new(type: "cargo", name: "foo", version: "1.0.0+build.1") + expect(purl.to_s).to eq "pkg:cargo/foo@1.0.0%2Bbuild.1" + end + + it "encodes each namespace segment separately, preserving the / separator" do + purl = described_class.new(type: "golang", namespace: "github.com/gorilla", name: "mux", + version: "v1.8.1") + expect(purl.to_s).to eq "pkg:golang/github.com/gorilla/mux@v1.8.1" + end + end + + describe "#== and #hash" do + it "considers two purls equal when their canonical strings match" do + a = described_class.new(type: "PyPI", name: "Foo_Bar", version: "1.0") + b = described_class.new(type: "pypi", name: "foo-bar", version: "1.0") + expect(a).to eq b + expect(a.hash).to eq b.hash + end + + it "is not equal to a purl with a different version" do + a = described_class.new(type: "gem", name: "rails", version: "7.0.0") + b = described_class.new(type: "gem", name: "rails", version: "7.0.1") + expect(a).not_to eq b + end + + it "is not equal to a plain string" do + purl = described_class.new(type: "gem", name: "rails") + expect(purl == "pkg:gem/rails").to be false + end + end +end diff --git a/Library/Homebrew/test/vulns/repology_spec.rb b/Library/Homebrew/test/vulns/repology_spec.rb new file mode 100644 index 0000000000000..9171dec376649 --- /dev/null +++ b/Library/Homebrew/test/vulns/repology_spec.rb @@ -0,0 +1,302 @@ +# typed: true +# frozen_string_literal: true + +require "vulns/repology" + +RSpec.describe Homebrew::Vulns::Repology do + let(:fixture) { TEST_FIXTURE_DIR/"vulns/repology.json" } + let(:index) { described_class.from_file(fixture) } + + describe "#initialize" do + it "raises Error when the top-level value is not a JSON object" do + expect { described_class.new([]) } + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /not a JSON object/) + end + + it "raises Error when the formulae key is missing" do + expect { described_class.new({ "meta" => {} }) } + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /missing 'formulae' key/) + end + end + + describe "#meta and #formulae" do + it "exposes the meta block and formula names" do + expect(index.meta["osv_distros"]).to include "Debian" + expect(index.meta["ambiguous_projects"]).to eq({ "antlr" => ["antlr", "antlr4-cpp-runtime"] }) + expect(index.formulae).to contain_exactly("curl", "libgee", "ack", "postgresql") + end + end + + describe "#distro_packages_for" do + it "returns the ecosystem => srcnames map for a known formula" do + expect(index.distro_packages_for("curl")).to eq( + "Alpine" => ["curl"], "Debian" => ["curl"], "FreeBSD" => ["curl"], + "Ubuntu" => ["curl"], "openSUSE" => ["curl"] + ) + end + + it "returns multiple candidate srcnames per ecosystem" do + expect(index.distro_packages_for("ack")).to eq("Ubuntu" => ["ack", "ack-grep"]) + end + + it "falls back to the base name for an @-versioned formula" do + expect(index.distro_packages_for("postgresql@16")) + .to eq("Debian" => ["postgresql-17"], "Alpine" => ["postgresql17"]) + end + + it "returns an empty hash for an unknown formula" do + expect(index.distro_packages_for("no-such-formula")).to eq({}) + end + + it "returns frozen values" do + result = index.distro_packages_for("libgee") + expect(result).to be_frozen + expect(result["Debian"]).to be_frozen + end + + it "drops malformed entries when coercing" do + idx = described_class.new({ "formulae" => { "x" => { "Debian" => ["ok"], 123 => ["bad"], + "Empty" => [] } } }) + expect(idx.distro_packages_for("x")).to eq("Debian" => ["ok"]) + end + end + + describe ".name_candidates" do + it "generates deduplicated normalisation variants" do + expect(described_class.name_candidates("libmatio")) + .to eq ["libmatio", "matio"] + end + + it "strips an @-version suffix and applies affix variants to the base" do + expect(described_class.name_candidates("gnu-complexity@1")) + .to eq ["gnu-complexity@1", "gnu-complexity", "complexity"] + end + + it "strips a trailing 2" do + expect(described_class.name_candidates("qscintilla2")).to eq ["qscintilla2", "qscintilla"] + end + + it "returns just the name when no variant applies" do + expect(described_class.name_candidates("curl")).to eq ["curl"] + end + + it "does not yield an empty candidate for a bare 'lib' name" do + expect(described_class.name_candidates("lib")).to eq ["lib"] + end + end + + describe ".distil" do + let(:entries) do + [ + { "repo" => "debian_12", "srcname" => "curl", "status" => "outdated" }, + { "repo" => "debian_13", "srcname" => "curl", "status" => "newest" }, + { "repo" => "alpine_3_17", "srcname" => "old-curl", "status" => "legacy" }, + { "repo" => "alpine_3_22", "srcname" => "curl", "status" => "newest" }, + { "repo" => "freebsd", "srcname" => "ftp/curl", "binname" => "curl", "status" => "newest" }, + { "repo" => "opensuse_games_tumbleweed", "srcname" => "wrong" }, + { "repo" => "scoop", "binname" => "curl" }, + ] + end + + it "collapses versioned repos, drops legacy, uses binname for FreeBSD, ignores unmapped repos" do + expect(described_class.distil(entries)) + .to eq("Alpine" => ["curl"], "Debian" => ["curl"], "FreeBSD" => ["curl"]) + end + + it "collects all distinct srcnames per ecosystem, sorted" do + multi = [ + { "repo" => "ubuntu_22_04", "srcname" => "ack" }, + { "repo" => "ubuntu_18_04", "srcname" => "ack-grep" }, + ] + expect(described_class.distil(multi)).to eq("Ubuntu" => ["ack", "ack-grep"]) + end + + it "returns an empty hash for no mappable entries" do + expect(described_class.distil([{ "repo" => "scoop" }])).to eq({}) + end + end + + describe ".lookup" do + def project(homebrew:, distros:, status: "newest") + homebrew.map { |n| { "repo" => "homebrew", "srcname" => n, "status" => status } } + + distros.map { |repo, n| { "repo" => repo, "srcname" => n } } + end + + it "tries name candidates until one contains the requested formula in its Homebrew entries" do + allow(described_class).to receive(:fetch_project).with("libmatio").and_return([]) + allow(described_class).to receive(:fetch_project).with("matio").and_return( + project(homebrew: ["libmatio"], distros: [["debian_12", "matio"]]), + ) + expect(described_class.lookup("libmatio")).to eq("Debian" => ["matio"]) + end + + it "rejects a candidate whose Homebrew entries do not include the requested formula" do + allow(described_class).to receive(:fetch_project).with("libc").and_return([]) + allow(described_class).to receive(:fetch_project).with("c").and_return( + project(homebrew: ["c"], distros: [["freebsd", "c"]]), + ) + expect(described_class.lookup("libc")).to eq({}) + end + + it "accepts a candidate that lists the @-stripped base name" do + allow(described_class).to receive(:fetch_project).with("node@20").and_return([]) + allow(described_class).to receive(:fetch_project).with("node").and_return( + project(homebrew: ["node", "node@22"], distros: [["debian_12", "nodejs"]]), + ) + expect(described_class.lookup("node@20")).to eq("Debian" => ["nodejs"]) + end + + it "accepts a project that also lists sibling formulae with a different base name" do + # Repology groups wget + wget2 under one project; the sibling's distro + # srcnames come through as extra low-confidence distro queries whose + # upstream-CVE range check will not match this formula's identity. + allow(described_class).to receive(:fetch_project).with("wget").and_return( + project(homebrew: ["wget", "wget2"], + distros: [["debian_12", "wget"], ["debian_12", "wget2"]]), + ) + expect(described_class.lookup("wget")).to eq("Debian" => ["wget", "wget2"]) + end + + it "still rejects a candidate whose Homebrew entries do not include the requested formula at all" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo-utils"], distros: [["debian_12", "wrong"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "foo"]]), + ) + expect(described_class.lookup("libfoo")).to eq("Debian" => ["foo"]) + end + + it "continues past a candidate with no mapped OSV distros" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["scoop", "foo"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["alpine_3_22", "foo"]]), + ) + expect(described_class.lookup("libfoo")).to eq("Alpine" => ["foo"]) + end + + it "resolves two matching candidates by preferred Homebrew status" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "libfoo4"]], status: "rolling"), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "libfoo5"]], status: "newest"), + ) + expect(described_class.lookup("libfoo")).to eq("Debian" => ["libfoo5"]) + end + + it "prefers a sole exact-name contribution over a preferred base-name contribution" do + allow(described_class).to receive(:fetch_project).with("libfoo@1").and_return( + [{ "repo" => "homebrew", "srcname" => "libfoo@1", "status" => "rolling" }, + { "repo" => "homebrew", "srcname" => "libfoo", "status" => "newest" }, + { "repo" => "debian_12", "srcname" => "exact" }], + ) + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "base"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return([]) + expect(described_class.lookup("libfoo@1")).to eq("Debian" => ["exact"]) + end + + it "falls back to a resolved base pool when the exact pool is an unresolvable collision" do + allow(described_class).to receive(:fetch_project).with("libfoo@1").and_return( + project(homebrew: ["libfoo@1"], distros: [["debian_12", "a"]]), + ) + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo@1"], distros: [["debian_12", "b"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "base"]]), + ) + expect(described_class.lookup("libfoo@1")).to eq("Debian" => ["base"]) + end + + it "contributes a project listing both exact and base names to both pools" do + allow(described_class).to receive(:fetch_project).with("libfoo@1").and_return( + project(homebrew: ["libfoo@1", "libfoo"], distros: [["debian_12", "a"]]), + ) + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo@1"], distros: [["debian_12", "b"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return([]) + # Exact pool: [a/true, b/true] collides. Base pool: [a/true] (from the + # first project, which also lists `libfoo`) resolves. + expect(described_class.lookup("libfoo@1")).to eq("Debian" => ["a"]) + end + + it "returns {} when two matching candidates both have preferred status (unresolvable)" do + allow(described_class).to receive(:fetch_project).with("libfoo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "a"]]), + ) + allow(described_class).to receive(:fetch_project).with("foo").and_return( + project(homebrew: ["libfoo"], distros: [["debian_12", "b"]]), + ) + expect(described_class.lookup("libfoo")).to eq({}) + end + + it "returns an empty hash when no candidate resolves" do + allow(described_class).to receive(:fetch_project).and_return([]) + expect(described_class.lookup("no-such")).to eq({}) + end + + it "propagates fetch errors rather than treating them as a miss" do + allow(described_class).to receive(:fetch_project) + .and_raise(Homebrew::Vulns::CachedFeed::Error, "Repology API request failed") + expect { described_class.lookup("curl") }.to raise_error(Homebrew::Vulns::CachedFeed::Error) + end + end + + describe ".fetch_project" do + it "returns the entries array from ::Repology.single_package_query" do + entries = [{ "repo" => "debian_12", "srcname" => "curl" }] + allow(Repology).to receive(:single_package_query) + .with("curl", repository: Repology::HOMEBREW_CORE).and_return({ "curl" => entries }) + expect(described_class.fetch_project("curl")).to eq entries + end + + it "returns [] for a nonexistent project (HTTP 200 with empty array)" do + allow(Repology).to receive(:single_package_query).and_return({ "no-such" => [] }) + expect(described_class.fetch_project("no-such")).to eq [] + end + + it "raises Error when the underlying query fails (returns nil)" do + allow(Repology).to receive(:single_package_query).and_return(nil) + expect { described_class.fetch_project("curl") } + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /request for "curl" failed/) + end + + it "raises Error on an unexpected response shape" do + allow(Repology).to receive(:single_package_query).and_return({ "curl" => { "oops" => true } }) + expect { described_class.fetch_project("curl") } + .to raise_error(Homebrew::Vulns::CachedFeed::Error, /unexpected shape/) + end + end + + describe ".load" do + it "reads a fresh cache file without downloading" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + FileUtils.cp fixture, cache/"repology.json" + expect(Utils::Curl).not_to receive(:curl_download) + expect(described_class.load(cache:).formulae).to include "curl" + end + end + + it "falls back to a stale cache when the download fails" do + Dir.mktmpdir do |dir| + cache = Pathname(dir) + stale = cache/"repology.json" + FileUtils.cp fixture, stale + FileUtils.touch stale, mtime: Time.now - (described_class.default_max_age + 1) + expect(Utils::Curl).to receive(:curl_download) + .and_raise(ErrorDuringExecution.new(["curl"], status: 22)) + loaded = T.let(nil, T.nilable(Homebrew::Vulns::Repology)) + expect { loaded = described_class.load(cache:) } + .to output(/Failed to refresh repology\.json/).to_stderr + expect(loaded&.formulae).to include "curl" + end + end + end +end diff --git a/Library/Homebrew/test/vulns/scanner_spec.rb b/Library/Homebrew/test/vulns/scanner_spec.rb index 77c3e737fe35d..97b30162b0bb5 100644 --- a/Library/Homebrew/test/vulns/scanner_spec.rb +++ b/Library/Homebrew/test/vulns/scanner_spec.rb @@ -4,90 +4,6 @@ require "vulns/scanner" RSpec.describe Homebrew::Vulns::Scanner do - describe ".repo_url" do - it "extracts a GitHub repo from an archive/refs/tags URL" do - url = "https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz" - expect(described_class.repo_url(url)).to eq "https://github.com/nektos/act" - end - - it "extracts a GitHub repo from a releases/download URL" do - url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" - expect(described_class.repo_url(url)).to eq "https://github.com/owner/repo" - end - - it "extracts a GitHub repo from a .git URL" do - expect(described_class.repo_url("https://github.com/AomediaOrg/aom.git")) - .to eq "https://github.com/AomediaOrg/aom" - end - - it "extracts a GitLab repo, stripping the /-/ path segment" do - url = "https://gitlab.com/owner/repo/-/archive/v1.2.3/repo-v1.2.3.tar.gz" - expect(described_class.repo_url(url)).to eq "https://gitlab.com/owner/repo" - end - - it "extracts a Codeberg repo" do - url = "https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz" - expect(described_class.repo_url(url)).to eq "https://codeberg.org/owner/repo" - end - - it "falls back to the head URL when the stable URL is not a supported forge" do - stable = "https://aomedia.googlesource.com/aom.git" - head = "https://github.com/AomediaOrg/aom.git" - expect(described_class.repo_url(stable, head)).to eq "https://github.com/AomediaOrg/aom" - end - - it "falls back to the homepage when neither stable nor head is a supported forge" do - stable = "https://libssh2.org/download/libssh2-1.11.0.tar.gz" - homepage = "https://github.com/libssh2/libssh2" - expect(described_class.repo_url(stable, nil, homepage)).to eq "https://github.com/libssh2/libssh2" - end - - it "returns nil for unsupported hosts" do - expect(described_class.repo_url("https://example.com/source.tar.gz")).to be_nil - end - - it "returns nil for nil input" do - expect(described_class.repo_url(nil)).to be_nil - expect(described_class.repo_url(nil, nil)).to be_nil - end - end - - describe ".tag" do - it "extracts from archive/refs/tags .tar.gz" do - expect(described_class.tag("https://github.com/nektos/act/archive/refs/tags/v0.2.84.tar.gz")) - .to eq "v0.2.84" - end - - it "extracts a tag without a v prefix" do - url = "https://github.com/abseil/abseil-cpp/archive/refs/tags/20250814.1.tar.gz" - expect(described_class.tag(url)).to eq "20250814.1" - end - - it "extracts from archive/refs/tags .zip" do - expect(described_class.tag("https://github.com/owner/repo/archive/refs/tags/v1.0.0.zip")) - .to eq "v1.0.0" - end - - it "extracts from archive/.tar.gz" do - expect(described_class.tag("https://codeberg.org/owner/repo/archive/v1.2.3.tar.gz")) - .to eq "v1.2.3" - end - - it "extracts from releases/download//" do - url = "https://github.com/owner/repo/releases/download/v1.2.3/source.tar.gz" - expect(described_class.tag(url)).to eq "v1.2.3" - end - - it "extracts from tarball/" do - expect(described_class.tag("https://github.com/owner/repo/tarball/v1.2.3")).to eq "v1.2.3" - end - - it "returns nil when no tag pattern matches" do - expect(described_class.tag("https://example.com/source.tar.gz")).to be_nil - expect(described_class.tag(nil)).to be_nil - end - end - describe ".resolved_ids" do it "collects security-type resolves across all patches, uppercased and deduplicated" do patches = [ @@ -211,7 +127,7 @@ target = described_class.new([aom]).build_target(aom) - expect(target.repo_url).to eq "https://github.com/AomediaOrg/aom" + expect(target.repo_url).to eq "https://github.com/aomediaorg/aom" expect(target.tag).to eq "v3.13.1" end @@ -340,7 +256,7 @@ def osv_record(id, severity: "HIGH", **extra) it "skips formulae without a queryable repo URL and tag" do allow(Homebrew::Vulns::OSV).to receive(:query_batch).with( - [{ repo_url: "https://github.com/nektos/act", version: "v0.2.84" }], + [{ ecosystem: "GIT", name: "https://github.com/nektos/act", version: "v0.2.84" }], ).and_return([[]]) results = described_class.new([act, unsupported]).scan @@ -448,8 +364,8 @@ def osv_record(id, severity: "HIGH", **extra) described_class.new([core_thing, tap_thing]).scan expect(queried).to eq [ - { repo_url: "https://github.com/owner-a/thing", version: "v1.0.0" }, - { repo_url: "https://github.com/owner-b/thing", version: "v2.0.0" }, + { ecosystem: "GIT", name: "https://github.com/owner-a/thing", version: "v1.0.0" }, + { ecosystem: "GIT", name: "https://github.com/owner-b/thing", version: "v2.0.0" }, ] end @@ -535,7 +451,7 @@ def osv_record(id, severity: "HIGH", **extra) described_class.new([act]).scan - expect(queried).to eq [{ repo_url: "https://github.com/nektos/act", version: "v0.2.80" }] + expect(queried).to eq [{ ecosystem: "GIT", name: "https://github.com/nektos/act", version: "v0.2.80" }] end it "reports the installed version in findings" do @@ -571,7 +487,7 @@ def osv_record(id, severity: "HIGH", **extra) results = described_class.new([act]).scan - expect(queried).to eq [{ repo_url: "https://github.com/nektos/act", version: "v0.2.84" }] + expect(queried).to eq [{ ecosystem: "GIT", name: "https://github.com/nektos/act", version: "v0.2.84" }] expect(results.outdated_without_sbom).to eq ["act"] end end diff --git a/Library/Homebrew/test/vulns/vulnerability_spec.rb b/Library/Homebrew/test/vulns/vulnerability_spec.rb index 4e0dadb4194b6..63b8bf7e9f2cd 100644 --- a/Library/Homebrew/test/vulns/vulnerability_spec.rb +++ b/Library/Homebrew/test/vulns/vulnerability_spec.rb @@ -152,6 +152,152 @@ def semver_range(*events) end end + describe "#range_status" do + def affected(ecosystem, name, *ranges, versions: nil) + { "package" => { "ecosystem" => ecosystem, "name" => name }, + "ranges" => ranges, "versions" => versions }.compact + end + + def range(type, *events) + { "type" => type, "events" => events } + end + + it "matches only the affected entry for the given package" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "0" }, { "fixed" => "2.28.1" })), + affected("PyPI", "urllib3", range("ECOSYSTEM", { "introduced" => "0" }, { "fixed" => "1.26.5" })), + ]) + expect(v.range_status("PyPI", "requests", "2.27.0")) + .to have_attributes(affected?: true, fixed_in: "2.28.1") + expect(v.range_status("PyPI", "urllib3", "2.27.0")) + .to have_attributes(affected?: false, fixed_in: "1.26.5") + end + + it "returns nil when no affected entry matches the package" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "other", range("ECOSYSTEM", { "fixed" => "1.0" })), + ]) + expect(v.range_status("PyPI", "requests", "2.0")).to be_nil + end + + it "skips GIT ranges as uncomparable and returns nil when nothing else is checkable" do + v = vuln("id" => "CVE-2026-32316", "affected" => [ + affected("GIT", "https://github.com/jqlang/jq", + range("GIT", { "introduced" => "0" }, + { "fixed" => "e47e56d226519635768e6aab2f38f0ab037c09e5" })), + ]) + expect(v.range_status("GIT", "https://github.com/jqlang/jq", "1.8.1")).to be_nil + end + + it "uses a comparable range when the same entry also carries a GIT range" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("GIT", "https://github.com/jqlang/jq", + range("GIT", { "fixed" => "e47e56d" }), + range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.8.2" })), + ]) + expect(v.range_status("GIT", "https://github.com/jqlang/jq", "1.8.1")) + .to have_attributes(affected?: true, fixed_in: "1.8.2") + end + + it "unions all affected entries for the same package (Log4Shell has three)" do + log4j = lambda do |intro, fixed| + affected("Maven", "org.apache.logging.log4j:log4j-core", + range("ECOSYSTEM", { "introduced" => intro }, { "fixed" => fixed })) + end + v = vuln("id" => "GHSA-jfh8-c2jp-5v3q", "affected" => [ + log4j.call("2.0-beta9", "2.3.2"), + log4j.call("2.4", "2.12.4"), + log4j.call("2.13.0", "2.17.0"), + ]) + expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.3.0")) + .to have_attributes(state: :affected, fixed_in: "2.3.2") + expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.10.0")) + .to have_attributes(state: :affected, fixed_in: "2.12.4") + expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.17.0")) + .to have_attributes(state: :fixed, fixed_in: "2.17.0") + end + + it "reports :not_applicable when the target is below every introduced boundary" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })), + ]) + expect(v.range_status("PyPI", "requests", "2.31.0")) + .to have_attributes(state: :not_applicable, fixed_in: nil) + end + + it "does not report a last_affected boundary as fixed_in" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", + range("ECOSYSTEM", { "introduced" => "0" }, { "last_affected" => "2.0" })), + ]) + expect(v.range_status("PyPI", "requests", "2.0")) + .to have_attributes(state: :affected, fixed_in: nil) + expect(v.range_status("PyPI", "requests", "2.1")) + .to have_attributes(state: :fixed, fixed_in: "2.0") + end + + it "picks the fixed boundary of the interval containing the target across disjoint branches" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", + range("ECOSYSTEM", + { "introduced" => "0" }, { "fixed" => "2.28.1" }, + { "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })), + ]) + expect(v.range_status("PyPI", "requests", "3.0.1")) + .to have_attributes(affected?: true, fixed_in: "3.0.4") + expect(v.range_status("PyPI", "requests", "2.27.0")) + .to have_attributes(affected?: true, fixed_in: "2.28.1") + end + + it "reports :fixed with the highest fixed boundary at or below the target" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", + range("ECOSYSTEM", + { "introduced" => "0" }, { "fixed" => "2.28.1" }, + { "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })), + ]) + expect(v.range_status("PyPI", "requests", "2.31.0")) + .to have_attributes(state: :fixed, fixed_in: "2.28.1") + expect(v.range_status("PyPI", "requests", "3.1.0")) + .to have_attributes(state: :fixed, fixed_in: "3.0.4") + end + + it "reports affected with no fixed_in for an open-ended interval" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "2.0.0" })), + ]) + expect(v.range_status("PyPI", "requests", "2.31.0")) + .to have_attributes(affected?: true, fixed_in: nil) + end + + it "returns nil (not :not_applicable) when every comparison in the only range fails" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("crates.io", "serde", + range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.0.0" })), + ]) + expect(v.range_status("crates.io", "serde", "not-semver")).to be_nil + end + + it "compares SEMVER ranges with strict semver ordering" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("crates.io", "serde", + range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.0.0" })), + ]) + expect(v.range_status("crates.io", "serde", "1.0.0-rc.1")) + .to have_attributes(affected?: true, fixed_in: "1.0.0") + end + + it "checks an explicit versions list when present" do + v = vuln("id" => "CVE-1", "affected" => [ + affected("PyPI", "requests", versions: ["2.30.0", "2.31.0"]), + ]) + expect(v.range_status("PyPI", "requests", "2.31.0")) + .to have_attributes(state: :affected, fixed_in: nil) + expect(v.range_status("PyPI", "requests", "2.32.0")) + .to have_attributes(state: :not_applicable, fixed_in: nil) + end + end + describe "#identifiers and #cve_ids" do it "returns id followed by aliases" do v = vuln("id" => "GHSA-xxxx-yyyy-zzzz", "aliases" => ["CVE-2024-1234", "OSV-2024-1"]) @@ -162,6 +308,13 @@ def semver_range(*events) expect(vuln("id" => "CVE-2024-1234").identifiers).to eq ["CVE-2024-1234"] end + it "excludes upstream and related (directed references, not identities of this record)" do + v = vuln("id" => "DEBIAN-CVE-2024-1234", "upstream" => ["CVE-2024-1234"], + "related" => ["CVE-2024-9999"]) + expect(v.identifiers).to eq ["DEBIAN-CVE-2024-1234"] + expect(v.upstream).to eq ["CVE-2024-1234"] + end + it "extracts CVE ids from id and aliases" do v = vuln("id" => "CVE-2024-1234", "aliases" => ["GHSA-xxxx-yyyy-zzzz", "CVE-2024-5678"]) expect(v.cve_ids).to contain_exactly("CVE-2024-1234", "CVE-2024-5678") diff --git a/Library/Homebrew/utils/repology.rb b/Library/Homebrew/utils/repology.rb index 0cb63b9cfb3fb..61a593fb53144 100644 --- a/Library/Homebrew/utils/repology.rb +++ b/Library/Homebrew/utils/repology.rb @@ -1,6 +1,7 @@ # typed: strict # frozen_string_literal: true +require "erb" require "utils/curl" require "utils/output" @@ -8,16 +9,17 @@ module Repology extend Utils::Output::Mixin + API_BASE = "https://repology.org/api/v1" HOMEBREW_CORE = "homebrew" HOMEBREW_CASK = "homebrew_casks" sig { params(last_package_in_response: T.nilable(String), repository: String).returns(T::Hash[String, T.untyped]) } def self.query_api(last_package_in_response = "", repository:) - last_package_in_response += "/" if last_package_in_response.present? - url = "https://repology.org/api/v1/projects/#{last_package_in_response}?inrepo=#{repository}&outdated=1" + cursor = last_package_in_response.present? ? "#{ERB::Util.url_encode(last_package_in_response)}/" : "" + url = "#{API_BASE}/projects/#{cursor}?inrepo=#{repository}&outdated=1" result = Utils::Curl.curl_output( - "--silent", url.to_s, + "--fail", "--silent", url, use_homebrew_curl: !Utils::Curl.curl_supports_tls13? ) JSON.parse(result.stdout) @@ -33,12 +35,13 @@ def self.query_api(last_package_in_response = "", repository:) sig { params(name: String, repository: String).returns(T.nilable(T::Hash[String, T.untyped])) } def self.single_package_query(name, repository:) - url = "https://repology.org/api/v1/project/#{name}" + url = "#{API_BASE}/project/#{ERB::Util.url_encode(name)}" result = Utils::Curl.curl_output( - "--location", "--silent", url.to_s, + "--fail", "--location", "--silent", url, use_homebrew_curl: !Utils::Curl.curl_supports_tls13? ) + raise "curl exit #{result.exit_status}: #{result.stderr.strip}" unless result.success? data = JSON.parse(result.stdout) { name => data } diff --git a/Library/Homebrew/vulns/cached_feed.rb b/Library/Homebrew/vulns/cached_feed.rb new file mode 100644 index 0000000000000..adfab0772e23f --- /dev/null +++ b/Library/Homebrew/vulns/cached_feed.rb @@ -0,0 +1,80 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "tempfile" +require "utils/curl" + +module Homebrew + module Vulns + # Base class for read-only loaders of a single upstream JSON feed cached + # under `HOMEBREW_CACHE/vulns/`. Subclasses implement {.data_url}, + # {.cache_filename} and `#initialize(data)` (which validates the parsed + # payload) and may override {.default_max_age}. {.load} handles freshness, + # atomic refresh and stale-cache fallback uniformly. + class CachedFeed + extend T::Helpers + extend Utils::Output::Mixin + + abstract! + + class Error < RuntimeError; end + + sig { abstract.returns(String) } + def self.data_url; end + + sig { abstract.returns(String) } + def self.cache_filename; end + + sig { overridable.returns(Integer) } + def self.default_max_age = 86_400 + + sig { overridable.params(data: T.anything).void } + def initialize(data); end + + sig { params(cache: Pathname, max_age: Integer).returns(T.attached_class) } + def self.load(cache: HOMEBREW_CACHE/"vulns", max_age: default_max_age) + cache_file = cache/cache_filename + return from_file(cache_file) if cache_file.exist? && (Time.now - cache_file.mtime) <= max_age + + refresh(cache_file) + rescue ErrorDuringExecution, Error => e + raise unless cache_file.exist? + + opoo "Failed to refresh #{cache_filename} (#{e.message.lines.first&.strip}); " \ + "using cached copy from #{cache_file.mtime}." + from_file(cache_file) + end + + # Download to a per-process sibling temp file and validate before + # atomically replacing the cache so a failed, truncated or concurrent + # fetch cannot corrupt the stale copy. + sig { params(cache_file: Pathname).returns(T.attached_class) } + def self.refresh(cache_file) + cache_file.dirname.mkpath + Tempfile.create([cache_filename, ".download"], cache_file.dirname.to_s) do |tmp| + tmp.close + path = Pathname(tmp.path) + Utils::Curl.curl_download("--fail", "--silent", data_url, to: path) + loaded = from_file(path) + File.rename(path, cache_file) + return loaded + end + end + + sig { params(path: Pathname).returns(T.attached_class) } + def self.from_file(path) + new(JSON.parse(path.read)) + rescue JSON::ParserError => e + raise Error, "Failed to parse #{cache_filename} at #{path}: #{e.message}" + end + + sig { params(value: T.anything).returns(T.nilable(T::Hash[String, T.untyped])) } + def as_hash(value) + case value + when Hash then value + end + end + end + end +end diff --git a/Library/Homebrew/vulns/cpan_sec.rb b/Library/Homebrew/vulns/cpan_sec.rb new file mode 100644 index 0000000000000..d21dd6b4d19ab --- /dev/null +++ b/Library/Homebrew/vulns/cpan_sec.rb @@ -0,0 +1,130 @@ +# typed: strict +# frozen_string_literal: true + +require "vulns/cached_feed" +require "vulns/vulnerability" + +module Homebrew + module Vulns + # Loader for the CPAN Security Advisory database. + # Source: https://github.com/briandfoy/cpan-security-advisory + # + # The upstream repository ships a compiled `cpan-security-advisory.json` + # keyed on CPAN distribution name. This class fetches and caches that file + # and exposes advisories per distribution. Evaluating `affected_versions` + # range strings against a formula version is left to {Vulns::Match}. + class CPANSec < CachedFeed + DATA_URL = "https://raw.githubusercontent.com/briandfoy/cpan-security-advisory/" \ + "master/cpan-security-advisory.json" + + sig { override.returns(String) } + def self.data_url = DATA_URL + + sig { override.returns(String) } + def self.cache_filename = "cpansa.json" + + Advisory = Struct.new( + :id, :cves, :affected_versions, :fixed_versions, + :severity, :description, :references, :reported, + keyword_init: true + ) + + sig { override.params(data: T.anything).void } + def initialize(data) + super + raise Error, "CPANSA data is not a JSON object" unless (top = as_hash(data)) + raise Error, "CPANSA data missing 'dists' key" unless (dists = as_hash(top["dists"])) + + @dists = T.let(dists, T::Hash[String, T.untyped]) + @meta = T.let(as_hash(top["meta"]) || {}, T::Hash[String, T.untyped]) + end + + sig { returns(T::Hash[String, T.untyped]) } + attr_reader :meta + + sig { returns(T::Array[String]) } + def distributions + @dists.keys + end + + sig { params(distribution: String).returns(T::Array[Advisory]) } + def advisories_for(distribution) + entry = @dists[distribution] + return [] unless entry.is_a?(Hash) + + Array(entry["advisories"]).filter_map { |a| build_advisory(a) if a.is_a?(Hash) } + end + + # CPANSA constraints: each `affected_versions` array entry is a + # comma-joined AND of `<`/`<=`/`>`/`>=`/`==`/`=`/bare-version terms; the + # array is an OR of those. `fixed_versions` uses the same grammar. + # Compared with {Version}; Perl's decimal-vs-dotted equivalence + # (`1.002003` == `v1.2.3`) is not modelled since homebrew-core CPAN + # formulae uniformly use the decimal form. + sig { params(advisory: Advisory, version: String).returns(Vulnerability::RangeStatus) } + def self.range_status(advisory, version) + target = Version.new(version.sub(/\Av/i, "")) + affected = advisory.affected_versions.empty? || + advisory.affected_versions.any? { |c| satisfies?(target, c) } + bounds = advisory.fixed_versions.flat_map { |c| lower_bounds(c) } + if affected + fixed_in = bounds.select { |v| target < v }.min&.to_s + Vulnerability::RangeStatus.new(state: :affected, fixed_in:).freeze + elsif advisory.fixed_versions.any? { |c| satisfies?(target, c) } + fixed_in = bounds.select { |v| target >= v }.max&.to_s + Vulnerability::RangeStatus.new(state: :fixed, fixed_in:).freeze + else + Vulnerability::RangeStatus.new(state: :not_applicable, fixed_in: nil).freeze + end + end + + CONSTRAINT = /\A\s*(<=|>=|==|<|>|=)?\s*v?(\d[\w.]*)\s*\z/ + private_constant :CONSTRAINT + + LOWER_BOUND_OPS = [">=", ">", "==", "=", nil].freeze + private_constant :LOWER_BOUND_OPS + + sig { params(target: Version, conjunction: String).returns(T::Boolean) } + def self.satisfies?(target, conjunction) + conjunction.split(",").all? do |term| + match = term.match(CONSTRAINT) + next false unless match + + bound = Version.new(T.must(match[2])) + case match[1] + when "<" then target < bound + when "<=" then target <= bound + when ">" then target > bound + when ">=" then target >= bound + else target == bound + end + end + end + + sig { params(conjunction: String).returns(T::Array[Version]) } + def self.lower_bounds(conjunction) + conjunction.split(",").filter_map do |term| + match = term.match(CONSTRAINT) + Version.new(T.must(match[2])) if match && LOWER_BOUND_OPS.include?(match[1]) + end + end + + sig { params(raw: T::Hash[String, T.untyped]).returns(T.nilable(Advisory)) } + def build_advisory(raw) + id = raw["id"] + return if id.nil? + + Advisory.new( + id:, + cves: Array(raw["cves"]).map(&:to_s), + affected_versions: Array(raw["affected_versions"]).map(&:to_s), + fixed_versions: Array(raw["fixed_versions"]).map(&:to_s), + severity: raw["severity"], + description: raw["description"], + references: Array(raw["references"]).map(&:to_s), + reported: raw["reported"], + ).freeze + end + end + end +end diff --git a/Library/Homebrew/vulns/identify.rb b/Library/Homebrew/vulns/identify.rb new file mode 100644 index 0000000000000..b996ef725d7df --- /dev/null +++ b/Library/Homebrew/vulns/identify.rb @@ -0,0 +1,228 @@ +# typed: strict +# frozen_string_literal: true + +require "vulns/purl" + +module Homebrew + module Vulns + # Derives OSV.dev query keys (forge repo URL, release tag) from formula + # source URLs. Shared between {Scanner} and the advisory-matching pipeline. + module Identify + TWO_SEGMENT_PATH = %r{/([^/]+/[^/]+)} + private_constant :TWO_SEGMENT_PATH + + # GitLab supports nested subgroups (e.g. `xorg/lib/libx11`); the path is + # bounded by `.git`, the `/-/` route marker, the legacy `/uploads/` and + # `/wikis/` routes, or the end of the URL. Host-level `/-/` and `/api/` + # routes are rejected via the leading negative lookahead. + GITLAB_PATH = %r{/(?!-|api/)([^/]+(?:/[^/]+)+?)(?:\.git)?(?=/-/|/uploads/|/wikis/|/?\z)} + private_constant :GITLAB_PATH + + FORGES = T.let( + { + "github.com" => TWO_SEGMENT_PATH, + "codeberg.org" => TWO_SEGMENT_PATH, + "gitlab.com" => GITLAB_PATH, + "gitlab.gnome.org" => GITLAB_PATH, + "gitlab.freedesktop.org" => GITLAB_PATH, + "invent.kde.org" => GITLAB_PATH, + }.freeze, + T::Hash[String, Regexp], + ) + private_constant :FORGES + + TAG_PATTERNS = T.let( + [ + %r{/archive/refs/tags/([^/]+)\.tar\.gz$}, + %r{/archive/refs/tags/([^/]+)\.zip$}, + %r{/archive/([^/]+)\.tar\.gz$}, + %r{/archive/([^/]+)\.zip$}, + %r{/releases/download/([^/]+)/}, + %r{/tarball/([^/]+)$}, + ].freeze, + T::Array[Regexp], + ) + private_constant :TAG_PATTERNS + + WAYBACK_PREFIX = %r{\Ahttps?://web\.archive\.org/web/\d+[a-z_*]*/} + private_constant :WAYBACK_PREFIX + + # OSV.dev's GIT ecosystem indexes repository URLs case-sensitively but + # normalises `github.com` paths to lowercase (GitHub itself is + # case-insensitive). GitLab and Codeberg are case-sensitive so their + # paths are preserved. + LOWERCASE_PATH_HOSTS = ["github.com"].freeze + private_constant :LOWERCASE_PATH_HOSTS + + sig { params(urls: T.nilable(String)).returns(T.nilable(String)) } + def self.repo_url(*urls) + urls.each do |url| + next if url.nil? + + url = url.sub(WAYBACK_PREFIX, "") + FORGES.each do |host, path_pattern| + match = url.match(%r{\Ahttps?://#{Regexp.escape(host)}#{path_pattern}}) + next if match.nil? + + repo_path = T.must(match[1]).sub(/\.git$/, "") + repo_path = repo_path.downcase if LOWERCASE_PATH_HOSTS.include?(host) + return "https://#{host}/#{repo_path}" + end + end + nil + end + + sig { params(url: T.nilable(String)).returns(T.nilable(String)) } + def self.tag(url) + return if url.nil? + + TAG_PATTERNS.each do |pattern| + match = url.match(pattern) + return match[1] if match + end + nil + end + + # `ecosystem` is the OSV.dev ecosystem identifier for `name`, or `"CPAN"` + # for CPAN distributions (queried via CPANSA, not OSV). + RegistryPackage = Struct.new(:ecosystem, :name, :version, :purl, keyword_init: true) + + ARCHIVE_EXTENSIONS = /\.(?:tar\.gz|tar\.bz2|tar\.xz|tgz|zip|gem|crate|tar|nupkg)\z/i + private_constant :ARCHIVE_EXTENSIONS + + # Cabal package versions are dot-separated non-negative integers only. + HACKAGE_PKGID = /\A(.+)-(\d+(?:\.\d+)*)\z/ + private_constant :HACKAGE_PKGID + + # Simplified from CPAN::DistnameInfo: greedy name, version is digits/ + # dots/underscores optionally `v`-prefixed. A -TRIAL suffix is stripped. + # Does not handle the rare `_`-separated form (e.g. `libao-perl_0.03-1`); + # no homebrew-core formula currently uses it. + CPAN_DISTNAME = /\A(.+)-(v?\d[\d._]*)(?:-TRIAL\d*)?\z/ + private_constant :CPAN_DISTNAME + + # Recognise a `Gem::Platform` suffix by its OS token; the CPU token is + # open-ended (riscv64, s390x, ppc64le, ...) so is matched generically. + GEM_PLATFORM_SUFFIX = / + -(?: + java|jruby|truffleruby|dalvik|dotnet|mswin\d+(?:_\d+)?| + \w+- + (?:aix|cygwin|darwin|freebsd|linux|macruby|mingw\w*|mswin\d*| + netbsd\w*|openbsd|bitrig|solaris|wasi) + (?:[-_][\w.]+)? + )\z + /x + private_constant :GEM_PLATFORM_SUFFIX + + sig { params(url: T.nilable(String)).returns(T.nilable(RegistryPackage)) } + def self.registry_package(url) + return if url.nil? + + ecosystem, purl = registry_purl(url) + return if purl.nil? + + name = case purl.type + when "maven" then "#{purl.namespace}:#{purl.name}" + # OSV keys PyPI packages by their PEP 503 normalised name. + when "pypi" then purl.name.gsub(/[-_.]+/, "-") + # CPANSA is keyed on the distribution name alone, without the author. + when "cpan" then purl.name + else purl.namespace ? "#{purl.namespace}/#{purl.name}" : purl.name + end + RegistryPackage.new(ecosystem:, name:, version: purl.version, purl: purl.to_s).freeze + end + + sig { params(url: String).returns(T.nilable([String, Purl])) } + def self.registry_purl(url) + basename = decode(File.basename(url)).sub(ARCHIVE_EXTENSIONS, "") + + case url + when %r{\Ahttps://files\.pythonhosted\.org/packages/(?:[^/]+/){3}(?![^/]+\.whl\z)} + # PEP 440 canonical versions contain no hyphen, so the last one delimits. + name, _, version = basename.rpartition("-") + return if name.empty? + + ["PyPI", Purl.new(type: "pypi", name:, version:)] + when %r{\Ahttps://registry\.npmjs\.org/(?:((?:@|%40)[^/]+)/)?([^/@%][^/]*)/-/} + namespace = Regexp.last_match(1) + name = T.must(Regexp.last_match(2)) + namespace &&= "@#{decode(namespace).delete_prefix("@")}" + name = decode(name) + return unless (version = version_after_prefix(basename, name)) + + ["npm", Purl.new(type: "npm", namespace:, name:, version:)] + when %r{\Ahttps://static\.crates\.io/crates/([^/]+)/} + name = decode(T.must(Regexp.last_match(1))) + return unless (version = version_after_prefix(basename, name)) + + ["crates.io", Purl.new(type: "cargo", name:, version:)] + when %r{\Ahttps://rubygems\.org/(?:downloads|gems)/} + name, version = gem_name_version(basename) + return if name.nil? + + ["RubyGems", Purl.new(type: "gem", name:, version:)] + when %r{\Ahttps://hackage\.haskell\.org/package/([^/]+)} + match = T.must(Regexp.last_match(1)).match(HACKAGE_PKGID) + return if match.nil? + + ["Hackage", Purl.new(type: "hackage", name: T.must(match[1]), version: match[2])] + when %r{\Ahttps://repo\.hex\.pm/tarballs/} + # Hex package names are `[a-z][a-z0-9_]*` so the first hyphen delimits. + name, sep, version = basename.partition("-") + return if sep.empty? + + ["Hex", Purl.new(type: "hex", name:, version:)] + when %r{/authors/id/[A-Z]/[A-Z]{2}/([A-Z][A-Z0-9-]+)/} + author = T.must(Regexp.last_match(1)) + match = basename.match(CPAN_DISTNAME) + return if match.nil? + + ["CPAN", Purl.new(type: "cpan", namespace: author, name: T.must(match[1]), version: match[2])] + # Maven Central only: OSV's bare `Maven` ecosystem is Central-scoped, + # so third-party repositories (Google, fabricmc, jfrog, ...) are skipped. + when %r{\Ahttps://repo1?\.maven\.(?:apache\.)?org/maven2/(.+)/([^/]+)/([^/]+)/\2-\3[.-][^/]+\z}, + %r{\Ahttps://search\.maven\.org/remotecontent\?filepath=(.+)/([^/]+)/([^/]+)/\2-\3[.-][^/]+\z} + group_id = T.must(Regexp.last_match(1)).tr("/", ".") + artifact_id = T.must(Regexp.last_match(2)) + version = Regexp.last_match(3) + ["Maven", Purl.new(type: "maven", namespace: group_id, name: artifact_id, version:)] + when %r{\Ahttps://(?:cran|cloud)\.r-project\.org/src/contrib/(?:Archive/[^/]+/)?([^/_]+)_([^/]+)\.tar\.gz\z} + ["CRAN", Purl.new(type: "cran", name: T.must(Regexp.last_match(1)), version: Regexp.last_match(2))] + when %r{\Ahttps://(?:api|www)\.nuget\.org/(?:v3-flatcontainer|api/v2/package)/([^/]+)/([^/]+)(?:/|\z)} + ["NuGet", Purl.new(type: "nuget", name: T.must(Regexp.last_match(1)), version: Regexp.last_match(2))] + end + end + + # Percent-decode a URL path segment. Unlike `decode_www_form_component` + # this leaves `+` alone and unlike `decode_uri_component` (missing from + # Sorbet's stdlib RBI) it never raises on malformed input. + sig { params(component: String).returns(String) } + def self.decode(component) + return component unless component.include?("%") + + component.b.gsub(/%[0-9A-Fa-f]{2}/) { |m| Integer(m[1, 2], 16).chr } + .force_encoding(component.encoding) + end + + sig { params(basename: String, name: String).returns(T.nilable(String)) } + def self.version_after_prefix(basename, name) + prefix = "#{name}-" + return unless basename.start_with?(prefix) + + version = basename[prefix.length..] + version.presence + end + + # Split a `.gem` basename into name and version, discarding any trailing + # {Gem::Platform} suffix (e.g. `nokogiri-1.16.0-arm64-darwin-22`). + sig { params(basename: String).returns([T.nilable(String), T.nilable(String)]) } + def self.gem_name_version(basename) + deplatformed = basename.sub(GEM_PLATFORM_SUFFIX, "") + name, sep, version = deplatformed.rpartition("-") + return [nil, nil] if sep.empty? || !version.match?(/\A\d[\w.]*\z/) + + [name, version] + end + end + end +end diff --git a/Library/Homebrew/vulns/match.rb b/Library/Homebrew/vulns/match.rb new file mode 100644 index 0000000000000..0b64434599a05 --- /dev/null +++ b/Library/Homebrew/vulns/match.rb @@ -0,0 +1,641 @@ +# typed: strict +# frozen_string_literal: true + +require "formula_versions" +require "vulns/cpan_sec" +require "vulns/identify" +require "vulns/osv" +require "vulns/osv_export" +require "vulns/repology" +require "vulns/vulnerability" + +module Homebrew + module Vulns + # Authoring-time advisory matcher. For a given {Formula} it derives every + # OSV.dev query key it can (forge repository, language-registry package for + # the primary URL and each `resource`, distro source packages via + # {Repology}, CPAN distribution via {CPANSec}), issues *versionless* queries + # against each, resolves distro advisories to their upstream CVEs, and + # evaluates each hit's affected range against the version we ship. + # + # Runs in `Homebrew/advisory-database` CI and the homebrew-core PR bot to + # produce candidate `BREW-*` records for human review; never on a user's + # machine, so request volume is traded for recall and every candidate + # carries a strategy/confidence label for the reviewer. + class Match + include Utils::Output::Mixin + + # Descending precision. When several strategies reach the same CVE the + # highest is reported as the hit's primary strategy; the rest are kept as + # supporting evidence. + STRATEGY_PRECISION = T.let( + { git: 4, registry: 3, cpansa: 2, distro: 1 }.freeze, + T::Hash[Symbol, Integer], + ) + + # Recorded in `database_specific.confidence` for the reviewer. + CONFIDENCE = T.let( + { git: "high", registry: "high", cpansa: "medium", distro: "low" }.freeze, + T::Hash[Symbol, String], + ) + + Identity = Struct.new( + :git_repo, :git_tag, :primary_package, :resource_packages, :distro_packages, + keyword_init: true + ) do + sig { returns(T::Boolean) } + def identifiable? + !git_repo.nil? || !primary_package.nil? || resource_packages.any? || distro_packages.any? + end + end + + # `ecosystem`/`name` are the OSV `package` fields queried, so a hit's + # `affected[]` entry can be matched back to this evidence. + # `subject_version` is the version to evaluate that entry's ranges + # against: the formula version for the primary source, the pinned + # resource version for a resource, `nil` for distro (whose versions are + # not comparable to ours). `advisory` carries the CPANSA record for + # `:cpansa` evidence so its constraint strings survive to + # {#range_status}. `source_record` is the {Vulnerability} this evidence + # was matched against (attached at hit-construction time), so after + # {#dedup_by_cve} merges hits each evidence still points at the record + # whose `affected[]` it should be checked against. + Evidence = Struct.new(:strategy, :ecosystem, :name, :subject_version, :key, :resource, + :advisory, :source_record, keyword_init: true) do + sig { params(record: Vulnerability).returns(T.untyped) } + def with_source(record) + source_record ? self : self.class.new(**to_h, source_record: record).freeze + end + end + + class Hit + sig { returns(Vulnerability) } + attr_reader :vulnerability + + sig { returns(T::Array[Evidence]) } + attr_reader :evidence + + sig { params(vulnerability: Vulnerability, evidence: T::Array[Evidence]).void } + def initialize(vulnerability:, evidence:) + raise ArgumentError, "Hit requires at least one Evidence" if evidence.empty? + + @vulnerability = vulnerability + @evidence = T.let( + evidence.map { |e| e.with_source(vulnerability) } + .sort_by { |e| -STRATEGY_PRECISION.fetch(e.strategy) }.freeze, + T::Array[Evidence], + ) + end + + sig { returns(Evidence) } + def primary_evidence + evidence.fetch(0) + end + + sig { returns(Symbol) } + def strategy + primary_evidence.strategy + end + + sig { returns(T.nilable(String)) } + def resource + primary_evidence.resource + end + + sig { returns(String) } + def canonical_id + vulnerability.cve_ids.min || vulnerability.id + end + end + + sig { params(repology: T.nilable(Repology), cpan_sec: T.nilable(CPANSec), bulk: T::Boolean).void } + def initialize(repology: nil, cpan_sec: nil, bulk: false) + @repology = repology + @cpan_sec = cpan_sec + @bulk = bulk + @vuln_cache = T.let({}, T::Hash[String, T.nilable(Vulnerability)]) + @formula_versions = T.let({}, T::Hash[String, FormulaVersions]) + @formula_rev_lists = T.let({}, T::Hash[String, T::Array[[String, String]]]) + end + + sig { returns(Repology) } + def repology + @repology ||= Repology.load + end + + sig { returns(CPANSec) } + def cpan_sec + @cpan_sec ||= CPANSec.load + end + + sig { params(formula: Formula).returns(Identity) } + def identify(formula) + stable = formula.stable + stable_url = stable&.url + Identity.new( + git_repo: Identify.repo_url(stable_url, formula.head&.url, formula.homepage), + git_tag: Identify.tag(stable_url) || stable&.specs&.dig(:tag) || stable&.version&.to_s, + primary_package: Identify.registry_package(stable_url), + resource_packages: formula.resources.filter_map do |r| + pkg = Identify.registry_package(r.url) + [r.name, pkg] if pkg + end.to_h.freeze, + distro_packages: distro_packages_for(formula.name), + ).freeze + end + + # Returns one {Hit} per distinct vulnerability (grouped by CVE alias) + # reached by any strategy. Distro-ecosystem records are resolved to their + # `upstream` CVE(s) so multi-CVE advisories split into per-CVE hits and + # collapse onto the same CVE reached via GIT/registry. All queries are + # versionless so historic bump-fixed advisories are returned; + # {#range_status} evaluates each hit against the shipped version. + sig { params(formula: Formula).returns(T::Array[Hit]) } + def advisories_for(formula) + result = T.let([], T::Array[Hit]) + each_advisory_batch([formula]) { |_, hits| result = hits } + result + end + + BULK_CHUNK = 200 + private_constant :BULK_CHUNK + + # Bulk form of {#advisories_for}: builds the labelled queries for a chunk + # of formulae at once, sends them through a single {OSV.query_batch} + # (which itself slices at `BATCH_SIZE`), then yields `(formula, hits)` in + # input order. Per-formula query counts vary widely (one distro entry per + # ecosystem×srcname), so chunking bounds memory without accumulating the + # whole tap's queries or records; the `@vuln_cache` still spans chunks. + sig { + params(formulae: T::Enumerable[Formula], + _blk: T.proc.params(formula: Formula, hits: T::Array[Hit]).void).void + } + def each_advisory_batch(formulae, &_blk) + formulae.each_slice(BULK_CHUNK) do |chunk| + identities = chunk.map { |f| [f, identify(f)] } + labelled = T.let([], T::Array[[OSV::Package, [Formula, Evidence]]]) + identities.each do |f, identity| + next unless identity.identifiable? + + build_osv_queries(identity, f.version.to_s).each do |query, evidence| + labelled << [query, [f, evidence]] + end + end + + by_formula = T.let({}, T::Hash[Formula, T::Hash[String, T::Array[Evidence]]]) + if labelled.any? + OSV.query_batch(labelled.map(&:first)).each_with_index do |stubs, i| + formula, evidence = labelled.fetch(i).last + id_evidence = by_formula[formula] ||= {} + stubs.each { |stub| (id_evidence[stub.fetch("id")] ||= []) << evidence } + end + end + + prefetch_vulnerabilities(by_formula.each_value.flat_map(&:keys)) + + identities.each do |f, identity| + next yield f, [] unless identity.identifiable? + + yield f, hits_from(by_formula[f] || {}, identity) + end + end + end + + sig { + params(id_evidence: T::Hash[String, T::Array[Evidence]], identity: Identity).returns(T::Array[Hit]) + } + def hits_from(id_evidence, identity) + hits = resolve_upstream(id_evidence, identity) + cpan_evidence(identity).each do |ev| + cpan_sec.advisories_for(ev.name).each do |adv| + annotated = Evidence.new(**ev.to_h, advisory: adv).freeze + if adv.cves.any? + adv.cves.each do |cve| + record = fetch_vulnerability(cve) || cpansa_vulnerability(adv, id: cve) + hits << Hit.new(vulnerability: record, evidence: [annotated]) + end + else + hits << Hit.new(vulnerability: cpansa_vulnerability(adv, id: adv.id.to_s), + evidence: [annotated]) + end + end + end + dedup_by_cve(hits) + end + + # Synthesise a {Vulnerability} for a CPANSA advisory when OSV has no + # record. `id` is scoped to the single CVE (or CPANSA id) being handled + # so a multi-CVE advisory whose CVEs are absent from OSV yields distinct + # records instead of collapsing under the lowest CVE in dedup. + sig { params(adv: CPANSec::Advisory, id: String).returns(Vulnerability) } + def cpansa_vulnerability(adv, id:) + summary = adv.description.to_s.lines.first&.strip + Vulnerability.new({ + "id" => id, + "summary" => summary, + "details" => adv.description, + "references" => adv.references.map { |u| { "type" => "WEB", "url" => u } }, + }.compact) + end + + sig { + params(identity: Identity, formula_version: String).returns(T::Array[[OSV::Package, Evidence]]) + } + def build_osv_queries(identity, formula_version) + queries = T.let([], T::Array[[OSV::Package, Evidence]]) + + if (repo = identity.git_repo) + queries << [{ ecosystem: "GIT", name: repo, version: nil }, + Evidence.new(strategy: :git, ecosystem: "GIT", name: repo, + subject_version: identity.git_tag || formula_version, + key: repo).freeze] + end + + if (pkg = identity.primary_package) && pkg.ecosystem != "CPAN" + queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: nil }, + Evidence.new(strategy: :registry, ecosystem: pkg.ecosystem, name: pkg.name, + subject_version: pkg.version, key: pkg.purl).freeze] + end + + identity.resource_packages.each do |resource, pkg| + next if pkg.ecosystem == "CPAN" + + queries << [{ ecosystem: pkg.ecosystem, name: pkg.name, version: nil }, + Evidence.new(strategy: :registry, ecosystem: pkg.ecosystem, name: pkg.name, + subject_version: pkg.version, key: pkg.purl, resource:).freeze] + end + + identity.distro_packages.each do |ecosystem, srcnames| + srcnames.each do |srcname| + queries << [{ ecosystem:, name: srcname, version: nil }, + Evidence.new(strategy: :distro, ecosystem:, name: srcname, + key: "#{ecosystem}/#{srcname}").freeze] + end + end + + queries + end + + sig { params(identity: Identity).returns(T::Array[Evidence]) } + def cpan_evidence(identity) + result = T.let([], T::Array[Evidence]) + primary = identity.primary_package + if primary&.ecosystem == "CPAN" + result << Evidence.new(strategy: :cpansa, ecosystem: "CPAN", name: primary.name, + subject_version: primary.version, key: primary.purl) + end + identity.resource_packages.each do |resource, pkg| + next if pkg.ecosystem != "CPAN" + + result << Evidence.new(strategy: :cpansa, ecosystem: "CPAN", name: pkg.name, + subject_version: pkg.version, key: pkg.purl, resource:) + end + result + end + + CVE_ID = /\ACVE-\d{4}-\d+\z/ + private_constant :CVE_ID + + MAX_UPSTREAM_HOPS = 5 + private_constant :MAX_UPSTREAM_HOPS + + # Turn `id => [Evidence, ...]` into `[Hit, ...]`, resolving each record to + # the CVE(s) it derives from. `upstream` is walked transitively with a + # per-walk visited set (chains like `USN -> UBUNTU-CVE-* -> CVE-*` occur + # in practice). `related` links to different vulnerabilities per the OSV + # schema and is only consulted for its bare CVE ids when `upstream` is + # empty (AlmaLinux ALSA records use it that way). A record that is + # already a CVE by id or alias, or that reaches no CVE within the hop + # budget, is kept as-is. Each resolved hit gains synthesised evidence + # pointing at our own identity so {#range_status} can check the CVE + # record's `affected[]` against our version. + sig { + params(id_evidence: T::Hash[String, T::Array[Evidence]], identity: Identity) + .returns(T::Array[Hit]) + } + def resolve_upstream(id_evidence, identity) + own = own_evidence(identity) + hits = T.let([], T::Array[Hit]) + + id_evidence.each do |id, evidence| + record = fetch_vulnerability(id) + next if record.nil? + + resolved = resolve_to_cves(record, Set[id], MAX_UPSTREAM_HOPS) + if resolved.empty? + hits << Hit.new(vulnerability: record, evidence:) + next + end + + resolved.each do |cve_record| + ev = cve_record.equal?(record) ? evidence : evidence + own + hits << Hit.new(vulnerability: cve_record, evidence: ev) + end + end + + hits + end + + # AlmaLinux ALSA-* records list their source CVEs in `related` rather than + # `upstream`. That is a data-source quirk; per the OSV schema `related` + # otherwise names *different* vulnerabilities and must not be traversed. + RELATED_AS_UPSTREAM_PREFIX = "ALSA-" + private_constant :RELATED_AS_UPSTREAM_PREFIX + + # Returns the set of CVE records `record` derives from. `[record]` if it + # is one already; `[]` if the walk exhausts without reaching a CVE (the + # caller then keeps `record` itself as a low-confidence hit). + sig { + params(record: Vulnerability, seen: T::Set[String], budget: Integer) + .returns(T::Array[Vulnerability]) + } + def resolve_to_cves(record, seen, budget) + return [record] if record.cve_ids.any? + return [] if budget.zero? + + follow = record.upstream.presence + follow ||= record.related.grep(CVE_ID) if record.id.start_with?(RELATED_AS_UPSTREAM_PREFIX) + Array(follow).uniq.flat_map do |ref| + next [] unless seen.add?(ref) + + upstream = fetch_vulnerability(ref) + upstream ? resolve_to_cves(upstream, seen, budget - 1) : [] + end.uniq(&:id) + end + + # Evidence rows pointing at our own identity keys (git repo, primary + # registry package) with the formula/package version as subject. Attached + # to distro-resolved upstream hits so {#range_status} can evaluate the + # upstream CVE record's `affected[]` against something comparable. + sig { params(identity: Identity).returns(T::Array[Evidence]) } + def own_evidence(identity) + result = T.let([], T::Array[Evidence]) + if (repo = identity.git_repo) + result << Evidence.new(strategy: :distro, ecosystem: "GIT", name: repo, + subject_version: identity.git_tag, key: "upstream:#{repo}").freeze + end + if (pkg = identity.primary_package) + result << Evidence.new(strategy: :distro, ecosystem: pkg.ecosystem, name: pkg.name, + subject_version: pkg.version, key: "upstream:#{pkg.purl}").freeze + end + result + end + + # Bulk mode (the `--all` sweep) trusts the published index; only a + # single-formula run (the PR bot, or an explicit named check) may hit the + # live Repology API for a formula the index doesn't yet cover. + sig { params(name: String).returns(Repology::DistroMap) } + def distro_packages_for(name) + indexed = repology.distro_packages_for(name) + return indexed if indexed.any? || @bulk + + Repology.lookup(name) + rescue CachedFeed::Error => e + odebug "Repology lookup for #{name} failed: #{e.message}" + {} + end + + MAX_VULN_FETCH_THREADS = 15 + private_constant :MAX_VULN_FETCH_THREADS + + # OSV `querybatch` returns id/modified stubs. Warm `@vuln_cache` with the + # full records for a chunk's stub ids before per-formula processing so + # {#resolve_upstream} reads mostly from cache. + sig { params(ids: T::Array[String]).void } + def prefetch_vulnerabilities(ids) + missing = ids.uniq.reject { |id| @vuln_cache.key?(id) } + missing.each_slice(MAX_VULN_FETCH_THREADS) do |slice| + slice.map { |id| [id, Thread.new { load_vulnerability(id) }] } + .each { |id, t| @vuln_cache[id] = t.value } + end + end + + sig { params(id: String).returns(T.nilable(Vulnerability)) } + def fetch_vulnerability(id) + @vuln_cache.fetch(id) { @vuln_cache[id] = load_vulnerability(id) } + end + + sig { params(id: String).returns(T.nilable(Vulnerability)) } + def load_vulnerability(id) + Vulnerability.new(OSV.vulnerability(id)) + rescue OSV::Error => e + odebug "OSV.vulnerability(#{id}) failed: #{e.message}" + nil + end + + sig { params(hits: T::Array[Hit]).returns(T::Array[Hit]) } + def dedup_by_cve(hits) + hits.group_by(&:canonical_id).map do |_, group| + next group.fetch(0) if group.one? + + primary = T.must(group.max_by { |h| STRATEGY_PRECISION.fetch(h.strategy) }) + Hit.new(vulnerability: primary.vulnerability, + evidence: group.flat_map(&:evidence).uniq) + end + end + + # Evaluate `hit` against every evidence's subject, each against the + # record that evidence was matched against, and aggregate: `:affected` if + # any subject is affected (a fixed primary must not hide an affected + # resource, or vice versa), else `:fixed` if any is fixed, else + # `:not_applicable` only when every comparable subject says so. Returns + # `[status, evidence]` where `evidence` is the one whose result was + # chosen (used by {#first_fixed_version} and for the emitted record's + # resource attribution), or `nil` if no evidence produced a checkable + # answer. + sig { params(hit: Hit).returns(T.nilable([Vulnerability::RangeStatus, Evidence])) } + def range_status(hit) + results = hit.evidence.filter_map do |ev| + status = evidence_range_status(ev, ev.subject_version) + [status, ev] if status + end + return if results.empty? + + results.find { |s, _| s.affected? } || + results.find { |s, _| s.fixed? } || + results.first + end + + sig { + params(evidence: Evidence, subject_version: T.nilable(String)) + .returns(T.nilable(Vulnerability::RangeStatus)) + } + def evidence_range_status(evidence, subject_version) + return if subject_version.nil? + + if evidence.strategy == :cpansa + adv = evidence.advisory + CPANSec.range_status(adv, subject_version) if adv + else + evidence.source_record&.range_status(evidence.ecosystem, evidence.name, subject_version) + end + end + + # Emit a candidate `BREW-*` OSV record for `hit` against `formula`. + # + # `first_fixed` is the {PkgVersion} at which Homebrew first shipped a fix + # (from {#first_fixed_version} or a hand-set value). Otherwise + # {#range_status} is consulted: `affected? == false` sets + # `fixed: pkg_version` and `ecosystem_specific.fix: "bump"`; + # `affected? == true` (or no comparable range) emits no `fixed` event and + # `fix: null`. As with {OsvExport.record_for}, {OsvExport.merge_existing} + # preserves on-disk `ranges` on rewrite so a hand-corrected boundary + # sticks. + sig { + params(formula: Formula, hit: Hit, first_fixed: T.nilable(String), now: Time) + .returns(T::Hash[Symbol, T.untyped]) + } + def to_brew_record(formula, hit, first_fixed: nil, now: Time.now.utc) + vuln = hit.vulnerability + timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") + status, status_evidence = range_status(hit) + + fixed = first_fixed + fixed ||= formula.pkg_version.to_s if status&.fixed? + events = T.let([{ introduced: "0" }], T::Array[T::Hash[Symbol, String]]) + events << { fixed: } if fixed + + record = T.let({ + schema_version: OsvExport::SCHEMA_VERSION, + id: "#{OsvExport::ID_PREFIX}-#{formula.name}-#{hit.canonical_id}", + published: timestamp, + modified: timestamp, + upstream: vuln.identifiers, + affected: [affected_entry(formula, hit, events, fixed, status, status_evidence)], + database_specific: { + source: "matched", + strategy: hit.strategy.to_s, + confidence: confidence_for(hit, status), + upstream_evidence: hit.evidence.map { |e| e.to_h.except(:advisory, :source_record).compact }, + }, + }, T::Hash[Symbol, T.untyped]) + + record[:summary] = vuln.summary if vuln.summary + record[:details] = vuln.details if vuln.details + record[:severity] = vuln.severity_entries if vuln.severity_entries.any? + if (refs = vuln.references).any? + record[:references] = refs.uniq { |r| [r["type"], URI::RFC2396_PARSER.unescape(r["url"].to_s)] } + end + + record + end + + sig { + params(hit: Hit, status: T.nilable(Vulnerability::RangeStatus)).returns(String) + } + def confidence_for(hit, status) + base = CONFIDENCE.fetch(hit.strategy) + return base if status + + # No comparable range: the reviewer must set the boundary by hand. + (base == "high") ? "medium" : "low" + end + + sig { + params(formula: Formula, hit: Hit, events: T::Array[T::Hash[Symbol, String]], + fixed: T.nilable(String), status: T.nilable(Vulnerability::RangeStatus), + status_evidence: T.nilable(Evidence)) + .returns(T::Hash[Symbol, T.untyped]) + } + def affected_entry(formula, hit, events, fixed, status, status_evidence) + eco = T.let({ fix: fixed ? "bump" : nil }, T::Hash[Symbol, T.nilable(String)]) + eco[:range_state] = status.state.to_s if status + eco[:upstream_fixed_in] = status.fixed_in if status&.fixed_in + # Attribute the resource whose subject decided the state, falling back + # to the highest-precision evidence when nothing was comparable. + if (resource = status_evidence&.resource || hit.resource) + eco[:resource] = resource + eco[:resource_purl] = (status_evidence if status_evidence&.resource)&.key || + hit.evidence.find { |e| e.resource == resource }&.key + end + { + package: { + ecosystem: OsvExport::ECOSYSTEM, + name: formula.name, + purl: OsvExport.purl(formula.name), + }, + ranges: [{ type: "ECOSYSTEM", events: }], + ecosystem_specific: eco, + } + end + + # Walk homebrew-core git history (newest first) via {FormulaVersions} and + # return the `pkg_version` at the oldest revision where the aggregate of + # every checkable subject is still `:fixed`. Re-running the full + # per-evidence range check with each revision's subject versions keeps + # `last_affected` and exclusive-bound semantics intact and stops as soon + # as any subject (primary or a resource) drops back into `:affected`, so + # a primary fixed at 2.0 with a resource fixed at 3.0 yields 3.0. + # + # Returns: + # - `nil` when the current aggregate is not `:fixed`. + # - `:never_affected` when the walk reaches `:not_applicable` (or the + # start of the formula's history) without ever seeing `:affected`, + # i.e. Homebrew jumped from a version below `introduced` straight past + # `fixed` and never shipped an affected build. The caller drops the + # candidate rather than emitting `{introduced: "0", fixed: }`. + # - a `pkg_version` String when the walk hits `:affected`, or when it + # stops at an unloadable revision (best-effort boundary; the reviewer + # can tighten). + # + # The rev-list and per-revision loads are cached per formula. + sig { params(formula: Formula, hit: Hit).returns(T.nilable(T.any(String, Symbol))) } + def first_fixed_version(formula, hit) + return unless range_status(hit)&.first&.fixed? + + fv = @formula_versions[formula.name] ||= FormulaVersions.new(formula) + revs = @formula_rev_lists[formula.name] ||= + [].tap { |a| fv.rev_list("HEAD") { |rev, entry| a << [rev, entry] } } + + last_fixed = T.let(formula.pkg_version.to_s, String) + revs.each do |rev, entry| + state = fv.formula_at_revision(rev, entry) do |old| + [aggregate_state_at(old, hit), old.pkg_version.to_s] + end + # `nil` means the revision failed to load; can't verify further. + return last_fixed if state.nil? + + aggregate, pkg_version = state + return :never_affected if aggregate == :not_applicable + return last_fixed if aggregate != :fixed + + last_fixed = pkg_version + end + :never_affected + end + + sig { params(formula: Formula, hit: Hit).returns(T.nilable(Symbol)) } + def aggregate_state_at(formula, hit) + results = hit.evidence.filter_map do |ev| + # Evidence built without a subject_version (distro queries, own- + # identity rows for a formula with no derivable tag) is deliberately + # uncheckable and must stay that way at historical revisions too; + # substituting the historical formula version would compare it + # against the distro record's distro-versioned range. + next if ev.subject_version.nil? + + subject = subject_version(formula, ev.resource)&.to_s + evidence_range_status(ev, subject) + end + return if results.empty? + return :affected if results.any?(&:affected?) + return :fixed if results.any?(&:fixed?) + + :not_applicable + end + + sig { params(formula: Formula, resource: T.nilable(String)).returns(T.nilable(Version)) } + def subject_version(formula, resource) + if resource + begin + formula.resource(resource)&.version + rescue ResourceMissingError + nil + end + else + formula.version + end + end + end + end +end diff --git a/Library/Homebrew/vulns/osv.rb b/Library/Homebrew/vulns/osv.rb index b022fda701748..0480337c3a597 100644 --- a/Library/Homebrew/vulns/osv.rb +++ b/Library/Homebrew/vulns/osv.rb @@ -15,12 +15,12 @@ module OSV class Error < RuntimeError; end class ApiError < Error; end + Package = T.type_alias { { ecosystem: String, name: String, version: T.nilable(String) } } + # POST /v1/querybatch. Returns one array of vuln hashes per input package, # in the same order. Follows per-result `next_page_token` continuations. - sig { - params(packages: T::Array[{ repo_url: String, version: String }]) - .returns(T::Array[T::Array[T::Hash[String, T.untyped]]]) - } + # A `nil` version queries all known vulnerabilities for the package. + sig { params(packages: T::Array[Package]).returns(T::Array[T::Array[T::Hash[String, T.untyped]]]) } def self.query_batch(packages) return [] if packages.empty? @@ -29,10 +29,10 @@ def self.query_batch(packages) packages.each_slice(BATCH_SIZE).with_index do |batch, batch_index| offset = batch_index * BATCH_SIZE pending = batch.map.with_index do |pkg, index| - { - slot: offset + index, - query: { package: { name: pkg.fetch(:repo_url), ecosystem: "GIT" }, version: pkg.fetch(:version) }, - } + query = T.let({ package: { name: pkg.fetch(:name), ecosystem: pkg.fetch(:ecosystem) } }, + T::Hash[Symbol, T.untyped]) + query[:version] = pkg.fetch(:version) if pkg.fetch(:version) + { slot: offset + index, query: } end page = 0 diff --git a/Library/Homebrew/vulns/purl.rb b/Library/Homebrew/vulns/purl.rb new file mode 100644 index 0000000000000..f92f1e1f5f070 --- /dev/null +++ b/Library/Homebrew/vulns/purl.rb @@ -0,0 +1,90 @@ +# typed: strict +# frozen_string_literal: true + +module Homebrew + module Vulns + # A package URL per https://github.com/package-url/purl-spec. + # + # Minimal builder for the registry types Homebrew derives from formula + # source URLs. Applies the spec's per-type name normalisation and RFC 3986 + # percent-encoding when serialised. Parsing, qualifiers and subpath are + # intentionally omitted. + class Purl + sig { returns(String) } + attr_reader :type, :name + + sig { returns(T.nilable(String)) } + attr_reader :namespace, :version + + sig { + params(type: String, name: String, namespace: T.nilable(String), version: T.nilable(String)).void + } + def initialize(type:, name:, namespace: nil, version: nil) + raise ArgumentError, "type is required" if type.empty? + raise ArgumentError, "name is required" if name.empty? + + @type = T.let(type.downcase.freeze, String) + namespace = nil if namespace && namespace.empty? + namespace, name = self.class.normalize(@type, namespace, name) + @namespace = T.let(namespace && -namespace, T.nilable(String)) + @name = T.let(-name, String) + version = nil if version && version.empty? + @version = T.let(version && -version, T.nilable(String)) + end + + sig { returns(String) } + def to_s + purl = "pkg:#{@type}/" + if @namespace + purl << @namespace.split("/").reject(&:empty?).map { |s| self.class.encode(s) }.join("/") + purl << "/" + end + purl << self.class.encode(@name) + purl << "@#{self.class.encode(@version)}" if @version + purl.freeze + end + + sig { params(other: T.anything).returns(T::Boolean) } + def ==(other) + case other + when Purl + type == other.type && namespace == other.namespace && + name == other.name && version == other.version + else false + end + end + alias eql? == + + sig { returns(Integer) } + def hash + [type, namespace, name, version].hash + end + + # Percent-encode a single purl component. The spec permits the RFC 3986 + # unreserved set plus `:` unencoded; `+` for space is forbidden so + # `URI.encode_www_form_component` is unsuitable. + sig { params(component: String).returns(String) } + def self.encode(component) + component.b.gsub(/[^A-Za-z0-9\-._~:]/n) { |c| format("%%%02X", c.ord) } + end + + # Per-type normalisation from purl-spec PURL-TYPES.rst for the types we emit. + sig { + params(type: String, namespace: T.nilable(String), name: String) + .returns([T.nilable(String), String]) + } + def self.normalize(type, namespace, name) + case type + when "pypi" + [namespace, name.downcase.tr("_", "-")] + when "hex" + [namespace&.downcase, name.downcase] + when "cpan" + [namespace&.upcase, name] + else + [namespace, name] + end + end + end + end +end diff --git a/Library/Homebrew/vulns/repology.rb b/Library/Homebrew/vulns/repology.rb new file mode 100644 index 0000000000000..c85436b0db17c --- /dev/null +++ b/Library/Homebrew/vulns/repology.rb @@ -0,0 +1,211 @@ +# typed: strict +# frozen_string_literal: true + +require "utils/repology" +require "vulns/cached_feed" + +module Homebrew + module Vulns + # Reader for the Repology-derived formula → distro-package index published + # by Homebrew/advisory-database (`data/repology.json`, built by that + # repository's `RepologyIndex` via `rake repology:build`). + # + # The index maps each formula name to its source-package names in + # OSV.dev-covered distro ecosystems so {Vulns::Match} can query those + # ecosystems' advisories. {.lookup} provides a live single-project API + # fallback for formulae the published index doesn't yet cover. + class Repology < CachedFeed + DATA_URL = "https://raw.githubusercontent.com/Homebrew/advisory-database/" \ + "main/data/repology.json" + + sig { override.returns(String) } + def self.data_url = DATA_URL + + sig { override.returns(String) } + def self.cache_filename = "repology.json" + + sig { override.returns(Integer) } + def self.default_max_age = 7 * 86_400 + + DistroMap = T.type_alias { T::Hash[String, T::Array[String]] } + + sig { params(name: String).returns(String) } + def self.base_name(name) = name.sub(/@.+\z/, "") + + sig { override.params(data: T.anything).void } + def initialize(data) + super + raise Error, "Repology index is not a JSON object" unless (top = as_hash(data)) + raise Error, "Repology index missing 'formulae' key" unless (formulae = as_hash(top["formulae"])) + + @formulae = T.let(formulae, T::Hash[String, T.untyped]) + @meta = T.let(as_hash(top["meta"]) || {}, T::Hash[String, T.untyped]) + end + + sig { returns(T::Hash[String, T.untyped]) } + attr_reader :meta + + sig { returns(T::Array[String]) } + def formulae + @formulae.keys + end + + # Returns `{osv_ecosystem => [srcname, ...]}` for `formula_name`, or an + # empty hash if the index has no entry. The index is keyed on the + # Homebrew formula name as Repology records it, so `@`-versioned + # variants (`postgresql@16`) are looked up under their base name too. + sig { params(formula_name: String).returns(DistroMap) } + def distro_packages_for(formula_name) + entry = @formulae[formula_name] || @formulae[self.class.base_name(formula_name)] + return {} unless entry.is_a?(Hash) + + entry.filter_map do |eco, names| + next unless eco.is_a?(String) + + list = Array(names).grep(String) + [eco, list.freeze] if list.any? + end.to_h.freeze + end + + # Repology repo-name prefix => `{ecosystem:, name_field:}`. Kept in step + # with `RepologyIndex::OSV_DISTROS` in Homebrew/advisory-database; only + # the fields {.distil} needs are duplicated here. + OSV_DISTROS = T.let( + { + "debian_" => { ecosystem: "Debian" }, + "ubuntu_" => { ecosystem: "Ubuntu" }, + "alpine_" => { ecosystem: "Alpine" }, + "opensuse_leap_" => { ecosystem: "openSUSE" }, + "opensuse_tumbleweed" => { ecosystem: "openSUSE" }, + "rocky_" => { ecosystem: "Rocky Linux" }, + "almalinux_" => { ecosystem: "AlmaLinux" }, + "mageia_" => { ecosystem: "Mageia" }, + "openeuler_" => { ecosystem: "openEuler" }, + "ubi_" => { ecosystem: "Red Hat" }, + "freebsd" => { ecosystem: "FreeBSD", name_field: "binname" }, + }.freeze, + T::Hash[String, { ecosystem: String, name_field: T.nilable(String) }], + ) + private_constant :OSV_DISTROS + + # Kept in step with `RepologyIndex::PREFERRED_STATUSES`. + PREFERRED_STATUSES = %w[newest outdated devel unique noscheme].freeze + private_constant :PREFERRED_STATUSES + + # Live single-project fallback for a formula the published index does + # not cover: a new formula in a homebrew-core PR before the next nightly + # index build, or one the index put in `meta.ambiguous_projects`. + # + # Fetches each project in {.name_candidates}, keeps those whose Homebrew + # entries include `formula_name` (or its `@`-stripped base), then applies + # the same preferred-status resolution as `RepologyIndex#resolve` across + # the survivors. Unlike the index builder, a project that also lists + # sibling formulae with a different base (`wget` + `wget2`, `sqlite` + + # `sqlite-analyzer`, `ffmpeg` + a third-party `ffmpeg-full`) is *not* + # rejected: the distro srcnames for the sibling flow through as extra + # low-confidence distro queries whose upstream-CVE range check will not + # match this formula's identity, so the cost is uncomparable noise rather + # than a wrong `:affected`/`:fixed` claim. This cannot detect collisions + # with projects outside {.name_candidates} (e.g. `allegro4`), which only + # the full crawl sees. + sig { params(formula_name: String).returns(DistroMap) } + def self.lookup(formula_name) + base = base_name(formula_name) + exact = [] + by_base = [] + name_candidates(formula_name).each do |candidate| + entries = fetch_project(candidate) + next if entries.empty? + + brew = homebrew_entries(entries) + distros = distil(entries) + next if distros.empty? + + # A project listing both the exact and base names contributes to both + # pools, matching the producer's per-key contributions. + exact << { preferred: brew.fetch(formula_name), distros: } if brew.key?(formula_name) + by_base << { preferred: brew.fetch(base), distros: } if base != formula_name && brew.key?(base) + end + + # Resolve the exact-name pool first, mirroring + # `#distro_packages_for`'s `@formulae[name] || @formulae[base]` + # precedence over the producer's per-key resolved index. + resolve_contributions(exact) || resolve_contributions(by_base) || {} + end + + sig { + params(contributions: T::Array[{ preferred: T::Boolean, distros: DistroMap }]) + .returns(T.nilable(DistroMap)) + } + def self.resolve_contributions(contributions) + chosen = contributions.one? ? contributions : contributions.select { |c| c.fetch(:preferred) } + chosen.fetch(0).fetch(:distros) if chosen.one? + end + + sig { params(entries: T::Array[T::Hash[String, T.untyped]]).returns(T::Hash[String, T::Boolean]) } + def self.homebrew_entries(entries) + result = {} + entries.each do |e| + next if e["repo"] != "homebrew" + + name = (e["srcname"] || e["binname"]).to_s + next if name.empty? + + result[name] ||= false + result[name] = true if PREFERRED_STATUSES.include?(e["status"]) + end + result + end + + sig { params(formula_name: String).returns(T::Array[String]) } + def self.name_candidates(formula_name) + base = base_name(formula_name) + [ + formula_name, + base, + base.delete_prefix("lib"), + base.delete_prefix("gnu-"), + base.delete_suffix("2"), + ].uniq.reject(&:empty?) + end + + # Fetch one Repology project. A nonexistent project returns HTTP 200 with + # `[]`, so an empty array is the only "try next candidate" signal; + # transport failures, HTTP errors, malformed JSON and unexpected shapes + # all raise so callers don't mistake an outage for "no packages". + sig { params(project: String).returns(T::Array[T::Hash[String, T.untyped]]) } + def self.fetch_project(project) + result = ::Repology.single_package_query(project, repository: ::Repology::HOMEBREW_CORE) + raise Error, "Repology API request for #{project.inspect} failed" if result.nil? + + entries = result.fetch(project) + if !entries.is_a?(Array) || !entries.all?(Hash) + raise Error, "Repology API returned unexpected shape for #{project.inspect}" + end + + entries + end + + sig { params(entries: T::Array[T::Hash[String, T.untyped]]).returns(DistroMap) } + def self.distil(entries) + result = Hash.new { |h, k| h[k] = [] } + entries.each do |entry| + repo = entry["repo"] + next unless repo.is_a?(String) + + distro = OSV_DISTROS.find { |prefix, _| repo.start_with?(prefix) }&.last + next unless distro + next if entry["status"] == "legacy" + + name = entry[distro[:name_field] || "srcname"] || entry["binname"] + next unless name.is_a?(String) + + result[distro.fetch(:ecosystem)] << name + end + result.transform_values! { |names| names.uniq.sort.freeze } + result.default = nil + result.sort.to_h.freeze + end + end + end +end diff --git a/Library/Homebrew/vulns/scanner.rb b/Library/Homebrew/vulns/scanner.rb index 4e43d15be3f41..4f016d6b2dadb 100644 --- a/Library/Homebrew/vulns/scanner.rb +++ b/Library/Homebrew/vulns/scanner.rb @@ -2,67 +2,24 @@ # frozen_string_literal: true require "sbom" +require "vulns/identify" require "vulns/osv" require "vulns/vulnerability" module Homebrew module Vulns class Scanner - FORGES = %w[github.com gitlab.com codeberg.org].freeze - private_constant :FORGES - - TAG_PATTERNS = T.let( - [ - %r{/archive/refs/tags/([^/]+)\.tar\.gz$}, - %r{/archive/refs/tags/([^/]+)\.zip$}, - %r{/archive/([^/]+)\.tar\.gz$}, - %r{/archive/([^/]+)\.zip$}, - %r{/releases/download/([^/]+)/}, - %r{/tarball/([^/]+)$}, - ].freeze, - T::Array[Regexp], - ) - private_constant :TAG_PATTERNS - - sig { params(urls: T.nilable(String)).returns(T.nilable(String)) } - def self.repo_url(*urls) - urls.each do |url| - next if url.nil? - - forge = FORGES.find { |f| url.include?(f) } - next if forge.nil? - - match = url.match(%r{https?://#{Regexp.escape(forge)}/([^/]+/[^/]+)}) - next if match.nil? - - repo_path = T.must(match[1]).sub(/\.git$/, "").sub(%r{/-/.*}, "") - return "https://#{forge}/#{repo_path}" - end - nil - end - sig { params(source_url: T.nilable(String), head_url: T.nilable(String), homepage: T.nilable(String)).returns(T.nilable(String)) } def self.target_repo_url(source_url, head_url, homepage) - url = repo_url(source_url, head_url, homepage) - url ||= source_url if tag(source_url) + url = Identify.repo_url(source_url, head_url, homepage) + url ||= source_url if Identify.tag(source_url) url ||= head_url url end - sig { params(url: T.nilable(String)).returns(T.nilable(String)) } - def self.tag(url) - return if url.nil? - - TAG_PATTERNS.each do |pattern| - match = url.match(pattern) - return match[1] if match - end - nil - end - SBOM_SRC_SPDXID = /\ASPDXRef-Archive-.*-src\z/ private_constant :SBOM_SRC_SPDXID @@ -155,7 +112,7 @@ def scan end targets = queryable.map { |f| T.must(target_for(f)) } - batch = OSV.query_batch(targets.map { |t| { repo_url: t.repo_url, version: t.tag } }) + batch = OSV.query_batch(targets.map { |t| { ecosystem: "GIT", name: t.repo_url, version: t.tag } }) findings = queryable.each_with_index.filter_map do |formula, index| target = targets.fetch(index) @@ -199,7 +156,7 @@ def build_target(formula) homepage = formula.homepage stable_repo_url = self.class.target_repo_url(stable_url, head_url, homepage) - stable_tag = self.class.tag(stable_url) || stable&.specs&.[](:tag) || stable&.version&.to_s + stable_tag = Identify.tag(stable_url) || stable&.specs&.[](:tag) || stable&.version&.to_s if (prefix = formula.any_installed_prefix) installed_pkg_version = formula.any_installed_version @@ -209,7 +166,7 @@ def build_target(formula) if (sbom = self.class.source_from_sbom(prefix)) sbom_url, sbom_version = sbom repo_url = self.class.target_repo_url(sbom_url, head_url, homepage) - tag = self.class.tag(sbom_url) || sbom_version || installed_version.presence + tag = Identify.tag(sbom_url) || sbom_version || installed_version.presence if repo_url && tag return Target.new(repo_url:, tag:, version: installed_version, from_installed_sbom: true, current_recipe_applies:) diff --git a/Library/Homebrew/vulns/vulnerability.rb b/Library/Homebrew/vulns/vulnerability.rb index 701c281b19038..5d303c770a0ee 100644 --- a/Library/Homebrew/vulns/vulnerability.rb +++ b/Library/Homebrew/vulns/vulnerability.rb @@ -33,10 +33,10 @@ class Vulnerability attr_reader :severity sig { returns(T::Array[String]) } - attr_reader :aliases + attr_reader :aliases, :upstream, :related sig { returns(T::Array[T::Hash[String, T.untyped]]) } - attr_reader :references, :affected + attr_reader :references, :affected, :severity_entries sig { params(data: T::Hash[String, T.untyped]).void } def initialize(data) @@ -44,8 +44,11 @@ def initialize(data) @summary = T.let(data["summary"], T.nilable(String)) @details = T.let(data["details"], T.nilable(String)) @aliases = T.let(Array(data["aliases"]), T::Array[String]) + @upstream = T.let(Array(data["upstream"]), T::Array[String]) + @related = T.let(Array(data["related"]), T::Array[String]) @references = T.let(Array(data["references"]), T::Array[T::Hash[String, T.untyped]]) @affected = T.let(Array(data["affected"]), T::Array[T::Hash[String, T.untyped]]) + @severity_entries = T.let(Array(data["severity"]), T::Array[T::Hash[String, T.untyped]]) @severity = T.let(extract_severity(data), T.nilable(Symbol)) end @@ -67,9 +70,14 @@ def severity_level SEVERITY_LEVEL.fetch(sev, 0) end + # Only `id` and `aliases` name *this* vulnerability. `upstream` is a + # directed reference (a distro advisory pointing at one-or-more source + # CVEs) and `related` links to different vulnerabilities; neither is safe + # to treat as an identity of this record. {Match} follows `upstream` + # explicitly and re-attributes the hit to each CVE it names. sig { returns(T::Array[String]) } def identifiers - [id, *aliases].compact + [id, *aliases].uniq end sig { returns(T::Array[String]) } @@ -96,6 +104,111 @@ def fixed_versions end.uniq end + # `state` is `:affected` (in an interval), `:fixed` (past the closing + # boundary of at least one interval), or `:not_applicable` (below the + # `introduced` of every interval; the vulnerability never applied to this + # version). `fixed_in` is the boundary that closed the containing + # interval (`:affected`) or the highest boundary at-or-below the version + # (`:fixed`). + RangeStatus = Struct.new(:state, :fixed_in, keyword_init: true) do + sig { returns(T::Boolean) } + def affected? = state == :affected + + sig { returns(T::Boolean) } + def fixed? = state == :fixed + end + + # Evaluates `version` against every `affected[]` entry whose `package` + # matches `{ecosystem, name}` (an advisory can carry several disjoint + # entries for the same package, e.g. GHSA-jfh8-c2jp-5v3q's three + # log4j-core ranges), honouring range `type`: + # + # - `SEMVER` ranges compare with {Semver}. + # - `ECOSYSTEM` ranges compare with {Version} (best-effort; the record's + # own ecosystem defines the ordering, but callers only invoke this for + # ecosystems whose versions are broadly `Version`-comparable). + # - `GIT` ranges are commit hashes and are skipped as uncomparable. + # + # Returns `nil` when no entry matches the package or no comparable range + # exists in the matching entries, so callers can distinguish + # `:not_applicable`/`:fixed` from "could not check". + sig { params(ecosystem: String, name: String, version: String).returns(T.nilable(RangeStatus)) } + def range_status(ecosystem, name, version) + entries = affected_entries_for(ecosystem, name) + return if entries.empty? + + target = normalize_version(version) + checked = T.let(false, T::Boolean) + past_fixes = T.let([], T::Array[String]) + + entries.each do |entry| + Array(entry["ranges"]).each do |range| + type = range["type"] + next if type == "GIT" + + cmp = comparator_for(type) + intervals(Array(range["events"])).each do |lower, upper, upper_inclusive| + inside = in_interval?(target, lower, upper, upper_inclusive, cmp) + checked = true + return RangeStatus.new(state: :affected, fixed_in: upper_inclusive ? nil : upper).freeze if inside + next unless upper + + rel = cmp.call(target, upper) + past_fixes << upper if upper_inclusive ? rel.positive? : rel >= 0 + rescue Uncomparable + next + end + end + + versions = Array(entry["versions"]) + next if versions.empty? + + checked = true + if versions.any? { |v| normalize_version(v.to_s) == target } + return RangeStatus.new(state: :affected, fixed_in: nil).freeze + end + end + + return unless checked + + state = past_fixes.any? ? :fixed : :not_applicable + RangeStatus.new(state:, fixed_in: past_fixes.max_by { |v| Version.new(v) }).freeze + end + + sig { params(ecosystem: String, name: String).returns(T::Array[T::Hash[String, T.untyped]]) } + def affected_entries_for(ecosystem, name) + affected.select do |aff| + pkg = aff["package"] + pkg.is_a?(Hash) && pkg["ecosystem"] == ecosystem && pkg["name"] == name + end + end + + sig { params(range_type: T.nilable(String)).returns(T.proc.params(a: String, b: String).returns(Integer)) } + def comparator_for(range_type) + if range_type == "SEMVER" + ->(a, b) { Semver.compare(a, b) || raise(Uncomparable) } + else + ->(a, b) { Version.new(a) <=> Version.new(b) || raise(Uncomparable) } + end + end + + sig { + params(target: String, lower: T.nilable(String), upper: T.nilable(String), + upper_inclusive: T::Boolean, + cmp: T.proc.params(a: String, b: String).returns(Integer)).returns(T::Boolean) + } + def in_interval?(target, lower, upper, upper_inclusive, cmp) + above = lower.nil? || cmp.call(target, lower) >= 0 + below = if upper.nil? + true + elsif upper_inclusive + cmp.call(target, upper) <= 0 + else + cmp.call(target, upper).negative? + end + above && below + end + # OSV has already matched this record against the queried version. This # method only overrides that with `false` when every affected entry can # be evaluated locally (explicit `versions` list or `SEMVER` range) and @@ -167,7 +280,6 @@ def normalize_version(version) class Uncomparable < StandardError end - private_constant :Uncomparable sig { params(target: String, events: T::Array[T::Hash[String, T.untyped]]).returns(T::Boolean) } def in_semver_range?(target, events)