Skip to main content

src/rebar_sbom_cyclonedx.erl

%% SPDX-License-Identifier: BSD-3-Clause
%% SPDX-FileCopyrightText: 2019 Bram Verburg
%% SPDX-FileCopyrightText: 2022 lafirest
%% SPDX-FileCopyrightText: 2025 Erlang Ecosystem Foundation
%% SPDX-FileCopyrightText: 2024-2025 Stritzinger GmbH

-module(rebar_sbom_cyclonedx).

-export([bom/5, bom/6, uuid/0]).

-include("rebar_sbom.hrl").

bom(FileInfo, IsStrictVersion, App, Plugin, MetadataInfo) ->
    bom(FileInfo, IsStrictVersion, App, Plugin, uuid(), MetadataInfo).

bom({FilePath, _} = FileInfo, IsStrictVersion, App, Plugin, Serial, MetadataInfo) ->
    {AppInfo, RawComponents} = App,
    {PluginInfo, PluginDepsInfo} = Plugin,
    ValidRawComponents = lists:filter(fun(E) -> E =/= undefined end, RawComponents),
    % Filtering out rebar_sbom from plugin dependencies to avoid duplicates in output
    ValidPluginDepsInfo = lists:filter(
        fun(E) ->
            E =/= undefined andalso proplists:get_value(name, E) =/= <<"rebar_sbom">>
        end,
        PluginDepsInfo
    ),
    AllDeps = dependencies(ValidRawComponents) ++ dependencies(ValidPluginDepsInfo),
    SBoM0 = #sbom{
        serial = Serial,
        metadata = metadata(AppInfo, PluginInfo, MetadataInfo),
        components = components(ValidRawComponents) ++ components(ValidPluginDepsInfo),
        dependencies = [dependency(AppInfo), dependency(PluginInfo) | AllDeps]
    },
    % Normalize and remove duplicates where CycloneDX forbids them
    SBoM = normalize_sbom(SBoM0),
    try
        V = version(FileInfo, IsStrictVersion, SBoM),
        SBoM#sbom{version = V}
    catch
        _:Reason:_Stacktrace ->
            rebar_api:error(
                "scan file:~ts failed, reason:~p, will use the default version number ~p",
                [FilePath, Reason, ?DEFAULT_VERSION]
            ),
            SBoM
    end.

-spec metadata(App, Plugin, MetadataInfo) -> Metadata when
    App :: proplists:proplist(),
    Plugin :: proplists:proplist(),
    MetadataInfo :: proplists:proplist(),
    Metadata :: rebar_sbom:metadata().
metadata(App, Plugin, MetadataInfo) ->
    #metadata{
        timestamp = calendar:system_time_to_rfc3339(erlang:system_time(second)),
        tools = [component(Plugin)],
        manufacturer = manufacturer(proplists:get_value(manufacturer, MetadataInfo, undefined)),
        authors = sbom_authors(proplists:get_value(author, MetadataInfo, undefined), App),
        component = component(App),
        licenses = sbom_licenses(proplists:get_value(licenses, MetadataInfo, undefined), App)
    }.

-spec sbom_authors(Author, App) -> Authors when
    Author :: undefined | string(),
    App :: proplists:proplist(),
    Authors :: [rebar_sbom:individual()].
