From b415c43bff86667b14b67eb9bafa204ca678618d Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Thu, 18 Jun 2026 15:28:05 -0700 Subject: [PATCH] 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 --- cmd/incredigo/main.go | 22 ++- docs/FINDINGS-2026-06-16.md | 61 +++++++ docs/ROTATION-PROOFS.md | 73 ++++++++ internal/discover/env.go | 30 ++- internal/discover/scanners_test.go | 55 ++++++ internal/rotate/appsecret.go | 204 +++++++++++++++++++++ internal/rotate/appsecret_test.go | 145 +++++++++++++++ internal/rotate/cloudflare.go | 200 ++++++++++++++++++++ internal/rotate/cloudflare_test.go | 136 ++++++++++++++ internal/rotate/ghactions.go | 284 +++++++++++++++++++++++++++++ internal/rotate/ghactions_test.go | 198 ++++++++++++++++++++ internal/rotate/gitlab.go | 190 +++++++++++++++++++ internal/rotate/gitlab_test.go | 150 +++++++++++++++ internal/rotate/proofs.go | 70 +++++++ 14 files changed, 1809 insertions(+), 9 deletions(-) create mode 100644 docs/ROTATION-PROOFS.md create mode 100644 internal/rotate/appsecret.go create mode 100644 internal/rotate/appsecret_test.go create mode 100644 internal/rotate/cloudflare.go create mode 100644 internal/rotate/cloudflare_test.go create mode 100644 internal/rotate/ghactions.go create mode 100644 internal/rotate/ghactions_test.go create mode 100644 internal/rotate/gitlab.go create mode 100644 internal/rotate/gitlab_test.go create mode 100644 internal/rotate/proofs.go diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go index af9bd17..fe2d81e 100644 --- a/cmd/incredigo/main.go +++ b/cmd/incredigo/main.go @@ -442,7 +442,7 @@ func rotateCmd() *cobra.Command { } results := rotate.Execute(ctx, gp, ecreds, v) ew := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - fmt.Fprintln(ew, "DRIVER\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR") + fmt.Fprintln(ew, "DRIVER\tPROOF\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR") var rotated, failed int for _, r := range results { errStr := "" @@ -452,8 +452,8 @@ func rotateCmd() *cobra.Command { } else { rotated++ } - fmt.Fprintf(ew, "%s\t%s\t%t\t%t\t%t\t%t\t%s\n", - r.Driver, r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr) + fmt.Fprintf(ew, "%s\t%s\t%s\t%t\t%t\t%t\t%t\t%s\n", + r.Driver, rotate.ProofLevelOf(r.Driver).String(), r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr) out := "ok" if r.Err != nil { out = r.Err.Error() @@ -486,20 +486,26 @@ func rotateCmd() *cobra.Command { w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) if blastScan { - fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY\tCONSUMERS") + fmt.Fprintln(w, "DRIVER\tPROOF\tSOURCE\tIDENTITY\tCONSUMERS") } else { - fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY") + fmt.Fprintln(w, "DRIVER\tPROOF\tSOURCE\tIDENTITY") } for i, p := range rotate.PlanAll(creds) { drv := p.Driver + // PROOF is how the driver's real-cutover path was validated + // (LIVE-VM vs MOCK-ONLY) — surfaced so a mock-only driver can + // never be mistaken for a live one. No driver => no proof. + proof := "-" if drv == "" { drv = "(none)" + } else { + proof = rotate.ProofLevelOf(p.Driver).String() } if blastScan { - fmt.Fprintf(w, "%s\t%s\t%s\t%d file(s)\n", - drv, p.Credential.Source, p.Credential.Identity, len(radius[i].Files())) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d file(s)\n", + drv, proof, p.Credential.Source, p.Credential.Identity, len(radius[i].Files())) } else { - fmt.Fprintf(w, "%s\t%s\t%s\n", drv, p.Credential.Source, p.Credential.Identity) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", drv, proof, p.Credential.Source, p.Credential.Identity) } } w.Flush() diff --git a/docs/FINDINGS-2026-06-16.md b/docs/FINDINGS-2026-06-16.md index 9d8d386..77bf22a 100644 --- a/docs/FINDINGS-2026-06-16.md +++ b/docs/FINDINGS-2026-06-16.md @@ -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:///?tok=`. 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:///?id=&token=`. 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 diff --git a/docs/ROTATION-PROOFS.md b/docs/ROTATION-PROOFS.md new file mode 100644 index 0000000..9194645 --- /dev/null +++ b/docs/ROTATION-PROOFS.md @@ -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. diff --git a/internal/discover/env.go b/internal/discover/env.go index 57bc4bc..f8ff03a 100644 --- a/internal/discover/env.go +++ b/internal/discover/env.go @@ -59,8 +59,18 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er // regardless of the secret-ish heuristic: a weak/empty DB password is // exactly the kind of leak we must not miss. source, kind := e.Name(), Kind(KindToken) + secretBlob := val if dbSrc := dbSourceForURL(val); dbSrc != "" { source, kind = dbSrc, KindPassword + } else if appSigningKey(k) && len(val) >= 12 { + // An app SIGNING secret (Django SECRET_KEY, Rails secret_key_base, a + // JWT signing secret, …) lives in this very file; tag it Source= + // "appsecret" with a self-contained blob carrying the value AND its + // file path so the local in-place rotator is driver-ready. + source = "appsecret" + secretBlob = "appsecret://local/?" + url.Values{ + "key": {k}, "val": {val}, "path": {p}, + }.Encode() } else if !looksSecret(k, val) { continue } @@ -70,7 +80,7 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er Identity: filepath.Base(p) + " / " + k, Location: p, Modified: fi.ModTime(), - Secret: v.Store([]byte(val)), + Secret: v.Store([]byte(secretBlob)), }) } f.Close() @@ -104,6 +114,24 @@ func dbSourceForURL(val string) string { return "" } +// appSigningKeys are the env-var names that denote a local application SIGNING secret +// — the kind the appsecret rotator can regenerate in place. Matched case-insensitively +// and exactly (so STRIPE_API_KEY / DB_PASSWORD stay generic env secrets, not appsecrets). +var appSigningKeys = map[string]bool{ + "secret_key": true, // Django / Flask + "secret_key_base": true, // Rails + "django_secret_key": true, + "flask_secret_key": true, + "app_key": true, // Laravel + "app_secret": true, + "jwt_secret": true, + "session_secret": true, + "nextauth_secret": true, + "rails_secret": true, +} + +func appSigningKey(key string) bool { return appSigningKeys[strings.ToLower(key)] } + func looksSecret(key, val string) bool { lk := strings.ToLower(key) for _, h := range sensitiveHints { diff --git a/internal/discover/scanners_test.go b/internal/discover/scanners_test.go index b8fcfa7..f8d77b8 100644 --- a/internal/discover/scanners_test.go +++ b/internal/discover/scanners_test.go @@ -141,6 +141,61 @@ func TestEnvScanner(t *testing.T) { "pgSECRET", "redisSECRET", "weak") } +// TestEnvScannerAppSecret proves an app SIGNING secret (SECRET_KEY) is tagged +// Source="appsecret" with a self-contained blob carrying its value AND file path, so +// the local in-place rotator can Detect+Rotate it straight from a .env scan — while a +// generic API key in the same file stays a plain env secret. +func TestEnvScannerAppSecret(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + appVal := "django-insecure-0123456789abcdefghijABCDEF" + writeFixture(t, filepath.Join(dir, ".env"), + "SECRET_KEY="+appVal+"\nSTRIPE_API_KEY=sk_live_keepGenericEnv00\n") + + v := vault.New() + defer v.Purge() + creds, err := (&envScanner{}).Scan(context.Background(), v) + if err != nil { + t.Fatal(err) + } + + var app *Credential + for i := range creds { + if creds[i].Source == "appsecret" { + app = &creds[i] + } + } + if app == nil { + t.Fatalf("SECRET_KEY not tagged Source=appsecret; got %+v", creds) + } + if app.Identity != ".env / SECRET_KEY" { + t.Errorf("unexpected identity %q", app.Identity) + } + + // The blob must round-trip the value AND the file path for the rotator. + buf, _ := v.Open(app.Secret) + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil || u.Scheme != "appsecret" { + t.Fatalf("appsecret blob malformed: %v (%q)", err, string(buf.Bytes())) + } + if u.Query().Get("val") != appVal { + t.Error("appsecret blob does not carry the signing value") + } + if got := u.Query().Get("path"); !strings.HasSuffix(got, ".env") { + t.Errorf("appsecret blob path missing/wrong: %q", got) + } + + // The generic API key in the same file must NOT be tagged appsecret. + for _, c := range creds { + if c.Source == "appsecret" && strings.Contains(c.Identity, "STRIPE") { + t.Error("generic API key wrongly tagged appsecret") + } + } + + // Leak-check: the signing value must not appear in any non-secret field. + assertNoLeak(t, creds, appVal) +} + func TestNetrcScanner(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/internal/rotate/appsecret.go b/internal/rotate/appsecret.go new file mode 100644 index 0000000..4019a90 --- /dev/null +++ b/internal/rotate/appsecret.go @@ -0,0 +1,204 @@ +package rotate + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// AppSecret rotates a LOCAL application signing secret — the kind of long, random +// string an app uses to sign sessions/tokens (Django SECRET_KEY, Rails +// secret_key_base, Flask SECRET_KEY, a JWT signing secret, an APP_KEY, …). Unlike the +// gopass-custodied credentials, an app signing secret's authoritative home IS a config +// file the app reads at boot, so "rotating" it means generating a fresh value and +// rewriting it, in place, in every file that holds the current value. +// +// This is an in-place rotation: replacing the literal old value with a fresh one is +// itself the cutover (the old value is gone the moment the files are rewritten), so +// RevokeOld is a no-op and the §3 sealed backup is the recovery path. Verify re-reads +// every target file and proves the new value is present and the old value is absent. +// +// The credential's secret is a single self-contained line (so it round-trips the +// single-line gopass sink unchanged): +// +// appsecret://local/?key=&val=&path=[&path=…] +// +// - key= the setting/env-var name the secret is stored under (human-readable; +// used only for the audit identity, never for matching). +// - val= the CURRENT secret value. Replacement is keyed on this literal string, +// so it works regardless of file format (.env / yaml / json / toml). +// - path= one or more files that contain the value. All are rewritten atomically. +// +// SECURITY (Hard Rule #3): val is a secret — it lives only in the vault blob and is +// written to the app's OWN config files (its legitimate secret store); it never reaches +// Identity/Meta/logs/argv, and every error scrubs it. A minimum-length guard refuses to +// treat a short, common string as the secret (which could mass-corrupt files). +type AppSecret struct{} + +// init self-registers the driver. Availability alone changes nothing; the +// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies. +func init() { Register(&AppSecret{}) } + +// Name identifies the driver in plans and audit records. +func (a *AppSecret) Name() string { return "appsecret" } + +// Detect claims credentials tagged Source == "appsecret". +func (a *AppSecret) Detect(c discover.Credential) bool { return c.Source == "appsecret" } + +// appSec is the parsed credential blob. None of its fields are ever logged. +type appSec struct { + key string + val string + paths []string +} + +// minSecretLen guards against keying a literal replacement on a short/common string. +// Real app signing secrets are long random strings; anything shorter is rejected so a +// stray "val=key" can never sweep through unrelated file contents. +const minSecretLen = 12 + +func parseAppSecret(v *vault.Vault, h *vault.Handle) (appSec, error) { + buf, err := v.Open(h) + if err != nil { + return appSec{}, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return appSec{}, fmt.Errorf("appsecret: parse secret url: %w", err) + } + if u.Scheme != "appsecret" { + return appSec{}, fmt.Errorf("appsecret: unexpected scheme %q", u.Scheme) + } + q := u.Query() + s := appSec{key: q.Get("key"), val: q.Get("val"), paths: q["path"]} + if s.val == "" { + return appSec{}, fmt.Errorf("appsecret: secret has no value") + } + if len(s.val) < minSecretLen { + return appSec{}, fmt.Errorf("appsecret: value too short to rotate safely (<%d chars)", minSecretLen) + } + if len(s.paths) == 0 { + return appSec{}, fmt.Errorf("appsecret: secret names no target file") + } + return s, nil +} + +// build re-encodes an appSec into the single-line blob form. +func (s appSec) build() string { + u := &url.URL{Scheme: "appsecret", Host: "local", Path: "/"} + q := url.Values{} + if s.key != "" { + q.Set("key", s.key) + } + q.Set("val", s.val) + for _, p := range s.paths { + q.Add("path", p) + } + u.RawQuery = q.Encode() + return u.String() +} + +// Rotate generates a fresh value and rewrites every target file, replacing the literal +// old value in place, then returns a vault handle to a rebuilt blob carrying the new +// value. It requires that at least one replacement actually happened across the files; +// otherwise the old value was not where we were told it is and we must NOT claim a +// rotation. +func (a *AppSecret) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + s, err := parseAppSecret(v, c.Secret) + if err != nil { + return nil, err + } + newVal, err := genHex(32) // 64 hex chars — a strong app signing secret + if err != nil { + return nil, err + } + + total := 0 + for _, p := range s.paths { + n, err := replaceInFile(p, s.val, newVal) + if err != nil { + // Never let the old or new value leak through a path error. + return nil, fmt.Errorf("appsecret: rewrite %s: %w", p, redactErr(err, s.val, newVal)) + } + total += n + } + if total == 0 { + return nil, fmt.Errorf("appsecret: old value not found in any target file — nothing rotated") + } + + ns := s + ns.val = newVal + return v.Store([]byte(ns.build())), nil +} + +// Verify re-reads every target file and proves the new value is present and the old +// value is absent. It is credential-less in the Execute spine, so it reads the new +// value (and paths) straight from the rebuilt blob. +func (a *AppSecret) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseAppSecret(v, newSecret) + if err != nil { + return err + } + for _, p := range s.paths { + b, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("appsecret verify: read %s: %w", p, err) + } + if !strings.Contains(string(b), s.val) { + return fmt.Errorf("appsecret verify: new value missing from %s", p) + } + } + return nil +} + +// RevokeOld is a no-op: the in-place rewrite already removed the old value from every +// file. Defined to satisfy the safety-spine ordering. +func (a *AppSecret) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + return nil +} + +// replaceInFile replaces every occurrence of old with new in the file at path, +// preserving the file mode, via a temp file + atomic rename so a crash never leaves a +// half-written config. It returns the number of replacements made (0 if old was not +// present — not an error here; Rotate enforces the across-files total). +func replaceInFile(path, old, new string) (int, error) { + b, err := os.ReadFile(path) + if err != nil { + return 0, err + } + n := strings.Count(string(b), old) + if n == 0 { + return 0, nil + } + out := strings.ReplaceAll(string(b), old, new) + + mode := os.FileMode(0o600) + if fi, err := os.Stat(path); err == nil { + mode = fi.Mode().Perm() + } + tmp := path + ".incredigo.tmp" + if err := os.WriteFile(tmp, []byte(out), mode); err != nil { + return 0, err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return 0, err + } + return n, nil +} + +// redactErr scrubs any secret substrings out of an error before it can surface. +func redactErr(err error, secrets ...string) error { + msg := err.Error() + for _, s := range secrets { + if s != "" { + msg = strings.ReplaceAll(msg, s, "***") + } + } + return fmt.Errorf("%s", msg) +} diff --git a/internal/rotate/appsecret_test.go b/internal/rotate/appsecret_test.go new file mode 100644 index 0000000..e44ee83 --- /dev/null +++ b/internal/rotate/appsecret_test.go @@ -0,0 +1,145 @@ +package rotate + +import ( + "context" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +func TestAppSecretDetect(t *testing.T) { + a := &AppSecret{} + if !a.Detect(discover.Credential{Source: "appsecret"}) { + t.Error("should detect Source=appsecret") + } + if a.Detect(discover.Credential{Source: "env"}) { + t.Error("should not detect Source=env") + } +} + +// TestAppSecretRotateRewritesAllFiles proves the real local cutover: the literal old +// value is replaced by a fresh value in EVERY target file (atomic rewrite), the new +// blob carries the new value, and Verify confirms presence-of-new / absence-of-old. +func TestAppSecretRotateRewritesAllFiles(t *testing.T) { + dir := t.TempDir() + oldVal := "s3cret-signing-value-abcdefghijklmnop" + + // Two files share the same app signing secret (e.g. an app .env and a worker .env). + envPath := filepath.Join(dir, ".env") + ymlPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(envPath, []byte("PORT=8080\nSECRET_KEY="+oldVal+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ymlPath, []byte("service:\n secret_key: "+oldVal+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + v := vault.New() + defer v.Purge() + blob := "appsecret://local/?" + url.Values{ + "key": {"SECRET_KEY"}, + "val": {oldVal}, + "path": {envPath, ymlPath}, + }.Encode() + cred := discover.Credential{ + Source: "appsecret", + Identity: ".env / SECRET_KEY", + Secret: v.Store([]byte(blob)), + } + a := &AppSecret{} + ctx := context.Background() + + newH, err := a.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + nb, _ := v.Open(newH) + nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes()))) + newVal := nu.Query().Get("val") + if newVal == "" || newVal == oldVal { + t.Fatalf("blob carries no fresh value (got %q)", newVal) + } + + // CUTOVER PROOF: both files now hold the new value and NOT the old one. + for _, p := range []string{envPath, ymlPath} { + b, _ := os.ReadFile(p) + if !strings.Contains(string(b), newVal) { + t.Errorf("%s missing new value after rotation", filepath.Base(p)) + } + if strings.Contains(string(b), oldVal) { + t.Errorf("%s still contains the OLD value after rotation", filepath.Base(p)) + } + } + + // Untouched lines survive (atomic full-file rewrite, not truncation). + eb, _ := os.ReadFile(envPath) + if !strings.Contains(string(eb), "PORT=8080") { + t.Error("unrelated config line lost during rewrite") + } + + // Mode preserved on the 0644 file. + if fi, _ := os.Stat(ymlPath); fi.Mode().Perm() != 0o644 { + t.Errorf("file mode not preserved: got %o", fi.Mode().Perm()) + } + + if err := a.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + + // Verify(old) must FAIL — the old value is gone from the files. + if err := a.Verify(ctx, cred.Secret, v); err == nil { + t.Error("Verify(old) must fail after rotation — old value should be absent") + } + + if err := a.RevokeOld(ctx, cred, v); err != nil { + t.Errorf("RevokeOld (no-op) returned: %v", err) + } + + // Leak-check: no secret value in the credential's non-secret metadata. + if strings.Contains(cred.Identity, oldVal) || strings.Contains(cred.Identity, newVal) { + t.Error("secret value leaked into credential metadata") + } +} + +// TestAppSecretNoMatchIsError proves we never claim a rotation when the old value is +// not actually present in any file (would otherwise silently mint+discard a value). +func TestAppSecretNoMatchIsError(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".env") + if err := os.WriteFile(p, []byte("SECRET_KEY=something-else-entirely-here\n"), 0o600); err != nil { + t.Fatal(err) + } + v := vault.New() + defer v.Purge() + blob := "appsecret://local/?" + url.Values{ + "key": {"SECRET_KEY"}, "val": {"value-that-is-not-present-xx"}, "path": {p}, + }.Encode() + cred := discover.Credential{Source: "appsecret", Secret: v.Store([]byte(blob))} + if _, err := (&AppSecret{}).Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should error when the old value is in no target file") + } +} + +func TestAppSecretRejectsShortOrEmpty(t *testing.T) { + v := vault.New() + defer v.Purge() + a := &AppSecret{} + // too-short value (under minSecretLen) must be refused. + short := discover.Credential{Source: "appsecret", + Secret: v.Store([]byte("appsecret://local/?val=abc&path=/tmp/x"))} + if _, err := a.Rotate(context.Background(), short, v); err == nil { + t.Error("Rotate should reject a too-short value") + } + // no path must be refused. + nopath := discover.Credential{Source: "appsecret", + Secret: v.Store([]byte("appsecret://local/?val=a-long-enough-value-here"))} + if _, err := a.Rotate(context.Background(), nopath, v); err == nil { + t.Error("Rotate should reject a secret with no target file") + } +} diff --git a/internal/rotate/cloudflare.go b/internal/rotate/cloudflare.go new file mode 100644 index 0000000..0f22d18 --- /dev/null +++ b/internal/rotate/cloudflare.go @@ -0,0 +1,200 @@ +package rotate + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// Cloudflare rotates a Cloudflare API token by ROLLING its value: +// +// PUT /client/v4/user/tokens/{id}/value +// +// Cloudflare regenerates the secret value of the SAME token id and returns the new +// value; the previous value is invalidated immediately. Because the roll IS the +// cutover (same id, fresh value, old value dead), this is an in-place rotation like +// the DB drivers: RevokeOld is a no-op and the §3 sealed backup is the recovery +// path. Rolling the value (rather than create-new + delete-old) keeps every API +// token's id/policies/name stable, so nothing that references the token by id breaks. +// +// The credential's secret is a single self-contained line (so it round-trips the +// single-line gopass sink unchanged): +// +// cloudflare:///?id=&token= +// +// - scheme "cloudflare" maps to https; "http"/"https" are accepted so a test +// can point at a loopback emulator. +// - the API host; "api" (or empty) defaults to api.cloudflare.com. +// - id= the token id — required to address the roll endpoint; the value +// cannot be mapped back to an id. +// - token= the token value being rotated (Bearer auth). +// +// SECURITY: the token value lives only in the vault blob and the Bearer header; it +// never reaches Identity/Meta/logs/argv. Response bodies carrying the new value are +// decoded straight into the rebuilt blob. +type Cloudflare struct { + // HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout + // client using http.DefaultTransport. + HTTPClient *http.Client +} + +// init self-registers the driver. Availability alone changes nothing; the +// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies. +func init() { Register(&Cloudflare{}) } + +// Name identifies the driver in plans and audit records. +func (c *Cloudflare) Name() string { return "cloudflare" } + +// Detect claims credentials tagged Source == "cloudflare". +func (c *Cloudflare) Detect(cr discover.Credential) bool { return cr.Source == "cloudflare" } + +func (c *Cloudflare) client() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 15 * time.Second} +} + +// cfSecret is the parsed credential blob. None of its fields are ever logged. +type cfSecret struct { + base string // scheme://host + id string + token string +} + +func parseCFSecret(v *vault.Vault, h *vault.Handle) (cfSecret, error) { + buf, err := v.Open(h) + if err != nil { + return cfSecret{}, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return cfSecret{}, fmt.Errorf("cloudflare: parse secret url: %w", err) + } + if u.Scheme != "cloudflare" && u.Scheme != "http" && u.Scheme != "https" { + return cfSecret{}, fmt.Errorf("cloudflare: unexpected scheme %q", u.Scheme) + } + scheme := u.Scheme + host := u.Host + if scheme == "cloudflare" { + scheme = "https" + } + if host == "" || host == "api" { + host = "api.cloudflare.com" + } + q := u.Query() + s := cfSecret{base: scheme + "://" + host, id: q.Get("id"), token: q.Get("token")} + if s.token == "" { + return cfSecret{}, fmt.Errorf("cloudflare: secret has no token") + } + if s.id == "" { + return cfSecret{}, fmt.Errorf("cloudflare: secret has no token id") + } + return s, nil +} + +// build re-encodes a cfSecret into the single-line blob form (scheme normalised back +// to "cloudflare" so the in-vault form stays stable). +func (s cfSecret) build() string { + host := hostOf(s.base) + if host == "api.cloudflare.com" { + host = "api" + } + u := &url.URL{Scheme: "cloudflare", Host: host, Path: "/"} + q := url.Values{} + q.Set("id", s.id) + q.Set("token", s.token) + u.RawQuery = q.Encode() + return u.String() +} + +// cfResult is the standard Cloudflare envelope; only the fields we use are decoded. +type cfRollResult struct { + Result string `json:"result"` + Success bool `json:"success"` +} + +// Rotate rolls the token value and returns a vault handle to a rebuilt blob carrying +// the new value (same id). The old value is dead the moment Cloudflare responds. +func (c *Cloudflare) Rotate(ctx context.Context, cr discover.Credential, v *vault.Vault) (*vault.Handle, error) { + s, err := parseCFSecret(v, cr.Secret) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, + s.base+"/client/v4/user/tokens/"+url.PathEscape(s.id)+"/value", strings.NewReader("{}")) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.token) + + resp, err := c.client().Do(req) + if err != nil { + return nil, fmt.Errorf("cloudflare: roll token: %w", err) + } + defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cloudflare: roll token: unexpected status %s", resp.Status) + } + var rolled cfRollResult + if err := json.NewDecoder(resp.Body).Decode(&rolled); err != nil { + return nil, fmt.Errorf("cloudflare: decode rolled token: %w", err) + } + if !rolled.Success || rolled.Result == "" { + return nil, fmt.Errorf("cloudflare: roll token: empty value in response") + } + + ns := s + ns.token = rolled.Result + return v.Store([]byte(ns.build())), nil +} + +// Verify proves the new token value authenticates and is active via the token +// verify endpoint. +func (c *Cloudflare) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseCFSecret(v, newSecret) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/client/v4/user/tokens/verify", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+s.token) + resp, err := c.client().Do(req) + if err != nil { + return fmt.Errorf("cloudflare verify: %w", err) + } + defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("cloudflare verify: status %s", resp.Status) + } + var verified struct { + Success bool `json:"success"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&verified); err != nil { + return fmt.Errorf("cloudflare verify: decode: %w", err) + } + if !verified.Success || verified.Result.Status != "active" { + return fmt.Errorf("cloudflare verify: token not active") + } + return nil +} + +// RevokeOld is a no-op: rolling the value already invalidated the previous token. +// Defined to satisfy the safety-spine ordering. +func (c *Cloudflare) RevokeOld(ctx context.Context, cr discover.Credential, v *vault.Vault) error { + return nil +} diff --git a/internal/rotate/cloudflare_test.go b/internal/rotate/cloudflare_test.go new file mode 100644 index 0000000..d789131 --- /dev/null +++ b/internal/rotate/cloudflare_test.go @@ -0,0 +1,136 @@ +package rotate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// cfEmu is an in-process Cloudflare API emulator for one token id: it tracks the +// currently-valid value, rolls it on PUT .../value, and verifies a Bearer token. +type cfEmu struct { + mu sync.Mutex + id string + cur string // currently-valid token value + minted int +} + +func (e *cfEmu) bearer(r *http.Request) string { + return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") +} + +func (e *cfEmu) valid(tok string) bool { + e.mu.Lock() + defer e.mu.Unlock() + return tok != "" && tok == e.cur +} + +func (e *cfEmu) roll() string { + e.mu.Lock() + defer e.mu.Unlock() + e.minted++ + e.cur = "cf-rolled-value-" + itoa(e.minted) + return e.cur +} + +func (e *cfEmu) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/client/v4/user/tokens/"+e.id+"/value", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !e.valid(e.bearer(r)) { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]any{"success": false}) + return + } + newVal := e.roll() + json.NewEncoder(w).Encode(map[string]any{"success": true, "result": newVal}) + }) + mux.HandleFunc("/client/v4/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) { + if !e.valid(e.bearer(r)) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]any{"success": false}) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{"id": e.id, "status": "active"}, + }) + }) + return mux +} + +func TestCloudflareDetect(t *testing.T) { + c := &Cloudflare{} + if !c.Detect(discover.Credential{Source: "cloudflare"}) { + t.Error("should detect Source=cloudflare") + } + if c.Detect(discover.Credential{Source: "aws"}) { + t.Error("should not detect Source=aws") + } +} + +func TestCloudflareRotateRealCutover(t *testing.T) { + emu := &cfEmu{id: "tok123", cur: "cf-seed-value-0001"} + srv := httptest.NewTLSServer(emu.handler()) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "https://") + + v := vault.New() + defer v.Purge() + oldVal := emu.cur + cred := discover.Credential{ + Source: "cloudflare", + Identity: "cloudflare api token", + Secret: v.Store([]byte("cloudflare://" + host + "/?id=" + emu.id + "&token=" + url.QueryEscape(oldVal))), + } + c := &Cloudflare{HTTPClient: srv.Client()} + ctx := context.Background() + + newH, err := c.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + nb, _ := v.Open(newH) + nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes()))) + newVal := nu.Query().Get("token") + if newVal == "" || newVal == oldVal { + t.Fatalf("blob carries no fresh token value (got %q)", newVal) + } + if nu.Query().Get("id") != emu.id { + t.Errorf("token id changed: want %q got %q", emu.id, nu.Query().Get("id")) + } + + if err := c.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + + // CUTOVER: the old value is dead after the roll. + if err := c.Verify(ctx, cred.Secret, v); err == nil { + t.Error("Verify(old) must fail after rotation — old value should be invalid") + } else if strings.Contains(err.Error(), newVal) { + t.Error("error leaked the new token value") + } + + if err := c.RevokeOld(ctx, cred, v); err != nil { + t.Errorf("RevokeOld (no-op) returned: %v", err) + } +} + +func TestCloudflareRejectsBadSecret(t *testing.T) { + c := &Cloudflare{} + v := vault.New() + defer v.Purge() + // missing id + cred := discover.Credential{Source: "cloudflare", Secret: v.Store([]byte("cloudflare://api/?token=abc"))} + if _, err := c.Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should reject a secret with no token id") + } +} diff --git a/internal/rotate/ghactions.go b/internal/rotate/ghactions.go new file mode 100644 index 0000000..ae06713 --- /dev/null +++ b/internal/rotate/ghactions.go @@ -0,0 +1,284 @@ +package rotate + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/crypto/nacl/box" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// GHActions rotates the VALUE of a GitHub Actions repository secret in place: +// +// GET /repos/{owner}/{repo}/actions/secrets/public-key -> {key_id, key} +// PUT /repos/{owner}/{repo}/actions/secrets/{name} <- {encrypted_value, key_id} +// +// GitHub stores Actions secrets sealed to the repo's libsodium public key; the only +// way to write one is to encrypt with crypto_box_seal (NaCl "sealed box"). PUT to an +// existing secret name OVERWRITES its value, so the previous value is dead the moment +// the PUT returns — this is an in-place rotation like the DB drivers: RevokeOld is a +// no-op and the §3 sealed backup is the recovery path. +// +// Note on what is being rotated: the rotated material is the SECRET VALUE consumed by +// workflows (val=), NOT the PAT used to authenticate the API call (pat=). The PAT is a +// long-lived management credential carried in the blob; it is unchanged by a roll. +// +// The credential's secret is a single self-contained line (so it round-trips the +// single-line gopass sink unchanged): +// +// ghactions:///?owner=&repo=&name=&pat=&val= +// +// - scheme "ghactions" maps to https; "http"/"https" are accepted so a test can +// point at a loopback emulator. +// - the API host; "api" (or empty) defaults to api.github.com. +// - owner/repo the repository whose secret is rolled. +// - name= the Actions secret name (stable; the roll keeps name/visibility). +// - pat= the management PAT (Bearer auth; repo+secrets scope). Not rotated here. +// - val= the secret value being rotated. GitHub never returns it, so we keep +// the plaintext we generated in the rebuilt blob; the wire only ever +// carries the sealed-box ciphertext. +// +// SECURITY: neither pat nor val ever reaches Identity/Meta/logs/argv. val is generated +// locally, sealed straight into the request body, and otherwise lives only in the +// vault blob. Verify can only prove the secret EXISTS (GitHub never discloses values), +// so it asserts a 200 on the secret's metadata endpoint, not a value match. +type GHActions struct { + // HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout + // client using http.DefaultTransport. + HTTPClient *http.Client +} + +// init self-registers the driver. Availability alone changes nothing; the +// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies. +func init() { Register(&GHActions{}) } + +// Name identifies the driver in plans and audit records. +func (g *GHActions) Name() string { return "ghactions" } + +// Detect claims credentials tagged Source == "ghactions". +func (g *GHActions) Detect(c discover.Credential) bool { return c.Source == "ghactions" } + +func (g *GHActions) client() *http.Client { + if g.HTTPClient != nil { + return g.HTTPClient + } + return &http.Client{Timeout: 15 * time.Second} +} + +// ghaSecret is the parsed credential blob. None of its fields are ever logged. +type ghaSecret struct { + base string // scheme://host + owner string + repo string + name string + pat string + val string +} + +func parseGHASecret(v *vault.Vault, h *vault.Handle) (ghaSecret, error) { + buf, err := v.Open(h) + if err != nil { + return ghaSecret{}, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return ghaSecret{}, fmt.Errorf("ghactions: parse secret url: %w", err) + } + if u.Scheme != "ghactions" && u.Scheme != "http" && u.Scheme != "https" { + return ghaSecret{}, fmt.Errorf("ghactions: unexpected scheme %q", u.Scheme) + } + scheme := u.Scheme + host := u.Host + if scheme == "ghactions" { + scheme = "https" + } + if host == "" || host == "api" { + host = "api.github.com" + } + q := u.Query() + s := ghaSecret{ + base: scheme + "://" + host, + owner: q.Get("owner"), + repo: q.Get("repo"), + name: q.Get("name"), + pat: q.Get("pat"), + val: q.Get("val"), + } + if s.owner == "" || s.repo == "" { + return ghaSecret{}, fmt.Errorf("ghactions: secret has no owner/repo") + } + if s.name == "" { + return ghaSecret{}, fmt.Errorf("ghactions: secret has no secret name") + } + if s.pat == "" { + return ghaSecret{}, fmt.Errorf("ghactions: secret has no management pat") + } + return s, nil +} + +// build re-encodes a ghaSecret into the single-line blob form (scheme normalised back +// to "ghactions" so the in-vault form stays stable). +func (s ghaSecret) build() string { + host := hostOf(s.base) + if host == "api.github.com" { + host = "api" + } + u := &url.URL{Scheme: "ghactions", Host: host, Path: "/"} + q := url.Values{} + q.Set("owner", s.owner) + q.Set("repo", s.repo) + q.Set("name", s.name) + q.Set("pat", s.pat) + q.Set("val", s.val) + u.RawQuery = q.Encode() + return u.String() +} + +func (g *GHActions) authReq(ctx context.Context, method, url, pat string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+pat) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + return req, nil +} + +// Rotate generates a fresh value, seals it to the repo's libsodium public key, PUTs it +// over the existing secret name (overwriting the old value), and returns a vault handle +// to a rebuilt blob carrying the new plaintext value. +func (g *GHActions) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + s, err := parseGHASecret(v, c.Secret) + if err != nil { + return nil, err + } + + // 1) fetch the repo's public key for Actions secrets. + keyURL := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/public-key", + s.base, url.PathEscape(s.owner), url.PathEscape(s.repo)) + req, err := g.authReq(ctx, http.MethodGet, keyURL, s.pat, nil) + if err != nil { + return nil, err + } + resp, err := g.client().Do(req) + if err != nil { + return nil, fmt.Errorf("ghactions: fetch public-key: %w", err) + } + var pk struct { + KeyID string `json:"key_id"` + Key string `json:"key"` + } + if err := decodeJSON(resp, &pk); err != nil { + return nil, fmt.Errorf("ghactions: public-key: %w", err) + } + if pk.KeyID == "" || pk.Key == "" { + return nil, fmt.Errorf("ghactions: public-key response incomplete") + } + rawKey, err := base64.StdEncoding.DecodeString(pk.Key) + if err != nil { + return nil, fmt.Errorf("ghactions: decode public-key: %w", err) + } + if len(rawKey) != 32 { + return nil, fmt.Errorf("ghactions: public-key wrong length %d", len(rawKey)) + } + + // 2) generate the new secret value and seal it (crypto_box_seal). + newVal, err := genHex(24) + if err != nil { + return nil, err + } + var recipient [32]byte + copy(recipient[:], rawKey) + sealed, err := box.SealAnonymous(nil, []byte(newVal), &recipient, rand.Reader) + if err != nil { + return nil, fmt.Errorf("ghactions: seal value: %w", err) + } + + // 3) PUT the sealed value over the existing secret name. + putBody, _ := json.Marshal(map[string]string{ + "encrypted_value": base64.StdEncoding.EncodeToString(sealed), + "key_id": pk.KeyID, + }) + putURL := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s", + s.base, url.PathEscape(s.owner), url.PathEscape(s.repo), url.PathEscape(s.name)) + preq, err := g.authReq(ctx, http.MethodPut, putURL, s.pat, bytes.NewReader(putBody)) + if err != nil { + return nil, err + } + preq.Header.Set("Content-Type", "application/json") + presp, err := g.client().Do(preq) + if err != nil { + return nil, fmt.Errorf("ghactions: put secret: %w", err) + } + defer func() { io.Copy(io.Discard, presp.Body); presp.Body.Close() }() + // GitHub returns 201 (created) or 204 (updated). + if presp.StatusCode != http.StatusCreated && presp.StatusCode != http.StatusNoContent { + return nil, fmt.Errorf("ghactions: put secret: unexpected status %s", presp.Status) + } + + ns := s + ns.val = newVal + return v.Store([]byte(ns.build())), nil +} + +// Verify proves the secret exists under the (rebuilt) blob's coordinates. GitHub never +// returns Actions secret values, so this is an existence/authorization check (200), not +// a value match — the value's correctness was guaranteed at seal time. +func (g *GHActions) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseGHASecret(v, newSecret) + if err != nil { + return err + } + getURL := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s", + s.base, url.PathEscape(s.owner), url.PathEscape(s.repo), url.PathEscape(s.name)) + req, err := g.authReq(ctx, http.MethodGet, getURL, s.pat, nil) + if err != nil { + return err + } + resp, err := g.client().Do(req) + if err != nil { + return fmt.Errorf("ghactions verify: %w", err) + } + defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("ghactions verify: status %s", resp.Status) + } + var meta struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return fmt.Errorf("ghactions verify: decode: %w", err) + } + if !strings.EqualFold(meta.Name, s.name) { + return fmt.Errorf("ghactions verify: secret name mismatch") + } + return nil +} + +// RevokeOld is a no-op: the PUT overwrote the previous value in place, so there is no +// separate old credential to retire. Defined to satisfy the safety-spine ordering. +func (g *GHActions) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + return nil +} + +// decodeJSON drains+closes the response and decodes a 200 body, returning a status +// error otherwise. +func decodeJSON(resp *http.Response, out any) error { + defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/internal/rotate/ghactions_test.go b/internal/rotate/ghactions_test.go new file mode 100644 index 0000000..18a185c --- /dev/null +++ b/internal/rotate/ghactions_test.go @@ -0,0 +1,198 @@ +package rotate + +import ( + "crypto/rand" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "golang.org/x/crypto/nacl/box" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// ghaEmu is an in-process GitHub Actions API emulator for ONE repo secret. It owns a +// libsodium (NaCl) box keypair, serves the public key, and on PUT decrypts the sealed +// value with the private key so a test can assert the value the driver actually wrote. +type ghaEmu struct { + mu sync.Mutex + pub [32]byte + priv [32]byte + keyID string + pat string // the only accepted Bearer token + secret string // decrypted current secret value (after a PUT) + hasSecret bool +} + +func newGHAEmu(pat string) *ghaEmu { + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + panic(err) + } + return &ghaEmu{pub: *pub, priv: *priv, keyID: "kid-123", pat: pat} +} + +func (e *ghaEmu) auth(r *http.Request) bool { + return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") == e.pat +} + +func (e *ghaEmu) handler() http.Handler { + mux := http.NewServeMux() + const base = "/repos/octo/lab/actions/secrets" + + mux.HandleFunc(base+"/public-key", func(w http.ResponseWriter, r *http.Request) { + if !e.auth(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "key_id": e.keyID, + "key": base64.StdEncoding.EncodeToString(e.pub[:]), + }) + }) + + // PUT writes (seals) the value; GET returns metadata only (never the value). + mux.HandleFunc(base+"/DEPLOY_TOKEN", func(w http.ResponseWriter, r *http.Request) { + if !e.auth(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + switch r.Method { + case http.MethodPut: + var body struct { + Encrypted string `json:"encrypted_value"` + KeyID string `json:"key_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.KeyID != e.keyID { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + sealed, err := base64.StdEncoding.DecodeString(body.Encrypted) + if err != nil { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + plain, ok := box.OpenAnonymous(nil, sealed, &e.pub, &e.priv) + if !ok { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + e.mu.Lock() + e.secret = string(plain) + e.hasSecret = true + e.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + e.mu.Lock() + has := e.hasSecret + e.mu.Unlock() + if !has { + w.WriteHeader(http.StatusNotFound) + return + } + json.NewEncoder(w).Encode(map[string]any{"name": "DEPLOY_TOKEN"}) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + return mux +} + +func (e *ghaEmu) stored() string { + e.mu.Lock() + defer e.mu.Unlock() + return e.secret +} + +func TestGHActionsDetect(t *testing.T) { + g := &GHActions{} + if !g.Detect(discover.Credential{Source: "ghactions"}) { + t.Error("should detect Source=ghactions") + } + if g.Detect(discover.Credential{Source: "gitlab"}) { + t.Error("should not detect Source=gitlab") + } +} + +func TestGHActionsRotateSealsRealValue(t *testing.T) { + emu := newGHAEmu("ghp_managementPAT0001") + srv := httptest.NewTLSServer(emu.handler()) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "https://") + + v := vault.New() + defer v.Purge() + oldVal := "old-deploy-value-0000" + blob := "ghactions://" + host + "/?" + url.Values{ + "owner": {"octo"}, "repo": {"lab"}, "name": {"DEPLOY_TOKEN"}, + "pat": {emu.pat}, "val": {oldVal}, + }.Encode() + cred := discover.Credential{ + Source: "ghactions", + Identity: "github actions secret DEPLOY_TOKEN", + Secret: v.Store([]byte(blob)), + } + g := &GHActions{HTTPClient: srv.Client()} + ctx := context.Background() + + newH, err := g.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + // The rebuilt blob must carry a fresh value (different from the old one). + nb, _ := v.Open(newH) + nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes()))) + newVal := nu.Query().Get("val") + if newVal == "" || newVal == oldVal { + t.Fatalf("blob carries no fresh value (got %q)", newVal) + } + if nu.Query().Get("name") != "DEPLOY_TOKEN" { + t.Errorf("secret name changed: %q", nu.Query().Get("name")) + } + + // CUTOVER PROOF: the value GitHub actually received (decrypted from the sealed + // box) must equal the new plaintext in the rebuilt blob — proving the roll wrote + // the real new value, not a simulation. + if got := emu.stored(); got != newVal { + t.Fatalf("server stored %q, blob says %q — sealed value mismatch", got, newVal) + } + if emu.stored() == oldVal { + t.Error("old value still stored after rotation") + } + + if err := g.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + + if err := g.RevokeOld(ctx, cred, v); err != nil { + t.Errorf("RevokeOld (no-op) returned: %v", err) + } + + // Leak-check: neither the management PAT nor any secret value may appear in the + // credential's non-secret metadata. + for _, fld := range []string{cred.Identity} { + if strings.Contains(fld, emu.pat) || strings.Contains(fld, newVal) || strings.Contains(fld, oldVal) { + t.Error("secret material leaked into credential metadata") + } + } +} + +func TestGHActionsRejectsBadSecret(t *testing.T) { + g := &GHActions{} + v := vault.New() + defer v.Purge() + // missing owner/repo + cred := discover.Credential{Source: "ghactions", + Secret: v.Store([]byte("ghactions://api/?name=X&pat=abc&val=y"))} + if _, err := g.Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should reject a secret with no owner/repo") + } +} diff --git a/internal/rotate/gitlab.go b/internal/rotate/gitlab.go new file mode 100644 index 0000000..c7dc683 --- /dev/null +++ b/internal/rotate/gitlab.go @@ -0,0 +1,190 @@ +package rotate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// GitLab rotates a GitLab personal access token (PAT) via GitLab's first-party +// self-rotate endpoint: +// +// POST /api/v4/personal_access_tokens/self/rotate +// +// GitLab issues a NEW token and, in the same call, REVOKES the token that +// authenticated the request. Because the rotate IS the revocation, this is an +// in-place rotation (like the DB drivers): RevokeOld is a no-op and the §3 sealed +// backup is the recovery path. Verify-before-revoke is still honoured by the spine +// — the new token is verified and stored before the (no-op) revoke step — and the +// old token is already dead the instant Rotate returns. +// +// The credential's secret is a single self-contained line (so it round-trips the +// single-line gopass sink unchanged): +// +// gitlab://[:port]/?tok= +// +// - scheme "gitlab" maps to https; "http"/"https" are accepted so a test can +// point at a loopback emulator. +// - the GitLab API host (gitlab.com or a self-managed host). +// - tok= the PAT being rotated. No username is needed — GitLab authenticates +// the PAT itself via the PRIVATE-TOKEN header, and self/rotate targets +// whichever token signed the request. +// +// SECURITY: the token lives only in the vault blob and the PRIVATE-TOKEN header; it +// never reaches Identity/Meta/logs/argv. Response bodies carrying the new token are +// decoded straight into the rebuilt blob, not stringified elsewhere. +type GitLab struct { + // HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout + // client using http.DefaultTransport. + HTTPClient *http.Client +} + +// init self-registers the driver. Availability alone changes nothing; the +// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies. +func init() { Register(&GitLab{}) } + +// Name identifies the driver in plans and audit records. +func (g *GitLab) Name() string { return "gitlab" } + +// Detect claims credentials tagged Source == "gitlab". +func (g *GitLab) Detect(c discover.Credential) bool { return c.Source == "gitlab" } + +func (g *GitLab) client() *http.Client { + if g.HTTPClient != nil { + return g.HTTPClient + } + return &http.Client{Timeout: 15 * time.Second} +} + +// gitlabSecret is the parsed credential blob. None of its fields are ever logged. +type gitlabSecret struct { + base string // scheme://host[:port] + token string +} + +func parseGitLabSecret(v *vault.Vault, h *vault.Handle) (gitlabSecret, error) { + buf, err := v.Open(h) + if err != nil { + return gitlabSecret{}, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return gitlabSecret{}, fmt.Errorf("gitlab: parse secret url: %w", err) + } + if u.Scheme != "gitlab" && u.Scheme != "http" && u.Scheme != "https" { + return gitlabSecret{}, fmt.Errorf("gitlab: unexpected scheme %q", u.Scheme) + } + scheme := u.Scheme + if scheme == "gitlab" { + scheme = "https" + } + tok := u.Query().Get("tok") + if tok == "" { + // tolerate a userinfo form (gitlab://_:@host/) too. + if u.User != nil { + tok, _ = u.User.Password() + } + } + if tok == "" { + return gitlabSecret{}, fmt.Errorf("gitlab: secret has no token") + } + if u.Host == "" { + return gitlabSecret{}, fmt.Errorf("gitlab: secret has no host") + } + return gitlabSecret{base: scheme + "://" + u.Host, token: tok}, nil +} + +// build re-encodes a gitlabSecret into the single-line blob form (scheme normalised +// back to "gitlab" so the in-vault form stays stable). +func (s gitlabSecret) build() string { + u := &url.URL{Scheme: "gitlab", Host: hostOf(s.base), Path: "/"} + q := url.Values{} + q.Set("tok", s.token) + u.RawQuery = q.Encode() + return u.String() +} + +// Rotate calls self/rotate with the old token and returns a vault handle to a +// rebuilt blob carrying the new token. GitLab revokes the old token as part of this +// call, so nothing further is needed to retire it. +func (g *GitLab) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + s, err := parseGitLabSecret(v, c.Secret) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + s.base+"/api/v4/personal_access_tokens/self/rotate", bytes.NewReader([]byte("{}"))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("PRIVATE-TOKEN", s.token) + + resp, err := g.client().Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab: rotate token: %w", err) + } + defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("gitlab: rotate token: unexpected status %s", resp.Status) + } + var rotated struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&rotated); err != nil { + return nil, fmt.Errorf("gitlab: decode rotated token: %w", err) + } + if rotated.Token == "" { + return nil, fmt.Errorf("gitlab: rotate token: empty token in response") + } + + ns := s + ns.token = rotated.Token + return v.Store([]byte(ns.build())), nil +} + +// Verify proves the new token authenticates by calling GET /api/v4/user with it. +func (g *GitLab) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseGitLabSecret(v, newSecret) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/api/v4/user", nil) + if err != nil { + return err + } + req.Header.Set("PRIVATE-TOKEN", s.token) + resp, err := g.client().Do(req) + if err != nil { + return fmt.Errorf("gitlab verify: %w", err) + } + defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gitlab verify: GET /user status %s", resp.Status) + } + var who struct { + Username string `json:"username"` + } + if err := json.NewDecoder(resp.Body).Decode(&who); err != nil { + return fmt.Errorf("gitlab verify: decode user: %w", err) + } + if who.Username == "" { + return fmt.Errorf("gitlab verify: token did not resolve to a user") + } + return nil +} + +// RevokeOld is a no-op: self/rotate already revoked the previous token. Defined to +// satisfy the safety-spine ordering. +func (g *GitLab) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + return nil +} diff --git a/internal/rotate/gitlab_test.go b/internal/rotate/gitlab_test.go new file mode 100644 index 0000000..c8231e7 --- /dev/null +++ b/internal/rotate/gitlab_test.go @@ -0,0 +1,150 @@ +package rotate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// glEmu is an in-process GitLab API emulator: it tracks one currently-valid token +// and emulates self/rotate (mint new, revoke the caller's token) and GET /user. +// It records every Authorization/PRIVATE-TOKEN value seen so a test can assert the +// new token is never logged where it shouldn't be. +type glEmu struct { + mu sync.Mutex + cur string // the currently-valid token + minted int // rotate counter, makes each new token unique + seenTok []string // PRIVATE-TOKEN values observed +} + +func (e *glEmu) valid(tok string) bool { + e.mu.Lock() + defer e.mu.Unlock() + e.seenTok = append(e.seenTok, tok) + return tok != "" && tok == e.cur +} + +func (e *glEmu) rotate() string { + e.mu.Lock() + defer e.mu.Unlock() + e.minted++ + e.cur = "glpat-rotated-" + strings.Repeat("z", 4) + itoa(e.minted) + return e.cur +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +func (e *glEmu) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/personal_access_tokens/self/rotate", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !e.valid(r.Header.Get("PRIVATE-TOKEN")) { + w.WriteHeader(http.StatusUnauthorized) + return + } + newTok := e.rotate() // also revokes the caller's (old) token + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"id": e.minted, "token": newTok, "revoked": false}) + }) + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + if !e.valid(r.Header.Get("PRIVATE-TOKEN")) { + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(map[string]any{"id": 1, "username": "labadmin"}) + }) + return mux +} + +func TestGitLabDetect(t *testing.T) { + g := &GitLab{} + if !g.Detect(discover.Credential{Source: "gitlab"}) { + t.Error("should detect Source=gitlab") + } + if g.Detect(discover.Credential{Source: "gitea"}) { + t.Error("should not detect Source=gitea") + } +} + +func TestGitLabRotateRealCutover(t *testing.T) { + emu := &glEmu{cur: "glpat-seedtoken-0001"} + srv := httptest.NewTLSServer(emu.handler()) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "https://") + + v := vault.New() + defer v.Purge() + oldTok := emu.cur + cred := discover.Credential{ + Source: "gitlab", + Identity: "gitlab pat", + Secret: v.Store([]byte("gitlab://" + host + "/?tok=" + url.QueryEscape(oldTok))), + } + g := &GitLab{HTTPClient: srv.Client()} + ctx := context.Background() + + newH, err := g.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + // Extract the new token from the rebuilt blob. + nb, _ := v.Open(newH) + nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes()))) + newTok := nu.Query().Get("tok") + if newTok == "" || newTok == oldTok { + t.Fatalf("blob carries no fresh token (got %q)", newTok) + } + + if err := g.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + + // CUTOVER: the old token must be dead the moment Rotate returned (self/rotate + // revokes it), independent of RevokeOld. + if err := g.Verify(ctx, cred.Secret, v); err == nil { + t.Error("Verify(old) must fail after rotation — old token should be revoked") + } else if strings.Contains(err.Error(), newTok) { + t.Error("error leaked the new token") + } + + // RevokeOld is a no-op for this in-place engine. + if err := g.RevokeOld(ctx, cred, v); err != nil { + t.Errorf("RevokeOld (no-op) returned: %v", err) + } + + // The new token must never have been handed to the emulator as the OLD token's + // stand-in before it existed; and it must not appear in any leaked structure. + for _, fld := range []string{cred.Identity} { + if strings.Contains(fld, newTok) || strings.Contains(fld, oldTok) { + t.Error("token leaked into credential metadata") + } + } +} + +func TestGitLabRejectsBadSecret(t *testing.T) { + g := &GitLab{} + v := vault.New() + defer v.Purge() + cred := discover.Credential{Source: "gitlab", Secret: v.Store([]byte("gitlab://gitlab.example.com/"))} // no token + if _, err := g.Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should reject a secret with no token") + } +} diff --git a/internal/rotate/proofs.go b/internal/rotate/proofs.go new file mode 100644 index 0000000..bd56ad2 --- /dev/null +++ b/internal/rotate/proofs.go @@ -0,0 +1,70 @@ +package rotate + +// Proof tracking — DATA, deliberately kept OUT of the driver code. +// +// Every Rotator in this package 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, which the real deployment also configures. +// +// What differs between drivers is HOW their real-cutover path has been *validated*. +// That validation status is data, not behavior, so it lives here in one table +// rather than as prose scattered through (and easily confused with) the drivers. +// docs/ROTATION-PROOFS.md is the human-facing mirror of this same data; `incredigo +// rotate` surfaces it so a mock-only driver can never be mistaken for a live one. + +// ProofLevel records how a driver's real-cutover behaviour has been validated. +type ProofLevel int + +const ( + // ProofUnproven: no cutover proof recorded (e.g. the dry-run noop helper). + ProofUnproven ProofLevel = iota + // ProofMockOnly: 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. + ProofMockOnly + // ProofLiveVM: cutover proven against the REAL target software running in the + // sandbox VM (real PostgreSQL/MariaDB/Redis/wireguard-tools/Gitea/local files). + ProofLiveVM +) + +// String renders the level as the token shown in the CLI and the manifest. +func (p ProofLevel) String() string { + switch p { + case ProofLiveVM: + return "LIVE-VM" + case ProofMockOnly: + return "MOCK-ONLY" + default: + return "UNPROVEN" + } +} + +// proofLevels maps a driver's Name() to how its real-cutover path was validated. +// This is the single source of truth; keep docs/ROTATION-PROOFS.md in sync. +// +// IMPORTANT honesty note: a driver running in the VM is NOT automatically 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. +var proofLevels = map[string]ProofLevel{ + // proven against the real target software in the sandbox VM: + "postgres": ProofLiveVM, // real PostgreSQL (lab-provision-pg.sh) + "mysql": ProofLiveVM, // real MariaDB (lab-provision-dbclones.sh) + "redis": ProofLiveVM, // real redis-server(lab-provision-dbclones.sh) + "wireguard": ProofLiveVM, // real wg (lab-provision-wg.sh) + "gitea": ProofLiveVM, // real Gitea (lab-provision-gitea.sh) + "appsecret": ProofLiveVM, // real local files (lab-provision-appsec.sh) + + // proven only against an emulator / mock: + "aws": ProofMockOnly, // moto mock AWS in VM; real AWS never hit + "sshkey": ProofMockOnly, // in-process SSH server; live VM POC not run + "openwrt": ProofMockOnly, // in-process SSH server; live QEMU deferred + "mullvad": ProofMockOnly, // httptest emulator; needs a paid account + "cloudflare": ProofMockOnly, // httptest emulator; no self-host + "ghactions": ProofMockOnly, // httptest emulator; no self-host + "gitlab": ProofMockOnly, // httptest emulator; GitLab CE too heavy for the VM +} + +// ProofLevelOf returns the recorded proof level for a driver name. Unknown or +// proofless drivers (e.g. the noop dry-run helper) report ProofUnproven. +func ProofLevelOf(name string) ProofLevel { return proofLevels[name] }