#!/usr/bin/env bash
# IMPORTANT: do not invoke this via `sh`/`dash`. The shebang pins bash so that
# `set -euo pipefail` and `set -o pipefail` are recognized and command errors
# fail the gate. Running it under a different shell silently drops the
# pipefail guarantee, masking advisory exits.
#
# Probe the real interpreter early and refuse to run if the caller used a
# different shell (e.g. `sh script/cpan-audit-project` from CI).
if [ -z "${BASH_VERSION:-}" ]; then
    printf '%s\n' 'cpan-audit-project must be invoked through bash; current shell is not bash' >&2
    exit 2
fi
set -euo pipefail

if [ "$#" -ne 1 ] || [ ! -d "$1" ]; then
  printf 'Usage: %s <isolated-perl5-library-root>\n' "$0" >&2
  exit 2
fi

repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
perl5_root="$(CDPATH= cd -- "$1" && pwd)"
exclusions="$repo_root/cpan-audit-exclusions.txt"

# ISOLATION PRECONDITION (DD-499).
#
# This gate asks "is the set of distributions installed in this root
# vulnerable?" - a question that is only ABOUT THE PRODUCT when the root holds
# the product's dependencies and nothing else. Pointed at a shared CPAN tree it
# answers faithfully about the tree and misleadingly about the product: on
# 2026-08-09 `$HOME/perl5/lib/perl5` produced exit 88 and twenty-four advisories
# across seven distributions, not one of which was a declared runtime
# dependency. Three rounds read that as a release blocker before anyone checked
# what it had measured, and one filed a ticket against it.
#
# So the subject is stated out loud, and a root that is not an isolated product
# root is REFUSED rather than audited. The refusal is deliberately a distinct
# exit status: 0 clean, 1 a disposition guard fired, 2 the caller made a usage
# error, 3 the gate was pointed at the wrong subject, 4 the gate could not run at
# all, 5 advisories were found. Collapsing "wrong subject" into "finding" is what
# made the original misreading so easy.
#
# Status 4 exists because the opposite collapse was also live (DD-517). When
# cpan-audit cannot start - a mixed-version library tree, a missing dependency -
# perl dies with status 1 or 2, and 1 is this gate's OWN code for a disposition
# guard. A tool that never ran was therefore indistinguishable from a real
# finding and from a fired guard, and the suite's "gate is non-zero for a
# vulnerable fixture" test passed on the strength of a crash. A gate that cannot
# look must never be mistaken for a gate that looked and found something.
#
# "Inside the checkout" is the test because that is what an isolated root IS
# here - CI builds one at local/lib/perl5 and audits it in place. It is a crisp
# check with no false positive against that path, which matters: a guard that
# red-lines CI would be worse than the misreading it prevents. A genuinely
# isolated root built elsewhere stays auditable through the opt-in below, which
# is explicit on purpose - the default has to be refusal, or the habit that
# caused DD-499 simply continues.
printf 'auditing library root: %s\n' "$perl5_root"
if [ "${DD_CPAN_AUDIT_ALLOW_EXTERNAL_ROOT:-}" != '1' ] \
  && [ "$perl5_root" != "$repo_root" ] \
  && case "$perl5_root" in "$repo_root"/*) false ;; *) true ;; esac; then
  cat >&2 <<EOF
$perl5_root is not an isolated product library root: it lies outside the
repository working tree $repo_root, so it is a shared CPAN tree carrying
whatever else has been installed on this machine. Advisories found there are
not this product's exposure, and reporting them as such is how DD-499 was
filed against a product whose real CVE position was clean.

To audit the product against a shared tree, use the gate built for that
question, which walks the transitive runtime closure of the declared chain and
ignores everything outside it:

    script/cpan-audit-declared-chain "$perl5_root"

To audit an isolated root, build one and point this gate at it, as CI does:

    cpanm --installdeps --local-lib-contained local .
    script/cpan-audit-project local/lib/perl5

If this root really is isolated and merely lives outside the checkout, say so
explicitly:

    DD_CPAN_AUDIT_ALLOW_EXTERNAL_ROOT=1 script/cpan-audit-project "$perl5_root"
EOF
  exit 3
fi

if grep -R -n -E "Plack::Middleware::XSendfile|(enable|enable_if)[[:space:]\(]+(['\"](Plack::Middleware::)?XSendfile['\"]|XSendfile([[:space:];,)]+|$))|X-Sendfile-Type|X-Accel-Mapping" \
  "$repo_root/app.psgi" "$repo_root/bin" "$repo_root/lib" "$repo_root/share"; then
  printf '%s\n' 'CPANSA-Plack-2026-7381 exclusion invalid: XSendfile middleware is activated by production code' >&2
  exit 1
fi

if grep -R -n -E 'File::Temp[[:space:]]*(->|::)[[:space:]]*safe_level[[:space:]]*\(' \
  "$repo_root/app.psgi" "$repo_root/bin" "$repo_root/lib" "$repo_root/share"; then
  printf '%s\n' 'CPANSA-File-Temp-2011-4116 exclusion invalid: File::Temp vulnerable safety checks are activated by production code' >&2
  exit 1
fi

# Dancer2 sessions are reached through the DSL keyword, not a method call, so an
# arrow-anchored pattern would have missed every real use of them. The keyword
# forms are anchored to statement position instead: unanchored `session '` also
# matched POD prose and tmux session strings, which would have made this guard
# fire on a tree that never touches Dancer2 sessions at all.
if grep -R -n -E "Dancer2::Session|^[[:space:]]*session[[:space:]]*[('\"]|=[[:space:]]*session[[:space:]]*\(|^[[:space:]]*set[[:space:]]+'?session'?[[:space:]]" \
  "$repo_root/app.psgi" "$repo_root/bin" "$repo_root/lib" "$repo_root/share"; then
  printf '%s\n' 'CPANSA-Dancer2-2026-13577 exclusion invalid: Dancer2 session handling is activated by production code' >&2
  exit 1
fi

if ! command -v cpan-audit >/dev/null 2>&1; then
  printf '%s\n' 'cpan-audit is not on PATH, so this gate audited nothing. That is not a clean result.' >&2
  exit 4
fi

# Captured rather than streamed, because the status alone cannot tell "found
# advisories" from "died before it could look": cpan-audit exits non-zero for
# both, and a Perl startup failure exits 1 or 2, which this gate already spends
# on its own guards. The OUTPUT does distinguish them - a run that audited
# anything names advisories, and a run that died prints a Perl diagnostic and no
# advisory at all - so the decision is made on that and the output is passed
# through unchanged either way.

# TWO RUNS, AND THEY ANSWER DIFFERENT QUESTIONS (DD-567).
#
# The first run includes the interpreter and exists to be READ. The second
# excludes it and is the only one allowed to decide the exit status, because the
# subject of THIS gate is the set of distributions installed in the isolated root
# it was given. The interpreter is not one of them: this distribution does not
# ship perl, cannot patch it, and does not declare it as a runtime dependency.
#
# That is not a judgement made here. CLAUDE.md already classes host interpreter
# advisories as environmental rather than release blockers, and
# script/cpan-audit-declared-chain - the gate this project treats as
# authoritative - already skips the interpreter outright. Until now the two gates
# disagreed about scope, and the one that said "in scope" ran FIRST in CI: on
# 2026-08-16 an advisory against perl 5.44.0 (CVE-2026-15534) failed this step and
# skipped the declared-chain audit, the full test run and the coverage gate with
# it. Five merges reached master with no test verdict at all while every local run
# reported PASS.
#
# The scope is what changed. The strictness did not: a distribution advisory in
# the isolated root still exits 5, including when an interpreter advisory appears
# in the same run, and a cpan-audit that died before auditing is still UNUSABLE
# rather than clean. Pinned by t/156-cpan-audit-interpreter-scope.t.
# CORPUS PRECONDITION (DD-790).
#
# Every verdict below is only as good as the advisory database behind it, and until
# now no run said which database that was. On 2026-09-06 this project's database was
# thirty days old and did not contain URI at all, so a genuine advisory against the
# installed URI 5.34 was not merely missed - it was unreportable. The run said
# "clean" in the words it uses when it has actually looked.
#
# THE STAMP COMES FROM THE BINARY WE ACTUALLY SHELL OUT TO. A `perl -MCPANSA::DB`
# one-liner here could resolve a different @INC than the cpan-audit on PATH and
# report the version of a database no verdict came from - this card's own defect,
# rebuilt inside its fix. `cpan-audit --version` names what the tool loaded.
#
# Upstream already warns about this (cpan-audit --fresh, CPAN::Audit::FreshnessCheck)
# but measured on 2026-09-06 it writes to STDERR and leaves the exit status alone -
# 91 with the flag and 91 without - so no caller reading a status can see it. The
# threshold below is upstream's own CPAN_AUDIT_FRESH_DAYS, so one name moves the
# warning and this refusal together; only the consequence is ours.
set +e
version_output="$(cpan-audit --version 2>&1)"
version_rc=$?
set -e
if [ "$version_rc" -ne 0 ]; then
  printf 'did not audit: cannot establish the advisory database version - cpan-audit --version exited %s\n' "$version_rc" >&2
  exit 4
fi

db_stamp="$(printf '%s\n' "$version_output" | awk '/CPANSA::DB/ {print $2; exit}')"
if [ -z "$db_stamp" ]; then
  db_stamp="$(printf '%s\n' "$version_output" | awk '/CPAN::Audit::DB/ {print $2; exit}')"
fi

# A stamp we cannot parse is a corpus we cannot vouch for, so it refuses rather than
# defaulting to acceptance - the gate must never report clean on a database whose
# provenance it could not establish.
if ! printf '%s' "$db_stamp" | grep -qE '^[0-9]{8}([.][0-9]+)?$'; then
  printf 'advisory database: %s (age unknown)\n' "${db_stamp:-unreported}"
  printf 'did not audit: cpan-audit --version reported no YYYYMMDD.NNN database stamp, so the corpus cannot be established\n' >&2
  exit 4
fi

# The AGE is computed in perl, and that is not a contradiction of the rule above.
# The rule is about the STAMP: it must come from the binary that produces the
# verdict, because a stamp read from anywhere else can describe a database no
# verdict came from. Arithmetic on a stamp already in hand carries no such risk.
#
# It is perl rather than date(1) because `date -d` is a GNU extension. BSD and
# macOS date reject it, so the original version of this block was silently
# Linux-only: on a Mac it would have fallen into the guard below and refused to
# audit at all, turning a portable gate into one that fail-closes on half its
# platforms. perl is already a hard dependency here - the gate audits a Perl
# library root and cpan-audit is itself perl - so this adds nothing new.
db_age_days="$(perl -e '
    my ($y, $m, $d) = ($ARGV[0] =~ /^(\d{4})(\d{2})(\d{2})/) or exit 1;
    sub dfc { my ($y,$m,$d)=@_; $y -= ($m<=2?1:0);
              my $e=int(($y>=0?$y:$y-399)/400); my $yoe=$y-$e*400;
              my $doy=int((153*($m+($m>2?-3:9))+2)/5)+$d-1;
              return $e*146097 + $yoe*365+int($yoe/4)-int($yoe/100)+$doy - 719468; }
    my @n = gmtime(time);
    print dfc($n[5]+1900, $n[4]+1, $n[3]) - dfc($y, $m, $d);
' "$db_stamp" 2>/dev/null || true)"
if [ -z "$db_age_days" ]; then
  printf 'advisory database: %s (age unknown)\n' "$db_stamp"
  printf 'did not audit: could not compute the age of advisory database stamp %s\n' "$db_stamp" >&2
  exit 4
fi

# 21, stricter than upstream's 30 on purpose: 30 days is precisely the age at which
# this project's database silently lacked the advisory a clean verdict was cited to
# disprove. CI installs CPANSA-DB fresh on every run, so this cannot wedge a runner.
#
# 21 rather than a rounder number because the limit must EXCEED THE LARGEST REAL GAP
# BETWEEN PUBLICATIONS, or the gate refuses during a normal quiet spell with no newer
# database to install. Measured from the CPAN index on 2026-09-06 over 40 CPANSA-DB
# releases across 223 days: median gap 4, mean 5.7, MAXIMUM 18, one gap of 39 over
# 14 days and none over 30. That 18 is the max of a 223-day window - one sample of
# the tail, not the tail - so 21 is "above the largest gap seen", not a proven
# ceiling. Keep this in step with DEFAULT_MAX_DB_AGE_DAYS in
# script/cpan-audit-declared-chain; they are one policy expressed in two languages.
db_age_limit="${CPAN_AUDIT_FRESH_DAYS:-21}"
if ! printf '%s' "$db_age_limit" | grep -qE '^[0-9]+$'; then
  printf 'did not audit: CPAN_AUDIT_FRESH_DAYS is set to %s, which is not a whole number of days\n' "$db_age_limit" >&2
  exit 4
fi

# Printed on EVERY run, including clean ones. The clean path is the one readers
# believe, so it is the one that must carry the corpus.
printf 'advisory database: %s (%s days old)\n' "$db_stamp" "$db_age_days"

if [ "$db_age_days" -gt "$db_age_limit" ]; then
  # THE RECIPE CREATES ITS OWN DIRECTORY AND NEVER NAMES A FIXED PATH (DD-798).
  # A fixed path under world-writable /tmp is predictable; /tmp is 1777, so the
  # sticky bit stops a user deleting another's files but not creating that
  # directory first; cpanm REUSES an existing directory rather than refusing it;
  # and the next line puts it FIRST on PERL5LIB. Together that is a security tool
  # advising the user to load Perl from a location they do not control - CWE-377
  # and CWE-378. The audited root IS still echoed 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, which is the distinction two drafts of the test got
  # wrong before the assertion was narrowed to the created-and-prepended directory.
  printf 'did not audit: advisory database is %s days old (%s) and the limit is %s. Refresh it WITHOUT disturbing a shared CPAN tree:\n' \
    "$db_age_days" "$db_stamp" "$db_age_limit" >&2
  printf '    DIR=$(mktemp -d)\n' >&2
  printf '    cpanm --local-lib-contained "$DIR" CPANSA::DB\n' >&2
  printf '    PERL5LIB="$DIR/lib/perl5:$PERL5LIB" %s %s\n' "$0" "$perl5_root" >&2
  printf 'Or set CPAN_AUDIT_FRESH_DAYS if you accept auditing against a corpus that old.\n' >&2
  exit 4
fi

# CPANSA-podlators-2026-82560 affects Pod::Text. CPAN::Audit's installed scan
# currently identifies the podlators distribution through its other primary
# module, Pod::Man, whose independent module version can stay at 5.01_02 after
# the distribution ships the fixed Pod::Text 6.1.1. The exact advisory is
# dispositioned in cpan-audit-exclusions.txt only while this check proves that
# the scanned root itself supplies the fixed Pod::Text. A vulnerable core copy
# outside this root is not accepted as proof of the product version.
pod_text_report="$(perl -I "$perl5_root" -MPod::Text -e '
    use strict;
    use warnings;
    use version;

    my ($root) = @ARGV;
    my $module = $INC{"Pod/Text.pm"} // q{};
    my $actual = eval { version->parse( $Pod::Text::VERSION // q{} ) };
    my $minimum = version->parse("6.1.1");

    die "Pod::Text security floor failed: module was not loaded from the audited root\n"
        if index( $module, "$root/" ) != 0;
    die "Pod::Text security floor failed: loaded version "
        . ( $Pod::Text::VERSION // "(unknown)" )
        . " is older than 6.1.1\n"
        if !$actual || $actual < $minimum;

    print "Pod::Text security floor: $Pod::Text::VERSION ($module)\n";
' "$perl5_root" 2>&1)" || {
  printf '%s\n' "$pod_text_report" >&2
  exit 1
}
printf '%s\n' "$pod_text_report"

set +e
audit_output="$(cpan-audit installed "$perl5_root" --perl --exclude-file "$exclusions" 2>&1)"
set -e

printf '%s\n' "$audit_output"

set +e
verdict_output="$(cpan-audit installed "$perl5_root" --exclude-file "$exclusions" 2>&1)"
audit_status=$?
set -e

if [ "$audit_status" -eq 0 ]; then
  exit 0
fi

# DECIDE ON THE SUBJECT OF EACH ADVISORY, NOT ON A FLAG (DD-567, second attempt).
#
# The first attempt ran cpan-audit a second time without --perl and trusted that
# to drop the interpreter. It does not. Counted on 2026-08-17: perl is reported
# once with the flag and once without - the flag only changes how the version is
# rendered (==5.044000 versus 5.044000). CI proved the fix void while the local
# tests passed, because the SHIM emitted perl only under --perl and so encoded
# the assumption under test.
#
# So the subject is read directly. cpan-audit names each finding as
# "DIST (have VERSION) has N advisor...", and this gate's subject is the
# DISTRIBUTIONS installed in the isolated root. The interpreter is not one of
# them: this product does not ship perl, cannot patch it, and CLAUDE.md classes
# host interpreter advisories as environmental - a position
# script/cpan-audit-declared-chain already takes by skipping perl outright.
#
# ANSI is stripped first. CI output is coloured (ESC[31mperl (have ...) and a
# matcher that worked on the plain local form would pass every test here and fail
# in the only place that matters.
plain_verdict="$(printf '%s' "$verdict_output" | sed -e 's/\x1b\[[0-9;]*m//g')"
offending="$(printf '%s\n' "$plain_verdict" \
  | grep -E '^[A-Za-z0-9_:-]+ \(have [^)]*\) has [0-9]+ advisor' \
  | grep -vE '^perl \(have ' || true)"

if [ -n "$offending" ]; then
  # PRINT THE RUN THAT DECIDED (DD-574). The block above prints the run that
  # INCLUDES the interpreter, for visibility; this is the run whose subject is
  # the isolated root alone, and it is the one that just failed the gate.
  #
  # Without this the gate exits 5 having shown only the interpreter advisory,
  # so a reader concludes the interpreter failed it - which is precisely the
  # thing DD-567 exists to prevent. CI run 31982450230 did exactly that, and it
  # is worse than the bug DD-567 fixed: a gate that fails for a STATED wrong
  # reason at least tells you where to look.
  printf '%s\n' '--- the advisories that decided this gate (distributions only; the interpreter is not this gate subject) ---'
  printf '%s\n' "$offending"

  # A real finding. Remapped to a single status of its own rather than passed
  # through, because cpan-audit's own non-zero statuses vary with what it found
  # (65 and 66 have both been seen) and could collide with the gate's 1, 2, 3
  # and 4. A finding must not be able to impersonate a usage error.
  exit 5
fi

# ADVISORIES FOUND, BUT NONE OF THEM THIS GATE'S SUBJECT.
#
# cpan-audit exits non-zero for a finding, so reaching here with recognisable
# advisory lines means it audited successfully and everything it found was the
# interpreter's. That is a PASS for this gate, and it must be distinguished from
# the block below - a run that died before auditing, which also exits non-zero
# and has no advisory lines at all. Collapsing the two is the exact confusion
# status 4 was created to end (DD-517).
if printf '%s\n' "$plain_verdict" | grep -qE '^[A-Za-z0-9_:-]+ \(have [^)]*\) has [0-9]+ advisor'; then
  printf '%s\n' 'no advisory against any distribution in this root; the only findings are against the perl interpreter, which this gate does not judge - see script/cpan-audit-declared-chain'
  exit 0
fi

cat >&2 <<EOF
cpan-audit exited $audit_status without reporting a single advisory, which means
it did not audit $perl5_root - it failed before it could. The output above is its
diagnostic, not a security result.

This gate is reporting UNUSABLE (4) rather than a finding on purpose. Treating
the failure as a finding would be no safer: it would put a false advisory in
front of whoever reads it, and the next person to see the gate go green after
"fixing" it would have fixed nothing.

The usual cause is a library tree built for a different Perl than the one running
cpan-audit. Check that the interpreter and the tool come from the same install:

    command -v perl cpan-audit
    perl -e 'print "\$]\n"'
EOF
exit 4

__END__

=head1 NAME

cpan-audit-project - fail-closed audit of an isolated Developer Dashboard dependency root

=head1 PURPOSE

Audit the CPAN distributions installed into one isolated project library root,
using the current CPANSA advisory database. The script distinguishes a confirmed
distribution finding, an invalid disposition, a wrong scan target, a caller
error, and an audit that could not produce a verdict.

=head1 WHY IT EXISTS

A scan of a shared developer library reports every project installed there and
cannot attribute those findings to this distribution. This gate refuses shared
roots and is run in CI against the fresh root populated by the dependency
installer. It also verifies the vulnerable module itself where the advisory
database currently identifies the containing distribution through a different
module with an independent version.

=head1 WHEN TO USE

Run this script after populating an isolated Perl library root and before
accepting a security or release verdict for its distributions. CI invokes it
against the project dependency root after installing Developer Dashboard's
prerequisites.

=head1 HOW TO USE

    cpanm --installdeps --notest -L local .
    script/cpan-audit-project local/lib/perl5

The root must be inside the checkout unless
C<DD_CPAN_AUDIT_ALLOW_EXTERNAL_ROOT=1> explicitly confirms that an external
root is genuinely isolated. C<cpan-audit> and its own modules must be available
on C<PATH> and C<PERL5LIB>. The advisory database must be no more than 21 days
old by default.

=head1 INPUTS

=over 4

=item * One isolated Perl library root

The directory containing the installed project distributions, such as
C<local/lib/perl5>.

=item * CPAN::Audit tooling

C<cpan-audit> must be installed separately from the root under audit, with its
own library directory exposed to its interpreter.

=back

=head1 OUTPUT

The script names the audited root and advisory database. Exit status 0 means no
in-scope distribution advisory was found; 1 means a reviewed disposition guard
failed; 2 means invalid usage; 3 means the target is not an accepted isolated
root; 4 means the audit could not establish a usable verdict; and 5 means an
in-scope distribution advisory was found. Interpreter advisories are printed
for context but do not decide this distribution-only gate.

=head1 SECURITY NOTES

The C<podlators> advisory CPANSA-podlators-2026-82560 is dispositioned only
after Perl proves that C<Pod::Text> 6.1.1 or later is loaded from the audited
root. The scanned root must not inherit an older core copy as evidence of its
own dependency version. The exact advisory exclusion is retained because the
installed scanner currently attributes the distribution through C<Pod::Man>'s
independent module version.

=head1 EXAMPLES

Audit the CI-style in-checkout root:

    script/cpan-audit-project local/lib/perl5

Audit a deliberately isolated external root:

    DD_CPAN_AUDIT_ALLOW_EXTERNAL_ROOT=1 script/cpan-audit-project /tmp/isolated/lib/perl5

=cut
