openpen 0.2.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/dist/checks/auth-bypass.d.ts +12 -0
  4. package/dist/checks/auth-bypass.js +93 -0
  5. package/dist/checks/bac.d.ts +12 -0
  6. package/dist/checks/bac.js +107 -0
  7. package/dist/checks/base.d.ts +22 -0
  8. package/dist/checks/base.js +13 -0
  9. package/dist/checks/index.d.ts +7 -0
  10. package/dist/checks/index.js +40 -0
  11. package/dist/checks/llm-leak.d.ts +23 -0
  12. package/dist/checks/llm-leak.js +251 -0
  13. package/dist/checks/mass-assignment.d.ts +12 -0
  14. package/dist/checks/mass-assignment.js +169 -0
  15. package/dist/checks/prompt-injection.d.ts +23 -0
  16. package/dist/checks/prompt-injection.js +262 -0
  17. package/dist/checks/security-headers.d.ts +12 -0
  18. package/dist/checks/security-headers.js +133 -0
  19. package/dist/checks/sensitive-data.d.ts +12 -0
  20. package/dist/checks/sensitive-data.js +122 -0
  21. package/dist/checks/sqli.d.ts +12 -0
  22. package/dist/checks/sqli.js +178 -0
  23. package/dist/checks/ssrf.d.ts +12 -0
  24. package/dist/checks/ssrf.js +126 -0
  25. package/dist/checks/xss.d.ts +12 -0
  26. package/dist/checks/xss.js +79 -0
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +300 -0
  29. package/dist/fuzzer/engine.d.ts +27 -0
  30. package/dist/fuzzer/engine.js +126 -0
  31. package/dist/fuzzer/mutator.d.ts +8 -0
  32. package/dist/fuzzer/mutator.js +54 -0
  33. package/dist/fuzzer/payloads.d.ts +13 -0
  34. package/dist/fuzzer/payloads.js +167 -0
  35. package/dist/reporter/index.d.ts +5 -0
  36. package/dist/reporter/index.js +5 -0
  37. package/dist/reporter/json.d.ts +5 -0
  38. package/dist/reporter/json.js +14 -0
  39. package/dist/reporter/terminal.d.ts +5 -0
  40. package/dist/reporter/terminal.js +59 -0
  41. package/dist/spec/openapi.d.ts +5 -0
  42. package/dist/spec/openapi.js +119 -0
  43. package/dist/spec/parser.d.ts +11 -0
  44. package/dist/spec/parser.js +45 -0
  45. package/dist/types.d.ts +145 -0
  46. package/dist/types.js +4 -0
  47. package/dist/utils/http.d.ts +37 -0
  48. package/dist/utils/http.js +92 -0
  49. package/dist/utils/logger.d.ts +8 -0
  50. package/dist/utils/logger.js +20 -0
  51. package/dist/ws/checks.d.ts +18 -0
  52. package/dist/ws/checks.js +558 -0
  53. package/dist/ws/engine.d.ts +47 -0
  54. package/dist/ws/engine.js +139 -0
  55. package/package.json +41 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * WebSocket security checks
