Skip to content

🔒(ci) harden every workflow install and pin every action to a hash - #1607

Draft
davd-gzl wants to merge 5 commits into
suitenumerique:mainfrom
davd-gzl:fix/pin-workflow-installs
Draft

🔒(ci) harden every workflow install and pin every action to a hash#1607
davd-gzl wants to merge 5 commits into
suitenumerique:mainfrom
davd-gzl:fix/pin-workflow-installs

Conversation

@davd-gzl

@davd-gzl davd-gzl commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Every install step in the four workflow files runs code shipped by the packages it fetches, and every uses: names a tag or a branch, either of which its owner can repoint. SonarCloud counts 60 security findings across those files, 30 issues and 30 hotspots.

This clears 55 of the 60, and it does not turn the gate green.

The five left are MAJOR, and the gate reads the worst finding in a pull request's own new code rather than a count, so new_security_rating is C against the A it asks for. Three are the uv sync --locked --all-extras calls, where --no-build refuses the project's own editable install and brevo-python==1.2.0 with it. Two are the pip install --user .[dev] calls, where no lock file names the version installed. Clearing them needs a lock file for src/summary or a maintainer marking the five, and neither is in this diff.

meet.yml becomes ci.yml on the way, which git records as a rename of two changed lines.

Five changes follow.


pip install, uv run: a step runs whatever the package it fetches asks it to

Both pip install calls take --only-binary=:all:, so a wheel is unpacked and no setup.py runs, and every uv run takes --no-sync --no-build.

Two flags because they answer two rules. --no-sync fixes the version, by using the environment the sync above provisioned; building is a separate guarantee that nothing on the line carried.


gitlint: a wheels-only install drops it a minor version

gitlint 0.19.1 depends on gitlint-core[trusted-deps], and that extra pins sh==1.14.3, which ships no wheel. So --only-binary=:all: on the package name does not fail, it backtracks:

$ pip install --only-binary=:all: requests gitlint
Successfully installed ... gitlint-0.18.0 gitlint-core-0.18.0 sh-1.14.2

gitlint-core is the same console script without that extra, and resolves sh to 2.4.0, which ships one.


npm ci, yarn install, curl: five installs and a download run unconstrained

Nothing in src/frontend, src/sdk/library or src/mail passed --ignore-scripts, so any package in those trees could execute code on the runner while it installed. The three package.json files declare no postinstall and no prepare, so every script now skipped belongs to a dependency.

npm install -g yarn took whatever the tag pointed at, and now reads yarn@1.22.22, the version it already resolved to.

The dockerize download followed redirects to any protocol. --proto and --proto-redir hold it to https.


uses:: a tag and a branch both move under the pin

actions/checkout@v6 is a tag its owner can repoint, and numerique-gouv/action-trivy-cache@main is a branch that changes on every push. All 65 uses now name a 40-character commit, with the version it resolved to in a trailing comment.

Every pin is the commit its old ref pointed at, so nothing here changes what runs. One pass proves it, dereferencing each comment and comparing:

$ grep -rhoP 'uses: \K\S+@\w40 # v\S+' .github/workflows/ | sort -u |
  while read -r ref _ ver; do repo=${ref%@*}; sha=${ref#*@}
    [ "$(gh api repos/$repo/commits/$ver --jq .sha)" = "$sha" ] && echo ok || echo "FAIL $repo $ver"; done | sort | uniq -c
     12 ok

Seven uses carry a branch name rather than a version, five of action-trivy-cache@main plus action-argocd-webhook-notification@main and helm-gh-pages@add-overwrite-option. Those repositories publish no tag, so those workflows were following a branch and now stay where they are, and Dependabot cannot move them either.

SonarCloud exempts GitHub's own actions, 28 of the 65. Pinning those too leaves one style in the file. .github/dependabot.yml is new, and it moves the 58 that carry a version.


meet.yml: the file is named after the repository, not its contents

The repository is already meet, so the file name says nothing a reader can use. It becomes ci.yml, and the workflow's name: becomes CI.

The other changed line is the print-statement check, whose exclusion names meet.yml by hand, because the step's own grep "print(" carries the pattern. A rename puts that deleted line back inside the diff and the job fails on its own removed text, so the exclusion now covers .github/workflows/**.

A package could run arbitrary code on the runner while installing,
and the versions were resolved rather than taken from the pins.
The two pip calls now take wheels only, and uv run no longer
resolves an environment of its own.
gitlint-core keeps the linter at 0.19.1, which the wheels-only pin was
resolving down to 0.18.0 through an sdist-only sh. --ignore-scripts
stops three npm ci, one yarn install and one npm install -g running
the scripts of what they fetch, and yarn is pinned to 1.22.22. curl
holds the dockerize download and its redirects to https.
A tag and a branch both move, so actions/checkout@v6 and
numerique-gouv/action-trivy-cache@main ran whatever the owner had last
pushed. Each of the 65 uses now names a 40-character commit, with the
version it resolved to in a trailing comment.

dependabot.yml keeps the hash and that comment moving together.
The repository is already called meet, so the file name said nothing
about what the workflow holds. The print-statement check now excludes
the whole workflows directory rather than one file by name: its own
grep carries the literal print(, so the rename would otherwise match
it on the deleted lines and fail the job.
--no-sync already stops uv run resolving an environment of its own,
and that is what the version findings were about. Building is a
separate guarantee that nothing on the line carried, so --no-build
now says it outright. It is inert beside --no-sync, and the three
lint jobs are unchanged.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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