diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3d02456..1e14e39b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write # OIDC identity for Sigstore keyless signing / attestation steps: - uses: actions/download-artifact@v4 with: @@ -52,13 +53,58 @@ jobs: - name: checksums run: sha256sum odek-* > checksums.txt + # SPDX SBOM of a representative build. All matrix entries compile the + # same source with the same go.mod, so the module set is identical; + # one SBOM covers the release. Upload handled by the release step so + # the SBOM ships signed like every other asset. + - name: SBOM + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + format: spdx-json + artifact: odek-linux-amd64 + output-file: odek-${{ github.ref_name }}-sbom.spdx.json + upload-artifact: false + upload-release-assets: false + + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: v3.1.3 # pinned: a future cosign major must not break a tagged release mid-flight + + # Keyless signing: Fulcio-issued certificate bound to this workflow's + # OIDC identity, logged in the Rekor transparency log. Explicit file + # list (not a glob) so generated .bundle outputs can never be re-signed. + - name: sign artifacts + run: | + BINARIES="odek-linux-amd64 odek-linux-arm64 odek-darwin-amd64 odek-darwin-arm64" + for f in $BINARIES checksums.txt; do + cosign sign-blob --yes --bundle "${f}.bundle" "$f" + done + + # in-toto attestation naming the exact source commit each artifact was + # built from. Predicate type "custom" keeps the step free of cosign's + # SLSA schema validation; the content is SLSA-provenance-shaped. + - name: provenance attestation + env: + GIT_REF: ${{ github.ref }} + GIT_SHA: ${{ github.sha }} + run: | + printf '{ + "builder": {"id": "https://github.com/BackendStack21/odek/.github/workflows/release.yml@%s"}, + "buildType": "https://github.com/BackendStack21/odek/.github/workflows/release.yml", + "invocation": {"configSource": {"uri": "git+https://github.com/BackendStack21/odek", "digest": {"sha1": "%s"}}}, + "metadata": {"completeness": {"parameters": true, "environment": false}, "reproducible": false}, + "materials": [{"uri": "git+https://github.com/BackendStack21/odek", "digest": {"sha1": "%s"}}] + }' "${GIT_REF}" "${GIT_SHA}" "${GIT_SHA}" > provenance.json + BINARIES="odek-linux-amd64 odek-linux-arm64 odek-darwin-amd64 odek-darwin-arm64" + for f in $BINARIES checksums.txt; do + cosign attest-blob --yes --type custom --predicate provenance.json --bundle "${f}.attestation.bundle" "$f" + done + - name: release uses: softprops/action-gh-release@v3 with: files: | - odek-linux-amd64 - odek-linux-arm64 - odek-darwin-amd64 - odek-darwin-arm64 + odek-* checksums.txt + checksums.txt.bundle generate_release_notes: true diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f2ddf934..9ddfedf8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -487,6 +487,15 @@ All security-relevant state under `~/.odek` is written through `internal/fsatomi `odek upgrade` verifies the downloaded release against the published `checksums.txt` (SHA-256) and refuses to install a binary with no checksum entry, swapping it in atomically over the running executable. The latest-release lookup authenticates with `GITHUB_TOKEN` / `GH_TOKEN` when set; a 401/403/429 from the REST API falls back to the public HTML latest-release redirect and synthesized `browser_download_url`s (checksum verification is unchanged). +Release binaries are additionally Sigstore keyless-signed at build time: each release ships `.bundle` signatures and `.attestation.bundle` in-toto attestations naming the exact source commit, plus an SPDX SBOM (`odek--sbom.spdx.json`). Signatures are issued against the workflow's OIDC identity (`https://github.com/BackendStack21/odek/.github/workflows/release.yml@refs/tags/*`) and logged in the Rekor transparency log, so a mirrored or tampered release feed cannot forge a valid bundle. Verify with: + +``` +cosign verify-blob --bundle odek-darwin-arm64.bundle \ + --certificate-identity-regexp '^https://github\.com/BackendStack21/odek/' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + odek-darwin-arm64 +``` + ### Resource bounds Hostile or accidental input is bounded everywhere it is sized, to keep it from OOMing or stalling the process. The major caps: @@ -711,7 +720,7 @@ Background jobs inherit the shell tool's security model with no downgrade: | Served Web UI page is framed or clickjacked | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`, no inline scripts | | Forged `` envelope renders as instructions in the Web UI | Client renders mismatched envelopes as plain text; all output HTML-escaped | | Skill imported from attacker URL with rebinding / `inet_aton` tricks | Import-time SSRF guard (scheme allowlist, 1-hop redirects, private-IP blocking) | -| Compromised release feed serves a trojaned binary | `odek upgrade` verifies SHA-256 against `checksums.txt`, fails closed | +| Compromised release feed serves a trojaned binary | `odek upgrade` verifies SHA-256 against `checksums.txt`, fails closed; artifacts are additionally Sigstore keyless-signed with the bundles published in the same release — a tampered feed cannot forge a signature rooted in the Rekor transparency log | | Compaction summary launders untrusted text into system context | Digest wrapped with the untrusted boundary | | Concurrent `odek schedule add` processes clobber each other | Cross-process `flock`; lock failure is a hard error | | Tampered `schedules.json` replaced with a multi-gigabyte blob | 10 MiB size cap |