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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions lib/mix_audit/hex_advisories.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
defmodule MixAudit.HexAdvisories do
def advisories(config \\ :hex_core.default_config()) do
with {:ok, {200, _headers, %{packages: packages}}} <- :hex_repo.get_versions(config) do
packages
|> Enum.filter(&has_advisories?/1)
|> Enum.flat_map(&package_advisories(config, field(&1, :name)))
else
_ -> []
end
end

def map_package_advisories(package, releases, advisories) do
advisories
|> Enum.with_index()
|> advisory_groups()
|> Enum.map(fn %{advisory: advisory, indexes: indexes} ->
affected_versions = affected_versions(releases, indexes)

%MixAudit.Advisory{
id: advisory_id(advisory),
package: package,
disclosure_date: nil,
url: field(advisory, :html_url) || field(advisory, :api_url),
title: field(advisory, :summary) || advisory_id(advisory),
description: field(advisory, :summary) || "",
vulnerable_version_ranges: Enum.map(affected_versions, &"= #{&1}"),
first_patched_versions: first_patched_versions(releases, indexes, affected_versions),
severity: severity(advisory)
}
end)
|> Enum.reject(&Enum.empty?(&1.vulnerable_version_ranges))
end

defp advisory_groups(indexed_advisories) do
Enum.reduce(indexed_advisories, [], fn {advisory, index}, groups ->
identifiers = identifiers(advisory)

case Enum.find_index(groups, &same_advisory?(&1.identifiers, identifiers)) do
nil ->
[
%{
advisory: advisory,
identifiers: identifiers,
indexes: [index]
}
| groups
]

group_index ->
List.update_at(groups, group_index, fn group ->
%{
group
| advisory: preferred_advisory(group.advisory, advisory),
identifiers: MapSet.union(group.identifiers, identifiers),
indexes: [index | group.indexes]
}
end)
end
end)
|> Enum.reverse()
end
Comment thread
dl-alexandre marked this conversation as resolved.

defp package_advisories(config, package) do
case :hex_repo.get_package(config, package) do
{:ok, {200, _headers, %{releases: releases, advisories: advisories}}} ->
map_package_advisories(package, releases, advisories)

_ ->
[]
end
end

defp has_advisories?(package) do
package
|> field(:with_advisories)
|> empty?()
|> Kernel.not()
end

defp same_advisory?(left, right), do: not MapSet.disjoint?(left, right)

defp identifiers(advisory) do
advisory
|> aliases()
|> MapSet.new()
end

defp aliases(advisory) do
[field(advisory, :id) | field(advisory, :aliases) || []]
|> Enum.reject(&is_nil/1)
end

defp preferred_advisory(left, right) do
cond do
ghsa_id?(field(left, :id)) -> left
ghsa_id?(field(right, :id)) -> right
true -> left
end
end

defp ghsa_id?(id) when is_binary(id), do: String.starts_with?(id, "GHSA-")
defp ghsa_id?(_id), do: false

defp affected_versions(releases, advisory_indexes) do
releases
|> Enum.filter(&has_advisory_index?(&1, advisory_indexes))
|> Enum.map(&field(&1, :version))
end

defp first_patched_versions(_releases, _advisory_indexes, []), do: []

defp first_patched_versions(releases, advisory_indexes, affected_versions) do
highest_affected_version = Enum.max(affected_versions, Version)

releases
|> Enum.reject(&has_advisory_index?(&1, advisory_indexes))
|> Enum.map(&field(&1, :version))
|> Enum.filter(&(Version.compare(&1, highest_affected_version) == :gt))
|> Enum.sort(Version)
|> Enum.take(1)
end

defp has_advisory_index?(release, advisory_indexes) do
advisory_indexes = MapSet.new(advisory_indexes)

release
|> field(:advisory_indexes)
|> MapSet.new()
|> MapSet.disjoint?(advisory_indexes)
|> Kernel.not()
end

defp advisory_id(advisory) do
aliases(advisory)
|> Enum.find(&ghsa_id?/1)
|> Kernel.||(field(advisory, :id) || List.first(field(advisory, :aliases) || []))
end

defp severity(advisory) do
case field(advisory, :severity) do
nil ->
nil

severity when is_atom(severity) ->
severity
|> Atom.to_string()
|> String.replace_prefix("SEVERITY_", "")
|> String.downcase()

severity ->
to_string(severity)
end
end

