Harden the Binjovi cluster surface #20

Merged
pipeline-bot merged 5 commits from fix/binjovi-platform-hardening into trunk 2026-08-26 09:38:54 +00:00
Owner

Five findings from the Binjovi adversarial review, cluster side. Each is a separate commit; bash tests/check.sh passes.

1. The image-digest policy only constrained images already named like ours. The binjovi-image-only matchCondition meant a pod in ns binjovi running alpine:latest was never matched, and even in a matched pod an added sidecar on a floating tag passed. The namespace is now the only selector: in binjovi and binjovi-staging every container, init container and ephemeral container must carry @sha256:. Scope widened to CREATE+UPDATE on pods plus UPDATE on pods/ephemeralcontainers. failurePolicy: Fail kept.

Verified against the live cluster: server-side dry-run applies cleanly, and all five running pods across both namespaces are digest-pinned, so the stricter policy admits exactly what is there today. Container and EphemeralContainer are different schema types, so the lists are never concatenated — a CEL runtime error under failurePolicy: Fail would deny every pod operation in those namespaces. Not verified: no live admission test. Post-deploy smoke: kubectl debug a pod in ns binjovi with a tag image (must deny) and a digest image (must admit).

2. The webhook signing secret was readable from every namespace. The Role was bound to forgejo-creds-reader, the identity behind ClusterSecretStore forgejo-credentials — which carries no conditions block, and in External Secrets that admits every namespace. A separate Role contains nothing while the identity stays shared. The signing value now has its own reader ServiceAccount and its own store scoped to ns binjovi, shaped like the other three Binjovi stores.

⚠️ Needs the matching binjovi-repo change (opened alongside this): manifests/credentials.yaml must name store binjovi-webhook instead of forgejo-credentials. They cannot land atomically. Between them that one ExternalSecret reports NotReady — not an outage, because its target has deletionPolicy: Retain and the value is static, so webhook verification keeps working. I chose the brief transient over a dual-grant window, because the alternative meant weakening a brand-new guard.

forgejo-credentials itself is untouched, as its blast radius is a separate decision. Its real consumers: flux-system (forgejo-git-auth), shipwright-build (forgejo-buildkit-secret, forgejo-clone-secret), workflows and workflows-alice (forgejo-admin-token, forgejo-pipeline-bot-token), pipelines-operator-system (forgejo-pipeline-bot-token). workflows-alice looks like tenant-collapse residue.

3. A comment described its blast radius backwards. The bootstrap Job's comment called the Forgejo database credential one of "two narrow powers" and contrasted it favourably with an administrator token. It is PGUSER=forgejo — full read/write on the whole Forgejo database, and stronger than a site-administrator API token, since a user-table write can set is_admin, replace a hash, or mint a session. Comment only; the Job is byte-identical.

4. Two unused Argo destinations removed. Nothing on either deploy branch targets shipwright-build or binjovi-probes. Because the Argo controller is cluster-admin, RBAC escalation prevention does not apply, so that reach was free privilege into the privileged BuildKit namespace.

5. The MinIO lifecycle rule was not idempotent. mc ilm rule add has no --id flag — confirmed in the pinned release's help — and three calls left three identical rules with three different IDs. Replaced with mc ilm rule import from a fixed-ID JSON. Verified by extracting the rendered Job script with kubectl kustomize and running it three times against a throwaway MinIO seeded with duplicates: exactly one rule each time. So the first live run also self-heals whatever has already accumulated.

Guards assert properties now, not the old strings, and were mutation-tested: 17 deliberate regressions, all 17 go red. That surfaced a real gap — grep -Fq 'failurePolicy: Fail' passed a weakened policy because the file's own header names the field in prose; it is now anchored with a regex, and two checks strip comments before grepping.