sbom_authors(undefined, App) ->
    case os:getenv("GITHUB_ACTOR") of
        false ->
            authors(App);
        Actor ->
            [#individual{name = Actor}]
    end;
sbom_authors(Author, _App) ->
    [#individual{name = Author}].

-spec sbom_licenses(LicensesIn, App) -> LicensesOut when
    LicensesIn :: undefined | [string()],
    App :: proplists:proplist(),
    LicensesOut :: [rebar_sbom:license()].
sbom_licenses(undefined, App) ->
    component_field(licenses, App);
sbom_licenses(Licenses, _App) ->
    [license(License) || License <- Licenses].

components(RawComponents) ->
    [component(RawComponent) || RawComponent <- RawComponents].

component(RawComponent) ->
    #component{
        bom_ref = bom_ref_of_component(RawComponent),
        name = component_field(name, RawComponent),
        authors = authors(RawComponent),
        version = component_field(version, RawComponent),
        description = component_field(description, RawComponent),
        scope = component_field(scope, RawComponent),
        hashes = component_field(sha256, RawComponent),
        licenses = component_field(licenses, RawComponent),
        externalReferences = component_field(external_references, RawComponent),
        cpe = component_field(cpe, RawComponent),
        purl = component_field(purl, RawComponent)
    }.

component_field(licenses = Field, RawComponent) ->
    case proplists:get_value(Field, RawComponent) of
        undefined ->
            [];
        Licenses ->
            [license(License) || License <- Licenses]
    end;
component_field(sha256 = Field, RawComponent) ->
    case proplists:get_value(Field, RawComponent) of
        undefined ->
            [];
        Hash ->
            [#{alg => "SHA-256", hash => binary:bin_to_list(Hash)}]
    end;
component_field(external_references = Field, RawComponent) ->
    case proplists:get_value(Field, RawComponent) of
        undefined ->
            [];
        [] ->
            [];
        References ->
            [
                #external_reference{type = Type, url = Url}
             || {Type, Url} <- maps:to_list(References)
            ]
    end;
component_field(Field, RawComponent) ->
    case proplists:get_value(Field, RawComponent) of
        Value when is_binary(Value) ->
            binary:bin_to_list(Value);
        Else ->
            Else
    end.

license(Name) when is_binary(Name) ->
    license(binary:bin_to_list(Name));
license(Name) ->
    case rebar_sbom_license:spdx_id(Name) of
        undefined ->
            #license{name = Name};
        SpdxId ->
            #license{id = SpdxId}
    end.

-spec manufacturer(ManufacturerIn) -> ManufacturerOut when
    ManufacturerIn :: undefined | map(),
    ManufacturerOut :: rebar_sbom:organization() | undefined.
manufacturer(undefined) ->
    undefined;
manufacturer(Manufacturer) ->
    #organization{
        name = maps:get(name, Manufacturer, undefined),
        address = address(maps:get(address, Manufacturer, undefined)),
        url = maps:get(url, Manufacturer, []),
        contact = individuals(maps:get(contact, Manufacturer, undefined))
    }.

-spec address(undefined | map()) -> undefined | rebar_sbom:address().
address(undefined) ->
    undefined;
address(AddressMap) ->
    #address{
        country = maps:get(country, AddressMap, undefined),
        region = maps:get(region, AddressMap, undefined),
        locality = maps:get(locality, AddressMap, undefined),
        post_office_box_number = maps:get(post_office_box_number, AddressMap, undefined),
        postal_code = maps:get(postal_code, AddressMap, undefined),
        street_address = maps:get(street_address, AddressMap, undefined)
    }.

-spec individuals(IndividualsIn) -> IndividualsOut when
    IndividualsIn :: [string()],
    IndividualsOut :: [rebar_sbom:individual()].
individuals(undefined) ->
    [];
individuals(Individuals) ->
    lists:map(
        fun(Individual) ->
            #individual{
                name = maps:get(name, Individual, undefined),
                email = maps:get(email, Individual, undefined),
                phone = maps:get(phone, Individual, undefined)
            }
        end,
        Individuals
    ).

-spec authors(App) -> Authors when
    App :: proplists:proplist(),
    Authors :: [rebar_sbom:individual()].
authors(App) ->
    [#individual{name = Name} || Name <- proplists:get_value(authors, App, [])].

uuid() ->
    [A, B, C, D, E] = [crypto:strong_rand_bytes(Len) || Len <- [4, 2, 2, 2, 6]],
    UUID = lists:join("-", [
        hex(Part)
     || Part <- [A, B, <<4:4, C:12/binary-unit:1>>, <<2:2, D:14/binary-unit:1>>, E]
    ]),
    "urn:uuid:" ++ UUID.

hex(Bin) ->
    string:lowercase(<<<<Hex>> || <<Nibble:4>> <= Bin, Hex <- integer_to_list(Nibble, 16)>>).

dependencies(RawComponents) ->
    [dependency(RawComponent) || RawComponent <- RawComponents].

dependency(RawComponent) ->
    RawDependencies = proplists:get_value(dependencies, RawComponent, []),
    #dependency{
        ref = bom_ref_of_component(RawComponent),
        dependencies = [dependency([{name, D}]) || D <- RawDependencies]
    }.

