c2f08d1a21
Extends the Phase A-tier-1 site-side rotation engine with an optional Node browser-sidecar and a deterministic self-heal tier, all proven LIVE-VM against the local lab change-password form. go-rod stays the default; the sidecar is a swap-in Driver behind the same Session/Driver/Rotator interfaces — no spine change, backup + verify-new-before-revoke-old + proof gates all apply unchanged. M-B1 sidecar parity: internal/browserrot/sidecar/ (index.js JSON-RPC over stdio, patchright real-fingerprint Chromium, playwright-core fallback) + sidecar.go Driver/Session. A shared test harness runs go-rod and the sidecar through the identical live rotation proof. M-B2 stealth: patchright removes navigator.webdriver; humanized input (Bezier mouse paths + per-key typing jitter, no ML trajectory generation per plan §5). M-B3 self-heal: Healer interface + heal-retry fill/click/text wrappers; on a rotted selector, heal.js relocates the field from DOM structure ONLY (never a value) and the engine rewrites + persists the recipe (recipes.go RecipeBook) so the next run is Tier-1 again. Deterministic heuristic is the proven Tier-2; Stagehand LLM strategy is opt-in + UNPROVEN. Leak-check asserts no secret ever reaches a heal request (redacted IPC transcript). Secret seam documented honestly: fill carries base64 secret bytes across exactly one local stdio boundary, typed and wiped, never logged/persisted/sent to a model. MFA/CAPTCHA still degrade to the human worklist (Hard Rule 5). 14 tests green, -race clean; lab/lab-provision-browserrot.sh proves both drivers. M-B4 (first REAL self-owned site) deliberately NOT started — needs per-credential authorization (safe-candidate ladder). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
7.1 KiB
Go
223 lines
7.1 KiB
Go
package browserrot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"incredigo/internal/pwgen"
|
|
"incredigo/internal/rotate"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// healingFake is a browser-free Session+Healer: it FAILS any fill/click/text whose
|
|
// selector is not in `good`, and its Heal maps a field description to the correct
|
|
// selector — modeling a recipe whose selectors have rotted and a Tier-2 heal fixing them.
|
|
// It records every Heal description so a test can prove no secret is ever passed to heal.
|
|
type healingFake struct {
|
|
mu sync.Mutex
|
|
good map[string]bool // selectors that "exist" on the page
|
|
healMap map[string]string
|
|
healSeen []string // descriptions passed to Heal (never a secret, by contract)
|
|
healCalls int
|
|
secrets [][]byte // secret bytes handed to Fill (for leak assertions)
|
|
success string
|
|
}
|
|
|
|
func (h *healingFake) Name() string { return "healing-fake" }
|
|
func (h *healingFake) Open(context.Context) (Session, error) {
|
|
return h, nil
|
|
}
|
|
func (h *healingFake) Goto(context.Context, string) (string, error) { return "about:blank", nil }
|
|
func (h *healingFake) Fill(_ context.Context, sel string, secret []byte) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if !h.good[sel] {
|
|
return fmt.Errorf("healing-fake: no element for %q", sel)
|
|
}
|
|
h.secrets = append(h.secrets, append([]byte(nil), secret...))
|
|
return nil
|
|
}
|
|
func (h *healingFake) Click(_ context.Context, sel string) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if !h.good[sel] {
|
|
return fmt.Errorf("healing-fake: no element for %q", sel)
|
|
}
|
|
return nil
|
|
}
|
|
func (h *healingFake) Text(_ context.Context, sel string) (string, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if !h.good[sel] {
|
|
return "", fmt.Errorf("healing-fake: no element for %q", sel)
|
|
}
|
|
return h.success, nil
|
|
}
|
|
func (h *healingFake) Close() error { return nil }
|
|
|
|
func (h *healingFake) Heal(_ context.Context, description string) (string, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.healCalls++
|
|
h.healSeen = append(h.healSeen, description)
|
|
for kw, sel := range h.healMap {
|
|
if strings.Contains(description, kw) {
|
|
return sel, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("healing-fake: cannot heal %q", description)
|
|
}
|
|
|
|
var _ Healer = (*healingFake)(nil)
|
|
|
|
// The engine must: (1) heal a missed selector via Tier-2, (2) rewrite the recipe in
|
|
// memory, (3) never pass a secret to Heal. Then the healed recipe must PERSIST so a
|
|
// second run needs NO heal (Tier-1 again) — the whole "learn once" loop.
|
|
func TestHeal_engineRetriesRewritesAndPersists(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
const oldPW = "OLD-corr3ct-horse"
|
|
c := gitHubPAT(t, v, oldPW)
|
|
|
|
// Recipe with a ROTTED current-password selector; every other selector is correct.
|
|
site := Site{
|
|
Host: "github.com",
|
|
Form: Form{
|
|
CurrentSel: "#stale-current", NewSel: "#new", ConfirmSel: "#confirm",
|
|
SubmitSel: "#submit", SuccessSel: "#result", SuccessText: "SUCCESS",
|
|
},
|
|
Proof: rotate.ProofMockOnly,
|
|
}
|
|
fake := &healingFake{
|
|
good: map[string]bool{
|
|
"#current": true, "#new": true, "#confirm": true, "#submit": true, "#result": true,
|
|
},
|
|
healMap: map[string]string{"current password": "#current"},
|
|
success: "SUCCESS",
|
|
}
|
|
|
|
r := New(site, fake, pwgen.DefaultPolicy())
|
|
newH, err := r.Rotate(context.Background(), c, v)
|
|
if err != nil {
|
|
t.Fatalf("Rotate with heal: %v", err)
|
|
}
|
|
if !r.Healed() {
|
|
t.Fatal("expected Healed() == true after a selector miss was healed")
|
|
}
|
|
if got := r.HealedSite().Form.CurrentSel; got != "#current" {
|
|
t.Fatalf("healed recipe CurrentSel = %q, want #current", got)
|
|
}
|
|
if fake.healCalls != 1 {
|
|
t.Fatalf("expected exactly 1 heal call, got %d", fake.healCalls)
|
|
}
|
|
// Leak check: no description handed to Heal may contain either secret.
|
|
newPW := mustReveal(t, v, newH)
|
|
for _, d := range fake.healSeen {
|
|
if strings.Contains(d, oldPW) || strings.Contains(d, newPW) {
|
|
t.Fatalf("LEAK: a secret reached a heal description: %q", d)
|
|
}
|
|
}
|
|
v.Forget(newH)
|
|
|
|
// Persist the healed recipe, reload it, and run again with a FRESH healing fake whose
|
|
// heal would fail — proving the second run is Tier-1 (no heal needed).
|
|
path := filepath.Join(t.TempDir(), "recipes.json")
|
|
rb := &RecipeBook{Sites: map[string]Site{}}
|
|
rb.Put(r.HealedSite())
|
|
if err := rb.Save(path); err != nil {
|
|
t.Fatalf("Save recipes: %v", err)
|
|
}
|
|
rb2, err := LoadRecipes(path)
|
|
if err != nil {
|
|
t.Fatalf("Load recipes: %v", err)
|
|
}
|
|
learned, ok := rb2.Get("github.com")
|
|
if !ok {
|
|
t.Fatal("healed recipe not found after reload")
|
|
}
|
|
if learned.Form.CurrentSel != "#current" {
|
|
t.Fatalf("persisted CurrentSel = %q, want #current", learned.Form.CurrentSel)
|
|
}
|
|
|
|
fake2 := &healingFake{
|
|
good: map[string]bool{
|
|
"#current": true, "#new": true, "#confirm": true, "#submit": true, "#result": true,
|
|
},
|
|
healMap: map[string]string{}, // heal would FAIL if called
|
|
success: "SUCCESS",
|
|
}
|
|
c2 := gitHubPAT(t, v, oldPW)
|
|
r2 := New(learned, fake2, pwgen.DefaultPolicy())
|
|
newH2, err := r2.Rotate(context.Background(), c2, v)
|
|
if err != nil {
|
|
t.Fatalf("second run (should be Tier-1) failed: %v", err)
|
|
}
|
|
if r2.Healed() {
|
|
t.Fatal("second run should NOT heal — the recipe was already learned")
|
|
}
|
|
if fake2.healCalls != 0 {
|
|
t.Fatalf("second run must need 0 heals, got %d", fake2.healCalls)
|
|
}
|
|
v.Forget(newH2)
|
|
}
|
|
|
|
// If a selector misses and the Session cannot heal (no Healer), the engine fails closed.
|
|
func TestHeal_failsClosedWithoutHealer(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
c := gitHubPAT(t, v, "OLD")
|
|
site := testSite()
|
|
site.Form.CurrentSel = "#missing"
|
|
// plain fakeDriver does NOT implement Healer, and its Fill never errors — so to force
|
|
// the miss we use a driver whose Fill fails on the missing selector.
|
|
drv := &fakeDriver{success: "SUCCESS", failFill: "#missing"}
|
|
r := New(site, drv, pwgen.DefaultPolicy())
|
|
if _, err := r.Rotate(context.Background(), c, v); err == nil {
|
|
t.Fatal("expected fail-closed when a selector misses and no healer exists")
|
|
} else if !strings.Contains(err.Error(), "cannot self-heal") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRecipes_roundTripAndMissingFile(t *testing.T) {
|
|
// Missing file -> empty book, no error.
|
|
rb, err := LoadRecipes(filepath.Join(t.TempDir(), "nope.json"))
|
|
if err != nil {
|
|
t.Fatalf("missing file should not error: %v", err)
|
|
}
|
|
if len(rb.Sites) != 0 {
|
|
t.Fatalf("missing file should yield empty book, got %d", len(rb.Sites))
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "r.json")
|
|
rb.Put(Site{Host: "a.com", Form: Form{CurrentSel: "#c"}, Proof: rotate.ProofLiveVM})
|
|
rb.Put(Site{Host: "b.com", Form: Form{NewSel: "#n"}})
|
|
if err := rb.Save(path); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
got, err := LoadRecipes(path)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if len(got.Sites) != 2 {
|
|
t.Fatalf("want 2 recipes, got %d", len(got.Sites))
|
|
}
|
|
if s, _ := got.Get("a.com"); s.Form.CurrentSel != "#c" || s.Proof != rotate.ProofLiveVM {
|
|
t.Fatalf("round-trip lost data for a.com: %+v", s)
|
|
}
|
|
}
|
|
|
|
// mustReveal reads a handle's plaintext for a test-only leak assertion.
|
|
func mustReveal(t *testing.T, v *vault.Vault, h *vault.Handle) string {
|
|
t.Helper()
|
|
buf, err := v.Open(h)
|
|
if err != nil {
|
|
t.Fatalf("open handle: %v", err)
|
|
}
|
|
return string(buf.Bytes())
|
|
}
|