@sickr/replay 0.9.0 → 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 CHANGED
@@ -246,19 +246,74 @@ function pumpNewLines(ws, offsets, opts) {
246
246
  continue;
247
247
  }
248
248
  for (const line of result.lines) {
249
- try {
250
- const event = JSON.parse(line);
251
- ws.send(JSON.stringify({ kind: 'event', event }));
252
- }
253
- catch (e) {
254
- if (opts.verbose)
255
- process.stderr.write(`sickr: skipped malformed event line (${e.message})\n`);
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
+ }
256
262
  }
257
263
  }
258
264
  offsets[runId] = result.newOffset;
259
265
  }
260
266
  writeOffsets(offsets);
261
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
+ }
262
317
  /** Decode a WebSocket `MessageEvent.data` payload into a UTF-8 string.
263
318
  * The browser WebSocket gives strings for text frames; the Node `ws`
264
319
  * package gives Buffers; some shims give ArrayBuffer/Uint8Array. Reading
package/dist/redact.js CHANGED
@@ -1,19 +1,132 @@
1
- const MASK = '‹redacted›'; // ‹redacted›
2
- // Assignment of a secret-ish-named var: KEY=..., TOKEN: "...", etc.
3
- const ASSIGN = /\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*)\s*[:=]\s*("?)([^\s"']+)\2/g;
4
- // Standalone secret shapes.
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
- /\bBearer\s+[A-Za-z0-9._\-]+/g,
7
- /\bsk-[A-Za-z0-9]{16,}/g,
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
- /\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g, // JWT
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.replace(ASSIGN, (_m, key) => `${key}=${MASK}`);
16
- for (const re of SHAPES)
17
- out = out.replace(re, MASK);
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.0",
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" },