Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7f40278
vulns: extract Identify from Scanner
andrew Jul 15, 2026
3dfee79
vulns: add Purl builder and Identify.registry_package
andrew Jul 15, 2026
093fd5f
vulns: extend Identify::FORGES with OSV-indexed GitLab hosts
andrew Jul 16, 2026
e650a47
vulns: add CPANSec loader for cpan-security-advisory.json
andrew Jul 16, 2026
8eab7af
vulns: extract CachedFeed base class from CPANSec
andrew Jul 16, 2026
0420992
vulns: add Repology reader for advisory-database's distro index
andrew Jul 16, 2026
5441227
vulns/repology: delegate fetch_project to shared ::Repology client
andrew Jul 27, 2026
e3a6164
vulns/osv: generalise query_batch to arbitrary ecosystems
andrew Jul 27, 2026
6dff421
vulns: add Match with identify and advisories_for
andrew Jul 27, 2026
7edb93f
vulns/match: add to_brew_record and first_fixed_version
andrew Jul 27, 2026
2e00163
vulns/vulnerability: include OSV upstream field in identifiers
andrew Jul 28, 2026
a03c814
dev-cmd/advisory-match: wire Vulns::Match to a CLI
andrew Jul 28, 2026
e2c5553
Address style and Copilot review
andrew Jul 28, 2026
6525bc1
vulns: raise spec files to typed: true
andrew Jul 28, 2026
d7e7161
vulns: add per-package range evaluation
andrew Jul 28, 2026
f5b8b6a
vulns/match: query versionlessly and evaluate ranges per evidence
andrew Jul 28, 2026
ac85818
vulns/match: batch OSV queries across formulae and stream output
andrew Jul 28, 2026
269b3ae
vulns: raise let-free spec files to typed: strict
andrew Jul 28, 2026
8aa628f
vulns: three-state range status over the union of matching entries
andrew Jul 28, 2026
3f0f00f
vulns/match: transitive upstream, CPANSA-only hits, threaded prefetch
andrew Jul 28, 2026
a67b535
dev-cmd/advisory-match: leave source: generated records untouched
andrew Jul 28, 2026
35a9239
vulns/match: tighten range-state edge cases
andrew Jul 28, 2026
9cb74dc
vulns/match: keep per-evidence source records and aggregate subjects
andrew Jul 28, 2026
4206491
vulns/match: aggregate every subject per historical revision
andrew Jul 28, 2026
1ac19d2
vulns/match: keep versionless evidence uncheckable in the history walk
andrew Jul 28, 2026
2c1daef
vulns/match: return :never_affected when Homebrew skipped the interval
andrew Jul 28, 2026
4f439a9
vulns: lowercase github.com paths and accept sibling-formula Repology…
andrew Jul 28, 2026
119bb74
Merge branch 'main' into vulns-identify
andrew Jul 28, 2026
966b021
dev-cmd/advisory-match: explicit require "fileutils"
andrew Jul 28, 2026
bd990a5
Merge branch 'main' into vulns-identify
andrew Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions Library/Homebrew/dev-cmd/advisory-match.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# typed: strict
# frozen_string_literal: true

require "abstract_command"
require "formula"
require "vulns/match"
Comment thread
andrew marked this conversation as resolved.

module Homebrew
module DevCmd
class AdvisoryMatch < AbstractCommand
cmd_args do
description <<~EOS
Match <formula> against OSV.dev (GIT, language-registry and distro
ecosystems) and CPANSA to produce candidate `BREW-*` advisory records
for <https://github.com/Homebrew/advisory-database>.

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 <directory> as " \
"`BREW-<formula>-<id>.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

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

Loading
Loading