b415c43bff
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>
199 lines
5.5 KiB
Go
199 lines
5.5 KiB
Go
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")
|
|
}
|
|
}
|