File Coverage

blib/lib/App/CPAN/SBOM.pm
Criterion Covered Total %
statement 83 350 23.7
branch 0 116 0.0
condition 0 95 0.0
subroutine 28 40 70.0
pod 1 11 9.0
total 112 612 18.3


line stmt bran cond sub pod time code
1             package App::CPAN::SBOM;
2              
3 1     1   328905 use 5.010001;
  1         5  
4 1     1   8 use strict;
  1         3  
  1         63  
5 1     1   7 use warnings;
  1         2  
  1         76  
6 1     1   791 use utf8;
  1         380  
  1         8  
7              
8 1     1   714 use CPAN::Audit;
  1         6449455  
  1         76  
9 1     1   11 use CPAN::Meta;
  1         2  
  1         49  
10 1     1   1159 use Cpanel::JSON::XS qw(encode_json);
  1         4329  
  1         95  
11 1     1   627 use Data::Dumper;
  1         7707  
  1         90  
12 1     1   15 use File::Basename;
  1         2  
  1         115  
13 1     1   6 use File::Spec;
  1         2  
  1         33  
14 1     1   719 use Getopt::Long qw(GetOptionsFromArray :config gnu_compat);
  1         13262  
  1         6  
15 1     1   1050 use HTTP::Tiny;
  1         130769  
  1         76  
16 1     1   877 use MetaCPAN::Client;
  1         507564  
  1         59  
17 1     1   575 use MIME::Base64;
  1         859  
  1         83  
18 1     1   601 use Pod::Usage qw(pod2usage);
  1         44803  
  1         84  
19 1     1   629 use URI::PackageURL;
  1         14089  
  1         97  
20              
21 1     1   799 use SBOM::CycloneDX::Component;
  1         1303768  
  1         68  
22 1     1   10 use SBOM::CycloneDX::ExternalReference;
  1         2  
  1         34  
23 1     1   839 use SBOM::CycloneDX::Hash;
  1         26152  
  1         63  
24 1     1   8 use SBOM::CycloneDX::License;
  1         1  
  1         31  
25 1     1   5 use SBOM::CycloneDX::Metadata;
  1         2  
  1         24  
26 1     1   5 use SBOM::CycloneDX::OrganizationalContact;
  1         3  
  1         33  
27 1     1   6 use SBOM::CycloneDX::Util qw(cpan_meta_to_spdx_license cyclonedx_tool cyclonedx_component);
  1         2  
  1         92  
28 1     1   669 use SBOM::CycloneDX::Vulnerability::Affect;
  1         13069  
  1         50  
29 1     1   807 use SBOM::CycloneDX::Vulnerability::Rating;
  1         36329  
  1         53  
30 1     1   880 use SBOM::CycloneDX::Vulnerability::Source;
  1         9444  
  1         47  
31 1     1   765 use SBOM::CycloneDX::Vulnerability;
  1         148877  
  1         58  
32 1     1   11 use SBOM::CycloneDX;
  1         3  
  1         5129  
