From cff24aefac5b4410b5230a0a74f0369e2aaaf734 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 15:16:54 -0400 Subject: [PATCH 1/2] fix: least privilege for the workflow tokens, and a test that holds it OpenSSF Scorecard scored this repository zero on Token-Permissions. The reason was not subtle: gate.yml and package.yml declared no top-level permissions at all, so both ran with whatever the repository default happened to be, and fips-image.yml granted packages: write at the top level, where it applies to every job the workflow will ever have rather than to the one that pushes an image. All three now declare contents: read at the top. The two jobs that genuinely need to write say so themselves, next to the step that uses it: the attesting job in package.yml, and the image job in fips-image.yml. The test is the point of the commit rather than a companion to it. A top-level write costs nothing visible when it is forgotten: nothing fails, the token is simply broader than the work needs, and the next workflow added inherits the habit. So the property is held by the build. Every file under .github/workflows must declare a top-level permissions block and it must grant no write, with the population read from git ls-files so a workflow added without a thought for it is caught by its own existence. Demonstrated red by removing the block from gate.yml, which failed the test with the message a reader would need. The remaining Scorecard findings are not silently accepted: Code-Review and Branch-Protection both need someone other than the author approving changes, which R22 already tracks; Maintained wants ninety days of history the repository does not have yet; Signed-Releases is slice 101. Signed-off-by: Ayla Croft --- .github/workflows/fips-image.yml | 9 ++++- .github/workflows/gate.yml | 8 +++++ .github/workflows/package.yml | 6 ++++ test/workflows_test.exs | 57 ++++++++++++++++++++++++++++---- 4 files changed, 73 insertions(+), 7 deletions(-) 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..628fe8d 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 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 From cf91290bd9f1956671d36849b11b90fc032e837b Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 15:49:51 -0400 Subject: [PATCH 2/2] fix: give the FIPS jobs back the package read the top-level block took away The previous commit narrowed every gate job's token to contents: read. Before it, the jobs inherited the repository default, which grants read on every scope, so fips-tag and fips silently depended on packages: read and lost it. The failure did not look like an authorization failure, which is why it is worth the comment now in the file. fips-tag logs in to the registry and polls for the FIPS image; with no packages scope the image is simply invisible to it, so it polled for the full thirty minutes and failed with 'was not published' while the fips-image workflow had already reported the same tag as present and skipped its build. Two checks disagreeing about whether an image exists is what this looks like from the outside. Both jobs now declare contents: read and packages: read. The fips job needs it as much as fips-tag does: the runner pulls its container before the first step runs, so without the scope the job fails with 'manifest unknown' and no output. Caught on the branch by the gate rather than after merge, which is the argument for the required checks being what they are. Signed-off-by: Ayla Croft --- .github/workflows/gate.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 628fe8d..ef836a0 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -116,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: @@ -157,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: