Skip to content

Commit db80718

Browse files
committed
Add aliases to to SecurityAdvisory and add hex_advisory:group_for_display/1 (#188)
1 parent f0f6762 commit db80718

5 files changed

Lines changed: 312 additions & 71 deletions

File tree

proto/hex_pb_package.proto

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ message Release {
2727
optional bytes outer_checksum = 5;
2828
// Indexes into Package.advisories for advisories affecting this release
2929
repeated uint32 advisory_indexes = 6;
30-
// Release published timestamp
30+
// Release published timestamp. Optional for backwards compatibility —
31+
// clients treat absence as "no information".
3132
optional Timestamp published_at = 7;
3233
}
3334

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

6266
enum AdvisorySeverity {

src/hex_advisory.erl

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
%% @doc
2+
%% Display-time deduplication of security advisories.
3+
%%
4+
%% Multiple advisory sources (EEF, GHSA, NVD, ...) can publish the same
5+
%% vulnerability under different identifiers and cross-reference each other
6+
%% via the `aliases' field. `group_for_display/1' groups such advisories
7+
%% and picks a deterministic primary so callers can render one entry per
8+
%% vulnerability.
9+
-module(hex_advisory).
10+
-export([group_for_display/1]).
11+
12+
-type advisory() :: map().
13+
-type group() :: map().
14+
15+
-spec group_for_display([advisory()]) -> [group()].
16+
group_for_display(Advisories) ->
17+
%% Preserve input order of first member per group.
18+
{GroupOrder, GroupsByKey} =
19+
lists:foldl(
20+
fun(Adv, {Order, Map}) ->
21+
Key = group_key(Adv),
22+
case maps:is_key(Key, Map) of
23+
true ->
24+
Existing = maps:get(Key, Map),
25+
{Order, Map#{Key := Existing ++ [Adv]}};
26+
false ->
27+
{Order ++ [Key], Map#{Key => [Adv]}}
28+
end
29+
end,
30+
{[], #{}},
31+
Advisories
32+
),
33+
[merge_group(maps:get(Key, GroupsByKey)) || Key <- GroupOrder].
34+
35+
%%====================================================================
36+
%% Grouping
37+
%%====================================================================
38+
39+
%% Key is the first CVE-prefixed identifier across {id, aliases}, else id.
40+
group_key(#{id := Id} = Adv) ->
41+
Ids = [Id | maps:get(aliases, Adv, [])],
42+
case lists:dropwhile(fun(I) -> not is_cve(I) end, Ids) of
43+
[Cve | _] -> Cve;
44+
[] -> Id
45+
end.
46+
47+
is_cve(<<"CVE-", _/binary>>) -> true;
48+
is_cve(_) -> false.
49+
50+
%%====================================================================
51+
%% Merging
52+
%%====================================================================
53+
54+
merge_group(Advisories) ->
55+
Primary = pick_primary(Advisories),
56+
Rest = [A || A <- Advisories, maps:get(id, A) =/= maps:get(id, Primary)],
57+
Primary#{aliases => display_aliases(Primary, [Primary | Rest])}.
58+
59+
pick_primary(Advisories) ->
60+
[Primary | _] = lists:sort(
61+
fun(A, B) -> source_key(A) =< source_key(B) end,
62+
Advisories
63+
),
64+
Primary.
65+
66+
source_key(#{id := Id}) -> {source_priority(Id), Id}.
67+
68+
source_priority(<<"EEF-", _/binary>>) -> 0;
69+
source_priority(<<"GHSA-", _/binary>>) -> 1;
70+
source_priority(<<"NVD-", _/binary>>) -> 2;
71+
source_priority(_) -> 3.
72+
73+
%%====================================================================
74+
%% Aliases
75+
%%====================================================================
76+
77+
display_aliases(Primary, Advisories) ->
78+
PrimaryId = maps:get(id, Primary),
79+
AdvisoryIds = sets:from_list([maps:get(id, A) || A <- Advisories]),
80+
AllIds = lists:flatmap(fun identifiers/1, Advisories),
81+
Unique = uniq(AllIds),
82+
[
83+
#{
84+
id => Id,
85+
url => alias_url(Id, AdvisoryIds)
86+
}
87+
|| Id <- Unique, Id =/= PrimaryId
88+
].
89+
90+
identifiers(Advisory) ->
91+
[maps:get(id, Advisory) | maps:get(aliases, Advisory, [])].
92+
93+
alias_url(Id, AdvisoryIds) ->
94+
case sets:is_element(Id, AdvisoryIds) of
95+
true -> <<"https://osv.dev/vulnerability/", Id/binary>>;
96+
false -> undefined
97+
end.
98+
99+
uniq(List) ->
100+
{_, Out} =
101+
lists:foldl(
102+
fun(X, {Seen, Acc}) ->
103+
case sets:is_element(X, Seen) of
104+
true -> {Seen, Acc};
105+
false -> {sets:add_element(X, Seen), Acc ++ [X]}
106+
end
107+
end,
108+
{sets:new(), []},
109+
List
110+
),
111+
Out.

0 commit comments

Comments
 (0)