@sickr/replay 0.9.0-beta.3 → 0.9.1
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.
- package/dist/live.js +90 -17
- package/dist/redact.js +124 -11
- package/package.json +3 -2
package/dist/live.js
CHANGED
|
@@ -183,16 +183,7 @@ async function sessionLoop(creds, urlid, opts) {
|
|
|
183
183
|
tailTimer = setInterval(() => pumpNewLines(ws, offsets, opts), 500);
|
|
184
184
|
});
|
|
185
185
|
ws.addEventListener('message', (ev) => {
|
|
186
|
-
|
|
187
|
-
// Buffer (not a string) on the browser-style addEventListener API.
|
|
188
|
-
// Without explicit decode we'd silently treat every message as '{}'.
|
|
189
|
-
let raw = '';
|
|
190
|
-
const d = ev.data;
|
|
191
|
-
if (typeof d === 'string')
|
|
192
|
-
raw = d;
|
|
193
|
-
else if (d && typeof d.toString === 'function') {
|
|
194
|
-
raw = d.toString('utf8');
|
|
195
|
-
}
|
|
186
|
+
const raw = decodeWsPayload(ev.data);
|
|
196
187
|
if (opts.verbose)
|
|
197
188
|
process.stderr.write(`sickr: ws recv (${raw.length}b): ${raw.slice(0, 120)}\n`);
|
|
198
189
|
let m;
|
|
@@ -255,19 +246,101 @@ function pumpNewLines(ws, offsets, opts) {
|
|
|
255
246
|
continue;
|
|
256
247
|
}
|
|
257
248
|
for (const line of result.lines) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
249
|
+
// Tolerant parse: one NDJSON line may contain multiple concatenated
|
|
250
|
+
// JSON objects if the OS interleaved two appendFileSync writes
|
|
251
|
+
// (observed on Windows when Claude + Codex hooks fire near-simultaneously).
|
|
252
|
+
// Split on `}{` boundaries and try each fragment.
|
|
253
|
+
for (const fragment of splitJsonObjects(line)) {
|
|
254
|
+
try {
|
|
255
|
+
const event = JSON.parse(fragment);
|
|
256
|
+
ws.send(JSON.stringify({ kind: 'event', event }));
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
if (opts.verbose)
|
|
260
|
+
process.stderr.write(`sickr: skipped malformed event fragment (${e.message})\n`);
|
|
261
|
+
}
|
|
265
262
|
}
|
|
266
263
|
}
|
|
267
264
|
offsets[runId] = result.newOffset;
|
|
268
265
|
}
|
|
269
266
|
writeOffsets(offsets);
|
|
270
267
|
}
|
|
268
|
+
/** Split a line that may contain MULTIPLE concatenated JSON objects into
|
|
269
|
+
* individual object strings. Defensive splitter that respects quotes +
|
|
270
|
+
* escapes (so a `}{` inside a string doesn't split). Used to recover from
|
|
271
|
+
* the rare case where two `appendFileSync` writes interleave on the same
|
|
272
|
+
* NDJSON file and produce `{...}{...}\n` instead of `{...}\n{...}\n`.
|
|
273
|
+
*
|
|
274
|
+
* Returns `[line]` unchanged for normal single-object lines. Exported for
|
|
275
|
+
* test coverage. */
|
|
276
|
+
export function splitJsonObjects(line) {
|
|
277
|
+
const out = [];
|
|
278
|
+
let depth = 0;
|
|
279
|
+
let inString = false;
|
|
280
|
+
let escape = false;
|
|
281
|
+
let start = 0;
|
|
282
|
+
for (let i = 0; i < line.length; i++) {
|
|
283
|
+
const c = line[i];
|
|
284
|
+
if (escape) {
|
|
285
|
+
escape = false;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (inString) {
|
|
289
|
+
if (c === '\\')
|
|
290
|
+
escape = true;
|
|
291
|
+
else if (c === '"')
|
|
292
|
+
inString = false;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (c === '"') {
|
|
296
|
+
inString = true;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (c === '{') {
|
|
300
|
+
if (depth === 0)
|
|
301
|
+
start = i;
|
|
302
|
+
depth++;
|
|
303
|
+
}
|
|
304
|
+
else if (c === '}') {
|
|
305
|
+
depth--;
|
|
306
|
+
if (depth === 0) {
|
|
307
|
+
out.push(line.slice(start, i + 1));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// No braces found at all (or unbalanced)? Return the original line so the
|
|
312
|
+
// outer JSON.parse can still try (and fail loudly if it's not JSON).
|
|
313
|
+
if (out.length === 0)
|
|
314
|
+
return [line];
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
/** Decode a WebSocket `MessageEvent.data` payload into a UTF-8 string.
|
|
318
|
+
* The browser WebSocket gives strings for text frames; the Node `ws`
|
|
319
|
+
* package gives Buffers; some shims give ArrayBuffer/Uint8Array. Reading
|
|
320
|
+
* any of those as `typeof === 'string'` silently produces empty JSON,
|
|
321
|
+
* which dropped steer messages on the CLI side in 0.9.0-beta.2 (#bug6).
|
|
322
|
+
* Always run inbound frames through this. Exported for test coverage. */
|
|
323
|
+
export function decodeWsPayload(data) {
|
|
324
|
+
if (typeof data === 'string')
|
|
325
|
+
return data;
|
|
326
|
+
if (data == null)
|
|
327
|
+
return '';
|
|
328
|
+
// Buffer / Uint8Array / ArrayBuffer all expose toString — but ArrayBuffer's
|
|
329
|
+
// default toString returns "[object ArrayBuffer]", so coerce via Uint8Array.
|
|
330
|
+
if (data instanceof ArrayBuffer)
|
|
331
|
+
return Buffer.from(new Uint8Array(data)).toString('utf8');
|
|
332
|
+
if (ArrayBuffer.isView(data))
|
|
333
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
334
|
+
if (typeof data.toString === 'function') {
|
|
335
|
+
try {
|
|
336
|
+
return data.toString('utf8');
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return '';
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return '';
|
|
343
|
+
}
|
|
271
344
|
function isAlive(pid) {
|
|
272
345
|
try {
|
|
273
346
|
process.kill(pid, 0);
|
package/dist/redact.js
CHANGED
|
@@ -1,19 +1,132 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
//
|
|
1
|
+
// Redaction layer — the SOLE filter between captured Claude / Codex
|
|
2
|
+
// hook payloads and (a) on-disk NDJSON in ~/.sickr/runs/, (b) HTML
|
|
3
|
+
// rendering, and (c) the gzipped POST to sickr.ai/api/replay.
|
|
4
|
+
//
|
|
5
|
+
// Treat every change here as security-critical. The threat model:
|
|
6
|
+
// - A user's session contains real secrets (Bearer tokens, Stripe keys,
|
|
7
|
+
// DB passwords, PEM blocks, etc.)
|
|
8
|
+
// - One `replay share` publishes the redacted transcript to a public URL
|
|
9
|
+
// - Anyone with the link (or guessing it) sees the unredacted bytes
|
|
10
|
+
//
|
|
11
|
+
// Two failure modes to guard against:
|
|
12
|
+
// 1. Variable-style assignments (`KEY=...`, `password: "..."`, JSON
|
|
13
|
+
// `"api_key":"..."`, CLI `-p<pwd>`) where the NAME hints "this is a
|
|
14
|
+
// secret"
|
|
15
|
+
// 2. Known-shape secrets that are recognisable from their format
|
|
16
|
+
// regardless of context (Stripe `sk_live_...`, GitHub `ghp_...`,
|
|
17
|
+
// Slack `xoxb-...`, PEM private keys, URL userinfo, etc.)
|
|
18
|
+
//
|
|
19
|
+
// 2026-05-31 security review surfaced gaps in both layers — see
|
|
20
|
+
// tests/redact.test.ts for the regression corpus mirroring those findings.
|
|
21
|
+
//
|
|
22
|
+
// When adding new patterns: ALWAYS pair with a positive + negative test in
|
|
23
|
+
// tests/redact.test.ts. False positives (over-redaction) are far better
|
|
24
|
+
// than false negatives (leaked secrets), but the bar for both should be
|
|
25
|
+
// raised case-by-case.
|
|
26
|
+
const MASK = '‹redacted›';
|
|
27
|
+
// ── 1. Assignments of secret-named variables ─────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// Matches both env-style (KEY=value, key=value, mysql_password=…) and
|
|
30
|
+
// JSON / object key forms ("password":"value", api_key: "value").
|
|
31
|
+
//
|
|
32
|
+
// Case-insensitive so lowercase variables hit. Char class includes
|
|
33
|
+
// [A-Za-z0-9_-] so `mysql-password`, `api-key`, `my_secret_token` all hit.
|
|
34
|
+
// The keyword list is bounded — we want anything CONTAINING key/token/
|
|
35
|
+
// secret/password/passwd/credential/auth/bearer/apikey.
|
|
36
|
+
const ASSIGN = /\b([A-Za-z0-9_-]*(?:key|token|secret|password|passwd|credential|apikey|api[_-]?key|auth|bearer)[A-Za-z0-9_-]*)\s*[:=]\s*("?)([^\s"',;)}\]]+)\2/gi;
|
|
37
|
+
// JSON-style keys ("password":"value") — separate from ASSIGN because the
|
|
38
|
+
// quoted-key+quoted-value shape isn't reliably caught by the loose ASSIGN
|
|
39
|
+
// above (which allows unquoted values). Treats `key`/`token`/etc. as the
|
|
40
|
+
// match-anywhere keyword set.
|
|
41
|
+
const JSON_KEY = /"([A-Za-z0-9_-]*(?:key|token|secret|password|passwd|credential|apikey|api[_-]?key|auth|bearer)[A-Za-z0-9_-]*)"\s*:\s*"([^"]+)"/gi;
|
|
42
|
+
// CLI -p<pwd> form — MySQL, PostgreSQL, MongoDB, basic-auth tools.
|
|
43
|
+
// Constrained: must be immediately preceded by whitespace or start-of-line
|
|
44
|
+
// AND have at least 4 chars after -p. We deliberately don't redact
|
|
45
|
+
// short -p switches (typically port numbers, not passwords).
|
|
46
|
+
const CLI_PSWITCH = /(^|\s)(-p)([^\s'"]{4,})/g;
|
|
47
|
+
// ── 2. Known-shape secrets (no surrounding context needed) ───────────────
|
|
48
|
+
//
|
|
49
|
+
// Each entry is a literal regex + a one-line provenance note. When you add
|
|
50
|
+
// one, also add a fixture line to tests/redact.test.ts to lock the shape.
|
|
5
51
|
const SHAPES = [
|
|
6
|
-
|
|
7
|
-
/\
|
|
52
|
+
// Generic OAuth-style Bearer header.
|
|
53
|
+
/\bBearer\s+[A-Za-z0-9._\-+/=]+/g,
|
|
54
|
+
// OpenAI: sk-..., sk-proj-..., sk-svcacct-...
|
|
55
|
+
/\bsk(?:-[A-Za-z0-9_-]+)?-[A-Za-z0-9_-]{16,}/g,
|
|
56
|
+
// Anthropic: sk-ant-..., sk-ant-api03-..., sk-ant-sid01-...
|
|
57
|
+
/\bsk-ant(?:-[a-z0-9]+)?-[A-Za-z0-9_-]{16,}/g,
|
|
58
|
+
// GitHub: classic personal access token (ghp_), fine-grained
|
|
59
|
+
// (github_pat_), OAuth (gho_), server-to-server (ghs_), refresh (ghr_),
|
|
60
|
+
// user-to-server (ghu_).
|
|
8
61
|
/\bghp_[A-Za-z0-9]{20,}/g,
|
|
9
62
|
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
63
|
+
/\bghs_[A-Za-z0-9]{20,}/g,
|
|
64
|
+
/\bgho_[A-Za-z0-9]{20,}/g,
|
|
65
|
+
/\bghr_[A-Za-z0-9]{20,}/g,
|
|
66
|
+
/\bghu_[A-Za-z0-9]{20,}/g,
|
|
67
|
+
// AWS Access Key ID (note: only the ID — the SECRET key has no
|
|
68
|
+
// recognisable shape and must come through ASSIGN / known env var name).
|
|
10
69
|
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
11
|
-
/\
|
|
70
|
+
/\bASIA[0-9A-Z]{16}\b/g, // STS temporary creds
|
|
71
|
+
// JWT — three base64url segments separated by dots. Anchored on the
|
|
72
|
+
// typical eyJ prefix (the base64url of `{"`).
|
|
73
|
+
/\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g,
|
|
74
|
+
// Stripe API keys: secret / restricted / publishable, live + test modes.
|
|
75
|
+
/\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
76
|
+
// Stripe webhook signing secret — no live/test split in this prefix.
|
|
77
|
+
/\bwhsec_[A-Za-z0-9]{16,}/g,
|
|
78
|
+
// Slack: bot/user/oauth/refresh/app tokens.
|
|
79
|
+
/\bxox[abprsoaur]-[A-Za-z0-9-]{10,}/g,
|
|
80
|
+
// Google API keys (and Firebase, OAuth client id collision is OK — we
|
|
81
|
+
// over-redact rather than leak).
|
|
82
|
+
/\bAIza[0-9A-Za-z_-]{35}/g,
|
|
83
|
+
// GitLab Personal Access Token.
|
|
84
|
+
/\bglpat-[A-Za-z0-9_-]{20,}/g,
|
|
85
|
+
// npm: automation + publish tokens.
|
|
86
|
+
/\bnpm_[A-Za-z0-9]{36}/g,
|
|
87
|
+
// SendGrid.
|
|
88
|
+
/\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g,
|
|
89
|
+
// Twilio: Account SID (AC...) + API Key SID (SK...).
|
|
90
|
+
/\bAC[0-9a-f]{32}\b/g,
|
|
91
|
+
/\bSK[0-9a-f]{32}\b/g,
|
|
92
|
+
// Square: bare and OAuth.
|
|
93
|
+
/\b(?:EAAA|sq0(?:atp|csp|idp))-[A-Za-z0-9_-]{20,}/g,
|
|
94
|
+
// Mailgun.
|
|
95
|
+
/\bkey-[a-f0-9]{32}\b/g,
|
|
96
|
+
// URL userinfo — https://user:password@host. Redacts the entire
|
|
97
|
+
// userinfo block; we re-emit a scheme://[redacted]@host marker so the
|
|
98
|
+
// resulting text still reads as a URL.
|
|
99
|
+
/\b(https?|ftp|ssh|git|mysql|postgres(?:ql)?|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s/@:]+:[^\s/@]+@/gi,
|
|
100
|
+
// PEM private key blocks — match the entire wrapper including header
|
|
101
|
+
// and footer. Multi-line; the `[\s\S]+?` is non-greedy so adjacent
|
|
102
|
+
// blocks don't merge.
|
|
103
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
104
|
+
// SSH private keys (OpenSSH format header).
|
|
105
|
+
/-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]+?-----END OPENSSH PRIVATE KEY-----/g,
|
|
106
|
+
// PGP private blocks.
|
|
107
|
+
/-----BEGIN PGP PRIVATE KEY BLOCK-----[\s\S]+?-----END PGP PRIVATE KEY BLOCK-----/g,
|
|
12
108
|
];
|
|
13
|
-
/** Mask secrets before anything is written to disk or shared.
|
|
109
|
+
/** Mask secrets before anything is written to disk or shared.
|
|
110
|
+
* Applied in three passes:
|
|
111
|
+
* 1. JSON-key form first (most precise)
|
|
112
|
+
* 2. ASSIGN form (env / object literal)
|
|
113
|
+
* 3. SHAPES (context-free known formats)
|
|
114
|
+
* 4. CLI -p<pwd> form last (after shape matches don't capture them)
|
|
115
|
+
* Idempotent: redact(redact(x)) == redact(x). */
|
|
14
116
|
export function redact(input) {
|
|
15
|
-
let out = input
|
|
16
|
-
|
|
17
|
-
|
|
117
|
+
let out = input;
|
|
118
|
+
out = out.replace(JSON_KEY, (_m, key) => `"${key}":"${MASK}"`);
|
|
119
|
+
out = out.replace(ASSIGN, (_m, key) => `${key}=${MASK}`);
|
|
120
|
+
for (const re of SHAPES) {
|
|
121
|
+
// Lambda preserves a URL-userinfo marker so the result still parses
|
|
122
|
+
// as a URL; everything else collapses to MASK.
|
|
123
|
+
out = out.replace(re, (m) => {
|
|
124
|
+
const urlMatch = /^([a-z]+):\/\//i.exec(m);
|
|
125
|
+
if (urlMatch && m.endsWith('@'))
|
|
126
|
+
return `${urlMatch[1]}://${MASK}@`;
|
|
127
|
+
return MASK;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
out = out.replace(CLI_PSWITCH, (_m, pre, flag) => `${pre}${flag}${MASK}`);
|
|
18
131
|
return out;
|
|
19
132
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sickr/replay",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "npx @sickr/replay — local Claude Code audit + one-click share. The free wedge into SICKR.",
|
|
6
6
|
"bin": { "replay": "dist/cli.js", "sickr": "dist/cli.js" },
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc",
|
|
11
11
|
"test": "vitest run",
|
|
12
|
-
"dev": "tsc -w"
|
|
12
|
+
"dev": "tsc -w",
|
|
13
|
+
"prepublishOnly": "npm run build && npm test && node scripts/pre-publish-check.mjs"
|
|
13
14
|
},
|
|
14
15
|
"engines": { "node": ">=20" },
|
|
15
16
|
"license": "UNLICENSED",
|