curl -u "$VAR" puts a credential in argv on shared hosts — five call sites, one class #821

Open
opened 2026-08-22 23:42:26 +02:00 by timothy · 0 comments
Owner

Raised by a cold review of #772's preflight and deliberately NOT fixed there: fixing one site would leave the class intact and the codebase inconsistent, which is the shape docs/decisions/records/process/ keeps recording as the wrong move.

The shape

curl -u "$SOMETHING" places user:pass in the process's argv, where any user on the host can read it with ps for the duration of the call. Call sites:

Site Credential
scripts/ci-detect-already-validated.sh:75 ETV_STATUS_AUTH (= REGISTRY_USER:REGISTRY_PASSWORD)
scripts/ci-toolchain-image-resolves.sh ETV_REGISTRY_AUTH (same secrets)
scripts/pr-changed-files.sh:99 ETV_GITEA_BASICAUTH
scripts/select-queue.sh:59 ETV_GITEA_BASICAUTH
scripts/issue-qualification-audit.sh:24 ETV_GITEA_BASICAUTH

Exposure is worst on the container-free lane (runs-on: small, no container:), because that is the shared host — bumblebee/jazz, which also run the prod transcoders — rather than a per-job container. ci.required-job-step-execution-markers already carves that lane out of its isolation reasoning for the same underlying reason.

The registry credential is the one that matters: #697 established it reaches ersatztv:prod (the tag prod's stack follows) and ersatztv-ci:<sha> (the toolchain image five container: jobs execute).

Not urgent, and worth saying why

The hosts are single-tenant homelab machines, the window is one HTTP call wide, and the token/basic-auth values are already in the runner's environment (which ps -E can also reach on some configurations). This is defence in depth, not an open door — which is exactly why it should be fixed as a class in one pass rather than opportunistically in whatever PR happens to touch a caller.

Options

  1. curl -K - reading user = "..." from stdin — no file, no argv; needs care with " and \ in the value.
  2. An Authorization: Basic <base64> header built in-process — but -H is argv too, so this only helps if the header is also passed via -K -.
  3. A shared scripts/lib/gitea-curl.sh helper so there is ONE place that gets this right, which would also collapse four near-identical api_get implementations.

(3) is the one that stops the class recurring; the four copies are why this is five rows instead of one.

Done-when

  • No scripts/ caller passes a credential in argv (enumerate from the git index, not from this list — an issue's file list is not the population)
  • The chosen mechanism is exercised by a test, not just read
  • Adversarial review passed
Raised by a cold review of #772's preflight and deliberately NOT fixed there: fixing one site would leave the class intact and the codebase inconsistent, which is the shape `docs/decisions/records/process/` keeps recording as the wrong move. ## The shape `curl -u "$SOMETHING"` places `user:pass` in the process's argv, where any user on the host can read it with `ps` for the duration of the call. Call sites: | Site | Credential | |---|---| | `scripts/ci-detect-already-validated.sh:75` | `ETV_STATUS_AUTH` (= `REGISTRY_USER`:`REGISTRY_PASSWORD`) | | `scripts/ci-toolchain-image-resolves.sh` | `ETV_REGISTRY_AUTH` (same secrets) | | `scripts/pr-changed-files.sh:99` | `ETV_GITEA_BASICAUTH` | | `scripts/select-queue.sh:59` | `ETV_GITEA_BASICAUTH` | | `scripts/issue-qualification-audit.sh:24` | `ETV_GITEA_BASICAUTH` | Exposure is worst on the **container-free** lane (`runs-on: small`, no `container:`), because that is the shared host — bumblebee/jazz, which also run the prod transcoders — rather than a per-job container. `ci.required-job-step-execution-markers` already carves that lane out of its isolation reasoning for the same underlying reason. The registry credential is the one that matters: #697 established it reaches `ersatztv:prod` (the tag prod's stack follows) and `ersatztv-ci:<sha>` (the toolchain image five `container:` jobs execute). ## Not urgent, and worth saying why The hosts are single-tenant homelab machines, the window is one HTTP call wide, and the token/basic-auth values are already in the runner's environment (which `ps -E` can also reach on some configurations). This is defence in depth, not an open door — which is exactly why it should be fixed as a class in one pass rather than opportunistically in whatever PR happens to touch a caller. ## Options 1. `curl -K -` reading `user = "..."` from stdin — no file, no argv; needs care with `"` and `\` in the value. 2. An `Authorization: Basic <base64>` header built in-process — but `-H` is argv too, so this only helps if the header is also passed via `-K -`. 3. A shared `scripts/lib/gitea-curl.sh` helper so there is ONE place that gets this right, which would also collapse four near-identical `api_get` implementations. (3) is the one that stops the class recurring; the four copies are why this is five rows instead of one. ## Done-when - [ ] No `scripts/` caller passes a credential in argv (enumerate from the git index, not from this list — an issue's file list is not the population) - [ ] The chosen mechanism is exercised by a test, not just read - [ ] Adversarial review passed
timothy added the ci-cdpriority: lowsecurity labels 2026-08-22 23:42:35 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#821