Skip to content

fix(gate-4): audit the LOCK, and stop reporting "could not audit" as "CVEs" - #135

Merged
rubenvdlinde merged 2 commits into
mainfrom
fix/gate4-audit-the-lock
Aug 3, 2026
Merged

fix(gate-4): audit the LOCK, and stop reporting "could not audit" as "CVEs"#135
rubenvdlinde merged 2 commits into
mainfrom
fix/gate4-audit-the-lock

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Found by the gates' first real CI run, on ConductionNL/openbuild#104. gate-4 reported composer-audit: FAIL — CVEs or advisories. There are no CVEs — auditing the same lock reports No security vulnerability advisories found.

What was actually wrong

composer audit with no vendor/ present does not audit the lock, and what it does instead depends on the composer version. Both behaviours are wrong:

composer behaviour with no vendor/ gate-4 said
>= 2.8 No installed packages found. Please run "composer install" ... or pass "--locked", exit 1 "CVEs or advisories" — a security finding for a run that audited nothing
2.7.x No packages - skipping audit, exit 0 PASS — a silent fail-open, having audited nothing

The second is the more dangerous one, and it is why this could not be fixed by "just run composer install in CI first": on any runner with an older composer, gate-4 has been passing without auditing anything, and that output is indistinguishable from a clean audit.

The fix

  • Audit --locked whenever a composer.lock exists. The lock is the right object anyway — it is what CI installs and what pins the transitive tree — and it needs no vendor/, so the gate stops depending on whether some earlier step happened to run composer install.
  • An exit 0 whose output says "no packages" is a FAILURE, not a pass. Auditing nothing is never a clean audit.
  • "Could not run" is reported in those words, and explicitly not as a CVE finding, so a configuration error and a real advisory are distinguishable at a glance.

Positive control — three directions

Clean php:8.3-cli container, composer 2.10.2, no vendor/:

input result
openbuild's real lock [gate-4] composer-audit: PASS — log: No security vulnerability advisories found.
lock pinning guzzlehttp/guzzle 6.5.0 [gate-4] composer-audit: FAIL — CVEs or advisories, log names the package and advisories: Found 16 security vulnerability advisories affecting 2 packages, GHSA-h95v-h523-3mw8
no lock, no vendor [gate-4] composer-audit: FAIL — audit COULD NOT RUN (no installed packages and no lock to audit) — NOT a CVE finding

Worth recording: composer 2.10 refuses to resolve a vulnerable package at all by default (policy.advisories.block), so the middle control needed that policy disabled to even produce a lock. The first attempt silently produced no lock — and the new code correctly reported "could not run" instead of inventing a finding, which is the behaviour under test.

The package's 13 invariant assertions still pass.

Why this matters beyond gate-4

This is the same defect class the package's coverage block exists for: a check that cannot run must never be indistinguishable from a check that ran and found nothing. gate-4 had both failure modes at once depending on the runner, and neither was visible in the output.

Conduction Release Bot and others added 2 commits August 3, 2026 20:55
…build outputs

`head` closes the pipe as soon as it has its 40 lines, `tr` dies of SIGPIPE, and
`set -o pipefail` three lines above turns that into a FAILED JOB — for a
log-preview line with no bearing on whether the build worked.

    tr '\0' '\n' < /tmp/frontend-build-files.z | head -40

It only fires when the producer is still writing as head leaves, so it is
invisible on small builds and certain on large ones:

  openregister    build writes <40 files   -> Frontend Build passed
  nextcloud-vue   dist is thousands        -> failed EVERY run since the gate
                                              landed (835918f, today 16:40)

Reproduced outside CI. 200k NUL-separated entries:

    $ bash -c "set -euo pipefail; tr '\0' '\n' < big.z | head -40 >/dev/null; \
               echo REACHED-AFTER"
    exit=141          # 128+13 = SIGPIPE, and REACHED-AFTER never prints

    $ bash -c "set -euo pipefail; tr '\0' '\n' < big.z | sed -n '1,40p' >/dev/null; \
               echo REACHED-AFTER"
    REACHED-AFTER
    exit=0

`sed` reads its input to the end, so there is nothing to break, and it still
prints exactly 40 lines. The "…" trailer now says how many files were elided
instead of implying there might be none.

SAME BUG, WORSE FAILURE MODE, same file — the Quality Report step:

    COVERED=$(grep -oP 'coveredstatements="\K[0-9]+' clover.xml | head -1 || echo "0")

