Skip to content

fix: harden the workflows — spoofable actor check and overly broad permissions - #95

Merged
Stensel8 merged 5 commits into
mainfrom
claude/hugo-cicd-cleanup-if8vl9
Aug 14, 2026
Merged

fix: harden the workflows — spoofable actor check and overly broad permissions#95
Stensel8 merged 5 commits into
mainfrom
claude/hugo-cicd-cleanup-if8vl9

Conversation

@Stensel8

@Stensel8 Stensel8 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Alle zizmor-bevindingen in deze repo opgeruimd: 13 → 0.

Type of change

  • fix — bug fix (broken link, incorrect command, layout issue)

1. Spoofbare bot-check in de checksum-workflow (high)

error[bot-conditions]: spoofable bot actor check
  if: ... && github.actor == 'renovate[bot]'

Die if bewaakt een job met contents: write die pusht. github.actor is wie de laatste gebeurtenis veroorzaakte — bij een synchronize dus wie er als laatste pushte. Vervangen door de auteur van de PR, die vastligt bij het openen:

if: >-
  startsWith(github.head_ref, 'renovate/') &&
  github.event.pull_request.user.login == 'renovate[bot]'

Kwam mee uit het Stensel8/scripts-patroon; THectic.nl heeft dezelfde fix in een eigen PR.

2. Rechten stonden bovenaan, dus gold voor élke job (3× error, 1× warning)

Workflow Was Nu
hugo.yml pages: write + id-token: write voor build én deploy contents: read voor build, Pages-rechten alleen voor deploy
pr-checks.yml pull-requests: write voor alle 8 jobs alleen voor image-format en update-checklist
python-checks.yml geen permissions → erft de repo-default permissions: {} + contents: read op de job

De splitsing van hugo.yml heb ik nagezocht in plaats van gegokt: configure-pages in de build-job heeft alleen contents: read nodig; pages: write en id-token: write horen bij deploy-pages. Als ik dat verkeerd had ingeschat, was je Pages-deploy stuk gegaan.

3. Tien checkouts hielden hun token vast (warning)

artipacked: de credentials bleven in .git/config staan zolang de job liep, terwijl geen van deze jobs pusht. Nu overal persist-credentials: false — behalve in de checksum-bot, die wél pusht en het token daarom expliciet aan dat ene commando meegeeft.

4. Pages-URL ging rechtstreeks een run: in (note)

-    --baseURL "${{ steps.pages.outputs.base_url }}/"
+  env:
+    BASE_URL: ${{ steps.pages.outputs.base_url }}
+    --baseURL "${BASE_URL}/"

Een expressie in een run: wordt geëxpandeerd vóórdat bash hem ziet.

Waarom dit nu pas opviel

Code scanning faalt alleen op alerts die een PR nieuw introduceert. Deze stonden er al, dus ze bleven onder de radar. In hugo-template waren de workflows nieuw — daar sloeg het wél aan, en dat bracht ook de spoofbare check in deze repo aan het licht.

Getest

zizmor: No findings to report. actionlint: groen.

Checklist

  • PR title follows the commit convention
  • Both EN and NL versions updated (if applicable) — n.v.t., geen contentwijziging
  • Media is in AVIF format (not PNG/JPG) — n.v.t.
  • No broken image references — n.v.t.
  • Tested locally with hugo server — n.v.t.; actionlint en zizmor lokaal gedraaid, en de Pages-rechten nagezocht in de documentatie

🤖 Generated with Claude Code

https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s

Two weaknesses in the workflow added last week, both found by code scanning
once the same pattern landed in hugo-template, where zizmor's findings are
visible on the pull request.

github.actor == 'renovate[bot]' guards a job that has contents: write and
pushes. actor is whoever triggered the most recent event, which on a
synchronize is whoever pushed last, and zizmor rates comparing it to a bot
name as spoofable. Replaced with the pull request's author, which is fixed
when the pull request opens and cannot be set to another account.

The checkout also left the token in .git/config for the whole job, including
while the script downloads release tarballs off the internet. It now checks
out with persist-credentials: false and hands the token to the push alone.

