From a53876eea50a24917d3cb25116bbff75e680491f Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:35:59 -0500 Subject: [PATCH] feat(workflow-lint): fail when a job holding a secret installs tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo install` executes `build.rs` for every crate it resolves — installing a tool is running third-party code. Keeping secrets out of the job that does it bounds what that code can reach. The defence is narrower than it first looks, and the comment in the step says so. Secrets are injected per step, not per job, so a build script cannot read a key that a later step declares. What it can do is alter a script or a requirements file in the workspace that the signing step then executes with the key in its environment. Splitting the job removes the shared workspace. `pip install --require-hashes` is exempt: it fixes the exact artifacts, so nothing is resolved. `cargo install --locked` is not — it pins the tool's version and then builds the tool's own tree. Gated behind `tooling-isolation`, default true, the same shape as `check-exec-bit` in shell-ci. Three repositories do not meet it today — epistle, helmly and helmly-agent — and they meet it when they next bump their pin deliberately, which is how a new check reaches consumers here. Verified against every cloned repository in the organisation: the five that were expected to pass do, and the three known violators produce exactly the findings an independent sweep had already recorded — one in epistle, two each in helmly and helmly-agent. Also exercised on a fixture covering the three edge cases: hash-pinned install with a secret passes, unpinned install with a secret fails, install without a secret passes. Refs Glyndor/apt#121 Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- .github/workflows/workflow-lint.yml | 128 ++++++++++++++++++++++++++++ docs/reusables/README.md | 2 +- docs/reusables/workflow-lint.md | 1 + 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index f5c4efe..28388a7 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -26,6 +26,10 @@ on: description: sha256 of that release's linux.x86_64 tarball; travels with shellcheck-version, so overriding one without the other fails the install type: string default: "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" + tooling-isolation: + description: Fail when a job that holds a secret also installs third-party tooling + type: boolean + default: true permissions: contents: read @@ -83,3 +87,127 @@ jobs: env: SHELLCHECK_BIN: ${{ env.SHELLCHECK_BIN }} run: actionlint -color "-shellcheck=${SHELLCHECK_BIN}" + + # A job that installs third-party tooling is running code we did not + # write: `cargo install` (and the other installers) executes build + # scripts over the whole dependency tree, so the secrets a job holds + # are reachable from code outside this organisation. Keeping the + # install out of any job that holds a secret bounds that reach to + # nothing. `pip install --require-hashes` is the exception because a + # hash-pinned install resolves nothing — the artifacts are fixed by + # the lockfile, so no surprise tree appears. + - name: A job holding a secret must not install third-party tooling + if: ${{ inputs.tooling-isolation }} + run: | + python3 - <<'PY' + import glob + import re + import sys + + import yaml + + # `secrets.` is how every GitHub Actions expression reaches a + # secret. `secrets: inherit` and `secrets:` declarations on + # reusable calls don't match — those are declaration blocks, not + # references, and `secrets:` does not contain a `.`. + SECRET_RE = re.compile(r"(?