rotate: 4 SaaS-token/app-signing drivers + data-separated proof tracking

Add gitlab, cloudflare, ghactions (MOCK-ONLY) and appsecret (LIVE-VM) one-file
Rotators, each with a table-driven cutover-proof + leak-check test. gitlab/
cloudflare/ghactions are in-place SaaS-token rolls (self/rotate, value-roll,
sealed-secret overwrite) so RevokeOld is a no-op; ghactions seals via
nacl/box.SealAnonymous (no new go.mod dep). appsecret regenerates a local app
signing secret and rewrites it in place across every target file (atomic, mode-
preserving, redacted errors), discoverable via exact-match app-signing key names
in env.go.

Keep real-rotation code distinct from mock code: how each driver's cutover was
validated lives as DATA in internal/rotate/proofs.go (single source of truth) +
docs/ROTATION-PROOFS.md, surfaced as a PROOF column in `incredigo rotate` so a
MOCK-ONLY driver can never be mistaken for a LIVE-VM one. appsecret proven LIVE-VM
against real local files in the sandbox VM.

82 tests green, -race clean on rotate/sink/vault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-18 15:28:05 -07:00
parent f6d145669c
commit b415c43bff
14 changed files with 1809 additions and 9 deletions
+61
View File
@@ -203,6 +203,67 @@ secret-name heuristic. Regression: `scanners_test.go:TestEnvScanner`.
Full suite after the batch: **68 green**, `-race` clean on rotate/sink/vault/discover.
## SaaS-token / app-signing batch — 4 drivers + proof manifest, 2026-06-18
Four more drivers, each one-file + table-driven test (cutover proof + leak-check). User
direction this batch: **keep real-rotation code unmistakably distinct from mock code, and
record the real-vs-mock distinction as DATA, not behaviour.** Implemented as a single Go
table (`internal/rotate/proofs.go`) + a human mirror (`docs/ROTATION-PROOFS.md`), surfaced
as a **PROOF** column in `incredigo rotate` so a mock-only driver can never be mistaken for
a live one.
- **gitlab** (`gitlab.go`, MOCK-ONLY) — GitLab PAT via `POST
/api/v4/personal_access_tokens/self/rotate` (`PRIVATE-TOKEN` header). GitLab mints the
new token *and revokes the caller's old one in the same call* → in-place, `RevokeOld`
no-op. Blob `gitlab://<host>/?tok=<token>`. The `httptest` emulator enforces the revoke,
so the unit test's `Verify(old)`-fails assertion proves a real cutover. GitLab CE is too
heavy for the VM → MOCK-ONLY.
- **cloudflare** (`cloudflare.go`, MOCK-ONLY) — Cloudflare API token *value roll* via
`PUT /client/v4/user/tokens/{id}/value` (Bearer). Same token id, fresh value, previous
value dead → in-place, `RevokeOld` no-op; keeps id/policies/name stable. Blob
`cloudflare://<host>/?id=<id>&token=<val>`. Verify via `/tokens/verify` (`status:active`).
- **ghactions** (`ghactions.go`, MOCK-ONLY) — GitHub Actions repo secret. Fetches the repo
libsodium public key (`actions/secrets/public-key`), seals a fresh value with
`crypto_box_seal` (`golang.org/x/crypto/nacl/box.SealAnonymous`, no new go.mod dep), and
`PUT`s it over the existing secret name (overwrite = cutover) → `RevokeOld` no-op. The
rotated material is the **secret value** (`val=`), NOT the management `pat=` that
authenticates the call. GitHub never returns secret values, so Verify is an
existence/authorization check (200 on the secret), not a value match. The emulator owns
the box keypair and **decrypts the sealed PUT**, asserting the decrypted value equals the
driver's new blob value — proving the encryption is real, not simulated.
- **appsecret** (`appsecret.go`, **LIVE-VM**) — LOCAL app signing secret (Django
`SECRET_KEY`, Rails `secret_key_base`, JWT/session secrets, …). Generates a fresh
64-hex value and rewrites it **in place across every target file** (atomic temp+rename,
mode preserved), keyed on the literal old value so it is file-format-agnostic
(.env/yaml/json/toml). In-place ⇒ `RevokeOld` no-op; Verify re-reads every file asserting
new-present / old-absent. Blob `appsecret://local/?key=&val=&path=…[&path=…]`. A
`minSecretLen` (12) guard refuses to key a replacement on a short/common string
(mass-corruption guard), and a zero-replacement result is an error (never claim a
rotation that didn't happen). Errors scrub the value via `redactErr`.
**appsecret discovery (`env.go`).** An exact-match set of app-signing key names
(`SECRET_KEY`, `SECRET_KEY_BASE`, `JWT_SECRET`, `APP_KEY`, …) now tags those `.env` entries
`Source="appsecret"` with a self-contained blob carrying the value AND the file path, so the
local rotator is driver-ready straight from a scan. Generic API keys (STRIPE_API_KEY) stay
plain env secrets. Regression: `scanners_test.go:TestEnvScannerAppSecret`.
**appsecret live VM cutover (LIVE-VM).** `lab-provision-appsec.sh` wrote two real config
files (`labapp/.env` 0600, `labworker/config.yaml` 0644) sharing one `SECRET_KEY`, seeded
the blob into isolated gopass, and ran the shipped binary `incredigo rotate --execute
--prefix imported/`. All assertions PASSED: backup gate sealed+verified first; both files
rewritten with a fresh 64-hex value; OLD value gone from both; unrelated `PORT=8080`
preserved; 0644 mode preserved. The run's table showed `appsecret LIVE-VM` — confirming the
new PROOF column renders in the real binary.
**Honesty taxonomy (the point of the manifest).** Running in the VM does NOT make a driver
LIVE-VM — only the *real target software* validating the cutover does. So this batch is
3× MOCK-ONLY (gitlab/cloudflare/ghactions: no self-host / heavy CE) + 1× LIVE-VM
(appsecret: real local files). AWS remains the canonical example: it ran in the VM but only
against `moto` (a mock), so it is MOCK-ONLY. Promoting a MOCK-ONLY driver needs only a real
service + re-run — zero driver code changes (the drivers contain no test awareness).
Full suite after the batch: **82 green**, `-race` clean on rotate/sink/vault.
## Summary
- Harness result: **31/31 PASS**, including the global secret-leak canary (no CANARY
+73
View File
@@ -0,0 +1,73 @@
# Rotation cutover proofs
This file is the **human-facing mirror** of `internal/rotate/proofs.go`. That Go table is
the single source of truth (`incredigo rotate` surfaces it as the **PROOF** column); this
document explains what each level *means* and records the basis for every driver's level.
Keep the two in sync — if you change one, change the other.
## Why this exists
Every `Rotator` in `internal/rotate` contains **only real rotation code**: it talks to a
real service (HTTP API, DB client, SSH, local files). None of them contain simulation
branches or test awareness — the only thing a test injects is an endpoint / `HTTPClient` /
binary path, which a real deployment also configures.
What differs between drivers is **how their real-cutover path has been validated**. That is
*data, not behaviour*, so it lives in one table (here + `proofs.go`) instead of as prose
scattered through — and easily confused with — the driver code. This is the guardrail that
stops a mock-only driver from ever being mistaken for a live one.
## Levels
| Level | Meaning |
|-----------|---------|
| **LIVE-VM** | Cutover proven against the **real target software** running in the sandbox VM (real PostgreSQL / MariaDB / Redis / `wg` / Gitea / local files). |
| **MOCK-ONLY** | Cutover proven only against an **emulator** — our own `httptest` / in-process SSH server, **or a third-party mock** (e.g. `moto` for AWS). **NOT** proven against the real target service. |
| **UNPROVEN** | No cutover proof recorded (e.g. the dry-run noop helper). |
> **Honesty note:** running in the VM does **not** automatically make a driver LIVE-VM.
> AWS ran in the VM but only against **moto** (a mock AWS), so it is **MOCK-ONLY**. LIVE-VM
> means the *real* target software validated the cutover.
## Current status
### LIVE-VM — proven against real target software in the sandbox VM
| Driver | Real target | Provisioner |
|------------|-------------|-------------|
| `postgres` | real PostgreSQL | `lab-provision-pg.sh` |
| `mysql` | real MariaDB | `lab-provision-dbclones.sh` |
| `redis` | real `redis-server` | `lab-provision-dbclones.sh` |
| `wireguard` | real `wg` | `lab-provision-wg.sh` |
| `gitea` | real Gitea | `lab-provision-gitea.sh` |
| `appsecret` | real local files | `lab-provision-appsec.sh` |
### MOCK-ONLY — proven only against an emulator / mock
| Driver | Emulator | Why not (yet) LIVE-VM |
|--------------|----------|------------------------|
| `aws` | `moto` mock AWS in VM | real AWS never hit |
| `sshkey` | in-process SSH server | live VM POC not run |
| `openwrt` | in-process SSH server | live QEMU deferred |
| `mullvad` | `httptest` emulator | needs a paid account |
| `cloudflare` | `httptest` emulator | no self-host |
| `ghactions` | `httptest` emulator (holds the libsodium box keypair, decrypts the sealed PUT) | no self-host |
| `gitlab` | `httptest` emulator | GitLab CE too heavy for the VM |
## What the MOCK-ONLY emulators actually prove
The emulators are **not** stubs that rubber-stamp success — each enforces the real
protocol so the test exercises the driver's true cutover logic:
- **gitlab** — `self/rotate` mints a new PAT *and revokes the caller's old one*; the test
asserts `Verify(old)` fails after `Rotate`, proving the revoke really happened.
- **cloudflare** — `PUT .../value` rolls the value for the same token id and invalidates
the previous value; the test asserts the id is unchanged and `Verify(old)` fails.
- **ghactions** — the emulator owns a real NaCl `box` keypair, serves the public key, and
on `PUT` **decrypts the sealed value** and stores the plaintext; the test asserts the
decrypted value equals the new value the driver put in the rebuilt blob — proving the
sealed-box encryption is correct, not simulated.
Promoting any of these to LIVE-VM only requires standing up the real service (a self-hosted
GitLab CE / a real Cloudflare token / a throwaway GitHub repo) and re-running the same
driver against it — no driver code changes.