Class: Brew::Vulns::Formula

Inherits:
Object
  • Object
show all
Defined in:
lib/brew/vulns/formula.rb

Constant Summary collapse

CYCLONEDX_PATCH_TYPES =
{
  "backport"    => "backport",
  "cherry_pick" => "cherry-pick",
  "unofficial"  => "unofficial",
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data) ⇒ Formula

Returns a new instance of Formula.



12
13
14
15
16
17
18
19
20
21
22
# File 'lib/brew/vulns/formula.rb', line 12

def initialize(data)
  @name = data["name"] || data["full_name"]
  @version = data.dig("versions", "stable") || data["version"]
  @revision = data["revision"].to_i
  @source_url = data.dig("urls", "stable", "url")
  @stable_tag = data.dig("urls", "stable", "tag")
  @head_url = data.dig("urls", "head", "url")
  @homepage = data["homepage"]
  @dependencies = data["dependencies"] || []
  @patches = data["patches"] || []
end

Instance Attribute Details

#dependenciesObject (readonly)

Returns the value of attribute dependencies.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def dependencies
  @dependencies
end

#head_urlObject (readonly)

Returns the value of attribute head_url.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def head_url
  @head_url
end

#homepageObject (readonly)

Returns the value of attribute homepage.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def homepage
  @homepage
end

#nameObject (readonly)

Returns the value of attribute name.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def name
  @name
end

#patchesObject (readonly)

Returns the value of attribute patches.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def patches
  @patches
end

#revisionObject (readonly)

Returns the value of attribute revision.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def revision
  @revision
end

#source_urlObject (readonly)

Returns the value of attribute source_url.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def source_url
  @source_url
end

#versionObject (readonly)

Returns the value of attribute version.



10
11
12
# File 'lib/brew/vulns/formula.rb', line 10

def version
  @version
end

Class Method Details

.load_allObject

Raises:



129
130
131
132
133
134
135
# File 'lib/brew/vulns/formula.rb', line 129

def self.load_all
  json, status = Open3.capture2("brew", "info", "--json=v2", "--eval-all")
  raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?

  data = JSON.parse(json)
  data["formulae"].map { |f| new(f) }
end

.load_from_brewfile(brewfile_path, include_deps: false) ⇒ Object

Raises:



175
176
177
178
179
180
# File 'lib/brew/vulns/formula.rb', line 175

def self.load_from_brewfile(brewfile_path, include_deps: false)
  raise Error, "Brewfile not found: #{brewfile_path}" unless File.exist?(brewfile_path)

  formula_names = parse_brewfile(brewfile_path)
  load_named(formula_names, include_deps: include_deps)
end

.load_installedObject

Raises:



137
138
139
140
141
142
143
# File 'lib/brew/vulns/formula.rb', line 137

def self.load_installed
  json, status = Open3.capture2("brew", "info", "--json=v2", "--installed")
  raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?

  data = JSON.parse(json)
  data["formulae"].map { |f| new(f) }
end

.load_named(formula_names, include_deps: false) ⇒ Object

Raises:



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/brew/vulns/formula.rb', line 145

def self.load_named(formula_names, include_deps: false)
  return [] if formula_names.empty?

  json, status = Open3.capture2("brew", "info", "--json=v2", *formula_names)
  raise Error, "brew info failed with status #{status.exitstatus}" unless status.success?

  data = JSON.parse(json)
  formulae = data["formulae"].map { |f| new(f) }

  if include_deps
    dep_names = []
    formula_names.each do |name|
      deps_output, = Open3.capture2("brew", "deps", name)
      dep_names.concat(deps_output.split("\n").map(&:strip))
    end
    dep_names.uniq!
    dep_names -= formula_names

    if dep_names.any?
      deps_json, deps_status = Open3.capture2("brew", "info", "--json=v2", *dep_names)
      if deps_status.success?
        deps_data = JSON.parse(deps_json)
        formulae.concat(deps_data["formulae"].map { |f| new(f) })
      end
    end
  end

  formulae.uniq { |f| f.name }
end

.parse_brewfile(brewfile_path) ⇒ Object

Raises:



182
183
184
185
186
187
# File 'lib/brew/vulns/formula.rb', line 182

def self.parse_brewfile(brewfile_path)
  output, status = Open3.capture2("brew", "bundle", "list", "--file=#{brewfile_path}", "--formula")
  raise Error, "brew bundle list failed with status #{status.exitstatus}" unless status.success?

  output.split("\n").map(&:strip).reject(&:empty?)
end

Instance Method Details

#cyclonedx_pedigreeObject

