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
+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")
}
}