Five findings from the Binjovi adversarial review, cluster side. Each is a separate commit; `bash tests/check.sh` passes. **1. The image-digest policy only constrained images already named like ours.** The `binjovi-image-only` matchCondition meant a pod in ns `binjovi` running `alpine:latest` was never matched, and even in a matched pod an added sidecar on a floating tag passed. The namespace is now the only selector: in `binjovi` and `binjovi-staging` every container, init container and ephemeral container must carry `@sha256:`. Scope widened to CREATE+UPDATE on pods plus UPDATE on `pods/ephemeralcontainers`. `failurePolicy: Fail` kept. Verified against the live cluster: server-side dry-run applies cleanly, and all five running pods across both namespaces are digest-pinned, so the stricter policy admits exactly what is there today. Container and EphemeralContainer are different schema types, so the lists are never concatenated — a CEL runtime error under `failurePolicy: Fail` would deny every pod operation in those namespaces. **Not verified:** no live admission test. Post-deploy smoke: `kubectl debug` a pod in ns `binjovi` with a tag image (must deny) and a digest image (must admit). **2. The webhook signing secret was readable from every namespace.** The Role was bound to `forgejo-creds-reader`, the identity behind ClusterSecretStore `forgejo-credentials` — which carries no `conditions` block, and in External Secrets that admits every namespace. A separate Role contains nothing while the identity stays shared. The signing value now has its own reader ServiceAccount and its own store scoped to ns `binjovi`, shaped like the other three Binjovi stores. ⚠️ **Needs the matching binjovi-repo change** (opened alongside this): `manifests/credentials.yaml` must name store `binjovi-webhook` instead of `forgejo-credentials`. They cannot land atomically. Between them that one ExternalSecret reports NotReady — **not an outage**, because its target has `deletionPolicy: Retain` and the value is static, so webhook verification keeps working. I chose the brief transient over a dual-grant window, because the alternative meant weakening a brand-new guard. `forgejo-credentials` itself is **untouched**, as its blast radius is a separate decision. Its real consumers: `flux-system` (`forgejo-git-auth`), `shipwright-build` (`forgejo-buildkit-secret`, `forgejo-clone-secret`), `workflows` and `workflows-alice` (`forgejo-admin-token`, `forgejo-pipeline-bot-token`), `pipelines-operator-system` (`forgejo-pipeline-bot-token`). `workflows-alice` looks like tenant-collapse residue. **3. A comment described its blast radius backwards.** The bootstrap Job's comment called the Forgejo database credential one of "two narrow powers" and contrasted it favourably with an administrator token. It is `PGUSER=forgejo` — full read/write on the whole Forgejo database, and *stronger* than a site-administrator API token, since a `user`-table write can set `is_admin`, replace a hash, or mint a session. Comment only; the Job is byte-identical. **4. Two unused Argo destinations removed.** Nothing on either deploy branch targets `shipwright-build` or `binjovi-probes`. Because the Argo controller is cluster-admin, RBAC escalation prevention does not apply, so that reach was free privilege into the privileged BuildKit namespace. **5. The MinIO lifecycle rule was not idempotent.** `mc ilm rule add` has no `--id` flag — confirmed in the pinned release's help — and three calls left three identical rules with three different IDs. Replaced with `mc ilm rule import` from a fixed-ID JSON. Verified by extracting the rendered Job script with `kubectl kustomize` and running it three times against a throwaway MinIO seeded with duplicates: exactly one rule each time. So the first live run also self-heals whatever has already accumulated. **Guards assert properties now, not the old strings**, and were mutation-tested: **17 deliberate regressions, all 17 go red**. That surfaced a real gap — `grep -Fq 'failurePolicy: Fail'` passed a weakened policy because the file's own header names the field in prose; it is now anchored with a regex, and two checks strip comments before grepping.
The policy matched a pod only when a container image started with
`registry.sean.farm/build/binjovi:` or `...binjovi@`. That made the rule
say "if you name your image like ours, pin it". An attacker does not name
an image like ours. A pod in ns binjovi that ran `alpine:latest` matched
nothing, so the policy did not apply and the pod started on a mutable
tag. In a pod that did match, an added sidecar on a floating tag passed.

The namespace is now the only selector. In ns binjovi and ns
binjovi-staging, every container, init-container, and ephemeral-container
image must carry `@sha256:`.

Scope also widens from CREATE to CREATE and UPDATE on pods, because a
pod's container image is mutable in place, and to UPDATE on the
pods/ephemeralcontainers subresource, where `kubectl debug` arrives.
`Container` and `EphemeralContainer` are different schema types, so the
two lists keep separate variables and separate validations.

Measured against the canonical deploy branches: ns binjovi and ns
binjovi-staging each run ONE Deployment, with one container and no init
containers, on
registry.sean.farm/build/binjovi:sha-806866e5c851@sha256:0a70c9c8...
Probes run in binjovi-probes and builds in binjovi-builds, which this
policy does not match. So it refuses nothing that runs today.

