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

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:

  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.