defp empty?(nil), do: true
defp empty?(collection), do: Enum.empty?(collection)

defp field(map, key) when is_map(map) do
Map.get(map, key) || Map.get(map, to_string(key))
end
end
5 changes: 5 additions & 0 deletions lib/mix_audit/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ defmodule MixAudit.Repo do
def advisories do
synchronize()

(yaml_advisories() ++ MixAudit.HexAdvisories.advisories())
|> Enum.uniq_by(&{&1.package, &1.id})
end

defp yaml_advisories do
package_advisories_path()
|> Path.wildcard()
|> Enum.map(&map_advisory/1)
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ defmodule MixAudit.MixProject do
defp deps do
[
{:yaml_elixir, "~> 2.11"},
{:hex_core, "~> 0.17.0"},
{:jason, "~> 1.4"},
{:ex_doc, ">= 0.0.0", only: :dev},
{:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false}
Expand Down
7 changes: 4 additions & 3 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
%{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
"earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ex_doc": {:hex, :ex_doc, "0.38.4", "ab48dff7a8af84226bf23baddcdda329f467255d924380a0cf0cee97bb9a9ede", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "f7b62346408a83911c2580154e35613eb314e0278aeea72ed7fedef9c1f165b2"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"hex_core": {:hex, :hex_core, "0.17.0", "b942b9bc1b6959ea289e77c4915330935b83fb569232e6d6bf21de5d1ec581e7", [:rebar3], [], "hexpm", "c7e2e2bee85ccdd2d56b88efb8883c45cf0247a18310a030f204cf207b806b3a"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
Expand Down
83 changes: 83 additions & 0 deletions test/mix_audit/hex_advisories_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
defmodule MixAudit.HexAdvisoriesTest do
use ExUnit.Case

alias MixAudit.HexAdvisories

test "map_package_advisories/3 maps Hex advisory indexes to exact vulnerable versions" do
releases = [
%{version: "0.1.0", advisory_indexes: [0]},
%{version: "0.2.0", advisory_indexes: [0, 1]},
%{version: "1.0.0", advisory_indexes: []}
]

advisories = [
%{
id: "GHSA-1234",
severity: :SEVERITY_HIGH,
html_url: "https://osv.dev/vulnerability/GHSA-1234",
summary: "Vulnerable challenge handling"
},
%{
id: "GHSA-5678",
severity: "medium",
api_url: "https://api.osv.dev/v1/vulns/GHSA-5678",
summary: "Replay vulnerability"
}
]

assert [
%MixAudit.Advisory{
id: "GHSA-1234",
package: "altcha",
severity: "high",
url: "https://osv.dev/vulnerability/GHSA-1234",
title: "Vulnerable challenge handling",
description: "Vulnerable challenge handling",
vulnerable_version_ranges: ["= 0.1.0", "= 0.2.0"],
first_patched_versions: ["1.0.0"]
},
%MixAudit.Advisory{
id: "GHSA-5678",
package: "altcha",
severity: "medium",
url: "https://api.osv.dev/v1/vulns/GHSA-5678",
title: "Replay vulnerability",
vulnerable_version_ranges: ["= 0.2.0"],
first_patched_versions: ["1.0.0"]
}
] = HexAdvisories.map_package_advisories("altcha", releases, advisories)
end

test "map_package_advisories/3 collapses aliased OSV records" do
releases = [
%{version: "1.0.0", advisory_indexes: [0]},
%{version: "1.1.0", advisory_indexes: [0, 1]},
%{version: "1.2.0", advisory_indexes: []}
]

advisories = [
%{
id: "EEF-CVE-2026-39803",
aliases: ["CVE-2026-39803", "GHSA-9q9q-324x-93r2"],
severity: :SEVERITY_HIGH,
summary: "EEF record"
},
%{
id: "GHSA-9q9q-324x-93r2",
aliases: ["CVE-2026-39803", "EEF-CVE-2026-39803"],
severity: :SEVERITY_HIGH,
summary: "GHSA record"
}
]

assert [
%MixAudit.Advisory{
id: "GHSA-9q9q-324x-93r2",
package: "bandit",
title: "GHSA record",
vulnerable_version_ranges: ["= 1.0.0", "= 1.1.0"],
first_patched_versions: ["1.2.0"]
}
] = HexAdvisories.map_package_advisories("bandit", releases, advisories)
end
end