browserrot: Node stealth sidecar + AI self-heal (M-B1..M-B3, LIVE-VM)
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>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user