Developer-Dashboard
view release on metacpan or search on metacpan
t/34-scorecard-guardrails.t view on Meta::CPAN
use strict;
use warnings FATAL => 'all';
use Capture::Tiny qw(capture);
use Cwd qw(abs_path);
use File::Spec;
use FindBin qw($RealBin);
use Test::More;
use YAML::XS ();
my $ROOT = abs_path( File::Spec->catdir( $RealBin, File::Spec->updir ) );
my $repo_workflow = File::Spec->catfile( $ROOT, '.github', 'workflows', 'test.yml' );
# Detect the checkout by EXISTENCE, never by directory-ness. In the primary
# checkout .git is a directory, but in a linked git worktree it is a regular file
# holding a "gitdir:" pointer - and ticket work in this repository is done in
# per-ticket worktrees. A -d test here therefore skipped this entire file exactly
# where changes are authored, so every guardrail below (SHA pins, workflow
# permission scoping, dependency floors) was inert at the one moment it was most
# needed. An unpacked release tarball has neither shape, so the original intent -
# do not run source-tree checks against an installed dist - is unchanged.
plan skip_all => 'Scorecard guardrails are source-tree-only checks'
if !-e File::Spec->catdir( $ROOT, '.git' ) || !-f $repo_workflow;
ok( _git_tracks('LICENSE'), 'root LICENSE is tracked for Scorecard license detection' );
ok( _git_tracks('SECURITY.md'), 'root SECURITY.md is tracked for Scorecard security-policy detection' );
ok( _git_tracks('SECURITY_CHECKS.md'), 'root SECURITY_CHECKS.md is tracked so OWASP and release audit tests read the same repo state on CI' );
ok( _git_tracks('.github/dependabot.yml'), 'Dependabot config is tracked for Scorecard dependency-update-tool detection' );
ok( _git_tracks('.github/workflows/codeql.yml'), 'CodeQL workflow is tracked for Scorecard SAST detection' );
ok( _git_tracks('.github/workflows/package-ghcr.yml'), 'GHCR packaging workflow is tracked for Scorecard packaging detection' );
ok( _git_tracks('.github/workflows/fuzz-js.yml'), 'fuzzing workflow is tracked for Scorecard fuzzing detection' );
ok( _git_tracks('.github/workflows/release-github.yml'), 'GitHub release workflow is tracked for Scorecard signed-release detection' );
ok( _git_tracks('.clusterfuzzlite/Dockerfile'), 'ClusterFuzzLite Dockerfile is tracked for Scorecard fuzzing detection' );
my $license = _slurp('LICENSE');
like( $license, qr/\AMIT License\n\nCopyright \(c\) \d{4} Developer Dashboard Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy/s, 'LICENSE uses the canonical MIT text that GitHub can classify' );
like( $license, qr/THE SOFTWARE IS PROVIDED "AS IS"/, 'LICENSE includes the canonical MIT warranty disclaimer' );
my $security = _slurp('SECURITY.md');
like( $security, qr/Reporting A Vulnerability/i, 'SECURITY.md documents vulnerability reporting' );
like( $security, qr/security@/i, 'SECURITY.md includes a private reporting contact' );
my $dependabot = _slurp('.github/dependabot.yml');
like( $dependabot, qr/package-ecosystem:\s*["']github-actions["']/, 'Dependabot manages GitHub Actions updates' );
like( $dependabot, qr/package-ecosystem:\s*["']npm["']/, 'Dependabot manages npm-based fuzzing dependencies' );
my $codeql = _slurp('.github/workflows/codeql.yml');
unlike( $codeql, qr/^permissions:\s*$(?:\n^[^\n]*:\s*write\s*$)+/ms, 'CodeQL workflow does not use top-level write permissions' );
like( $codeql, qr/jobs:\n\s+analyze:\n(?:.+\n)*?\s+permissions:\n(?:.+\n)*?\s+security-events:\s*write\b/ms, 'CodeQL workflow grants security-events write only at the job level' );
like( $codeql, qr/uses:\s*github\/codeql-action\/init\@[0-9a-f]{40}/, 'CodeQL init action is pinned by full SHA' );
like( $codeql, qr/uses:\s*github\/codeql-action\/analyze\@[0-9a-f]{40}/, 'CodeQL analyze action is pinned by full SHA' );
my $package_workflow = _slurp('.github/workflows/package-ghcr.yml');
unlike( $package_workflow, qr/^permissions:\s*$(?:\n^[^\n]*:\s*write\s*$)+/ms, 'packaging workflow does not use top-level write permissions' );
like( $package_workflow, qr/jobs:\n\s+package:\n(?:.+\n)*?\s+permissions:\n(?:.+\n)*?\s+packages:\s*write\b/ms, 'packaging workflow grants package publish access only at the job level' );
like( $package_workflow, qr/ghcr\.io/i, 'packaging workflow publishes to GHCR' );
like( $package_workflow, qr/uses:\s*docker\/build-push-action\@[0-9a-f]{40}/, 'docker build-push action is pinned by full SHA' );
like( $package_workflow, qr/uses:\s*docker\/login-action\@[0-9a-f]{40}/, 'docker login action is pinned by full SHA' );
# The login step is the only one in the pipeline that handles registry
# credentials, so its execution must not depend on GitHub rerouting a stale
# runtime for it: the docker/login-action v3 line declares node20 while the v4
# line declares node24 natively. Keep the pin on v4+, and keep an auditable
# vX.Y.Z comment beside the SHA so a reviewer can tell which upstream release the
# 40-hex pin resolves to without a network round-trip. DD-449 removed the
# FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 shim these jobs used to set, because it is
# documented as removable once no pinned action still declares node20, and none
# does - script/audit-action-pins is what keeps that true.
my ($login_pin_version) = $package_workflow =~ m{uses:\s*docker/login-action\@[0-9a-f]{40}\s+\#\s*v(\d+(?:\.\d+){2})\b};
ok( defined $login_pin_version, 'docker login action pin carries an auditable vX.Y.Z comment beside its SHA' )
or diag('no "# vX.Y.Z" comment found beside the docker/login-action SHA pin');
cmp_ok( ( split /\./, ( $login_pin_version // '0.0.0' ) )[0],
'>=', 4, 'docker login action is pinned on the node24-native v4 line, not the node20 v3 line' );
my $github_release_workflow = _slurp('.github/workflows/release-github.yml');
unlike( $github_release_workflow, qr/^permissions:\s*$(?:\n^[^\n]*:\s*write\s*$)+/ms, 'GitHub release workflow does not use top-level write permissions' );
like( $github_release_workflow, qr/jobs:\n\s+release:\n(?:.+\n)*?\s+permissions:\n(?:.+\n)*?\s+contents:\s*write\b/ms, 'GitHub release workflow grants release-publish access only at the job level' );
like( $github_release_workflow, qr/gh\s+release\s+create\b/, 'GitHub release workflow creates GitHub releases' );
like( $github_release_workflow, qr/gh\s+release\s+upload\b/, 'GitHub release workflow updates existing GitHub releases' );
like( $github_release_workflow, qr/cpanm\s+--notest\s+Devel::Cover\b/, 'GitHub release workflow installs Devel::Cover before it runs the numeric coverage gate' );
like( $github_release_workflow, qr/\.asc\b/, 'GitHub release workflow publishes a detached signature asset next to the release tarball' );
like( $github_release_workflow, qr/Developer-Dashboard-\*\.tar\.gz/, 'GitHub release workflow locates built distribution tarballs from the repo root' );
# Scorecard's Signed-Releases check scores a signed artifact 8 and reserves the
# last 2 points for build provenance, so the signing half alone caps the check at
# 8/10 (checks/evaluation/signed_releases.go sets releaseMap[release]=8 for
# releasesAreSigned and =10 for releasesHaveProvenance). The decisive detail is
# HOW the provenance probe looks: probes/releasesHaveProvenance/impl.go matches
# release ASSETS by name against provenanceExtensions = {".intoto.jsonl"} and
# nothing else. Recording the attestation in GitHub's attestation store is
# therefore necessary but NOT sufficient - without an asset whose name ends
# .intoto.jsonl the check stays at 8 no matter how the attestation was produced.
# Pin the asset, the action, and the pin's auditability together, because losing
# any one of them silently returns the check to 8 with the workflow still green.
like( $github_release_workflow, qr/uses:\s*actions\/attest-build-provenance\@[0-9a-f]{40}/,
'GitHub release workflow generates a build-provenance attestation via a SHA-pinned first-party action' );
like( $github_release_workflow, qr/\.intoto\.jsonl\b/,
'GitHub release workflow publishes the provenance as an .intoto.jsonl asset, the only name Scorecard scores as provenance' );
my ($attest_pin_version) =
$github_release_workflow =~ m{uses:\s*actions/attest-build-provenance\@[0-9a-f]{40}\s+\#\s*v(\d+(?:\.\d+){2})\b};
ok( defined $attest_pin_version, 'attest-build-provenance pin carries an auditable vX.Y.Z comment beside its SHA' )
or diag('no "# vX.Y.Z" comment found beside the actions/attest-build-provenance SHA pin');
cmp_ok( ( split /\./, ( $attest_pin_version // '0.0.0' ) )[0],
'>=', 4, 'attest-build-provenance is pinned on the v4 line that ships the node24-native attest core' );
# Least privilege across the split, asserted structurally rather than by regex:
# minting an OIDC token is what lets this workflow speak as the repository, and
# the `release` job is the one that runs the whole Perl suite, the coverage pass
# and `dzil build` - thousands of lines of project code plus every CPAN
# dependency they pull in. Handing OIDC to that job would put the repository's
# identity behind the largest block of executable code in the pipeline. The
# attestation therefore belongs in its own job that runs no project code, and
# this pair of assertions is what stops a later edit from "simplifying" the two
# jobs back into one.
my $release_yaml = YAML::XS::LoadFile( File::Spec->catfile( $ROOT, '.github', 'workflows', 'release-github.yml' ) );
is( ref $release_yaml, 'HASH', 'GitHub release workflow parses as YAML' );
my $release_job = $release_yaml->{jobs}{release} // {};
my $provenance_job = $release_yaml->{jobs}{provenance} // {};
is( $release_job->{permissions}{'id-token'}, undef,
( run in 1.516 second using v1.01-cache-2.11-cpan-007c89162af )