Audit 003 · continuous integration
28 findings, one demoted
In google/gvisor at commit 2f5253ab, a structural audit of the 15 GitHub Actions workflows returns 28 findings. I re-ran the instrument against the live commit, recounted every number with curl alone, and then checked the one finding it graded CRITICAL against its own mechanism. That one does not survive. It is demoted below, with the shell output that demotes it.
What the surface returns
The instrument reads YAML as text and classifies. Five codes came back at this commit:
| Code | Locations | What it names |
|---|---|---|
SC1_ROOT_PERMISSIONS_MISSING | 12 | the workflow declares no root permissions:, so the effective ceiling is the repository or organisation default |
SC1_JOB_WRITE_SCOPE_REVIEW | 10 | a job declares explicit write scopes — the correct form, published for review |
GATE_RC_1_AUTORIDAD_CONTAMINADA | 4 | the job holds authority and executes code it does not control |
SC1_ROOT_WRITE_INHERITED | 1 | the root grants write that jobs inherit without asking |
GATE_RC_4_SECRETO_EXPUESTO | 1 | a secret value is interpolated into a run: block |
Twelve of those 28 depend on a fact I did not measure: the organisation's default permission for GITHUB_TOKEN. If that default is read-only, the twelve inherit read and nothing is exposed. So they are published here as undetermined, not as exposed. A count that needs an unread default is not a severity.
The one graded CRITICAL, and why it does not stay critical
In .github/workflows/go.yml, job generate, four of the file's five ${{ secrets.* }} expressions sit inside run: blocks. Line 27 is the clearest:
- id: setup
run: |
if ! [[ -z "${{ secrets.GO_TOKEN }}" ]]; then
echo has_token=true >> "$GITHUB_OUTPUT"
GitHub substitutes that expression into the script text, before the shell parses it. So the secret's value is not data — it is syntax. I ran the three cases:
# A · a value containing a double quote
$ bash -c 'if ! [[ -z "abc"def" ]]; then echo has_token; fi'
bash: -c: line 0: unexpected EOF while looking for matching `"'
rc=2 # the step dies; has_token is never written
# B · a value containing a command substitution
$ bash -c 'if ! [[ -z "$(echo EJECUTADO >&2)" ]]; then echo has_token; fi'
EJECUTADO # the value executes on the runner
rc=0
# C · the same quoted value, passed through env:
$ TOKEN='abc"def' bash -c 'if ! [[ -z "${TOKEN}" ]]; then echo has_token_env; fi'
has_token_env # inert — this is the one-line fix
rc=0
What that proves, and what it does not. It proves the value is parsed as shell. It does not prove an attacker can reach it: no event field, no pull-request input and no matrix value flows into that expression at this commit, and GitHub masks secret values in the job log — the value does not land in the log, contrary to the first draft of the machine report, which said it would. The trigger is the secret's own value: an operator pasting a token with a quote, a backslash or a newline.
So the honest severity is latent hygiene — a failure that becomes an outage or a self-inflicted execution, never a leak to a third party. The classifier said CRITICAL. The mechanism says otherwise, and the mechanism is the one with evidence behind it. That is the only kind of correction worth publishing: the one that removes a number from my own side of the ledger.
Reproduce it in 60 seconds
The script reads a byte-exact copy of the 15 audited files that ships inside the artifact, under objeto/ with the licence that governs them, and recounts six published numbers. No network, no token, no clone, no YAML parser — the surface is read as text, which is what the finding is about. A report that can only be checked with a connection cannot be checked in a courtroom, on a plane, or in ten years.
| # | Published count | Value |
|---|---|---|
| 1 | workflow files read | 15 |
| 2 | workflows with no root permissions: | 12 |
| 3 | workflows interpolating a secret inside run: | 1 |
| 4 | secret interpolations inside run: blocks | 4 |
| 5 | secret interpolations outside run: (the correct place) | 7 |
| 6 | occurrences inside go.yml | 4 |
git clone --depth 1 https://github.com/alexisromtz-lab/la-auditora.git
cd la-auditora/portafolio/auditoria-003-gvisor
python3 reproduce.py # rc 0 = the 6 published counts reproduce, offline
python3 reproduce.py --control # rc 0 = the counter moves when the input moves
python3 reproduce.py --remoto # witness: the pinned copy still equals the commit
python3 reproduce.py --self-test # rc 0 = the counter tells `run:` from `env:`
Exit code 0 means all six counts reproduce. Exit 1 means at least one published number is wrong — that is the direction that matters, and it is measured, not asserted: the built-in control perturbs the copy and publishes which counts move.
| Perturbation of the copy | Count that must move |
|---|---|
declare a root permissions: in build.yml | sin_permissions_raiz 12 → 11 |
add one ${{ secrets.* }} outside run: in go.yml | ocurrencias_fuera_de_run 7 → 8 |
A counter that returns the same numbers on a perturbed input is a counter that always says green. --control exits 0 only when both moves are observed, and the same values are published in ficha.json so no reader has to take this paragraph's word for it.
--remoto is a witness, not a second verdict: it re-downloads the 15 paths from the pinned commit and compares bytes. Today it returns 15 of 15 identical. If Google ever rewrites that history, the pinned copy stops being the object and the witness says TESTIGO DIVERGE — without ever changing the exit code of the audit, so a network outage cannot make a correct audit look wrong.
The number that moved: 31 → 28
The ledger entry for this repository holds 31 findings. This report holds 28. Neither number is wrong; they are two commits:
| Source | Ref | UTC | Findings |
|---|---|---|---|
| ledger entry | 6ff6a1f4 | 2026-11-11 23:24:07 | 31 |
| this report | 2f5253ab | 2026-11-12 00:40:58 | 28 |
The two refs are 8 commits apart (compare 2f5253ab...6ff6a1f4 → ahead 0 · behind 8). Three findings were closed in those eight hours. Publishing either number without its ref would produce a figure that cannot be reproduced — so the ref publishes next to the number, always. Re-running the instrument against the live commit the same day returned 28 again, with the same five codes and the same counts.
What this does not prove
It does not read the organisation's default permissions. Twelve findings assume an inherited ceiling I did not measure. They are undetermined, and a reader who knows the default can close them in one line.
It does not observe runtime. The Actions history for this repository was not read. Nothing here claims that any workflow has fired wrongly on any date.
It publishes no line numbers from the instrument. The instrument judges YAML as text; the line numbers above come from reading the file, and the reproduce script reads the same text.
Declared out of scope by the instrument itself: reusable workflows (workflow_call), composite actions hosted in other repositories, OIDC trust policy at the destination registry, and the interior of tree scripts — make and cmake read the tree, so they fall outside the authority check.
No patch is proposed. This page reports measurements. The one-line remedy for the demoted finding is shown because it costs one line and it is measured, not because it is a request to anyone.
What I did not find — and looked for
A report that only lists hits is not a report. These came back clean at the same commit, measured directly on the fetched files:
| Check | Result |
|---|---|
remote uses: references pinned to a full 40-hex SHA | 23 / 23 — zero floating tags, zero branch refs |
continue-on-error: true | 0 occurrences — no step can swallow its own failure |
secret interpolations placed in env: or with: instead of run: | 7 — the recommended form, used correctly |
23 of 23 pinned actions is better than almost every repository I have measured. This is a surface that is otherwise careful, which is exactly why the four run: interpolations are worth naming — and why they are named as hygiene, not as an alarm.
Need this on your repository?
The audit is the product, and the receipt is the point. Every finding ships as a pinned commit, the blob hash of the file it lives in, the exact command that reproduces it, and a declared list of what was not measured — including the severities I had to revise.