Files
infra/license-bot/SECURITY.md
T
2026-10-20 02:43:27 +01:00

303 lines
17 KiB
Markdown

# Verification — AGPL license bot, merge gate and key exposure
Measured on the development VM (Gitea **1.27.2**, runner `alpine-runner`,
label `alpine-latest:docker://node:alpine`) on 2026-10-07. Everything below was
observed, not inferred; each test lists the method, the raw observation and the
verdict. Where a result is version- or host-specific it is called out.
Two questions are answered:
1. **Is the bot's `license-bot / addlicense` status authoritative — can a PR that
is not supposed to be trusted fake a green?**
2. **Can anyone get a key out?** Masking in logs is not evidence of safety: the
only question is whether a value is reachable by an actor and can leave the
host.
---
## Test 1 — authoritativeness / "fake greens"
### T1.1 An untrusted (fork) PR run cannot post a commit status
- **Hypothesis.** A fork PR run's `secrets.GITHUB_TOKEN` has no write access, so
it cannot create the status the gate depends on.
- **Method.** Workflow on a fork branch (`admin/license-bot-lab:forge-test`)
running as a fork PR:
`POST /api/v1/repos/Dcntrlized/license-bot-lab/statuses/<head_sha>` with
`{"context":"license-bot / addlicense","state":"success"}` using
`${{ secrets.GITHUB_TOKEN }}`; the same call again against the fork's own repo;
then `GET /repos/{repo}/commits/{sha}/statuses` read back by the admin API.
- **Observation.** `BASE_HTTP_403` and `FORK_HTTP_403`, body
`{"message":"user should have a permission to write to a repo"}`; no
`license-bot / addlicense` status ever appeared on the PR head. The same fork
run *can* post a comment (`201`).
- **Verdict.** PASS for the platform mechanism: a fork PR cannot produce the
suffix-free context the bot owns. It can still post *comments*.
### T1.2 A same-repo (in-repo) PR run can post any context
- **Hypothesis.** A contributor with repository write access can forge the bot's
exact context through their own workflow.
- **Method.** In-repo branch workflow, `POST .../statuses/<head_sha>` with
`{"context":"license-bot / addlicense","state":"success"}`.
- **Observation.** `FORGE_HTTP_201`; `GET .../statuses` then listed
`success license-bot / addlicense` with the description the workflow set
(`forged by a contributor workflow`).
- **Verdict.** FAIL by design: any writer can forge any context. Write access is
the trust boundary (a writer can equally edit the workflow or push directly);
the gate protects against *untrusted* actors and honest mistakes, not against
collaborators.
### T1.3 Required branch-protection checks are enforced on the merge API
- **Hypothesis.** With branch protection requiring a context that nobody posts, a
normal writer's merge is refused — i.e. required checks are a real gate.
- **Method.** `POST /repos/{r}/branch_protections`
(`branch_name=main`, `enable_status_check=true`,
`status_check_contexts=["nobody-posted-this-context"]`), then
`POST /repos/{r}/pulls/{n}/merge` as the non-admin collaborator `gateuser`
(`Authorization: token <gateuser>`), `{"Do":"merge"}`.
- **Observation.** `405 {"message":"Not all required status checks successful"}`.
Removing the requirement and re-merging succeeded (`200`).
- **Verdict.** PASS: branch protection is enforced on the REST merge path for a
normal writer. Caveat: admins can override, and contexts match by exact string.
- **Re-measured 2026-10-08 on a protected throwaway branch** (`gate-target`,
context `license-bot / addlicense`), which pins the fail-closed property the
webhook-only design relies on:
| State of the required context | `POST /pulls/57/merge` |
|---|---|
| **absent** (bot down, never posted) | `405 Not all required status checks successful` |
| `pending` | `405` |
| `success` | `200` |
Cleanup: protection removed, both probe branches deleted, `main` untouched.
So "the bot is down" means "no status" means "no merge" — an unlicensed pull
request cannot be merged by accident.
### T1.4 A *required scoped workflow* is NOT enforced on the merge API (1.27.2)
- **Hypothesis.** If a scoped workflow is registered from a source repo and marked
required, a failing run blocks merges — making it the natural public gate.
- **Method.** Source repo `Dcntrlized/ci-workflows` with
`.gitea/scoped_workflows/gate.yml` (a job that always exits 1). Registered on
`/org/Dcntrlized/settings/actions/scoped-workflows` via
`POST .../add` `repo_name=ci-workflows` (bare name only — `owner/name` and the
numeric id are rejected `400 Repository not found.`), then marked required via
`POST .../required` with `repo_id`, `workflow_ids=gate.yml`,
`required_workflow_ids=gate.yml`,
`required_patterns[gate.yml]="Dcntrlized/ci-workflows: Gate / *"`. Consumer:
throwaway `Dcntrlized/gate-lab`, PR with a real failing status
`Dcntrlized/ci-workflows: Gate / check (pull_request) = failure` on the head.
- **Observation.** The PR page showed *"Some required checks failed"*, but
`POST /pulls/{n}/merge` returned **200** — both for the site admin (PR B) and
for the non-admin writer `gateuser` (PR 4, head `7c00336c`). A required pattern
that never posts does not block either.
- **Verdict.** FAIL: on 1.27.2 required scoped workflows are a UI-only signal,
not an API-enforced gate. Do not rely on them. (Upstream 28.1.0 not tested.)
### T1.5 Scoped workflows do not leak consumer secrets to fork PRs
- **Hypothesis.** A scoped workflow sourced from another repo might receive the
consumer's repo/org secrets even on a fork PR.
- **Method.** A scoped `secret-probe.yml` registered from `ci-workflows` tested
presence of the consumer's repo secret, an org secret and `secrets.GITHUB_TOKEN`
on a fork PR and on an in-repo PR (presence via `[ -n "$VAR" ]`, so no value is
needed).
- **Observation.** Fork PR: `PROBE_secret=[]`,
`PROBE_org_secret_present=[false]`, `PROBE_token_present=[true]`. In-repo PR:
`PROBE_secret=[***]`, `PROBE_org_secret_present=[true]` (values masked in the
log).
- **Verdict.** PASS: scoped workflows inherit the consumer's secret rules; forks
get none. (Masking of the in-repo log is cosmetic — see Test 2.)
### T1.6 Positive control — the bot's own status
- **Method.** The real bot (own account token) posts
`POST /repos/{r}/statuses/<sha>` with its context in the same lab and the status
is read back via the API.
- **Observation.** Context is exactly `license-bot / addlicense`, no `(event)`
suffix, description `license headers added by the bot`.
- **Verdict.** The context the gate should require is the one T1.1 shows forks
cannot create.
### Test 1 conclusions
| Test | Result |
|---|---|
| T1.1 fork cannot post a status | 403 — PASS |
| T1.2 in-repo writer can forge any context | 201 — FAIL by design (write = trust) |
| T1.3 required branch-protection check blocks a writer's merge | 405 — PASS |
| T1.4 required scoped workflow blocks a merge | 200 — FAIL (UI-only on 1.27.2) |
| T1.5 scoped fork runs get no consumer secrets | no secret present — PASS |
| T1.6 bot context identifier | `license-bot / addlicense` (suffix-free) |
**Gate to use:** branch protection on the default branch requiring the exact
context `license-bot / addlicense`, plus at least one approval so an author cannot
self-merge. It is enforced on the merge API (T1.3) and unforgeable by fork PRs
(T1.1); it does not stop a collaborator (T1.2) or an admin.
**Not covered by Test 1:** whether a run *without* write access can reach secrets
by other means — that is Test 2, and the answer changes the conclusion above,
because an attacker who obtains the admin token can forge any status.
---
## Test 2 — key exposure (reachability and egress, not masking)
Methodology: unique canary values are planted as a repo secret and an org secret,
a workflow prints their **lengths** and posts them **base64-encoded to the PR as a
comment** through the run token (a real outbound HTTP POST, the "malicious site"
model). The same probe runs from an in-repo PR and from a fork PR. A control step
proves the network path and the runner's capabilities, so a "nothing received"
result means "nothing to send", not "no channel".
### T2.1 An in-repo PR exfiltrates a repo secret and an org secret
- **Observation.** `REPO_CANARY_LEN=23 ORG_CANARY_LEN=26 BOT_TOKEN_LEN=0
RUN_TOKEN_LEN=40`; the log lines `REPO_CANARY_B64=***` /
`ORG_CANARY_B64=***` (masked) but `EXFIL_POST_HTTP=201`; the created comment
body decoded to exactly `CANARY-61ca3f58dd2d9ae3` and
`ORGCANARY-d0c7e3ad5b14af5d`.
- **Verdict.** FAIL (severe). Log masking protects nothing; the values left the
host. Any collaborator who can open an in-repo PR reads every repo/org secret.
### T2.2 A fork PR gets no repo/org secrets
- **Observation.** `LEN repo=0 org=0 run_token=40`,
`EXFIL_POST_HTTP=201`, comment
`FORK EXFIL probe: repo_canary_len=0 org_canary_len=0 run_token_len=40`.
- **Verdict.** PASS for direct secret access: the platform withholds repo/org
secrets from fork runs; the run token present is base-repo read-only.
### T2.3 CRITICAL — a fork PR escapes the runner sandbox through the Docker socket
- **Hypothesis.** Untrusted builds might still reach host resources through the
runner.
- **Method.** From the *fork* PR job: `ls -l /var/run/docker.sock`;
`apk add --no-cache docker-cli`; `docker ps --format '{{.Names}}'`; then
`docker run --rm -v /:/host alpine sh -c 'echo HOST_READ_OK;
cat /host/etc/hostname; ls /host | head -5'`.
- **Observation.** Socket present: `srw-rw---- 1 root 969 /var/run/docker.sock`.
`docker ps` listed `GITEA-ACTIONS-TASK-…` (the job), `gitea_runner-runner-1`
and **`gitea`**. The host-root bind mount worked: `HOST_READ_OK`,
`dcntrlized_dev`, `bin boot dev etc home`.
- **Verdict.** CRITICAL FAIL. A fork PR — no write access, no secrets — has
root-equivalent access to the runner host: read/write the host filesystem
(including Gitea's data directory and database, i.e. every org/repo secret and
admin token), `docker exec` any container (including `gitea`) and start
privileged containers. This defeats T2.2 in practice: secrets are reachable
indirectly, and the T1.1 "forks cannot fake a status" property is moot once the
attacker steals an admin token.
- **Remediation (required before opening the repos).** In the runner config
(`/home/admin/gitea_runner/config.yaml` on the dev VM) set
`container.docker_host: "-"`. Upstream documents this exact value as "runner
will find an available docker host automatically, but won't mount it into the
job and action containers" (gitea runner `config.example.yaml`, and
`run_context.go`: the socket bind is skipped when `containerDaemonSocket()`
returns `-`). Keep `container.valid_volumes: []` (an empty list means no
workflow-declared volume can be mounted) and leave `container.privileged:
false`. Then restart the runner and re-run T2.3: `ls /var/run/docker.sock` must
fail and a `docker` CLI in the job must not reach a daemon. Until that holds,
no other mitigation matters. A rootless/DIND or VM-isolated runner is the
stronger option for public repositories.
- **Status: FIXED and verified 2026-10-07.** The hardened runner configs are
versioned in the root repo at `infra/gitea-runner/` and deployed on the dev VM
(`container.docker_host: "-"`, an `--internal` job network with Gitea as its
only attached service, `privileged: false`, `valid_volumes: []`, and a job
image with the tools baked in so no workflow needs egress). Measured from a
job after the change: `SOCKET_ABSENT`; `example.com` does not even resolve;
the host (`192.168.122.183:3000`) is `Network unreachable`; `gitea:3000`
answers `200`; the job route is only `172.31.240.0/24 dev eth0 scope link`;
and workflows declaring `--network host`, `--privileged` or `--volume /etc/...`
are ignored by the runner. The runner was re-registered so the job context URL
is `gitea:3000` (the host address is unreachable by design). The fork probe
that previously read the host now finds neither the socket nor a network path.
Verification runs on the lab repo: isolation probe run 179; scoped
`license-check.yml` run 180 and repo `.gitea/workflows/license.yml` run 201
both succeed with `SERVER: http://gitea:3000`. The two advisory files share
`concurrency.group: license-headers-<PR>`, so exactly one of them runs per PR
(the scoped one normally wins; the repo file takes over if the scoped workflow
is unregistered) — a `cancelled` repo run is the expected dedup, not a
failure.
### T2.4 CRITICAL — the org `TOKEN` secret is the admin/bot token
- **Hypothesis.** The token the bot uses might also sit in Actions secrets, where
any PR workflow can read it.
- **Method.** An in-repo PR workflow printed `len` and `sha256` of
`secrets.TOKEN` (no plaintext); the same hash was computed locally for the
token in `Maestro/.env` that drives the bot.
- **Observation.** `ORG_TOKEN_LEN=40 SHA=fd539c2ffd50376f6747a05424d4e5fa6e02c67da35cebf3001a2a19524418ad`;
local bot token: `len=40
sha=fd539c2ffd50376f6747a05424d4e5fa6e02c67da35cebf3001a2a19524418ad` —
identical. (`BOT_TOKEN_LEN=0`: it is not duplicated under another name.)
- **Verdict.** CRITICAL FAIL. The site-admin token that authenticates the bot is
readable by every in-repo PR workflow, and by any fork PR through T2.3.
- **Remediation.** Rotate it; give the bot a dedicated least-privilege account
(`write:repository` + `read:issue` + `write:issue`, scoped to the five repos);
stop storing that token as an org/repo Actions secret (release publishing needs
a separate, approval-gated or push-only path).
- **Update 2026-10-08.** The bot no longer has any Actions dependency: it is the
webhook-only service described below, and its token lives only in the
container's environment on the Gitea host. The org `TOKEN` secret is now used
by nothing except `release.yaml`'s registry auth, so it can be deleted once
that workflow gets its own `write:package` token. The remaining work is the
**rotation** of the bot token onto a dedicated non-admin account.
### T2.5 Egress and runner capability control
- **Observation.** From the job: `NET_GITEA=200`, `NET_INTERNET=200`
(`http://example.com` reachable), `HAS_DOCKER=none` before installing the CLI.
- **Verdict.** A working outbound channel exists, so the negative result in T2.2
is meaningful, and the positive results in T2.1/T2.4 are exfiltration, not a
logging artifact.
### Test 2 conclusions
| Test | Reachable | Left the host | Result |
|---|---|---|---|
| T2.1 in-repo PR, repo+org secrets | yes | yes (HTTP 201, comment) | FAIL — masking is cosmetic |
| T2.2 fork PR, repo/org secrets | no | n/a (channel worked, 201) | PASS for direct access |
| T2.3 fork PR, Docker socket / host root | yes | yes (host FS read) | CRITICAL — runner sandbox escape |
| T2.4 org `TOKEN` == bot/admin token | yes (in-repo) | yes | CRITICAL — rotate and relocate |
| T2.5 egress control | yes | — | confirms the tests above |
**Net:** the bot's design is right (separate revertable commit, exact suffix-free
status context, no token in the repo), but on this host an untrusted pull request
can read the whole machine, so no claim of "no key leakage" or "unforgeable gate"
holds until the runner is fixed. Order of work: **T2.3 remediation → token
rotation/T2.4 → branch protection on `license-bot / addlicense` → re-test.**
---
## Update 2026-10-08 — webhook-only v0.0.1
The service was folded down to one trigger and one process, which removes the
last piece of the exposure story that lived in Actions:
- **Webhooks are the only trigger.** Gitea delivers HMAC-signed `pull_request`
events to `http://license-bot:8080/hook`; the bot verifies
`X-Gitea-Signature` and ignores duplicate `X-Gitea-Delivery` ids. A check that
did not run therefore means the service was down, not that some other
component failed.
- **Startup reconciliation replaces polling.** On boot the bot scans the open
pull requests of every allowlisted repository and processes those whose head
does not already carry a successful `license-bot / addlicense` status. The
commit status is the durable record of what the bot has resolved, so a
delivery lost while it was down, or a `pending` left by a crash, is repaired
without a cursor file and without a polling loop.
- **No arguments.** The binary takes no flags; everything is environment driven
(`v0.0.1`).
- **Reachability, measured on this instance.** A job/pod on the runner's
`--internal` job network reached a sidecar published under a name alias
(`HEALTH=ok`) and an unauthenticated request was rejected (`401`), while the
host IP and the internet stayed unreachable — so a sidecar/service on Gitea's
own Docker network is reachable by Gitea without widening the job network's
egress.
- **Advisory workflows are gone.** The read-only per-repository
`.gitea/workflows/license.yml` and the scoped `license-check.yml` were
removed: they were never the gate (T1.4) and the status is what branch
protection enforces (T1.3).
- **The gate** is still branch protection requiring the exact context
`license-bot / addlicense`; the absent-status case measured above is what makes
it fail-closed. Required approvals are configured separately by the operator.