Both came in from the Stensel8/scripts pattern this was modelled on. Scoped
to update-checksums.yml on purpose: zizmor reports further findings in the
older workflows here, and those are a separate piece of work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s
Copilot AI lite review requested due to automatic review settings August 14, 2026 16:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

zizmor reported thirteen findings across the older workflows here, all of
them predating the checksum bot. This clears the lot.

Permissions were granted at the top of each workflow, which hands them to
every job in it:

- hugo.yml gave pages: write and id-token: write to the build job as well as
  the deploy job. Only deploy-pages needs those; configure-pages in the build
  job needs contents: read and nothing more. Split accordingly.
- pr-checks.yml gave pull-requests: write to all eight jobs. Only the two
  that write to the pull request need it: image-format, which posts a comment
  about non-AVIF images, and update-checklist, which rewrites the
  description. The rest read a checkout.
- python-checks.yml declared no permissions at all, so it inherited whatever
  the repository default happens to be.

Ten checkouts kept the token in .git/config for the duration of their job
without needing it; none of these push. They now pass persist-credentials:
false.

The Pages base URL was interpolated straight into a run: block, where an
expression is expanded before bash sees it. It goes through env now.

Verified against the actual requirements rather than trimmed by guesswork:
the documented split is contents: read for configure-pages, pages: write and
id-token: write for deploy-pages.

zizmor is clean on this branch, and actionlint passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s
@Stensel8 Stensel8 changed the title fix: harden the checksum bot against a spoofable actor check fix: harden the workflows — spoofable actor check and overly broad permissions Aug 14, 2026
…ksum

A failed install reported success. When nmcli rejects the certificate
configuration, run_nmcli returns False instead of exiting, so the caller can
retry without the explicit CA bundle. The return value of that retry was
discarded, so when it failed too the script carried on to announce "eduroam
profile created successfully" and exited 0 with no profile created.
Reproduced with an nmcli stub that fails both attempts; it now reports the
failure and exits 1.

--silent did not silence the prompts. Only show_message consulted the flag;
prompt_input looked at gui_tool alone, so a --silent run on a desktop still
opened a zenity box asking for the username. The flag is documented as "Run
without GUI". Decided once in __init__ now, so the two cannot disagree.

The log sanitiser mangled ordinary sentences. Its password pattern took the
separator as optional and then consumed the following word, which turned the
message that explains the keyring prompt into "Your password=[REDACTED] now
be requested by your desktop keyring". Requiring an = or : fixes the prose
while still masking real values; checked against password=, "password: ",
"PASSWORD = ", and the username and email patterns, which are unaffected.

The docs tell people to verify the download against a published SHA-256, so
both the English and Dutch pages carry the new hash. It matches the file in
this commit.

bandit and pyflakes are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s

Copy link
Copy Markdown
Collaborator Author

Audit van saxion-eduroam.py erbij gedaan. Drie bugs gevonden, alle drie reproduceerbaar met een nmcli-stub.

1. Een mislukte installatie meldde succes

De ernstigste. run_nmcli geeft bij een certificaatfout False terug in plaats van af te breken, zodat de aanroeper het opnieuw kan proberen zónder expliciete CA-bundle. Maar de uitkomst van díé tweede poging werd weggegooid:

if not success:
    print("Note: Using system default trust store (implicit validation).")
    self.run_nmcli(cmd)      # <- return genegeerd

Met beide pogingen falend:

Note: Using system default trust store (implicit validation).
eduroam profile created successfully.       <- er is niets aangemaakt
[WARN] eduroam profile saved, but automatic activation failed.
--- exitcode: 0

Een student krijgt te horen dat het gelukt is, ziet daarna dat eduroam niet werkt, en heeft geen idee waarom. Nu wordt de fout gemeld en is de exitcode 1.

2. --silent opende alsnog een GUI

--silent is gedocumenteerd als "Run without GUI", maar alleen show_message keek naar die vlag. prompt_input keek naar gui_tool, dus op een desktop kwam er gewoon een zenity-venster:

ZENITY AANGEROEPEN met: --entry --title=Saxion eduroam Installer --text=Username (...)

Dat breekt elk niet-interactief gebruik. gui_tool wordt nu één keer in __init__ bepaald, zodat de twee niet uit elkaar kunnen lopen.

