mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 11:40:51 +01:00
browse: redact typed values from the audit and dialog logs (GST-1631) (#4073)
* browse: redact typed values from the audit and dialog logs (GST-1631) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review-Finding: p1/specialist-batched#1 reader=security Review-Finding: p1/red-team#4 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: redact malformed and unknown chain steps in the audit log Review-Finding: v2/outside-voice#1 reader=outside-voice Review-Finding: v2/adversarial-native#1 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: chain audit line logs only real command names (GST-1631) Review-Finding: fc/fix-check#1 reader=fix-check Review-Finding: fc/fix-check#2 reader=fix-check Review-Finding: fc/fix-check#3 reader=fix-check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8a3d809384
commit
b10cc47efe
5 files changed
+476
-15
No files matched your search
+142
-8
@@ -5,14 +5,21 @@
|
||||
* ring buffers (console, network, dialog), the audit log persists across server
|
||||
* restarts and is never truncated by the server. Each entry records:
|
||||
*
|
||||
* - timestamp, command, args (truncated), page origin
|
||||
* - timestamp, command, args (typed values redacted, then truncated), page origin
|
||||
* - duration, status (ok/error), error message if any
|
||||
* - whether cookies were imported (elevated security context)
|
||||
* - connection mode (headless/headed)
|
||||
*
|
||||
* The log is plain text on disk, so every value a command types (a fill
|
||||
* value, typed text, a js expression, a prompt answer, a cookie, header,
|
||||
* storage or skill-arg value) is replaced with [REDACTED] before truncation,
|
||||
* and the error text is scrubbed of the same values. Navigation and click
|
||||
* args are kept as typed.
|
||||
*
|
||||
* All writes are best-effort — audit failures never cause command failures.
|
||||
*/
|
||||
|
||||
import { ALL_COMMANDS, canonicalizeCommand } from './commands';
|
||||
import { appendSecureFile } from './file-permissions';
|
||||
|
||||
export interface AuditEntry {
|
||||
@@ -21,7 +28,7 @@ export interface AuditEntry {
|
||||
/** If the agent typed an alias (e.g. 'setcontent'), the raw input is preserved here
|
||||
* while `cmd` holds the canonical name ('load-html'). Omitted when cmd === rawCmd. */
|
||||
aliasOf?: string;
|
||||
args: string;
|
||||
args: string[];
|
||||
origin: string;
|
||||
durationMs: number;
|
||||
status: 'ok' | 'error';
|
||||
@@ -33,8 +40,132 @@ export interface AuditEntry {
|
||||
const MAX_ARGS_LENGTH = 200;
|
||||
const MAX_ERROR_LENGTH = 300;
|
||||
|
||||
const REDACTED = '[REDACTED]';
|
||||
|
||||
let auditPath: string | null = null;
|
||||
|
||||
interface RedactedArgs {
|
||||
args: string[];
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
function redactAfterSeparator(args: string[], sep: string): RedactedArgs {
|
||||
const joined = args.join(' ');
|
||||
const at = joined.indexOf(sep);
|
||||
if (at <= 0) return { args: [REDACTED], secrets: [joined] };
|
||||
return { args: [joined.slice(0, at + 1) + REDACTED], secrets: [joined.slice(at + 1).trim()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A chain line keeps only the step names that are real commands, never their
|
||||
* args: every step writes its own audit line with its exact args redacted per
|
||||
* command, and a second parse of the flow text here could differ from chain's.
|
||||
*/
|
||||
function redactChain(flow: string): RedactedArgs {
|
||||
let steps: string[][];
|
||||
try {
|
||||
const parsed = JSON.parse(flow);
|
||||
if (!Array.isArray(parsed) || !parsed.every(step => Array.isArray(step))) return { args: [REDACTED], secrets: [flow] };
|
||||
steps = parsed.map(step => step.map(String));
|
||||
} catch {
|
||||
steps = flow.split(' | ').map(seg => seg.trim().split(/\s+/));
|
||||
}
|
||||
const names = steps.map(([name = '']) => (ALL_COMMANDS.has(canonicalizeCommand(name)) ? name : REDACTED));
|
||||
const secrets = steps.map((step, i) => (names[i] === REDACTED ? step : step.slice(1)).join(' '));
|
||||
return { args: [names.join(' | ')], secrets };
|
||||
}
|
||||
|
||||
/** Replace the typed value of every value-carrying command with [REDACTED]. */
|
||||
export function redactCommandArgs(rawCmd: string, args: string[]): RedactedArgs {
|
||||
if (args.length === 0) return { args, secrets: [] };
|
||||
const cmd = canonicalizeCommand(rawCmd);
|
||||
switch (cmd) {
|
||||
case 'fill':
|
||||
if (args.length < 2) return { args: [REDACTED], secrets: [args.join(' ')] };
|
||||
return { args: [args[0], REDACTED], secrets: [args.slice(1).join(' ')] };
|
||||
case 'type': {
|
||||
const kept: string[] = [];
|
||||
let i = 0;
|
||||
if (args[i] === '--selector') { kept.push(...args.slice(i, i + 2)); i += 2; }
|
||||
if (args[i] === '--') { kept.push('--'); i += 1; }
|
||||
const text = args.slice(i);
|
||||
if (text.length === 0) return { args: [REDACTED], secrets: [args.join(' ')] };
|
||||
return { args: [...kept, REDACTED], secrets: [text.join(' ')] };
|
||||
}
|
||||
case 'js': {
|
||||
const kept: string[] = [];
|
||||
const expr: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--out' && i + 1 < args.length) { kept.push('--out', args[++i]); continue; }
|
||||
if (args[i] === '--raw') { kept.push('--raw'); continue; }
|
||||
expr.push(args[i]);
|
||||
}
|
||||
if (expr.length === 0) return { args, secrets: [] };
|
||||
return { args: [REDACTED, ...kept], secrets: [expr.join(' ')] };
|
||||
}
|
||||
case 'dialog-accept':
|
||||
return { args: [REDACTED], secrets: [args.join(' ')] };
|
||||
case 'cookie':
|
||||
return redactAfterSeparator(args, '=');
|
||||
case 'header':
|
||||
return redactAfterSeparator(args, ':');
|
||||
case 'storage':
|
||||
if (args[0] !== 'set' || args.length < 2) return { args, secrets: [] };
|
||||
if (args.length < 3) return { args: [args[0], REDACTED], secrets: [args[1]] };
|
||||
return { args: [args[0], args[1], REDACTED], secrets: [args.slice(2).join(' ')] };
|
||||
case 'skill': {
|
||||
if (args[0] !== 'run' || args.length < 3) return { args, secrets: [] };
|
||||
const out = args.slice(0, 2);
|
||||
const secrets: string[] = [];
|
||||
for (const a of args.slice(2)) {
|
||||
if (a === '--arg' || a.startsWith('--timeout=')) { out.push(a); continue; }
|
||||
const eq = a.indexOf('=');
|
||||
out.push(eq > 0 ? a.slice(0, eq + 1) + REDACTED : REDACTED);
|
||||
secrets.push(eq > 0 ? a.slice(eq + 1) : a);
|
||||
}
|
||||
return { args: out, secrets };
|
||||
}
|
||||
case 'tab-each': {
|
||||
if (!ALL_COMMANDS.has(canonicalizeCommand(args[0]))) return { args: [REDACTED], secrets: [args.join(' ')] };
|
||||
const inner = redactCommandArgs(args[0], args.slice(1));
|
||||
return { args: [args[0], ...inner.args], secrets: inner.secrets };
|
||||
}
|
||||
case 'chain':
|
||||
return redactChain(args[0]);
|
||||
default:
|
||||
return { args, secrets: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every redacted value, its JSON-escaped form and each 4+ char piece of it
|
||||
* (split on whitespace and on punctuation, since a js error echoes a bare
|
||||
* identifier) from text. Needles under 6 chars match only as whole tokens, so a
|
||||
* short value neither shreds the error nor shows through the text around it.
|
||||
*/
|
||||
export function scrubSecrets(text: string, secrets: string[]): string {
|
||||
const needles = new Set<string>();
|
||||
for (const secret of secrets) {
|
||||
if (!secret) continue;
|
||||
needles.add(secret);
|
||||
needles.add(JSON.stringify(secret).slice(1, -1));
|
||||
for (const piece of [...secret.split(/\s+/), ...secret.split(/[^A-Za-z0-9_$.-]+/)]) {
|
||||
if (piece.length >= 4) {
|
||||
needles.add(piece);
|
||||
needles.add(JSON.stringify(piece).slice(1, -1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = text;
|
||||
for (const needle of [...needles].sort((a, b) => b.length - a.length)) {
|
||||
if (!needle) continue;
|
||||
if (needle.length >= 6) { out = out.split(needle).join(REDACTED); continue; }
|
||||
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
out = out.replace(new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, 'g'), REDACTED);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function initAuditLog(logPath: string): void {
|
||||
auditPath = logPath;
|
||||
}
|
||||
@@ -42,12 +173,15 @@ export function initAuditLog(logPath: string): void {
|
||||
export function writeAuditEntry(entry: AuditEntry): void {
|
||||
if (!auditPath) return;
|
||||
try {
|
||||
const truncatedArgs = entry.args.length > MAX_ARGS_LENGTH
|
||||
? entry.args.slice(0, MAX_ARGS_LENGTH) + '…'
|
||||
: entry.args;
|
||||
const truncatedError = entry.error && entry.error.length > MAX_ERROR_LENGTH
|
||||
? entry.error.slice(0, MAX_ERROR_LENGTH) + '…'
|
||||
: entry.error;
|
||||
const redacted = redactCommandArgs(entry.cmd, entry.args);
|
||||
const args = redacted.args.join(' ');
|
||||
const error = entry.error ? scrubSecrets(entry.error, redacted.secrets) : entry.error;
|
||||
const truncatedArgs = args.length > MAX_ARGS_LENGTH
|
||||
? args.slice(0, MAX_ARGS_LENGTH) + '…'
|
||||
: args;
|
||||
const truncatedError = error && error.length > MAX_ERROR_LENGTH
|
||||
? error.slice(0, MAX_ERROR_LENGTH) + '…'
|
||||
: error;
|
||||
|
||||
const record: Record<string, unknown> = {
|
||||
ts: entry.ts,
|
||||
|
||||
@@ -135,3 +135,9 @@ export function addNetworkEntry(entry: NetworkEntry) {
|
||||
export function addDialogEntry(entry: DialogEntry) {
|
||||
dialogBuffer.push(entry);
|
||||
}
|
||||
|
||||
/** One browse-dialog.log line. The prompt response is text the agent typed (dialog-accept), so it never reaches disk. */
|
||||
export function formatDialogLogLine(e: DialogEntry): string {
|
||||
const response = e.response ? ' "[REDACTED]"' : '';
|
||||
return `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${response}`;
|
||||
}
|
||||
@@ -608,7 +608,7 @@ function generateHelpText(): string {
|
||||
}
|
||||
|
||||
// ─── Buffer (from buffers.ts) ────────────────────────────────────
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
||||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, formatDialogLogLine, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
||||
|
||||
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
|
||||
|
||||
@@ -675,10 +675,7 @@ async function flushBuffers() {
|
||||
const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
||||
if (newDialogCount > 0) {
|
||||
const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
|
||||
const lines = entries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
||||
).join('\n') + '\n';
|
||||
appendSecureFile(DIALOG_LOG_PATH, lines);
|
||||
appendSecureFile(DIALOG_LOG_PATH, entries.map(formatDialogLogLine).join('\n') + '\n');
|
||||
lastDialogFlushed = dialogBuffer.totalAdded;
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1259,7 +1256,7 @@ async function handleCommandInternalImpl(
|
||||
ts: new Date().toISOString(),
|
||||
cmd: command,
|
||||
aliasOf: isAliased ? rawCommand : undefined,
|
||||
args: args.join(' '),
|
||||
args,
|
||||
origin: browserManager.getCurrentUrl(),
|
||||
durationMs: successDuration,
|
||||
status: 'ok',
|
||||
@@ -1291,7 +1288,7 @@ async function handleCommandInternalImpl(
|
||||
ts: new Date().toISOString(),
|
||||
cmd: command,
|
||||
aliasOf: isAliased ? rawCommand : undefined,
|
||||
args: args.join(' '),
|
||||
args,
|
||||
origin: browserManager.getCurrentUrl(),
|
||||
durationMs: errorDuration,
|
||||
status: 'error',
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { initAuditLog, writeAuditEntry } from '../src/audit';
|
||||
import { formatDialogLogLine } from '../src/buffers';
|
||||
import { handleMetaCommand } from '../src/meta-commands';
|
||||
|
||||
// GST-1631: the audit log is plain JSONL in the state dir, so every value a
|
||||
// command types (fill, type, js, dialog-accept, cookie, header, storage set,
|
||||
// skill --arg, and the same inside tab-each and chain) must be redacted before
|
||||
// truncation and before the line is written. Navigation and click lines stay
|
||||
// readable. Error text is scrubbed of the same values.
|
||||
|
||||
let dir: string;
|
||||
let logPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-audit-redact-'));
|
||||
logPath = path.join(dir, 'browse-audit.jsonl');
|
||||
initAuditLog(logPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(cmd: string, args: string[], error?: string) {
|
||||
writeAuditEntry({
|
||||
ts: '2026-11-06T00:00:00.000Z',
|
||||
cmd,
|
||||
args,
|
||||
origin: 'https://example.com/login',
|
||||
durationMs: 1,
|
||||
status: error ? 'error' : 'ok',
|
||||
error,
|
||||
hasCookies: false,
|
||||
mode: 'launched',
|
||||
});
|
||||
}
|
||||
|
||||
function raw(): string {
|
||||
return fs.readFileSync(logPath, 'utf8');
|
||||
}
|
||||
|
||||
function line(): Record<string, any> {
|
||||
const lines = raw().trim().split('\n');
|
||||
return JSON.parse(lines[lines.length - 1]);
|
||||
}
|
||||
|
||||
describe('audit args redaction (on-disk line)', () => {
|
||||
it('fill keeps the selector and hides a one-word value', () => {
|
||||
write('fill', ['#password', 'hunter2']);
|
||||
expect(raw()).not.toContain('hunter2');
|
||||
expect(line().args).toBe('#password [REDACTED]');
|
||||
});
|
||||
|
||||
it('fill hides a multi-word value whole', () => {
|
||||
write('fill', ['input[name=pw]', 'correct', 'horse', 'battery']);
|
||||
expect(raw()).not.toMatch(/correct|horse|battery/);
|
||||
expect(line().args).toBe('input[name=pw] [REDACTED]');
|
||||
});
|
||||
|
||||
it('type hides the text and keeps --selector and --', () => {
|
||||
write('type', ['s3cretPass']);
|
||||
expect(line().args).toBe('[REDACTED]');
|
||||
write('type', ['--selector', '#pw', '--', '--s3cretPass']);
|
||||
expect(raw()).not.toContain('s3cretPass');
|
||||
expect(line().args).toBe('--selector #pw -- [REDACTED]');
|
||||
});
|
||||
|
||||
it('js hides the expression and keeps --out and --raw', () => {
|
||||
write('js', ['localStorage.setItem("tok","sk-live-123")']);
|
||||
expect(line().args).toBe('[REDACTED]');
|
||||
write('js', ['document.cookie="sid=sk-live-123"', '--out', '/tmp/out.txt', '--raw']);
|
||||
expect(raw()).not.toContain('sk-live-123');
|
||||
expect(line().args).toBe('[REDACTED] --out /tmp/out.txt --raw');
|
||||
});
|
||||
|
||||
it('dialog-accept hides the prompt text, and a bare dialog-accept stays empty', () => {
|
||||
write('dialog-accept', ['my', 'pin', '4321']);
|
||||
expect(raw()).not.toContain('4321');
|
||||
expect(line().args).toBe('[REDACTED]');
|
||||
write('dialog-accept', []);
|
||||
expect(line().args).toBe('');
|
||||
});
|
||||
|
||||
it('cookie keeps the name and hides the value; no separator hides it whole', () => {
|
||||
write('cookie', ['session=abc123xyz']);
|
||||
expect(line().args).toBe('session=[REDACTED]');
|
||||
write('cookie', ['abc123xyz']);
|
||||
expect(line().args).toBe('[REDACTED]');
|
||||
expect(raw()).not.toContain('abc123xyz');
|
||||
});
|
||||
|
||||
it('header keeps the name and hides the value, quoted or split into words', () => {
|
||||
write('header', ['Authorization: Bearer tok_live_999']);
|
||||
expect(line().args).toBe('Authorization:[REDACTED]');
|
||||
write('header', ['Authorization:', 'Bearer', 'tok_live_999']);
|
||||
expect(line().args).toBe('Authorization:[REDACTED]');
|
||||
write('header', ['tok_live_999']);
|
||||
expect(line().args).toBe('[REDACTED]');
|
||||
expect(raw()).not.toContain('tok_live_999');
|
||||
});
|
||||
|
||||
it('storage set keeps the key and hides the value; a storage read is unchanged', () => {
|
||||
write('storage', ['set', 'authToken', 'eyJhbGciOi.payload.sig']);
|
||||
expect(raw()).not.toContain('eyJhbGciOi');
|
||||
expect(line().args).toBe('set authToken [REDACTED]');
|
||||
write('storage', []);
|
||||
expect(line().args).toBe('');
|
||||
});
|
||||
|
||||
it('skill run keeps the skill name, arg keys and --timeout, and hides arg values', () => {
|
||||
write('skill', ['run', 'login', '--arg', 'password=pa55word!', '--arg', 'user=garry', '--timeout=30']);
|
||||
expect(raw()).not.toContain('pa55word!');
|
||||
expect(line().args).toBe('run login --arg password=[REDACTED] --arg user=[REDACTED] --timeout=30');
|
||||
write('skill', ['list']);
|
||||
expect(line().args).toBe('list');
|
||||
});
|
||||
|
||||
it('tab-each redacts the command it wraps', () => {
|
||||
write('tab-each', ['fill', '#otp', '998877']);
|
||||
expect(raw()).not.toContain('998877');
|
||||
expect(line().args).toBe('fill #otp [REDACTED]');
|
||||
write('tab-each', ['goto', 'https://example.com/a']);
|
||||
expect(line().args).toBe('goto https://example.com/a');
|
||||
});
|
||||
|
||||
it('chain logs only its step names, in JSON and pipe form', () => {
|
||||
write('chain', [JSON.stringify([['goto', 'https://example.com/login'], ['fill', '#pw', 'hunter2'], ['cookie', 'sid=zz9plural'], ['click', '@e3']])]);
|
||||
expect(raw()).not.toMatch(/hunter2|zz9plural/);
|
||||
expect(line().args).toBe('goto | fill | cookie | click');
|
||||
write('chain', ['goto https://example.com | fill #pw "two words" | click @e3']);
|
||||
expect(raw()).not.toMatch(/two words|"two|words"/);
|
||||
expect(line().args).toBe('goto | fill | click');
|
||||
});
|
||||
|
||||
it('a chain that is not a command list is hidden whole', () => {
|
||||
write('chain', ['{"fill":"hunter2"}']);
|
||||
expect(raw()).not.toContain('hunter2');
|
||||
expect(line().args).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('leaves navigation and click lines unchanged', () => {
|
||||
write('goto', ['https://example.com/login?next=/home']);
|
||||
expect(line().args).toBe('https://example.com/login?next=/home');
|
||||
write('click', ['@e3']);
|
||||
expect(line().args).toBe('@e3');
|
||||
write('select', ['#country', 'United', 'States']);
|
||||
expect(line().args).toBe('#country United States');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redaction runs before truncation', () => {
|
||||
it('a long fill value never leaks its head through the 200-char cut', () => {
|
||||
const secret = 'S'.repeat(20) + 'x'.repeat(480);
|
||||
write('fill', ['#password', secret]);
|
||||
expect(raw()).not.toContain('SSSS');
|
||||
expect(line().args).toBe('#password [REDACTED]');
|
||||
});
|
||||
|
||||
it('a long chain is redacted first and then truncated', () => {
|
||||
const steps = Array.from({ length: 30 }, (_, i) => ['fill', `#f${i}`, `secretvalue${i}`]);
|
||||
write('chain', [JSON.stringify(steps)]);
|
||||
const args = line().args as string;
|
||||
expect(raw()).not.toContain('secretvalue');
|
||||
expect(args.length).toBeLessThanOrEqual(201);
|
||||
expect(args.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error text is scrubbed of the typed values', () => {
|
||||
it('scrubs a fill value echoed by the page error', () => {
|
||||
write('fill', ['#password', 'hunter2'], 'locator.fill: could not fill "#password" with "hunter2"');
|
||||
expect(raw()).not.toContain('hunter2');
|
||||
expect(line().error).toBe('locator.fill: could not fill "#password" with "[REDACTED]"');
|
||||
});
|
||||
|
||||
it('scrubs each word of a multi-word value and its JSON-escaped form', () => {
|
||||
write('type', ['say', '"quoted"', 'battery'], 'Timeout typing "say \\"quoted\\" battery"; last word battery');
|
||||
expect(raw()).not.toMatch(/quoted|battery/);
|
||||
});
|
||||
|
||||
it('scrubs secrets from nested chain and tab-each commands', () => {
|
||||
write('chain', [JSON.stringify([['header', 'X-Api-Key: k3y-abcdef']])], 'header rejected: k3y-abcdef');
|
||||
expect(raw()).not.toContain('k3y-abcdef');
|
||||
write('tab-each', ['cookie', 'sid=c00kievalue'], 'cookie c00kievalue invalid');
|
||||
expect(raw()).not.toContain('c00kievalue');
|
||||
});
|
||||
|
||||
it('scrubs before the 300-char cut, so a value straddling it leaves no prefix', () => {
|
||||
write('fill', ['#pw', 'hunter2secret'], 'e'.repeat(295) + 'hunter2secret');
|
||||
expect(raw()).not.toContain('hunte');
|
||||
});
|
||||
|
||||
it('leaves errors of value-free commands unchanged', () => {
|
||||
write('goto', ['https://example.com'], 'net::ERR_NAME_NOT_RESOLVED at https://example.com');
|
||||
expect(line().error).toBe('net::ERR_NAME_NOT_RESOLVED at https://example.com');
|
||||
});
|
||||
|
||||
it('pipe-form chain tokenizes quoted values like chain does, so an unquoted echo is scrubbed', () => {
|
||||
write('chain', ['goto https://example.com | fill #pw "correct horse"'], 'fill failed: value correct horse rejected');
|
||||
expect(raw()).not.toMatch(/correct|horse/);
|
||||
expect(line().args).toBe('goto | fill');
|
||||
});
|
||||
|
||||
it('pipe-form js with a quoted inner --out keeps nothing of the expression', () => {
|
||||
write('chain', ['goto https://x | js "a --out b"']);
|
||||
expect(line().args).toBe('goto | js');
|
||||
});
|
||||
|
||||
it('scrubs a token a js error echoes without its surrounding punctuation', () => {
|
||||
write('js', ["localStorage.setItem('t', sk_live_abcdef123)"], 'ReferenceError: sk_live_abcdef123 is not defined');
|
||||
expect(raw()).not.toContain('sk_live_abcdef123');
|
||||
});
|
||||
|
||||
it('an empty quoted selector in pipe form cannot shift the value into a kept position', () => {
|
||||
write('chain', ['fill "" "my-super-secret" | type --selector "" "my-super-secret" | storage set "" "my-super-secret"']);
|
||||
expect(raw()).not.toContain('my-super-secret');
|
||||
});
|
||||
|
||||
it('a tab or newline inside a pipe-form segment never leaves the value in plain text', () => {
|
||||
write('chain', ['goto https://x | fill #pw\nhunter2secret | fill\t#pw\thunter2secret | tab-each fill\t#pw\thunter2secret'], 'Unknown command: fill\t#pw\thunter2secret');
|
||||
expect(raw()).not.toContain('hunter2secret');
|
||||
});
|
||||
|
||||
it('matches a short value only as a whole token', () => {
|
||||
write('type', ['a'], 'type a: failed at a step');
|
||||
expect(line().error).toBe('type [REDACTED]: failed at [REDACTED] step');
|
||||
write('fill', ['#otp', '5000'], 'locator.fill: Timeout 5000ms exceeded; value 5000 rejected');
|
||||
expect(line().error).toBe('locator.fill: Timeout 5000ms exceeded; value [REDACTED] rejected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chain line keeps only real command names (fix-check shapes)', () => {
|
||||
it('an empty quoted selector before an unquoted multi-word value leaves nothing', () => {
|
||||
write('chain', ['fill "" correct horse battery']);
|
||||
expect(line().args).toBe('fill');
|
||||
write('chain', ['type --selector "" correct horse']);
|
||||
expect(line().args).toBe('type');
|
||||
write('chain', ['storage set "" a b']);
|
||||
expect(line().args).toBe('storage');
|
||||
expect(raw()).not.toMatch(/correct|horse|battery/);
|
||||
});
|
||||
|
||||
it('a separator other than " | " leaves nothing of the following step', () => {
|
||||
write('chain', ['goto https://x |\nfill #pw hunter2secret']);
|
||||
expect(line().args).toBe('goto');
|
||||
write('chain', ['click @e1 |fill #pw hunter2secret']);
|
||||
expect(line().args).toBe('click');
|
||||
expect(raw()).not.toContain('hunter2secret');
|
||||
});
|
||||
|
||||
it('a tab inside a step leaves nothing of its args', () => {
|
||||
write('chain', ['goto https://x | storage set\tauthToken hunter2secret']);
|
||||
expect(line().args).toBe('goto | storage');
|
||||
write('chain', ['goto https://x | skill run\tlogin password=hunter2secret']);
|
||||
expect(line().args).toBe('goto | skill');
|
||||
write('chain', ['goto https://x | skill run login --timeout=30\tpassword=hunter2secret']);
|
||||
expect(line().args).toBe('goto | skill');
|
||||
expect(raw()).not.toMatch(/hunter2secret|authToken/);
|
||||
});
|
||||
|
||||
it('a step name that is not a command is redacted', () => {
|
||||
write('chain', ['hunter2secret | goto https://x']);
|
||||
expect(line().args).toBe('[REDACTED] | goto');
|
||||
write('chain', [JSON.stringify([['hunter2secret', 'x']])]);
|
||||
expect(line().args).toBe('[REDACTED]');
|
||||
expect(raw()).not.toContain('hunter2secret');
|
||||
});
|
||||
|
||||
it('each step still writes its own audit line with its args redacted per command', async () => {
|
||||
const bm = { getPage: () => ({ waitForLoadState: async () => {} }) } as any;
|
||||
const executeCommand = async (body: { command: string; args?: string[] }) => {
|
||||
write(body.command, body.args ?? []);
|
||||
return { status: 200, result: 'ok' };
|
||||
};
|
||||
const flows = [
|
||||
'goto https://example.com/login | fill #pw "correct horse" | cookie sid=zz9plural | click @e3',
|
||||
JSON.stringify([['goto', 'https://example.com/login'], ['fill', '#pw', 'correct horse'], ['cookie', 'sid=zz9plural'], ['click', '@e3']]),
|
||||
];
|
||||
for (const flow of flows) {
|
||||
fs.rmSync(logPath, { force: true });
|
||||
await handleMetaCommand('chain', [flow], bm, async () => {}, null, { executeCommand });
|
||||
write('chain', [flow]);
|
||||
const lines = raw().trim().split('\n').map(l => JSON.parse(l));
|
||||
expect(lines.map(l => [l.cmd, l.args])).toEqual([
|
||||
['goto', 'https://example.com/login'],
|
||||
['fill', '#pw [REDACTED]'],
|
||||
['cookie', 'sid=[REDACTED]'],
|
||||
['click', '@e3'],
|
||||
['chain', 'goto | fill | cookie | click'],
|
||||
]);
|
||||
expect(raw()).not.toMatch(/correct|horse|zz9plural/);
|
||||
}
|
||||
});
|
||||
|
||||
it('the server hands each command its args unjoined', () => {
|
||||
const src = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf8');
|
||||
const calls = src.split('writeAuditEntry({').slice(1).map(c => c.slice(0, c.indexOf('});')));
|
||||
expect(calls.length).toBe(2);
|
||||
for (const call of calls) expect(call).toMatch(/\n\s+args,\n/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialog log line (browse-dialog.log)', () => {
|
||||
it('does not persist the prompt response typed through dialog-accept', () => {
|
||||
const out = formatDialogLogLine({ timestamp: 0, type: 'prompt', message: 'Enter PIN', action: 'accepted', response: '4321' });
|
||||
expect(out).not.toContain('4321');
|
||||
expect(out).toBe('[1970-01-01T00:00:00.000Z] [prompt] "Enter PIN" → accepted "[REDACTED]"');
|
||||
});
|
||||
|
||||
it('keeps lines without a response as before', () => {
|
||||
expect(formatDialogLogLine({ timestamp: 0, type: 'alert', message: 'Saved', action: 'dismissed' }))
|
||||
.toBe('[1970-01-01T00:00:00.000Z] [alert] "Saved" → dismissed');
|
||||
});
|
||||
|
||||
it('is what the server flush writes', () => {
|
||||
const src = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf8');
|
||||
expect(src).toContain('appendSecureFile(DIALOG_LOG_PATH, entries.map(formatDialogLogLine)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
- **The browse audit log no longer stores typed secrets.** `.gstack/browse-audit.jsonl` wrote each command's arguments verbatim, so a password passed to `fill`, text sent with `type`, a `js` expression, a `dialog-accept` answer, a `cookie`, `header` or `storage set` value, and a `skill run --arg` value all reached disk in plain text, and an error that echoed the value stored a second copy. Each of those values, including inside `tab-each` and `chain`, is now written as `[REDACTED]` before the line is truncated, with the selector, cookie or header name, storage key, arg key and `--out` path kept, and the error text is scrubbed of the same values. Navigation and click lines are unchanged. `browse-dialog.log` no longer stores the prompt answer either. Lines written before this release still hold the old values; delete `.gstack/browse-audit.jsonl` and `.gstack/browse-dialog.log` to clear them. Reported in #4057 by @derHaken. (GST-1631)
|
||||
Reference in new issue
Block a user