Skip to content

Commit 2aa1196

Browse files
committed
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 <aylacroft@proton.me>
1 parent e302c92 commit 2aa1196

4 files changed

Lines changed: 73 additions & 7 deletions

File tree

‎.github/workflows/fips-image.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,19 @@ on:
1717
- .github/workflows/fips-image.yml
1818
workflow_dispatch:
1919

20+
# `packages: write` moved from here to the job below on 2026-09-23 (OpenSSF Scorecard
21+
# Token-Permissions). A write granted at the top level is granted to every job the workflow will
22+
# ever have, including ones nobody has written yet; granted at the job level it is granted to the
23+
# one job that pushes the image, and a reviewer sees it next to the push.
2024
permissions:
2125
contents: read
22-
packages: write
2326

2427
jobs:
2528
image:
29+
permissions:
30+
contents: read
31+
# The registry push at the end of this job, and nothing else here.
32+
packages: write
2633
runs-on: ubuntu-latest
2734
steps:
2835
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

‎.github/workflows/gate.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ on:
66
push:
77
pull_request:
88

9+
# Least privilege for the workflow's token, added 2026-09-23 after OpenSSF Scorecard scored this
10+
# repository 0 on Token-Permissions: neither this workflow nor `package.yml` declared any
11+
# top-level permissions, so both ran with whatever the repository default happened to be. Every
12+
# job here reads the tree and writes nothing back, so the whole workflow gets read and any job
13+
# that later needs more has to say so itself and be seen saying it in review.
14+
permissions:
15+
contents: read
16+
917
jobs:
1018
gate:
1119
runs-on: ubuntu-latest

‎.github/workflows/package.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ on:
4040
- .github/workflows/package.yml
4141
workflow_dispatch:
4242

43+
# Least privilege for the workflow's token (2026-09-23, OpenSSF Scorecard Token-Permissions). The
44+
# default is read; the one job that needs more, the attesting one, declares it at the job level
45+
# where a reviewer sees it beside the step that uses it.
46+
permissions:
47+
contents: read
48+
4349
jobs:
4450
# Slice 002. The bill of materials is derived from `mix.lock` alone: it is the same document
4551
# whichever runner produces it, and `mix trinity.sbom` needs the project compiled in the dev

‎test/workflows_test.exs‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,31 @@ defmodule WorkflowsTest do
44
@moduledoc """
55
The continuous integration definitions are code, and they are checked like code.
66
7-
Two properties. Every workflow and the Dependabot configuration parse as YAML, so a malformed
7+
Three properties. Every workflow and the Dependabot configuration parse as YAML, so a malformed
88
one fails here rather than silently not running: GitHub reports a broken workflow file on its
9-
own page and not on the pull request, which is the wrong place for it to be discovered. And
10-
every third-party action is pinned to a full commit hash rather than a tag, because a tag can
11-
be moved to point at different code after review; this is the OpenSSF Scorecard
12-
`Pinned-Dependencies` check, held here rather than waited for.
9+
own page and not on the pull request, which is the wrong place for it to be discovered. Every
10+
third-party action is pinned to a full commit hash rather than a tag, because a tag can be moved
11+
to point at different code after review; this is the OpenSSF Scorecard `Pinned-Dependencies`
12+
check, held here rather than waited for. And every workflow declares a top-level `permissions:`
13+
block granting no more than read, so that a job wanting to write has to say so itself: Scorecard's
14+
`Token-Permissions` check, which this repository scored **zero** on until 2026-09-23 because two
15+
workflows declared nothing at all and ran with whatever the repository default happened to be.
16+
17+
The third property is the one worth a sentence about why a test rather than a habit. A top-level
18+
write is granted to every job the workflow will ever have, including the ones nobody has written
19+
yet, and the cost of forgetting is invisible: nothing fails, the token is simply broader than the
20+
work needs. That is exactly the class of thing a build should hold rather than a reviewer.
1321
1422
The population comes from the tree (`git ls-files .github`), so a workflow added without a
15-
thought for either property is caught by its own existence.
23+
thought for any of the three is caught by its own existence.
1624
"""
1725
use ExUnit.Case, async: true
1826

1927
@action_ref ~r{uses:\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+)@(\S+)}
2028

29+
# Anything that is not one of these is a write, whatever it is called.
30+
@read_only ~w(read none)
31+
2132
defp yaml_files do
2233
{out, 0} = System.cmd("git", ["ls-files", ".github"])
2334

@@ -54,4 +65,38 @@ defmodule WorkflowsTest do
5465
"tag can be moved to point at different code after it was reviewed"
5566
end
5667
end
68+
69+
test "every workflow declares a top-level permissions block, and it grants no write" do
70+
for file <- yaml_files(), String.contains?(file, "/workflows/") do
71+
permissions = file |> read_yaml() |> Map.get("permissions")
72+
73+
assert permissions != nil,
74+
"#{file} declares no top-level permissions, so its token is whatever the " <>
75+
"repository default happens to be. Declare `permissions: {contents: read}` and " <>
76+
"let any job that needs to write say so itself"
77+
78+
for {scope, level} <- top_level_scopes(permissions) do
79+
assert level in @read_only,
80+
"#{file} grants `#{scope}: #{level}` at the top level, which grants it to every " <>
81+
"job the workflow will ever have. Move it to the job that needs it"
82+
end
83+
end
84+
end
85+
86+
# `permissions:` is either a map of scopes, or one of the shorthands `read-all`/`write-all`.
87+
defp top_level_scopes("read-all"), do: []
88+
defp top_level_scopes("write-all"), do: [{"(every scope)", "write"}]
89+
defp top_level_scopes(map) when is_map(map), do: map
90+
defp top_level_scopes(other), do: [{"(unrecognised)", inspect(other)}]
91+
92+
defp read_yaml(file) do
93+
{json, 0} =
94+
System.cmd("python3", [
95+
"-c",
96+
"import sys,yaml,json; json.dump(yaml.safe_load(open(sys.argv[1])), sys.stdout)",
97+
file
98+
])
99+
100+
Jason.decode!(json)
101+
end
57102
end

0 commit comments

Comments
 (0)