playwright-write-guard 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // playwright-write-guard-suggest: draft `allow` entries from an audit log. Reads one file, writes none.
3
+
4
+ import { readFileSync } from 'node:fs';
5
+
6
+ const USAGE = 'usage: playwright-write-guard-suggest <log.jsonl>';
7
+
8
+ const args = process.argv.slice(2);
9
+ let text;
10
+ try {
11
+ if (args.length !== 1) throw new Error('usage');
12
+ text = readFileSync(args[0], 'utf8');
13
+ } catch {
14
+ console.error(USAGE);
15
+ process.exit(1);
16
+ }
17
+
18
+ const KEPT = new Set(['observe', 'block', 'mocked']);
19
+ const groups = new Map();
20
+ for (const line of text.split(/\r\n|\r|\n/)) {
21
+ let e;
22
+ try {
23
+ e = JSON.parse(line);
24
+ } catch {
25
+ continue;
26
+ }
27
+ if (e === null || typeof e !== 'object' || typeof e.method !== 'string' || e.method === '') continue;
28
+ if (e.kind !== 'request' || !KEPT.has(e.action)) continue;
29
+ let u;
30
+ try {
31
+ u = new URL(e.url);
32
+ } catch {
33
+ continue;
34
+ }
35
+ const path = u.origin + u.pathname;
36
+ const key = JSON.stringify([e.method, path]);
37
+ const g = groups.get(key) ?? { method: e.method, path, count: 0, nextAction: false };
38
+ g.count++;
39
+ if (Array.isArray(e.headerNames) && e.headerNames.includes('next-action')) g.nextAction = true;
40
+ groups.set(key, g);
41
+ }
42
+
43
+ const rows = [...groups.values()].map((g) => ({
44
+ ...g,
45
+ url: '^' + g.path.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') + '(?:[?#]|$)',
46
+ }));
47
+ // Code unit order, as Array.prototype.sort compares strings by default.
48
+ rows.sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : a.method < b.method ? -1 : a.method > b.method ? 1 : 0));
49
+
50
+ console.log(JSON.stringify({ allow: rows.map(({ method, url }) => ({ method, url })) }, null, 2));
51
+ for (const g of rows) {
52
+ const hint = g.nextAction
53
+ ? ': carries next-action, pin its action IDs with a headers entry'
54
+ : g.path.endsWith('/graphql') ? ': GraphQL, add an anchored body pattern' : '';
55
+ console.error(`${g.count} x ${g.method} ${g.path}${hint}`);
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-write-guard",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A read-only safety net for Playwright tests and AI browser agents: blocks writes at the network layer and refuses destructive clicks in the page.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -47,7 +47,8 @@
47
47
  }
48
48
  },
49
49
  "bin": {
50
- "playwright-write-guard-browser": "./bin/browser.mjs"
50
+ "playwright-write-guard-browser": "./bin/browser.mjs",
51
+ "playwright-write-guard-suggest": "./bin/suggest.mjs"
51
52
  },
