Current section
Files
Jump to
Current section
Files
src/hex_advisory.erl
%% @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.