Security

Current Baseline

Developer Dashboard now applies these runtime protections in the active codebase:

Cross-Site Request Defense

The dashboard's automatic loopback-admin tier authorizes on the remote address alone. There is no cookie in that decision, so SameSite=Strict protects nothing there: any page the operator happens to visit can aim a request at 127.0.0.1 and it arrives already authorized. Two independent checks close that, and both sit at one choke point that runs before trust-tier classification, so no tier can be reached without passing them.

The first is the Origin/Referer comparison, which covers the state-changing methods. Browsers unconditionally attach Origin to a cross-site state-changing request, so a foreign value there is proof of an attack.

The second is fetch metadata, and it exists because the first one cannot defend a GET. Browsers omit Origin on same-origin GETs and an attacker page can drop its Referer with a referrer policy, so on a GET there is frequently nothing to compare. GET is not a safe method on this product: /ajax/<file> runs an operator-written saved handler as a child process, and the route accepts GET. Without the second check an <img src="http://127.0.0.1:7890/ajax/deploy?..."> on any page the operator visits executes that handler blind, with attacker-chosen parameters.

Sec-Fetch-Site is the header that closes it. The browser sets it itself and it is a forbidden header name, so page script can neither forge nor suppress it. The policy is:

This is deliberately stricter than the usual Fetch Metadata Resource Isolation Policy, which carves out top-level cross-site navigations (Sec-Fetch-Mode: navigate with a GET). That carve-out is unsafe here, because a navigation is enough to execute a saved handler — window.open on an /ajax path is the same attack as the <img> tag. The cost of the stricter rule is that following a link into the dashboard from another site returns 403; entering by typed URL or bookmark, which is how the dashboard is actually opened, reports Sec-Fetch-Site: none and is unaffected.

The Dancer2 adapter has to forward Sec-Fetch-Site in its header normalizer alongside Origin and Referer. Without that forwarding the backend check is correct but never sees the header on an installed server, so the defense silently does nothing outside the unit tests.

OWASP Gate

Developer Dashboard now treats OWASP as a full security gate, not a baseline-only checklist.

The shipped OWASP compliance SOW now records the chapter-by-chapter evidence matrix and the current claim boundary. Use that record when deciding whether a public statement should stay at OWASP-aligned / OWASP-gated or can safely move to a stronger blanket compliance claim.

The repository security review is aligned to OWASP ASVS 5.0.0 across the full chapter set:

Every change must complete a V1 through V14 applicability review. If one chapter is not relevant to the change, that should be stated explicitly rather than skipped implicitly.

The practical repo policy is:

The same gate is also cross-mapped to the OWASP Top 10 2021 categories:

For this repository, route, auth, session, Ajax, static-file, command execution, packaging, and workflow changes must always be checked against at least A01, A03, A05, A07, A08, and A09.

The current shipped status record does not yet authorize an unqualified public OWASP compliant claim. The stronger claim stays blocked until the matrix, repo-side evidence, and the remaining governance and release gates are all closed together.

Repository Hygiene

The active tree outside the read-only older reference tree is kept free of:

That older reference tree remains read-only reference material and is not modified or committed as part of the active runtime.

Dependency Advisory Floors

Some dependencies are pinned to a minimum version purely because of a published advisory, not because the product needs a feature from that release. Those floors are declared in cpanfile, Makefile.PL, and dist.ini together, and both t/108-cpan-security-metadata.t and t/15-release-metadata.t assert all three copies so the declarations cannot drift apart.

Two rules make this policy work:

Each advisory-driven floor is backed by an executable proof rather than by a version string alone. t/108-cpan-security-metadata.t builds a fixture of the module at a genuinely affected version and requires script/cpan-audit-project to report that exact advisory identifier. A floor that drifted to a version with no real advisory behind it would fail that proof.

When raising a floor, audit the newly resolved chain rather than assuming it is safe: a higher floor can pull in additional distributions, and a floor set above a module's newest release would make the distribution uninstallable.

Auditing the transitive closure, not the named dependencies

The floor list used to be derived from the modules cpanfile names, while the real exposure comes from the transitive closure of what those modules require in turn. Two distributions reached the resolved chain that way and were caught only by manual audit, both pulled in by libwww-perl's own runtime requirements and neither named in cpanfile: HTTP::Date, and HTML::Parser under the names HTML::Entities and HTML::HeadParser.

Scanning what happens to be installed cannot catch this class. A resolver always takes the newest release, so the installed versions look clean while the declared floors still permit a vulnerable one. The question that has to be asked is the one an installer answers: what is the lowest release the declared chain still allows?

