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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user