diff --git a/src/rebar3_sbom_cpe.erl b/src/rebar3_sbom_cpe.erl index fc0cc89..1f5c750 100644 --- a/src/rebar3_sbom_cpe.erl +++ b/src/rebar3_sbom_cpe.erl @@ -65,8 +65,10 @@ cpe(<<"elixir">>, Version, _) -> cpe(_Name, _Version, undefined) -> undefined; cpe(Name, Version, Url) -> - Organization = github_url(Url), - build_cpe(Organization, Name, Version). + case github_url(Url) of + undefined -> undefined; + Organization -> build_cpe(Organization, Name, Version) + end. %--- Private ------------------------------------------------------------------- @@ -78,7 +80,9 @@ github_url(<<"https://github.com/", Rest/bitstring>>) -> Organization; github_url(<<"git@github.com:", Rest/bitstring>>) -> [Organization | _] = string:split(Rest, "/"), - Organization. + Organization; +github_url(_) -> + undefined. -spec build_cpe(Organization, Name, Version) -> CPE when Organization :: bitstring(), diff --git a/src/rebar3_sbom_cyclonedx.erl b/src/rebar3_sbom_cyclonedx.erl index f7e0440..a1342ae 100644 --- a/src/rebar3_sbom_cyclonedx.erl +++ b/src/rebar3_sbom_cyclonedx.erl @@ -130,13 +130,15 @@ component_field(external_references = Field, RawComponent) -> component_field(Field, RawComponent) -> case proplists:get_value(Field, RawComponent) of Value when is_binary(Value) -> - binary:bin_to_list(Value); + unicode:characters_to_list(Value); + Value when is_list(Value) -> + unicode:characters_to_list(Value); Else -> Else end. license(Name) when is_binary(Name) -> - license(binary:bin_to_list(Name)); + license(unicode:characters_to_list(Name)); license(Name) -> case rebar3_sbom_license:spdx_id(Name) of undefined -> diff --git a/src/rebar3_sbom_json.erl b/src/rebar3_sbom_json.erl index ac81842..cc8e539 100644 --- a/src/rebar3_sbom_json.erl +++ b/src/rebar3_sbom_json.erl @@ -145,7 +145,7 @@ dependency_to_json(D) -> bin(undefined) -> undefined; bin(Value) when is_list(Value) -> - erlang:list_to_binary(Value); + unicode:characters_to_binary(Value); bin(Value) -> Value. @@ -232,6 +232,6 @@ json_to_external_reference(#{<<"type">> := Type, <<"url">> := Url}) -> str(undefined) -> undefined; str(Value) when is_binary(Value) -> - erlang:binary_to_list(Value); + unicode:characters_to_list(Value); str(Value) -> Value. diff --git a/src/rebar3_sbom_otp.erl b/src/rebar3_sbom_otp.erl new file mode 100644 index 0000000..13bcbaf --- /dev/null +++ b/src/rebar3_sbom_otp.erl @@ -0,0 +1,104 @@ +%% SPDX-License-Identifier: BSD-3-Clause +%% SPDX-FileCopyrightText: 2026 Erlang Ecosystem Foundation + +-module(rebar3_sbom_otp). + +-export([otp_components/0, otp_components/1]). + +-include("rebar3_sbom.hrl"). + +-define(OTP_GITHUB, <<"https://github.com/erlang/otp">>). + +%% Returns OTP/ERTS component info in the same proplist format as dep_info. +-spec otp_components() -> [proplists:proplist()]. +otp_components() -> + otp_components(otp_apps()). + +-spec otp_components(Apps :: [atom()]) -> [proplists:proplist()]. +otp_components(Apps) -> + OtpRelease = erlang:system_info(otp_release), + ErtsVersion = erlang:system_info(version), + OtpComponent = otp_component(OtpRelease), + ErtsComponent = erts_component(ErtsVersion, OtpRelease), + AppComponents = [app_component(App, OtpRelease) || App <- Apps], + [OtpComponent, ErtsComponent | AppComponents]. + +%% Top-level Erlang/OTP component +otp_component(OtpRelease) -> + Name = <<"erlang/otp">>, + Version = list_to_binary(OtpRelease), + [ + {name, Name}, + {version, Version}, + {purl, rebar3_sbom_purl:otp_runtime(Name, Version, ?OTP_GITHUB)}, + {cpe, rebar3_sbom_cpe:cpe(Name, Version, ?OTP_GITHUB)}, + {authors, []}, + {description, <<"Erlang/OTP">>}, + {licenses, [<<"Apache-2.0">>]}, + {external_references, #{"vcs" => binary_to_list(?OTP_GITHUB)}}, + {dependencies, []}, + {scope, required} + ]. + +%% ERTS component +erts_component(ErtsVersion, OtpRelease) -> + Name = <<"erts">>, + Version = list_to_binary(ErtsVersion), + [ + {name, Name}, + {version, Version}, + {purl, rebar3_sbom_purl:otp_runtime(Name, Version, ?OTP_GITHUB)}, + {cpe, rebar3_sbom_cpe:cpe(<<"erlang/otp">>, list_to_binary(OtpRelease), ?OTP_GITHUB)}, + {authors, []}, + {description, <<"Erlang Runtime System">>}, + {licenses, [<<"Apache-2.0">>]}, + {external_references, #{"vcs" => binary_to_list(?OTP_GITHUB)}}, + {dependencies, []}, + {scope, required} + ]. + +%% Individual OTP application component +app_component(App, OtpRelease) -> + Name = atom_to_binary(App), + Version = + case application:get_key(App, vsn) of + {ok, Vsn} -> list_to_binary(Vsn); + undefined -> list_to_binary(OtpRelease) + end, + Description = + case application:get_key(App, description) of + {ok, Desc} -> unicode:characters_to_binary(Desc); + undefined -> <<>> + end, + Licenses = + case application:get_key(App, licenses) of + {ok, L} -> [unicode:characters_to_binary(Li) || Li <- L]; + undefined -> [<<"Apache-2.0">>] + end, + [ + {name, Name}, + {version, Version}, + {purl, rebar3_sbom_purl:otp_runtime(Name, Version, ?OTP_GITHUB)}, + {cpe, rebar3_sbom_cpe:cpe(<<"erlang/otp">>, list_to_binary(OtpRelease), ?OTP_GITHUB)}, + {authors, []}, + {description, Description}, + {licenses, Licenses}, + {external_references, #{"vcs" => binary_to_list(?OTP_GITHUB)}}, + {dependencies, []}, + {scope, required} + ]. + +%% Returns OTP apps actually used by the project's dependency tree. +%% These are the standard OTP applications that come bundled with Erlang. +-spec otp_apps() -> [atom()]. +otp_apps() -> + OtpLib = code:lib_dir(), + AllLoaded = [App || {App, _, _} <- application:loaded_applications()], + [App || App <- AllLoaded, is_otp_app(App, OtpLib)]. + +-spec is_otp_app(atom(), file:filename()) -> boolean(). +is_otp_app(App, OtpLib) -> + case code:lib_dir(App) of + {error, _} -> false; + Dir -> lists:prefix(OtpLib, Dir) + end. diff --git a/src/rebar3_sbom_prv.erl b/src/rebar3_sbom_prv.erl index dc08db0..9cb9cf0 100644 --- a/src/rebar3_sbom_prv.erl +++ b/src/rebar3_sbom_prv.erl @@ -45,7 +45,9 @@ init(State) -> "overwite existing files without prompting for confirmation"}, {strict_version, $V, "strict_version", {boolean, true}, "modify the version number of the BoM only when the content changes"}, - {author, $a, "author", string, "the author of the SBoM"} + {author, $a, "author", string, "the author of the SBoM"}, + {include_otp, undefined, "include-otp", {boolean, false}, + "include OTP/ERTS runtime components in the SBoM"} ]}, {short_desc, "Generates CycloneDX SBoM"}, {desc, "Generates a Software Bill-of-Materials (SBoM) in CycloneDX format"} @@ -71,15 +73,25 @@ do(State) -> PluginInfo = dep_info(Plugin), PluginDepsInfo = [dep_info(Dep) || Dep <- PluginDeps], + PluginOpts = rebar_state:get(State, rebar3_sbom, []), + IncludeOtp = + proplists:get_value(include_otp, Args) orelse + proplists:get_value(include_otp, PluginOpts, false), + FilePath = filepath(Output, Format), DepsInfo = [dep_info(Dep) || Dep <- rebar_state:all_deps(State)], + OtpComponents = + case IncludeOtp of + true -> rebar3_sbom_otp:otp_components(); + false -> [] + end, AppInfo = dep_info(App), AppInfo2 = [{sha256, hash(AppInfo, rebar_dir:base_dir(State))} | AppInfo], MetadataInfo = metadata(State), SBoM = rebar3_sbom_cyclonedx:bom( {FilePath, Format}, IsStrictVersion, - {AppInfo2, DepsInfo}, + {AppInfo2, DepsInfo ++ OtpComponents}, {PluginInfo, PluginDepsInfo}, MetadataInfo ), @@ -160,7 +172,7 @@ hex_metadata(Dep) -> hex_metadata_licenses(HexMetadata) -> HexMetadataLicenses = proplists:get_value(<<"licenses">>, HexMetadata, []), - [binary_to_list(HexMetadataLicense) || HexMetadataLicense <- HexMetadataLicenses]. + [unicode:characters_to_list(HexMetadataLicense) || HexMetadataLicense <- HexMetadataLicenses]. -spec get_github_link(HexMetadata, Links) -> binary() when HexMetadata :: [{binary(), binary()}], @@ -170,7 +182,7 @@ get_github_link([], Links) -> undefined -> undefined; Value -> - list_to_binary(Value) + unicode:characters_to_binary(Value) end; get_github_link(HexMetadata, _) -> Links = proplists:get_value(<<"links">>, HexMetadata, []), @@ -262,7 +274,7 @@ dep_info(_Name, _Version, {pkg, Name, Version, Sha256}, Common) -> {version, Version}, {purl, rebar3_sbom_purl:hex(Name, Version)}, {sha256, string:lowercase(Sha256)}, - {cpe, rebar3_sbom_cpe:cpe(Name, list_to_binary(Version), GitHubLink)} + {cpe, rebar3_sbom_cpe:cpe(Name, unicode:characters_to_binary(Version), GitHubLink)} | Common ]; dep_info(_Name, _Version, {pkg, Name, Version, _InnerChecksum, OuterChecksum, _RepoConf}, Common) -> @@ -279,15 +291,13 @@ dep_info(Name, _DepVersion, {git, Git, GitRef}, Common) -> {Version, Purl, CPE} = case GitRef of {tag, Tag} -> - GeneratedCPE = rebar3_sbom_cpe:cpe(Name, list_to_binary(Tag), list_to_binary(Git)), + GeneratedCPE = rebar3_sbom_cpe:cpe(Name, to_binary(Tag), to_binary(Git)), {Tag, rebar3_sbom_purl:git(Name, Git, Tag), GeneratedCPE}; {branch, Branch} -> - GeneratedCPE = rebar3_sbom_cpe:cpe( - Name, list_to_binary(Branch), list_to_binary(Git) - ), + GeneratedCPE = rebar3_sbom_cpe:cpe(Name, to_binary(Branch), to_binary(Git)), {Branch, rebar3_sbom_purl:git(Name, Git, Branch), GeneratedCPE}; {ref, Ref} -> - GeneratedCPE = rebar3_sbom_cpe:cpe(Name, list_to_binary(Ref), list_to_binary(Git)), + GeneratedCPE = rebar3_sbom_cpe:cpe(Name, to_binary(Ref), to_binary(Git)), {Ref, rebar3_sbom_purl:git(Name, Git, Ref), GeneratedCPE} end, [ @@ -305,7 +315,7 @@ dep_info(Name, Version, checkout, Common) -> {name, Name}, {version, Version}, {purl, rebar3_sbom_purl:local_otp_app(Name, Version)}, - {cpe, rebar3_sbom_cpe:cpe(Name, list_to_binary(Version), GitHubLink)} + {cpe, rebar3_sbom_cpe:cpe(Name, unicode:characters_to_binary(Version), GitHubLink)} | Common ]; dep_info(Name, Version, root_app, Common) -> @@ -315,7 +325,7 @@ dep_info(Name, Version, root_app, Common) -> {name, Name}, {version, Version}, {purl, Purl}, - {cpe, rebar3_sbom_cpe:cpe(Name, list_to_binary(Version), GitHubLink)} + {cpe, rebar3_sbom_cpe:cpe(Name, unicode:characters_to_binary(Version), GitHubLink)} | Common ]. @@ -325,7 +335,7 @@ filepath(Path, _Format) -> Path. write_file(Filename, Contents, true) -> - file:write_file(Filename, Contents); + file:write_file(Filename, unicode:characters_to_binary(Contents)); write_file(Filename, Xml, false) -> case file:read_file_info(Filename) of {error, enoent} -> @@ -415,3 +425,7 @@ hash(AppInfo, BaseDir) -> tar_path(BaseDir, Name, Version) -> TarFilename = io_lib:format("~s-~s.tar.gz", [Name, Version]), filename:join([BaseDir, "rel", Name, TarFilename]). + +to_binary(V) when is_atom(V) -> atom_to_binary(V); +to_binary(V) when is_list(V) -> unicode:characters_to_binary(V); +to_binary(V) when is_binary(V) -> V. diff --git a/src/rebar3_sbom_purl.erl b/src/rebar3_sbom_purl.erl index 745b690..b6a6d8f 100644 --- a/src/rebar3_sbom_purl.erl +++ b/src/rebar3_sbom_purl.erl @@ -8,32 +8,30 @@ % https://github.com/package-url/purl-spec --export([hex/2, git/3, github/2, bitbucket/2, local_otp_app/2, local/2]). +-export([hex/2, git/3, github/2, bitbucket/2, local_otp_app/2, local/2, otp_runtime/3]). hex(Name, Version) -> - purl(["hex", string:lowercase(Name)], Version). - -git(_Name, "git@github.com:" ++ Github, Ref) -> - Repo = string:replace(Github, ".git", "", trailing), - github(Repo, Ref); -git(_Name, "https://github.com/" ++ Github, Ref) -> - Repo = string:replace(Github, ".git", "", trailing), - github(Repo, Ref); -git(_Name, "git://github.com/" ++ Github, Ref) -> - Repo = string:replace(Github, ".git", "", trailing), - github(Repo, Ref); -git(_Name, "git@bitbucket.org:" ++ Github, Ref) -> - Repo = string:replace(Github, ".git", "", trailing), - bitbucket(Repo, Ref); -git(_Name, "https://bitbucket.org/" ++ Github, Ref) -> - Repo = string:replace(Github, ".git", "", trailing), - bitbucket(Repo, Ref); -git(_Name, "git://bitbucket.org/" ++ Github, Ref) -> - Repo = string:replace(Github, ".git", "", trailing), - bitbucket(Repo, Ref); -%% Git dependence other than GitHub and BitBucket are not currently supported -git(_Name, _Git, _R) -> - undefined. + purl(["hex", string:lowercase(to_list(Name))], to_list(Version)). + +git(_Name, Git0, Ref0) -> + Git = to_list(Git0), + Ref = to_list(Ref0), + case Git of + "git@github.com:" ++ Path -> + github(string:replace(Path, ".git", "", trailing), Ref); + "https://github.com/" ++ Path -> + github(string:replace(Path, ".git", "", trailing), Ref); + "git://github.com/" ++ Path -> + github(string:replace(Path, ".git", "", trailing), Ref); + "git@bitbucket.org:" ++ Path -> + bitbucket(string:replace(Path, ".git", "", trailing), Ref); + "https://bitbucket.org/" ++ Path -> + bitbucket(string:replace(Path, ".git", "", trailing), Ref); + "git://bitbucket.org/" ++ Path -> + bitbucket(string:replace(Path, ".git", "", trailing), Ref); + _ -> + undefined + end. github(Repo, Ref) -> [Organization, Name | _] = string:split(Repo, "/"), @@ -44,23 +42,42 @@ bitbucket(Repo, Ref) -> purl(["bitbucket", string:lowercase(Organization), string:lowercase(Name)], Ref). local_otp_app(Name, Version) -> - purl(["otp", string:lowercase(Name)], Version). + purl(["otp", string:lowercase(to_list(Name))], to_list(Version)). local(Name, Version) -> - purl(["generic", string:lowercase(Name)], Version). + purl(["generic", string:lowercase(to_list(Name))], to_list(Version)). + +otp_runtime(Name, Version, RepositoryUrl) -> + purl_with_qualifiers( + ["otp", string:lowercase(to_list(Name))], + to_list(Version), + [{"repository_url", to_list(RepositoryUrl)}, + {"vcs_url", "git+" ++ to_list(RepositoryUrl) ++ ".git"}] + ). purl(PathSegments, Version) -> Path = lists:join("/", [escape(Segment) || Segment <- PathSegments]), unicode:characters_to_binary(io_lib:format("pkg:~s@~s", [Path, escape(Version)])). +purl_with_qualifiers(PathSegments, Version, Qualifiers) -> + Path = lists:join("/", [escape(Segment) || Segment <- PathSegments]), + QS = lists:join("&", [escape(K) ++ "=" ++ escape(V) || {K, V} <- Qualifiers]), + unicode:characters_to_binary( + io_lib:format("pkg:~s@~s?~s", [Path, escape(Version), QS]) + ). + -if(?OTP_RELEASE >= 25). escape(String) -> - uri_string:quote(String). + uri_string:quote(to_list(String)). -else. escape(String) -> - http_uri:encode(String). + http_uri:encode(to_list(String)). -endif. + +to_list(V) when is_atom(V) -> atom_to_list(V); +to_list(V) when is_binary(V) -> binary_to_list(V); +to_list(V) when is_list(V) -> V. diff --git a/test/rebar3_sbom_otp_SUITE.erl b/test/rebar3_sbom_otp_SUITE.erl new file mode 100644 index 0000000..fafa985 --- /dev/null +++ b/test/rebar3_sbom_otp_SUITE.erl @@ -0,0 +1,316 @@ +%% SPDX-License-Identifier: BSD-3-Clause +%% SPDX-FileCopyrightText: 2026 Erlang Ecosystem Foundation + +-module(rebar3_sbom_otp_SUITE). + +-export([all/0, groups/0]). +-export([init_per_suite/1, end_per_suite/1]). +-export([init_per_group/2, end_per_group/2]). +-export([init_per_testcase/2, end_per_testcase/2]). + +%% Unit tests +-export([otp_components_returns_list_test/1]). +-export([otp_component_present_test/1]). +-export([erts_component_present_test/1]). +-export([otp_component_has_correct_version_test/1]). +-export([erts_component_has_correct_version_test/1]). +-export([otp_component_has_purl_test/1]). +-export([erts_component_has_purl_test/1]). +-export([otp_component_has_cpe_test/1]). +-export([erts_component_has_cpe_test/1]). +-export([otp_component_has_license_test/1]). +-export([otp_component_has_external_references_test/1]). +-export([app_components_present_test/1]). +-export([app_component_has_purl_test/1]). +-export([app_component_has_cpe_test/1]). +-export([app_component_has_version_test/1]). +-export([custom_apps_list_test/1]). + +%% Integration tests +-export([include_otp_flag_test/1]). +-export([otp_not_included_by_default_test/1]). +-export([otp_components_valid_json_test/1]). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). +-include_lib("rebar3_sbom/include/rebar3_sbom.hrl"). + +-define(PURL_REGEX, "^pkg:[a-z][a-z0-9+.-]*/([^/@?#]+/)*[^/@?#]+(@[^?#]+)?(\\?[^#]+)?(#.+)?$"). +-define(CPE_REGEX, + "^cpe:2\\.3:[aho\\*\\-](:(((\\?*|\\*?)([a-zA-Z0-9\\-\\._]|(\\\\[\\\\\\*\\?!\"#$$%&'\\(\\)\\+,/:;<=>@\\[\\]\\^`\\{\\|}~]))+(\\?*|\\*?))|[\\*\\-])){5}(:(([a-zA-Z]{2,3}(-([a-zA-Z]{2}|[0-9]{3}))?)|[\\*\\-]))(:(((\\?*|\\*?)([a-zA-Z0-9\\-\\._]|(\\\\[\\\\\\*\\?!\"#$$%&'\\(\\)\\+,/:;<=>@\\[\\]\\^`\\{\\|}~]))+(\\?*|\\*?))|[\\*\\-])){4}$" +). + +all() -> + [ + {group, unit}, + {group, integration} + ]. + +groups() -> + [ + {unit, [], [ + otp_components_returns_list_test, + otp_component_present_test, + erts_component_present_test, + otp_component_has_correct_version_test, + erts_component_has_correct_version_test, + otp_component_has_purl_test, + erts_component_has_purl_test, + otp_component_has_cpe_test, + erts_component_has_cpe_test, + otp_component_has_license_test, + otp_component_has_external_references_test, + app_components_present_test, + app_component_has_purl_test, + app_component_has_cpe_test, + app_component_has_version_test, + custom_apps_list_test + ]}, + {integration, [], [ + include_otp_flag_test, + otp_not_included_by_default_test, + otp_components_valid_json_test + ]} + ]. + +init_per_suite(Config) -> + application:load(rebar3_sbom), + Config. + +end_per_suite(_Config) -> + ok. + +init_per_group(unit, Config) -> + Components = rebar3_sbom_otp:otp_components(), + [{otp_components, Components} | Config]; +init_per_group(integration, Config) -> + Config; +init_per_group(_, Config) -> + Config. + +end_per_group(_, _Config) -> + ok. + +init_per_testcase(_, Config) -> + Config. + +end_per_testcase(_, _Config) -> + ok. + +%% --- Unit tests --- + +otp_components_returns_list_test(Config) -> + Components = ?config(otp_components, Config), + ?assert(is_list(Components)), + ?assert(length(Components) >= 2). + +otp_component_present_test(Config) -> + Components = ?config(otp_components, Config), + OtpComponent = find_component(<<"erlang/otp">>, Components), + ?assertNotEqual(undefined, OtpComponent). + +erts_component_present_test(Config) -> + Components = ?config(otp_components, Config), + ErtsComponent = find_component(<<"erts">>, Components), + ?assertNotEqual(undefined, ErtsComponent). + +otp_component_has_correct_version_test(Config) -> + Components = ?config(otp_components, Config), + OtpComponent = find_component(<<"erlang/otp">>, Components), + Version = proplists:get_value(version, OtpComponent), + ExpectedVersion = list_to_binary(erlang:system_info(otp_release)), + ?assertEqual(ExpectedVersion, Version). + +erts_component_has_correct_version_test(Config) -> + Components = ?config(otp_components, Config), + ErtsComponent = find_component(<<"erts">>, Components), + Version = proplists:get_value(version, ErtsComponent), + ExpectedVersion = list_to_binary(erlang:system_info(version)), + ?assertEqual(ExpectedVersion, Version). + +otp_component_has_purl_test(Config) -> + Components = ?config(otp_components, Config), + OtpComponent = find_component(<<"erlang/otp">>, Components), + Purl = proplists:get_value(purl, OtpComponent), + ?assertNotEqual(undefined, Purl), + ?assertNotEqual(nomatch, re:run(Purl, ?PURL_REGEX)). + +erts_component_has_purl_test(Config) -> + Components = ?config(otp_components, Config), + ErtsComponent = find_component(<<"erts">>, Components), + Purl = proplists:get_value(purl, ErtsComponent), + ?assertNotEqual(undefined, Purl), + ?assertNotEqual(nomatch, re:run(Purl, ?PURL_REGEX)). + +otp_component_has_cpe_test(Config) -> + Components = ?config(otp_components, Config), + OtpComponent = find_component(<<"erlang/otp">>, Components), + Cpe = proplists:get_value(cpe, OtpComponent), + ?assertNotEqual(undefined, Cpe), + OtpRelease = list_to_binary(erlang:system_info(otp_release)), + Expected = <<"cpe:2.3:a:erlang:erlang/otp:", OtpRelease/binary, ":*:*:*:*:*:*:*">>, + ?assertEqual(Expected, Cpe). + +erts_component_has_cpe_test(Config) -> + Components = ?config(otp_components, Config), + ErtsComponent = find_component(<<"erts">>, Components), + Cpe = proplists:get_value(cpe, ErtsComponent), + ?assertNotEqual(undefined, Cpe), + %% ERTS uses the erlang/otp CPE with the OTP release version + OtpRelease = list_to_binary(erlang:system_info(otp_release)), + Expected = <<"cpe:2.3:a:erlang:erlang/otp:", OtpRelease/binary, ":*:*:*:*:*:*:*">>, + ?assertEqual(Expected, Cpe). + +otp_component_has_license_test(Config) -> + Components = ?config(otp_components, Config), + OtpComponent = find_component(<<"erlang/otp">>, Components), + Licenses = proplists:get_value(licenses, OtpComponent), + ?assertMatch([_ | _], Licenses). + +otp_component_has_external_references_test(Config) -> + Components = ?config(otp_components, Config), + OtpComponent = find_component(<<"erlang/otp">>, Components), + Refs = proplists:get_value(external_references, OtpComponent), + ?assert(is_map(Refs)), + ?assert(maps:is_key("vcs", Refs)). + +app_components_present_test(Config) -> + Components = ?config(otp_components, Config), + %% kernel and stdlib should always be present + KernelComponent = find_component(<<"kernel">>, Components), + StdlibComponent = find_component(<<"stdlib">>, Components), + ?assertNotEqual(undefined, KernelComponent), + ?assertNotEqual(undefined, StdlibComponent). + +app_component_has_purl_test(Config) -> + Components = ?config(otp_components, Config), + KernelComponent = find_component(<<"kernel">>, Components), + Purl = proplists:get_value(purl, KernelComponent), + ?assertNotEqual(undefined, Purl), + ?assertNotEqual(nomatch, re:run(Purl, ?PURL_REGEX)). + +app_component_has_cpe_test(Config) -> + Components = ?config(otp_components, Config), + KernelComponent = find_component(<<"kernel">>, Components), + Cpe = proplists:get_value(cpe, KernelComponent), + ?assertNotEqual(undefined, Cpe), + %% OTP app components share the erlang/otp CPE + OtpRelease = list_to_binary(erlang:system_info(otp_release)), + Expected = <<"cpe:2.3:a:erlang:erlang/otp:", OtpRelease/binary, ":*:*:*:*:*:*:*">>, + ?assertEqual(Expected, Cpe). + +app_component_has_version_test(Config) -> + Components = ?config(otp_components, Config), + KernelComponent = find_component(<<"kernel">>, Components), + Version = proplists:get_value(version, KernelComponent), + ?assertNotEqual(undefined, Version), + ?assert(is_binary(Version)), + ?assert(byte_size(Version) > 0). + +custom_apps_list_test(_Config) -> + Components = rebar3_sbom_otp:otp_components([kernel, stdlib]), + %% Should have erlang/otp + erts + kernel + stdlib = 4 + ?assertEqual(4, length(Components)), + Names = [proplists:get_value(name, C) || C <- Components], + ?assert(lists:member(<<"erlang/otp">>, Names)), + ?assert(lists:member(<<"erts">>, Names)), + ?assert(lists:member(<<"kernel">>, Names)), + ?assert(lists:member(<<"stdlib">>, Names)). + +%% --- Integration tests --- + +include_otp_flag_test(Config) -> + State = rebar3_sbom_test_utils:init_rebar_state(Config, "basic_app"), + PrivDir = ?config(priv_dir, Config), + SBoMPath = filename:join(PrivDir, "otp_flag_sbom.json"), + Cmd = ["sbom", "-F", "json", "-o", SBoMPath, "-V", "false", "-f", "--include-otp"], + {ok, _FinalState} = rebar3:run(State, Cmd), + {ok, File} = file:read_file(SBoMPath), + SBoMJSON = json:decode(File), + #{<<"components">> := Components} = SBoMJSON, + Names = [maps:get(<<"name">>, C) || C <- Components], + ?assert(lists:member(<<"erlang/otp">>, Names), "erlang/otp component missing"), + ?assert(lists:member(<<"erts">>, Names), "erts component missing"), + ?assert(lists:member(<<"kernel">>, Names), "kernel component missing"), + ?assert(lists:member(<<"stdlib">>, Names), "stdlib component missing"), + %% Verify OTP components have proper CPE + OtpComponent = find_json_component(<<"erlang/otp">>, Components), + ?assertMatch(#{<<"cpe">> := _}, OtpComponent), + #{<<"cpe">> := OtpCpe} = OtpComponent, + OtpRelease = list_to_binary(erlang:system_info(otp_release)), + ExpectedCpe = <<"cpe:2.3:a:erlang:erlang/otp:", OtpRelease/binary, ":*:*:*:*:*:*:*">>, + ?assertEqual(ExpectedCpe, OtpCpe), + %% Verify OTP components have proper PURL + ?assertMatch(#{<<"purl">> := _}, OtpComponent), + #{<<"purl">> := OtpPurl} = OtpComponent, + ?assertNotEqual(nomatch, re:run(OtpPurl, ?PURL_REGEX)). + +otp_not_included_by_default_test(Config) -> + State = rebar3_sbom_test_utils:init_rebar_state(Config, "basic_app"), + PrivDir = ?config(priv_dir, Config), + SBoMPath = filename:join(PrivDir, "no_otp_sbom.json"), + Cmd = ["sbom", "-F", "json", "-o", SBoMPath, "-V", "false", "-f"], + {ok, _FinalState} = rebar3:run(State, Cmd), + {ok, File} = file:read_file(SBoMPath), + SBoMJSON = json:decode(File), + #{<<"components">> := Components} = SBoMJSON, + Names = [maps:get(<<"name">>, C) || C <- Components], + ?assertNot( + lists:member(<<"erlang/otp">>, Names), "erlang/otp should not be present by default" + ), + ?assertNot(lists:member(<<"erts">>, Names), "erts should not be present by default"). + +otp_components_valid_json_test(Config) -> + State = rebar3_sbom_test_utils:init_rebar_state(Config, "basic_app"), + PrivDir = ?config(priv_dir, Config), + SBoMPath = filename:join(PrivDir, "otp_valid_sbom.json"), + Cmd = ["sbom", "-F", "json", "-o", SBoMPath, "-V", "false", "-f", "--include-otp"], + {ok, _FinalState} = rebar3:run(State, Cmd), + {ok, File} = file:read_file(SBoMPath), + SBoMJSON = json:decode(File), + %% Verify basic structure is valid + ?assertMatch(#{<<"bomFormat">> := <<"CycloneDX">>}, SBoMJSON), + ?assertMatch(#{<<"specVersion">> := _}, SBoMJSON), + #{<<"components">> := Components} = SBoMJSON, + %% Every OTP component should have required fields + OtpNames = [<<"erlang/otp">>, <<"erts">>, <<"kernel">>, <<"stdlib">>], + lists:foreach( + fun(Name) -> + case find_json_component(Name, Components) of + undefined -> + ct:fail("Missing OTP component: ~s", [Name]); + Component -> + ?assertMatch(#{<<"type">> := <<"application">>}, Component), + ?assertMatch(#{<<"name">> := _}, Component), + ?assertMatch(#{<<"version">> := _}, Component), + ?assertMatch(#{<<"purl">> := _}, Component), + ?assertMatch(#{<<"scope">> := <<"required">>}, Component), + ?assertMatch(#{<<"licenses">> := [_ | _]}, Component) + end + end, + OtpNames + ). + +%% --- Helpers --- + +find_component(Name, Components) -> + case + lists:search( + fun(C) -> proplists:get_value(name, C) =:= Name end, + Components + ) + of + {value, C} -> C; + false -> undefined + end. + +find_json_component(Name, Components) -> + case + lists:search( + fun(C) -> maps:get(<<"name">>, C, undefined) =:= Name end, + Components + ) + of + {value, C} -> C; + false -> undefined + end. diff --git a/test/rebar3_sbom_purl_SUITE.erl b/test/rebar3_sbom_purl_SUITE.erl index 303e7e3..bc476c4 100644 --- a/test/rebar3_sbom_purl_SUITE.erl +++ b/test/rebar3_sbom_purl_SUITE.erl @@ -15,6 +15,7 @@ -export([git_unsupported_host_test/1]). -export([local_purl_test/1]). -export([local_otp_app_purl_test/1]). +-export([otp_runtime_purl_test/1]). % Includes -include_lib("stdlib/include/assert.hrl"). @@ -30,7 +31,8 @@ all() -> git_bitbucket_variants_test, git_unsupported_host_test, local_otp_app_purl_test, - local_purl_test + local_purl_test, + otp_runtime_purl_test ]. %--- Test cases ---------------------------------------------------------------- @@ -92,3 +94,16 @@ local_otp_app_purl_test(_) -> local_purl_test(_) -> Purl = rebar3_sbom_purl:local("Local-App", "0.9.0"), ?assertEqual(<<"pkg:generic/local-app@0.9.0">>, Purl). + +otp_runtime_purl_test(_) -> + GH = <<"https://github.com/erlang/otp">>, + Purl = rebar3_sbom_purl:otp_runtime(<<"erlang/otp">>, <<"28">>, GH), + ?assertMatch(<<"pkg:otp/erlang%2Fotp@28?repository_url=", _/binary>>, Purl), + %% Verify repository_url and vcs_url qualifiers are present + PurlStr = binary_to_list(Purl), + ?assertNotEqual(nomatch, string:find(PurlStr, "repository_url=")), + ?assertNotEqual(nomatch, string:find(PurlStr, "vcs_url=")), + ErtsPurl = rebar3_sbom_purl:otp_runtime(<<"erts">>, <<"15.2">>, GH), + ?assertMatch(<<"pkg:otp/erts@15.2?repository_url=", _/binary>>, ErtsPurl), + KernelPurl = rebar3_sbom_purl:otp_runtime(<<"kernel">>, <<"10.2">>, GH), + ?assertMatch(<<"pkg:otp/kernel@10.2?repository_url=", _/binary>>, KernelPurl).