17 KiB
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:
- Is the bot's
license-bot / addlicensestatus authoritative — can a PR that is not supposed to be trusted fake a green? - 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_TOKENhas 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; thenGET /repos/{repo}/commits/{sha}/statusesread back by the admin API. - Observation.
BASE_HTTP_403andFORK_HTTP_403, body{"message":"user should have a permission to write to a repo"}; nolicense-bot / addlicensestatus 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 .../statusesthen listedsuccess license-bot / addlicensewith 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"]), thenPOST /repos/{r}/pulls/{n}/mergeas the non-admin collaboratorgateuser(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, contextlicense-bot / addlicense), which pins the fail-closed property the webhook-only design relies on:State of the required context POST /pulls/57/mergeabsent (bot down, never posted) 405 Not all required status checks successfulpending405success200Cleanup: protection removed, both probe branches deleted, mainuntouched.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-workflowswith.gitea/scoped_workflows/gate.yml(a job that always exits 1). Registered on/org/Dcntrlized/settings/actions/scoped-workflowsviaPOST .../addrepo_name=ci-workflows(bare name only —owner/nameand the numeric id are rejected400 Repository not found.), then marked required viaPOST .../requiredwithrepo_id,workflow_ids=gate.yml,required_workflow_ids=gate.yml,required_patterns[gate.yml]="Dcntrlized/ci-workflows: Gate / *". Consumer: throwawayDcntrlized/gate-lab, PR with a real failing statusDcntrlized/ci-workflows: Gate / check (pull_request) = failureon the head. - Observation. The PR page showed "Some required checks failed", but
POST /pulls/{n}/mergereturned 200 — both for the site admin (PR B) and for the non-admin writergateuser(PR 4, head7c00336c). 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.ymlregistered fromci-workflowstested presence of the consumer's repo secret, an org secret andsecrets.GITHUB_TOKENon 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, descriptionlicense 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 linesREPO_CANARY_B64=***/ORG_CANARY_B64=***(masked) butEXFIL_POST_HTTP=201; the created comment body decoded to exactlyCANARY-61ca3f58dd2d9ae3andORGCANARY-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, commentFORK 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}}'; thendocker 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 pslistedGITEA-ACTIONS-TASK-…(the job),gitea_runner-runner-1andgitea. 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 execany container (includinggitea) 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.yamlon the dev VM) setcontainer.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 runnerconfig.example.yaml, andrun_context.go: the socket bind is skipped whencontainerDaemonSocket()returns-). Keepcontainer.valid_volumes: [](an empty list means no workflow-declared volume can be mounted) and leavecontainer.privileged: false. Then restart the runner and re-run T2.3:ls /var/run/docker.sockmust fail and adockerCLI 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--internaljob 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.comdoes not even resolve; the host (192.168.122.183:3000) isNetwork unreachable;gitea:3000answers200; the job route is only172.31.240.0/24 dev eth0 scope link; and workflows declaring--network host,--privilegedor--volume /etc/...are ignored by the runner. The runner was re-registered so the job context URL isgitea: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; scopedlicense-check.ymlrun 180 and repo.gitea/workflows/license.ymlrun 201 both succeed withSERVER: http://gitea:3000. The two advisory files shareconcurrency.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) — acancelledrepo 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
lenandsha256ofsecrets.TOKEN(no plaintext); the same hash was computed locally for the token inMaestro/.envthat 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
TOKENsecret is now used by nothing exceptrelease.yaml's registry auth, so it can be deleted once that workflow gets its ownwrite:packagetoken. 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.comreachable),HAS_DOCKER=nonebefore 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_requestevents tohttp://license-bot:8080/hook; the bot verifiesX-Gitea-Signatureand ignores duplicateX-Gitea-Deliveryids. 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 / addlicensestatus. The commit status is the durable record of what the bot has resolved, so a delivery lost while it was down, or apendingleft 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
--internaljob 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.ymland the scopedlicense-check.ymlwere 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.