Skip to content

Least privilege for the workflow tokens, and a test that holds it - #74

Merged
HackTuah merged 2 commits into
mainfrom
fix/token-permissions
Sep 23, 2026
Merged

HackTuah merged 2 commits into
mainfrom
fix/token-permissions

Conversation

@HackTuah

Copy link
Copy Markdown
Member

OpenSSF Scorecard scored this repository zero on Token-Permissions in its first ever successful run. The cause was not subtle.

Workflow Before After
gate.yml no top-level permissions: at all contents: read
package.yml no top-level permissions: at all contents: read; the attesting job keeps its own id-token/attestations write
fips-image.yml packages: write at the top level contents: read; packages: write moved to the image job

A workflow with no permissions: block runs with whatever the repository default happens to be. A write granted at the top level is granted to every job the workflow will ever have, including the ones nobody has written yet. Neither is a theoretical problem in a project whose own security model is built on least privilege for effects.

The test is the point of this change

A forgotten top-level write costs nothing visible: nothing fails, the token is simply broader than the work needs, and the next workflow added inherits the habit. That is exactly the class of thing this project holds in the build rather than in a reviewer's attention.

test/workflows_test.exs gains a third property beside the two it already held (everything parses; every action pinned to a full commit SHA): every file under .github/workflows declares a top-level permissions: block, and it grants no write. The population comes from git ls-files, so a workflow added without a thought for it is caught by its own existence.

Demonstrated red rather than asserted. With the block removed from gate.yml:

.github/workflows/gate.yml 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
Result: 3/4 passed

The other Scorecard findings, not silently accepted

  • Code-Review 0 and Branch-Protection 4 both need someone other than the author approving changes. docs/06-risk-register.md R22 already tracks this and states the lift condition.
  • Maintained 0 wants ninety days of history this repository does not have yet.
  • Signed-Releases −1 is slice 101.
  • Fuzzing 0, SAST 0: Scorecard does not recognise sobelow, which runs on every commit.

Evidence

mix gate exit 0; 576 passed, 18 excluded; coverage 80.31%. actionlint clean on all four workflows (two pre-existing shellcheck style notes, untouched by this change).

@HackTuah

Copy link
Copy Markdown
Member Author

Correction: this change broke the FIPS leg, and the fix is in

fips-tag failed on this branch. The cause was the change itself, and the mechanism is worth recording because it does not look like what it is.

Before this PR, gate.yml declared no top-level permissions:, so every job inherited the repository default — which is read on every scope, including packages. Declaring contents: read at the top narrowed each job's token to exactly that, and silently took packages: read away from the two jobs that depended on it.

What that looked like from the outside:

  • the fips-image workflow reported the tag already published and skipped its build;
  • fips-tag, in the same commit, logged in to the registry, polled for thirty minutes, and failed with was not published.

Two checks disagreeing about whether an image exists is what an authorization failure looks like when the API answers "not found" instead of "not allowed".

Both jobs now declare contents: read and packages: read, with the reason in a comment beside them. The fips job needs it as much as fips-tag does: the runner pulls its container before the job's first step, so without the scope it fails with manifest unknown and no output at all.

This is the argument for the required checks being what they are — it was caught on the branch, not after merge.

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>
…k 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 <aylacroft@proton.me>
@HackTuah
HackTuah force-pushed the fix/token-permissions branch from f519564 to cf91290 Compare September 23, 2026 19:56
@HackTuah
HackTuah merged commit 72bad64 into main Sep 23, 2026
13 checks passed
@HackTuah
HackTuah deleted the fix/token-permissions branch September 23, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant