303 lines
17 KiB
Markdown
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.
|