Files
2026-10-20 02:43:27 +01:00

205 lines
12 KiB
Markdown

# license-bot
Adds the AGPL-3.0 license header to files a pull request **adds** without one,
in a separate, revertable commit on the pull request head, comments on the pull
request, and posts the `license-bot / addlicense` commit status that branch
protection requires before merging. Works for same-repository pull requests
**and** for fork pull requests.
Version v0.0.1 takes no arguments and has one trigger: **Gitea webhooks**.
## Why a service instead of a Gitea Actions workflow
- Gitea evaluates a pull request's workflows **from the head branch**, so a
contributor (or a fork) can edit, delete or fake the job — including reporting
a green status. A workflow therefore cannot be an enforcement boundary.
- Actions runs triggered by fork pull requests get no repository secrets and a
read-only token, so a workflow can never hold the credentials needed to push a
commit back to a contributor's fork.
- **Nothing permissive lives in Actions.** The bot-account token exists only in
this service's environment on the Gitea host. An in-repo PR workflow can read
every repo and org Actions secret (measured: the value left the host over HTTP
while the log showed `***`), so the fewer credentials reachable from CI, the
smaller the blast radius.
- The merge gate is the **commit status**, not a workflow run: a required
branch-protection check that was never posted blocks the merge (`405`), while
a required *scoped workflow* that never ran does not (`200`, measured on
1.27.2).
## How it works
1. On startup it reconciles once: every open pull request of every allowlisted
repository whose head does not already carry a successful
`license-bot / addlicense` status is checked. The status is the durable record
of what the bot has already resolved, so the scan is self-healing without a
cursor.
2. While running it receives Gitea `pull_request` webhooks (HMAC-SHA256
verified against `WEBHOOK_SECRET`, duplicate delivery ids ignored) and
processes `opened`, `synchronize`, `reopened` and `ready_for_review`.
3. It re-reads the pull request from the API (the payload is only a hint) and
lists the files the pull request **changed** that still exist at the head:
`added`, `modified`, `renamed` and `copied` are all in scope, so an existing
unlicensed file gets its header the first time any pull request touches it.
Removals are skipped (there is no content left to license), and a
deletion-only pull request is reported `success` ("no changed files need a
license header") with no comment and no commit. Otherwise it posts `pending`
for those files and logs what it is about to check.
4. It clones the head branch (in-repo or fork), runs `google/addlicense`
v1.2.0 with the repository's own `.license-header.tpl` and `.licenseignore`
over exactly those files. Because the scope includes modified files, a
`.licenseignore` that is missing an entry makes the bot rewrite a file the
contributor only touched — keep vendored and generated paths listed there.
5. If a header was added: it creates **its own commit on top of the pull request
head** — never an amend, never a force-push. The contributor's commit keeps
its sha, message and author; the bot is the committer of the extra commit,
whose body says it can be reverted or dropped freely.
6. It pushes with a plain fast-forward `git push`. If the branch moved meanwhile
the push is rejected; the bot drops its local work, re-reads the tip,
re-applies the headers and retries (up to 5 attempts, then the next event
retries). It never rewrites somebody else's history.
7. It replaces its comment — the previous one is deleted and a fresh one posted,
never edited in place, so the message always sits at the bottom of the
timeline next to the latest status — and resolves the status: `success` when
the head carries the headers, `failure` when the contributor has to add them
by hand (maintainer edits disabled), `error` if the bot itself failed. A
pass that finds nothing left to fix deletes a stale problem comment so a
resolved failure never sits next to a green status. A crash leaves the status
`pending`, which keeps the gate closed until the startup reconcile resolves it.
Loop safety: the bot's own commit produces one more `synchronize` event; that
run finds nothing to fix and reports success. Processed `(repo, PR, sha)`
triples are cached in memory and already-green heads are skipped.
## Failure semantics
Webhooks are the only trigger, so **a check that did not run means the service
was down**, not that some other component failed. The consequences are bounded
by design:
- If the service is down when a pull request opens, no status is posted; the
gate (if enabled) refuses to merge — nothing unlicensed slips through.
- When the service starts, the reconciliation pass processes every open pull
request that is not green, including ones left `pending` by a crash.
- A failed *check* (push refused, API error) resolves the status to `failure`
or `error` and comments, so a parked pull request is visible rather than
silently green.
## Configuration
All configuration is environment driven — the image contains no secrets and the
process takes no arguments.
| Variable | Default | Meaning |
| --- | --- | --- |
| `GITEA_URL` | dev-VM fallback, required by the compose | Target instance base URL. On an instance whose Gitea runs on the same Docker daemon use the network alias (e.g. `http://gitea:3000`) so the bot needs no egress and no published port. |
| `GITEA_PUBLIC_URL` | `GITEA_URL` | URL used for the links in the bot's comments. Set it when `GITEA_URL` is the internal Docker alias, otherwise a reader's browser cannot resolve `http://gitea:3000`. |
| `GITEA_TOKEN` | — (required) | Bot-account token with write access to the target repositories. Needs `write:repository` (clone/push/commit status) plus `read:issue` + `write:issue` (read the files list, post/update the comment) — a repository-only token authenticates but cannot comment. |
| `WEBHOOK_SECRET` | — (required) | HMAC-SHA256 secret of the org webhook. Every delivery is rejected unless `X-Gitea-Signature` matches. |
| `REPOS` | the five Dcntrlized repos | Comma-separated `owner/name` allowlist. Nothing outside it is ever touched. |
| `STATUS_CONTEXT` | `license-bot / addlicense` | Commit-status context; must equal the branch-protection string byte for byte. |
| `LISTEN` | `:8080` | Webhook and health listen address. |
| `DRY_RUN` | `false` | Inspect and report but never commit or push. |
| `BOT_NAME` / `BOT_EMAIL` | `Dcntrlized Auto CopyLeft Bot` / `[email protected]` | Committer identity of the bot's commit. |
| `HOLDER` | `Dcntrlized` | `addlicense -c` value. |
| `ADDLICENSE_BIN` | `addlicense` | `addlicense` executable. |
| `WORK_ROOT` | `$TMPDIR` | Where per-pull-request worktrees are created. |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. |
Copy `.env.example` and fill in `GITEA_TOKEN` and `WEBHOOK_SECRET`. Never commit
that file.
Routes: `POST /hook` (webhook, `POST /webhook` is accepted as an alias) and
`GET /healthz` (container healthcheck).
## Deploying to an instance
Three values plus one command:
```sh
cp .env.example .env
# GITEA_URL = the instance (network alias when Gitea shares the daemon)
# GITEA_TOKEN = a DEDICATED BOT ACCOUNT token, never an admin token
# WEBHOOK_SECRET = openssl rand -hex 32
# NETWORK_NAME = the network the Gitea container already runs on
./deploy.sh
```
`deploy.sh` creates the network if missing, builds and starts the container,
waits for the healthcheck, verifies the token against `GET /api/v1/user` and
verifies that `http://license-bot:8080/hook` resolves **from Gitea's own
network** — the exact URL the webhook has to reach. It then prints the two
instance-side steps with ready-to-paste commands:
0. **Let Gitea reach the service (prerequisite).** With its default
`security.ALLOWED_HOST_LIST`, Gitea refuses to deliver a webhook to an
internal Docker alias: the delivery is recorded as failed with
`webhook can only call allowed HTTP servers … deny 'license-bot(<ip>)'` even
though the URL is perfectly reachable from inside the container. Add
`[security]` → `ALLOWED_HOST_LIST = license-bot` to Gitea's `app.ini` (or
list the private subnet) and restart Gitea. This is a Gitea-side allowlist,
not a networking problem — check it first if deliveries never arrive.
1. **The webhook (required).** An **organization** webhook to
`http://license-bot:8080/hook`, content type JSON, secret = `WEBHOOK_SECRET`,
events = `pull_request`. Without it nothing is ever checked.
2. **The gate.** Branch protection on each default branch requiring the exact
context `license-bot / addlicense`, plus at least one approval so the author
cannot self-merge. Verify afterwards that the context string matches exactly:
a mismatch silently never blocks anything.
A safe first run is `DRY_RUN=true docker compose up -d --force-recreate`: the
bot comments and posts statuses but never commits.
There is no Actions runner dependency anywhere in this path: the bot only needs
to be reachable from Gitea and to reach Gitea.
## Security notes
- The token is only ever read from the environment. It is never logged: URLs
embedded in git errors are redacted before they reach a log line or a PR
comment.
- Webhook deliveries are rejected unless the HMAC-SHA256 signature matches, and
duplicate delivery ids are ignored.
- Only repositories in `REPOS` are processed.
- The bot never rewrites history: it adds a commit and uses a plain
fast-forward push, so a push that lost a race against a new contributor commit
is rejected. The bot then discards its local commit, re-reads the branch,
re-runs the check on the new tip and pushes again, repeating until it succeeds
or finds nothing left to change (bounded at 5 attempts, after which the next
event retries). Nothing is ever overwritten.
- If the contributor disabled maintainer edits on the pull request, the push is
refused by the server; the bot then comments that the header must be added by
hand and reports a failing status. It never bypasses that.
### Threat model (measured on Gitea 1.27.2)
The full per-test methodology and raw observations are in
[`SECURITY.md`](SECURITY.md). Summary:
- **Merge gate.** Branch-protection checks are enforced on the merge API: a
normal writer's merge is refused `405 Not all required status checks
successful` when the required context is **absent**, still `405` when it is
`pending`, and allowed (`200`) on `success`. A fork PR run cannot post any
status (`403 user should have a permission to write to a repo`). Requiring the
exact context `license-bot / addlicense` is therefore a fail-closed gate: if
the bot is down and never posts, nothing merges.
- A repository **writer** can forge any context (`201`); write access is the
trust boundary, as it is for any other repository change.
- **Do not use required scoped workflows as the gate on 1.27.2.** With a scoped
workflow marked required and failing, a writer's `POST /pulls/{n}/merge`
returned `200` — the requirement is UI-only.
- **Log masking is not protection.** An in-repo PR workflow exfiltrated a repo
secret and an org secret over HTTP (`201`) although the log showed `***`. The
only real question is reachability plus egress — which is why the bot's token
stays on the host.
- **RESOLVED — job-container host read (T2.3).** The dev runner used to expose
`/var/run/docker.sock` to job containers. It now pins
`container.docker_host: "-"`, `privileged: false`, `valid_volumes: []` and an
`--internal` job network; a real job measured `SOCKET_ABSENT`, no internet,
the host IP unreachable and `gitea:3000` reachable. Configs + deploy script:
`infra/gitea-runner/`.
- **STILL OPEN: the org `TOKEN` Actions secret.** It is the site-admin token
(`sha256` matched, T2.4). The bot no longer has anything to do with it, so it
can be deleted as soon as `release.yaml` gets its own least-privilege
`write:package` token, and the bot account's own token should be rotated onto
a dedicated non-admin account scoped to the five repositories.