NAME

cpan-audit-declared-chain - audit the transitive runtime closure of the declared dependency chain for permitted vulnerable resolutions

WHAT IT IS

A fail-closed advisory gate that reads the distribution's own declared runtime requirements, walks every runtime requirement reachable from them using the metadata cpanm writes next to each installed distribution, and reports any distribution whose lowest still-permitted release falls inside a published CPAN security advisory range.

WHAT IT IS FOR

It answers the question an installer answers, which is not the question an installed-distribution scan answers. A scan of what happens to be installed reports the versions a resolver already picked, and a resolver always picks the newest release, so that scan stays green while the declared floors still permit a vulnerable version. This gate reports the floor itself.

WHY IT EXISTS

The advisory floor list was originally derived from the modules the cpanfile names, while the real exposure comes from the transitive closure. Two modules reached the product that way and were only caught by manual audit:

  • HTTP::Date, required by libwww-perl, where a vulnerable 6.06 satisfied every declared requirement.

  • HTML::Parser, required by libwww-perl under the names HTML::Entities and HTML::HeadParser, where the only floor anywhere in the chain was 3.71 and a vulnerable 3.83 satisfied it.

Neither module is named in the cpanfile and neither is called by the product, so no source-level check could ever have found them. The declared floor is the whole mitigation, and this gate is what verifies the floor is actually there.

WHEN TO USE

Run it whenever dependency metadata changes, whenever an advisory floor is raised, and as a continuous-integration step against the isolated dependency root the build resolves. It is deliberately a live gate: the advisory database moves, so a chain that was clean yesterday can legitimately fail today.

HOW TO USE

Give it the Perl library root whose distribution metadata should be walked. The cpanfile and the reviewed advisory disposition file default to the ones next to the script, and both can be overridden. CPAN_AUDIT_FRESH_DAYS overrides how old an advisory database may be before the gate refuses to answer from it.

Exit codes are fail-closed:

  • 0 - no distribution in the closure permits a version inside an advisory range.

  • 1 - at least one permitted vulnerable resolution was found.

  • 2 - the gate could not audit the whole chain (bad usage, missing CPAN::Audit, a library root with no distribution metadata, a distribution metadata file it could not read or parse, or an advisory database whose age could not be established or exceeds the limit). It never reports a clean chain it did not establish, and an unreadable metadata file is not established: dropping one shrinks the closure, and a smaller closure is exactly what hides a finding.

The advisory database is named on every run

Every run prints the advisory database behind its verdict and how old it is, whether or not that run finds anything:

advisory database: CPAN::Audit::DB 20260906.002 (0 days old)

This is not decoration. A verdict is only as good as the corpus it was reached from, and on 2026-09-06 this gate reported the closure clean against a database thirty days old that did not contain URI at all - so a real advisory against the installed URI was not missed but unreportable, and the clean line looked exactly like a clean line from a current database. Naming the corpus is what lets a reader tell "nothing is wrong" from "nothing could have been found", and the clean path is the one that most needs it.

Refusing a corpus too old to answer from

Past a limit the gate declines to produce a verdict at all rather than answering from a database it cannot vouch for, and the refusal names the fix.

The threshold is CPAN_AUDIT_FRESH_DAYS, which is CPAN::Audit's own variable rather than a private one, so setting it moves upstream's freshness warning and this refusal together instead of leaving two thresholds to disagree in silence. The refusal names a way forward, because a refusal with no way forward gets worked around rather than followed. That recipe creates its own directory with mktemp -d and never names a fixed path: a fixed path under world-writable /tmp is predictable, cpanm reuses an existing directory rather than refusing it, and the next line of the recipe puts that directory first on PERL5LIB. Recommending it would mean a security tool advising the user to load Perl from a location they do not control (CWE-377, CWE-378). The audited root is still echoed back at the end of the recipe so the command can be re-run as printed - that path is the caller's own argument, not one this gate invents.

It defaults here to 21 days - stricter than upstream's 30, because 30 days is precisely the age at which this project's database lacked the advisory a clean verdict was later cited to disprove.

Twenty-one is derived rather than chosen. The limit must exceed the largest real gap between publications, or the gate refuses during an ordinary quiet spell when 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 across 40 CPANSA-DB releases spanning 223 days: median gap 4 days, maximum 18. That maximum is one sample of the tail rather than the tail, so read 21 as "above the largest gap seen in 223 days", not as a proven ceiling; if a longer gap appears upstream, that is the number to move.

Note that upstream already detects this and deliberately declines to act on it: cpan-audit --fresh warns through CPAN::Audit::FreshnessCheck but leaves the exit status unchanged, so no caller reading a status can see it. That is the right choice for an interactive audit and the wrong one for a release gate. This gate escalates a signal upstream already emits; it does not invent a policy.

WHAT USES IT

The continuous-integration dependency audit job runs it against the isolated local/lib/perl5 root the build resolves, and t/109-declared-chain-advisory-closure.t exercises both its contracts and its detection behaviour against synthetic metadata fixtures.

EXAMPLES

Example 1 - audit the isolated dependency root a build resolved:

script/cpan-audit-declared-chain local/lib/perl5

Example 2 - audit the operator's own library root:

script/cpan-audit-declared-chain "$HOME/perl5/lib/perl5"

Example 3 - audit a candidate cpanfile before committing it:

script/cpan-audit-declared-chain --cpanfile /tmp/candidate-cpanfile local/lib/perl5

Example 4 - audit with an alternative reviewed disposition list:

script/cpan-audit-declared-chain \
  --exclude-file /tmp/reviewed-advisories.txt local/lib/perl5

AUTHOR

Developer Dashboard Contributors