Skip to content
10 changes: 7 additions & 3 deletions src/rebar3_sbom_cpe.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------------------------------

Expand All @@ -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(),
Expand Down
6 changes: 4 additions & 2 deletions src/rebar3_sbom_cyclonedx.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
4 changes: 2 additions & 2 deletions src/rebar3_sbom_json.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
104 changes: 104 additions & 0 deletions src/rebar3_sbom_otp.erl
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 27 additions & 13 deletions src/rebar3_sbom_prv.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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
),
Expand Down Expand Up @@ -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()}],
Expand All @@ -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, []),
Expand Down Expand Up @@ -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) ->
Expand All @@ -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,
[
Expand All @@ -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) ->
Expand All @@ -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
].

Expand All @@ -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} ->
Expand Down Expand Up @@ -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.
73 changes: 45 additions & 28 deletions src/rebar3_sbom_purl.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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, "/"),
Expand All @@ -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.
Loading