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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
grouped_advisories =
Enum.map(indexed_advisories, fn {advisory, index} ->
advisory
|> groupable_advisory()
|> Map.put(:mix_audit_advisory, advisory)
|> Map.put(:mix_audit_index, index)
end)

grouped_advisories
|> :hex_advisory.group_for_display()
|> Enum.map(fn grouped_advisory ->
identifiers = identifiers(grouped_advisory)

indexes =
grouped_advisories
|> Enum.filter(&(not MapSet.disjoint?(identifiers, identifiers(&1))))
|> Enum.map(& &1.mix_audit_index)

%{advisory: grouped_advisory.mix_audit_advisory, indexes: indexes}
end)
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 identifiers(advisory) do
advisory
|> aliases()
|> Enum.map(&to_binary_id/1)
|> MapSet.new()
end

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

defp groupable_advisory(advisory) do
%{
id: to_binary_id(field(advisory, :id)),
aliases: Enum.map(field(advisory, :aliases) || [], &to_binary_id/1)
}
end

defp alias_id(%{id: id}), do: id
defp alias_id(%{"id" => id}), do: id
defp alias_id(id), do: id

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

defp to_binary_id(id) when is_binary(id), do: id
defp to_binary_id(id), do: to_string(id)

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: "EEF 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