From 3446f9013838e7bee4ec746712c3e6291e9da4be Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 26 Jun 2026 20:46:49 -0400 Subject: [PATCH 1/5] harden installer: verify via GitHub build attestation, not curl | sh Addresses #198. Anchor the installer's integrity outside the (mutable) release assets: - Enable `github-attestations` in dist so each release artifact gets a Sigstore-backed SLSA attestation recorded in GitHub's transparency log. - scripts/install.sh now downloads hyperlink-installer.sh to a file and runs `gh attestation verify` on it before executing, instead of piping curl straight into sh. Generated-by: Claude Opus 4.8 (1M context) --- dist-workspace.toml | 4 ++++ scripts/install.sh | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/dist-workspace.toml b/dist-workspace.toml index c09736d..8dd1527 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -19,3 +19,7 @@ npm-scope = "@untitaker" install-path = "CARGO_HOME" # Whether to install an updater program install-updater = false +# Emit GitHub build attestations (Sigstore-backed SLSA provenance) for +# release artifacts, recorded in GitHub's transparency log so consumers can +# verify them independently of the (mutable) release assets — see #198. +github-attestations = true diff --git a/scripts/install.sh b/scripts/install.sh index 8321447..326ca16 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,4 +4,21 @@ tag="`grep 'version = ' Cargo.toml | head -1 | cut -d'"' -f2`" echo "downloading hyperlink $tag" -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/untitaker/hyperlink/releases/download/$tag/hyperlink-installer.sh | sh +# Download the installer to a file (rather than piping straight into sh) and +# verify it against its GitHub build attestation before running it. The +# attestation is Sigstore-backed and lives in GitHub's transparency log, so — +# unlike a checksum shipped beside the script in the same mutable release — it +# cannot be swapped together with the artifact. See #198. +# +# Requires `gh` (preinstalled on GitHub runners) and a token in GH_TOKEN / +# GITHUB_TOKEN. Attestations exist for releases built after +# `github-attestations` was enabled in dist-workspace.toml. +installer="`mktemp`" +curl --proto '=https' --tlsv1.2 -LsSf \ + "https://github.com/untitaker/hyperlink/releases/download/$tag/hyperlink-installer.sh" \ + -o "$installer" + +gh attestation verify "$installer" --repo untitaker/hyperlink + +sh "$installer" +rm -f "$installer" From 05f30b9d2ffeb1b5d4c866628b8573145cfe8d14 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 26 Jun 2026 20:54:05 -0400 Subject: [PATCH 2/5] ci: regenerate release.yml for github-attestations Enabling `github-attestations` in dist-workspace.toml requires the generated release workflow to be regenerated (the `plan` job's out-of-date check caught this). Adds the `attestations`/`id-token` write permissions and the `actions/attest-build-provenance@v2` step that dist emits for attested builds. Generated-by: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d5332e..0009a97 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + permissions: + "attestations": "write" + "contents": "read" + "id-token": "write" steps: - name: enable windows longpaths run: | @@ -144,6 +148,10 @@ jobs: # Actually do builds and make zips and whatnot dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" + - name: Attest + uses: actions/attest-build-provenance@v2 + with: + subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" - id: cargo-dist name: Post-build # We force bash here just because github makes it really hard to get values up From dd4241e205573a6b505bd1c178bccab20af1bf84 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 27 Jun 2026 07:24:02 -0400 Subject: [PATCH 3/5] fixup! ci: regenerate release.yml for github-attestations --- .github/workflows/release.yml | 17 +++++++++-------- dist-workspace.toml | 6 ++++++ scripts/install.sh | 12 ++++++++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0009a97..bb94a13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,10 +112,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json - permissions: - "attestations": "write" - "contents": "read" - "id-token": "write" steps: - name: enable windows longpaths run: | @@ -148,10 +144,6 @@ jobs: # Actually do builds and make zips and whatnot dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json echo "dist ran successfully" - - name: Attest - uses: actions/attest-build-provenance@v2 - with: - subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" - id: cargo-dist name: Post-build # We force bash here just because github makes it really hard to get values up @@ -232,6 +224,10 @@ jobs: runs-on: "ubuntu-22.04" outputs: val: ${{ steps.host.outputs.manifest }} + permissions: + "attestations": "write" + "contents": "write" + "id-token": "write" steps: - uses: actions/checkout@v4 with: @@ -274,6 +270,11 @@ jobs: run: | # Remove the granular manifests rm -f artifacts/*-dist-manifest.json + - name: Attest + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + artifacts/* - name: Create GitHub Release env: PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" diff --git a/dist-workspace.toml b/dist-workspace.toml index 8dd1527..c9da099 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -23,3 +23,9 @@ install-updater = false # release artifacts, recorded in GitHub's transparency log so consumers can # verify them independently of the (mutable) release assets — see #198. github-attestations = true +# Attest in the "host" phase, not the default "build-local-artifacts". The +# default only attests the per-target binaries; the installer scripts and +# checksums are *global* artifacts assembled in the host phase, so attesting +# there is what gives hyperlink-installer.sh (the file scripts/install.sh +# verifies) an attestation at all. +github-attestations-phase = "host" diff --git a/scripts/install.sh b/scripts/install.sh index 326ca16..ea89cf2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -14,11 +14,19 @@ echo "downloading hyperlink $tag" # GITHUB_TOKEN. Attestations exist for releases built after # `github-attestations` was enabled in dist-workspace.toml. installer="`mktemp`" +# Always clean up the temp file, including when `set -e` aborts on a failed +# verification below. +trap 'rm -f "$installer"' EXIT + curl --proto '=https' --tlsv1.2 -LsSf \ "https://github.com/untitaker/hyperlink/releases/download/$tag/hyperlink-installer.sh" \ -o "$installer" -gh attestation verify "$installer" --repo untitaker/hyperlink +# Pin the producing workflow, not just the repo: only an attestation minted by +# the real release workflow is accepted, so a different (or compromised) +# workflow in the same repo cannot mint one that passes. +gh attestation verify "$installer" \ + --repo untitaker/hyperlink \ + --signer-workflow untitaker/hyperlink/.github/workflows/release.yml sh "$installer" -rm -f "$installer" From 1a09b58cb4d525cb45691a119258ac3fd419c0db Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 27 Jun 2026 09:51:55 -0400 Subject: [PATCH 4/5] install.sh: add HYPERLINK_SKIP_ATTESTATION opt-out; trim comments --- dist-workspace.toml | 10 ++-------- scripts/install.sh | 28 +++++++++++----------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/dist-workspace.toml b/dist-workspace.toml index c9da099..85b11d7 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -19,13 +19,7 @@ npm-scope = "@untitaker" install-path = "CARGO_HOME" # Whether to install an updater program install-updater = false -# Emit GitHub build attestations (Sigstore-backed SLSA provenance) for -# release artifacts, recorded in GitHub's transparency log so consumers can -# verify them independently of the (mutable) release assets — see #198. +# Attest release artifacts (see #198); "host" phase so the installer — a global +# artifact — is covered, not just the per-target binaries. github-attestations = true -# Attest in the "host" phase, not the default "build-local-artifacts". The -# default only attests the per-target binaries; the installer scripts and -# checksums are *global* artifacts assembled in the host phase, so attesting -# there is what gives hyperlink-installer.sh (the file scripts/install.sh -# verifies) an attestation at all. github-attestations-phase = "host" diff --git a/scripts/install.sh b/scripts/install.sh index ea89cf2..5e72a5a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,29 +4,23 @@ tag="`grep 'version = ' Cargo.toml | head -1 | cut -d'"' -f2`" echo "downloading hyperlink $tag" -# Download the installer to a file (rather than piping straight into sh) and -# verify it against its GitHub build attestation before running it. The -# attestation is Sigstore-backed and lives in GitHub's transparency log, so — -# unlike a checksum shipped beside the script in the same mutable release — it -# cannot be swapped together with the artifact. See #198. -# -# Requires `gh` (preinstalled on GitHub runners) and a token in GH_TOKEN / -# GITHUB_TOKEN. Attestations exist for releases built after -# `github-attestations` was enabled in dist-workspace.toml. +# Download the installer to a file and verify it against its GitHub build +# attestation before running it (see #198). Set HYPERLINK_SKIP_ATTESTATION=1 to +# skip — e.g. on runners without `gh` / a GitHub token, such as Forgejo. installer="`mktemp`" -# Always clean up the temp file, including when `set -e` aborts on a failed -# verification below. trap 'rm -f "$installer"' EXIT curl --proto '=https' --tlsv1.2 -LsSf \ "https://github.com/untitaker/hyperlink/releases/download/$tag/hyperlink-installer.sh" \ -o "$installer" -# Pin the producing workflow, not just the repo: only an attestation minted by -# the real release workflow is accepted, so a different (or compromised) -# workflow in the same repo cannot mint one that passes. -gh attestation verify "$installer" \ - --repo untitaker/hyperlink \ - --signer-workflow untitaker/hyperlink/.github/workflows/release.yml +if [ -z "$HYPERLINK_SKIP_ATTESTATION" ]; then + # --signer-workflow pins the producing workflow, not just the repo. + gh attestation verify "$installer" \ + --repo untitaker/hyperlink \ + --signer-workflow untitaker/hyperlink/.github/workflows/release.yml +else + echo "hyperlink: HYPERLINK_SKIP_ATTESTATION set, skipping attestation check" >&2 +fi sh "$installer" From 88988dcb1baa13b9adb088c96db22581700e0e30 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 27 Jun 2026 15:26:37 -0400 Subject: [PATCH 5/5] install.sh: gh-based attestation with SKIP/FORCE overrides Base attestation verification on whether the `gh` CLI is present rather than an explicit opt-out. On GitHub-hosted runners `gh` is available and the check runs automatically; on runners without it (e.g. Forgejo or other non-GitHub CI) verification is skipped with a warning so the action works out of the box. Two overrides: - HYPERLINK_SKIP_ATTESTATION=1 skips verification entirely, even when `gh` is present. - HYPERLINK_FORCE_ATTESTATION=1 requires verification and fails when `gh` is missing. Document the behaviour in the README GitHub action section. Generated-by: Claude Opus 4.8 (1M context) --- README.md | 23 +++++++++++++++++++++++ scripts/install.sh | 15 +++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f95ef97..f29a7cc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,29 @@ A command-line tool to find broken links in your static site. args: public/ --sources src/ ``` +The action downloads the prebuilt binary and verifies it against its [GitHub +build +attestation](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) +before running (see #198). Verification uses the `gh` CLI: on GitHub-hosted +runners `gh` is present, so the check runs automatically. On runners without +`gh` — e.g. [Forgejo](https://forgejo.org/) or other non-GitHub CI — +verification is skipped with a warning so the action still works. + +Two environment variables override this: + +* `HYPERLINK_SKIP_ATTESTATION=1` skips verification entirely, even when `gh` is + available. +* `HYPERLINK_FORCE_ATTESTATION=1` requires verification and fails when `gh` is + missing. + +```yaml +- uses: untitaker/hyperlink@0.2.1 + env: + HYPERLINK_SKIP_ATTESTATION: "1" + with: + args: public/ --sources src/ +``` + ### NPM ```bash diff --git a/scripts/install.sh b/scripts/install.sh index 5e72a5a..3f7aeac 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5,8 +5,10 @@ tag="`grep 'version = ' Cargo.toml | head -1 | cut -d'"' -f2`" echo "downloading hyperlink $tag" # Download the installer to a file and verify it against its GitHub build -# attestation before running it (see #198). Set HYPERLINK_SKIP_ATTESTATION=1 to -# skip — e.g. on runners without `gh` / a GitHub token, such as Forgejo. +# attestation before running it (see #198). Verification needs the `gh` CLI; when +# `gh` is absent (e.g. Forgejo or other non-GitHub CI) the check is skipped with +# a warning. Set HYPERLINK_SKIP_ATTESTATION=1 to skip it entirely, or +# HYPERLINK_FORCE_ATTESTATION=1 to fail instead of skipping when `gh` is missing. installer="`mktemp`" trap 'rm -f "$installer"' EXIT @@ -14,13 +16,18 @@ curl --proto '=https' --tlsv1.2 -LsSf \ "https://github.com/untitaker/hyperlink/releases/download/$tag/hyperlink-installer.sh" \ -o "$installer" -if [ -z "$HYPERLINK_SKIP_ATTESTATION" ]; then +if [ -n "$HYPERLINK_SKIP_ATTESTATION" ]; then + echo "hyperlink: HYPERLINK_SKIP_ATTESTATION set, skipping attestation verification" >&2 +elif command -v gh >/dev/null 2>&1; then # --signer-workflow pins the producing workflow, not just the repo. gh attestation verify "$installer" \ --repo untitaker/hyperlink \ --signer-workflow untitaker/hyperlink/.github/workflows/release.yml +elif [ -n "$HYPERLINK_FORCE_ATTESTATION" ]; then + echo "hyperlink: HYPERLINK_FORCE_ATTESTATION is set but 'gh' is not installed; cannot verify attestation" >&2 + exit 1 else - echo "hyperlink: HYPERLINK_SKIP_ATTESTATION set, skipping attestation check" >&2 + echo "hyperlink: 'gh' not found, skipping attestation verification (set HYPERLINK_FORCE_ATTESTATION=1 to require it)" >&2 fi sh "$installer"