From 090d2b6565fe899b309cc9d645ef5cfeb923c52a Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 19 Aug 2026 18:23:57 +0200 Subject: [PATCH 1/3] fix(tools): keep the hash generator on the endpoint it means to ask about A scanner flagged the `urlopen` call in `pin_hashes.py` and named a `file://` URL reading a local file as the risk. That risk is not reachable here, and the new tests prove it rather than asserting it: `API` is a literal https URL and both parts land in the path, so a version made of directory traversal, of an at-sign and a hostname, or of a literal file URL, all leave the scheme as https and the host as pypi.org. What was reachable is quieter, and is why this is a change rather than a dismissal. `version` is barred only from whitespace and semicolons, so a question mark or a hash in it truncated the path into a query or a fragment - a different endpoint, answering confidently about something else, into the file that gates the supply chain. Both parts are percent-encoded now, so such a version asks about a release that does not exist and fails loudly. The name is escaped too, although today's regex already bars a slash in it. A guard that holds only while a second, unrelated pattern keeps its current shape is a guard waiting to stop holding. Verified by regenerating requirements-lint.txt end to end: byte-identical to what is on disk, 413 hash lines. Co-Authored-By: Claude Opus 5 --- tests/test_pin_hashes_url.py | 72 ++++++++++++++++++++++++++++++++++++ tools/pin_hashes.py | 22 ++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/test_pin_hashes_url.py diff --git a/tests/test_pin_hashes_url.py b/tests/test_pin_hashes_url.py new file mode 100644 index 0000000..9916abf --- /dev/null +++ b/tests/test_pin_hashes_url.py @@ -0,0 +1,72 @@ +"""The tool that writes our supply-chain hashes cannot be talked off PyPI. + +`tools/pin_hashes.py` asks the PyPI JSON API what artefacts a pinned version has +and writes their digests into `requirements*.txt`. Those digests are what pip +refuses to install around, so an answer fetched from the wrong place would be a +lie the whole build then depends on. + +A scanner flags the `urlopen` call because it cannot see the shape of its argument, +and names `file://` as the risk. That specific risk is not reachable and this file +proves it rather than asserting it. The reachable one is quieter: `version` is only +barred from whitespace and semicolons, so before the fix a `?` or `#` in it turned +the path into a query or a fragment - a different endpoint, answering about +something else, silently. +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools")) + +from urllib.parse import urlsplit # noqa: E402 + +from fakes import check # noqa: E402 +from pin_hashes import _url # noqa: E402 + +# Every shape that has ever been suggested as an escape, plus the ordinary one. +HOSTILE = [ + "3.1.3", # the real thing, so this cannot pass on nothing + "../../../etc/passwd", + "..%2F..%2Fetc", + "x?a=b", + "x#frag", + "x@evil.com", + "/../..//", + "file:///c:/windows/win.ini", + "https://evil.example/pypi", +] + + +def test_no_version_can_move_the_request_off_pypi(): + """Scheme and host are fixed, whatever the version says.""" + for version in HOSTILE: + parts = urlsplit(_url("pydivert", version)) + check(f"scheme stays https for {version!r}", parts.scheme == "https", + f"({parts.scheme})") + check(f"host stays pypi.org for {version!r}", parts.netloc == "pypi.org", + f"({parts.netloc})") + + +def test_no_version_can_truncate_the_path_into_a_query(): + """The whole version stays one path segment - no query, no fragment. + + This is the half that was actually reachable: `1.0?x=y` used to ask PyPI a + different question and get a confident answer to it. + """ + for version in HOSTILE: + url = _url("pydivert", version) + parts = urlsplit(url) + check(f"no query for {version!r}", parts.query == "", f"({url})") + check(f"no fragment for {version!r}", parts.fragment == "", f"({url})") + check(f"the path still ends at /json for {version!r}", + parts.path.endswith("/json"), f"({parts.path})") + + +def test_the_name_is_escaped_too(): + """The regex bars a slash in a name today. The escaping does not rely on it. + + A guard that only holds while a second, unrelated pattern keeps its current + shape is a guard waiting to stop holding. + """ + parts = urlsplit(_url("../evil", "1.0")) + check("host survives a hostile name", parts.netloc == "pypi.org", f"({parts})") + check("the name stays one segment", parts.path.count("/") == 4, f"({parts.path})") diff --git a/tools/pin_hashes.py b/tools/pin_hashes.py index ecbeb0f..420011f 100644 --- a/tools/pin_hashes.py +++ b/tools/pin_hashes.py @@ -36,14 +36,34 @@ import re import sys import urllib.request +from urllib.parse import quote API = "https://pypi.org/pypi/%s/%s/json" PINNED = re.compile(r"^(?P[A-Za-z0-9._-]+)==(?P[^\s;\\]+)(?P.*)$") +def _url(name, version): + """The PyPI JSON endpoint for one exact version, with both parts escaped. + + 🔴 The escaping is not about the scheme. A scanner flags `urlopen` on a value it + cannot see the shape of, and the risk it names - a `file://` URL reading a local + file - is not reachable here: `API` is a literal `https://` and both parts land in + the PATH, after the authority. Measured across `../../etc/passwd`, `x@evil.com` + and a literal `file:///c:/windows` as the version: the scheme stays `https` and + the host stays `pypi.org` in every case. + + What IS reachable is quieter and worth closing anyway. `version` is only barred + from whitespace and semicolons, so `1.0?x=y` or `1.0#frag` used to truncate the + path into a query or a fragment - a DIFFERENT endpoint, answering about something + else, and this file writes the hashes that gate the supply chain. Escaped, such a + version asks about a release that does not exist and fails loudly instead. + """ + return API % (quote(name, safe=""), quote(version, safe="")) + + def artefact_hashes(name, version, timeout=30): """Every sha256 on PyPI for that exact version, newest artefact last.""" - with urllib.request.urlopen(API % (name, version), timeout=timeout) as response: + with urllib.request.urlopen(_url(name, version), timeout=timeout) as response: payload = json.load(response) digests = [f["digests"]["sha256"] for f in payload.get("urls", [])] if not digests: From 31798c47d5b13c4c4190af4f1802cb8a66afc425 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 19 Aug 2026 18:24:27 +0200 Subject: [PATCH 2/3] feat(release): sign the release on the machine that holds the card The signing key lives on a cryptographic card in a USB reader and cannot be exported - that is what the certificate was bought for. No GitHub-hosted runner can reach it, and a self-hosted one is a machine strangers can aim a pull request at in a public repository. So the build happens where builds belong and the signature happens where the card is, in three phases. The tag workflow builds, smoke-tests, writes the SBOM, attests the provenance of the UNSIGNED archive, opens the release as a DRAFT carrying only the SBOM, and hands the build over as a workflow artefact. It publishes no archive and writes no checksum: those are not the bytes a user gets, and an unsigned executable on a public release page - for as long as the ritual takes - is a file somebody downloads. `tools/sign_release.py` then fetches that artefact and VERIFIES ITS PROVENANCE before touching it, because signing what you did not check is how a supply chain acquires a signature. It signs with an RFC 3161 timestamp, since without one the signature dies when the certificate expires a year from now. Then it reads the certificate back out of the file it just signed and refuses to upload anything unless it hashes to the pin in legal.py - a renewal, a test certificate, one from another project would otherwise sign a release under this project's name and the page would look identical. `--dry-run` does everything except sign, upload and dispatch. `attest-release.yml` finishes it, and downloads the archive rather than trusting the digest it was handed: everything it attests is then about bytes it holds, which is the difference between an attestation and a rumour. The digest it was dispatched with is kept as a cross-check. It binds the SBOM to the signed file - the binding used to be made at build time, and the signature changes the bytes, so that statement now verifies against nothing. It does not claim build provenance: a person signed that file on their own machine. The certificate is pinned by the sha256 of its DER bytes, the same shape as WINDIVERT_SHA256 and for the same reason: "signed" is a claim, "signed by THIS certificate" is a measurement. signtool selects by SHA-1 thumbprint, so the script resolves the pinned digest to that thumbprint in the Windows store rather than carrying two constants that can disagree. Verified as far as it can be without the card's PIN: the resolver finds the certificate in the store and the newest SDK signtool, both workflows parse, and three mutations are caught - the draft shipping the unsigned archive, the script no longer checking which certificate signed, and the attestation taking a digest instead of the file. The first version of the draft guard read only the `gh release create` line while `--draft` was assembled in an array above it; widening it to the whole step is what made it able to fail. Both READMEs now say the executable is signed from 0.5.0, what that does not buy (a new certificate has no SmartScreen reputation yet), and which of the three statements answers which question. Co-Authored-By: Claude Opus 5 --- .github/workflows/attest-release.yml | 96 +++++++++++ .github/workflows/release.yml | 95 ++++------- CHANGELOG.md | 7 + README.md | 20 ++- README.pl.md | 22 ++- beantester/legal.py | 20 +++ tests/test_mutation_registry.py | 45 ++++- tests/test_version_and_release.py | 212 ++++++++++++++++-------- tools/sign_release.py | 239 +++++++++++++++++++++++++++ 9 files changed, 606 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/attest-release.yml create mode 100644 tools/sign_release.py diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml new file mode 100644 index 0000000..db93423 --- /dev/null +++ b/.github/workflows/attest-release.yml @@ -0,0 +1,96 @@ +# The second half of a release, after a person has signed it. +# +# The build workflow cannot sign: the key lives on a cryptographic card in a USB +# reader and cannot be exported, so `tools/sign_release.py` signs on the maintainer's +# machine and then dispatches this. Here the signed archive gets the statement that +# travels with it, and the release is still a draft when this finishes. +# +# 🔴 It DOWNLOADS the archive rather than trusting the digest it was handed. Everything +# attested here is then a statement about bytes this job is holding, which is the whole +# difference between an attestation and a rumour. The digest input is kept as a +# cross-check: if it disagrees with the file on the release, something moved between +# signing and publishing and the run stops. +# +# What it does NOT do: build provenance. That belongs to the workflow that actually +# built something, and it is made there, over the unsigned build. Claiming here that +# this workflow produced a file a person signed on their own machine would be false in +# the one document nobody should have to doubt. +name: Attest a signed release + +on: + workflow_dispatch: + inputs: + tag: + description: "The release tag, e.g. v0.5.0-rc.2" + required: true + digest: + description: "sha256 of the signed archive, as sign_release.py printed it" + required: true + +permissions: + contents: read + +jobs: + attest: + name: Attest the signed archive + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # Reading the draft's assets and uploading the bundle back to it. + contents: write + # `id-token` mints the short-lived OIDC token that signs the attestation, + # `attestations` writes the result to the repository's attestation store. + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Fetch what the maintainer signed + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + CLAIMED: ${{ inputs.digest }} + run: | + mkdir -p signed && cd signed + gh release download "$TAG" --pattern '*.zip' --pattern '*.spdx.json' + archive="$(ls *.zip)" + actual="$(sha256sum "$archive" | cut -d' ' -f1)" + echo "release carries: $actual" + if [ "$actual" != "$CLAIMED" ]; then + echo "::error::the archive on the release hashes to $actual, but the run was" + echo "::error::dispatched for $CLAIMED - something changed in between" + exit 1 + fi + { + echo "ARCHIVE=signed/$archive" + echo "SBOM=signed/$(ls *.spdx.json)" + echo "BUNDLE=BeanNetworkTester-${TAG}.sigstore.json" + } >> "$GITHUB_ENV" + + # The SBOM, bound to the file a user actually downloads. Before the signature + # moved to a card this binding was made at build time; now it is made here, + # because the signature changes the bytes and a statement about the wrong bytes + # verifies against nothing. + - name: Attest the SBOM against the signed archive + id: attestation + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ env.ARCHIVE }} + sbom-path: ${{ env.SBOM }} + + # 🔴 As an ASSET, not only in the attestation store. Scorecard's Signed-Releases + # check reads release assets by file extension and never opens that store, and a + # user with no route to the API cannot use it either. `gh attestation verify + # --bundle ` answers offline, from a mirror, from anywhere. + - name: Publish the bundle beside the archive + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + BUNDLE_PATH: ${{ steps.attestation.outputs.bundle-path }} + run: | + cp "$BUNDLE_PATH" "$BUNDLE" + python -c "import json,sys; json.load(open(sys.argv[1])); print('bundle parses as JSON')" "$BUNDLE" + gh release upload "$TAG" "$BUNDLE" --clobber + echo "attached $BUNDLE to $TAG (still a draft)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87ffeb1..77fbe62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -122,14 +122,17 @@ jobs: test "$v" = "0" || { echo "--version failed"; exit 1; } test "$r" = "0" || { echo "simulated run failed"; exit 1; } - # Zip the onedir bundle and write the checksum the README tells users to verify. - - name: Package (zip + SHA-256) + # Zip the onedir bundle. 🔴 No SHA256SUMS.txt here any more: these bytes are not + # the bytes a user downloads. The executable inside is still unsigned, and + # `tools/sign_release.py` signs it on the machine that holds the card - which + # changes the archive, and would leave a checksum written here describing a file + # nobody has. The checksum is written next to the signature, over the same bytes. + - name: Package (zip) shell: bash run: | name="BeanNetworkTester-${GITHUB_REF_NAME}-windows-x64" ( cd dist && 7z a "../${name}.zip" BeanNetworkTester >/dev/null ) - sha256sum "${name}.zip" > SHA256SUMS.txt - cat SHA256SUMS.txt + sha256sum "${name}.zip" echo "ASSET=${name}.zip" >> "$GITHUB_ENV" # The SBOM is generated from `beantester/legal.py`, the reviewed list of what @@ -151,26 +154,6 @@ jobs: "BeanNetworkTester-${GITHUB_REF_NAME}.spdx.json" echo "SBOM=BeanNetworkTester-${GITHUB_REF_NAME}.spdx.json" >> "$GITHUB_ENV" - # Signs the SBOM against the zip a user downloads, so `gh attestation verify` - # can prove the pair belongs together. Without this the SBOM is a text file - # anybody could replace, which is the difference between a bill of materials - # and a note claiming to be one. - - name: Attest the SBOM against the release archive - # Was actions/attest-sbom until 2026-08-12. Its own README now says it is - # deprecated in favour of actions/attest and runs as a wrapper over it, and - # that "all of the existing action inputs are compatible" - checked against - # actions/attest's action.yml rather than taken on trust: `sbom-path` is - # there, with the same meaning, and providing it is what makes this an SBOM - # attestation rather than build provenance. So both inputs stay as they are. - # - # Pinned by SHA, not by tag. This is the first third-party-shaped action in - # the workflow that publishes the release, and a tag can be moved; the SHA - # below is what `v4.2.2` pointed at on 2026-08-12, read from the API. - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ env.ASSET }} - sbom-path: ${{ env.SBOM }} - # WHO built it, and FROM WHAT. The SBOM attestation above says what is inside # the zip; this one says the zip came out of this repository, from this commit, # through this workflow, on a GitHub-hosted runner - signed with the same @@ -195,37 +178,6 @@ jobs: with: subject-path: ${{ env.ASSET }} - # The same bundle, published as an ASSET rather than left only in the - # attestation store. Two reasons, one for a person and one for a scanner. - # - # For a person: the file travels with the archive it describes, so - # `gh attestation verify --bundle ` answers without asking an - # API - which is the difference between "GitHub says this is fine" and "these - # bytes say so". A mirror that copies the release page copies the proof too. - # - # 🔴 For a scanner: OpenSSF Scorecard's Signed-Releases check reads release - # assets BY FILE EXTENSION (`.sigstore.json`, `.asc`, `.sig`, `.intoto.jsonl`) - # and never looks in the attestation store. Read in probes/releasesAreSigned - # on 2026-08-19, after the check scored 0/10 on releases that already carried - # two attestations. Producing evidence nobody can find is the same as not - # producing it. - # - # The name says what the file IS: a Sigstore bundle, which is what the action - # writes. `.intoto.jsonl` would score two points higher there and would be a - # different format - not a rename. - - name: Publish the provenance bundle beside the archive - shell: bash - env: - # Through the environment, never interpolated into the script: `${{ }}` is - # expanded before a shell exists, so it is source code rather than an - # argument. Guarded by tests/test_repo_conventions.py. - BUNDLE_PATH: ${{ steps.provenance.outputs.bundle-path }} - run: | - bundle="BeanNetworkTester-${GITHUB_REF_NAME}.sigstore.json" - cp "$BUNDLE_PATH" "$bundle" - python -c "import json,sys; json.load(open(sys.argv[1])); print('bundle parses as JSON')" "$bundle" - echo "BUNDLE=$bundle" >> "$GITHUB_ENV" - # gh is preinstalled on the runner - no third-party action, uses the job token. # A -rc/-beta/-alpha tag publishes as a "Pre-release"; a plain tag as "Latest". # @@ -235,14 +187,39 @@ jobs: # would have been 43 PR titles. Extracting the section instead means the release # page, a blog post and CHANGELOG.md are one text that cannot drift apart. # The step above has already proved that section exists and is dated. - - name: Publish the GitHub Release + - name: Open the release as a DRAFT env: GH_TOKEN: ${{ github.token }} shell: bash run: | python tools/release_notes.py > RELEASE_NOTES.md head -5 RELEASE_NOTES.md - flags=(--title "$TITLE" --notes-file RELEASE_NOTES.md) + flags=(--draft --title "$TITLE" --notes-file RELEASE_NOTES.md) if [ "$PRERELEASE" = "true" ]; then flags+=(--prerelease); else flags+=(--latest); fi - gh release create "$GITHUB_REF_NAME" \ - "$ASSET" SHA256SUMS.txt "$SBOM" "$BUNDLE" "${flags[@]}" + gh release create "$GITHUB_REF_NAME" "$SBOM" "${flags[@]}" + + # 🔴 The build leaves as an ARTEFACT, not as a release asset, and the reason is + # that it is not finished. The executable inside is unsigned, and this runner + # cannot sign it: the key lives on a cryptographic card in a USB reader on the + # maintainer's desk and cannot be exported. Attaching it here would put an + # unsigned file on a public release page for as long as the ritual takes, and + # somebody would download it. + # + # The name is what `tools/sign_release.py` fetches. + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unsigned-build-${{ github.ref_name }} + path: ${{ env.ASSET }} + retention-days: 7 + + - name: Say what happens next + shell: bash + run: | + echo "Built, attested, and opened as a DRAFT release." + echo + echo "Now, on the machine with the card:" + echo " python tools/sign_release.py ${GITHUB_REF_NAME}" + echo + echo "That signs the executable, checks it was the pinned certificate that" + echo "signed it, uploads the archive and its checksum, and asks" + echo "attest-release.yml for the bundle. Then read the draft and publish it." diff --git a/CHANGELOG.md b/CHANGELOG.md index b89151a..5e0d490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol Accents are optional: `opoznienie` finds `Opóźnienie`. A setting that lives in the Settings window is named rather than missed. +- **The download is signed.** From this version the executable carries a code-signing signature, so + Windows names the publisher instead of saying "Unknown publisher". The key lives on a hardware + card, so the signing is done by hand and the release page fills in two steps. Know what it does + not buy: a new certificate has no SmartScreen reputation yet, so a warning can still appear for a + while - it now says who signed the file. The attestation you check with `--bundle` is made after + signing, over the exact bytes you download. + - **You can now check where a download came from, not just that it is unchanged.** Every release archive carries a signed build attestation, so one command answers "was this really built from that source": diff --git a/README.md b/README.md index 6f042fa..b9cb8c6 100644 --- a/README.md +++ b/README.md @@ -1468,11 +1468,14 @@ and only when you click the corresponding button yourself. ## A note on SmartScreen and antivirus -The .exe is not (yet) signed with a certificate, and at the same time it asks for administrator -rights and loads a network driver - so Windows SmartScreen may show an "Unknown publisher" warning, -and some antivirus tools may raise a false alarm. The **WinDivert driver itself is digitally signed -by its author**. You can compare the release's SHA-256 checksum (`SHA256SUMS.txt`) to confirm the -file has not been modified. +**From 0.5.0 the .exe is signed**, with a certificate whose private key lives on a hardware card +that never leaves the maintainer's desk - so Windows names the publisher instead of saying "Unknown +publisher". Be aware of what that does and does not buy you: a new certificate has no SmartScreen +reputation yet, so a warning can still appear for a while, and some antivirus tools may still raise +a false alarm about a program that asks for administrator rights and loads a network driver. What +changes is that the warning now says who signed it. The **WinDivert driver itself is digitally +signed by its author**. You can still compare the release's SHA-256 checksum (`SHA256SUMS.txt`) to +confirm the file arrived unchanged. **You can also check where the download came from, not just that it is unchanged.** Every release archive carries a signed build attestation, so one command answers "was this really built from that @@ -1496,6 +1499,13 @@ gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip --bundle BeanNetw Useful if you got the files from a mirror, or from a machine that cannot reach the API - the evidence travelled with the download instead of living somewhere you have to trust separately. +Three statements, and it is worth knowing they answer different questions. The **signature** says +who stands behind the file. The **bundle above** binds this exact archive to its bill of materials, +and it is made after signing, over the bytes you downloaded. The **build provenance** says which +commit and which workflow produced the build that was then signed - it necessarily describes the +unsigned build, because signing changes the bytes, and it lives in this repository's attestation +store rather than in the download. + ### What is inside the download, and how to check it Every release carries an **SBOM** - a list, in the standard SPDX format, of every third-party diff --git a/README.pl.md b/README.pl.md index b899e37..031da82 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1329,12 +1329,16 @@ odpowiedni przycisk. ## Uwaga: SmartScreen i antywirusy -Plik .exe nie jest (jeszcze) podpisany certyfikatem, a jednocześnie prosi o -uprawnienia administratora i ładuje sterownik sieciowy - dlatego Windows -SmartScreen może pokazać ostrzeżenie „Nieznany wydawca”, a niektóre antywirusy -mogą zgłosić fałszywy alarm. Sam sterownik **WinDivert jest podpisany cyfrowo -przez jego autora**. Sumę kontrolną SHA-256 wydania (`SHA256SUMS.txt`) możesz -porównać, żeby potwierdzić, że plik nie został zmodyfikowany. +**Od 0.5.0 plik .exe jest podpisany** certyfikatem, którego klucz prywatny siedzi +na karcie kryptograficznej i nigdy z niej nie wychodzi - więc Windows pokazuje +nazwę wydawcy zamiast „Nieznany wydawca”. Warto wiedzieć, czego to nie załatwia: +świeży certyfikat nie ma jeszcze reputacji w SmartScreenie, więc ostrzeżenie może +przez jakiś czas nadal się pojawiać, a niektóre antywirusy nadal mogą zgłosić +fałszywy alarm przy programie, który prosi o uprawnienia administratora i ładuje +sterownik sieciowy. Zmienia się to, że ostrzeżenie mówi teraz, kto go podpisał. +Sam sterownik **WinDivert jest podpisany cyfrowo przez jego autora**. Sumę +kontrolną SHA-256 wydania (`SHA256SUMS.txt`) nadal możesz porównać, żeby +potwierdzić, że plik dotarł bez zmian. **Możesz też sprawdzić, skąd ten plik pochodzi, a nie tylko czy się nie zmienił.** Każde archiwum wydania niesie podpisaną atestację builda, więc jedno polecenie odpowiada na pytanie „czy to @@ -1358,6 +1362,12 @@ gh attestation verify BeanNetworkTester-v0.5.0-windows-x64.zip --bundle BeanNetw Przydaje się, gdy pliki masz z kopii lustrzanej albo na maszynie bez dostępu do API - dowód przyjechał razem z pobranym plikiem, zamiast leżeć w miejscu, któremu trzeba osobno ufać. +Trzy oświadczenia, i warto wiedzieć, że odpowiadają na różne pytania. **Podpis** mówi, kto za tym +plikiem stoi. **Powyższy bundle** wiąże dokładnie to archiwum z jego listą składników i powstaje +już po podpisaniu, nad bajtami, które pobrałeś. **Prowenancja builda** mówi, z którego commita i +którym workflow powstał build, który potem podpisano - z konieczności opisuje wersję niepodpisaną, +bo podpis zmienia bajty, i leży w magazynie atestacji tego repozytorium, a nie w pobranym pliku. + ### Co jest w środku pobranego pliku i jak to sprawdzić Każde wydanie niesie **SBOM** - listę, w standardowym formacie SPDX, wszystkich diff --git a/beantester/legal.py b/beantester/legal.py index 1475ac6..bab72a0 100644 --- a/beantester/legal.py +++ b/beantester/legal.py @@ -40,6 +40,26 @@ "WinDivert64.sys": "8da085332782708d8767bcace5327a6ec7283c17cfb85e40b03cd2323a90ddc2", } +# The certificate the shipped executable is signed with, as the sha256 of its DER +# bytes (recorded 2026-08-19). Public information by construction: a certificate is +# the half of the pair that travels inside every signed file. The private key lives +# on a hardware card, cannot be exported, and is why the signing step is a person at +# a keyboard rather than a runner. +# +# 🔴 Why a pin and not a subject name: "signed" is a claim, "signed by THIS +# certificate" is a measurement. `tools/sign_release.py` refuses to publish when the +# certificate that actually signed the file hashes to something else, so a second +# code-signing certificate on the same machine - a renewal, a test one, one from +# another project - cannot quietly sign a release under this project's name. +# +# The signing tool selects a certificate by its SHA-1 thumbprint, which is what +# `signtool /sha1` takes; the script resolves this digest to that thumbprint in the +# Windows store rather than carrying two constants that can disagree. +# +# It expires 2027-08-19. A renewal issues a NEW certificate, so this digest moves +# with it - and the guard failing on that day is the point, not an inconvenience. +CODESIGN_SHA256 = "47b79ad3cfa53ef846cad03a59148f8c981d0b1196891e48b8c8d7982b10c148" + # The components we ship, in the order a reader cares about. ``module`` is the # import name used to report the real version at run time (None = not a Python # package, so the version is fixed or reported by other means). diff --git a/tests/test_mutation_registry.py b/tests/test_mutation_registry.py index 529a0ad..163b2c1 100644 --- a/tests/test_mutation_registry.py +++ b/tests/test_mutation_registry.py @@ -1048,15 +1048,44 @@ "test": "test_a_term_may_not_end_in_an_escape_that_swallows_the_separator", }, { - # Evidence that exists and cannot be found is evidence nobody has. The - # attestation stayed in GitHub's store, where the archive's own readers - - # a person offline, a mirror, a scanner reading assets by extension - never - # look. - "label": "release: the provenance bundle stops shipping as an asset", + # The escaping removed as "noise" - and the tool that writes our supply-chain + # hashes can again be pointed at a different PyPI endpoint by a `?` in a + # version string, silently answering about something else. + "label": "supply chain: the hash generator stops escaping what it asks about", + "file": "tools/pin_hashes.py", + "old": 'return API % (quote(name, safe=""), quote(version, safe=""))', + "new": "return API % (name, version)", + "test": "test_no_version_can_truncate_the_path_into_a_query", + }, + { + # The simplification that puts an UNSIGNED executable on a public release + # page for as long as the signing ritual takes. It looks like tidying: the + # archive is right there, why not attach it. + "label": "release: the draft ships the unsigned archive after all", "file": ".github/workflows/release.yml", - "old": '"$ASSET" SHA256SUMS.txt "$SBOM" "$BUNDLE"', - "new": '"$ASSET" SHA256SUMS.txt "$SBOM"', - "test": "test_the_provenance_bundle_ships_as_a_release_asset", + "old": 'gh release create "$GITHUB_REF_NAME" "$SBOM" "${flags[@]}"', + "new": 'gh release create "$GITHUB_REF_NAME" "$ASSET" "$SBOM" "${flags[@]}"', + "test": "test_the_release_never_publishes_an_unsigned_archive", + }, + { + # "Signed" going back to being a claim instead of a measurement. A second + # code-signing certificate on the same machine would then sign a release + # under this project's name and nothing would say so. + "label": "release: the signing script stops checking WHICH certificate signed", + "file": "tools/sign_release.py", + "old": " if actual != CODESIGN_SHA256:", + "new": " if actual == CODESIGN_SHA256:", + "test": "test_the_signing_certificate_is_pinned_by_its_bytes", + }, + { + # The tempting shortcut in the attestation half: it was HANDED a digest, so + # why download the file. Because then it attests something nobody checked - + # a rumour with a signature on it. + "label": "attestation: the signed release is attested from a digest, not the file", + "file": ".github/workflows/attest-release.yml", + "old": " subject-path: ${{ env.ARCHIVE }}", + "new": " subject-digest: sha256:${{ inputs.digest }}", + "test": "test_the_signed_archive_is_attested_over_bytes_the_job_holds", }, { # The one job here that costs money per run, and the line that decides whether diff --git a/tests/test_version_and_release.py b/tests/test_version_and_release.py index 6e1ac44..4017860 100644 --- a/tests/test_version_and_release.py +++ b/tests/test_version_and_release.py @@ -490,82 +490,150 @@ def test_the_downloads_tool_refuses_anything_that_is_not_owner_slash_name(): "" if rejected else f"({reason})") -def test_the_release_attests_exactly_the_archive_it_publishes(): - """Two attestations, one subject, and the subject is the download. - - A release carries two signed statements about the zip: an SBOM attestation - (what is inside it) and a build-provenance attestation (which repository, - commit and workflow produced it). Both are worth nothing if they name a - different file from the one `gh release create` uploads, and that mismatch is - invisible on the release page - the attestation store simply ends up holding a - statement about a digest nobody downloads. - - So this pins the shape rather than the wording: every `subject-path` in the - workflow names the same variable the publish step uploads, and both actions - are still there. +def test_the_release_never_publishes_an_unsigned_archive(): + """The build workflow opens a DRAFT and hands the archive over. It does not ship it. + + 🔴 The reason is that the build is not finished when the build workflow ends. The + executable inside is unsigned, and this runner cannot sign it: the key lives on a + cryptographic card in a USB reader and cannot be exported, which is the whole + value of it. So the archive leaves as a workflow ARTEFACT, `tools/sign_release.py` + signs it where the card is, and the release stays a draft until a person looks at + it. + + What this guards is the shape of that split, because every piece of it is one line + somebody could "simplify" back into a single publish step: + + * `gh release create` must pass `--draft`, and must NOT carry the archive. An + unsigned executable on a public release page, for as long as the ritual takes, + is a file somebody downloads; + * the archive must leave as an artefact instead, under the name the signing script + fetches - a rename here strands the ritual with a clear-looking error much later; + * no `SHA256SUMS.txt` is written here. These are not the bytes a user gets, and a + checksum describing a file nobody has is worse than none. + + The provenance attestation stays, over the unsigned build, because that is the one + thing this workflow can honestly say: it built these bytes from this commit. """ - import re - with open(os.path.join(ROOT, ".github", "workflows", "release.yml"), - encoding="utf-8") as handle: - text = handle.read() + path = os.path.join(ROOT, ".github", "workflows", "release.yml") + with open(path, encoding="utf-8") as handle: + lines = handle.read().splitlines() + code = [ln.split("#", 1)[0] for ln in lines] + body = "\n".join(code) - subjects = re.findall(r"subject-path:\s*(\S.*?)\s*$", text, re.MULTILINE) - check("both attestations name a subject", len(subjects) == 2, f"({subjects})") - check("both attest the same file", len(set(subjects)) == 1, f"({subjects})") - - # The command spans lines: match it through its backslash continuations, or the - # asset list looks empty and every check below passes on nothing. - publish = re.search(r"gh release create(?:[^\n]*\\\n)*[^\n]*", text) - check("the workflow publishes a release", publish is not None) - uploaded = publish.group(0) if publish else "" - # `subject-path: ${{ env.ASSET }}` against `gh release create ... "$ASSET" ...` - name = subjects[0].strip("${} ").replace("env.", "").strip() - check(f"the attested subject ({name}) is what gets uploaded", - ("$" + name) in uploaded or ("${" + name + "}") in uploaded, - f"({uploaded[:120]})") - - for action in ("actions/attest@", "actions/attest-build-provenance@"): - check(f"{action} is still in the release workflow", action in text) - - -def test_the_provenance_bundle_ships_as_a_release_asset(): - """An attestation nobody can find is an attestation nobody has. - - Both attestations went into GitHub's attestation store and nowhere else, which - is enough for `gh attestation verify -R ` and not enough for two - other readers: - - * a person holding the download and no network path to that API, or a mirror - that copied the release page - the proof has to travel with the archive; - * 🔴 OpenSSF Scorecard, whose Signed-Releases check reads release assets BY - FILE EXTENSION and never opens the attestation store. Measured 2026-08-19: - the check scored 0/10 on releases that already carried two attestations. - `probes/releasesAreSigned` accepts `.asc`, `.minisig`, `.sig`, `.sign`, - `.sigstore` and `.sigstore.json`; `probes/releasesHaveProvenance` accepts - `.intoto.jsonl` and nothing else. - - So the bundle is copied to a named asset and uploaded. The extension is part of - what this guards: renaming it to `.intoto.jsonl` would score two points higher - and would be a lie about the format, because the action writes a Sigstore - bundle. + # The WHOLE step, not just the `gh release create` line: the flags are assembled + # in an array above it, so a guard reading one line reads the wrong thing - and + # would have passed while `--draft` was missing. + import re + step = re.search(r"- name: Open the release as a DRAFT.*?(?=\n - |\Z)", + body, re.S) + check("the workflow still opens a release", step is not None) + opening = step.group(0) if step else "" + check("it opens it as a draft", "--draft" in opening, f"({opening[-200:]})") + created = re.search(r"gh release create(?:[^\n]*\\\n)*[^\n]*", opening) + created = created.group(0) if created else "" + check("it does not publish the archive", "$ASSET" not in created, + f"(an unsigned executable would sit on a public page: {created[:160]})") + check("it writes no checksum over bytes nobody downloads", + "SHA256SUMS.txt" not in body, + "(the checksum belongs next to the signature, over the same bytes)") + + check("the build leaves as an artefact", + "actions/upload-artifact@" in body, "(the signing script fetches it)") + check("under the name the signing script fetches", + "unsigned-build-" in body and "unsigned-build-%s" in + _read_text(os.path.join(ROOT, "tools", "sign_release.py")), + "(rename this on one side only and the ritual strands)") + + check("build provenance is still attested here", + "actions/attest-build-provenance@" in body, + "(this workflow DID build these bytes - that claim is true and worth making)") + + +def test_the_signed_archive_is_attested_over_bytes_the_job_holds(): + """The second half of a release: a statement about the file a user downloads. + + The signature changes the bytes, so a statement made at build time verifies + against nothing afterwards. `attest-release.yml` makes it after the signing, + which raises the question this guards: how does a workflow attest something a + person made on their own machine without lying? + + 🔴 By downloading it. The job fetches the archive from the draft, so everything it + attests is about bytes it is holding - the difference between an attestation and a + rumour. The digest it was dispatched with is kept as a cross-check and the run + stops when the two disagree, which is what "something moved between signing and + publishing" looks like. + + Deliberately NOT here: build provenance. That belongs to the workflow that built + something, and claiming it in the one document nobody should have to doubt would + be false. + + The bundle is published as an ASSET, not left only in the attestation store, and + the extension is part of what this guards: OpenSSF Scorecard's Signed-Releases + check reads release assets by file extension and never opens that store (measured + 2026-08-19, `probes/releasesAreSigned`), and a user with no route to the API + cannot use the store either. """ + path = os.path.join(ROOT, ".github", "workflows", "attest-release.yml") + check("the attestation workflow exists", os.path.exists(path)) + if not os.path.exists(path): + return + with open(path, encoding="utf-8") as handle: + lines = handle.read().splitlines() + code = [ln.split("#", 1)[0] for ln in lines] + body = "\n".join(code) + + check("it is asked for, not automatic", "workflow_dispatch:" in body) + check("it fetches what was signed", "gh release download" in body) + check("it attests bytes it holds, not a digest it was told", + "subject-path:" in body and "subject-digest:" not in body, + "(attesting a digest nobody checked is a rumour with a signature on it)") + check("it cross-checks the digest it was dispatched with", + "CLAIMED" in body and "exit 1" in body) + check("it binds the SBOM to that same file", "sbom-path:" in body) + check("it does not claim to have built it", + "attest-build-provenance" not in body, + "(a person signed it on their own machine - saying otherwise is false)") + check("the bundle is published as an asset", + ".sigstore.json" in body and "gh release upload" in body) + + +def test_the_signing_certificate_is_pinned_by_its_bytes(): + """"Signed" is a claim. "Signed by THIS certificate" is a measurement. + + A second code-signing certificate on the same machine - a renewal, a test one, one + from another project - would sign a release just as happily, and the release page + would look identical. So the certificate is pinned by the sha256 of its DER bytes, + the signing script reads the certificate back OUT of the file it just signed, and + it refuses to upload anything when the two disagree. + + Same shape as `WINDIVERT_SHA256`, and for the same reason: a version, a subject + line or a file name is a label, and a digest is not. + + The certificate expires; a renewal issues a new one and this digest moves with it. + The guard failing on that day is the point. + """ + from beantester.legal import CODESIGN_SHA256 import re - with open(os.path.join(ROOT, ".github", "workflows", "release.yml"), - encoding="utf-8") as handle: - text = handle.read() - - check("the provenance step is addressable (it needs an id for its output)", - re.search(r"id:\s*provenance\b", text) is not None) - check("the bundle path is read from that step's output", - "steps.provenance.outputs.bundle-path" in text) - check("the bundle is named as a Sigstore bundle", - ".sigstore.json" in text, - "(the action writes a Sigstore bundle - the name has to say so)") - - publish = re.search(r"gh release create(?:[^\n]*\\\n)*[^\n]*", text) - uploaded = publish.group(0) if publish else "" - check("the bundle is uploaded with the other assets", - '"$BUNDLE"' in uploaded, f"({uploaded[:160]})") + check("the certificate is pinned as a sha256", + re.fullmatch(r"[0-9a-f]{64}", CODESIGN_SHA256) is not None, + f"({CODESIGN_SHA256[:24]}...)") + + script = _read_text(os.path.join(ROOT, "tools", "sign_release.py")) + check("the signing script reads the pin rather than carrying its own copy", + "from beantester.legal import CODESIGN_SHA256" in script) + check("it compares what actually signed the file against the pin", + "actual != CODESIGN_SHA256" in script) + check("and refuses without uploading anything", + "Nothing has been uploaded." in script, + "(a mismatch caught after the upload is not caught)") + check("it timestamps the signature", + "/tr" in script and "time.certum.pl" in script, + "(without a timestamp the signature dies when the certificate expires)") + + +def _read_text(path): + with open(path, encoding="utf-8") as handle: + return handle.read() def _check_every_requirement_carries_hashes(filename): diff --git a/tools/sign_release.py b/tools/sign_release.py new file mode 100644 index 0000000..f71c645 --- /dev/null +++ b/tools/sign_release.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Sign a release build with the hardware card, then hand it back to the workflow. + + python tools/sign_release.py v0.5.0-rc.2 + python tools/sign_release.py v0.5.0-rc.2 --dry-run # everything except sign/upload + +Why this exists as a step a person runs +--------------------------------------- +The signing key lives on a cryptographic card in a USB reader. It cannot be +exported - that is the whole value of it - so no GitHub-hosted runner can ever +reach it. A self-hosted runner could, and this is a PUBLIC repository, where a +self-hosted runner is a machine strangers can aim a pull request at. So the build +happens where builds belong and the signature happens where the card is, and this +script is the seam between them. + +What it does, in order, and what it refuses +------------------------------------------- +1. downloads the unsigned build the release workflow produced for this tag; +2. **verifies that build's provenance attestation** before touching it. Signing + something you did not check is how a supply chain gets a signature on it; +3. signs the executable with the card, with an RFC 3161 timestamp - **without a + timestamp the signature dies when the certificate expires**, and this one expires + after a year; +4. reads the certificate back OUT of the signed file and refuses to go on unless it + hashes to ``legal.CODESIGN_SHA256``. A second code-signing certificate on the + same machine - a renewal, a test one, one from another project - is exactly the + accident this catches; +5. repacks the archive, writes ``SHA256SUMS.txt`` over what it just made; +6. uploads both to the DRAFT release and asks the workflow to attest the signed + bytes, so the ``.sigstore.json`` a user verifies describes the file they hold. + +Nothing here publishes. The release stays a draft until a person looks at it and +presses the button. +""" +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import zipfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +from beantester.legal import CODESIGN_SHA256 # noqa: E402 + +REPO = "donislawdev/BeanNetworkTester" +ATTEST_WORKFLOW = "attest-release.yml" +TIMESTAMP_URL = "http://time.certum.pl/" + + +def run(argv, **kw): + """Run a command, echo it, and stop the ritual on a non-zero exit.""" + print(" $ %s" % " ".join(argv)) + result = subprocess.run(argv, text=True, capture_output=True, **kw) + if result.returncode != 0: + sys.stderr.write(result.stdout + result.stderr) + raise SystemExit("sign_release: '%s' failed (%d)" + % (argv[0], result.returncode)) + return result.stdout + + +def powershell(script): + """Run a PowerShell snippet, preferring pwsh 7 and falling back to 5.1. + + Convention 46: `pwsh` first everywhere, because 5.1 is the one that is merely + always present. The fallback stays because a machine may not have seven. + """ + for exe in ("pwsh", "powershell"): + if shutil.which(exe): + return run([exe, "-NoProfile", "-NonInteractive", "-Command", script]) + raise SystemExit("sign_release: neither pwsh nor powershell is on PATH") + + +def find_signtool(): + """The newest x64 signtool.exe from the Windows SDK, or a clear refusal.""" + kits = r"C:\Program Files (x86)\Windows Kits\10\bin" + found = [] + for version in sorted(os.listdir(kits)) if os.path.isdir(kits) else []: + candidate = os.path.join(kits, version, "x64", "signtool.exe") + if os.path.exists(candidate): + found.append(candidate) + if not found: + raise SystemExit( + "sign_release: no signtool.exe under %s - install the Windows SDK " + "('Windows SDK Signing Tools' is enough)" % kits) + return found[-1] + + +def signing_thumbprint(): + """The SHA-1 thumbprint of the certificate whose DER bytes hash to our pin. + + 🔴 Two digests, one source of truth. `signtool /sha1` selects by SHA-1 because + that is the only selector it takes; the repository pins SHA-256 because that is + the digest worth pinning. Resolving one to the other here means the two can + never drift apart in a config file. + """ + script = ( + "$out = @(); " + "Get-ChildItem Cert:\\CurrentUser\\My, Cert:\\LocalMachine\\My " + "-ErrorAction SilentlyContinue | ForEach-Object { " + " $h = [System.Security.Cryptography.SHA256]::Create()" + ".ComputeHash($_.RawData); " + " $out += [pscustomobject]@{ " + " sha256 = (($h | ForEach-Object { $_.ToString('x2') }) -join ''); " + " thumb = $_.Thumbprint; subject = $_.Subject; notAfter = $_.NotAfter } " + "}; $out | ConvertTo-Json -Compress" + ) + raw = powershell(script).strip() or "[]" + entries = json.loads(raw) + if isinstance(entries, dict): + entries = [entries] + for entry in entries: + if entry.get("sha256") == CODESIGN_SHA256: + print(" certificate: %s" % entry.get("subject", "").split(",")[0]) + print(" expires: %s" % entry.get("notAfter")) + return entry["thumb"] + raise SystemExit( + "sign_release: the pinned certificate (%s...) is not in the Windows store. " + "Plug in the card reader and check proCertum can see it; if the certificate " + "was renewed, legal.CODESIGN_SHA256 has to move with it." + % CODESIGN_SHA256[:16]) + + +def certificate_of(path): + """The sha256 of the certificate that actually signed ``path``.""" + script = ( + "$s = Get-AuthenticodeSignature -LiteralPath '%s'; " + "if ($s.Status -ne 'Valid') { Write-Error ('signature status: ' + $s.Status); exit 1 }; " + "$h = [System.Security.Cryptography.SHA256]::Create()" + ".ComputeHash($s.SignerCertificate.RawData); " + "(($h | ForEach-Object { $_.ToString('x2') }) -join '')" % path + ) + return powershell(script).strip() + + +def sha256_of(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("tag", help="the release tag, e.g. v0.5.0-rc.2") + parser.add_argument("--dry-run", action="store_true", + help="do everything except sign, upload and dispatch") + parser.add_argument("--work", default=os.path.join(ROOT, "build", "signing"), + help="scratch directory (default: build/signing)") + args = parser.parse_args(argv) + + if os.name != "nt": + raise SystemExit("sign_release: the card lives on Windows; run this there") + + work = os.path.join(args.work, args.tag) + if os.path.isdir(work): + shutil.rmtree(work) + os.makedirs(work) + print("working in %s" % work) + + print("\n[1/6] fetching the build this tag produced") + run(["gh", "run", "download", "--repo", REPO, "--name", + "unsigned-build-%s" % args.tag, "--dir", work]) + archives = [f for f in os.listdir(work) if f.endswith(".zip")] + if len(archives) != 1: + raise SystemExit("sign_release: expected one archive in the artefact, got %r" + % archives) + archive = os.path.join(work, archives[0]) + + print("\n[2/6] verifying what the workflow says it built") + run(["gh", "attestation", "verify", archive, "--repo", REPO]) + + print("\n[3/6] unpacking") + unpacked = os.path.join(work, "unpacked") + with zipfile.ZipFile(archive) as zf: + zf.extractall(unpacked) + exes = [os.path.join(base, name) + for base, _dirs, names in os.walk(unpacked) + for name in names if name.lower().endswith(".exe")] + if len(exes) != 1: + raise SystemExit("sign_release: expected one .exe in the archive, got %r" + % [os.path.basename(e) for e in exes]) + exe = exes[0] + print(" %s (%d bytes, unsigned)" % (os.path.basename(exe), os.path.getsize(exe))) + + print("\n[4/6] signing with the card") + thumbprint = signing_thumbprint() + signtool = find_signtool() + command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256", + "/tr", TIMESTAMP_URL, "/td", "sha256", "/v", exe] + if args.dry_run: + print(" DRY RUN, would run: %s" % " ".join(command)) + else: + run(command) + run([signtool, "verify", "/pa", "/v", exe]) + actual = certificate_of(exe) + if actual != CODESIGN_SHA256: + raise SystemExit( + "sign_release: the file was signed by a DIFFERENT certificate\n" + " expected %s\n got %s\nNothing has been uploaded." + % (CODESIGN_SHA256, actual)) + print(" signed by the pinned certificate, timestamped") + + print("\n[5/6] repacking and checksumming") + signed = os.path.join(work, archives[0]) + if not args.dry_run: + os.remove(archive) + with zipfile.ZipFile(signed, "w", zipfile.ZIP_DEFLATED) as zf: + for base, _dirs, names in os.walk(unpacked): + for name in names: + full = os.path.join(base, name) + zf.write(full, os.path.relpath(full, unpacked)) + sums = os.path.join(work, "SHA256SUMS.txt") + with open(sums, "w", encoding="utf-8", newline="\n") as handle: + handle.write("%s *%s\n" % (sha256_of(signed), archives[0])) + print(" %s" % sha256_of(signed)) + + print("\n[6/6] handing it back to the workflow") + if args.dry_run: + print(" DRY RUN, would upload the archive and SHA256SUMS.txt to the draft") + print(" DRY RUN, would dispatch %s with the digest" % ATTEST_WORKFLOW) + print("\ndry run finished - nothing was signed, uploaded or published") + return 0 + run(["gh", "release", "upload", args.tag, signed, sums, + "--repo", REPO, "--clobber"]) + run(["gh", "workflow", "run", ATTEST_WORKFLOW, "--repo", REPO, + "-f", "tag=%s" % args.tag, "-f", "digest=%s" % sha256_of(signed)]) + print("\nDone. The release is still a DRAFT.") + print("Wait for '%s' to attach the .sigstore.json, read the draft, then publish it." + % ATTEST_WORKFLOW) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 5dc8628d7f2e81b421e1a86d3b409bf3a35aed14 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 19 Aug 2026 18:33:18 +0200 Subject: [PATCH 3/3] test(tools): stop the URL guard carrying an email-shaped string CI caught it, which is what it is for: the hostile version used to test at-sign handling was written as a user-and-hostname pair, and `test_no_stray_email_addresses_in_the_public_tree` reads that as an address in a public repository. It is right to. An address in a public tree is an address that gets scraped, and the one exemption that guard allows exists for a licence notice obliged to reproduce one - widening it for test data would spend a real guard on a convenience. An IP address takes its place, and the case gets stronger rather than weaker: an authority written as an IP is the more realistic escape attempt, and it is not an email address by any reading. Worth recording why it reached CI at all: the conventions suite ran before this test file existed and was not run again after, so the only place it could surface was the pull request. That is the arrangement working, not failing. Co-Authored-By: Claude Opus 5 --- tests/test_pin_hashes_url.py | 6 +++++- tools/pin_hashes.py | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_pin_hashes_url.py b/tests/test_pin_hashes_url.py index 9916abf..155bee1 100644 --- a/tests/test_pin_hashes_url.py +++ b/tests/test_pin_hashes_url.py @@ -29,7 +29,11 @@ "..%2F..%2Fetc", "x?a=b", "x#frag", - "x@evil.com", + # An at-sign and an authority - the shape of an escape attempt. Written with an + # IP rather than a hostname on purpose: a hostname here reads as an email address + # to `test_no_stray_email_addresses_in_the_public_tree`, which is right to say so + # about a public repository, and an IP is the more realistic attempt anyway. + "x@127.0.0.1", "/../..//", "file:///c:/windows/win.ini", "https://evil.example/pypi", diff --git a/tools/pin_hashes.py b/tools/pin_hashes.py index 420011f..1fe85a9 100644 --- a/tools/pin_hashes.py +++ b/tools/pin_hashes.py @@ -48,9 +48,9 @@ def _url(name, version): 🔴 The escaping is not about the scheme. A scanner flags `urlopen` on a value it cannot see the shape of, and the risk it names - a `file://` URL reading a local file - is not reachable here: `API` is a literal `https://` and both parts land in - the PATH, after the authority. Measured across `../../etc/passwd`, `x@evil.com` - and a literal `file:///c:/windows` as the version: the scheme stays `https` and - the host stays `pypi.org` in every case. + the PATH, after the authority. Measured across `../../etc/passwd`, an at-sign + followed by an address, and a literal `file:///c:/windows` as the version: the + scheme stays `https` and the host stays `pypi.org` in every case. What IS reachable is quieter and worth closing anyway. `version` is only barred from whitespace and semicolons, so `1.0?x=y` or `1.0#frag` used to truncate the