diff --git a/proto/hex_pb_package.proto b/proto/hex_pb_package.proto index e99869d..3e4da51 100644 --- a/proto/hex_pb_package.proto +++ b/proto/hex_pb_package.proto @@ -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; } @@ -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 { diff --git a/src/hex_advisory.erl b/src/hex_advisory.erl new file mode 100644 index 0000000..700c252 --- /dev/null +++ b/src/hex_advisory.erl @@ -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. diff --git a/src/hex_pb_package.erl b/src/hex_pb_package.erl index b12d6ae..e1006cf 100644 --- a/src/hex_pb_package.erl +++ b/src/hex_pb_package.erl @@ -84,7 +84,8 @@ html_url => unicode:chardata(), % = 3, required severity => 'SEVERITY_NONE' | 'SEVERITY_LOW' | 'SEVERITY_MEDIUM' | 'SEVERITY_HIGH' | 'SEVERITY_CRITICAL' | integer(), % = 4, optional, enum AdvisorySeverity cvss_score => float() | integer() | infinity | '-infinity' | nan, % = 5, optional - api_url => unicode:chardata() % = 6, required + api_url => unicode:chardata(), % = 6, required + aliases => [unicode:chardata()] % = 7, repeated }. -type 'Dependency'() :: @@ -205,7 +206,15 @@ encode_msg_SecurityAdvisory(#{id := F1, summary := F2, html_url := F3, api_url : #{cvss_score := F5} -> begin TrF5 = id(F5, TrUserData), e_type_float(TrF5, <>, TrUserData) end; _ -> B4 end, - begin TrF6 = id(F6, TrUserData), e_type_string(TrF6, <>, TrUserData) end. + B6 = begin TrF6 = id(F6, TrUserData), e_type_string(TrF6, <>, TrUserData) end, + case M of + #{aliases := F7} -> + TrF7 = id(F7, TrUserData), + if TrF7 == [] -> B6; + true -> e_field_SecurityAdvisory_aliases(TrF7, B6, TrUserData) + end; + _ -> B6 + end. encode_msg_Dependency(Msg, TrUserData) -> encode_msg_Dependency(Msg, <<>>, TrUserData). @@ -282,6 +291,12 @@ e_mfield_Release_published_at(Msg, Bin, TrUserData) -> Bin2 = e_varint(byte_size(SubBin), Bin), <>. +e_field_SecurityAdvisory_aliases([Elem | Rest], Bin, TrUserData) -> + Bin2 = <>, + Bin3 = e_type_string(id(Elem, TrUserData), Bin2, TrUserData), + e_field_SecurityAdvisory_aliases(Rest, Bin3, TrUserData); +e_field_SecurityAdvisory_aliases([], Bin, _TrUserData) -> Bin. + e_enum_RetirementReason('RETIRED_OTHER', Bin, _TrUserData) -> <>; e_enum_RetirementReason('RETIRED_INVALID', Bin, _TrUserData) -> <>; e_enum_RetirementReason('RETIRED_SECURITY', Bin, _TrUserData) -> <>; @@ -725,46 +740,48 @@ skip_32_RetirementStatus(<<_:32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, TrUserDat skip_64_RetirementStatus(<<_:64, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, TrUserData) -> dfp_read_field_def_RetirementStatus(Rest, Z1, Z2, F, F@_1, F@_2, TrUserData). decode_msg_SecurityAdvisory(Bin, TrUserData) -> - dfp_read_field_def_SecurityAdvisory(Bin, 0, 0, 0, id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), TrUserData). - -dfp_read_field_def_SecurityAdvisory(<<10, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> d_field_SecurityAdvisory_id(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -dfp_read_field_def_SecurityAdvisory(<<18, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> d_field_SecurityAdvisory_summary(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -dfp_read_field_def_SecurityAdvisory(<<26, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> d_field_SecurityAdvisory_html_url(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -dfp_read_field_def_SecurityAdvisory(<<32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> d_field_SecurityAdvisory_severity(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -dfp_read_field_def_SecurityAdvisory(<<45, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> d_field_SecurityAdvisory_cvss_score(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -dfp_read_field_def_SecurityAdvisory(<<50, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> d_field_SecurityAdvisory_api_url(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -dfp_read_field_def_SecurityAdvisory(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, _) -> - S1 = #{id => F@_1, summary => F@_2, html_url => F@_3, api_url => F@_6}, + dfp_read_field_def_SecurityAdvisory(Bin, 0, 0, 0, id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id([], TrUserData), TrUserData). + +dfp_read_field_def_SecurityAdvisory(<<10, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_SecurityAdvisory_id(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_SecurityAdvisory(<<18, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_SecurityAdvisory_summary(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_SecurityAdvisory(<<26, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_SecurityAdvisory_html_url(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_SecurityAdvisory(<<32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_SecurityAdvisory_severity(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_SecurityAdvisory(<<45, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_SecurityAdvisory_cvss_score(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_SecurityAdvisory(<<50, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_SecurityAdvisory_api_url(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_SecurityAdvisory(<<58, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> d_field_SecurityAdvisory_aliases(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dfp_read_field_def_SecurityAdvisory(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, R1, TrUserData) -> + S1 = #{id => F@_1, summary => F@_2, html_url => F@_3, api_url => F@_6, aliases => lists_reverse(R1, TrUserData)}, S2 = if F@_4 == '$undef' -> S1; true -> S1#{severity => F@_4} end, if F@_5 == '$undef' -> S2; true -> S2#{cvss_score => F@_5} end; -dfp_read_field_def_SecurityAdvisory(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> dg_read_field_def_SecurityAdvisory(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData). +dfp_read_field_def_SecurityAdvisory(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dg_read_field_def_SecurityAdvisory(Other, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). -dg_read_field_def_SecurityAdvisory(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) when N < 32 - 7 -> - dg_read_field_def_SecurityAdvisory(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -dg_read_field_def_SecurityAdvisory(<<0:1, X:7, Rest/binary>>, N, Acc, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> +dg_read_field_def_SecurityAdvisory(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 32 - 7 -> + dg_read_field_def_SecurityAdvisory(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +dg_read_field_def_SecurityAdvisory(<<0:1, X:7, Rest/binary>>, N, Acc, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> Key = X bsl N + Acc, case Key of - 10 -> d_field_SecurityAdvisory_id(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 18 -> d_field_SecurityAdvisory_summary(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 26 -> d_field_SecurityAdvisory_html_url(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 32 -> d_field_SecurityAdvisory_severity(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 45 -> d_field_SecurityAdvisory_cvss_score(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 50 -> d_field_SecurityAdvisory_api_url(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); + 10 -> d_field_SecurityAdvisory_id(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 18 -> d_field_SecurityAdvisory_summary(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 26 -> d_field_SecurityAdvisory_html_url(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 32 -> d_field_SecurityAdvisory_severity(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 45 -> d_field_SecurityAdvisory_cvss_score(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 50 -> d_field_SecurityAdvisory_api_url(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 58 -> d_field_SecurityAdvisory_aliases(Rest, 0, 0, 0, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); _ -> case Key band 7 of - 0 -> skip_varint_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 1 -> skip_64_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 2 -> skip_length_delimited_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 3 -> skip_group_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); - 5 -> skip_32_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) + 0 -> skip_varint_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 1 -> skip_64_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 2 -> skip_length_delimited_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 3 -> skip_group_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); + 5 -> skip_32_SecurityAdvisory(Rest, 0, 0, Key bsr 3, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) end end; -dg_read_field_def_SecurityAdvisory(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, _) -> - S1 = #{id => F@_1, summary => F@_2, html_url => F@_3, api_url => F@_6}, +dg_read_field_def_SecurityAdvisory(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, R1, TrUserData) -> + S1 = #{id => F@_1, summary => F@_2, html_url => F@_3, api_url => F@_6, aliases => lists_reverse(R1, TrUserData)}, S2 = if F@_4 == '$undef' -> S1; true -> S1#{severity => F@_4} end, @@ -772,56 +789,67 @@ dg_read_field_def_SecurityAdvisory(<<>>, 0, 0, _, F@_1, F@_2, F@_3, F@_4, F@_5, true -> S2#{cvss_score => F@_5} end. -d_field_SecurityAdvisory_id(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) when N < 57 -> d_field_SecurityAdvisory_id(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -d_field_SecurityAdvisory_id(<<0:1, X:7, Rest/binary>>, N, Acc, F, _, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> +d_field_SecurityAdvisory_id(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> d_field_SecurityAdvisory_id(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_id(<<0:1, X:7, Rest/binary>>, N, Acc, F, _, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, - dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, NewFValue, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData). + dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, NewFValue, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). -d_field_SecurityAdvisory_summary(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) when N < 57 -> d_field_SecurityAdvisory_summary(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -d_field_SecurityAdvisory_summary(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, _, F@_3, F@_4, F@_5, F@_6, TrUserData) -> +d_field_SecurityAdvisory_summary(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_field_SecurityAdvisory_summary(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_summary(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, _, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, - dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, NewFValue, F@_3, F@_4, F@_5, F@_6, TrUserData). + dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, NewFValue, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). -d_field_SecurityAdvisory_html_url(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) when N < 57 -> d_field_SecurityAdvisory_html_url(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -d_field_SecurityAdvisory_html_url(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, _, F@_4, F@_5, F@_6, TrUserData) -> +d_field_SecurityAdvisory_html_url(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_field_SecurityAdvisory_html_url(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_html_url(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, _, F@_4, F@_5, F@_6, F@_7, TrUserData) -> {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, - dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, F@_2, NewFValue, F@_4, F@_5, F@_6, TrUserData). + dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, F@_2, NewFValue, F@_4, F@_5, F@_6, F@_7, TrUserData). -d_field_SecurityAdvisory_severity(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) when N < 57 -> d_field_SecurityAdvisory_severity(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -d_field_SecurityAdvisory_severity(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, _, F@_5, F@_6, TrUserData) -> +d_field_SecurityAdvisory_severity(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_field_SecurityAdvisory_severity(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_severity(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, _, F@_5, F@_6, F@_7, TrUserData) -> {NewFValue, RestF} = {id(d_enum_AdvisorySeverity(begin <> = <<(X bsl N + Acc):32/unsigned-native>>, id(Res, TrUserData) end), TrUserData), Rest}, - dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, F@_2, F@_3, NewFValue, F@_5, F@_6, TrUserData). - -d_field_SecurityAdvisory_cvss_score(<<0:16, 128, 127, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, TrUserData) -> dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id(infinity, TrUserData), F@_6, TrUserData); -d_field_SecurityAdvisory_cvss_score(<<0:16, 128, 255, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, TrUserData) -> - dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id('-infinity', TrUserData), F@_6, TrUserData); -d_field_SecurityAdvisory_cvss_score(<<_:16, 1:1, _:7, _:1, 127:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, TrUserData) -> - dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id(nan, TrUserData), F@_6, TrUserData); -d_field_SecurityAdvisory_cvss_score(<>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, TrUserData) -> - dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id(Value, TrUserData), F@_6, TrUserData). - -d_field_SecurityAdvisory_api_url(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) when N < 57 -> d_field_SecurityAdvisory_api_url(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -d_field_SecurityAdvisory_api_url(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, _, TrUserData) -> + dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, F@_2, F@_3, NewFValue, F@_5, F@_6, F@_7, TrUserData). + +d_field_SecurityAdvisory_cvss_score(<<0:16, 128, 127, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, F@_7, TrUserData) -> + dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id(infinity, TrUserData), F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_cvss_score(<<0:16, 128, 255, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, F@_7, TrUserData) -> + dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id('-infinity', TrUserData), F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_cvss_score(<<_:16, 1:1, _:7, _:1, 127:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, F@_7, TrUserData) -> + dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id(nan, TrUserData), F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_cvss_score(<>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, _, F@_6, F@_7, TrUserData) -> + dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, id(Value, TrUserData), F@_6, F@_7, TrUserData). + +d_field_SecurityAdvisory_api_url(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_field_SecurityAdvisory_api_url(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_api_url(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, _, F@_7, TrUserData) -> + {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, + dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, NewFValue, F@_7, TrUserData). + +d_field_SecurityAdvisory_aliases(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + d_field_SecurityAdvisory_aliases(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +d_field_SecurityAdvisory_aliases(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, Prev, TrUserData) -> {NewFValue, RestF} = begin Len = X bsl N + Acc, <> = Rest, Bytes2 = binary:copy(Bytes), {id(Bytes2, TrUserData), Rest2} end, - dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, NewFValue, TrUserData). + dfp_read_field_def_SecurityAdvisory(RestF, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, cons(NewFValue, Prev, TrUserData), TrUserData). -skip_varint_SecurityAdvisory(<<1:1, _:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> skip_varint_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -skip_varint_SecurityAdvisory(<<0:1, _:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData). +skip_varint_SecurityAdvisory(<<1:1, _:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> skip_varint_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +skip_varint_SecurityAdvisory(<<0:1, _:7, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). -skip_length_delimited_SecurityAdvisory(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) when N < 57 -> - skip_length_delimited_SecurityAdvisory(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData); -skip_length_delimited_SecurityAdvisory(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> +skip_length_delimited_SecurityAdvisory(<<1:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) when N < 57 -> + skip_length_delimited_SecurityAdvisory(Rest, N + 7, X bsl N + Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData); +skip_length_delimited_SecurityAdvisory(<<0:1, X:7, Rest/binary>>, N, Acc, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> Length = X bsl N + Acc, <<_:Length/binary, Rest2/binary>> = Rest, - dfp_read_field_def_SecurityAdvisory(Rest2, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData). + dfp_read_field_def_SecurityAdvisory(Rest2, 0, 0, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). -skip_group_SecurityAdvisory(Bin, _, Z2, FNum, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> +skip_group_SecurityAdvisory(Bin, _, Z2, FNum, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> {_, Rest} = read_group(Bin, FNum), - dfp_read_field_def_SecurityAdvisory(Rest, 0, Z2, FNum, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData). + dfp_read_field_def_SecurityAdvisory(Rest, 0, Z2, FNum, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). -skip_32_SecurityAdvisory(<<_:32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData). +skip_32_SecurityAdvisory(<<_:32, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). -skip_64_SecurityAdvisory(<<_:64, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData) -> dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, TrUserData). +skip_64_SecurityAdvisory(<<_:64, Rest/binary>>, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData) -> dfp_read_field_def_SecurityAdvisory(Rest, Z1, Z2, F, F@_1, F@_2, F@_3, F@_4, F@_5, F@_6, F@_7, TrUserData). decode_msg_Dependency(Bin, TrUserData) -> dfp_read_field_def_Dependency(Bin, 0, 0, 0, id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), id('$undef', TrUserData), TrUserData). @@ -1110,17 +1138,23 @@ merge_msg_RetirementStatus(#{} = PMsg, #{reason := NFreason} = NMsg, _) -> end. -compile({nowarn_unused_function,merge_msg_SecurityAdvisory/3}). -merge_msg_SecurityAdvisory(#{} = PMsg, #{id := NFid, summary := NFsummary, html_url := NFhtml_url, api_url := NFapi_url} = NMsg, _) -> +merge_msg_SecurityAdvisory(#{} = PMsg, #{id := NFid, summary := NFsummary, html_url := NFhtml_url, api_url := NFapi_url} = NMsg, TrUserData) -> S1 = #{id => NFid, summary => NFsummary, html_url => NFhtml_url, api_url => NFapi_url}, S2 = case {PMsg, NMsg} of {_, #{severity := NFseverity}} -> S1#{severity => NFseverity}; {#{severity := PFseverity}, _} -> S1#{severity => PFseverity}; _ -> S1 end, + S3 = case {PMsg, NMsg} of + {_, #{cvss_score := NFcvss_score}} -> S2#{cvss_score => NFcvss_score}; + {#{cvss_score := PFcvss_score}, _} -> S2#{cvss_score => PFcvss_score}; + _ -> S2 + end, case {PMsg, NMsg} of - {_, #{cvss_score := NFcvss_score}} -> S2#{cvss_score => NFcvss_score}; - {#{cvss_score := PFcvss_score}, _} -> S2#{cvss_score => PFcvss_score}; - _ -> S2 + {#{aliases := PFaliases}, #{aliases := NFaliases}} -> S3#{aliases => 'erlang_++'(PFaliases, NFaliases, TrUserData)}; + {_, #{aliases := NFaliases}} -> S3#{aliases => NFaliases}; + {#{aliases := PFaliases}, _} -> S3#{aliases => PFaliases}; + {_, _} -> S3 end. -compile({nowarn_unused_function,merge_msg_Dependency/3}). @@ -1281,12 +1315,22 @@ v_msg_SecurityAdvisory(#{id := F1, summary := F2, html_url := F3, api_url := F6} _ -> ok end, v_type_string(F6, [api_url | Path], TrUserData), + case M of + #{aliases := F7} -> + if is_list(F7) -> + _ = [v_type_string(Elem, [aliases | Path], TrUserData) || Elem <- F7], + ok; + true -> mk_type_error({invalid_list_of, string}, F7, [aliases | Path]) + end; + _ -> ok + end, lists:foreach(fun (id) -> ok; (summary) -> ok; (html_url) -> ok; (severity) -> ok; (cvss_score) -> ok; (api_url) -> ok; + (aliases) -> ok; (OtherKey) -> mk_type_error({extraneous_key, OtherKey}, M, Path) end, maps:keys(M)), @@ -1463,7 +1507,8 @@ get_msg_defs() -> #{name => html_url, fnum => 3, rnum => 4, type => string, occurrence => required, opts => []}, #{name => severity, fnum => 4, rnum => 5, type => {enum, 'AdvisorySeverity'}, occurrence => optional, opts => []}, #{name => cvss_score, fnum => 5, rnum => 6, type => float, occurrence => optional, opts => []}, - #{name => api_url, fnum => 6, rnum => 7, type => string, occurrence => required, opts => []}]}, + #{name => api_url, fnum => 6, rnum => 7, type => string, occurrence => required, opts => []}, + #{name => aliases, fnum => 7, rnum => 8, type => string, occurrence => repeated, opts => []}]}, {{msg, 'Dependency'}, [#{name => package, fnum => 1, rnum => 2, type => string, occurrence => required, opts => []}, #{name => requirement, fnum => 2, rnum => 3, type => string, occurrence => required, opts => []}, @@ -1519,7 +1564,8 @@ find_msg_def('SecurityAdvisory') -> #{name => html_url, fnum => 3, rnum => 4, type => string, occurrence => required, opts => []}, #{name => severity, fnum => 4, rnum => 5, type => {enum, 'AdvisorySeverity'}, occurrence => optional, opts => []}, #{name => cvss_score, fnum => 5, rnum => 6, type => float, occurrence => optional, opts => []}, - #{name => api_url, fnum => 6, rnum => 7, type => string, occurrence => required, opts => []}]; + #{name => api_url, fnum => 6, rnum => 7, type => string, occurrence => required, opts => []}, + #{name => aliases, fnum => 7, rnum => 8, type => string, occurrence => repeated, opts => []}]; find_msg_def('Dependency') -> [#{name => package, fnum => 1, rnum => 2, type => string, occurrence => required, opts => []}, #{name => requirement, fnum => 2, rnum => 3, type => string, occurrence => required, opts => []}, diff --git a/test/hex_advisory_SUITE.erl b/test/hex_advisory_SUITE.erl new file mode 100644 index 0000000..409e35f --- /dev/null +++ b/test/hex_advisory_SUITE.erl @@ -0,0 +1,79 @@ +-module(hex_advisory_SUITE). + +-compile([export_all]). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +all() -> + [ + identity_when_no_aliases, + groups_advisories_sharing_cve_alias, + prefers_eef_over_ghsa_over_nvd, + breaks_same_source_ties_by_id_ascending, + attaches_osv_url_only_to_alias_ids_that_are_group_members + ]. + +identity_when_no_aliases(_Config) -> + A = base_advisory(<<"GHSA-aaaa-1111-bbbb">>, <<"single">>, []), + [Result] = hex_advisory:group_for_display([A]), + ?assertEqual(<<"GHSA-aaaa-1111-bbbb">>, maps:get(id, Result)), + ?assertEqual([], maps:get(aliases, Result)), + ok. + +groups_advisories_sharing_cve_alias(_Config) -> + A = base_advisory(<<"GHSA-aaaa-1111-bbbb">>, <<"A">>, [<<"CVE-2026-0001">>]), + B = base_advisory(<<"GHSA-cccc-2222-dddd">>, <<"B">>, [<<"CVE-2026-0001">>]), + [Result] = hex_advisory:group_for_display([A, B]), + %% Same source priority (GHSA-), tie-break by id ascending → A wins + ?assertEqual(<<"GHSA-aaaa-1111-bbbb">>, maps:get(id, Result)), + AliasIds = [maps:get(id, Alias) || Alias <- maps:get(aliases, Result)], + ?assertEqual( + lists:sort([<<"CVE-2026-0001">>, <<"GHSA-cccc-2222-dddd">>]), + lists:sort(AliasIds) + ), + ok. + +prefers_eef_over_ghsa_over_nvd(_Config) -> + Eef = base_advisory(<<"EEF-CVE-2026-9">>, <<"E">>, [<<"CVE-2026-9">>]), + Ghsa = base_advisory(<<"GHSA-zzzz-9999-zzzz">>, <<"G">>, [<<"CVE-2026-9">>]), + Nvd = base_advisory(<<"NVD-CVE-2026-9">>, <<"N">>, [<<"CVE-2026-9">>]), + [Result] = hex_advisory:group_for_display([Nvd, Ghsa, Eef]), + ?assertEqual(<<"EEF-CVE-2026-9">>, maps:get(id, Result)), + ok. + +breaks_same_source_ties_by_id_ascending(_Config) -> + A = base_advisory(<<"OTHER-2026-0002">>, <<"A">>, [<<"CVE-2026-0002">>]), + B = base_advisory(<<"OTHER-2026-0001">>, <<"B">>, [<<"CVE-2026-0002">>]), + [Result] = hex_advisory:group_for_display([A, B]), + ?assertEqual(<<"OTHER-2026-0001">>, maps:get(id, Result)), + ok. + +attaches_osv_url_only_to_alias_ids_that_are_group_members(_Config) -> + A = base_advisory(<<"GHSA-aaaa-1111-bbbb">>, <<"A">>, [<<"CVE-2026-0001">>]), + B = base_advisory(<<"GHSA-cccc-2222-dddd">>, <<"B">>, [<<"CVE-2026-0001">>]), + [Result] = hex_advisory:group_for_display([A, B]), + ByName = maps:from_list([{maps:get(id, X), X} || X <- maps:get(aliases, Result)]), + %% GHSA-cccc-2222-dddd is itself an advisory in the group → has osv.dev url + Ghsa = maps:get(<<"GHSA-cccc-2222-dddd">>, ByName), + ?assertEqual( + <<"https://osv.dev/vulnerability/GHSA-cccc-2222-dddd">>, + maps:get(url, Ghsa) + ), + %% CVE-2026-0001 was only an alias string → no url + Cve = maps:get(<<"CVE-2026-0001">>, ByName), + ?assertEqual(undefined, maps:get(url, Cve)), + ok. + +%%==================================================================== +%% Helpers +%%==================================================================== + +base_advisory(Id, Summary, Aliases) -> + #{ + id => Id, + summary => Summary, + html_url => <<"https://osv.dev/vulnerability/", Id/binary>>, + api_url => <<"https://api.osv.dev/v1/vulns/", Id/binary>>, + aliases => Aliases + }. diff --git a/test/hex_registry_SUITE.erl b/test/hex_registry_SUITE.erl index 0eafc68..2b1f580 100644 --- a/test/hex_registry_SUITE.erl +++ b/test/hex_registry_SUITE.erl @@ -103,7 +103,8 @@ package_test(_Config) -> html_url => <<"https://github.com/advisories/GHSA-abcd-1234-efgh">>, severity => 'SEVERITY_HIGH', cvss_score => 8.0, - api_url => <<"https://api.osv.dev/v1/vulns/GHSA-abcd-1234-efgh">> + api_url => <<"https://api.osv.dev/v1/vulns/GHSA-abcd-1234-efgh">>, + aliases => [<<"CVE-2026-0001">>, <<"NVD-CVE-2026-0001">>] } ] },