App-CPAN-SBOM
view release on metacpan or search on metacpan
lib/App/CPAN/SBOM.pm view on Meta::CPAN
use CPAN::Audit;
use CPAN::Meta;
use Cpanel::JSON::XS qw(encode_json);
use Data::Dumper;
use File::Basename;
use File::Spec;
use Getopt::Long qw(GetOptionsFromArray :config gnu_compat);
use HTTP::Tiny;
use MetaCPAN::Client;
use MIME::Base64;
use Pod::Usage qw(pod2usage);
use URI::PackageURL;
use SBOM::CycloneDX::Component;
use SBOM::CycloneDX::ExternalReference;
use SBOM::CycloneDX::Hash;
use SBOM::CycloneDX::License;
use SBOM::CycloneDX::Metadata;
use SBOM::CycloneDX::OrganizationalContact;
use SBOM::CycloneDX::Util qw(cpan_meta_to_spdx_license cyclonedx_tool cyclonedx_component);
use SBOM::CycloneDX::Vulnerability::Affect;
use SBOM::CycloneDX::Vulnerability::Rating;
use SBOM::CycloneDX::Vulnerability::Source;
use SBOM::CycloneDX::Vulnerability;
use SBOM::CycloneDX;
our $VERSION = '1.04';
sub DEBUG { $ENV{SBOM_DEBUG} || 0 }
sub cli_error {
my ($error, $code) = @_;
$error =~ s/ at .* line \d+.*//;
say STDERR "ERROR: $error";
return $code || 1;
}
sub run {
my (@args) = @_;
my %options = ();
GetOptionsFromArray(
\@args, \%options, qw(
help|h
man
v
debug|d
output|o=s
meta=s
distribution=s
maxdepth=i
vulnerabilities!
validate!
project-meta=s
project-type=s
project-author=s@
project-description=s
project-directory=s
project-license=s
project-name=s
project-version=s
server-url=s
api-key=s
skip-tls-check
project-id=s
parent-project-id=s
cyclonedx-spec-version=s
list-spdx-licenses
)
) or pod2usage(-verbose => 0);
pod2usage(-exitstatus => 0, -verbose => 2) if defined $options{man};
pod2usage(-exitstatus => 0, -verbose => 0) if defined $options{help};
$options{'project-meta'} //= $options{meta};
if (defined $options{v}) {
return show_version();
}
if ($options{'list-spdx-licenses'}) {
say $_ for (sort @{SBOM::CycloneDX::Enum->SPDX_LICENSES});
return 0;
}
unless ($options{distribution} || $options{'project-meta'} || $options{'project-directory'}) {
pod2usage(-exitstatus => 0, -verbose => 0);
}
$options{maxdepth} //= 1;
$options{validate} //= 1;
if (defined $options{debug}) {
$ENV{SBOM_DEBUG} = 1;
}
my $bom = SBOM::CycloneDX->new;
my $spec_version = '1.6';
if (defined $options{'cyclonedx-spec-version'}) {
$spec_version = $options{'cyclonedx-spec-version'};
}
if ($spec_version >= 1.2 && $spec_version <= 1.7) {
$bom->spec_version($spec_version);
}
if (defined $options{distribution}) {
my ($distribution, $version) = split '@', $options{distribution};
return cli_error('Missing distribution version') unless $version;
make_sbom_from_dist(bom => $bom, distribution => $distribution, version => $version, options => \%options);
}
if (defined $options{'project-directory'} || defined $options{'project-meta'}) {
make_sbom_from_project(bom => $bom, options => \%options);
}
$bom->metadata->timestamp(time);
$bom->metadata->tools->push(cyclonedx_tool());
my $output_file = $options{output} // 'bom.json';
say STDERR "Save SBOM to $output_file";
open my $fh, '>', $output_file or Carp::croak "Failed to open file: $!";
say $fh $bom->to_string;
close $fh;
if ($options{validate}) {
my @errors = $bom->validate;
say STDERR $_ foreach (@errors);
}
if (defined $options{'server-url'} && defined $options{'api-key'}) {
submit_bom(bom => $bom, options => \%options);
}
}
sub show_version {
(my $progname = $0) =~ s/.*\///;
say <<"VERSION";
$progname version $VERSION
Copyright 2025-2026, Giuseppe Di Terlizzi <gdt\@cpan.org>
This program is part of the "App-CPAN-SBOM" distribution and is free software;
you can redistribute it and/or modify it under the same terms as Perl itself.
Complete documentation for $progname can be found using 'man $progname'
or on the internet at <https://metacpan.org/dist/App-CPAN-SBOM>.
VERSION
return 0;
}
sub make_sbom_from_project {
my (%params) = @_;
my $audit_discover = CPAN::Audit::Discover->new;
my $bom = $params{bom};
my $options = $params{options} || {};
my @META_FILES = (qw[META.json META.yml MYMETA.json MYMETA.yml]);
say STDERR 'Generate SBOM';
my $project_type = $options->{'project-type'} || 'library';
my $project_directory = File::Spec->rel2abs($options->{'project-directory'});
my $project_meta = $options->{'project-meta'} || $options->{'meta'};
my $project_name = $options->{'project-name'} || basename($project_directory);
my $project_version = $options->{'project-version'} || 0;
my $project_description = $options->{'project-description'};
my $project_license = $options->{'project-license'};
my $project_author = $options->{'project-author'} || [];
if ($project_directory) {
return cli_error('Directory not found') unless -d $project_directory;
}
unless ($project_meta) {
foreach (@META_FILES) {
my $meta_file = File::Spec->catfile($project_directory, $_);
if (-f $meta_file) {
$project_meta = $meta_file;
lib/App/CPAN/SBOM.pm view on Meta::CPAN
add_authors_to_component(bom => $bom, component => $root_component, authors => \@authors);
if ($project_description) {
$root_component->description($project_description);
}
# Add root BOM component in metadata
$bom->metadata->component($root_component);
# Find dependencies from "cpanfile.snapshot" or "cpanfile"
if (my @audit_deps = $audit_discover->discover($project_directory)) {
@dependencies = @audit_deps;
}
foreach my $dependency (@dependencies) {
make_dep_compoment(
module => $dependency->{module},
dist => $dependency->{dist},
version => $dependency->{version},
bom => $bom,
parent_component => $root_component,
maxdepth => $options->{maxdepth}
);
}
return $root_component;
}
sub make_sbom_from_dist {
my (%params) = @_;
my $distribution = $params{distribution};
my $version = $params{version};
my $bom = $params{bom};
my $options = $params{options} || {};
say STDERR "Generate SBOM for $distribution\@$version";
my $mcpan = MetaCPAN::Client->new;
my $release_data = $mcpan->release({all => [{distribution => $distribution}, {version => $version}]});
my $dist_data = $release_data->next;
unless ($dist_data) {
Carp::carp("Unable to find release ($distribution\@$version) in Meta::CPAN");
return;
}
my $metadata = $dist_data->metadata;
my @authors = make_authors($metadata->{author});
my $purl = URI::PackageURL->new(
type => 'cpan',
name => $dist_data->distribution,
version => $dist_data->version,
qualifiers => {author => $dist_data->author},
validate => 0
);
my @external_references = make_external_references($dist_data->metadata->{resources});
my $license_name = join ' AND ', @{$metadata->{license}};
my $license_id = cpan_meta_to_spdx_license($license_name) || 'NONE';
my $license_info = ($license_id ne 'NONE') ? {id => $license_id} : {name => $license_name};
my $bom_license = SBOM::CycloneDX::License->new($license_info);
my $root_component = SBOM::CycloneDX::Component->new(
type => 'library',
name => $dist_data->name,
version => $dist_data->version,
licenses => [$bom_license],
bom_ref => $purl->to_string,
purl => $purl,
external_references => \@external_references
);
add_authors_to_component(bom => $bom, component => $root_component, authors => \@authors);
if (my $abstract = $dist_data->abstract) {
$root_component->description($abstract);
}
$bom->metadata->component($root_component);
if ($options->{vulnerabilities}) {
make_vulnerabilities(
bom => $bom,
distribution => $dist_data->distribution,
version => $dist_data->version,
bom_ref => $purl->to_string
);
}
foreach my $dependency (@{$dist_data->dependency}) {
if ($dependency->{phase} eq 'runtime' and $dependency->{relationship} eq 'requires') {
next if ($dependency->{module} eq 'perl');
make_dep_compoment(
module => $dependency->{module},
bom => $bom,
parent_component => $root_component,
maxdepth => $options->{maxdepth}
);
}
}
return $root_component;
}
sub make_external_references {
my $resources = shift;
my @external_references = ();
lib/App/CPAN/SBOM.pm view on Meta::CPAN
my $module_data = $mcpan->module($module);
unless ($module_data) {
Carp::carp("Unable to find module ($module) in Meta::CPAN");
return;
}
$author //= $module_data->author;
$distribution = $module_data->distribution;
if (!$version) {
$version = $module_data->version;
}
# Standardize the core perl distribution version
if ($distribution eq 'perl') {
$version =~ s/^v?5\.([1-9]\d+)(.*)$/5.0$1*/;
}
}
$version =~ s/^v//;
my $release_data = $mcpan->release({
either => [
{all => [{distribution => $distribution}, {version => $version}]},
{all => [{distribution => $distribution}, {version => "v$version"}]},
]
});
my $dist_data = $release_data->next;
DEBUG
and say STDERR sprintf '-- %s[%d] Collect distribution %s@%s info (parent component %s)',
(" " x ($depth - 1)), $depth, $distribution, $version, $parent_component->bom_ref;
unless ($dist_data) {
Carp::carp("Unable to find release ($distribution\@$version) in Meta::CPAN");
return;
}
my $metadata = $dist_data->metadata;
$author //= $dist_data->author;
my @authors = make_authors($metadata->{author});
# Distribution License
my $license_name = join ' AND ', @{$dist_data->metadata->{license}};
my $license_id = cpan_meta_to_spdx_license($license_name) || 'NONE';
my $license = ($license_id ne 'NONE') ? {id => $license_id} : {name => $license_name};
my $bom_license = SBOM::CycloneDX::License->new($license);
my $purl = URI::PackageURL->new(
type => 'cpan',
name => $distribution,
version => $version,
qualifiers => {author => $author},
validate => 0
);
my @ext_refs = make_external_references($dist_data->metadata->{resources});
my $hashes = SBOM::CycloneDX::List->new;
if (my $checksum = $dist_data->checksum_sha256) {
$hashes->add(SBOM::CycloneDX::Hash->new(alg => 'SHA-256', content => $checksum));
}
if (my $checksum = $dist_data->checksum_md5) {
$hashes->add(SBOM::CycloneDX::Hash->new(alg => 'MD5', content => $checksum));
}
my $component = SBOM::CycloneDX::Component->new(
type => 'library',
name => $distribution,
version => $version,
licenses => [$bom_license],
bom_ref => $purl->to_string,
purl => $purl,
hashes => $hashes,
external_references => \@ext_refs,
);
add_authors_to_component(bom => $bom, component => $component, authors => \@authors);
if (my $abstract = $dist_data->abstract) {
$component->description($abstract);
}
if (!$bom->get_component_by_bom_ref($purl->to_string)) {
$bom->components->push($component);
}
if ($add_vulns) {
make_vulnerabilities(
bom => $bom,
distribution => $distribution,
version => $version,
bom_ref => $purl->to_string
);
}
$bom->add_dependency($parent_component, [$component]);
if ($depth < $maxdepth) {
$depth++;
foreach my $dependency (@{$dist_data->dependency}) {
if ($dependency->{phase} eq 'runtime' and $dependency->{relationship} eq 'requires') {
next if ($dependency->{module} eq 'perl');
make_dep_compoment(
module => $dependency->{module},
bom => $bom,
parent_component => $component,
depth => $depth
);
}
( run in 1.537 second using v1.01-cache-2.11-cpan-0bb4e1dffa6 )