script/cpan-audit-declared-chain asks exactly that. It reads the declared runtime requirements, walks every runtime requirement reachable from them using the metadata that is written next to each installed distribution, resolves the lowest release each distribution's accumulated floor still permits, and reports any that falls inside a published advisory range. It shares the reviewed advisory disposition list with the installed-distribution gate, and it fails closed: a library root it cannot walk, a missing advisory database, or a single distribution metadata file it cannot read or parse all exit non-zero rather than reporting a clean chain it never established. The last of those matters as much as the others, because a dropped metadata file shrinks the closure, and a smaller closure is precisely what hides a finding — a partial walk that reports "no distribution permits a vulnerable version" is the same false clean this gate was built to end.

The gate runs as its own continuous-integration step against the isolated dependency root the build resolves, and t/109-declared-chain-advisory-closure.t pins both its contracts and its detection behaviour against a synthetic chain built in the shape of the original defect. That gate is deliberately live: the advisory database moves, so a newly published advisory against any distribution in the closure is a real finding, and the answer is to raise that distribution's floor or record a reviewed disposition for the advisory.

Which library root each gate may be pointed at

The two gates answer different questions, and pointing one of them at the wrong library root produces an answer that is entirely correct and entirely not about this product.

script/cpan-audit-project inventories the distributions installed in a root and reports advisories against them. That is a statement about the product only when the root holds the product's dependencies and nothing else. Pointed at a shared CPAN tree — the one a developer machine accumulates across every project it has ever built — it reports that tree faithfully, and a reader takes it as product exposure because nothing in the output says otherwise.

So the gate states the root it is auditing, and refuses a root outside the repository working tree with exit status 3, deliberately distinct from 0 clean, 1 a disposition guard fired, and 2 a usage error. Collapsing "wrong subject" into "finding" is what let the misreading spread: a red gate reads as a blocker regardless of what it measured. The refusal names the gate that does answer the question for a shared tree.

DD_CPAN_AUDIT_ALLOW_EXTERNAL_ROOT=1 is the explicit opt-in for a root that is genuinely isolated but lives outside the checkout, such as one built inside a container. The default has to be refusal rather than a warning, because a warning above two dozen advisory lines is not read.

Isolation is tested as "inside the repository working tree" rather than as purity of the closure. The stricter-sounding rule — that the root may contain only distributions in the declared runtime closure — is wrong here: an isolated root built by cpanm --installdeps --local-lib-contained also carries toolchain and test distributions, so that rule would refuse the very root continuous integration audits. A guard that red-lines CI is worse than the misreading it set out to prevent.

To judge the product against a shared tree, use script/cpan-audit-declared-chain, whose subject is the declared runtime closure and which ignores everything outside it. t/110-cpan-audit-root-isolation.t pins all three cases — refusal, opt-in, and the in-tree root CI uses — and pins this documentation against the script, because what produced the original misreading was an operator following instructions that named the refused invocation.

The cost of not having this is measured: the gate was run against $HOME/perl5/lib/perl5 and exited 88 with twenty-four advisories across seven distributions, not one of them a declared runtime dependency. Three separate rounds read that as a release blocker, and one filed it as a priority-2 security defect, while the product's own position was clean across the 81 distributions in its declared closure.

CI Action Pinning

Every third-party GitHub Action is pinned by full 40-character commit SHA, never by a floating tag, and each pin carries a trailing # vX.Y.Z comment naming the upstream release it resolves to. t/34-scorecard-guardrails.t asserts both halves: the SHA form for each action, and the absence of any floating tag across every workflow. The comment exists so a reviewer can tell what a 40-hex pin is without a network round-trip; because a comment can drift from the SHA beside it, the comment is documentation and the SHA is the control.

A proposed bump is verified against the upstream tag before it is taken, rather than trusted because an automated dependency PR proposed it. Resolving the upstream refs/tags/vX.Y.Z must return exactly the SHA the bump introduces; a pin that does not resolve to the named release is rejected regardless of where the change came from.

That rule is enforced by script/audit-action-pins, which the test workflow runs on every push. For each pinned action it reads the action.yml the pinned commit actually carries and resolves the tag named in the comment, failing the build when a pin declares a runtime below node24 or when its comment names a tag that resolves to a different commit. It separates "this pin is wrong" from "this run could not find out" and exits non-zero for both, so an audit that could not run never reads as an audit that passed. t/142-action-pin-provenance.t covers that decision logic against fixtures, without a network.

The enforcement exists because the documentation above was already policy and was still violated. Three pins carried comments written from intent rather than resolved from the tag: actions/checkout was annotated # v5.2.2 — a tag that has never existed upstream — over a commit that is really v4.2.2, and shogo82148/actions-setup-perl was annotated # v1.32.0 over v1.31.3. All were node20 actions. GitHub force-runs node20 actions on node24, which actions/checkout survives and actions-setup-perl v1.31.3 does not: it fails with Error: unable to get latest version. The Setup Perl step therefore failed on every CI run for ten days, skipping the suite, the coverage gate and both dependency audits, while the guardrail test read the comments and certified the migration as complete. A version floor read from a comment cannot detect a comment that lies, which is why the resolving check is a separate gate.

Runtime declarations are part of this review. Every pinned action now declares node24, so the jobs no longer set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24, GitHub's transitional shim for rerouting node20 actions. Steps that handle credentials — the GHCR login in particular — are held on node24-native release lines, and t/34-scorecard-guardrails.t gates those version floors rather than only the pin format.

Release Provenance

A vX.XX tag push publishes the distribution tarball, its SHA-256 checksum, and a detached GPG signature. Those three assets prove integrity and authorship, but they say nothing about where the artifact came from, so the release also carries SLSA build provenance generated by actions/attest-build-provenance.

The provenance is published as a release asset, not only recorded in GitHub's attestation store. That distinction is load-bearing rather than stylistic: tooling that scores provenance — OpenSSF Scorecard's Signed-Releases check among it — recognises provenance solely by a release asset whose name ends in .intoto.jsonl, and treats an attestation held only in the attestation store as absent. Two assets are published per release: the .intoto.jsonl DSSE envelope, which is the shape provenance tooling consumes, and the full .sigstore.json bundle, which is the only one of the two carrying the certificate chain and transparency-log entry needed to verify that provenance offline.

Provenance is generated in a separate job from the one that builds the artifact, and the separation is the security control rather than an organisational convenience. Producing an attestation requires id-token: write, which is what allows a job to mint an OpenID Connect token asserting this repository's identity. The build job runs the full test suite, the coverage pass, and dzil build — the largest body of executable project code in the pipeline, plus every CPAN dependency it pulls in — so granting it OIDC would put the repository's identity behind all of that code. The provenance job instead runs no project code at all: it re-downloads the artifact GitHub actually published, verifies it against the published checksum with sha256sum --check --strict before putting its name to it, attests it, and attaches the result. Attesting the published bytes rather than a local rebuild also makes the attestation describe what consumers really download; a mismatch fails the release instead of producing provenance for an artifact nobody received.

Running no project code has one consequence worth stating explicitly, because it is easy to reintroduce: the provenance job checks nothing out, so its working directory holds no git remotes, and every gh call in it must name its repository with --repo. gh resolves the base repository from --repo first, then the GH_REPO environment variable, and only then from git remotes, where it fails as no git remotes found; it never reads Actions' GITHUB_REPOSITORY. An unqualified gh release upload therefore fails after the attestation has been minted, which would leave the attestation recorded in the store but never attached — provenance that looks generated and scores nothing.

t/34-scorecard-guardrails.t gates this structurally, parsing the workflow as YAML rather than pattern-matching it: the build job must not hold id-token or attestations permissions, the provenance job must hold both at job level and must depend on the build job, the provenance job's steps must verify the checksum and must not invoke prove, dzil, or cpanm, and — folding backslash continuations first, so a flag on a later line still counts — the job must check nothing out while every gh invocation in it selects its repository explicitly. Those assertions exist so a later change cannot quietly collapse the two jobs back into one, or silently strip the repository selector that the absent checkout makes mandatory.

Verification

Run these checks:

dashboard doctor
dashboard doctor --fix
prove -lr t

For security-sensitive changes, the local verification loop must also include the OWASP-driven repo audit commands from SECURITY_CHECKS.md, including the auth/session, redirect, traversal, command-execution, header, and raw-SQL grep checks plus the focused web and SSL regressions.

Recent repo audit summary:

Private Reporting

The published root security policy lives in SECURITY.md and currently directs private reports to:

That root file now also documents the coordinated-disclosure timing contract:

The repository also treats the live OpenSSF Scorecard report as a security and release gate. Run:

bash -ic "scorecard --repo=github.com/manif3station/developer-dashboard"

before closing a task that changes repository policy, workflows, releases, or security posture.