Skip to content

Commit d8f0071

Browse files
committed
fix: sanitize parameterized fill echoes
1 parent ff980e9 commit d8f0071

5 files changed

Lines changed: 239 additions & 16 deletions

File tree

‎docs/adr/0017-parameterized-recorded-inputs.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,16 @@ The literal exists only on the live request path long enough to execute the inte
5353
addition to key-name redaction, because an opaque value need not look secret.
5454
2. The platform interaction receives the literal.
5555
3. Before response/event/recording retention, the fill result's semantic `text` field and an exact
56-
post-fill `refLabel` become `${VAR}`. Stable selector/ref provenance and unrelated strings are
57-
preserved byte-for-byte; post-fill selector-chain candidates are dropped only when a parsed
58-
`text`, `label`, or `value` term exactly equals the supplied literal.
56+
post-fill `refLabel` become `${VAR}`. Stable selector/ref provenance is preserved byte-for-byte.
57+
Untrusted backend extras and nested settle output parameterize delimited occurrences of the
58+
supplied literal; post-fill selector-chain candidates are dropped only when a parsed `text`,
59+
`label`, or `value` term semantically contains it.
5960
4. At `recordActionEntry`, the recorder reconstructs the fill's literal positional, writes only the
60-
placeholder into `SessionAction`, and parameterizes the semantic result field again. ADR 0012
61-
`target-v1` evidence keeps its structure; exact value-bearing accessibility labels become the
62-
placeholder without rewriting identity fragments that merely contain the same characters. The
63-
`recordAs` control flag itself is not serialized into the script.
61+
placeholder into `SessionAction`, and parameterizes the semantic result field plus arbitrary
62+
backend/settle echoes again. ADR 0012 `target-v1` evidence keeps its structure; exact
63+
value-bearing accessibility labels become the placeholder without rewriting identity fragments
64+
that merely contain the same characters. The `recordAs` control flag itself is not serialized
65+
into the script.
6466
5. The existing ADR 0012/0016 writer receives an already-parameterized action. Its selector provenance,
6567
portability checks, same-directory temp write, and atomic publication algorithm are unchanged.
6668

