Files
incredigo/internal/rotate/ghactions_test.go
T
leetcrypt b415c43bff 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>
2026-06-18 15:28:05 -07:00

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