3. De log-maskering sloopte gewone zinnen

Het wachtwoordpatroon had de scheidingstekens optioneel ([=: ]*) en at daardoor het wóórd na "password" op. Het bericht dat juist uitlegt wat er gaat gebeuren, werd:

Your password=[REDACTED] now be requested by your desktop keyring
If you do not see a password=[REDACTED] open your network settings

Nu is een = of : verplicht. Getest dat echte waarden nog steeds gemaskeerd worden — password=, password: , PASSWORD = , plus de gebruikersnaam- en e-mailpatronen — en dat de uitleg heel blijft.

Checksum gebumpt

De docs vragen gebruikers de download te verifiëren, dus die hash moest mee. In beide talen bijgewerkt:

oud:    bef16a8ce91644a26cdd428f8dd0300de8e49ed72d9cbf4b6d39efea6d8facc1
nieuw:  1e5863d5d03fbe65878f31909a1bea4a47bc28f8d352bd18b676c363a8d9caa8

Gecontroleerd dat de oude hash nergens meer voorkomt en dat de nieuwe overeenkomt met het bestand in deze commit.

bandit, pyflakes, actionlint, zizmor en markdownlint: allemaal schoon.


Generated by Claude Code

claude added 2 commits August 14, 2026 22:28
The fallback path creates the eduroam profile without an explicit CA bundle,
and its comment called that "still secure via domain suffix validation". It
is not. With no ca-cert, wpa_supplicant does not verify the server
certificate against any trust anchor, so domain-suffix-match only inspects a
name inside a certificate nobody vouched for. Anyone can present a
self-signed certificate carrying ise.infra.saxion.net, which on eduroam means
a rogue access point can collect the MSCHAPv2 exchange from students who
followed our own guide.

The path stays, because a profile that cannot be created helps nobody, but it
now prints what it is doing and how to fix it rather than describing itself
as secure. The warning only appears when the fallback is actually taken; the
normal path is unchanged and silent.

Also: the keyring message named GNOME Keyring only, on a script whose whole
point is to work on GNOME and KDE alike. It names KWallet too now. Verified
the KDE path end to end with a kdialog stub: detection and both dialog
invocations are correct.

Checksum in both language pages bumped to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s
802-1x.ca-cert pointed at the distribution's CA bundle, so any of the roughly
150 public authorities in it was an acceptable signer for a server calling
itself ise.infra.saxion.net. domain-suffix-match does not close that gap: the
name is exactly what an attacker holding a certificate from any of those CAs
gets to choose.

The chain Saxion publishes through eduroam CAT is now embedded in the script
and written to ~/.config/saxion-eduroam/saxion-eduroam-ca.pem, and ca-cert
points there. Trust narrows from every public CA to USERTrust RSA
Certification Authority plus GEANT OV RSA CA 4, which is what the official CAT
installers configure. Both are shipped, as CAT does, so the chain still builds
if GEANT rotates the intermediate under the same root.

Embedded rather than shipped alongside, because the guide tells people to
download one file and verify one checksum. A second file would mean a second
download nobody checks.

The unvalidated fallback is gone. It existed for systems with no CA bundle,
which cannot happen now that the chain travels with the script, and connecting
without validation means handing a Saxion password to whatever access point
answered.

Both language pages describe the pinning, name the two authorities, and point
at the new path. Checksum bumped to match.

Verified against the certificates Saxion actually publishes: the embedded
block is byte-identical to the CAT export, the file written at runtime matches
it, nmcli is called once with the pinned path and never with the system
bundle, and a rejected certificate now fails with guidance instead of falling
through.

Note on wifi.cloned-mac-address: left at permanent deliberately. Saxion does
not register devices by MAC, but it does block one temporarily when it looks
like it is scanning or flooding, and a randomised address would let that be
shrugged off by reconnecting. Documented in the code rather than left to be
rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwrLVfDhkTVHC945s1kZ2s
@Stensel8
Stensel8 merged commit be68241 into main Aug 14, 2026
14 checks passed
@Stensel8
Stensel8 deleted the claude/hugo-cicd-cleanup-if8vl9 branch August 14, 2026 22:56
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.

3 participants