diff --git a/.github/workflows/fips-image.yml b/.github/workflows/fips-image.yml index d096a12..a606c74 100644 --- a/.github/workflows/fips-image.yml +++ b/.github/workflows/fips-image.yml @@ -17,12 +17,19 @@ on: - .github/workflows/fips-image.yml workflow_dispatch: +# `packages: write` moved from here to the job below on 2026-09-23 (OpenSSF Scorecard +# Token-Permissions). A write granted at the top level is granted to every job the workflow will +# ever have, including ones nobody has written yet; granted at the job level it is granted to the +# one job that pushes the image, and a reviewer sees it next to the push. permissions: contents: read - packages: write jobs: image: + permissions: + contents: read + # The registry push at the end of this job, and nothing else here. + packages: write runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index d119fd0..ef836a0 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -6,6 +6,14 @@ on: push: pull_request: +# Least privilege for the workflow's token, added 2026-09-23 after OpenSSF Scorecard scored this +# repository 0 on Token-Permissions: neither this workflow nor `package.yml` declared any +# top-level permissions, so both ran with whatever the repository default happened to be. Every +# job here reads the tree and writes nothing back, so the whole workflow gets read and any job +# that later needs more has to say so itself and be seen saying it in review. +permissions: + contents: read + jobs: gate: runs-on: ubuntu-latest @@ -108,6 +116,17 @@ jobs: # files that define it; this job never builds it. fips-tag: runs-on: ubuntu-latest + # `packages: read` is not decoration. The workflow-level block above narrows every job's token + # to what it names, and before that block existed these two jobs inherited the repository + # default, which grants read on every scope. Declaring `contents: read` at the top therefore + # took `packages: read` away, and this job's `docker login` to the registry stopped being able + # to see the FIPS image: it polled for the full thirty minutes and failed with "was not + # published", while the fips-image workflow had already reported the tag as present. Two + # checks disagreeing about whether an image exists is what an authorization failure looks like + # from the outside. Measured on this branch before it was merged. + permissions: + contents: read + packages: read outputs: ref: ${{ steps.tag.outputs.ref }} steps: @@ -149,6 +168,11 @@ jobs: fips: needs: fips-tag runs-on: ubuntu-latest + # The runner pulls this container before the job's first step, using the credentials below, + # so without `packages: read` the job fails with `manifest unknown` and no output at all. + permissions: + contents: read + packages: read container: image: ${{ needs.fips-tag.outputs.ref }} credentials: diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 5f98df8..84e4a79 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -40,6 +40,12 @@ on: - .github/workflows/package.yml workflow_dispatch: +# Least privilege for the workflow's token (2026-09-23, OpenSSF Scorecard Token-Permissions). The +# default is read; the one job that needs more, the attesting one, declares it at the job level +# where a reviewer sees it beside the step that uses it. +permissions: + contents: read + jobs: # Slice 002. The bill of materials is derived from `mix.lock` alone: it is the same document # whichever runner produces it, and `mix trinity.sbom` needs the project compiled in the dev diff --git a/test/workflows_test.exs b/test/workflows_test.exs index 9393c85..65b8206 100644 --- a/test/workflows_test.exs +++ b/test/workflows_test.exs @@ -4,20 +4,31 @@ defmodule WorkflowsTest do @moduledoc """ The continuous integration definitions are code, and they are checked like code. - Two properties. Every workflow and the Dependabot configuration parse as YAML, so a malformed + Three properties. Every workflow and the Dependabot configuration parse as YAML, so a malformed one fails here rather than silently not running: GitHub reports a broken workflow file on its - own page and not on the pull request, which is the wrong place for it to be discovered. And - every third-party action is pinned to a full commit hash rather than a tag, because a tag can - be moved to point at different code after review; this is the OpenSSF Scorecard - `Pinned-Dependencies` check, held here rather than waited for. + own page and not on the pull request, which is the wrong place for it to be discovered. Every + third-party action is pinned to a full commit hash rather than a tag, because a tag can be moved + to point at different code after review; this is the OpenSSF Scorecard `Pinned-Dependencies` + check, held here rather than waited for. And every workflow declares a top-level `permissions:` + block granting no more than read, so that a job wanting to write has to say so itself: Scorecard's + `Token-Permissions` check, which this repository scored **zero** on until 2026-09-23 because two + workflows declared nothing at all and ran with whatever the repository default happened to be. + + The third property is the one worth a sentence about why a test rather than a habit. A top-level + write is granted to every job the workflow will ever have, including the ones nobody has written + yet, and the cost of forgetting is invisible: nothing fails, the token is simply broader than the + work needs. That is exactly the class of thing a build should hold rather than a reviewer. The population comes from the tree (`git ls-files .github`), so a workflow added without a - thought for either property is caught by its own existence. + thought for any of the three is caught by its own existence. """ use ExUnit.Case, async: true @action_ref ~r{uses:\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+)@(\S+)} + # Anything that is not one of these is a write, whatever it is called. + @read_only ~w(read none) + defp yaml_files do {out, 0} = System.cmd("git", ["ls-files", ".github"]) @@ -54,4 +65,38 @@ defmodule WorkflowsTest do "tag can be moved to point at different code after it was reviewed" end end + + test "every workflow declares a top-level permissions block, and it grants no write" do + for file <- yaml_files(), String.contains?(file, "/workflows/") do + permissions = file |> read_yaml() |> Map.get("permissions") + + assert permissions != nil, + "#{file} declares no top-level permissions, so its token is whatever the " <> + "repository default happens to be. Declare `permissions: {contents: read}` and " <> + "let any job that needs to write say so itself" + + for {scope, level} <- top_level_scopes(permissions) do + assert level in @read_only, + "#{file} grants `#{scope}: #{level}` at the top level, which grants it to every " <> + "job the workflow will ever have. Move it to the job that needs it" + end + end + end + + # `permissions:` is either a map of scopes, or one of the shorthands `read-all`/`write-all`. + defp top_level_scopes("read-all"), do: [] + defp top_level_scopes("write-all"), do: [{"(every scope)", "write"}] + defp top_level_scopes(map) when is_map(map), do: map + defp top_level_scopes(other), do: [{"(unrecognised)", inspect(other)}] + + defp read_yaml(file) do + {json, 0} = + System.cmd("python3", [ + "-c", + "import sys,yaml,json; json.dump(yaml.safe_load(open(sys.argv[1])), sys.stdout)", + file + ]) + + Jason.decode!(json) + end end