From fc0e32fa7867a33a15f480e8ee26d1b7af0b40b1 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Sun, 30 Aug 2026 13:02:21 -0600 Subject: [PATCH] ci(release): add advisory scanning, SBOM, and signed release artifacts `cargo deny check` now gates every push, every pull request (ci.yml), and every tagged release (release.yml, before build) against deny.toml's advisory, license, and source policy. Seven RUSTSEC advisories already inherited through acton-ai's exact pin (via libsql) are recorded there with reasons and tracked in #47; anything else fails the check. Each release archive now carries a sbom/ directory with a CycloneDX 1.5 SBOM per binary it ships, generated per target so cross-platform-only dependencies don't leak into a platform that never builds them. The publish job signs SHA256SUMS keylessly with cosign, against the workflow's own GitHub Actions OIDC identity rather than a stored key. The verification command is in the release notes and in packaging/README.md. docs/supply-chain-policy.md is the written policy #43 asked for: the acton-ai pin rationale, the dependency-review process, what the advisory scan enforces and what it currently ignores and why, and how a new advisory gets triaged. Closes #43 --- .github/workflows/ci.yml | 24 ++++++ .github/workflows/release.yml | 94 +++++++++++++++++++- deny.toml | 80 +++++++++++++++++ docs/supply-chain-policy.md | 156 ++++++++++++++++++++++++++++++++++ packaging/README.md | 22 +++++ 5 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 deny.toml create mode 100644 docs/supply-chain-policy.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2207261..fe44fe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,30 @@ env: RUST_BACKTRACE: 1 jobs: + # Advisory, license, and source policy from `deny.toml` — see + # docs/supply-chain-policy.md. Split from `clippy-and-test` because it + # needs none of that job's FIPS build tooling: `cargo deny` reads + # `Cargo.lock` and crate metadata, it never compiles anything, so this + # finishes in seconds rather than the FIPS module's twenty-plus minutes. + # A crate landing on RUSTSEC with no matching `deny.toml` entry fails this + # job, on every push and every pull request. + advisories: + name: Dependency advisories + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deny + uses: taiki-e/install-action@cargo-deny + + - name: cargo deny check + # `--locked` is a `cargo-deny` flag, not a `check` flag: it must + # come before the subcommand. + run: cargo deny --locked check + clippy-and-test: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 856fd10..feec3e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,9 +102,35 @@ jobs: fi echo "garrison-agent $crate" + # Advisory, license, and source policy from `deny.toml` — the same gate + # `ci.yml` runs on every push, run again here against whatever + # `Cargo.lock` the tagged commit actually carries. A tag can point to a + # commit whose CI run predates an advisory that has since been published, + # so re-checking at release time is not redundant with CI: it is the + # "and release" half of #43's "every push and release." + supply-chain: + name: Dependency advisories + needs: plan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.plan.outputs.tag }} + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deny + uses: taiki-e/install-action@cargo-deny + + - name: cargo deny check + # `--locked` is a `cargo-deny` flag, not a `check` flag: it must + # come before the subcommand. + run: cargo deny --locked check + build: name: ${{ matrix.name }} - needs: plan + needs: [plan, supply-chain] runs-on: ${{ matrix.os }} timeout-minutes: 120 strategy: @@ -176,6 +202,9 @@ jobs: go-version: '1.24' cache: false + - name: Install cargo-cyclonedx + uses: taiki-e/install-action@cargo-cyclonedx + # Deliberately not cached. A release artifact is worth the twenty # minutes it takes to build AWS-LC from source on a clean runner, and a # restored cache is one more thing an auditor would have to trust. @@ -205,6 +234,19 @@ jobs: "$bin" --version echo "the FIPS provider installed; this binary is FIPS-enabled" + # One CycloneDX SBOM per binary this workspace ships, resolved for + # this leg's target so a cross-target-only dependency doesn't show up + # in a platform that never builds it. Run against the whole workspace + # rather than a single crate: cargo-cyclonedx has no per-package + # filter, so both `garrison-agent_bin.cdx.json` and + # `garrison-hooks_bin.cdx.json` are produced on every leg regardless + # of `matrix.hooks`, and the archive step below copies in only the + # one(s) it actually ships — the same guard the binary itself gets. + - name: Generate the SBOM + run: | + cargo cyclonedx --describe binaries --format json \ + --spec-version 1.5 --target ${{ matrix.target }} -q + - name: Assemble the archive id: archive env: @@ -216,9 +258,10 @@ jobs: run: | set -euo pipefail stage="garrison-$VERSION-$TARGET" - mkdir -p "$stage/docs" + mkdir -p "$stage/docs" "$stage/sbom" cp "target/$TARGET/release/garrison-agent" "$stage/" cp README.md LICENSE "$stage/" + cp agent/garrison-agent_bin.cdx.json "$stage/sbom/" # On Apple the validated module is a dylib rather than something # linked into the binary, so it is part of the artefact: the binary @@ -231,6 +274,7 @@ jobs: # Regenerated into the project root by hooks-service/build.rs, and # the plane's `[[schema_forge.hooks.bindings]]` entry needs it. cp hooks-service/hooks_descriptor.bin "$stage/" + cp hooks-service/garrison-hooks_bin.cdx.json "$stage/sbom/" mkdir -p "$stage/systemd" cp packaging/systemd/garrison-agent.service "$stage/systemd/" cp packaging/README.md "$stage/systemd/" @@ -281,6 +325,15 @@ jobs: name: Publish needs: [plan, build] runs-on: ubuntu-latest + # Job-level permissions replace the workflow-level block above rather + # than adding to it, so `contents: write` (the draft release, the + # upload) is repeated here alongside the one permission this job adds: + # `id-token: write`, which is what lets cosign mint a short-lived + # Sigstore certificate from this job's own OIDC identity instead of a + # long-lived signing key this repository would otherwise have to store. + permissions: + contents: write + id-token: write steps: - uses: actions/checkout@v4 with: @@ -300,6 +353,24 @@ jobs: shasum -a 256 -c SHA256SUMS ls -l + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + # Keyless: the certificate binds this signature to "the release.yml + # workflow, running as a push of this tag, in this repository" — not + # to a private key that would need generating, rotating, and keeping + # secret. Signing SHA256SUMS rather than every archive individually + # means one signature covers every artifact in the release, since + # each artifact's own hash is already a line in that file. + - name: Sign the checksums + working-directory: artifacts + run: | + set -euo pipefail + cosign sign-blob --yes \ + --output-signature SHA256SUMS.sig \ + --output-certificate SHA256SUMS.pem \ + SHA256SUMS + # Left as a draft on purpose: publishing is a person's decision, and the # draft is where they check the artifact list before an agency does. - name: Create the draft release @@ -324,7 +395,21 @@ jobs: itself: keep it beside the binary, which is where the binary looks for it. - Verify a download against `SHA256SUMS`: + Each archive also carries a `sbom/` directory: one CycloneDX 1.5 + SBOM per binary it ships, generated for that archive's own target + triple from this repository's `Cargo.lock`. + + `SHA256SUMS` is signed keylessly with `cosign`, against this + workflow's own GitHub Actions OIDC identity rather than a stored + private key. Verify the signature, then the archive against the + now-trusted checksums: + + cosign verify-blob \ + --certificate SHA256SUMS.pem \ + --signature SHA256SUMS.sig \ + --certificate-identity-regexp '^https://github\.com/Govcraft/garrison/\.github/workflows/release\.yml@.*$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + SHA256SUMS shasum -a 256 -c SHA256SUMS --ignore-missing NOTES @@ -334,4 +419,5 @@ jobs: flags+=(--prerelease) fi gh release create "$TAG" "${flags[@]}" \ - artifacts/SHA256SUMS artifacts/*.tar.gz + artifacts/SHA256SUMS artifacts/SHA256SUMS.sig artifacts/SHA256SUMS.pem \ + artifacts/*.tar.gz diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..12abc8c --- /dev/null +++ b/deny.toml @@ -0,0 +1,80 @@ +# Dependency-advisory, license, and source policy, enforced by `cargo deny +# check` on every push, every pull request (`.github/workflows/ci.yml`), and +# every release (`.github/workflows/release.yml`). See +# `docs/supply-chain-policy.md` for what this file means and how the ignore +# list below is reviewed. +# +# Regenerate the skeleton with `cargo deny init`; this file is hand-trimmed +# from that template, keeping only the fields whose value differs from +# cargo-deny's default. + +[graph] +# `garrison-hooks` is Linux-only in practice (see ci.yml and release.yml), +# but nothing here is gated on it: a license or advisory that only affects a +# Windows-only dependency edge should still surface, since #43 is about +# catching problems before they ship, not about what today's runners build. +all-features = true + +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +# Every entry here is a known RUSTSEC advisory this workspace has decided +# not to act on yet, with the reason recorded next to it. An advisory that +# has no entry here fails the check — that is the point of this file. +ignore = [ + # All seven of the following are pulled in transitively by `libsql`, + # which `acton-ai` 0.35.0 depends on for its embedded-database feature. + # `acton-ai` is pinned to an exact version (see + # docs/supply-chain-policy.md), so none of these can be resolved away by + # `cargo update`; clearing them means moving that pin. Tracked in + # https://github.com/Govcraft/garrison/issues/47. + { id = "RUSTSEC-2025-0141", reason = "bincode 1.3.3 is unmaintained; pulled in by libsql via acton-ai 0.35.0, no safe upgrade exists on 1.x. Tracked in #47." }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile 2.2.0 is unmaintained; pulled in by libsql's hyper-rustls via acton-ai 0.35.0. Tracked in #47." }, + { id = "RUSTSEC-2026-0049", reason = "rustls-webpki 0.102.8 CRL distribution-point matching bug; garrison does not consume CRLs. Pulled in by libsql via acton-ai 0.35.0. Tracked in #47." }, + { id = "RUSTSEC-2026-0098", reason = "rustls-webpki 0.102.8 accepted URI name constraints it does not enforce; garrison asserts no URI names. Pulled in by libsql via acton-ai 0.35.0. Tracked in #47." }, + { id = "RUSTSEC-2026-0099", reason = "rustls-webpki 0.102.8 accepted name constraints against wildcard names incorrectly; requires a misissued certificate to reach. Pulled in by libsql via acton-ai 0.35.0. Tracked in #47." }, + { id = "RUSTSEC-2026-0104", reason = "rustls-webpki 0.102.8 reachable panic parsing a CRL; garrison does not parse CRLs. Pulled in by libsql via acton-ai 0.35.0. Tracked in #47." }, + { id = "RUSTSEC-2026-0258", reason = "h2 0.3.27 unbounded empty DATA frames (low severity); garrison-agent is an outbound gRPC/HTTP client on this stack, not a server accepting attacker frames. Pulled in by libsql's tonic/hyper via acton-ai 0.35.0. Tracked in #47." }, +] + +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# Every SPDX identifier that appears anywhere in the dependency graph today +# (`cargo metadata --all-features`, deduplicated, OR-expressions reduced to +# their members). A license expression is satisfied if any one member of an +# OR is on this list; an AND requires every member to be. Adding a +# dependency under a license not already covered here fails the check — +# that is the review point docs/supply-chain-policy.md describes. +allow = [ + "AGPL-3.0-only", # garrison's own crates + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "0BSD", + "BSL-1.0", + "CC0-1.0", + "MIT-0", + "CDLA-Permissive-2.0", + "MPL-2.0", + "Zlib", + "Unlicense", + "OpenSSL", + "Unicode-3.0", +] +confidence-threshold = 0.8 + +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +# Left at cargo-deny's defaults: duplicate versions warn rather than fail +# (this workspace has several, none worth blocking a release over today), +# and nothing is explicitly banned or allow-listed yet. +multiple-versions = "warn" + +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/supply-chain-policy.md b/docs/supply-chain-policy.md new file mode 100644 index 0000000..c08f74e --- /dev/null +++ b/docs/supply-chain-policy.md @@ -0,0 +1,156 @@ +# Supply-chain policy + +What Garrison pins and why, how a dependency change gets reviewed, what +`cargo deny check` enforces on every push and every release, what ships +alongside a release to let someone verify it, and what happens when an +advisory lands on something already in the tree. This document is the +"written supply-chain policy" #43 asked for; the mechanics it describes live +in `deny.toml`, `.github/workflows/ci.yml`, and +`.github/workflows/release.yml`. + +## The pin policy + +Almost every dependency in this workspace is a caret range (`cargo add`'s +default), which is the normal Cargo trust model: a patch or minor bump is +assumed compatible, and `Cargo.lock` pins the resolved version until +something asks `cargo update` to move it. + +`acton-ai` is the one exception: + +```toml +acton-ai = { version = "=0.35.0", default-features = false, features = [...] } +``` + +An exact pin, not a caret range. `garrison-agent` installs `acton-ai`'s FIPS +crypto provider before it parses a command line and refuses to start if the +module is not operating in FIPS mode — see `release.yml`'s "There is no +non-FIPS release" header comment. A caret range would let `cargo update` +silently move that binding to a version whose FIPS behavior nobody has +checked. The exact pin means every `acton-ai` upgrade is a deliberate edit to +`agent/Cargo.toml`, not something that rides in on an unrelated +`cargo update`. + +The cost of that pin is that `acton-ai` 0.35.0's own dependency tree — most +directly `libsql`, which it uses for its embedded-database feature — is +frozen too, RUSTSEC advisories against it included. See "Known, +accepted advisories" below. + +## Adding or changing a dependency + +1. `cargo add`/`cargo remove` only (see the crate-wide instruction; never + hand-edit a `Cargo.toml` dependency line). This resolves the latest + compatible version rather than whatever the editor happened to pin. +2. Run `cargo deny check` locally before opening the PR. A dependency + pulling in a license not already in `deny.toml`'s `[licenses] allow` list, + or a registry not on the `[sources] allow-registry` list, fails the check + and needs one of: + - the license or source is fine — add it to `deny.toml` in the same PR, + with the crate that needed it visible in the diff; + - it is not fine — find a different crate. +3. CI runs the same check (`ci.yml`'s `advisories` job) on the PR regardless; + step 2 exists so that finding happens on a laptop in ten seconds rather + than in a CI log ten minutes later. + +## Advisory scanning + +`cargo deny check advisories` runs against the RustSec advisory database: + +- **Every push and every pull request** — `ci.yml`, job `advisories`. Fast: + it reads `Cargo.lock` and crate metadata, it never compiles anything. +- **Every release** — `release.yml`, job `supply-chain`, gating `build`. A + tag can point to a commit whose CI run predates an advisory published + since; re-checking at release time catches that. + +A crate landing on RUSTSEC with no matching entry in `deny.toml`'s +`[advisories] ignore` list fails both. That is the mechanism behind #43's +acceptance criterion: a known-vulnerable crate fails CI. + +### Known, accepted advisories + +As of the pin above, `acton-ai` 0.35.0's `libsql` dependency carries seven +open RUSTSEC advisories, all listed in `deny.toml` with a reason. Summary: + +| Advisory | Crate | Why it is accepted for now | +|---|---|---| +| RUSTSEC-2025-0141 | bincode 1.3.3 | Unmaintained (no CVE); no safe upgrade on the 1.x line | +| RUSTSEC-2025-0134 | rustls-pemfile 2.2.0 | Unmaintained (no CVE); no safe upgrade | +| RUSTSEC-2026-0049 | rustls-webpki 0.102.8 | CRL distribution-point bug; garrison consumes no CRLs | +| RUSTSEC-2026-0098 | rustls-webpki 0.102.8 | Unenforced URI name constraints; garrison asserts no URI names | +| RUSTSEC-2026-0099 | rustls-webpki 0.102.8 | Wildcard name-constraint bug; requires a misissued cert to reach | +| RUSTSEC-2026-0104 | rustls-webpki 0.102.8 | Reachable panic parsing a CRL; garrison parses no CRLs | +| RUSTSEC-2026-0258 | h2 0.3.27 | Unbounded empty DATA frames (low severity); garrison is a client on this stack, not a server accepting the frames | + +Every one of these is inherited through the exact pin above, not something +`cargo update` can resolve away on its own. Clearing them means moving the +`acton-ai` pin to a release whose `libsql` no longer resolves to the +affected versions — tracked in +[#47](https://github.com/Govcraft/garrison/issues/47). + +## Responding to a new advisory + +When `cargo deny check advisories` starts failing on something not already +in the ignore list: + +1. Read the advisory (`cargo deny` prints the RustSec URL and the full + dependency path to the affected crate). +2. Decide whether garrison's own use of the affected crate can reach the + flaw. Most of this workspace's dependency surface is transitive, several + layers under `acton-ai`, `acton-service`, or `acton-reactive`; a crate + being *present* is not the same as garrison calling the affected code + path, and the table above is full of the latter. +3. If a direct dependency is affected and a compatible fixed version exists, + `cargo update -p ` and re-run the check — this is the common case + and needs no `deny.toml` change. +4. If the fix requires bumping something pinned (today, only `acton-ai`), + or no fix exists yet, add an `ignore` entry to `deny.toml` with a reason + that states which code path is or is not reachable, and open a tracking + issue the way #47 tracks the table above. An ignore entry with no + tracking issue is a debt nobody is watching. +5. If garrison's own code does reach the flaw and no upgrade is available, + that is a stop-ship: do not add an ignore entry to make CI pass, fix the + reachable path or drop the dependency. + +## Software bill of materials + +Each release archive (`release.yml`, job `build`) carries an `sbom/` +directory: one CycloneDX 1.5 SBOM per binary that archive ships, generated +by `cargo cyclonedx` from that leg's own `Cargo.lock` resolution and target +triple, so a target-specific dependency does not show up in a platform that +never builds it. `garrison-agent_bin.cdx.json` ships in every archive; +`garrison-hooks_bin.cdx.json` ships only where `garrison-hooks` does (Linux). + +CycloneDX rather than SPDX: it is what `cargo-cyclonedx` +(the actively maintained option for a Rust workspace, see +`.github/workflows/release.yml`) produces directly from `cargo metadata` +without a second conversion step, and it is a component-and-dependency-graph +format, which is the shape an advisory scanner or auditor asks for. + +## Signed release artifacts + +`release.yml`'s `publish` job signs `SHA256SUMS` — which already contains a +hash of every `.tar.gz` in the release — with `cosign sign-blob`, keylessly: +the signing certificate is minted from that job's own GitHub Actions OIDC +identity (`id-token: write`, scoped to the `publish` job only) through +Sigstore's Fulcio, and the signature and certificate are logged to the +public Rekor transparency log. There is no private key for this repository +to generate, rotate, or leak. + +`SHA256SUMS.sig` and `SHA256SUMS.pem` ship alongside `SHA256SUMS` on every +release. To verify a download: + +```sh +cosign verify-blob \ + --certificate SHA256SUMS.pem \ + --signature SHA256SUMS.sig \ + --certificate-identity-regexp '^https://github\.com/Govcraft/garrison/\.github/workflows/release\.yml@.*$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + SHA256SUMS + +shasum -a 256 -c SHA256SUMS --ignore-missing +``` + +The first command proves `SHA256SUMS` was signed by *this repository's* +release workflow and nothing else — the identity regexp is the check that +matters, not just "a signature exists." The second checks the downloaded +archive against those now-trusted hashes. This is also in the release notes +`publish` generates, next to the FIPS provenance description. diff --git a/packaging/README.md b/packaging/README.md index 5226fa2..e576523 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -44,6 +44,28 @@ reports stopping before it tears its actors down. | Chat log | `$XDG_STATE_HOME/garrison/chat.log` | | Install identity and key (enrolled installs) | `~/.config/garrison/install.json`, `install-key.pem` | +## Verifying a release + +Each release archive carries an `sbom/` directory (one CycloneDX SBOM per +binary it ships) and, alongside the archive, a `SHA256SUMS` file covering +every archive in the release, signed keylessly with `cosign`. Verify the +signature before trusting the checksums, and the checksums before trusting +the archive: + +```sh +cosign verify-blob \ + --certificate SHA256SUMS.pem \ + --signature SHA256SUMS.sig \ + --certificate-identity-regexp '^https://github\.com/Govcraft/garrison/\.github/workflows/release\.yml@.*$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + SHA256SUMS + +shasum -a 256 -c SHA256SUMS --ignore-missing +``` + +See `docs/supply-chain-policy.md` for what signs the release, what the SBOM +covers, and how dependency advisories are tracked. + ## Without systemd When no unit is loaded, the relay spawns `garrison-agent serve` itself as a