Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/fips-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 51 additions & 6 deletions test/workflows_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -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
Loading