Summary
pie self-update verifies a downloaded pie.phar by running gh attestation verify --owner=php <file> (gh path) or by walking the upstream Sigstore certificate chain and checking three Fulcio OID extensions (OpenSSL fallback). Neither check binds the attestation to the release tag the user asked for. Any historically-attested pie.phar from any php/pie build passes both verifiers, including releases known to contain other security bugs.
The attestation's DSSE payload contains predicate.buildDefinition.externalParameters.workflow.ref (e.g. refs/tags/1.3.9) and the certificate carries sourceRepositoryRef with the same value. PIE has the data on hand; it just doesn't compare it against what the user expected.
Practical exploitation requires an attacker who can deliver bytes from an older release through one of PIE's release-discovery paths: the hardcoded https://php.github.io/pie/pie-nightly.phar URL for the nightly channel, or the browser_download_url field in the GitHub releases API JSON for stable / preview. The CVSS reflects that prerequisite as AC:H. The bug PIE owns is "I won't notice when this happens"; the resulting compromise is "user is rolled back to a PIE release with known security bugs."
Details
What the verifiers check
The gh path, src/SelfManage/Verify/GithubCliAttestationVerification.php:48-54:
$verificationCommand = [
$gh,
self::GH_ATTESTATION_COMMAND,
'verify',
'--owner=php',
$pharFilename->filePath,
];
gh attestation verify --owner=php <file> looks up attestations for the file's sha256 from the php GitHub org and asserts the attestation chains to Sigstore's trusted root. gh exposes --source-ref, --source-tag, --source-digest, --signer-workflow, and --cert-identity flags for tightening; PIE uses none of them.
The OpenSSL fallback path, src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php:22-26:
private const ATTESTATION_CERTIFICATE_EXPECTED_EXTENSION_VALUES = [
FulcioSigstoreOidExtensions::ISSUER_V2 => 'https://token.actions.githubusercontent.com',
FulcioSigstoreOidExtensions::SOURCE_REPOSITORY_URI => 'https://github.com/php/pie',
FulcioSigstoreOidExtensions::SOURCE_REPOSITORY_OWNER_URI => 'https://github.com/php',
];
The upstream Fulcio extension class (vendor/thephpf/attestation/src/FulcioSigstoreOidExtensions.php) exposes only three OIDs: ISSUER_V2 (1.3.6.1.4.1.57264.1.8), SOURCE_REPOSITORY_URI (1.3.6.1.4.1.57264.1.12), and SOURCE_REPOSITORY_OWNER_URI (1.3.6.1.4.1.57264.1.16). The OID that would bind the ref, SOURCE_REPOSITORY_REF (1.3.6.1.4.1.57264.1.14), isn't defined, and the verifier has no reference-binding hook to pass it to.
So both paths assert "this artifact was attested by something in github.com/php/" and stop there.
Where the data exists but isn't checked
A pulled attestation for pie-1.3.9.phar carries, in its certificate's verification result:
sourceRepositoryURI: https://github.com/php/pie
sourceRepositoryRef: refs/tags/1.3.9
sourceRepositoryDigest: 9f845c2222ae326c462ca106d0be7df83f94c672
buildSignerURI: https://github.com/php/pie/.github/workflows/build-phar.yml@refs/tags/1.3.9
And in its DSSE payload:
predicate.buildDefinition.externalParameters.workflow.ref = "refs/tags/1.3.9"
predicate.buildDefinition.resolvedDependencies[0].uri = "git+https://github.com/php/pie@refs/tags/1.3.9"
Three independent fields name the tag. PIE reads none of them.
Release-discovery paths the attacker can influence
SelfUpdateCommand::execute:123-156 resolves the URL to fetch:
if ($updateChannel === Channel::Nightly) {
$latestRelease = new ReleaseMetadata(
'nightly',
'https://php.github.io/pie/pie-nightly.phar',
);
} else {
$latestRelease = $fetchLatestPieRelease->latestReleaseMetadata($updateChannel);
// ...ReleaseIsNewer check against $latestRelease->tag...
}
Nightly channel. The URL is hardcoded. pie-nightly.phar is served from the php.github.io GitHub Pages site fed by the php/pie repo. Whoever can push to that Pages source can swap the asset. Any historical attested pie.phar (e.g., from a 1.3.9 release) passes attestation verification because the file name and the php owner are all that's enforced.
Stable / preview channel. The fetcher calls latestReleaseMetadata which queries api.github.com/repos/php/pie/releases, picks the newest entry, and uses its assets[].browser_download_url. The asset URL is editable per-release by anyone with write to php/pie. Re-pointing a recent release's pie.phar asset at the bytes of an older release is a one-click change in the GitHub UI. The ReleaseIsNewer::forChannel check runs against $latestRelease->tag, not against the digest, so as long as the JSON's tag_name is monotonic, the swap is invisible to PIE.
In both cases, the prerequisite is some form of write access to PIE's release-distribution surface (Pages source, release assets), or a transient compromise of that surface. Neither is in PIE's direct control. The bug under audit is that PIE doesn't make the surface failure-evident by checking the attestation's ref/tag against what the JSON declares.
Why ReleaseIsNewer doesn't help
src/SelfManage/Update/ReleaseIsNewer.php:30-87 enforces tag-level monotonicity: Semver::satisfies($newVersion, '> ' . $currentPieVersion). This is a check against the release JSON's claimed tag, not against the attestation's ref. If the JSON says tag_name=1.5.10 but the asset bytes were built from refs/tags/1.4.4, the semver check passes (1.5.10 > current) and the attestation passes (1.4.4 bytes have valid attestation under php/). Bytes get installed.
Why PIE-002 is amplified by this gap
If PIE-002 (TOCTOU on self-update) gets fixed in a 1.4.5 release, an attacker who can deliver 1.4.4 bytes through either release-discovery path re-exposes the user to PIE-002. Same shape for PIE-001 (sudo rm) and any future PIE CVE: as long as historical PHARs remain attested, this gap is the rollback wedge.
Variant: pie self-verify can't bind to a version either
SelfVerifyCommand::execute (src/Command/SelfVerifyCommand.php:50-70) walks the same code path with the same gap:
$latestRelease = new ReleaseMetadata(PieVersion::get(), 'blah');
$pharFilename = BinaryFile::fromFileWithSha256Checksum(($this->fullPathToSelf)());
$verifyPiePhar = VerifyPieReleaseUsingAttestation::factory();
try {
$verifyPiePhar->verify($latestRelease, $pharFilename, $this->io);
} catch (FailedToVerifyRelease $failedToVerifyRelease) {
// ...abort
}
$this->io->write(sprintf(
'<info>%s You are running an authentic PIE version %s.</info>',
Emoji::GREEN_CHECKMARK,
$latestRelease->tag,
));
The output reads "You are running an authentic PIE version X" where X is PieVersion::get(), the version baked into the running PHAR. The verifier never compares X against the attestation's ref. So a pie.phar swapped on disk to a different attested release still passes self-verify; the command prints the swapped PHAR's self-claimed version and a green checkmark.
The misleading bit is the output text: it implies the command verified "version X" specifically. What it verified is "this artifact is in the set of all artifacts ever attested under the php/ org." That set includes every historical PIE release. self-verify is therefore not a useful tamper-detection tool against the threat model "an attacker swapped my pie.phar with a different php-attested pie.phar," which is the exact threat model PIE-003's main finding describes.
Honest behaviour for the current implementation would be to drop the version from the output. The right fix is to bind to an expected ref, which means asking the user (or api.github.com) what version they expected and asserting against that.
PoC
Three runs against real PIE releases, using PIE's actual gh invocation and the proposed fix:
mkdir -p /tmp/pie-poc-003 && cd /tmp/pie-poc-003
# Download a 2-month-old release and the latest.
gh release download 1.3.9 --repo php/pie --pattern 'pie.phar' --output pie-1.3.9.phar
gh release download 1.4.4 --repo php/pie --pattern 'pie.phar' --output pie-1.4.4.phar
Run 1. PIE's exact invocation against the old PHAR:
$ gh attestation verify --owner=php pie-1.3.9.phar
$ echo "exit=$?"
exit=0
The old, 2-month-stale PHAR passes. Same exit code as the latest PHAR.
Run 2. Same invocation but hardened with --source-ref (the proposed fix), asking for 1.4.4 while the bytes are from 1.3.9:
$ gh attestation verify --owner=php --source-ref refs/tags/1.4.4 pie-1.3.9.phar
Error: expected SourceRepositoryRef to be refs/tags/1.4.4, got refs/tags/1.3.9
$ echo "exit=$?"
exit=1
The downgrade is caught. The error message reads the attestation's bound ref directly.
Run 3. Sanity check that the hardened invocation doesn't false-positive against a matching PHAR:
$ gh attestation verify --owner=php --source-ref refs/tags/1.3.9 pie-1.3.9.phar
$ echo "exit=$?"
exit=0
$ gh attestation verify --owner=php --source-ref refs/tags/1.4.4 pie-1.4.4.phar
$ echo "exit=$?"
exit=0
Each genuine ref-vs-bytes pairing passes; the mismatch is the only thing that fails.
The OpenSSL fallback is equivalent on the source side: the upstream verifier (vendor/thephpf/attestation/src/Verification/VerifyAttestationWithOpenSsl.php) checks only the three OIDs defined in FulcioSigstoreOidExtensions.php, none of which name a ref. An OpenSSL-path PoC would require staging a local trusted-root and stubbing the attestation HTTP call, but the static-source claim is unambiguous: there is no code path that compares the cert's sourceRepositoryRef against the expected tag.
Verified on:
| PIE |
PHP |
gh |
OS |
Outcome |
commit 5c3fa89c5f63728f60fda04756b4044c01855ff7 (1.5.x) |
8.4.21 (NTS) |
2.92.0 |
Linux x86_64 (WSL2) |
gh path PoC reproduces against real php/pie attestations |
| 1.4.4 release tag |
(verifier code identical) |
n/a |
n/a |
yes (by code inspection) |
Impact
Vulnerability class: Insufficient verification of data authenticity (CWE-345); rollback / downgrade attack against a signed-update flow.
Capability granted: An attacker who can substitute the bytes served at https://php.github.io/pie/pie-nightly.phar (nightly channel) or the browser_download_url returned by api.github.com/repos/php/pie/releases (stable / preview channel) can deliver an older legitimately-attested pie.phar to any user running pie self-update. Such substitution requires write access to the Pages source or to a release's assets; both are within reach of a transient php/pie maintainer compromise, a GH-account hijack, a Pages-source repository compromise, or a CDN/cache poisoning event upstream of the user.
The rolled-back PIE is whatever historical release the attacker delivers, including any release with a known CVE. PIE-001 (sudo-elevated rm) and PIE-002 (TOCTOU self-update) both ship in 1.4.4 and earlier. Until the user installs a fully-patched version, both remain exploitable. The rollback is sticky: once an older PIE is installed, the user has to manually notice their version regressed and pull a newer release.
Affected versions: All PIE releases through 1.4.4 and the 1.5.x development branch up to commit 5c3fa89c5f63728f60fda04756b4044c01855ff7. The --owner=php-only invocation has been in place since the self-update verification path landed; no prior fix exists.
Affected configurations: Default. Triggered by pie self-update on any channel. Nightly is more exposed because the URL is fixed and Pages publish is a single workflow run; stable/preview requires release-asset substitution, which is auditable via GitHub but not enforced by PIE.
Affected users: Anyone running pie self-update against a PIE release distribution surface they don't fully trust to remain pinned to its original bytes. In practice this includes every PIE user, because the distribution surface is operated by php/pie maintainers and the bug fails open to "any historical attestation."
Required user action: Run pie self-update. The verification message reads as normal because the attestation verifies cleanly; nothing surfaces the ref mismatch.
Fix direction
Bind the verifier to the release the user asked for. Two changes, one per verifier path:
-
gh path. Add --source-ref (or --source-tag once gh 2.x exposes that flag explicitly) to the invocation at src/SelfManage/Verify/GithubCliAttestationVerification.php:48:
$verificationCommand = [
$gh,
self::GH_ATTESTATION_COMMAND,
'verify',
'--owner=php',
'--source-ref', 'refs/tags/' . $releaseMetadata->tag,
$pharFilename->filePath,
];
gh attestation verify already extracts the bound ref and prints a precise error when it mismatches. Nightly needs a separate strategy because there's no "tag": bind it to refs/heads/<default-branch> instead, or to the workflow path via --signer-workflow=php/pie/.github/workflows/build-phar.yml.
-
OpenSSL fallback. Add a fourth Fulcio OID (SOURCE_REPOSITORY_REF, 1.3.6.1.4.1.57264.1.14) to FulcioSigstoreOidExtensions.php, pass the expected refs/tags/<tag> through the existing $extensionsToVerify mechanism in FallbackVerificationUsingOpenSsl::verify. The DER-decoding code at assertCertificateExtensionClaims already handles arbitrary OID extensions, so the wiring is small. Alternatively, parse predicate.buildDefinition.resolvedDependencies[0].uri in the DSSE payload and assert it starts with git+https://github.com/php/pie@refs/tags/.
-
self-verify command. Accept an optional version argument from the user and bind to it: pie self-verify 1.5.0 verifies the running PHAR is genuinely the 1.5.0 release. Drop in --source-ref refs/tags/$argument to the underlying gh invocation; reject the run if no argument is supplied, or fall back to "I'll fetch the latest stable ref from api.github.com and check against that." The current behaviour, accepting any attested PIE while printing whatever version the running PHAR self-claims, is worse than no command at all because the green checkmark gives users false confidence.
Concretely at src/Command/SelfVerifyCommand.php:42:
public function execute(InputInterface $input, OutputInterface $output): int
{
// ...PHAR-build guard...
$expected = $input->getArgument('expected-version');
if ($expected === null) {
$this->io->writeError('<error>self-verify requires an expected version: pie self-verify <version></error>');
return Command::FAILURE;
}
$expectedRelease = new ReleaseMetadata($expected, 'n/a');
$pharFilename = BinaryFile::fromFileWithSha256Checksum(($this->fullPathToSelf)());
$verifyPiePhar = VerifyPieReleaseUsingAttestation::factory();
// ...with the fix from change 1, the verifier now passes --source-ref through.
$verifyPiePhar->verify($expectedRelease, $pharFilename, $this->io);
// ...success message printed against $expected, not PieVersion::get().
}
The output text should reference $expected rather than the version baked into the running PHAR. Once that's done, a swapped older PHAR fails the verify step because the attestation's ref won't match the user-supplied expectation.
As independent hardening, persist the last-installed PIE digest in Settings (already used for the channel choice) and refuse to install a release whose digest matches any historical digest in the persistence file. This closes the rollback even if both verifier paths above are bypassed.
For the nightly URL specifically, consider rotating away from a fixed-name asset. A signed manifest published alongside pie-nightly.phar (e.g. pie-nightly.manifest.json with the expected digest and ref) lets PIE assert the bytes are what the publish workflow intended, even without a Sigstore round-trip.
Summary
pie self-updateverifies a downloadedpie.pharby runninggh attestation verify --owner=php <file>(gh path) or by walking the upstream Sigstore certificate chain and checking three Fulcio OID extensions (OpenSSL fallback). Neither check binds the attestation to the release tag the user asked for. Any historically-attestedpie.pharfrom anyphp/piebuild passes both verifiers, including releases known to contain other security bugs.The attestation's DSSE payload contains
predicate.buildDefinition.externalParameters.workflow.ref(e.g.refs/tags/1.3.9) and the certificate carriessourceRepositoryRefwith the same value. PIE has the data on hand; it just doesn't compare it against what the user expected.Practical exploitation requires an attacker who can deliver bytes from an older release through one of PIE's release-discovery paths: the hardcoded
https://php.github.io/pie/pie-nightly.pharURL for the nightly channel, or thebrowser_download_urlfield in the GitHub releases API JSON for stable / preview. The CVSS reflects that prerequisite as AC:H. The bug PIE owns is "I won't notice when this happens"; the resulting compromise is "user is rolled back to a PIE release with known security bugs."Details
What the verifiers check
The
ghpath,src/SelfManage/Verify/GithubCliAttestationVerification.php:48-54:gh attestation verify --owner=php <file>looks up attestations for the file's sha256 from thephpGitHub org and asserts the attestation chains to Sigstore's trusted root.ghexposes--source-ref,--source-tag,--source-digest,--signer-workflow, and--cert-identityflags for tightening; PIE uses none of them.The OpenSSL fallback path,
src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php:22-26:The upstream Fulcio extension class (
vendor/thephpf/attestation/src/FulcioSigstoreOidExtensions.php) exposes only three OIDs:ISSUER_V2(1.3.6.1.4.1.57264.1.8),SOURCE_REPOSITORY_URI(1.3.6.1.4.1.57264.1.12), andSOURCE_REPOSITORY_OWNER_URI(1.3.6.1.4.1.57264.1.16). The OID that would bind the ref,SOURCE_REPOSITORY_REF(1.3.6.1.4.1.57264.1.14), isn't defined, and the verifier has no reference-binding hook to pass it to.So both paths assert "this artifact was attested by something in
github.com/php/" and stop there.Where the data exists but isn't checked
A pulled attestation for
pie-1.3.9.pharcarries, in its certificate's verification result:And in its DSSE payload:
Three independent fields name the tag. PIE reads none of them.
Release-discovery paths the attacker can influence
SelfUpdateCommand::execute:123-156resolves the URL to fetch:Nightly channel. The URL is hardcoded.
pie-nightly.pharis served from thephp.github.ioGitHub Pages site fed by thephp/pierepo. Whoever can push to that Pages source can swap the asset. Any historical attestedpie.phar(e.g., from a 1.3.9 release) passes attestation verification because the file name and thephpowner are all that's enforced.Stable / preview channel. The fetcher calls
latestReleaseMetadatawhich queriesapi.github.com/repos/php/pie/releases, picks the newest entry, and uses itsassets[].browser_download_url. The asset URL is editable per-release by anyone with write tophp/pie. Re-pointing a recent release'spie.pharasset at the bytes of an older release is a one-click change in the GitHub UI. TheReleaseIsNewer::forChannelcheck runs against$latestRelease->tag, not against the digest, so as long as the JSON'stag_nameis monotonic, the swap is invisible to PIE.In both cases, the prerequisite is some form of write access to PIE's release-distribution surface (Pages source, release assets), or a transient compromise of that surface. Neither is in PIE's direct control. The bug under audit is that PIE doesn't make the surface failure-evident by checking the attestation's ref/tag against what the JSON declares.
Why
ReleaseIsNewerdoesn't helpsrc/SelfManage/Update/ReleaseIsNewer.php:30-87enforces tag-level monotonicity:Semver::satisfies($newVersion, '> ' . $currentPieVersion). This is a check against the release JSON's claimed tag, not against the attestation's ref. If the JSON saystag_name=1.5.10but the asset bytes were built fromrefs/tags/1.4.4, the semver check passes (1.5.10 > current) and the attestation passes (1.4.4 bytes have valid attestation underphp/). Bytes get installed.Why PIE-002 is amplified by this gap
If PIE-002 (TOCTOU on self-update) gets fixed in a 1.4.5 release, an attacker who can deliver 1.4.4 bytes through either release-discovery path re-exposes the user to PIE-002. Same shape for PIE-001 (sudo rm) and any future PIE CVE: as long as historical PHARs remain attested, this gap is the rollback wedge.
Variant:
pie self-verifycan't bind to a version eitherSelfVerifyCommand::execute(src/Command/SelfVerifyCommand.php:50-70) walks the same code path with the same gap:The output reads "You are running an authentic PIE version X" where X is
PieVersion::get(), the version baked into the running PHAR. The verifier never compares X against the attestation's ref. So apie.pharswapped on disk to a different attested release still passesself-verify; the command prints the swapped PHAR's self-claimed version and a green checkmark.The misleading bit is the output text: it implies the command verified "version X" specifically. What it verified is "this artifact is in the set of all artifacts ever attested under the
php/org." That set includes every historical PIE release.self-verifyis therefore not a useful tamper-detection tool against the threat model "an attacker swapped my pie.phar with a different php-attested pie.phar," which is the exact threat model PIE-003's main finding describes.Honest behaviour for the current implementation would be to drop the version from the output. The right fix is to bind to an expected ref, which means asking the user (or
api.github.com) what version they expected and asserting against that.PoC
Three runs against real PIE releases, using PIE's actual
ghinvocation and the proposed fix:Run 1. PIE's exact invocation against the old PHAR:
The old, 2-month-stale PHAR passes. Same exit code as the latest PHAR.
Run 2. Same invocation but hardened with
--source-ref(the proposed fix), asking for1.4.4while the bytes are from1.3.9:The downgrade is caught. The error message reads the attestation's bound ref directly.
Run 3. Sanity check that the hardened invocation doesn't false-positive against a matching PHAR:
Each genuine ref-vs-bytes pairing passes; the mismatch is the only thing that fails.
The OpenSSL fallback is equivalent on the source side: the upstream verifier (
vendor/thephpf/attestation/src/Verification/VerifyAttestationWithOpenSsl.php) checks only the three OIDs defined inFulcioSigstoreOidExtensions.php, none of which name a ref. An OpenSSL-path PoC would require staging a local trusted-root and stubbing the attestation HTTP call, but the static-source claim is unambiguous: there is no code path that compares the cert'ssourceRepositoryRefagainst the expected tag.Verified on:
5c3fa89c5f63728f60fda04756b4044c01855ff7(1.5.x)php/pieattestationsImpact
Vulnerability class: Insufficient verification of data authenticity (CWE-345); rollback / downgrade attack against a signed-update flow.
Capability granted: An attacker who can substitute the bytes served at
https://php.github.io/pie/pie-nightly.phar(nightly channel) or thebrowser_download_urlreturned byapi.github.com/repos/php/pie/releases(stable / preview channel) can deliver an older legitimately-attestedpie.pharto any user runningpie self-update. Such substitution requires write access to the Pages source or to a release's assets; both are within reach of a transientphp/piemaintainer compromise, a GH-account hijack, a Pages-source repository compromise, or a CDN/cache poisoning event upstream of the user.The rolled-back PIE is whatever historical release the attacker delivers, including any release with a known CVE. PIE-001 (sudo-elevated rm) and PIE-002 (TOCTOU self-update) both ship in 1.4.4 and earlier. Until the user installs a fully-patched version, both remain exploitable. The rollback is sticky: once an older PIE is installed, the user has to manually notice their version regressed and pull a newer release.
Affected versions: All PIE releases through
1.4.4and the1.5.xdevelopment branch up to commit5c3fa89c5f63728f60fda04756b4044c01855ff7. The--owner=php-only invocation has been in place since the self-update verification path landed; no prior fix exists.Affected configurations: Default. Triggered by
pie self-updateon any channel. Nightly is more exposed because the URL is fixed and Pages publish is a single workflow run; stable/preview requires release-asset substitution, which is auditable via GitHub but not enforced by PIE.Affected users: Anyone running
pie self-updateagainst a PIE release distribution surface they don't fully trust to remain pinned to its original bytes. In practice this includes every PIE user, because the distribution surface is operated byphp/piemaintainers and the bug fails open to "any historical attestation."Required user action: Run
pie self-update. The verification message reads as normal because the attestation verifies cleanly; nothing surfaces the ref mismatch.Fix direction
Bind the verifier to the release the user asked for. Two changes, one per verifier path:
ghpath. Add--source-ref(or--source-tagoncegh2.x exposes that flag explicitly) to the invocation atsrc/SelfManage/Verify/GithubCliAttestationVerification.php:48:gh attestation verifyalready extracts the bound ref and prints a precise error when it mismatches. Nightly needs a separate strategy because there's no "tag": bind it torefs/heads/<default-branch>instead, or to the workflow path via--signer-workflow=php/pie/.github/workflows/build-phar.yml.OpenSSL fallback. Add a fourth Fulcio OID (
SOURCE_REPOSITORY_REF, 1.3.6.1.4.1.57264.1.14) toFulcioSigstoreOidExtensions.php, pass the expectedrefs/tags/<tag>through the existing$extensionsToVerifymechanism inFallbackVerificationUsingOpenSsl::verify. The DER-decoding code atassertCertificateExtensionClaimsalready handles arbitrary OID extensions, so the wiring is small. Alternatively, parsepredicate.buildDefinition.resolvedDependencies[0].uriin the DSSE payload and assert it starts withgit+https://github.com/php/pie@refs/tags/.self-verifycommand. Accept an optional version argument from the user and bind to it:pie self-verify 1.5.0verifies the running PHAR is genuinely the 1.5.0 release. Drop in--source-ref refs/tags/$argumentto the underlyingghinvocation; reject the run if no argument is supplied, or fall back to "I'll fetch the latest stable ref fromapi.github.comand check against that." The current behaviour, accepting any attested PIE while printing whatever version the running PHAR self-claims, is worse than no command at all because the green checkmark gives users false confidence.Concretely at
src/Command/SelfVerifyCommand.php:42:The output text should reference
$expectedrather than the version baked into the running PHAR. Once that's done, a swapped older PHAR fails the verify step because the attestation's ref won't match the user-supplied expectation.As independent hardening, persist the last-installed PIE digest in
Settings(already used for the channel choice) and refuse to install a release whose digest matches any historical digest in the persistence file. This closes the rollback even if both verifier paths above are bypassed.For the nightly URL specifically, consider rotating away from a fixed-name asset. A signed manifest published alongside
pie-nightly.phar(e.g.pie-nightly.manifest.jsonwith the expected digest and ref) lets PIE assert the bytes are what the publish workflow intended, even without a Sigstore round-trip.