Developer-Dashboard
view release on metacpan or search on metacpan
script/cpan-audit-declared-chain view on Meta::CPAN
#!/usr/bin/env perl
# IMPORTANT: this gate answers a different question from script/cpan-audit-project.
# That gate asks "is the set of distributions installed in this root vulnerable?".
# This gate asks "does the declared dependency chain PERMIT a vulnerable
# resolution?" - which is what an installer actually decides. A resolver always
# takes the newest release, so an installed-only audit stays green even while
# the declared floors allow a vulnerable version, and a floor list derived from
# the modules cpanfile names never sees the transitive requirements that
# libwww-perl, Dancer2 and JSON::MaybeXS pull in on their own.
use strict;
use warnings;
use File::Basename qw(basename dirname);
use File::Spec;
use Getopt::Long qw(GetOptions);
use JSON::XS ();
use constant EXIT_CLEAN => 0;
use constant EXIT_FINDING => 1;
use constant EXIT_UNUSABLE => 2;
# Stricter than CPAN::Audit::FreshnessCheck's own default of 30 days, deliberately.
# Upstream's threshold serves an interactive audit, where a warning on STDERR is
# read by the person who typed the command. This is a release gate, and 30 days is
# precisely the age at which this project's database silently lacked the URI
# advisory that a clean verdict was then cited to disprove (DD-790). The knob is
# upstream's own CPAN_AUDIT_FRESH_DAYS, so one name moves the warning and the
# refusal together rather than letting two thresholds disagree in silence.
#
# WHY 21 AND NOT A ROUNDER NUMBER. The limit must EXCEED THE LARGEST REAL GAP
# BETWEEN PUBLICATIONS, or the gate refuses during a normal quiet spell while no
# newer database exists to install - blocking for a reason nobody can act on, which
# is how a gate gets routed around instead of fixed. Measured from the CPAN index
# on 2026-09-06 across 40 CPANSA-DB releases spanning 223 days: median gap 4 days,
# mean 5.7, MAXIMUM 18, with 1 gap of 39 exceeding 14 days and none exceeding 30.
# 21 clears the observed maximum with three days of margin and still catches the
# 30-day staleness this card was raised for. IF A LONGER GAP EVER APPEARS UPSTREAM,
# THIS IS THE NUMBER THAT MOVES - the measurement, not the constant, is the reason.
# And 18 is the maximum of a 223-DAY WINDOW, which is one sample of the tail rather
# than the tail itself: read 21 as "chosen against the largest gap seen in 223 days",
# never as a proven ceiling.
use constant DEFAULT_MAX_DB_AGE_DAYS => 21;
exit main(@ARGV);
# Purpose: run the declared-chain advisory gate end to end.
# Input: the raw @ARGV list (one positional Perl library root, plus the
# optional --cpanfile and --exclude-file overrides).
# Output: an exit code - 0 clean, 1 at least one permitted vulnerable
# resolution, 2 the gate could not audit anything and refuses to
# report a clean result it did not establish.
sub main {
my @argv = @_;
my $repo_root = File::Spec->rel2abs( File::Spec->catdir( dirname(__FILE__), File::Spec->updir ) );
my $cpanfile = File::Spec->catfile( $repo_root, 'cpanfile' );
my $exclusions = File::Spec->catfile( $repo_root, 'cpan-audit-exclusions.txt' );
local @ARGV = @argv;
GetOptions(
'cpanfile=s' => \$cpanfile,
'exclude-file=s' => \$exclusions,
) or return _usage('unrecognized option');
my @positional = @ARGV;
return _usage('exactly one Perl library root is required')
if @positional != 1;
my $perl5_root = $positional[0];
return _usage("not a directory: $perl5_root") if !-d $perl5_root;
return _usage("cpanfile not readable: $cpanfile") if !-f $cpanfile;
my $version_class = _load_audit_modules();
return _unusable('CPAN::Audit::DB and CPAN::Audit::Version must be loadable; install CPAN::Audit and put it on PERL5LIB')
if !$version_class;
# The corpus is established BEFORE the subject is walked, because a database
# too old to answer with makes every later step moot - and reporting it only
# on the failure path would name the database exactly when nobody needs it.
my $corpus = _corpus_report($perl5_root);
# Flushed, because the refusal below goes to STDERR while this goes to STDOUT.
# Block-buffered, the corpus line lands AFTER the refusal in any combined log -
# so the reader meets "refused because the database is old" before being told
# which database, which is the wrong way round for the only line that explains it.
local $| = 1;
print $corpus->{line};
return _unusable( $corpus->{refusal} ) if defined $corpus->{refusal};
my $declared = _parse_cpanfile($cpanfile);
return _unusable("no runtime requirements declared in $cpanfile")
if !%{$declared};
my $metadata = _index_metadata($perl5_root);
return _unusable(
sprintf 'the closure would be incomplete - %d distribution metadata file(s) could not be read: %s',
scalar @{ $metadata->{unusable} },
join( '; ', @{ $metadata->{unusable} } )
) if @{ $metadata->{unusable} };
return _unusable("no distribution metadata (.meta) found under $perl5_root; the declared chain cannot be walked")
if !%{ $metadata->{module_to_dist} };
my $demand = _closure( $declared, $metadata );
my $floors = _distribution_floors( $demand, $metadata );
my $excluded = _read_exclusions($exclusions);
my @findings = _findings( $floors, $excluded, $version_class );
printf "declared-chain closure: %d modules across %d distributions under %s\n",
scalar( keys %{$demand} ), scalar( keys %{$floors} ), $perl5_root;
if ( !@findings ) {
print "No distribution in the declared runtime closure permits a version inside an advisory range.\n";
return EXIT_CLEAN;
}
for my $finding ( @findings ) {
printf "%s permits %s which has advisory %s\n",
$finding->{distribution}, $finding->{permitted}, $finding->{advisory};
printf " affected range: %s\n", $finding->{affected};
printf " fixed range: %s\n", ( $finding->{fixed} eq '' ? '(no fixed release)' : $finding->{fixed} );
( run in 0.872 second using v1.01-cache-2.11-cpan-ac8be02dad9 )