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
+14 -8
View File
@@ -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()
+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.
+29 -1
View File
@@ -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 {
+55
View File
@@ -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)
+204
View File
@@ -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=<NAME>&val=<value>&path=<file>[&path=<file>…]
//
// - 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)
}
+145
View File
@@ -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")
}
}
+200
View File
@@ -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://<host>/?id=<token-id>&token=<token-value>
//
// - scheme "cloudflare" maps to https; "http"/"https" are accepted so a test
// can point at a loopback emulator.
// - <host> 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
}
+136
View File
@@ -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")
}
}
+284
View File
@@ -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://<host>/?owner=<o>&repo=<r>&name=<secret-name>&pat=<token>&val=<value>
//
// - scheme "ghactions" maps to https; "http"/"https" are accepted so a test can
// point at a loopback emulator.
// - <host> 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)
}
+198
View File
@@ -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")
}
}
+190
View File
@@ -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://<host>[:port]/?tok=<token>
//
// - scheme "gitlab" maps to https; "http"/"https" are accepted so a test can
// point at a loopback emulator.
// - <host> 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://_:<token>@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
}
+150
View File
@@ -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")
}
}
+70
View File
@@ -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] }