‎src/daemon/__tests__/session-action-recorder.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,14 @@ test('parameterized fills keep literals out of recording state with deterministi
9797
flags: { recordAs: 'PASSWORD' },
9898
result: {
9999
text: password,
100-
message: 'Filled 21 chars',
100+
message: `Filled ${password}`,
101101
selector: 'id="password"',
102102
selectorChain: ['id="password"', `value="${password}" editable=true`],
103+
settle: {
104+
diff: {
105+
lines: [{ kind: 'added', text: `value=${password}` }],
106+
},
107+
},
103108
},
104109
targetEvidence: {
105110
role: 'textinput',
@@ -129,10 +134,16 @@ test('parameterized fills keep literals out of recording state with deterministi
129134
expect(serialized).not.toContain(password);
130135
expect(serialized).not.toContain(token);
131136
expect(session.actions[0]?.result?.text).toBe('${PASSWORD}');
137+
expect(session.actions[0]?.result?.message).toBe('Filled ${PASSWORD}');
132138
expect(session.actions[0]?.result?.selector).toBe('id="password"');
133139
expect(session.actions[0]?.targetEvidence?.label).toBe('${PASSWORD}');
134140
expect(session.actions[0]?.targetEvidence?.ancestry[0]?.label).toBe('Credentials');
135141
expect(session.actions[0]?.result?.selectorChain).toEqual(['id="password"']);
142+
expect(session.actions[0]?.result?.settle).toEqual({
143+
diff: {
144+
lines: [{ kind: 'added', text: 'value=${PASSWORD}' }],
145+
},
146+
});
136147
});
137148

138149
test('a one-character parameterized fill preserves unrelated response and selector text', () => {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { expect, test } from 'vitest';
2+
import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts';
3+
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
4+
import { finalizeTouchInteraction } from '../interaction-common.ts';
5+
6+
test('parameterized fill scrubs backend and nested settle echoes at the response boundary', () => {
7+
const secret = 'OpaqueValue1348';
8+
const placeholder = '${PASSWORD}';
9+
const sessionStore = makeSessionStore();
10+
const session = makeIosSession('parameterized-fill');
11+
session.recordSession = true;
12+
sessionStore.set(session.name, session);
13+
const payload = {
14+
text: secret,
15+
message: `Backend filled ${secret}`,
16+
selector: 'id="password"',
17+
selectorChain: ['id="password"', `value="${secret}" editable=true`],
18+
backendEcho: {
19+
id: secret,
20+
status: `accepted ${secret}`,
21+
nested: { kind: secret },
22+
},
23+
settle: {
24+
settled: true,
25+
waitedMs: 1,
26+
captures: 2,
27+
quietMs: 1,
28+
timeoutMs: 100,
29+
diff: {
30+
summary: { additions: 1, removals: 0, unchanged: 0 },
31+
lines: [{ kind: 'added', text: `TextField value=${secret}` }],
32+
},
33+
hint: `Observed ${secret}`,
34+
},
35+
};
36+
37+
const response = finalizeTouchInteraction({
38+
session,
39+
sessionStore,
40+
command: 'fill',
41+
positionals: ['id="password"', secret],
42+
flags: { recordAs: 'PASSWORD' },
43+
result: payload,
44+
responseData: payload,
45+
actionStartedAt: 1,
46+
actionFinishedAt: 2,
47+
});
48+
49+
expect(response.ok).toBe(true);
50+
if (!response.ok) return;
51+
expect(response.data).toMatchObject({
52+
text: placeholder,
53+
message: `Backend filled ${placeholder}`,
54+
selector: 'id="password"',
55+
selectorChain: ['id="password"'],
56+
backendEcho: {
57+
id: placeholder,
58+
status: `accepted ${placeholder}`,
59+
nested: { kind: placeholder },
60+
},
61+
settle: {
62+
diff: {
63+
lines: [{ kind: 'added', text: `TextField value=${placeholder}` }],
64+
},
65+
hint: `Observed ${placeholder}`,
66+
},
67+
});
68+
expect(JSON.stringify(response.data)).not.toContain(secret);
69+
70+
const recordedResult = session.actions[0]?.result;
71+
expect(recordedResult).toEqual(response.data);
72+
expect(JSON.stringify(recordedResult)).not.toContain(secret);
73+
});

‎src/daemon/parameterized-recorded-fill.ts‎

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,43 @@ import type { TargetAnnotationV1 } from '../replay/target-identity.ts';
22
import { tryParseSelectorChain } from '../selectors/parse.ts';
33

44
const VALUE_BEARING_SELECTOR_KEYS = new Set(['text', 'label', 'value']);
5+
const STABLE_ROOT_OUTPUT_STRING_KEYS = new Set([
6+
'action',
7+
'backend',
8+
'gesture',
9+
'kind',
10+
'platform',
11+
'ref',
12+
'selector',
13+
'targetKind',
14+
]);
15+
const STRUCTURED_OUTPUT_KEYS = new Set(['cost', 'evidence', 'resolution', 'settle']);
16+
const STRUCTURAL_NESTED_STRING_KEYS = new Set([
17+
'digest',
18+
'foregroundApp',
19+
'id',
20+
'kind',
21+
'ref',
22+
'role',
23+
'source',
24+
'verification',
25+
]);
26+
const IDENTIFIER_CHAR = /^[A-Za-z0-9_]$/;
527

628
/**
729
* Parameterize only fields whose contract says they carry the fill value.
8-
* Stable identity/provenance strings stay byte-for-byte unchanged; a derived
9-
* selector candidate is dropped only when a parsed text/label/value term is
10-
* exactly the supplied literal.
30+
* Stable identity/provenance strings stay byte-for-byte unchanged. Untrusted
31+
* backend extras and nested settle output are scrubbed recursively; a derived
32+
* selector candidate is dropped only when a parsed text/label/value term
33+
* semantically contains the supplied literal.
1134
*/
1235
export function parameterizeRecordedFillPayload<
1336
TPayload extends Record<string, unknown> | undefined,
1437
>(payload: TPayload, literal: string, placeholder: string): TPayload {
1538
if (!payload) return payload;
1639
const selectorChain = readStringArray(payload.selectorChain);
1740
return {
18-
...payload,
41+
...parameterizeBackendOutput(payload, literal, placeholder),
1942
...(typeof payload.text === 'string' ? { text: placeholder } : {}),
2043
...(payload.refLabel === literal ? { refLabel: placeholder } : {}),
2144
...(selectorChain
@@ -61,14 +84,122 @@ function selectorCandidateCarriesFillValue(candidate: string, literal: string):
6184
(term) =>
6285
VALUE_BEARING_SELECTOR_KEYS.has(term.key) &&
6386
typeof term.value === 'string' &&
64-
term.value === literal,
87+
parameterizeSensitiveString(term.value, literal, '') !== term.value,
6588
),
6689
) ?? false
6790
);
6891
}
6992

93+
function parameterizeBackendOutput(
94+
value: Record<string, unknown>,
95+
literal: string,
96+
placeholder: string,
97+
): Record<string, unknown> {
98+
return Object.fromEntries(
99+
Object.entries(value).map(([key, entry]) => [
100+
key,
101+
parameterizeRootOutputValue(entry, key, literal, placeholder),
102+
]),
103+
);
104+
}
105+
106+
function parameterizeRootOutputValue(
107+
value: unknown,
108+
key: string,
109+
literal: string,
110+
placeholder: string,
111+
): unknown {
112+
if (typeof value === 'string') {
113+
if (STABLE_ROOT_OUTPUT_STRING_KEYS.has(key)) return value;
114+
if (key === 'refLabel') return value === literal ? placeholder : value;
115+
}
116+
if (key === 'selectorChain' && isStringArray(value)) {
117+
return filterSensitiveSelectorCandidates(value, literal);
118+
}
119+
return parameterizeBackendOutputValue(
120+
value,
121+
key,
122+
literal,
123+
placeholder,
124+
STRUCTURED_OUTPUT_KEYS.has(key),
125+
);
126+
}
127+
128+
function parameterizeBackendOutputValue(
129+
value: unknown,
130+
key: string,
131+
literal: string,
132+
placeholder: string,
133+
preserveStructuralStrings: boolean,
134+
): unknown {
135+
if (typeof value === 'string') {
136+
if (preserveStructuralStrings && STRUCTURAL_NESTED_STRING_KEYS.has(key)) return value;
137+
return parameterizeSensitiveString(value, literal, placeholder);
138+
}
139+
if (Array.isArray(value)) {
140+
return value.map((entry) =>
141+
parameterizeBackendOutputValue(entry, key, literal, placeholder, preserveStructuralStrings),
142+
);
143+
}
144+
if (!value || typeof value !== 'object') return value;
145+
return Object.fromEntries(
146+
Object.entries(value).map(([nestedKey, entry]) => [
147+
nestedKey,
148+
parameterizeBackendOutputValue(
149+
entry,
150+
nestedKey,
151+
literal,
152+
placeholder,
153+
preserveStructuralStrings,
154+
),
155+
]),
156+
);
157+
}
158+
159+
function filterSensitiveSelectorCandidates(value: string[], literal: string): string[] {
160+
return value.filter((candidate) => !selectorCandidateCarriesFillValue(candidate, literal));
161+
}
162+
163+
function parameterizeSensitiveString(value: string, literal: string, placeholder: string): string {
164+
if (!literal.trim()) return value === literal ? placeholder : value;
165+
if (!isIdentifierLiteral(literal)) {
166+
return value.replaceAll(literal, placeholder);
167+
}
168+
return parameterizeDelimitedLiteral(value, literal, placeholder);
169+
}
170+
171+
function isIdentifierLiteral(literal: string): boolean {
172+
return Array.from(literal).every((character) => IDENTIFIER_CHAR.test(character));
173+
}
174+
175+
function parameterizeDelimitedLiteral(value: string, literal: string, placeholder: string): string {
176+
let cursor = 0;
177+
let result = '';
178+
while (cursor < value.length) {
179+
const matchIndex = value.indexOf(literal, cursor);
180+
if (matchIndex === -1) return result + value.slice(cursor);
181+
result += value.slice(cursor, matchIndex);
182+
result += isDelimitedMatch(value, literal, matchIndex) ? placeholder : literal;
183+
cursor = matchIndex + literal.length;
184+
}
185+
return result;
186+
}
187+
188+
function isDelimitedMatch(value: string, literal: string, matchIndex: number): boolean {
189+
return (
190+
!isIdentifierCharacter(value[matchIndex - 1]) &&
191+
!isIdentifierCharacter(value[matchIndex + literal.length])
192+
);
193+
}
194+
195+
function isIdentifierCharacter(value: string | undefined): boolean {
196+
return value !== undefined && IDENTIFIER_CHAR.test(value);
197+
}
198+
199+
function isStringArray(value: unknown): value is string[] {
200+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
201+
}
202+
70203
function readStringArray(value: unknown): string[] | undefined {
71-
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
72-
? value
73-
: undefined;
204+
return isStringArray(value) ? value : undefined;
74205
}

‎test/integration/provider-scenarios/active-session-script-publication.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,15 @@ test('parameterized fill publishes only ${VAR} and replay resolves it immediatel
138138
ref: `@${search.ref}`,
139139
text: secret,
140140
recordAs: 'SEARCH_TERM',
141+
settle: true,
142+
settleQuietMs: 1,
143+
timeoutMs: 1_000,
141144
...world.selection,
142145
});
143146
assert.equal(fill.text, '${SEARCH_TERM}');
147+
const settleOutput = JSON.stringify(fill.settle);
148+
assert.equal(settleOutput.includes(secret), false, settleOutput);
149+
assert.match(settleOutput, /\$\{SEARCH_TERM\}/);
144150
await client.command.wait({
145151
selector: 'id=com.android.settings:id/search',
146152
...world.selection,

0 commit comments

Comments
 (0)