That step also sets pipefail, and here `|| echo "0"` does not rescue the value:
it APPENDS "0" to what head already printed, so the arithmetic below receives
"1234\n0". Measured on a 60k-file clover.xml:

    old: COVERED=[0
    0]
    new: COVERED=[0]

Reporting a wrong coverage number is worse than failing, because nobody
re-verifies a number that rendered. Switched to `grep -oPm1`, which stops grep
itself and removes the pipe rather than working around it.

No other `| head` under pipefail remains in this workflow.
…"CVEs"

Found by the gates' first real CI run, on openbuild#104: gate-4 reported
`composer-audit: FAIL — CVEs or advisories`. There were no CVEs. Auditing the
same lock reports "No security vulnerability advisories found."

`composer audit` with no vendor/ present does not audit the lock, and what it
does instead depends on the composer version. BOTH behaviours are wrong:

  composer >= 2.8  "No installed packages found. Please run composer install
                    ... or pass --locked", exit 1 — so the gate announced a
                    security finding for a run that found nothing and audited
                    nothing. A configuration error wearing a CVE's clothes.
  composer 2.7.x   "No packages - skipping audit", exit 0 — a SILENT FAIL-OPEN.
                    The gate PASSED having audited nothing at all.

The second is the more dangerous one, and it is the reason this could not be
left as "just make CI run composer install first": on any runner with an older
composer, gate-4 has been passing without auditing anything, and that is
indistinguishable from a clean audit in the output.

Now:
- audit `--locked` whenever a composer.lock exists. The lock is the right
  object anyway — it is what CI installs and what pins the transitive tree —
  and it needs no vendor/, so the gate no longer depends on whether some
  earlier step happened to run `composer install`.
- an exit 0 whose output says "no packages" is treated as a FAILURE, not a
  pass. Auditing nothing is never a clean audit.
- "could not run" is reported in those words and explicitly NOT as a CVE
  finding, so the two are distinguishable at a glance.

Positive control, three directions, in a clean php:8.3-cli container with
composer 2.10.2 and no vendor/:

  openbuild's real lock          -> [gate-4] PASS  ("No security vulnerability
                                    advisories found.")
  lock with guzzlehttp/guzzle    -> [gate-4] FAIL — CVEs or advisories, and the
  6.5.0 pinned in                   log names the package and the advisories
                                    ("Found 16 security vulnerability
                                    advisories affecting 2 packages",
                                    GHSA-h95v-h523-3mw8, ...)
  no lock and no vendor          -> [gate-4] FAIL — "audit COULD NOT RUN ...
                                    NOT a CVE finding"

Worth noting for the second case: composer 2.10 refuses to resolve a
vulnerable package at all by default (policy.advisories.block), so the control
needed that policy disabled to even produce the lock. The first attempt at it
silently produced no lock — and the new code correctly reported "could not
run" rather than inventing a finding, which is itself the behaviour under test.
@rubenvdlinde
rubenvdlinde merged commit f4d9756 into main Aug 3, 2026
7 checks passed
rubenvdlinde added a commit to ConductionNL/openbuild that referenced this pull request Aug 3, 2026
The first real CI run of the gates on this PR failed on gate-4:
`composer-audit: FAIL — CVEs or advisories`. There are no CVEs — auditing this
repo's lock properly reports "No security vulnerability advisories found."

`composer audit` with no vendor/ present does not audit the lock. On the
runner's composer it errors and exits non-zero, which gate-4 reported as a
security finding. On composer 2.7.x the same situation prints "No packages -
skipping audit" and exits 0 — a silent fail-open that passes having audited
nothing.

Fixed in the package (ConductionNL/.github#135, released as v1.0.1): gate-4
audits `--locked`, treats an exit 0 that audited no packages as a failure, and
reports "could not run" in those words rather than as a CVE finding.

Both the workflow pin and composer.lock move to v1.0.1 so the two paths into
the gates cannot disagree about which version this repo is on.
rubenvdlinde added a commit to ConductionNL/openbuild that referenced this pull request Aug 3, 2026
…104)

* build(quality): run the Hydra gates as part of composer check:strict

openbuild is the pilot for conduction/hydra-gates. The gates have until now only
run inside hydra's own containers, which means an agent finishing a task here
could report done without any of the 61 mechanical gates having looked at its
diff. Wiring them into check:strict makes "gates pass" part of the definition of
done in the same command everything else already runs.

The gates are diff-scoped per ADR-020, so this does NOT import openbuild's
inherited debt. A full-repo run of this tree fails 16 gates today (img-alt,
button-name, table-headers, spec-anchor-existence and friends); scoped to a PR's
own diff, only what that PR touched is enforced. `composer gates:full` is
available for the audit view, and is deliberately not what check:strict runs.