bom_ref_of_component(RawComponent) ->
    Name = proplists:get_value(name, RawComponent),
    lists:flatten(io_lib:format("ref_component_~ts", [Name])).

version({FilePath, Format}, IsStrictVersion, NewSBoM) ->
    case filelib:is_regular(FilePath) of
        true ->
            OldSBoM = decode(FilePath, Format),
            version(IsStrictVersion, {NewSBoM, OldSBoM});
        false ->
            rebar_api:info(
                "Using default SBoM version ~p: no previous SBoM file found.",
                [?DEFAULT_VERSION]
            ),
            ?DEFAULT_VERSION
    end.

-spec version(IsStrictVersion, {NewSBoM, OldSBoM}) -> Version when
    IsStrictVersion :: boolean(),
    NewSBoM :: rebar_sbom:sbom(),
    OldSBoM :: rebar_sbom:sbom(),
    Version :: integer().
version(_, {_, OldSBoM}) when OldSBoM#sbom.version =:= 0 ->
    rebar_api:info(
        "Using default SBoM version ~p: invalid version in previous SBoM file.",
        [?DEFAULT_VERSION]
    ),
    ?DEFAULT_VERSION;
version(IsStrictVersion, {_, OldSBoM}) when not (IsStrictVersion) ->
    rebar_api:info(
        "Incrementing the SBoM version unconditionally: strict_version is set to false.", []
    ),
    OldSBoM#sbom.version + 1;
version(IsStrictVersion, {NewSBoM, OldSBoM}) when IsStrictVersion ->
    case is_sbom_equal(NewSBoM, OldSBoM) of
        true ->
            rebar_api:info(
                "Not incrementing the SBoM version: new SBoM is equivalent to the old SBoM.", []
            ),
            OldSBoM#sbom.version;
        false ->
            rebar_api:info(
                "Incrementing the SBoM version: new SBoM is not equivalent to the old SBoM.", []
            ),
            OldSBoM#sbom.version + 1
    end.

is_sbom_equal(#sbom{components = NewComponents}, #sbom{components = OldComponents}) ->
    lists:all(fun(C) -> lists:member(C, NewComponents) end, OldComponents) andalso
        lists:all(fun(C) -> lists:member(C, OldComponents) end, NewComponents).

decode(FilePath, "xml") ->
    rebar_sbom_xml:decode(FilePath);
decode(FilePath, "json") ->
    rebar_sbom_json:decode(FilePath).

-spec normalize_sbom(rebar_sbom:sbom()) -> rebar_sbom:sbom().
normalize_sbom(#sbom{metadata = Metadata0, components = Components0, dependencies = Deps0} = S) ->
    Components = lists:map(fun normalize_component/1, dedup(Components0)),
    Metadata = normalize_metadata(Metadata0),
    Deps = normalize_deps(Deps0),
    S#sbom{metadata = Metadata, components = Components, dependencies = Deps}.

-spec normalize_metadata(rebar_sbom:metadata()) -> rebar_sbom:metadata().
normalize_metadata(#metadata{authors = Authors0, licenses = Licenses0} = M) ->
    M#metadata{authors = dedup(Authors0), licenses = dedup(Licenses0)}.

-spec normalize_component(rebar_sbom:component()) -> rebar_sbom:component().
normalize_component(#component{authors = Authors0, licenses = Licenses0} = C) ->
    C#component{authors = dedup(Authors0), licenses = dedup(Licenses0)}.

-spec normalize_deps([rebar_sbom:dependency()]) -> [rebar_sbom:dependency()].
normalize_deps(Deps0) ->
    Deps1 = [D#dependency{dependencies = normalize_deps(D#dependency.dependencies)} || D <- Deps0],
    dedup(Deps1).

-spec dedup([term()]) -> [term()].
dedup(List) when is_list(List) -> lists:uniq(List).