3
+ * Built-in protocol tests for common WS vulnerabilities
4
+ */
5
+ import { connect, sendAndWait, sendRaw, close, parseJson } from './engine.js';
6
+ import { verbose } from '../utils/logger.js';
7
+ /**
8
+ * Check: Unauthenticated access
9
+ * Tests if the server accepts connections and responds to commands without auth
10
+ */
11
+ const unauthAccess = {
12
+ info: {
13
+ id: 'ws-unauth',
14
+ name: 'Unauthenticated Access',
15
+ description: 'Tests if server responds to commands without authentication',
16
+ },
17
+ async run(url, timeout) {
18
+ const start = Date.now();
19
+ try {
20
+ const conn = await connect(url, { timeout });
21
+ // Try common discovery commands without auth
22
+ const probes = [
23
+ '{"type":"LIST_CHANNELS"}',
24
+ '{"type":"LIST_AGENTS","channel":"#general"}',
25
+ '{"type":"SEARCH_SKILLS"}',
26
+ '{"action":"list"}',
27
+ '{"cmd":"help"}',
28
+ ];
29
+ const responses = [];
30
+ for (const probe of probes) {
31
+ const resp = await sendAndWait(conn, probe, { timeout: 1500 });
32
+ if (resp) {
33
+ responses.push(resp);
34
+ verbose(` [ws-unauth] ${probe} -> ${resp.slice(0, 120)}`);
35
+ }
36
+ }
37
+ await close(conn);
38
+ const successResponses = responses.filter(r => {
39
+ const p = parseJson(r);
40
+ if (!p.parsed)
41
+ return false;
42
+ const v = p.value;
43
+ return v.type !== 'ERROR' && !v.error;
44
+ });
45
+ if (successResponses.length > 0) {
46
+ return {
47
+ checkId: 'ws-unauth',
48
+ checkName: 'Unauthenticated Access',
49
+ status: 'fail',
50
+ severity: 'high',
51
+ description: `Server responded to ${successResponses.length} commands without authentication`,
52
+ evidence: successResponses.map(r => r.slice(0, 200)).join('\n'),
53
+ remediation: 'Require authentication before processing any commands',
54
+ duration: Date.now() - start,
55
+ };
56
+ }
57
+ return {
58
+ checkId: 'ws-unauth',
59
+ checkName: 'Unauthenticated Access',
60
+ status: 'pass',
61
+ severity: 'info',
62
+ description: 'Server requires authentication for commands',
63
+ evidence: `${probes.length} probes sent, all rejected or ignored`,
64
+ duration: Date.now() - start,
65
+ };
66
+ }
67
+ catch (e) {
68
+ return {
69
+ checkId: 'ws-unauth',
70
+ checkName: 'Unauthenticated Access',
71
+ status: 'error',
72
+ severity: 'info',
73
+ description: `Connection failed: ${e.message}`,
74
+ evidence: '',
75
+ duration: Date.now() - start,
76
+ };
77
+ }
78
+ },
79
+ };
80
+ /**
81
+ * Check: Message type enumeration
82
+ * Discovers valid message types by probing common names
83
+ */
84
+ const typeEnumeration = {
85
+ info: {
86
+ id: 'ws-enum',
87
+ name: 'Message Type Enumeration',
88
+ description: 'Discovers valid protocol message types',
89
+ },
90
+ async run(url, timeout) {
91
+ const start = Date.now();
92
+ try {
93
+ const conn = await connect(url, { timeout });
94
+ const types = [
95
+ 'IDENTIFY', 'LOGIN', 'AUTH', 'HELLO', 'REGISTER',
96
+ 'JOIN', 'LEAVE', 'MSG', 'MESSAGE', 'SEND',
97
+ 'PING', 'PONG', 'HEARTBEAT',
98
+ 'LIST', 'LIST_CHANNELS', 'LIST_AGENTS', 'LIST_USERS', 'LIST_ROOMS',
99
+ 'CREATE', 'CREATE_CHANNEL', 'CREATE_ROOM',
100
+ 'DELETE', 'REMOVE', 'BAN', 'KICK',
101
+ 'SUBSCRIBE', 'UNSUBSCRIBE', 'PUBLISH',
102
+ 'PROPOSAL', 'ACCEPT', 'REJECT', 'COMPLETE', 'DISPUTE',
103
+ 'REGISTER_SKILLS', 'SEARCH_SKILLS',
104
+ 'SET_PRESENCE', 'STATUS',
105
+ 'VERIFY_REQUEST', 'VERIFY_RESPONSE',
106
+ 'INVITE', 'TRANSFER', 'ADMIN',
107
+ ];
108
+ const recognized = [];
109
+ const errors = [];
110
+ for (const type of types) {
111
+ const resp = await sendAndWait(conn, JSON.stringify({ type }), { timeout: 500 });
112
+ if (resp) {
113
+ const p = parseJson(resp);
114
+ if (p.parsed) {
115
+ const v = p.value;
116
+ if (v.type === 'ERROR') {
117
+ const msg = (v.message || v.error || '');
118
+ // "Unknown message type" means it's NOT recognized
119
+ if (!/unknown.*type/i.test(msg)) {
120
+ recognized.push(type);
121
+ verbose(` [ws-enum] ${type} -> recognized (error: ${msg})`);
122
+ }
123
+ }
124
+ else {
125
+ recognized.push(type);
126
+ verbose(` [ws-enum] ${type} -> recognized (response: ${v.type})`);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ await close(conn);
132
+ return {
133
+ checkId: 'ws-enum',
134
+ checkName: 'Message Type Enumeration',
135
+ status: recognized.length > 0 ? 'warn' : 'pass',
136
+ severity: 'info',
137
+ description: `Discovered ${recognized.length} valid message types out of ${types.length} probed`,
138
+ evidence: recognized.length > 0
139
+ ? `Valid types: ${recognized.join(', ')}`
140
+ : 'No types discovered (server may require auth first)',
141
+ remediation: 'Ensure sensitive operations are gated behind authentication',
142
+ duration: Date.now() - start,
143
+ };
144
+ }
145
+ catch (e) {
146
+ return {
147
+ checkId: 'ws-enum',
148
+ checkName: 'Message Type Enumeration',
149
+ status: 'error',
150
+ severity: 'info',
151
+ description: `Connection failed: ${e.message}`,
152
+ evidence: '',
153
+ duration: Date.now() - start,
154
+ };
155
+ }
156
+ },
157
+ };
158
+ /**
159
+ * Check: Message size limit
160
+ * Tests if the server enforces payload size limits
161
+ */
162
+ const messageSizeLimit = {
163
+ info: {
164
+ id: 'ws-size',
165
+ name: 'Message Size Limit',
166
+ description: 'Tests if the server enforces WebSocket frame size limits',
167
+ },
168
+ async run(url, timeout) {
169
+ const start = Date.now();
170
+ const sizes = [
171
+ { label: '1KB', size: 1024 },
172
+ { label: '64KB', size: 64 * 1024 },
173
+ { label: '256KB', size: 256 * 1024 },
174
+ { label: '1MB', size: 1024 * 1024 },
175
+ { label: '5MB', size: 5 * 1024 * 1024 },
176
+ ];
177
+ let maxAccepted = 0;
178
+ let maxAcceptedLabel = 'none';
179
+ try {
180
+ for (const { label, size } of sizes) {
181
+ try {
182
+ const conn = await connect(url, { timeout });
183
+ const payload = JSON.stringify({ type: 'PING', data: 'x'.repeat(size) });
184
+ sendRaw(conn, payload);
185
+ // Wait for close event; scale with payload size for proxy latency
186
+ const waitMs = Math.max(2000, Math.min(Math.ceil(size / 256), 6000));
187
+ await new Promise(r => setTimeout(r, waitMs));
188
+ // Check for error responses (server may reject via error message instead of disconnect)
189
+ const errorResponse = conn.messages.find(m => {
190
+ if (m.direction !== 'received')
191
+ return false;
192
+ const p = parseJson(m.data);
193
+ if (!p.parsed)
194
+ return false;
195
+ const v = p.value;
196
+ const msg = (v.message || '').toLowerCase();
197
+ return (v.type === 'ERROR' && (msg.includes('too large') || msg.includes('size')));
198
+ });
199
+ if (errorResponse) {
200
+ verbose(` [ws-size] ${label} -> rejected (error response)`);
201
+ }
202
+ else if (conn.connected) {
203
+ maxAccepted = size;
204
+ maxAcceptedLabel = label;
205
+ verbose(` [ws-size] ${label} -> accepted`);
206
+ }
207
+ else {
208
+ verbose(` [ws-size] ${label} -> connection closed (code: ${conn.closedCode})`);
209
+ }
210
+ await close(conn);
211
+ }
212
+ catch {
213
+ verbose(` [ws-size] ${label} -> rejected/failed`);
214
+ break;
215
+ }
216
+ }
217
+ if (maxAccepted >= 1024 * 1024) {
218
+ return {
219
+ checkId: 'ws-size',
220
+ checkName: 'Message Size Limit',
221
+ status: 'fail',
222
+ severity: 'medium',
223
+ description: `Server accepts messages up to ${maxAcceptedLabel} without limit`,
224
+ evidence: `Max accepted payload: ${maxAcceptedLabel} (${maxAccepted} bytes)`,
225
+ remediation: 'Set maxPayload on WebSocket server (recommended: 256KB or less)',
226
+ duration: Date.now() - start,
227
+ };
228
+ }
229
+ if (maxAccepted >= 256 * 1024) {
230
+ return {
231
+ checkId: 'ws-size',
232
+ checkName: 'Message Size Limit',
233
+ status: 'warn',
234
+ severity: 'low',
235
+ description: `Server accepts messages up to ${maxAcceptedLabel}`,
236
+ evidence: `Max accepted payload: ${maxAcceptedLabel} (${maxAccepted} bytes)`,
237
+ remediation: 'Consider lowering maxPayload if protocol messages are small',
238
+ duration: Date.now() - start,
239
+ };
240
+ }
241
+ return {
242
+ checkId: 'ws-size',
243
+ checkName: 'Message Size Limit',
244
+ status: 'pass',
245
+ severity: 'info',
246
+ description: `Server enforces message size limit (max accepted: ${maxAcceptedLabel})`,
247
+ evidence: `Largest accepted: ${maxAcceptedLabel} (${maxAccepted} bytes)`,
248
+ duration: Date.now() - start,
249
+ };
250
+ }
251
+ catch (e) {
252
+ return {
253
+ checkId: 'ws-size',
254
+ checkName: 'Message Size Limit',
255
+ status: 'error',
256
+ severity: 'info',
257
+ description: `Test failed: ${e.message}`,
258
+ evidence: '',
259
+ duration: Date.now() - start,
260
+ };
261
+ }
262
+ },
263
+ };
264
+ /**
265
+ * Check: Rate limiting
266
+ * Tests if the server limits message frequency
267
+ */
268
+ const rateLimiting = {
269
+ info: {
270
+ id: 'ws-rate',
271
+ name: 'Rate Limiting',
272
+ description: 'Tests if the server enforces message rate limits',
273
+ },
274
+ async run(url, timeout) {
275
+ const start = Date.now();
276
+ try {
277
+ const conn = await connect(url, { timeout });
278
+ // Send 20 messages as fast as possible
279
+ const burstCount = 20;
280
+ const startBurst = Date.now();
281
+ for (let i = 0; i < burstCount; i++) {
282
+ sendRaw(conn, JSON.stringify({ type: 'PING', seq: i }));
283
+ }
284
+ // Wait for responses
285
+ await new Promise(r => setTimeout(r, 2000));
286
+ const burstDuration = Date.now() - startBurst;
287
+ // Check for rate limit errors in messages
288
+ const rateLimitErrors = conn.messages.filter(m => {
289
+ if (m.direction !== 'received')
290
+ return false;
291
+ const p = parseJson(m.data);
292
+ if (!p.parsed)
293
+ return false;
294
+ const v = p.value;
295
+ const msg = (v.message || v.error || '').toLowerCase();
296
+ return msg.includes('rate') || msg.includes('limit') || msg.includes('throttl') || msg.includes('too many');
297
+ });
298
+ // Also check if connection was closed (server may disconnect instead of sending error)
299
+ const disconnected = !conn.connected;
300
+ const rateLimitClose = disconnected && (conn.closedCode === 1008 ||
301
+ (conn.closedReason || '').toLowerCase().includes('rate limit'));
302
+ await close(conn);
303
+ if (rateLimitErrors.length > 0 || rateLimitClose) {
304
+ const evidence = rateLimitClose
305
+ ? `Connection closed with code ${conn.closedCode}: ${conn.closedReason}`
306
+ : rateLimitErrors[0]?.data.slice(0, 200) || '';
307
+ return {
308
+ checkId: 'ws-rate',
309
+ checkName: 'Rate Limiting',
310
+ status: 'pass',
311
+ severity: 'info',
312
+ description: `Server enforces rate limiting (${rateLimitErrors.length > 0 ? rateLimitErrors.length + ' rate limit responses' : 'connection disconnected'} for ${burstCount} burst messages)`,
313
+ evidence,
314
+ duration: Date.now() - start,
315
+ };
316
+ }
317
+ return {
318
+ checkId: 'ws-rate',
319
+ checkName: 'Rate Limiting',
320
+ status: 'warn',
321
+ severity: 'medium',
322
+ description: `No rate limiting detected for ${burstCount} rapid messages in ${burstDuration}ms`,
323
+ evidence: `Sent ${burstCount} messages, received ${conn.messages.filter(m => m.direction === 'received').length} responses, no rate limit errors or disconnection`,
324
+ remediation: 'Implement per-connection rate limiting to prevent message flooding',
325
+ duration: Date.now() - start,
326
+ };
327
+ }
328
+ catch (e) {
329
+ return {
330
+ checkId: 'ws-rate',
331
+ checkName: 'Rate Limiting',
332
+ status: 'error',
333
+ severity: 'info',
334
+ description: `Test failed: ${e.message}`,
335
+ evidence: '',
336
+ duration: Date.now() - start,
337
+ };
338
+ }
339
+ },
340
+ };
341
+ /**
342
+ * Check: Connection flood
343
+ * Tests if the server limits concurrent connections from one source
344
+ */
345
+ const connectionFlood = {
346
+ info: {
347
+ id: 'ws-flood',
348
+ name: 'Connection Flood',
349
+ description: 'Tests if the server limits concurrent connections per IP',
350
+ },
351
+ async run(url, timeout) {
352
+ const start = Date.now();
353
+ const maxConns = 15;
354
+ const conns = [];
355
+ let rejected = false;
356
+ let rejectedAt = 0;
357
+ try {
358
+ for (let i = 0; i < maxConns; i++) {
359
+ try {
360
+ const conn = await connect(url, { timeout: 2000 });
361
+ conns.push(conn);
362
+ verbose(` [ws-flood] Connection ${i + 1} opened`);
363
+ }
364
+ catch {
365
+ rejected = true;
366
+ rejectedAt = i + 1;
367
+ verbose(` [ws-flood] Connection ${i + 1} rejected`);
368
+ break;
369
+ }
370
+ }
371
+ // Also check if any connections were forcibly closed
372
+ const closedConns = conns.filter(c => c && !c.connected);
373
+ // Clean up
374
+ for (const conn of conns) {
375
+ if (conn)
376
+ await close(conn);
377
+ }
378
+ if (rejected || closedConns.length > 0) {
379
+ const limit = rejected ? rejectedAt : conns.length;
380
+ return {
381
+ checkId: 'ws-flood',
382
+ checkName: 'Connection Flood',
383
+ status: 'pass',
384
+ severity: 'info',
385
+ description: `Server limits connections per IP (limit ~${limit})`,
386
+ evidence: rejected
387
+ ? `Connection ${rejectedAt} was rejected`
388
+ : `${closedConns.length} connections were forcibly closed`,
389
+ duration: Date.now() - start,
390
+ };
391
+ }
392
+ return {
393
+ checkId: 'ws-flood',
394
+ checkName: 'Connection Flood',
395
+ status: 'warn',
396
+ severity: 'medium',
397
+ description: `Server accepted all ${maxConns} concurrent connections without limiting`,
398
+ evidence: `${conns.length} simultaneous connections from same IP were accepted`,
399
+ remediation: 'Implement per-IP connection limiting (recommended: 10-20 max)',
400
+ duration: Date.now() - start,
401
+ };
402
+ }
403
+ catch (e) {
404
+ // Clean up on error
405
+ for (const conn of conns) {
406
+ if (conn)
407
+ await close(conn).catch(() => { });
408
+ }
409
+ return {
410
+ checkId: 'ws-flood',
411
+ checkName: 'Connection Flood',
412
+ status: 'error',
413
+ severity: 'info',
414
+ description: `Test failed: ${e.message}`,
415
+ evidence: '',
416
+ duration: Date.now() - start,
417
+ };
418
+ }
419
+ },
420
+ };
421
+ /**
422
+ * Check: Invalid message handling
423
+ * Tests how server handles malformed/malicious input
424
+ */
425
+ const invalidMessages = {
426
+ info: {
427
+ id: 'ws-invalid',
428
+ name: 'Invalid Message Handling',
429
+ description: 'Tests server response to malformed and malicious payloads',
430
+ },
431
+ async run(url, timeout) {
432
+ const start = Date.now();
433
+ try {
434
+ const conn = await connect(url, { timeout });
435
+ // Keep to 8 payloads to stay under pre-auth rate limits
436
+ const payloads = [
437
+ // Malformed JSON
438
+ '{not valid json',
439
+ '{"type":',
440
+ '',
441
+ // Injection attempts
442
+ '{"type":"<script>alert(1)</script>"}',
443
+ '{"type":"IDENTIFY","name":"test\'; DROP TABLE users; --"}',
444
+ // Type confusion
445
+ '{"type":123}',
446
+ '{"type":null}',
447
+ // Prototype pollution
448
+ '{"__proto__":{"admin":true}}',
449
+ ];
450
+ let crashed = false;
451
+ let rateLimited = false;
452
+ let errorResponses = 0;
453
+ let silentDrops = 0;
454
+ for (const payload of payloads) {
455
+ try {
456
+ sendRaw(conn, payload);
457
+ await new Promise(r => setTimeout(r, 300));
458
+ if (!conn.connected) {
459
+ // Distinguish rate limiting from actual crash
460
+ if (conn.closedCode === 1008 || (conn.closedReason || '').toLowerCase().includes('rate limit')) {
461
+ rateLimited = true;
462
+ verbose(` [ws-invalid] Rate limited after: ${payload.slice(0, 60)}`);
463
+ }
464
+ else {
465
+ crashed = true;
466
+ verbose(` [ws-invalid] Server closed connection after: ${payload.slice(0, 60)}`);
467
+ }
468
+ break;
469
+ }
470
+ }
471
+ catch {
472
+ crashed = true;
473
+ break;
474
+ }
475
+ }
476
+ // Count error responses
477
+ errorResponses = conn.messages.filter(m => {
478
+ if (m.direction !== 'received')
479
+ return false;
480
+ const p = parseJson(m.data);
481
+ if (!p.parsed)
482
+ return false;
483
+ const v = p.value;
484
+ return v.type === 'ERROR' || v.error;
485
+ }).length;
486
+ silentDrops = payloads.length - errorResponses - (crashed || rateLimited ? 1 : 0);
487
+ await close(conn);
488
+ if (rateLimited) {
489
+ return {
490
+ checkId: 'ws-invalid',
491
+ checkName: 'Invalid Message Handling',
492
+ status: 'pass',
493
+ severity: 'info',
494
+ description: `Server rate-limited after ${conn.messages.length} malformed messages (expected behavior)`,
495
+ evidence: `Connection closed: code ${conn.closedCode}, ${errorResponses} error responses before disconnect`,
496
+ duration: Date.now() - start,
497
+ };
498
+ }
499
+ if (crashed) {
500
+ return {
501
+ checkId: 'ws-invalid',
502
+ checkName: 'Invalid Message Handling',
503
+ status: 'fail',
504
+ severity: 'medium',
505
+ description: 'Server crashed or disconnected on malformed input',
506
+ evidence: `Connection dropped (code: ${conn.closedCode}, reason: ${conn.closedReason}), ${conn.messages.length} messages exchanged`,
507
+ remediation: 'Gracefully handle all malformed input without closing the connection',
508
+ duration: Date.now() - start,
509
+ };
510
+ }
511
+ return {
512
+ checkId: 'ws-invalid',
513
+ checkName: 'Invalid Message Handling',
514
+ status: 'pass',
515
+ severity: 'info',
516
+ description: `Server handled ${payloads.length} malformed payloads gracefully`,
517
+ evidence: `${errorResponses} error responses, ${silentDrops} silently dropped, connection stable`,
518
+ duration: Date.now() - start,
519
+ };
520
+ }
521
+ catch (e) {
522
+ return {
523
+ checkId: 'ws-invalid',
524
+ checkName: 'Invalid Message Handling',
525
+ status: 'error',
526
+ severity: 'info',
527
+ description: `Test failed: ${e.message}`,
528
+ evidence: '',
529
+ duration: Date.now() - start,
530
+ };
531
+ }
532
+ },
533
+ };
534
+ // Registry
535
+ const ALL_WS_CHECKS = [
536
+ unauthAccess,
537
+ typeEnumeration,
538
+ messageSizeLimit,
539
+ rateLimiting,
540
+ connectionFlood,
541
+ invalidMessages,
542
+ ];
543
+ export function getAllWsChecks() {
544
+ return ALL_WS_CHECKS;
545
+ }
546
+ export function getWsChecksByIds(ids) {
547
+ const checks = [];
548
+ for (const id of ids) {
549
+ const check = ALL_WS_CHECKS.find(c => c.info.id === id);
550
+ if (!check)
551
+ throw new Error(`Unknown WebSocket check: ${id}`);
552
+ checks.push(check);
553
+ }
554
+ return checks;
555
+ }
556
+ export function listWsChecks() {
557
+ return ALL_WS_CHECKS.map(c => c.info);
558
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * WebSocket test engine
3
+ * Handles connections, message exchange, and response collection
4
+ */
5
+ import WebSocket from 'ws';
6
+ import type { WsMessage } from '../types.js';
7
+ export interface WsConnection {
8
+ ws: WebSocket;
9
+ messages: WsMessage[];
10
+ connected: boolean;
11
+ closedCode?: number;
12
+ closedReason?: string;
13
+ }
14
+ /**
15
+ * Open a WebSocket connection to the target
16
+ */
17
+ export declare function connect(url: string, options?: {
18
+ timeout?: number;
19
+ headers?: Record<string, string>;
20
+ }): Promise<WsConnection>;
21
+ /**
22
+ * Send a message and wait for a response
23
+ */
24
+ export declare function sendAndWait(conn: WsConnection, data: string, options?: {
25
+ timeout?: number;
26
+ matchFn?: (msg: string) => boolean;
27
+ }): Promise<string | null>;
28
+ /**
29
+ * Send raw data (string or buffer) without recording
30
+ */
31
+ export declare function sendRaw(conn: WsConnection, data: string | Buffer): void;
32
+ /**
33
+ * Close a connection gracefully
34
+ */
35
+ export declare function close(conn: WsConnection): Promise<void>;
36
+ /**
37
+ * Wait for a specific number of messages or timeout
38
+ */
39
+ export declare function waitForMessages(conn: WsConnection, count: number, timeout?: number): Promise<WsMessage[]>;
40
+ /**
41
+ * Try to parse a message as JSON
42
+ */
43
+ export declare function parseJson(data: string): {
44
+ parsed: boolean;
45
+ value?: unknown;
46
+ error?: string;
47
+ };