52
53
  "files": [
53
54
  "src",
package/src/core.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  export function compileConfig(raw) {
13
13
  const VERBS = ['save', 'delete', 'remove', 'publish', 'send', 'archive', 'pay', 'purchase',
14
14
  'log out', 'sign out', 'logout'];
15
+ const SUSPECT = '(?:^|[/._-])(?:(?:log|sign)[-_]?out|unsubscribe|confirm|delete|remove)(?:$|[/._?&#-])|[?&]action=';
15
16
  const fail = (path, message) => { throw new Error(`write-guard: ${path}: ${message}`); };
16
17
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
17
18
  const list = (v, path) => {
@@ -69,6 +70,14 @@ export function compileConfig(raw) {
69
70
  const parts = [...(cfg.defaultControls === false ? [] : [`\\b(?:${VERBS.join('|')})\\b`]),
70
71
  ...extra.map((s) => `(?:${s})`)];
71
72
 
73
+ if (cfg.defaultSuspectGets !== undefined && typeof cfg.defaultSuspectGets !== 'boolean') {
74
+ fail('defaultSuspectGets', 'must be a boolean');
75
+ }
76
+ const websocketWrites = list(cfg.websocketWrites, 'websocketWrites').map((s, i) => regex(s, `websocketWrites[${i}]`, ''));
77
+ const suspectGets = list(cfg.suspectGets, 'suspectGets');
78
+ suspectGets.forEach((s, i) => regex(s, `suspectGets[${i}]`, 'i'));
79
+ const suspectParts = [...(cfg.defaultSuspectGets === false ? [] : [SUSPECT]), ...suspectGets].map((s) => `(?:${s})`);
80
+
72
81
  return {
73
82
  policy,
74
83
  allow,
@@ -76,6 +85,8 @@ export function compileConfig(raw) {
76
85
  text: parts.length ? new RegExp(parts.join('|'), 'i') : null,
77
86
  icons: [...icons],
78
87
  websockets,
88
+ websocketWrites,
89
+ suspect: suspectParts.length ? new RegExp(suspectParts.join('|'), 'i') : null,
79
90
  log: cfg.log === undefined ? 'write-guard.jsonl' : cfg.log,
80
91
  logBodies: cfg.logBodies === true,
81
92
  };
@@ -104,6 +115,35 @@ export function decideRequest(request, config) {
104
115
  : { action: 'block', reason: `${what}; policy read-only` };
105
116
  }
106
117
 
118
+ /**
119
+ * `block` (read-only) or `observe` (observe) for a string frame matching any `websocketWrites`
120
+ * pattern, else `allow`. A frame that is not a string is always `allow`. Runs only in Node.
121
+ */
122
+ export function decideFrame(text, config) {
123
+ const hit = typeof text === 'string' ? config.websocketWrites.findIndex((re) => re.test(text)) : -1;
124
+ if (hit === -1) return { action: 'allow', reason: 'not a frame matching websocketWrites' };
125
+ const what = `frame matches websocketWrites[${hit}]`;
126
+ return config.policy === 'observe'
127
+ ? { action: 'observe', reason: `${what}; policy observe` }
128
+ : { action: 'block', reason: `${what}; policy read-only` };
129
+ }
130
+
131
+ /**
132
+ * `suspect: true` only for a GET or HEAD whose URL matches the compiled `suspect` pattern, tested
133
+ * by pathname plus search for an absolute URL and as given otherwise. Self-contained (injected into pages).
134
+ */
135
+ export function decideSuspect(request, config) {
136
+ const method = String(request.method ?? '').toUpperCase();
137
+ const url = String(request.url ?? '');
138
+ if (method !== 'GET' && method !== 'HEAD') return { suspect: false, reason: `${method} is not a GET or HEAD` };
139
+ if (config.suspect === null) return { suspect: false, reason: 'no suspect pattern is configured' };
140
+ let tested = url;
141
+ try { const u = new URL(url); tested = u.pathname + u.search; } catch { /* relative or unparsable: tested as given */ }
142
+ return config.suspect.test(tested)
143
+ ? { suspect: true, reason: `${method} ${tested.slice(0, 80)} matches the suspect pattern` }
144
+ : { suspect: false, reason: `${method} ${tested.slice(0, 80)} matches no suspect pattern` };
145
+ }
146
+
107
147
  /** `block` (read-only) or `observe` (observe) for a destructive control, else `allow`. */
108
148
  export function decideControl(control, config) {
109
149
  const label = String(control.label ?? '').trim();
package/src/fixture.mjs CHANGED
@@ -1,25 +1,51 @@
1
1
  // playwright-write-guard/test: Playwright Test's `test` and `expect`, with every context guarded.
2
2
  //
3
- // The test's own context is guarded with `install` (which also locks context.request and
4
- // page.request under read-only). Worker-level locks refuse the Playwright APIs that would make
5
- // a context, a request client or a connection the guard cannot see (newContext, newPage,
6
- // request.newContext, the global fetch, and the launch and connect family) while a guarded test
7
- // or hook runs. Nothing else is refused: Node's own http, https and net are not seen.
8
- // Playwright's own creation of the test's context (and, under observe, of the request
9
- // fixture) is the one exception, and the window that allows it is shut again straight after.
3
+ // The test's own context is guarded with `install`, which also judges each send through
4
+ // context.request and page.request. The `request` fixture's sends are judged too: by the test's
5
+ // guard in a test and its beforeEach and afterEach hooks, and with the config alone (recorded
6
+ // nowhere) in beforeAll and afterAll. While the test's own function runs, Node's global fetch is
7
+ // judged by the test's guard, and browser.newContext and browser.newPage return guarded contexts.
8
+ // Worker-level locks refuse request.newContext and the launch and connect family while a guarded
9
+ // test or hook runs, and newContext, newPage and fetch outside the test's own function. Node's
10
+ // own http, https and net are not seen. Playwright's own creation of the test's context and of
11
+ // the request fixture is the one exception, and the window that allows it is shut again straight
12
+ // after.
10
13
 
11
14
  import { test as base, expect } from '@playwright/test';
12
15
  import { compileConfig } from './core.mjs';
13
- import { install, loadConfig } from './install.mjs';
16
+ import { _judgeRequestClient, install, loadConfig } from './install.mjs';
14
17
 
15
18
  // Open only while Playwright builds this test's context, or its request fixture.
16
19
  let contextWindow = false;
17
20
  let requestWindow = false;
18
21
 
22
+ // The running test's guard, its config, its testInfo and the guards of the contexts it made,
23
+ // set while the `context` fixture is up.
24
+ let current = null;
25
+
26
+ // True while the test's own function runs: not a hook, not a fixture's setup or teardown.
27
+ // Playwright 1.64 keeps the running step in a private field; anything else refuses.
28
+ const inTestFunction = () => {
29
+ const r = current?.testInfo._timeoutManager?._running?.runnable;
30
+ return r?.type === 'test' && !r.fixture;
31
+ };
32
+
19
33
  const refuse = (what) => new Error(
20
34
  `write-guard: ${what} is refused: it would make a context, request client or connection the guard cannot see. `
21
35
  + 'Drive the app through the test\'s own page and context.');
22
36
 
37
+ const serviceWorkersError = (value) => new Error(
38
+ `write-guard: serviceWorkers is '${value}'; the guard needs 'block', or a service worker can send requests it never sees`);
39
+
40
+ // A context made in the test's own function, guarded with the test's config before it is returned.
41
+ async function guardedContext(newContext, options = {}) {
42
+ const { config, made } = current;
43
+ if (options.serviceWorkers !== undefined && options.serviceWorkers !== 'block') throw serviceWorkersError(options.serviceWorkers);
44
+ const context = await newContext({ ...options, serviceWorkers: 'block' });
45
+ made.push(await install(context, { ...config, log: null }));
46
+ return context;
47
+ }
48
+
23
49
  export const test = base.extend({
24
50
  // The guard's config. Evaluated when a test first needs it, so a bad config file is that
25
51
  // test's error rather than a failure to load the file. `test.use` replaces it, used as given.
@@ -32,19 +58,27 @@ export const test = base.extend({
32
58
  // before the launch family is locked.
33
59
  _writeGuardWorker: [async ({ browser, playwright }, use) => {
34
60
  const restore = [];
35
- const lock = (obj, name, label, open = () => false) => {
61
+ // `pass(original)` returns the function to call instead, or nothing to refuse.
62
+ const lock = (obj, name, label, pass = () => null) => {
36
63
  if (typeof obj?.[name] !== 'function') return;
37
- const original = obj[name];
38
- restore.push(() => { obj[name] = original; });
64
+ const original = obj[name].bind(obj);
65
+ const own = obj[name];
66
+ restore.push(() => { obj[name] = own; });
39
67
  obj[name] = async (...args) => {
40
- if (open()) return original.apply(obj, args);
68
+ const call = pass(original);
69
+ if (call) return call(...args);
41
70
  throw refuse(label);
42
71
  };
43
72
  };
44
- lock(browser, 'newContext', 'browser.newContext()', () => contextWindow);
45
- lock(browser, 'newPage', 'browser.newPage()');
46
- lock(playwright.request, 'newContext', 'request.newContext()', () => requestWindow);
47
- lock(globalThis, 'fetch', 'fetch() (Node\'s own, from the test)');
73
+ lock(browser, 'newContext', 'browser.newContext()', (original) => {
74
+ if (contextWindow) return original;
75
+ return inTestFunction() ? (options) => guardedContext(original, options) : null;
76
+ });
77
+ // Playwright 1.64's newPage calls this.newContext, the guarded one above.
78
+ lock(browser, 'newPage', 'browser.newPage()', (original) => (inTestFunction() ? original : null));
79
+ lock(playwright.request, 'newContext', 'request.newContext()', (original) => (requestWindow ? original : null));
80
+ lock(globalThis, 'fetch', 'fetch() (Node\'s own, outside the test function)',
81
+ (original) => (inTestFunction() ? current.guard.guardFetch(original) : null));
48
82
  for (const type of [playwright.chromium, playwright.firefox, playwright.webkit]) {
49
83
  for (const method of ['launch', 'launchPersistentContext', 'launchServer', 'connect', 'connectOverCDP']) {
50
84
  lock(type, method, `${type.name()}.${method}()`);
@@ -74,31 +108,36 @@ export const test = base.extend({
74
108
  contextWindow = false;
75
109
  // install cannot change how the context was created; a project that asked for service
76
110
  // workers made a context the guard cannot fully see.
77
- if (serviceWorkers !== 'block') {
78
- throw new Error(`write-guard: serviceWorkers is '${serviceWorkers}'; the guard needs 'block', or a service worker can send requests it never sees`);
79
- }
111
+ if (serviceWorkers !== 'block') throw serviceWorkersError(serviceWorkers);
80
112
  const guard = await install(context, { ...writeGuardConfig, log: null });
113
+ const made = [];
114
+ current = { guard, config: writeGuardConfig, testInfo, made };
81
115
  try {
82
116
  await use(context);
83
117
  } finally {
84
- await testInfo.attach('write-guard.json', { body: JSON.stringify(await guard.close(), null, 2), contentType: 'application/json' });
118
+ current = null;
119
+ const attach = async (name, g) => testInfo.attach(name, { body: JSON.stringify(await g.close(), null, 2), contentType: 'application/json' });
120
+ await attach('write-guard.json', guard);
121
+ for (const [i, g] of made.entries()) await attach(`write-guard-${i + 2}.json`, g);
85
122
  }
86
123
  },
87
124
 
88
- // Playwright builds the request fixture with request.newContext(), so under observe the
89
- // window opens for that. Fixtures are set up in parameter order, so this one comes first.
90
- // Under read-only this is where the fixture is refused, at setup, before anything is built.
91
- _writeGuardRequestWindow: async ({ writeGuardConfig }, use) => {
92
- if (compileConfig(writeGuardConfig).policy === 'read-only') {
93
- throw new Error('write-guard: the request fixture is refused under read-only');
94
- }
125
+ // Playwright builds the request fixture with request.newContext(), so the window opens for
126
+ // that. Fixtures are set up in parameter order, so this one comes first.
127
+ _writeGuardRequestWindow: async ({}, use) => {
95
128
  requestWindow = true;
96
129
  try { await use(); } finally { requestWindow = false; }
97
130
  },
98
131
 
99
- request: async ({ _writeGuardRequestWindow, request }, use) => {
132
+ // Not dependent on `context`, which Playwright refuses in beforeAll and afterAll. Each call
133
+ // is judged by the guard of the moment: the test's while its context is up, else the config alone.
134
+ request: async ({ _writeGuardRequestWindow, request, writeGuardConfig, baseURL }, use) => {
100
135
  requestWindow = false;
101
- await use(request);
136
+ const unrecorded = _judgeRequestClient(request, compileConfig(writeGuardConfig), { baseURL });
137
+ const client = () => (current ? current.guard.guardRequestClient(request, { baseURL }) : unrecorded);
138
+ await use(new Proxy(request, {
139
+ get: (obj, prop) => (typeof obj[prop] === 'function' ? (...args) => client()[prop](...args) : obj[prop]),
140
+ }));
102
141
  },
103
142
  });
104
143