check:strict keeps its 0/1 contract, but the gate exit code is preserved
separately and reported, because it carries the failure COUNT and flows route on
it. 99 is distinguished from a gate failure in the summary: it means the gates
could not run at all, which is a configuration error and not a clean tree.

The dependency is declared against a `path` repository at ../hydra, which is the
fleet's checkout layout. That is the part of this change that is provisional —
see the PR description; how hydra-gates should be DISTRIBUTED (private Packagist,
a Satis mirror, or a split public repo) is an open decision, and a path
repository is the shape that is verifiable today without one.

* build(quality): lock conduction/hydra-gates at dev stability

A path repository derives its version from the checked-out branch, so the
default minimum-stability of 'stable' rejects a '*' constraint outright:
'found conduction/hydra-gates[dev-main, ...] but it does not match your
minimum-stability'. '@dev' is the constraint that resolves, and it keeps
working once the package is eventually tagged.

* build(quality): resolve hydra-gates from the public package, not a path repo

The `path` repository at `../hydra` is what blocked this PR. It works on a
developer machine and fails in CI with `Source path "../hydra" is not found`,
because the fleet's sibling-checkout layout does not exist in a CI job and
hydra is private, so no runner can fetch it.

conduction/hydra-gates now lives in ConductionNL/.github, which is PUBLIC and
already owns the shared workflows (ConductionNL/.github#131). Pointing at it
needs no credentials in this repo or on any runner.

- repositories: `path ../hydra` -> `vcs https://github.com/ConductionNL/.github.git`
  with `"no-api": true`, so composer clones over git instead of the GitHub API
  and a rate-limited unauthenticated runner cannot become a failed install.
- require-dev: `@dev` -> `^1.0`, resolving to the v1.0.0 tag. That also drops
  the dev-stability requirement the previous commit needed, because a path
  repository derives its version from the checked-out branch and a tag does not.
- composer.lock pins commit fdad2546f2ac68aa64be5fecef898784c1847538.

Nothing else changes. `composer gates`, `composer gates:full` and the gate
handling inside `check:strict` are untouched, including the part that captures
the gate exit code separately: it carries the FAILURE COUNT, and 99 ("could not
run at all") is still reported distinctly from a gate failure so a
configuration error can never read as a clean tree.

Also merged origin/development in. The branch predated the fleet-wide
timeout-minutes work, so it was carrying a silent revert of the bounds on
exporter-e2e.yml and pull-request-lint-check.yaml.

* ci(quality): actually run the gates in CI, not just in a local composer script

Wiring the gates into `composer check:strict` was a local-only change. Nothing
in this repository's CI invokes check:strict — no workflow does, verified by
grepping .github/workflows — so this PR would have merged green with the 61
gates never having executed on any diff. A gate that runs only on a developer's
machine is not a gate, and a green that never ran it is the failure mode the
gates exist to catch.

The shared quality workflow now ships a `hydra-gates` job (ConductionNL/.github#131),
opt-in per repo and defaulting to off. openbuild is the pilot, so it opts in.

`hydra-gates-ref` is pinned to the v1.0.0 tag rather than tracking `main`, so a
change to the gates cannot move this repo's verdict without a commit here to
move the pin.

The job resolves the diff base from the PR's real target branch, checks out the
app with fetch-depth: 0 (a shallow clone has no base to diff against), and
reports exit 99 — "could not run at all" — distinctly from "N gates failed".

* build(quality): bump hydra-gates to v1.0.1 for the gate-4 audit fix

The first real CI run of the gates on this PR failed on gate-4:
`composer-audit: FAIL — CVEs or advisories`. There are no CVEs — auditing this
repo's lock properly reports "No security vulnerability advisories found."

`composer audit` with no vendor/ present does not audit the lock. On the
runner's composer it errors and exits non-zero, which gate-4 reported as a
security finding. On composer 2.7.x the same situation prints "No packages -
skipping audit" and exits 0 — a silent fail-open that passes having audited
nothing.

Fixed in the package (ConductionNL/.github#135, released as v1.0.1): gate-4
audits `--locked`, treats an exit 0 that audited no packages as a failure, and
reports "could not run" in those words rather than as a CVE finding.

Both the workflow pin and composer.lock move to v1.0.1 so the two paths into
the gates cannot disagree about which version this repo is on.

---------

Co-authored-by: Ruben van der Linde <juan.claude@conduction.nl>
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