The guard asserted the two `startsWith` strings, which locked the flaw in
place. It now asserts the property.
The Role binjovi-webhook-secret-read was bound to ServiceAccount
forgejo-creds-reader. That ServiceAccount is the identity of
ClusterSecretStore forgejo-credentials, and that store carries no
conditions block. In External Secrets, an absent conditions block lets
ANY namespace use the store. So the grant published the webhook signing
value cluster-wide.

The old comment said the access was kept "separate from the shared
Forgejo credential Role". A separate Role was made, but it was bound to
the SHARED identity, and the store authenticates as the identity. So the
stated separation was not achieved.

The signing value now has a dedicated ServiceAccount
binjovi-webhook-reader and a dedicated ClusterSecretStore
binjovi-webhook, scoped with conditions.namespaces: [binjovi], like the
other three Binjovi stores. The grant on the shared identity is removed.

COORDINATION: the ExternalSecret binjovi-webhook in ns binjovi lives on
the canonical Binjovi deploy branch, in manifests/credentials.yaml. It
still names secretStoreRef forgejo-credentials, and it must be changed to
name binjovi-webhook. Until that lands, that one ExternalSecret cannot
sync. The projected Secret has deletionPolicy Retain and the value is
static, so the webhook keeps working: the state is sync-unhealthy, not an
outage.

The shared forgejo-credentials store is NOT changed here. Its real
consumers are ns flux-system, ns shipwright-build (two), ns workflows,
ns workflows-alice, and ns pipelines-operator-system, across this repo
and sean/pipelines. Narrowing it needs a separate, measured decision.
The comment said the Job has "two narrow powers" and that "it never
receives an administrator token". That is backwards. The Job connects as
PGUSER=forgejo with the Forgejo database password, which gives full read
and write on the whole Forgejo database. A write on the "user" table can
set is_admin, replace a password hash, or make a session. So the Job is
STRONGER than a Forgejo site-administrator API token, not weaker.
Comparing it favourably against that token invites the wrong conclusion.

The comment now states the real blast radius, and it keeps the
containment facts that are genuinely true: digest-pinned images,
non-root, read-only root filesystem, all capabilities dropped, a 600s
deadline, and a Kubernetes token projected for 600s into only the one
container that calls the API.

Comment only. The Job behaviour does not change.
The AppProject let Argo CD write ns shipwright-build and ns
binjovi-probes. Measured against branch deploy and branch deploy-staging,
no manifest names either one. The two branches write ns binjovi, ns
binjovi-staging, ns binjovi-builds, and ns workflows, and nothing else.

The Argo application controller runs as cluster-admin, so RBAC escalation
prevention does not apply. Each destination is therefore a place where
anything able to write a deploy branch can make a ServiceAccount, a Role,
a RoleBinding, and a Job. shipwright-build is the privileged BuildKit
namespace.

Removal does not reach the runtime. Availability probe Jobs and project
BuildRuns are made by the control plane through its own namespace-scoped
RBAC, in ns binjovi-probes and ns binjovi-builds. An Argo destination has
no part in that. At the deployed revision (806866e5c851), the control
plane's Kubernetes client namespace is binjovi-builds, so it no longer
uses shipwright-build at all.

The guard asserted that shipwright-build was present. It now asserts that
both stay out.
fix(binjovi): converge the log bucket lifecycle
All checks were successful
pipeline/ci CI green @ 147feca54bca
147feca54b
The Job comment claimed idempotent lifecycle convergence, but the command
was `mc ilm rule add`. That form has no --id flag, so it mints a new
random rule ID and APPENDS a duplicate on each call. With backoffLimit 20
and restartPolicy OnFailure, and with the verify steps that can fail
after it, one retry loop could write up to 21 copies of the same rule.

`mc ilm rule import` replaces the WHOLE lifecycle configuration from
STDIN, so the result is one rule with a fixed ID however many times the
Job runs. It also removes duplicates that earlier runs already left on
the bucket.

Measured against the pinned mc release, with a throwaway MinIO:
  - three `rule add` calls left three identical rules with three IDs;
  - the rendered Job script, run three times over those three duplicates,
    left exactly one rule: ID binjovi-logs-v1-expire-30d, prefix v1/,
    30 days.

`import` was checked in that release's help output before use, and the mc
image has no grep, so the script uses none.

The guard asserted the `rule add` string. It now asserts convergence.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sean/seanfarm!20
No description provided.