Skip to content
Merged
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
6 changes: 5 additions & 1 deletion proto/hex_pb_package.proto
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ message Release {
optional bytes outer_checksum = 5;
// Indexes into Package.advisories for advisories affecting this release
repeated uint32 advisory_indexes = 6;
// Release published timestamp
// Release published timestamp. Optional for backwards compatibility —
// clients treat absence as "no information".
optional Timestamp published_at = 7;
}

Expand Down Expand Up @@ -57,6 +58,9 @@ message SecurityAdvisory {
optional float cvss_score = 5;
// OSV API URL for the advisory
required string api_url = 6;
// Other identifiers for the same vulnerability (e.g. a CVE id when the
// primary id is a GHSA id, or vice versa).
repeated string aliases = 7;
}

enum AdvisorySeverity {
Expand Down
111 changes: 111 additions & 0 deletions src/hex_advisory.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
%% @doc
%% Display-time deduplication of security advisories.
%%
%% Multiple advisory sources (EEF, GHSA, NVD, ...) can publish the same
%% vulnerability under different identifiers and cross-reference each other
%% via the `aliases' field. `group_for_display/1' groups such advisories
%% and picks a deterministic primary so callers can render one entry per
%% vulnerability.
-module(hex_advisory).
-export([group_for_display/1]).

-type advisory() :: map().
-type group() :: map().

-spec group_for_display([advisory()]) -> [group()].
group_for_display(Advisories) ->
%% Preserve input order of first member per group.
{GroupOrder, GroupsByKey} =
lists:foldl(
fun(Adv, {Order, Map}) ->
Key = group_key(Adv),
case maps:is_key(Key, Map) of
true ->
Existing = maps:get(Key, Map),
{Order, Map#{Key := Existing ++ [Adv]}};
false ->
{Order ++ [Key], Map#{Key => [Adv]}}
end
end,
{[], #{}},
Advisories
),
[merge_group(maps:get(Key, GroupsByKey)) || Key <- GroupOrder].

%%====================================================================
%% Grouping
%%====================================================================

%% Key is the first CVE-prefixed identifier across {id, aliases}, else id.
group_key(#{id := Id} = Adv) ->
Ids = [Id | maps:get(aliases, Adv, [])],
case lists:dropwhile(fun(I) -> not is_cve(I) end, Ids) of
[Cve | _] -> Cve;
[] -> Id
end.

is_cve(<<"CVE-", _/binary>>) -> true;
is_cve(_) -> false.

%%====================================================================
%% Merging
%%====================================================================

merge_group(Advisories) ->
Primary = pick_primary(Advisories),
Rest = [A || A <- Advisories, maps:get(id, A) =/= maps:get(id, Primary)],
Primary#{aliases => display_aliases(Primary, [Primary | Rest])}.

pick_primary(Advisories) ->
[Primary | _] = lists:sort(
fun(A, B) -> source_key(A) =< source_key(B) end,
Advisories
),
Primary.

source_key(#{id := Id}) -> {source_priority(Id), Id}.

source_priority(<<"EEF-", _/binary>>) -> 0;
source_priority(<<"GHSA-", _/binary>>) -> 1;
source_priority(<<"NVD-", _/binary>>) -> 2;
source_priority(_) -> 3.

%%====================================================================
%% Aliases
%%====================================================================

display_aliases(Primary, Advisories) ->
PrimaryId = maps:get(id, Primary),
AdvisoryIds = sets:from_list([maps:get(id, A) || A <- Advisories]),
AllIds = lists:flatmap(fun identifiers/1, Advisories),
Unique = uniq(AllIds),
[
#{
id => Id,
url => alias_url(Id, AdvisoryIds)
}
|| Id <- Unique, Id =/= PrimaryId
].

identifiers(Advisory) ->
[maps:get(id, Advisory) | maps:get(aliases, Advisory, [])].

alias_url(Id, AdvisoryIds) ->
case sets:is_element(Id, AdvisoryIds) of
true -> <<"https://osv.dev/vulnerability/", Id/binary>>;
false -> undefined
end.

uniq(List) ->
{_, Out} =
lists:foldl(
fun(X, {Seen, Acc}) ->
case sets:is_element(X, Seen) of
true -> {Seen, Acc};
false -> {sets:add_element(X, Seen), Acc ++ [X]}
end
end,
{sets:new(), []},
List
),
Out.
Loading
Loading