mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(runner): a secure field's value does not reach state.lastAction either
folio extracts lastAction, and extractor values are persisted as extractor_changes, so the password still reached the run directory through the spec after the three render sites were closed. The wrap sits in the runner rather than in lastActionFields because the hosts hold the next step's tree, not the one the action was chosen against: a field that stops being secure between the two would publish what the trace withheld. Live and replay now agree byte for byte.
This commit is contained in:
1 parent
38d328df90
commit
66fd5bce5d
7 files changed
+181
-8
No files matched your search
@@ -2,8 +2,10 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||
|
||||
@@ -129,6 +131,67 @@ func TestApplyActionTypesTheRealValueIntoEveryField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// lastActionExtractorSpec is what examples/folio/sanderling/spec.ts does with
|
||||
// the previous step's action: it extracts state.lastAction whole. Extractor
|
||||
// values are written to the trace as extractor_changes, so a typed value that
|
||||
// reaches state.lastAction.text lands in the run directory through the spec
|
||||
// rather than through the runner.
|
||||
const lastActionExtractorSpec = `
|
||||
import { actions, always, extract, InputText } from "@sanderling/spec";
|
||||
const reported = extract("lastAction", state => state.lastAction);
|
||||
globalThis.properties = {
|
||||
theActionReachesTheSpec: always(() => reported.current !== undefined),
|
||||
};
|
||||
globalThis.actions = actions(() => [InputText({ into: "%s", text: "` + typedCredential + `" })]);
|
||||
`
|
||||
|
||||
func TestTheTraceNeverCarriesATypedSecretThroughALastActionExtractor(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
treeJSON string
|
||||
selector string
|
||||
}{
|
||||
{"ios secure field", iosLoginTreeJSON, "id:LoginPassword"},
|
||||
{"web secure field", webLoginTreeJSON, "id:login-password"},
|
||||
{"android field reported as neither", androidLoginTreeJSON, "id:login_email"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
fastFocusSettle(t)
|
||||
state := newHarnessWithSpec(t, fmt.Sprintf(lastActionExtractorSpec, testCase.selector))
|
||||
state.mock.HierarchyJSON = testCase.treeJSON
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 2,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
steps := traceSteps(t, state.writer.Directory())
|
||||
if len(steps) < 2 {
|
||||
t.Fatalf("trace holds %d step(s), want the step that reports the action back", len(steps))
|
||||
}
|
||||
change, ok := steps[1].ExtractorChanges["lastAction"]
|
||||
if !ok {
|
||||
t.Fatalf("step 2 recorded no reading of state.lastAction: %+v", steps[1])
|
||||
}
|
||||
if strings.Contains(string(change.Curr), typedCredential) {
|
||||
t.Errorf("the trace carries the typed value through state.lastAction: %s", change.Curr)
|
||||
}
|
||||
if !strings.Contains(string(change.Curr), verifier.RedactedInputText) {
|
||||
t.Errorf("state.lastAction reported %s, want the typed value redacted in place",
|
||||
change.Curr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const secureLoginSpec = `
|
||||
import { llm, always, actions, InputText, taps, typing, weighted } from "@sanderling/spec";
|
||||
globalThis.properties = { ok: always(() => true) };
|
||||
|
||||
@@ -451,7 +451,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
// the effect committed. Reporting no action here would let a
|
||||
// property convict the app for an effect with no cause, so the
|
||||
// action is reported with its fate unknown instead.
|
||||
unconfirmed := nextAction
|
||||
unconfirmed := verifier.RecordedAction(nextAction, tree)
|
||||
lastAction = &unconfirmed
|
||||
} else if notDispatched != "" {
|
||||
// The action was chosen but nothing reached the driver, so the
|
||||
@@ -466,7 +466,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
lastAction = nil
|
||||
} else {
|
||||
consecutiveApplyFailures = 0
|
||||
applied := nextAction
|
||||
applied := verifier.RecordedAction(nextAction, tree)
|
||||
applied.Applied = true
|
||||
lastAction = &applied
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user