Skip to content

Commit 853ded2

Browse files
committed
Harden security artifact review handling
1 parent 3e01e48 commit 853ded2

4 files changed

Lines changed: 107 additions & 15 deletions

File tree

.github/workflows/security.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ jobs:
5656
node-version-file: .nvmrc
5757
cache: pnpm
5858
- run: pnpm install --frozen-lockfile
59+
- run: node --test scripts/security/*.test.mjs
5960
- run: pnpm -C apps/pythinker-code run prepack
6061
- run: pnpm -C apps/desktop run package
6162
- run: pnpm -C docs run build

docs/security/dependency-remediation-2026-08.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Advisories. The remediated lockfile returns zero vulnerabilities at every severi
1010
An unreachable advisory is still a valid dependency finding. Reachability affects urgency and
1111
test scope. It does not change an advisory to a false positive.
1212

13-
## Evidence Authority
13+
## Evidence authority
1414

1515
Use evidence in this order:
1616

@@ -21,7 +21,7 @@ Use evidence in this order:
2121

2222
Discovery sources can corroborate a result. They do not establish a safe version floor.
2323

24-
## Remediation Ownership
24+
## Remediation ownership
2525

2626
| Phase | Package families |
2727
| --- | --- |
@@ -32,7 +32,7 @@ Discovery sources can corroborate a result. They do not establish a safe version
3232

3333
Each family has one phase owner. Browser DOMPurify work includes both Mermaid and Monaco.
3434

35-
## Reachability Record
35+
## Reachability record
3636

3737
| Code | Family | Repository evidence and reachability |
3838
| --- | --- | --- |
@@ -48,7 +48,7 @@ Each family has one phase owner. Browser DOMPurify work includes both Mermaid an
4848
| R10 | Monaco | The web editor loads Monaco. Monaco 0.55.1 embedded DOMPurify 3.2.7 in its ESM, development, and minified distributions. A pnpm patch replaces the ESM implementation with DOMPurify 3.4.14 and removes CommonJS metadata. Post-install pruning removes the two unused vulnerable distributions. A lockfile override alone is not accepted. |
4949
| R11 | Tooling and package families | brace-expansion, js-yaml, Vite, esbuild, PostCSS, nanoid, linkify-it, qs, and body-parser occur in build, package, docs, test, or transitive runtime graphs. Full-workspace audit and artifact checks cover them even when production-only audit classification excludes them. |
5050

51-
## Verification Contract
51+
## Verification contract
5252

5353
The following checks are required:
5454

@@ -61,7 +61,7 @@ The following checks are required:
6161
The browser check does not rely only on license banners or version strings. It verifies that event
6262
handlers, script elements, and `javascript:` URLs cannot execute or remain active in rendered DOM.
6363

64-
## Alert and Recurrence Policy
64+
## Alert and recurrence policy
6565

6666
- Pull requests block every net-new dependency vulnerability, at every severity.
6767
- Critical and High disclosures receive triage within 24 hours and a fix target of 72 hours.

scripts/security/check-built-browser.mjs

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,35 @@ class CdpClient {
253253
constructor(url) {
254254
this.nextId = 1;
255255
this.pending = new Map();
256+
this.failure = null;
256257
this.socket = new WebSocket(url);
258+
this.socket.addEventListener('close', () => {
259+
this.rejectPending(new Error('CDP WebSocket closed.'));
260+
});
261+
this.socket.addEventListener('error', () => {
262+
this.rejectPending(new Error('CDP WebSocket failed.'));
263+
});
257264
}
258265

259266
async connect() {
267+
if (this.failure) throw this.failure;
260268
await new Promise((resolveOpen, reject) => {
261-
this.socket.addEventListener('open', resolveOpen, { once: true });
262-
this.socket.addEventListener('error', reject, { once: true });
269+
const cleanup = () => {
270+
this.socket.removeEventListener('open', onOpen);
271+
this.socket.removeEventListener('close', onFailure);
272+
this.socket.removeEventListener('error', onFailure);
273+
};
274+
const onOpen = () => {
275+
cleanup();
276+
resolveOpen();
277+
};
278+
const onFailure = () => {
279+
cleanup();
280+
reject(this.failure ?? new Error('CDP WebSocket failed before opening.'));
281+
};
282+
this.socket.addEventListener('open', onOpen, { once: true });
283+
this.socket.addEventListener('close', onFailure, { once: true });
284+
this.socket.addEventListener('error', onFailure, { once: true });
263285
});
264286
this.socket.addEventListener('message', (event) => {
265287
const message = JSON.parse(String(event.data));
@@ -273,14 +295,30 @@ class CdpClient {
273295
}
274296

275297
call(method, params = {}) {
298+
if (this.failure) return Promise.reject(this.failure);
299+
if (this.socket.readyState !== WebSocket.OPEN) {
300+
return Promise.reject(new Error('CDP WebSocket is not open.'));
301+
}
276302
const id = this.nextId++;
277303
return new Promise((resolveCall, reject) => {
278304
this.pending.set(id, { resolve: resolveCall, reject });
279-
this.socket.send(JSON.stringify({ id, method, params }));
305+
try {
306+
this.socket.send(JSON.stringify({ id, method, params }));
307+
} catch (error) {
308+
this.pending.delete(id);
309+
reject(error instanceof Error ? error : new Error(String(error)));
310+
}
280311
});
281312
}
282313

314+
rejectPending(error) {
315+
this.failure ??= error;
316+
for (const pending of this.pending.values()) pending.reject(this.failure);
317+
this.pending.clear();
318+
}
319+
283320
close() {
321+
this.rejectPending(new Error('CDP WebSocket closed.'));
284322
this.socket.close();
285323
}
286324
}
@@ -454,11 +492,15 @@ async function main() {
454492
}
455493
}
456494

457-
try {
458-
await main();
459-
} catch (error) {
460-
process.stderr.write(
461-
`Built browser security failed: ${error instanceof Error ? error.message : String(error)}\n`,
462-
);
463-
process.exitCode = 1;
495+
if (process.argv[1] && resolve(process.argv[1]) === import.meta.filename) {
496+
try {
497+
await main();
498+
} catch (error) {
499+
process.stderr.write(
500+
`Built browser security failed: ${error instanceof Error ? error.message : String(error)}\n`,
501+
);
502+
process.exitCode = 1;
503+
}
464504
}
505+
506+
export { CdpClient };
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { CdpClient } from './check-built-browser.mjs';
5+
6+
class FakeWebSocket extends EventTarget {
7+
static OPEN = 1;
8+
9+
readyState = FakeWebSocket.OPEN;
10+
11+
send() {}
12+
13+
close() {
14+
this.dispatchEvent(new Event('close'));
15+
}
16+
}
17+
18+
async function expectPendingCallRejection(eventName) {
19+
const OriginalWebSocket = globalThis.WebSocket;
20+
globalThis.WebSocket = FakeWebSocket;
21+
try {
22+
const client = new CdpClient('ws://artifact-security.test');
23+
const pending = client.call('Runtime.enable');
24+
client.socket.dispatchEvent(new Event(eventName));
25+
for (const call of [pending, client.call('Page.enable')]) {
26+
await assert.rejects(
27+
Promise.race([
28+
call,
29+
new Promise((_, reject) => {
30+
setTimeout(() => {
31+
reject(new Error('CDP call did not reject.'));
32+
}, 50);
33+
}),
34+
]),
35+
/CDP WebSocket (closed|failed)/,
36+
);
37+
}
38+
} finally {
39+
globalThis.WebSocket = OriginalWebSocket;
40+
}
41+
}
42+
43+
void test('rejects pending calls when the CDP socket closes', async () => {
44+
await expectPendingCallRejection('close');
45+
});
46+
47+
void test('rejects pending calls when the CDP socket fails', async () => {
48+
await expectPendingCallRejection('error');
49+
});

0 commit comments

Comments
 (0)