33              
34             our $VERSION = '1.03';
35              
36              
37 0 0   0 0   sub DEBUG { $ENV{SBOM_DEBUG} || 0 }
38              
39             sub cli_error {
40 0     0 0   my ($error, $code) = @_;
41 0           $error =~ s/ at .* line \d+.*//;
42 0           say STDERR "ERROR: $error";
43 0   0       return $code || 1;
44             }
45              
46             sub run {
47              
48 0     0 1   my (@args) = @_;
49              
50 0           my %options = ();
51              
52 0 0         GetOptionsFromArray(
53             \@args, \%options, qw(
54             help|h
55             man
56             v
57             debug|d
58              
59             output|o=s
60              
61             meta=s
62             distribution=s
63              
64             maxdepth=i
65              
66             vulnerabilities!
67             validate!
68              
69             project-meta=s
70             project-type=s
71             project-author=s@
72             project-description=s
73             project-directory=s
74             project-license=s
75             project-name=s
76             project-version=s
77              
78             server-url=s
79             api-key=s
80             skip-tls-check
81             project-id=s
82             parent-project-id=s
83              
84             cyclonedx-spec-version=s
85              
86             list-spdx-licenses
87             )
88             ) or pod2usage(-verbose => 0);
89              
90 0 0         pod2usage(-exitstatus => 0, -verbose => 2) if defined $options{man};
91 0 0         pod2usage(-exitstatus => 0, -verbose => 0) if defined $options{help};
92              
93 0   0       $options{'project-meta'} //= $options{meta};
94              
95 0 0         if (defined $options{v}) {
96 0           return show_version();
97             }
98              
99 0 0         if ($options{'list-spdx-licenses'}) {
100 0           say $_ for (sort @{SBOM::CycloneDX::Enum->SPDX_LICENSES});
  0            
101 0           return 0;
102             }
103              
104 0 0 0       unless ($options{distribution} || $options{'project-meta'} || $options{'project-directory'}) {
      0        
105 0           pod2usage(-exitstatus => 0, -verbose => 0);
106             }
107              
108 0   0       $options{maxdepth} //= 1;
109 0   0       $options{validate} //= 1;
110              
111 0 0         if (defined $options{debug}) {
112 0           $ENV{SBOM_DEBUG} = 1;
113             }
114              
115 0           my $bom = SBOM::CycloneDX->new;
116              
117 0 0         if (defined $options{distribution}) {
118              
119 0           my ($distribution, $version) = split '@', $options{distribution};
120              
121 0 0         return cli_error('Missing distribution version') unless $version;
122              
123 0           make_sbom_from_dist(bom => $bom, distribution => $distribution, version => $version, options => \%options);
124             }
125              
126 0 0 0       if (defined $options{'project-directory'} || defined $options{'project-meta'}) {
127 0           make_sbom_from_project(bom => $bom, options => \%options);
128             }
129              
130 0           $bom->metadata->tools->push(cyclonedx_tool());
131              
132 0   0       my $output_file = $options{output} // 'bom.json';
133              
134 0           say STDERR "Save SBOM to $output_file";
135              
136 0 0         open my $fh, '>', $output_file or Carp::croak "Failed to open file: $!";
137 0           say $fh $bom->to_string;
138 0           close $fh;
139              
140 0 0         if ($options{validate}) {
141 0           my @errors = $bom->validate;
142 0           say STDERR $_ foreach (@errors);
143             }
144              
145 0 0 0       if (defined $options{'server-url'} && defined $options{'api-key'}) {
146 0           submit_bom(bom => $bom, options => \%options);
147             }
148              
149             }
150              
151             sub show_version {
152              
153 0     0 0   (my $progname = $0) =~ s/.*\///;
154              
155 0           say <<"VERSION";
156             $progname version $VERSION
157              
158             Copyright 2025, Giuseppe Di Terlizzi
159              
160             This program is part of the "App-CPAN-SBOM" distribution and is free software;
161             you can redistribute it and/or modify it under the same terms as Perl itself.
162              
163             Complete documentation for $progname can be found using 'man $progname'
164             or on the internet at .
165             VERSION
166              
167 0           return 0;
168              
169             }
170              
171             sub make_sbom_from_project {
172              
173 0     0 0   my (%params) = @_;
174              
175 0           my $audit_discover = CPAN::Audit::Discover->new;
176              
177 0           my $bom = $params{bom};
178 0   0       my $options = $params{options} || {};
179              
180 0           my @META_FILES = (qw[META.json META.yml MYMETA.json MYMETA.yml]);
181              
182 0           say STDERR 'Generate SBOM';
183              
184 0   0       my $project_type = $options->{'project-type'} || 'library';
185 0           my $project_directory = File::Spec->rel2abs($options->{'project-directory'});
186 0   0       my $project_meta = $options->{'project-meta'} || $options->{'meta'};
187 0   0       my $project_name = $options->{'project-name'} || basename($project_directory);
188 0   0       my $project_version = $options->{'project-version'} || 0;
189 0           my $project_description = $options->{'project-description'};
190 0           my $project_license = $options->{'project-license'};
191 0   0       my $project_author = $options->{'project-author'} || [];
192              
193 0 0         if ($project_directory) {
194 0 0         return cli_error('Directory not found') unless -d $project_directory;
195             }
196              
197 0 0         unless ($project_meta) {
198 0           foreach (@META_FILES) {
199 0           my $meta_file = File::Spec->catfile($project_directory, $_);
200 0 0         if (-f $meta_file) {
201 0           $project_meta = $meta_file;
202 0           last;
203             }
204             }
205             }
206              
207 0           my @licenses = ();
208 0           my @authors = ();
209 0           my @external_references = ();
210 0           my @dependencies = ();
211              
212             # Use META/MYMETA for populate:
213             # - Name
214             # - Licenses
215             # - Authors
216             # - Dependencies
217              
218 0 0         if ($project_meta) {
219              
220 0           my $meta = CPAN::Meta->load_file($project_meta);
221              
222 0           $project_name = $meta->name;
223 0           $project_version = $meta->version;
224              
225 0           @authors = make_authors([$meta->author]);
226 0           @external_references = make_external_references($meta->{resources});
227 0           @licenses = (SBOM::CycloneDX::License->new(id => cpan_meta_to_spdx_license($meta->license)));
228              
229             # Detect distribution author dependencies
230             # TODO get the author-defined dependency version
231              
232 0           my $prereqs = $meta->effective_prereqs;
233 0           my $reqs = $prereqs->requirements_for("runtime", "requires");
234              
235 0           for my $module (sort $reqs->required_modules) {
236 0 0         next if $module eq 'perl';
237 0           push @dependencies, {module => $module};
238             }
239              
240             }
241              
242 0 0         if ($project_license) {
243 0           @licenses = (SBOM::CycloneDX::License->new(id => $project_license));
244             }
245              
246 0 0         if (@{$project_author}) {
  0            
247 0           @authors = make_authors($project_author);
248             }
249              
250 0           my $bom_ref = "$project_name\@$project_version";
251 0           $bom_ref =~ s/\s+/-/g;
252              
253             # Build root BOM component
254 0           my $root_component = SBOM::CycloneDX::Component->new(
255             type => $project_type,
256             name => $project_name,
257             version => $project_version,
258             bom_ref => $bom_ref,
259             licenses => \@licenses,
260             authors => \@authors,
261             external_references => \@external_references,
262             );
263              
264 0 0         if ($project_description) {
265 0           $root_component->description($project_description);
266             }
267              
268             # Add root BOM component in metadata
269 0           $bom->metadata->component($root_component);
270              
271             # Find dependencies from "cpanfile.snapshot" or "cpanfile"
272 0 0         if (my @audit_deps = $audit_discover->discover($project_directory)) {
273 0           @dependencies = @audit_deps;
274             }
275              
276 0           foreach my $dependency (@dependencies) {
277              
278             make_dep_compoment(
279             module => $dependency->{module},
280             dist => $dependency->{dist},
281             version => $dependency->{version},
282             bom => $bom,
283             parent_component => $root_component,
284             maxdepth => $options->{maxdepth}
285 0           );
286             }
287              
288 0           return $root_component;
289              
290             }
291              
292             sub make_sbom_from_dist {
293              
294 0     0 0   my (%params) = @_;
295              
296 0           my $distribution = $params{distribution};
297 0           my $version = $params{version};
298 0           my $bom = $params{bom};
299 0   0       my $options = $params{options} || {};
300              
301 0           say STDERR "Generate SBOM for $distribution\@$version";
302              
303 0           my $mcpan = MetaCPAN::Client->new;
304 0           my $release_data = $mcpan->release({all => [{distribution => $distribution}, {version => $version}]});
305              
306 0           my $dist_data = $release_data->next;
307              
308 0 0         unless ($dist_data) {
309 0           Carp::carp("Unable to find release ($distribution\@$version) in Meta::CPAN");
310 0           return;
311             }
312              
313 0           my $metadata = $dist_data->metadata;
314              
315 0           my @authors = make_authors($metadata->{author});
316              
317 0           my $purl = URI::PackageURL->new(
318             type => 'cpan',
319             namespace => $dist_data->author,
320             name => $dist_data->distribution,
321             version => $dist_data->version
322             );
323              
324 0           my @external_references = make_external_references($dist_data->metadata->{resources});
325              
326 0           my $license = join ' AND ', @{$metadata->{license}};
  0            
327 0           my $spdx_license = cpan_meta_to_spdx_license($license);
328              
329 0 0         my $bom_license = SBOM::CycloneDX::License->new(($spdx_license) ? {id => $spdx_license} : {name => $license});
330              
331 0           my $root_component = SBOM::CycloneDX::Component->new(
332             type => 'library',
333             name => $dist_data->name,
334             version => $dist_data->version,
335             licenses => [$bom_license],
336             authors => \@authors,
337             bom_ref => $purl->to_string,
338             purl => $purl,
339             external_references => \@external_references
340             );
341              
342 0 0         if (my $abstract = $dist_data->abstract) {
343 0           $root_component->description($abstract);
344             }
345              
346 0           $bom->metadata->component($root_component);
347              
348 0 0         if ($options->{vulnerabilities}) {
349 0           make_vulnerabilities(
350             bom => $bom,
351             distribution => $dist_data->distribution,
352             version => $dist_data->version,
353             bom_ref => $purl->to_string
354             );
355             }
356              
357 0           foreach my $dependency (@{$dist_data->dependency}) {
  0            
358 0 0 0       if ($dependency->{phase} eq 'runtime' and $dependency->{relationship} eq 'requires') {
359 0 0         next if ($dependency->{module} eq 'perl');
360              
361             make_dep_compoment(
362             module => $dependency->{module},
363             bom => $bom,
364             parent_component => $root_component,
365             maxdepth => $options->{maxdepth}
366 0           );
367              
368             }
369             }
370              
371 0           return $root_component;
372              
373             }
374              
375             sub make_external_references {
376              
377 0     0 0   my $resources = shift;
378              
379 0           my @external_references = ();
380              
381 0 0 0       if (defined $resources->{repository} && $resources->{repository}->{url}) {
382             my $external_reference
383 0           = SBOM::CycloneDX::ExternalReference->new(type => 'vcs', url => $resources->{repository}->{url});
384 0           push @external_references, $external_reference;
385             }
386              
387 0 0 0       if (defined $resources->{bugtracker} && $resources->{bugtracker}->{web}) {
388             my $external_reference
389 0           = SBOM::CycloneDX::ExternalReference->new(type => 'issue-tracker', url => $resources->{bugtracker}->{web});
390 0           push @external_references, $external_reference;
391             }
392              
393 0           return @external_references;
394              
395             }
396              
397             sub make_authors {
398              
399 0     0 0   my $metadata_authors = shift;
400              
401 0           my @authors = ();
402              
403 0           foreach my $metadata_author (@{$metadata_authors}) {
  0            
404 0 0         if ($metadata_author =~ /(.*) <(.*)>/) {
    0          
405 0           my ($name, $email) = $metadata_author =~ /(.*) <(.*)>/;
406 0           push @authors, SBOM::CycloneDX::OrganizationalContact->new(name => $name, email => _clean_email($email));
407             }
408             elsif ($metadata_author =~ /(.*), (.*)/) {
409 0           my ($name, $email) = $metadata_author =~ /(.*), (.*)/;
410 0           push @authors, SBOM::CycloneDX::OrganizationalContact->new(name => $name, email => _clean_email($email));
411             }
412             else {
413 0           push @authors, SBOM::CycloneDX::OrganizationalContact->new(name => $metadata_author);
414             }
415             }
416              
417 0           return @authors;
418              
419             }
420              
421             sub _clean_email {
422              
423 0     0     my $email = shift;
424              
425 0           $email =~ s/E//;
426 0           $email =~ s///;
427 0           $email =~ s///;
428 0           $email =~ s/\[at\]/@/;
429              
430 0           return $email;
431              
432             }
433              
434             sub make_dep_compoment {
435              
436 0     0 0   my (%params) = @_;
437              
438 0           my $distribution = $params{dist};
439 0           my $module = $params{module};
440 0   0       my $version = $params{version} || 0;
441 0           my $author = $params{author};
442 0           my $bom = $params{bom};
443 0           my $parent_component = $params{parent_component};
444 0   0       my $depth = $params{depth} || 1;
445 0   0       my $maxdepth = $params{maxdepth} || 1;
446 0   0       my $add_vulns = $params{add_vulns} || 0;
447              
448 0           my $mcpan = MetaCPAN::Client->new;
449              
450 0 0         if ($module) {
451              
452 0 0         DEBUG
453             and say STDERR sprintf '-- %s[%d] Collect module %s@%s info (parent component %s)',
454             (" " x ($depth - 1)), $depth, $module, $version, $parent_component->bom_ref;
455              
456 0           my $module_data = $mcpan->module($module);
457              
458 0 0         unless ($module_data) {
459 0           Carp::carp("Unable to find module ($module) in Meta::CPAN");
460 0           return;
461             }
462              
463 0   0       $author //= $module_data->author;
464              
465 0           $distribution = $module_data->distribution;
466              
467 0 0         if ($version == 0) {
468 0           $version = $module_data->version;
469             }
470              
471             }
472              
473 0           my $release_data = $mcpan->release({
474             either => [
475             {all => [{distribution => $distribution}, {version => $version}]},
476             {all => [{distribution => $distribution}, {version => "v$version"}]},
477             ]
478             });
479              
480 0           my $dist_data = $release_data->next;
481              
482 0 0         DEBUG
483             and say STDERR sprintf '-- %s[%d] Collect distribution %s@%s info (parent component %s)',
484             (" " x ($depth - 1)), $depth, $distribution, $version, $parent_component->bom_ref;
485              
486 0 0         unless ($dist_data) {
487 0           Carp::carp("Unable to find release ($distribution\@$version) in Meta::CPAN");
488 0           return;
489             }
490              
491 0           my $metadata = $dist_data->metadata;
492              
493 0   0       $author //= $dist_data->author;
494              
495 0           my @authors = make_authors($metadata->{author});
496              
497 0           my $license = join ' AND ', @{$dist_data->metadata->{license}};
  0            
498 0           my $spdx_license = cpan_meta_to_spdx_license($license);
499              
500 0 0         my $bom_license = SBOM::CycloneDX::License->new(($spdx_license) ? {id => $spdx_license} : {name => $license});
501              
502 0           my $purl = URI::PackageURL->new(type => 'cpan', namespace => $author, name => $distribution, version => $version);
503              
504 0           my @ext_refs = make_external_references($dist_data->metadata->{resources});
505              
506 0           my $hashes = SBOM::CycloneDX::List->new;
507              
508 0 0         if (my $checksum = $dist_data->checksum_sha256) {
509 0           $hashes->add(SBOM::CycloneDX::Hash->new(alg => 'sha-256', content => $checksum));
510             }
511              
512 0 0         if (my $checksum = $dist_data->checksum_md5) {
513 0           $hashes->add(SBOM::CycloneDX::Hash->new(alg => 'md5', content => $checksum));
514             }
515              
516 0           my $component = SBOM::CycloneDX::Component->new(
517             type => 'library',
518             name => $distribution,
519             version => $version,
520             licenses => [$bom_license],
521             authors => \@authors,
522             bom_ref => $purl->to_string,
523             purl => $purl,
524             hashes => $hashes,
525             external_references => \@ext_refs,
526             );
527              
528 0 0         if (my $abstract = $dist_data->abstract) {
529 0           $component->description($abstract);
530             }
531              
532 0 0         if (!$bom->get_component_by_bom_ref($purl->to_string)) {
533 0           $bom->components->push($component);
534             }
535              
536 0 0         if ($add_vulns) {
537 0           make_vulnerabilities(
538             bom => $bom,
539             distribution => $distribution,
540             version => $version,
541             bom_ref => $purl->to_string
542             );
543             }
544              
545 0           $bom->add_dependency($parent_component, [$component]);
546              
547 0 0         if ($depth < $maxdepth) {
548              
549 0           $depth++;
550              
551 0           foreach my $dependency (@{$dist_data->dependency}) {
  0            
552 0 0 0       if ($dependency->{phase} eq 'runtime' and $dependency->{relationship} eq 'requires') {
553 0 0         next if ($dependency->{module} eq 'perl');
554             make_dep_compoment(
555             module => $dependency->{module},
556 0           bom => $bom,
557             parent_component => $component,
558             depth => $depth
559             );
560             }
561             }
562              
563             }
564              
565 0           return $component;
566              
567             }
568              
569             sub make_vulnerabilities {
570              
571 0     0 0   my (%params) = @_;
572              
573 0           my $bom = $params{bom};
574 0           my $distribution = $params{distribution};
575 0           my $version = $params{version};
576 0           my $bom_ref = $params{bom_ref};
577              
578 0           my $audit = CPAN::Audit->new;
579              
580 0           my $result = $audit->command('dist', $distribution, $version);
581              
582 0 0         return unless (defined $result->{dists}->{$distribution});
583              
584 0           foreach my $advisory (@{$result->{dists}->{$distribution}->{advisories}}) {
  0            
585              
586 0           my $description = $advisory->{description};
587 0   0       my $severity = $advisory->{severity} || 'unknown';
588 0           my @cves = @{$advisory->{cves}};
  0            
589 0           my $cpansa = $advisory->{id};
590 0           my @references = @{$advisory->{references}};
  0            
591              
592 0           foreach my $cve (@cves) {
593              
594 0           my $vulnerability = SBOM::CycloneDX::Vulnerability->new(
595             id => $cve,
596             description => $description,
597             source => SBOM::CycloneDX::Vulnerability::Source->new(
598             name => 'NVD',
599             url => "https://nvd.nist.gov/vuln/detail/$cve"
600             ),
601             affects => [SBOM::CycloneDX::Vulnerability::Affect->new(ref => $bom_ref)],
602             ratings => [SBOM::CycloneDX::Vulnerability::Rating->new(severity => $severity)]
603             );
604              
605 0           $bom->vulnerabilities->add($vulnerability);
606             }
607             }
608              
609             }
610              
611             sub submit_bom {
612              
613 0     0 0   my (%params) = @_;
614              
615 0           my $bom = $params{bom};
616 0   0       my $options = $params{options} || {};
617              
618 0   0       $options->{'server-url'} //= $ENV{DTRACK_URL};
619 0   0       $options->{'api-key'} //= $ENV{DTRACK_API_KEY};
620 0   0       $options->{'project-id'} //= $ENV{DTRACK_PROJECT_ID};
621 0   0       $options->{'project-name'} //= $ENV{DTRACK_PROJECT_NAME};
622 0   0       $options->{'project-version'} //= $ENV{DTRACK_PROJECT_VERSION};
623 0   0       $options->{'parent-project-id'} //= $ENV{DTRACK_PARENT_PROJECT_ID};
624 0   0       $options->{'skip-tls-check'} //= $ENV{DTRACK_SKIP_TLS_CHECK};
625              
626 0           my $server_url = $options->{'server-url'};
627              
628 0           my $project_directory = File::Spec->rel2abs($options->{'project-directory'});
629 0   0       my $project_name = $options->{'project-name'} || basename($project_directory);
630 0   0       my $project_version = $options->{'project-version'} || 'main';
631              
632 0           my $bom_string = $bom->to_string;
633              
634 0           $server_url =~ s/\/$//;
635 0           $server_url .= '/api/v1/bom';
636              
637 0           my $bom_payload = {autoCreate => 'true', bom => encode_base64($bom_string, '')};
638              
639 0 0         if (defined $options->{'project-id'}) {
640 0           $bom_payload->{project} = $options->{'project-id'};
641             }
642              
643 0 0         unless (defined $options->{'project-id'}) {
644              
645 0 0         if ($project_name) {
646 0           $bom_payload->{projectName} = $project_name;
647             }
648              
649 0 0         if ($project_version) {
650 0           $bom_payload->{projectVersion} = $project_version;
651             }
652              
653             }
654              
655 0 0         if (defined $options->{'parent-project-id'}) {
656 0           $bom_payload->{parentUUID} = $options->{'parent-project-id'};
657             }
658              
659 0 0         my $verify_ssl = (defined $options->{'skip-tls-check'}) ? 0 : 1;
660              
661             my $ua = HTTP::Tiny->new(
662             verify_SSL => $verify_ssl,
663 0           default_headers => {'Content-Type' => 'application/json', 'X-Api-Key' => $options->{'api-key'}}
664             );
665              
666 0           say STDERR "Upload BOM in OSWASP Dependency Track ($server_url)";
667              
668 0           my $response = $ua->put($server_url, {content => encode_json($bom_payload)});
669              
670 0 0         DEBUG and say STDERR "-- Response <-- " . Dumper($response);
671              
672 0 0         unless ($response->{success}) {
673             return cli_error(sprintf(
674             'Failed to upload BOM file to OWASP Dependency Track: (%s) %s - %s',
675             $response->{status}, $response->{reason}, $response->{content}
676 0           ));
677             }
678              
679             }
680              
681             1;
682              
683             __END__