Maps brew info --json=v2 patch data to a CycloneDX pedigree hash (symbol-keyed for the sbom gem). Returns nil when there are no patches.



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/brew/vulns/formula.rb', line 75

def cyclonedx_pedigree
  return nil if patches.empty?

  cdx_patches = patches.map do |p|
    patch = { type: CYCLONEDX_PATCH_TYPES.fetch(p["type"].to_s, "unofficial") }
    patch[:diff] = { url: p["url"] } if p["url"]

    resolves = Array(p["resolves"]).filter_map do |r|
      next unless r.is_a?(Hash) && r["type"] && r["id"]

      { type: r["type"], id: r["id"] }
    end
    patch[:resolves] = resolves if resolves.any?

    patch
  end

  { patches: cdx_patches }
end

#full_versionObject

The Homebrew package version including the revision suffix, e.g. "1.81.6_6". This is the version string as it appears in pkg:brew/ purls and bottle paths.



26
27
28
# File 'lib/brew/vulns/formula.rb', line 26

def full_version
  revision.zero? ? version : "#{version}_#{revision}"
end

#patches_resolving(vuln_id) ⇒ Object

Patches whose resolves list contains vuln_id (case-insensitive).



39
40
41
42
43
44
# File 'lib/brew/vulns/formula.rb', line 39

def patches_resolving(vuln_id)
  target = vuln_id.to_s.upcase
  patches.select do |p|
    Array(p["resolves"]).any? { |r| r.is_a?(Hash) && r["type"] == "security" && r["id"].to_s.upcase == target }
  end
end

#purl(with_version: true) ⇒ Object

Package URL for this formula. Uses the purl gem so that special characters in the formula name (notably @ in versioned formulae like glibc@2.13) are percent-encoded; otherwise the @ would be parsed as the version separator.



34
35
36
# File 'lib/brew/vulns/formula.rb', line 34

def purl(with_version: true)
  Purl::PackageURL.new(type: "brew", name: name, version: with_version ? version : nil).to_s
end

#repo_urlObject

The git repository URL to use as the OSV GIT ecosystem package name. In order of preference:

1. owner/repo extracted from a recognised forge URL on stable, head,
 or homepage. Known forges are preferred even over a canonical
 non-forge git URL, because OSV's GIT coverage is much better there.
2. the stable URL itself when stable is a git checkout (`url ..., tag: ...`)
3. the head URL verbatim (head specs are always git URLs)

Returns nil when none of the above yield a URL.



103
104
105
106
107
108
109
110
111
# File 'lib/brew/vulns/formula.rb', line 103

def repo_url
  return @repo_url if defined?(@repo_url)

  @repo_url = extract_repo_url(source_url) ||
              extract_repo_url(head_url) ||
              extract_repo_url(homepage) ||
              (source_url if @stable_tag) ||
              head_url
end

#resolved_vulnerability_idsObject

CVE/GHSA identifiers declared as resolved by this formula's patches. Populated from brew info --json=v2 patches[].resolves[] (Homebrew >= 6.0.4); empty on older Homebrew versions or for formulae with no annotated patches.



49
50
51
52
53
54
55
56
57
58
# File 'lib/brew/vulns/formula.rb', line 49

def resolved_vulnerability_ids
  return @resolved_vulnerability_ids if defined?(@resolved_vulnerability_ids)

  @resolved_vulnerability_ids = patches
    .flat_map { |p| Array(p["resolves"]) }
    .select { |r| r.is_a?(Hash) && r["type"] == "security" }
    .map { |r| r["id"].to_s.upcase }
    .reject(&:empty?)
    .uniq
end

#resolves?(vulnerability) ⇒ Boolean

Returns:

  • (Boolean)


60
61
62
63
64
65
# File 'lib/brew/vulns/formula.rb', line 60

def resolves?(vulnerability)
  ids = resolved_vulnerability_ids
  return false if ids.empty?

  vulnerability.identifiers.any? { |id| ids.include?(id.to_s.upcase) }
end

#tagObject

The version identifier to send as the OSV query version. Prefers an explicit git tag (from a tag: stable spec or parsed from a forge tarball URL); falls back to the bare formula version, which OSV's GIT ecosystem accepts for at least some upstreams.



117
118
119
120
121
# File 'lib/brew/vulns/formula.rb', line 117

def tag
  return @tag if defined?(@tag)

  @tag = @stable_tag || extract_tag_from_url(source_url) || version
end

#to_osv_queryObject



123
124
125
126
127
# File 'lib/brew/vulns/formula.rb', line 123

def to_osv_query
  return nil unless repo_url && tag

  { repo_url: repo_url, version: tag, name: name }
end