@giwonn/claude-daily-review 0.4.0 → 0.4.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/.claude-plugin/marketplace.json +1 -1
- package/README.ko.md +34 -0
- package/README.md +35 -0
- package/commands/daily-review-setup.md +20 -0
- package/docs/superpowers/plans/2026-03-30-secret-redaction.md +1042 -0
- package/docs/superpowers/specs/2026-03-30-secret-redaction-design.md +321 -0
- package/hooks/on-stop.mjs +2 -1
- package/hooks/recover-sessions.mjs +3 -1
- package/lib/config.mjs +3 -0
- package/lib/raw-logger.mjs +8 -4
- package/lib/sanitizer.mjs +184 -0
- package/lib/sanitizer.test.mjs +333 -0
- package/lib/types.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
# Secret Redaction & Security Disclosure Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 대화 로그 저장 시 API 키, 토큰, 비밀번호 등 시크릿을 자동 마스킹하고, README에 보안 고지를 추가한다.
|
|
6
|
+
|
|
7
|
+
**Architecture:** 새 모듈 `lib/sanitizer.mjs`가 4개의 탐지 레이어(알려진 키 패턴, 구조적 시크릿, key=value, 고엔트로피)를 순차 적용하여 시크릿을 `[REDACTED:tag]` 형식으로 대체한다. `raw-logger.mjs`와 `recover-sessions.mjs`에서 저장 전에 호출한다.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js ESM, `node:test` (built-in test runner), 외부 의존성 없음
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-03-30-secret-redaction-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Structure
|
|
16
|
+
|
|
17
|
+
| File | Action | Responsibility |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `lib/sanitizer.mjs` | Create | 4-layer 시크릿 탐지 및 마스킹 순수 함수 |
|
|
20
|
+
| `lib/sanitizer.test.mjs` | Create | sanitizer 단위 테스트 (node:test) |
|
|
21
|
+
| `lib/types.d.ts` | Modify | `PrivacyConfig` 타입 추가, `Config`에 `privacy?` 필드 추가 |
|
|
22
|
+
| `lib/config.mjs` | Modify | default config 생성자에 `privacy` 추가 |
|
|
23
|
+
| `lib/raw-logger.mjs` | Modify | `appendRawLog`에서 sanitize 호출 |
|
|
24
|
+
| `hooks/recover-sessions.mjs` | Modify | 복구 시 sanitize 호출 |
|
|
25
|
+
| `commands/daily-review-setup.md` | Modify | 기존 레포 public 체크 + 경고 추가 |
|
|
26
|
+
| `README.md` | Modify | Security & Privacy 섹션 추가 |
|
|
27
|
+
| `README.ko.md` | Modify | 보안 및 개인정보 섹션 추가 |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### Task 1: sanitizer Layer 1 — Known Service Key Patterns
|
|
32
|
+
|
|
33
|
+
**Files:**
|
|
34
|
+
- Create: `lib/sanitizer.mjs`
|
|
35
|
+
- Create: `lib/sanitizer.test.mjs`
|
|
36
|
+
|
|
37
|
+
- [ ] **Step 1: Write failing tests for Layer 1**
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
// lib/sanitizer.test.mjs
|
|
41
|
+
import { describe, it } from 'node:test';
|
|
42
|
+
import assert from 'node:assert/strict';
|
|
43
|
+
import { sanitize } from './sanitizer.mjs';
|
|
44
|
+
|
|
45
|
+
describe('Layer 1: Known Service Key Patterns', () => {
|
|
46
|
+
it('redacts OpenAI API key', () => {
|
|
47
|
+
const input = 'my key is sk-proj-abc123def456ghi789jkl012mno';
|
|
48
|
+
const result = sanitize(input);
|
|
49
|
+
assert.ok(!result.includes('sk-proj-'));
|
|
50
|
+
assert.ok(result.includes('[REDACTED:openai_key]'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('redacts OpenAI legacy key', () => {
|
|
54
|
+
const input = 'sk-' + 'a'.repeat(48);
|
|
55
|
+
const result = sanitize(input);
|
|
56
|
+
assert.ok(result.includes('[REDACTED:openai_key]'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('redacts Anthropic API key', () => {
|
|
60
|
+
const input = 'key: sk-ant-api03-abcdef123456789012345678901234567890';
|
|
61
|
+
const result = sanitize(input);
|
|
62
|
+
assert.ok(result.includes('[REDACTED:anthropic_key]'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('redacts GitHub PAT', () => {
|
|
66
|
+
const input = 'token: ghp_' + 'A'.repeat(36);
|
|
67
|
+
const result = sanitize(input);
|
|
68
|
+
assert.ok(result.includes('[REDACTED:github_token]'));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('redacts GitHub OAuth token', () => {
|
|
72
|
+
const input = 'gho_' + 'B'.repeat(36);
|
|
73
|
+
const result = sanitize(input);
|
|
74
|
+
assert.ok(result.includes('[REDACTED:github_token]'));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('redacts AWS access key', () => {
|
|
78
|
+
const input = 'aws_access_key_id: AKIAIOSFODNN7EXAMPLE';
|
|
79
|
+
const result = sanitize(input);
|
|
80
|
+
assert.ok(result.includes('[REDACTED:aws_key]'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('redacts Slack token', () => {
|
|
84
|
+
const input = 'SLACK_TOKEN=' + 'xoxb' + '-1234-5678901234-abcdefghij';
|
|
85
|
+
const result = sanitize(input);
|
|
86
|
+
assert.ok(result.includes('[REDACTED:slack_token]'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('redacts Stripe key', () => {
|
|
90
|
+
const input = 'sk_' + 'live' + '_' + 'a'.repeat(24);
|
|
91
|
+
const result = sanitize(input);
|
|
92
|
+
assert.ok(result.includes('[REDACTED:stripe_key]'));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('redacts Google API key', () => {
|
|
96
|
+
const input = 'AIzaSyA-abcdefghijklmnopqrstuvwxyz12345';
|
|
97
|
+
const result = sanitize(input);
|
|
98
|
+
assert.ok(result.includes('[REDACTED:google_api_key]'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('redacts Vercel token', () => {
|
|
102
|
+
const input = 'vercel_abcdefghij1234567890ab';
|
|
103
|
+
const result = sanitize(input);
|
|
104
|
+
assert.ok(result.includes('[REDACTED:vercel_token]'));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('redacts npm token', () => {
|
|
108
|
+
const input = 'npm_' + 'a'.repeat(36);
|
|
109
|
+
const result = sanitize(input);
|
|
110
|
+
assert.ok(result.includes('[REDACTED:npm_token]'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('redacts SendGrid key', () => {
|
|
114
|
+
const input = 'SG.' + 'a'.repeat(22) + '.' + 'b'.repeat(43);
|
|
115
|
+
const result = sanitize(input);
|
|
116
|
+
assert.ok(result.includes('[REDACTED:sendgrid_key]'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('redacts Twilio key', () => {
|
|
120
|
+
const input = 'SK' + '0a1b2c3d'.repeat(4);
|
|
121
|
+
const result = sanitize(input);
|
|
122
|
+
assert.ok(result.includes('[REDACTED:twilio_key]'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not redact normal text', () => {
|
|
126
|
+
const input = 'This is a normal message about skating and skills.';
|
|
127
|
+
const result = sanitize(input);
|
|
128
|
+
assert.equal(result, input);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
134
|
+
|
|
135
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
136
|
+
Expected: FAIL — `Cannot find module './sanitizer.mjs'`
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 3: Implement Layer 1 in sanitizer**
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
// lib/sanitizer.mjs
|
|
142
|
+
// @ts-check
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Layer 1: Known service API key patterns.
|
|
146
|
+
* Each entry: [RegExp, replacement tag]
|
|
147
|
+
* @type {Array<[RegExp, string]>}
|
|
148
|
+
*/
|
|
149
|
+
const KNOWN_KEY_PATTERNS = [
|
|
150
|
+
[/sk-proj-[A-Za-z0-9_-]{20,}/g, '[REDACTED:openai_key]'],
|
|
151
|
+
[/sk-[A-Za-z0-9]{40,}/g, '[REDACTED:openai_key]'],
|
|
152
|
+
[/sk-ant-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic_key]'],
|
|
153
|
+
[/gh[pous]_[A-Za-z0-9]{36,}/g, '[REDACTED:github_token]'],
|
|
154
|
+
[/AKIA[0-9A-Z]{16}/g, '[REDACTED:aws_key]'],
|
|
155
|
+
[/(?:aws_secret_access_key|secret_key)\s*[=:]\s*[A-Za-z0-9/+=]{40}/gi, '[REDACTED:aws_secret]'],
|
|
156
|
+
[/xox[bpra]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack_token]'],
|
|
157
|
+
[/[sr]k_(live|test)_[A-Za-z0-9]{20,}/g, '[REDACTED:stripe_key]'],
|
|
158
|
+
[/AIza[0-9A-Za-z_-]{35}/g, '[REDACTED:google_api_key]'],
|
|
159
|
+
[/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}/g, '[REDACTED:supabase_key]'],
|
|
160
|
+
[/vercel_[A-Za-z0-9_-]{20,}/g, '[REDACTED:vercel_token]'],
|
|
161
|
+
[/npm_[A-Za-z0-9]{36,}/g, '[REDACTED:npm_token]'],
|
|
162
|
+
[/SG\.[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{43,}/g, '[REDACTED:sendgrid_key]'],
|
|
163
|
+
[/SK[0-9a-fA-F]{32}/g, '[REDACTED:twilio_key]'],
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Sanitize text by redacting known secret patterns.
|
|
168
|
+
* Pure function — no config dependency. Caller checks redactSecrets setting.
|
|
169
|
+
* @param {string} text
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function sanitize(text) {
|
|
173
|
+
if (!text) return text;
|
|
174
|
+
let result = text;
|
|
175
|
+
|
|
176
|
+
// Layer 1: Known service key patterns
|
|
177
|
+
for (const [pattern, tag] of KNOWN_KEY_PATTERNS) {
|
|
178
|
+
result = result.replace(pattern, tag);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
186
|
+
|
|
187
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
188
|
+
Expected: All 14 tests PASS
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 5: Commit**
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
git add lib/sanitizer.mjs lib/sanitizer.test.mjs
|
|
194
|
+
git commit -m "feat: add sanitizer Layer 1 — known service key patterns"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
### Task 2: sanitizer Layer 2 — Structural Secret Patterns
|
|
200
|
+
|
|
201
|
+
**Files:**
|
|
202
|
+
- Modify: `lib/sanitizer.mjs`
|
|
203
|
+
- Modify: `lib/sanitizer.test.mjs`
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 1: Write failing tests for Layer 2**
|
|
206
|
+
|
|
207
|
+
Append to `lib/sanitizer.test.mjs`:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
describe('Layer 2: Structural Secret Patterns', () => {
|
|
211
|
+
it('redacts PEM private key', () => {
|
|
212
|
+
const input = '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIB...\n-----END RSA PRIVATE KEY-----';
|
|
213
|
+
const result = sanitize(input);
|
|
214
|
+
assert.ok(!result.includes('MIIEpAIB'));
|
|
215
|
+
assert.ok(result.includes('[REDACTED:private_key]'));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('redacts EC private key', () => {
|
|
219
|
+
const input = '-----BEGIN EC PRIVATE KEY-----\ndata\n-----END EC PRIVATE KEY-----';
|
|
220
|
+
const result = sanitize(input);
|
|
221
|
+
assert.ok(result.includes('[REDACTED:private_key]'));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('redacts postgres connection string', () => {
|
|
225
|
+
const input = 'DB_URL: postgres://admin:p@ssw0rd@db.internal.com:5432/mydb';
|
|
226
|
+
const result = sanitize(input);
|
|
227
|
+
assert.ok(!result.includes('p@ssw0rd'));
|
|
228
|
+
assert.ok(result.includes('[REDACTED:connection_string]'));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('redacts mongodb+srv connection string', () => {
|
|
232
|
+
const input = 'mongodb+srv://user:pass@cluster0.abc123.mongodb.net/db';
|
|
233
|
+
const result = sanitize(input);
|
|
234
|
+
assert.ok(result.includes('[REDACTED:connection_string]'));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('redacts mysql connection string', () => {
|
|
238
|
+
const input = 'mysql://root:secret@localhost:3306/app';
|
|
239
|
+
const result = sanitize(input);
|
|
240
|
+
assert.ok(result.includes('[REDACTED:connection_string]'));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('redacts redis connection string', () => {
|
|
244
|
+
const input = 'redis://default:mypassword@redis.example.com:6379';
|
|
245
|
+
const result = sanitize(input);
|
|
246
|
+
assert.ok(result.includes('[REDACTED:connection_string]'));
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('redacts Bearer token', () => {
|
|
250
|
+
const input = 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9abc123';
|
|
251
|
+
const result = sanitize(input);
|
|
252
|
+
assert.ok(result.includes('[REDACTED:bearer_token]'));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('redacts generic Authorization header', () => {
|
|
256
|
+
const input = 'Authorization: Basic dXNlcjpwYXNzd29yZDEyMzQ1';
|
|
257
|
+
const result = sanitize(input);
|
|
258
|
+
assert.ok(result.includes('[REDACTED:auth_header]'));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('redacts JWT token', () => {
|
|
262
|
+
const input = 'token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYWRtaW4iOnRydWV9.signature123abc';
|
|
263
|
+
const result = sanitize(input);
|
|
264
|
+
assert.ok(result.includes('[REDACTED:jwt]'));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('does not redact normal URLs', () => {
|
|
268
|
+
const input = 'Visit https://example.com/docs for more info';
|
|
269
|
+
const result = sanitize(input);
|
|
270
|
+
assert.equal(result, input);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- [ ] **Step 2: Run tests to verify Layer 2 tests fail**
|
|
276
|
+
|
|
277
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
278
|
+
Expected: Layer 2 tests FAIL (no redaction yet), Layer 1 tests still PASS
|
|
279
|
+
|
|
280
|
+
- [ ] **Step 3: Implement Layer 2**
|
|
281
|
+
|
|
282
|
+
Add to `lib/sanitizer.mjs` — add patterns array after KNOWN_KEY_PATTERNS:
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
/**
|
|
286
|
+
* Layer 2: Structural secret patterns.
|
|
287
|
+
* @type {Array<[RegExp, string]>}
|
|
288
|
+
*/
|
|
289
|
+
const STRUCTURAL_PATTERNS = [
|
|
290
|
+
[/-----BEGIN [\w\s]*PRIVATE KEY-----[\s\S]*?-----END [\w\s]*PRIVATE KEY-----/g, '[REDACTED:private_key]'],
|
|
291
|
+
[/(postgres|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^\s"']+/g, '[REDACTED:connection_string]'],
|
|
292
|
+
[/Bearer\s+[A-Za-z0-9_.-]{20,}/g, '[REDACTED:bearer_token]'],
|
|
293
|
+
[/Authorization:\s*\S{20,}/gi, '[REDACTED:auth_header]'],
|
|
294
|
+
[/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_.-]+/g, '[REDACTED:jwt]'],
|
|
295
|
+
];
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Update the `sanitize` function to apply Layer 2 after Layer 1:
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
// Layer 2: Structural secret patterns
|
|
302
|
+
for (const [pattern, tag] of STRUCTURAL_PATTERNS) {
|
|
303
|
+
result = result.replace(pattern, tag);
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
- [ ] **Step 4: Run tests to verify all pass**
|
|
308
|
+
|
|
309
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
310
|
+
Expected: All Layer 1 + Layer 2 tests PASS
|
|
311
|
+
|
|
312
|
+
- [ ] **Step 5: Commit**
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
git add lib/sanitizer.mjs lib/sanitizer.test.mjs
|
|
316
|
+
git commit -m "feat: add sanitizer Layer 2 — structural secret patterns"
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
### Task 3: sanitizer Layer 3 — Key=Value Secrets
|
|
322
|
+
|
|
323
|
+
**Files:**
|
|
324
|
+
- Modify: `lib/sanitizer.mjs`
|
|
325
|
+
- Modify: `lib/sanitizer.test.mjs`
|
|
326
|
+
|
|
327
|
+
- [ ] **Step 1: Write failing tests for Layer 3**
|
|
328
|
+
|
|
329
|
+
Append to `lib/sanitizer.test.mjs`:
|
|
330
|
+
|
|
331
|
+
```js
|
|
332
|
+
describe('Layer 3: Key=Value Secrets', () => {
|
|
333
|
+
it('redacts password=value keeping key name', () => {
|
|
334
|
+
const input = 'password=mysecretpassword123';
|
|
335
|
+
const result = sanitize(input);
|
|
336
|
+
assert.equal(result, 'password=[REDACTED:secret_value]');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('redacts PASSWORD: value (case insensitive)', () => {
|
|
340
|
+
const input = 'PASSWORD: hunter2';
|
|
341
|
+
const result = sanitize(input);
|
|
342
|
+
assert.equal(result, 'PASSWORD: [REDACTED:secret_value]');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('redacts api_key=value', () => {
|
|
346
|
+
const input = 'api_key=abc123xyz';
|
|
347
|
+
const result = sanitize(input);
|
|
348
|
+
assert.equal(result, 'api_key=[REDACTED:secret_value]');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('redacts client_secret: value', () => {
|
|
352
|
+
const input = 'client_secret: my-super-secret';
|
|
353
|
+
const result = sanitize(input);
|
|
354
|
+
assert.equal(result, 'client_secret: [REDACTED:secret_value]');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('redacts database_url=value', () => {
|
|
358
|
+
const input = 'database_url=sqlite:///app.db';
|
|
359
|
+
const result = sanitize(input);
|
|
360
|
+
assert.equal(result, 'database_url=[REDACTED:secret_value]');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('redacts .env block (3+ consecutive KEY=value lines)', () => {
|
|
364
|
+
const input = 'DATABASE_URL=postgres://host/db\nSECRET_KEY=abc123\nAPI_TOKEN=xyz789\nPORT=3000';
|
|
365
|
+
const result = sanitize(input);
|
|
366
|
+
assert.ok(result.includes('[REDACTED:env_block'));
|
|
367
|
+
assert.ok(result.includes('4개'));
|
|
368
|
+
assert.ok(!result.includes('abc123'));
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('does not treat 2 consecutive lines as env block', () => {
|
|
372
|
+
const input = 'NODE_ENV=production\nPORT=3000';
|
|
373
|
+
const result = sanitize(input);
|
|
374
|
+
assert.ok(!result.includes('env_block'));
|
|
375
|
+
// individual key=value check — these aren't secret key names so should pass through
|
|
376
|
+
assert.ok(result.includes('NODE_ENV=production'));
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('env block takes priority over individual key=value', () => {
|
|
380
|
+
const input = 'PASSWORD=secret\nAPI_KEY=abc123\nTOKEN=xyz\nSECRET=test';
|
|
381
|
+
const result = sanitize(input);
|
|
382
|
+
// Should be one env_block, not 4 individual redactions
|
|
383
|
+
assert.ok(result.includes('[REDACTED:env_block'));
|
|
384
|
+
assert.ok(!result.includes('[REDACTED:secret_value]'));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('does not redact normal key=value that is not secret-named', () => {
|
|
388
|
+
const input = 'name=John\nage=30';
|
|
389
|
+
const result = sanitize(input);
|
|
390
|
+
assert.equal(result, input);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
- [ ] **Step 2: Run tests to verify Layer 3 tests fail**
|
|
396
|
+
|
|
397
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
398
|
+
Expected: Layer 3 tests FAIL, Layer 1+2 still PASS
|
|
399
|
+
|
|
400
|
+
- [ ] **Step 3: Implement Layer 3**
|
|
401
|
+
|
|
402
|
+
Add to `lib/sanitizer.mjs`:
|
|
403
|
+
|
|
404
|
+
```js
|
|
405
|
+
/**
|
|
406
|
+
* Layer 3 secret key names — case insensitive match.
|
|
407
|
+
*/
|
|
408
|
+
const SECRET_KEY_NAMES = /\b(password|passwd|pwd|secret|token|api_key|apikey|api-key|access_key|access_token|private_key|client_secret|auth_token|refresh_token|database_url|db_password|encryption_key|signing_key|master_key)/i;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Layer 3: .env block pattern — 3+ consecutive lines matching KEY=value.
|
|
412
|
+
*/
|
|
413
|
+
const ENV_LINE = /^[A-Z_]{3,}=\S+$/;
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Apply Layer 3: Key=Value secret redaction.
|
|
417
|
+
* .env blocks (3+ consecutive KEY=value lines) take priority.
|
|
418
|
+
* @param {string} text
|
|
419
|
+
* @returns {string}
|
|
420
|
+
*/
|
|
421
|
+
function redactKeyValueSecrets(text) {
|
|
422
|
+
const lines = text.split('\n');
|
|
423
|
+
/** @type {boolean[]} */
|
|
424
|
+
const redactedByBlock = new Array(lines.length).fill(false);
|
|
425
|
+
|
|
426
|
+
// Pass 1: Find .env blocks (3+ consecutive KEY=value lines)
|
|
427
|
+
let blockStart = -1;
|
|
428
|
+
for (let i = 0; i <= lines.length; i++) {
|
|
429
|
+
const isEnvLine = i < lines.length && ENV_LINE.test(lines[i].trim());
|
|
430
|
+
if (isEnvLine) {
|
|
431
|
+
if (blockStart === -1) blockStart = i;
|
|
432
|
+
} else {
|
|
433
|
+
if (blockStart !== -1) {
|
|
434
|
+
const blockLen = i - blockStart;
|
|
435
|
+
if (blockLen >= 3) {
|
|
436
|
+
const replacement = `[REDACTED:env_block - ${blockLen}개 환경변수]`;
|
|
437
|
+
lines[blockStart] = replacement;
|
|
438
|
+
for (let j = blockStart + 1; j < i; j++) {
|
|
439
|
+
lines[j] = null; // mark for removal
|
|
440
|
+
}
|
|
441
|
+
for (let j = blockStart; j < i; j++) {
|
|
442
|
+
redactedByBlock[j] = true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
blockStart = -1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Pass 2: Individual key=value redaction (skip lines already in env blocks)
|
|
451
|
+
for (let i = 0; i < lines.length; i++) {
|
|
452
|
+
if (lines[i] === null || redactedByBlock[i]) continue;
|
|
453
|
+
lines[i] = lines[i].replace(
|
|
454
|
+
new RegExp(`(${SECRET_KEY_NAMES.source})\\s*([=:])\\s*(\\S+)`, 'gi'),
|
|
455
|
+
(_, keyName, sep, _value) => `${keyName}${sep}${sep === ':' ? ' ' : ''}[REDACTED:secret_value]`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return lines.filter(l => l !== null).join('\n');
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Update the `sanitize` function to apply Layer 3:
|
|
464
|
+
|
|
465
|
+
```js
|
|
466
|
+
// Layer 3: Key=Value secrets (.env blocks, then individual key=value)
|
|
467
|
+
result = redactKeyValueSecrets(result);
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
- [ ] **Step 4: Run tests to verify all pass**
|
|
471
|
+
|
|
472
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
473
|
+
Expected: All Layer 1 + 2 + 3 tests PASS
|
|
474
|
+
|
|
475
|
+
- [ ] **Step 5: Commit**
|
|
476
|
+
|
|
477
|
+
```bash
|
|
478
|
+
git add lib/sanitizer.mjs lib/sanitizer.test.mjs
|
|
479
|
+
git commit -m "feat: add sanitizer Layer 3 — key=value and .env block detection"
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
### Task 4: sanitizer Layer 4 — High-Entropy Strings
|
|
485
|
+
|
|
486
|
+
**Files:**
|
|
487
|
+
- Modify: `lib/sanitizer.mjs`
|
|
488
|
+
- Modify: `lib/sanitizer.test.mjs`
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 1: Write failing tests for Layer 4**
|
|
491
|
+
|
|
492
|
+
Append to `lib/sanitizer.test.mjs`:
|
|
493
|
+
|
|
494
|
+
```js
|
|
495
|
+
describe('Layer 4: High-Entropy Strings', () => {
|
|
496
|
+
it('redacts a high-entropy 32+ char string', () => {
|
|
497
|
+
const input = 'secret: aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW';
|
|
498
|
+
const result = sanitize(input);
|
|
499
|
+
assert.ok(result.includes('[REDACTED:high_entropy_string]'));
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('does not redact a normal English word sequence', () => {
|
|
503
|
+
const input = 'this is a longvariablenamethatismorethan32characters';
|
|
504
|
+
const result = sanitize(input);
|
|
505
|
+
// All lowercase letters only — should not be redacted
|
|
506
|
+
assert.ok(!result.includes('[REDACTED'));
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('does not redact file paths', () => {
|
|
510
|
+
const input = 'C:\\Users\\admin\\Documents\\projects\\myapp\\src\\components\\Header.tsx';
|
|
511
|
+
const result = sanitize(input);
|
|
512
|
+
assert.ok(!result.includes('[REDACTED'));
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('does not redact unix file paths', () => {
|
|
516
|
+
const input = '/home/user/projects/myapp/src/components/Header.tsx';
|
|
517
|
+
const result = sanitize(input);
|
|
518
|
+
assert.ok(!result.includes('[REDACTED'));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('does not redact URLs', () => {
|
|
522
|
+
const input = 'https://api.example.com/v2/users/profiles/settings/notifications';
|
|
523
|
+
const result = sanitize(input);
|
|
524
|
+
assert.ok(!result.includes('[REDACTED'));
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('does not redact strings already caught by earlier layers', () => {
|
|
528
|
+
// ghp_ token should be caught by Layer 1, not Layer 4
|
|
529
|
+
const input = 'ghp_' + 'A'.repeat(36);
|
|
530
|
+
const result = sanitize(input);
|
|
531
|
+
assert.equal(result, '[REDACTED:github_token]');
|
|
532
|
+
assert.ok(!result.includes('high_entropy'));
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('redacts base64-like secrets', () => {
|
|
536
|
+
const input = 'key=dGhpcyBpcyBhIHNlY3JldCBrZXkgdGhhdCBzaG91bGQgYmUgcmVkYWN0ZWQ=';
|
|
537
|
+
const result = sanitize(input);
|
|
538
|
+
assert.ok(result.includes('[REDACTED'));
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('does not redact repeated character strings', () => {
|
|
542
|
+
const input = 'aaaaaaaaAAAAAAAA0000000011111111bbbb';
|
|
543
|
+
const result = sanitize(input);
|
|
544
|
+
// High repetition — likely not a secret
|
|
545
|
+
assert.ok(!result.includes('high_entropy'));
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
- [ ] **Step 2: Run tests to verify Layer 4 tests fail**
|
|
551
|
+
|
|
552
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
553
|
+
Expected: Layer 4 tests FAIL, Layer 1+2+3 still PASS
|
|
554
|
+
|
|
555
|
+
- [ ] **Step 3: Implement Layer 4**
|
|
556
|
+
|
|
557
|
+
Add to `lib/sanitizer.mjs`:
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
/**
|
|
561
|
+
* Check if a string has high entropy (likely a secret).
|
|
562
|
+
* Requires 3+ character classes and no single char > 30% of total.
|
|
563
|
+
* @param {string} str
|
|
564
|
+
* @returns {boolean}
|
|
565
|
+
*/
|
|
566
|
+
function isHighEntropy(str) {
|
|
567
|
+
let hasUpper = false, hasLower = false, hasDigit = false, hasSpecial = false;
|
|
568
|
+
/** @type {Map<string, number>} */
|
|
569
|
+
const freq = new Map();
|
|
570
|
+
|
|
571
|
+
for (const ch of str) {
|
|
572
|
+
freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
573
|
+
if (/[A-Z]/.test(ch)) hasUpper = true;
|
|
574
|
+
else if (/[a-z]/.test(ch)) hasLower = true;
|
|
575
|
+
else if (/[0-9]/.test(ch)) hasDigit = true;
|
|
576
|
+
else hasSpecial = true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const classCount = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length;
|
|
580
|
+
if (classCount < 3) return false;
|
|
581
|
+
|
|
582
|
+
// Reject if any single character is > 30% of the string
|
|
583
|
+
for (const count of freq.values()) {
|
|
584
|
+
if (count / str.length > 0.3) return false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Matches 32+ char alphanumeric-ish tokens */
|
|
591
|
+
const HIGH_ENTROPY_RE = /[A-Za-z0-9+/=_-]{32,}/g;
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Apply Layer 4: High-entropy string detection.
|
|
595
|
+
* Skips file paths, URLs, and strings already redacted.
|
|
596
|
+
* @param {string} text
|
|
597
|
+
* @returns {string}
|
|
598
|
+
*/
|
|
599
|
+
function redactHighEntropy(text) {
|
|
600
|
+
return text.replace(HIGH_ENTROPY_RE, (match, offset) => {
|
|
601
|
+
// Skip if already redacted by earlier layers
|
|
602
|
+
if (match.includes('REDACTED')) return match;
|
|
603
|
+
|
|
604
|
+
// Skip file paths (C:\... or /home/... or /usr/...)
|
|
605
|
+
const before = text.slice(Math.max(0, offset - 5), offset);
|
|
606
|
+
if (/[A-Za-z]:\\/.test(before + match.slice(0, 3))) return match;
|
|
607
|
+
if (/\//.test(before) && match.includes('/')) return match;
|
|
608
|
+
|
|
609
|
+
// Skip URLs
|
|
610
|
+
if (/https?:\/\//.test(text.slice(Math.max(0, offset - 10), offset + 10))) return match;
|
|
611
|
+
if (match.split('/').length > 2) return match;
|
|
612
|
+
|
|
613
|
+
// Skip if purely alphabetical (likely a variable or word)
|
|
614
|
+
if (/^[a-zA-Z]+$/.test(match)) return match;
|
|
615
|
+
|
|
616
|
+
// Entropy check
|
|
617
|
+
if (!isHighEntropy(match)) return match;
|
|
618
|
+
|
|
619
|
+
return '[REDACTED:high_entropy_string]';
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Update the `sanitize` function to apply Layer 4:
|
|
625
|
+
|
|
626
|
+
```js
|
|
627
|
+
// Layer 4: High-entropy strings
|
|
628
|
+
result = redactHighEntropy(result);
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
- [ ] **Step 4: Run tests to verify all pass**
|
|
632
|
+
|
|
633
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
634
|
+
Expected: All Layer 1 + 2 + 3 + 4 tests PASS
|
|
635
|
+
|
|
636
|
+
- [ ] **Step 5: Commit**
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
git add lib/sanitizer.mjs lib/sanitizer.test.mjs
|
|
640
|
+
git commit -m "feat: add sanitizer Layer 4 — high-entropy string detection"
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
645
|
+
### Task 5: Integration Tests for sanitizer
|
|
646
|
+
|
|
647
|
+
**Files:**
|
|
648
|
+
- Modify: `lib/sanitizer.test.mjs`
|
|
649
|
+
|
|
650
|
+
- [ ] **Step 1: Write integration tests**
|
|
651
|
+
|
|
652
|
+
Append to `lib/sanitizer.test.mjs`:
|
|
653
|
+
|
|
654
|
+
```js
|
|
655
|
+
describe('Integration: mixed secrets in one message', () => {
|
|
656
|
+
it('redacts multiple different secrets in a single message', () => {
|
|
657
|
+
const input = [
|
|
658
|
+
'이 API 키 sk-proj-abc123def456ghi789jkl012mno 로 접속하고',
|
|
659
|
+
'DB는 postgres://admin:secret@db.company.com:5432/prod 이고',
|
|
660
|
+
'password=hunter2 로 로그인해',
|
|
661
|
+
].join('\n');
|
|
662
|
+
const result = sanitize(input);
|
|
663
|
+
assert.ok(result.includes('[REDACTED:openai_key]'));
|
|
664
|
+
assert.ok(result.includes('[REDACTED:connection_string]'));
|
|
665
|
+
assert.ok(result.includes('[REDACTED:secret_value]'));
|
|
666
|
+
assert.ok(!result.includes('hunter2'));
|
|
667
|
+
assert.ok(!result.includes('sk-proj-'));
|
|
668
|
+
assert.ok(!result.includes('admin:secret'));
|
|
669
|
+
// Non-secret text preserved
|
|
670
|
+
assert.ok(result.includes('이 API 키'));
|
|
671
|
+
assert.ok(result.includes('로 접속하고'));
|
|
672
|
+
assert.ok(result.includes('로 로그인해'));
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('handles empty string', () => {
|
|
676
|
+
assert.equal(sanitize(''), '');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('handles null/undefined gracefully', () => {
|
|
680
|
+
assert.equal(sanitize(null), null);
|
|
681
|
+
assert.equal(sanitize(undefined), undefined);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('preserves message with no secrets', () => {
|
|
685
|
+
const input = 'React 컴포넌트에서 useState 훅을 사용하는 방법을 알려줘.\n상태 관리 패턴에 대해 고민하고 있어.';
|
|
686
|
+
assert.equal(sanitize(input), input);
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
- [ ] **Step 2: Run all tests**
|
|
692
|
+
|
|
693
|
+
Run: `node --test lib/sanitizer.test.mjs`
|
|
694
|
+
Expected: All tests PASS
|
|
695
|
+
|
|
696
|
+
- [ ] **Step 3: Commit**
|
|
697
|
+
|
|
698
|
+
```bash
|
|
699
|
+
git add lib/sanitizer.test.mjs
|
|
700
|
+
git commit -m "test: add integration tests for sanitizer"
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
---
|
|
704
|
+
|
|
705
|
+
### Task 6: Config — PrivacyConfig type and defaults
|
|
706
|
+
|
|
707
|
+
**Files:**
|
|
708
|
+
- Modify: `lib/types.d.ts`
|
|
709
|
+
- Modify: `lib/config.mjs`
|
|
710
|
+
|
|
711
|
+
- [ ] **Step 1: Add PrivacyConfig to types.d.ts**
|
|
712
|
+
|
|
713
|
+
In `lib/types.d.ts`, add `PrivacyConfig` interface after `Profile` and update `Config`:
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
export interface PrivacyConfig {
|
|
717
|
+
redactSecrets: boolean;
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
Update `Config` to add optional `privacy` field:
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
export interface Config {
|
|
725
|
+
storage: StorageConfig;
|
|
726
|
+
language: string;
|
|
727
|
+
periods: Periods;
|
|
728
|
+
profile: Profile;
|
|
729
|
+
privacy?: PrivacyConfig;
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
- [ ] **Step 2: Update default config creators in config.mjs**
|
|
734
|
+
|
|
735
|
+
In `lib/config.mjs`, update `createDefaultLocalConfig`:
|
|
736
|
+
|
|
737
|
+
```js
|
|
738
|
+
/** @param {string} basePath @returns {Config} */
|
|
739
|
+
export function createDefaultLocalConfig(basePath) {
|
|
740
|
+
return {
|
|
741
|
+
storage: { type: 'local', local: { basePath } },
|
|
742
|
+
language: 'ko',
|
|
743
|
+
periods: { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false },
|
|
744
|
+
profile: { company: '', role: '', team: '', context: '' },
|
|
745
|
+
privacy: { redactSecrets: true },
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
Update `createDefaultGitHubConfig`:
|
|
751
|
+
|
|
752
|
+
```js
|
|
753
|
+
/** @param {string} owner @param {string} repo @param {string} token @returns {Config} */
|
|
754
|
+
export function createDefaultGitHubConfig(owner, repo, token) {
|
|
755
|
+
return {
|
|
756
|
+
storage: { type: 'github', github: { owner, repo, token, basePath: '' } },
|
|
757
|
+
language: 'ko',
|
|
758
|
+
periods: { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false },
|
|
759
|
+
profile: { company: '', role: '', team: '', context: '' },
|
|
760
|
+
privacy: { redactSecrets: true },
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Also update `migrateOldConfig` to include privacy:
|
|
766
|
+
|
|
767
|
+
```js
|
|
768
|
+
function migrateOldConfig(old) {
|
|
769
|
+
return {
|
|
770
|
+
storage: {
|
|
771
|
+
type: 'local',
|
|
772
|
+
local: { basePath: join(old.vaultPath, old.reviewFolder) },
|
|
773
|
+
},
|
|
774
|
+
language: old.language,
|
|
775
|
+
periods: old.periods,
|
|
776
|
+
profile: old.profile,
|
|
777
|
+
privacy: { redactSecrets: true },
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
- [ ] **Step 3: Commit**
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
git add lib/types.d.ts lib/config.mjs
|
|
786
|
+
git commit -m "feat: add PrivacyConfig type and defaults for redactSecrets"
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
---
|
|
790
|
+
|
|
791
|
+
### Task 7: Integrate sanitizer into raw-logger.mjs
|
|
792
|
+
|
|
793
|
+
**Files:**
|
|
794
|
+
- Modify: `lib/raw-logger.mjs`
|
|
795
|
+
|
|
796
|
+
- [ ] **Step 1: Import sanitize and apply in appendRawLog**
|
|
797
|
+
|
|
798
|
+
At the top of `lib/raw-logger.mjs`, add import:
|
|
799
|
+
|
|
800
|
+
```js
|
|
801
|
+
import { sanitize } from './sanitizer.mjs';
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Update `appendRawLog` to accept a `redact` boolean parameter and apply sanitize. Change the function signature:
|
|
805
|
+
|
|
806
|
+
```js
|
|
807
|
+
/** @param {StorageAdapter} storage @param {string} sessionDir @param {string} date @param {HookInput} entry @param {boolean} [redact=true] @returns {Promise<void>} */
|
|
808
|
+
export async function appendRawLog(storage, sessionDir, date, entry, redact = true) {
|
|
809
|
+
await storage.mkdir(sessionDir);
|
|
810
|
+
const logPath = `${sessionDir}/${date}.jsonl`;
|
|
811
|
+
const now = new Date().toISOString();
|
|
812
|
+
let lines = '';
|
|
813
|
+
|
|
814
|
+
const userMsg = redact && entry.last_user_message ? sanitize(entry.last_user_message) : entry.last_user_message;
|
|
815
|
+
const assistantMsg = redact && entry.last_assistant_message ? sanitize(entry.last_assistant_message) : entry.last_assistant_message;
|
|
816
|
+
|
|
817
|
+
// User message row (with original question timestamp)
|
|
818
|
+
if (userMsg) {
|
|
819
|
+
lines += JSON.stringify({ type: 'user', message: userMsg, session_id: entry.session_id, cwd: entry.cwd, timestamp: entry.user_timestamp || now }) + '\n';
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Assistant message row (with response completion timestamp)
|
|
823
|
+
if (assistantMsg) {
|
|
824
|
+
lines += JSON.stringify({ type: 'assistant', message: assistantMsg, session_id: entry.session_id, cwd: entry.cwd, timestamp: now }) + '\n';
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (lines) {
|
|
828
|
+
await storage.append(logPath, lines);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
- [ ] **Step 2: Update on-stop.mjs to pass redact flag**
|
|
834
|
+
|
|
835
|
+
In `hooks/on-stop.mjs`, update the `appendRawLog` call to pass the config-based redact flag:
|
|
836
|
+
|
|
837
|
+
```js
|
|
838
|
+
const redact = config.privacy?.redactSecrets ?? true;
|
|
839
|
+
await appendRawLog(storage, sessionDir, date, input, redact);
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
- [ ] **Step 3: Commit**
|
|
843
|
+
|
|
844
|
+
```bash
|
|
845
|
+
git add lib/raw-logger.mjs hooks/on-stop.mjs
|
|
846
|
+
git commit -m "feat: integrate sanitizer into raw-logger for secret redaction on save"
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
---
|
|
850
|
+
|
|
851
|
+
### Task 8: Integrate sanitizer into recover-sessions.mjs
|
|
852
|
+
|
|
853
|
+
**Files:**
|
|
854
|
+
- Modify: `hooks/recover-sessions.mjs`
|
|
855
|
+
|
|
856
|
+
- [ ] **Step 1: Import sanitize and apply during recovery**
|
|
857
|
+
|
|
858
|
+
At the top of `hooks/recover-sessions.mjs`, add import:
|
|
859
|
+
|
|
860
|
+
```js
|
|
861
|
+
import { sanitize } from '../lib/sanitizer.mjs';
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
In the `main()` function, after `const config = loadConfig();`, read the redact setting:
|
|
865
|
+
|
|
866
|
+
```js
|
|
867
|
+
const redact = config.privacy?.redactSecrets ?? true;
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
In the section that appends missing entries (around the `for (const [date, entries] of Object.entries(missingByDate))` loop), apply sanitize to each entry's message:
|
|
871
|
+
|
|
872
|
+
```js
|
|
873
|
+
for (const [date, entries] of Object.entries(missingByDate)) {
|
|
874
|
+
const logPath = `${sessionDir}/${date}.jsonl`;
|
|
875
|
+
const lines = entries.map(e =>
|
|
876
|
+
JSON.stringify({ type: e.type, message: redact ? sanitize(e.message) : e.message, session_id: sessionId, cwd: e.cwd, timestamp: e.timestamp })
|
|
877
|
+
).join('\n') + '\n';
|
|
878
|
+
await storage.append(logPath, lines);
|
|
879
|
+
}
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
- [ ] **Step 2: Commit**
|
|
883
|
+
|
|
884
|
+
```bash
|
|
885
|
+
git add hooks/recover-sessions.mjs
|
|
886
|
+
git commit -m "feat: integrate sanitizer into session recovery"
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
---
|
|
890
|
+
|
|
891
|
+
### Task 9: Setup flow — public repo warning
|
|
892
|
+
|
|
893
|
+
**Files:**
|
|
894
|
+
- Modify: `commands/daily-review-setup.md`
|
|
895
|
+
|
|
896
|
+
- [ ] **Step 1: Add public repo check to the setup command**
|
|
897
|
+
|
|
898
|
+
In `commands/daily-review-setup.md`, find the "기존 저장소 사용" section (under **1b. Select or create a repository**, the "Existing" bullet). After the user provides `owner/repo`, add a public repo check before proceeding:
|
|
899
|
+
|
|
900
|
+
Insert after `- **Existing:** Ask for the repository in \`owner/repo\` format. Parse into \`owner\` and \`repo\`.`:
|
|
901
|
+
|
|
902
|
+
```markdown
|
|
903
|
+
After parsing owner/repo, check if the repository is public:
|
|
904
|
+
```bash
|
|
905
|
+
MSYS_NO_PATHCONV=1 gh api "repos/{owner}/{repo}" --jq '.private'
|
|
906
|
+
```
|
|
907
|
+
If the result is `false` (public repository), warn the user using AskUserQuestion:
|
|
908
|
+
- question: "⚠️ 이 저장소는 **public**입니다. 대화 내용과 회고 파일이 인터넷에 공개됩니다. private 저장소 사용을 강력히 권장합니다."
|
|
909
|
+
- options:
|
|
910
|
+
1. label: "private으로 변경 후 계속", description: "저장소를 비공개로 변경합니다"
|
|
911
|
+
2. label: "그대로 사용 (위험 인지)", description: "public 상태로 계속 진행합니다"
|
|
912
|
+
3. label: "다른 저장소 선택", description: "다른 저장소를 지정합니다"
|
|
913
|
+
|
|
914
|
+
- "private으로 변경 후 계속":
|
|
915
|
+
```bash
|
|
916
|
+
MSYS_NO_PATHCONV=1 gh api "repos/{owner}/{repo}" -X PATCH -f private=true
|
|
917
|
+
```
|
|
918
|
+
If successful: "저장소를 private으로 변경했습니다." and continue.
|
|
919
|
+
If failed: "권한이 없어 변경할 수 없습니다. 저장소 관리자에게 요청하세요." and ask again.
|
|
920
|
+
- "그대로 사용 (위험 인지)": continue with the public repo.
|
|
921
|
+
- "다른 저장소 선택": go back to 1b repo selection.
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
- [ ] **Step 2: Commit**
|
|
925
|
+
|
|
926
|
+
```bash
|
|
927
|
+
git add commands/daily-review-setup.md
|
|
928
|
+
git commit -m "feat: warn when using public GitHub repo in setup"
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
---
|
|
932
|
+
|
|
933
|
+
### Task 10: README Security & Privacy sections
|
|
934
|
+
|
|
935
|
+
**Files:**
|
|
936
|
+
- Modify: `README.md`
|
|
937
|
+
- Modify: `README.ko.md`
|
|
938
|
+
|
|
939
|
+
- [ ] **Step 1: Add Security & Privacy section to English README**
|
|
940
|
+
|
|
941
|
+
In `README.md`, insert before the `## License` section:
|
|
942
|
+
|
|
943
|
+
```markdown
|
|
944
|
+
## Security & Privacy
|
|
945
|
+
|
|
946
|
+
### What Gets Collected
|
|
947
|
+
|
|
948
|
+
This plugin automatically captures and stores **all conversations** with Claude Code:
|
|
949
|
+
- Full user messages and AI responses
|
|
950
|
+
- Working directory paths and project names
|
|
951
|
+
- Git commit messages, branch names, and remote URLs
|
|
952
|
+
|
|
953
|
+
### Corporate / Organizational Use
|
|
954
|
+
|
|
955
|
+
When using this plugin for work, the following may be recorded:
|
|
956
|
+
|
|
957
|
+
- Source code and business logic descriptions
|
|
958
|
+
- Internal system/service names and architecture details
|
|
959
|
+
- Colleague names, client information, and project specifics
|
|
960
|
+
- Internal URLs, IP addresses, and infrastructure configurations
|
|
961
|
+
|
|
962
|
+
**You are solely responsible for managing this information.**
|
|
963
|
+
Please review your organization's security policies before use.
|
|
964
|
+
|
|
965
|
+
### Automatic Secret Redaction
|
|
966
|
+
|
|
967
|
+
Known secret patterns (API keys, tokens, passwords, etc.) are automatically redacted to `[REDACTED]` before storage. However, this is a best-effort mechanism and **does not guarantee complete protection of all sensitive data.**
|
|
968
|
+
|
|
969
|
+
### GitHub Storage
|
|
970
|
+
|
|
971
|
+
If storing reviews on GitHub, **always use a private repository.** Storing to a public repository exposes your conversations and reviews to the internet. Since secret redaction cannot cover all cases, keeping the repository private is the most fundamental security measure.
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
- [ ] **Step 2: Add 보안 및 개인정보 section to Korean README**
|
|
975
|
+
|
|
976
|
+
In `README.ko.md`, insert before the `## 라이선스` section:
|
|
977
|
+
|
|
978
|
+
```markdown
|
|
979
|
+
## 보안 및 개인정보
|
|
980
|
+
|
|
981
|
+
### 자동 수집되는 정보
|
|
982
|
+
|
|
983
|
+
이 플러그인은 Claude Code와의 **모든 대화 내용**을 자동으로 캡처하여 저장합니다:
|
|
984
|
+
- 사용자 메시지 및 AI 응답 전문
|
|
985
|
+
- 작업 디렉토리 경로 및 프로젝트명
|
|
986
|
+
- Git 커밋 메시지, 브랜치명, 리모트 URL
|
|
987
|
+
|
|
988
|
+
### 회사/조직 내 사용 시 주의사항
|
|
989
|
+
|
|
990
|
+
회사 업무에 이 플러그인을 사용하면, 다음 정보가 저장소에 기록될 수 있습니다:
|
|
991
|
+
|
|
992
|
+
- 소스 코드 및 비즈니스 로직 설명
|
|
993
|
+
- 내부 시스템/서비스 이름 및 아키텍처
|
|
994
|
+
- 동료 이름, 고객 정보, 프로젝트 세부사항
|
|
995
|
+
- 내부 URL, IP 주소, 인프라 구성 정보
|
|
996
|
+
|
|
997
|
+
**이러한 정보의 관리 책임은 전적으로 사용자에게 있습니다.** 사용 전 소속 조직의 보안 정책을 확인하시기 바랍니다.
|
|
998
|
+
|
|
999
|
+
### 시크릿 자동 마스킹
|
|
1000
|
+
|
|
1001
|
+
API 키, 토큰, 비밀번호 등 알려진 시크릿 패턴은 저장 전에 자동으로 `[REDACTED]` 처리됩니다. 단, 이는 best-effort 방식이며 **모든 민감 정보의 완전한 차단을 보장하지 않습니다.**
|
|
1002
|
+
|
|
1003
|
+
### GitHub 저장소 사용 시
|
|
1004
|
+
|
|
1005
|
+
GitHub에 회고를 저장하는 경우, **반드시 private 저장소를 사용하세요.** public 저장소에 저장할 경우 대화 내용과 회고 파일이 인터넷에 공개됩니다. 시크릿 마스킹이 모든 경우를 커버하지 못하므로, private 저장소 유지는 가장 기본적인 보안 조치입니다.
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
- [ ] **Step 3: Also update config examples in both READMEs**
|
|
1009
|
+
|
|
1010
|
+
In both `README.md` and `README.ko.md`, add the `privacy` field to the JSON config examples. In the local storage config example, after the `profile` block:
|
|
1011
|
+
|
|
1012
|
+
```json
|
|
1013
|
+
"privacy": {
|
|
1014
|
+
"redactSecrets": true
|
|
1015
|
+
}
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
And in the GitHub storage config example, same location.
|
|
1019
|
+
|
|
1020
|
+
- [ ] **Step 4: Commit**
|
|
1021
|
+
|
|
1022
|
+
```bash
|
|
1023
|
+
git add README.md README.ko.md
|
|
1024
|
+
git commit -m "docs: add Security & Privacy section and privacy config to READMEs"
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
---
|
|
1028
|
+
|
|
1029
|
+
## Task Dependency Summary
|
|
1030
|
+
|
|
1031
|
+
```
|
|
1032
|
+
Task 1 (Layer 1) → Task 2 (Layer 2) → Task 3 (Layer 3) → Task 4 (Layer 4) → Task 5 (Integration tests)
|
|
1033
|
+
↓
|
|
1034
|
+
Task 6 (Config types) ──────────────────────────────────────────────────────────→ Task 7 (raw-logger integration)
|
|
1035
|
+
↓
|
|
1036
|
+
Task 8 (recover-sessions integration)
|
|
1037
|
+
|
|
1038
|
+
Task 9 (Setup public check) — independent
|
|
1039
|
+
Task 10 (README) — independent
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
Tasks 1-5 are sequential (each layer builds on previous). Task 6 can be done in parallel with Tasks 1-5. Tasks 7-8 depend on both Task 5 and Task 6. Tasks 9 and 10 are independent and can be done at any time.
|