205 lines
12 KiB
Markdown
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.
|