mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +00:00
fix(verifier): a secure field's typed value never reaches the record
A folio login run wrote the account email and password in cleartext into llm-calls.jsonl, 166 times in one run, beside screenshots of the same screens. Three sites rendered it: the recent-action memory, the candidate list, and the trace. One helper now covers all three so a fourth cannot bypass it, and the driver still receives the real text. Android redacts every typed value because it cannot tell a secure field from any other. That asymmetry is deliberate and documented: safe by default on the target that cannot tell.
This commit is contained in:
1 parent
b1e95739ad
commit
38d328df90
12 files changed
+418
-16
No files matched your search
@@ -0,0 +1,111 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// typedCredential stands in for what a login setup types. Nothing rendered for
|
||||
// a record may carry it.
|
||||
const typedCredential = "fixture-passphrase-9f21"
|
||||
|
||||
// secureLoginTreeJSON is the shape iOS and web produce for a login form: both
|
||||
// report `secure` on every editable field, so a field carrying `secure:false`
|
||||
// is positively known not to be a credential entry.
|
||||
const secureLoginTreeJSON = `{
|
||||
"attributes": {"bounds": "[0,0,390,844]", "class": "Window"},
|
||||
"children": [
|
||||
{"attributes": {"identifier": "LoginEmail", "class": "TextField", "hintText": "Email", "bounds": "[20,200,370,244]"},
|
||||
"editable": true, "enabled": true, "secure": false, "children": []},
|
||||
{"attributes": {"identifier": "LoginPassword", "class": "SecureTextField", "hintText": "Password", "bounds": "[20,260,370,304]"},
|
||||
"editable": true, "enabled": true, "secure": true, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// androidLoginTreeJSON is the same form as Android reports it. The native tree
|
||||
// mapper drops uiautomator's password attribute, so neither field carries the
|
||||
// fact and the two are indistinguishable.
|
||||
const androidLoginTreeJSON = `{
|
||||
"attributes": {"bounds": "[0,0,1080,2340]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "login_email", "class": "android.widget.EditText", "hintText": "Email", "bounds": "[20,200,1060,300]"},
|
||||
"enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "login_password", "class": "android.widget.EditText", "hintText": "Password", "bounds": "[20,320,1060,420]"},
|
||||
"enabled": true, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
func authoredTypingCandidates(t *testing.T, selector, text, treeJSON string) []ActionCandidate {
|
||||
t.Helper()
|
||||
actions := `{kind:'actions', generate: () => [{kind:'InputText', into:'` + selector +
|
||||
`', text:'` + text + `'}]}`
|
||||
return mustCandidates(t, enumVerifier(t, actions, treeJSON), LabelSourceVisibleText)
|
||||
}
|
||||
|
||||
func TestCandidatesRedactTextTypedIntoASecureField(t *testing.T) {
|
||||
candidates := authoredTypingCandidates(t, "id:LoginPassword", typedCredential, secureLoginTreeJSON)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidates = %v, want the one authored typing action", descriptions(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if !strings.Contains(candidate.Description, RedactedInputText) {
|
||||
t.Errorf("candidate description = %q, want the typed value redacted", candidate.Description)
|
||||
}
|
||||
if !strings.Contains(candidate.Description, "Password") {
|
||||
t.Errorf("candidate description = %q, want it to still name the field typed into", candidate.Description)
|
||||
}
|
||||
if candidate.Action.Text != typedCredential {
|
||||
t.Errorf("candidate action text = %q, want the real value so the driver still types it", candidate.Action.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesRedactEveryTypedValueWhereTheTargetCannotReportSecure(t *testing.T) {
|
||||
for _, selector := range []string{"id:login_email", "id:login_password"} {
|
||||
candidates := authoredTypingCandidates(t, selector, typedCredential, androidLoginTreeJSON)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("%s: candidates = %v, want the one authored typing action", selector, descriptions(candidates))
|
||||
}
|
||||
if strings.Contains(candidates[0].Description, typedCredential) {
|
||||
t.Errorf("%s: candidate description carries the typed value: %q", selector, candidates[0].Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesKeepTextTypedIntoAFieldReportedNotSecure(t *testing.T) {
|
||||
const address = "[email protected]"
|
||||
candidates := authoredTypingCandidates(t, "id:LoginEmail", address, secureLoginTreeJSON)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidates = %v, want the one authored typing action", descriptions(candidates))
|
||||
}
|
||||
if !strings.Contains(candidates[0].Description, address) {
|
||||
t.Errorf("candidate description = %q, want the typed value on a field reported not secure", candidates[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
// A spec that types into an element HANDLE is the shape every login setup has
|
||||
// (examples/folio/sanderling/spec.ts). The handle carries the goja host's own
|
||||
// `secure` field, which is a plain boolean and reads false on a platform that
|
||||
// reports nothing, so a rule trusting it would leave every Android value in the
|
||||
// clear. The tree the handle names is the only carrier of the three-way fact.
|
||||
func TestCandidatesRedactTextTypedIntoAnAndroidElementHandle(t *testing.T) {
|
||||
v := newVerifier(t)
|
||||
loadActionSpec(t, v, `
|
||||
import { InputText, actions, extract } from "@sanderling/spec";
|
||||
const field = extract((s) => s.ax.find({ "resource-id": "login_password" })).named("field");
|
||||
globalThis.actions = actions(() =>
|
||||
field.current ? [InputText({ into: field.current, text: "`+typedCredential+`" })] : []);
|
||||
`)
|
||||
pushTree(t, v, androidLoginTreeJSON)
|
||||
|
||||
candidates := mustCandidates(t, v, LabelSourceVisibleText)
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidates = %v, want the one authored typing action", descriptions(candidates))
|
||||
}
|
||||
if strings.Contains(candidates[0].Description, typedCredential) {
|
||||
t.Errorf("candidate description carries the typed value: %q", candidates[0].Description)
|
||||
}
|
||||
if candidates[0].Action.Text != typedCredential {
|
||||
t.Errorf("candidate action text = %q, want the real value so the driver still types it",
|
||||
candidates[0].Action.Text)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user