@wdyy/skills 0.1.27 → 0.1.30

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 (24) hide show
  1. package/.well-known/skills/index.json +8 -3
  2. package/.well-known/skills/wdyy-database-standard/SKILL.md +5 -5
  3. package/.well-known/skills/wdyy-database-standard/reference/database-rules.md +2 -1
  4. package/.well-known/skills/wdyy-database-standard/scripts/validate-table-design.mjs +10 -3
  5. package/.well-known/skills/wdyy-database-standard/scripts/validate-table-design.test.mjs +36 -0
  6. package/.well-known/skills/wdyy-database-standard/templates/table-design.template.md +3 -2
  7. package/.well-known/skills/wdyy-logging-standard/SKILL.md +16 -13
  8. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
  9. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +17 -8
  10. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.mjs +23 -4
  11. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +200 -303
  12. package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +167 -89
  13. package/.well-known/skills/wdyy-logging-standard/templates/nestjs-http-logging.middleware.template.ts +68 -46
  14. package/.well-known/skills/wdyy-logging-standard/templates/pino-logger.template.ts +38 -0
  15. package/.well-known/skills/wdyy-logging-standard/templates/security-audit-logger.template.ts +9 -14
  16. package/.well-known/skills/wdyy-safety-review/SKILL.md +54 -0
  17. package/.well-known/skills/wdyy-safety-review/agents/openai.yaml +4 -0
  18. package/.well-known/skills/wdyy-safety-review/references/security-review-rules.md +51 -0
  19. package/.well-known/skills/wdyy-safety-review/scripts/validate-security-checklist.mjs +66 -0
  20. package/.well-known/skills/wdyy-safety-review/scripts/validate-security-checklist.test.mjs +69 -0
  21. package/.well-known/skills/wdyy-safety-review/templates/security-checklist.template.md +17 -0
  22. package/README.md +8 -6
  23. package/lib/wdyy-cli.js +3 -1
  24. package/package.json +1 -1
@@ -3,19 +3,25 @@ import { spawnSync } from 'node:child_process';
3
3
  import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
+ import { once } from 'node:events';
6
7
  import { afterEach, test } from 'node:test';
7
8
  import {
9
+ assertPinoRollStorageOptions,
8
10
  BusinessFunctionCatalog,
9
- createLogFileName,
10
- createLogFilePath,
11
+ createPinoRollTransportOptions,
12
+ createRolledLogFileName,
11
13
  createTraceContext,
14
+ ensureLogDirectory,
15
+ HashChainTransform,
12
16
  LOG_DIRECTORY,
17
+ LOG_FILE_MODE,
13
18
  MAX_LOG_FILE_SIZE_BYTES,
14
19
  MIN_NETWORK_LOG_RETENTION_MONTHS,
20
+ parseTraceParent,
15
21
  requireSourceIp,
16
22
  resolveActorContext,
23
+ ROTATION_SIZE,
17
24
  sanitizeLogValue,
18
- SecureLogWriter,
19
25
  toLogEntry,
20
26
  } from '../templates/logger.template.ts';
21
27
  import {
@@ -26,7 +32,6 @@ import {
26
32
  process.env.TZ = 'Asia/Shanghai';
27
33
 
28
34
  const validator = new URL('./validate-log-entry.mjs', import.meta.url);
29
- const loggerTemplate = new URL('../templates/logger.template.ts', import.meta.url);
30
35
  const skillRoot = new URL('..', import.meta.url);
31
36
  const temporaryDirectories = [];
32
37
  const fixedDate = new Date('2026-07-09T08:00:00+08:00');
@@ -43,18 +48,21 @@ async function createTemporaryDirectory() {
43
48
  return directory;
44
49
  }
45
50
 
46
- async function validate(entry, stored = false) {
51
+ async function validate(input, mode = 'entry') {
47
52
  const directory = await createTemporaryDirectory();
48
- const input = join(directory, 'entry.log');
49
- await writeFile(input, stored ? entry : JSON.stringify(entry));
53
+ const fileName = mode === 'stored' ? 'backend.2026-07-09.1.log' : 'entry.json';
54
+ const inputPath = join(directory, fileName);
55
+ await writeFile(inputPath, typeof input === 'string' ? input : JSON.stringify(input));
56
+ const flags = mode === 'stored' ? ['--stored'] : mode === 'storage-options' ? ['--storage-options'] : [];
50
57
  return spawnSync(
51
58
  process.execPath,
52
- [...(stored ? ['--experimental-strip-types'] : []), validator.pathname, ...(stored ? ['--stored'] : []), input],
59
+ [validator.pathname, ...flags, inputPath],
53
60
  { encoding: 'utf8', env: { ...process.env, TZ: 'Asia/Shanghai' } },
54
61
  );
55
62
  }
56
63
 
57
- function validAccessEntry(overrides = {}) {
64
+ function validRequestEntry(overrides = {}) {
65
+ const trace = createTraceContext();
58
66
  return {
59
67
  timestamp: '2026-07-09 08:00:00',
60
68
  timestampEpochMs: fixedDate.getTime(),
@@ -63,9 +71,10 @@ function validAccessEntry(overrides = {}) {
63
71
  service: 'backend',
64
72
  instanceId: 'blue',
65
73
  env: 'production',
66
- logType: 'access',
67
- message: 'request completed',
68
- traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
74
+ logType: 'request',
75
+ message: 'incoming request',
76
+ traceId: trace.traceId,
77
+ spanId: trace.spanId,
69
78
  method: 'GET',
70
79
  route: '/patients/:patientId',
71
80
  sourceIp: '10.0.0.8',
@@ -74,59 +83,40 @@ function validAccessEntry(overrides = {}) {
74
83
  functionCode: 'patient.query',
75
84
  functionName: '患者信息查询',
76
85
  action: 'read',
77
- statusCode: 200,
78
- result: 'success',
79
- durationMs: 12.5,
80
86
  ...overrides,
81
87
  };
82
88
  }
83
89
 
84
- function validSecurityEntry(overrides = {}) {
90
+ function validResponseEntry(overrides = {}) {
91
+ const trace = createTraceContext();
85
92
  return {
86
93
  timestamp: '2026-07-09 08:00:00',
87
94
  timestampEpochMs: fixedDate.getTime(),
88
95
  timezone: '+08:00',
89
- level: 'warn',
96
+ level: 'info',
90
97
  service: 'backend',
91
98
  instanceId: 'blue',
92
99
  env: 'production',
93
- logType: 'security',
94
- message: 'access denied',
95
- eventId: 'event-1',
96
- eventType: 'access.denied',
97
- actorId: 'user-1',
98
- actorType: 'employee',
99
- action: 'patient.read',
100
- targetType: 'patient',
101
- targetId: 'patient-1',
102
- result: 'failure',
103
- reason: 'permission denied',
100
+ logType: 'response',
101
+ message: 'request completed',
102
+ traceId: trace.traceId,
103
+ method: 'GET',
104
+ route: '/patients/:patientId',
105
+ statusCode: 200,
106
+ result: 'success',
107
+ durationMs: 12.5,
104
108
  ...overrides,
105
109
  };
106
110
  }
107
111
 
108
- function runWriter(directory, body) {
109
- const source = `
110
- import { SecureLogWriter, toLogEntry } from ${JSON.stringify(loggerTemplate.href)};
111
- process.env.TZ = 'Asia/Shanghai';
112
- const fixedDate = new Date('2026-07-09T08:00:00+08:00');
113
- const writer = new SecureLogWriter(() => fixedDate);
114
- ${body}
115
- await writer.close();
116
- `;
117
- return spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', source], {
118
- cwd: directory,
119
- encoding: 'utf8',
120
- env: { ...process.env, TZ: 'Asia/Shanghai' },
121
- });
122
- }
123
-
124
- test('固定根日志路径、2MB 阈值和六个月留存合同', () => {
112
+ test('固定根日志路径、2MB 阈值、pino-roll 命名和留存合同', () => {
125
113
  assert.equal(LOG_DIRECTORY, './logs');
126
114
  assert.equal(MAX_LOG_FILE_SIZE_BYTES, 2 * 1024 * 1024);
115
+ assert.equal(ROTATION_SIZE, '2m');
116
+ assert.equal(LOG_FILE_MODE, 0o600);
127
117
  assert.equal(MIN_NETWORK_LOG_RETENTION_MONTHS, 6);
128
- assert.equal(createLogFileName(fixedDate), '2026-07-09_08-00-00.log');
129
- assert.equal(createLogFilePath(fixedDate, 2), './logs/2026-07-09_08-00-00_2.log');
118
+ assert.equal(createRolledLogFileName('backend', fixedDate, 1), 'backend.2026-07-09.1.log');
119
+ assert.equal(createRolledLogFileName('backend', fixedDate, 3), 'backend.2026-07-09.3.log');
130
120
  });
131
121
 
132
122
  test('完整业务结构保留但秘密和敏感个人信息被递归删除', () => {
@@ -154,302 +144,209 @@ test('项目确认的附加敏感字段也会被删除', () => {
154
144
  );
155
145
  });
156
146
 
157
- test('访问日志强制关键字段并映射 HTTP 结果', () => {
147
+ test('请求日志强制关键字段并净化入参', () => {
158
148
  const trace = createTraceContext();
159
- const entry = toLogEntry('info', 'request completed', {
160
- logType: 'access',
149
+ const entry = toLogEntry('info', 'incoming request', {
150
+ logType: 'request',
161
151
  traceId: trace.traceId,
162
152
  spanId: trace.spanId,
163
153
  method: 'POST',
164
- route: '/inventory/:inventoryId',
154
+ route: '/patients/:patientId',
165
155
  sourceIp: '10.0.0.8',
166
156
  actorId: 'user-1',
167
157
  actorType: 'employee',
168
- functionCode: 'inventory.update',
169
- functionName: '库存更新',
170
- action: 'update',
171
- statusCode: 422,
172
- durationMs: 7,
158
+ functionCode: 'patient.create',
159
+ functionName: '患者信息建档',
160
+ action: 'create',
161
+ query: { token: 'secret-value', ward: 'cardiology' },
162
+ body: { patientName: '张三', password: 'not-allowed' },
173
163
  }, fixedDate);
174
164
 
175
- assert.equal(entry.result, 'failure');
176
- assert.equal(entry.timezone, '+08:00');
177
- assert.throws(
178
- () => toLogEntry('info', 'incomplete', { logType: 'access' }, fixedDate),
179
- /Missing access log fields/,
180
- );
165
+ assert.equal(entry.logType, 'request');
166
+ assert.deepEqual(entry.query, { ward: 'cardiology' });
167
+ assert.deepEqual(entry.body, { patientName: '张三' });
181
168
  });
182
169
 
183
- test('显式功能目录按 method 与路由解析且拒绝缺失或重复映射', () => {
184
- const catalog = new BusinessFunctionCatalog([{
170
+ test('响应日志强制关键字段并映射 HTTP 结果', async () => {
171
+ const trace = createTraceContext();
172
+ const success = toLogEntry('info', 'request completed', {
173
+ logType: 'response',
174
+ traceId: trace.traceId,
185
175
  method: 'GET',
186
176
  route: '/patients/:patientId',
187
- functionCode: 'patient.query',
188
- functionName: '患者信息查询',
189
- action: 'read',
190
- }]);
191
- assert.deepEqual(catalog.resolve('get', '/patients/:patientId'), {
192
- functionCode: 'patient.query',
193
- functionName: '患者信息查询',
194
- action: 'read',
195
- });
196
- assert.throws(() => catalog.resolve('POST', '/patients/:patientId'), /Missing business function mapping/);
197
- assert.throws(() => new BusinessFunctionCatalog([
198
- {
199
- method: 'GET', route: '/patients', functionCode: 'patient.list', functionName: '患者列表', action: 'read',
200
- },
201
- {
202
- method: 'get', route: '/patients', functionCode: 'patient.query', functionName: '患者查询', action: 'read',
203
- },
204
- ]), /Duplicate business function mapping/);
205
- });
206
-
207
- test('认证和匿名主体语义明确且不接受不完整身份', () => {
208
- assert.deepEqual(resolveActorContext(), { actorId: 'anonymous', actorType: 'anonymous' });
209
- assert.deepEqual(resolveActorContext({ id: 'user-1', type: 'employee' }), {
210
- actorId: 'user-1', actorType: 'employee',
211
- });
212
- assert.throws(() => resolveActorContext({ id: 'user-1' }), /both id and type/);
213
- });
214
-
215
- test('访问日志拒绝无效 IP、未知功能和缺少主体', async () => {
216
- for (const [overrides, expected] of [
217
- [{ sourceIp: 'unknown' }, /valid IPv4 or IPv6/],
218
- [{ functionCode: undefined }, /Missing access log fields.*functionCode/],
219
- [{ functionCode: 'Patient Query' }, /stable lowercase business identifier/],
220
- [{ functionCode: 'unknown' }, /stable lowercase business identifier/],
221
- [{ functionName: '待确认' }, /resolved non-empty business name/],
222
- [{ actorId: undefined }, /Missing access log fields.*actorId/],
223
- ]) {
224
- const result = await validate(validAccessEntry(overrides));
225
- assert.notEqual(result.status, 0);
226
- assert.match(result.stderr, expected);
227
- }
228
- assert.equal(requireSourceIp('2001:db8::1'), '2001:db8::1');
229
- assert.throws(() => requireSourceIp('unknown'), /valid IPv4 or IPv6/);
230
- });
231
-
232
- test('访问日志拒绝原始查询 URL 和无效 traceId', async () => {
233
- const rawRoute = await validate(validAccessEntry({ route: '/patients/1?token=secret' }));
234
- assert.notEqual(rawRoute.status, 0);
235
- assert.match(rawRoute.stderr, /route template/);
236
-
237
- const invalidTrace = await validate(validAccessEntry({ traceId: '00000000000000000000000000000000' }));
238
- assert.notEqual(invalidTrace.status, 0);
239
- assert.match(invalidTrace.stderr, /traceId/);
240
- });
241
-
242
- test('有效 traceparent 延续 trace,无效值生成新上下文', () => {
243
- const incoming = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01';
244
- const continued = createTraceContext(incoming);
245
- assert.equal(continued.traceId, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
246
- assert.equal(continued.parentSpanId, 'bbbbbbbbbbbbbbbb');
247
- assert.match(continued.traceparent, /^00-a{32}-[0-9a-f]{16}-01$/);
248
-
249
- const replaced = createTraceContext('00-00000000000000000000000000000000-0000000000000000-01');
250
- assert.match(replaced.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
251
- assert.notEqual(replaced.traceId, '00000000000000000000000000000000');
252
- });
177
+ statusCode: 200,
178
+ durationMs: 12.5,
179
+ }, fixedDate);
180
+ const failure = toLogEntry('warn', 'request completed', {
181
+ logType: 'response',
182
+ traceId: trace.traceId,
183
+ method: 'GET',
184
+ route: '/patients/:patientId',
185
+ statusCode: 404,
186
+ durationMs: 8,
187
+ }, fixedDate);
253
188
 
254
- test('安全与审计失败事件必须包含原因', () => {
255
- assert.doesNotThrow(() => toLogEntry('warn', 'denied', {
256
- logType: 'security',
257
- eventId: 'event-1',
258
- eventType: 'access.denied',
259
- actorId: 'user-1',
260
- actorType: 'employee',
261
- action: 'patient.read',
262
- targetType: 'patient',
263
- targetId: 'patient-1',
264
- result: 'failure',
265
- reason: 'permission denied',
266
- }, fixedDate));
189
+ assert.equal(success.result, 'success');
190
+ assert.equal(failure.result, 'failure');
191
+ const mismatch = await validate(validResponseEntry({ statusCode: 404, result: 'success' }));
192
+ assert.notEqual(mismatch.status, 0);
193
+ assert.match(mismatch.stderr, /statusCode and result must match/);
267
194
  assert.throws(
268
- () => toLogEntry('warn', 'denied', {
269
- logType: 'security',
270
- eventId: 'event-1',
271
- eventType: 'access.denied',
272
- actorId: 'user-1',
273
- actorType: 'employee',
274
- action: 'patient.read',
275
- targetType: 'patient',
276
- targetId: 'patient-1',
277
- result: 'failure',
195
+ () => toLogEntry('info', 'bad', {
196
+ logType: 'response',
197
+ traceId: trace.traceId,
198
+ method: 'GET',
199
+ route: '/patients/:patientId',
200
+ statusCode: 200,
278
201
  }, fixedDate),
279
- /require reason/,
202
+ /Missing response log fields/,
280
203
  );
281
- const auditEntry = toLogEntry('info', 'permission changed', {
282
- logType: 'audit',
283
- eventId: 'event-2',
284
- eventType: 'permission.changed',
285
- actorId: 'admin-1',
286
- actorType: 'employee',
287
- action: 'permission.update',
288
- targetType: 'role',
289
- targetId: 'role-1',
290
- result: 'success',
291
- before: { permission: 'read' },
292
- after: { permission: 'write', token: 'not-allowed' },
293
- }, fixedDate);
294
- assert.deepEqual(auditEntry.after, { permission: 'write' });
295
- });
296
-
297
- test('请求触发的安全审计日志要求完整功能和来源上下文', () => {
298
- assert.doesNotThrow(() => toLogEntry('warn', 'denied', {
299
- ...validSecurityEntry(),
300
- traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
301
- sourceIp: '10.0.0.8',
302
- route: '/patients/:patientId',
303
- functionCode: 'patient.query',
304
- functionName: '患者信息查询',
305
- }, fixedDate));
306
- assert.throws(() => toLogEntry('warn', 'denied', {
307
- ...validSecurityEntry(),
308
- traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
309
- sourceIp: '10.0.0.8',
310
- }, fixedDate), /require traceId, sourceIp, route, functionCode and functionName/);
311
- });
312
-
313
- test('校验器拒绝绕过净化的秘密字段', async () => {
314
- const result = await validate(validAccessEntry({ body: { password: 'not-allowed' } }));
315
- assert.notEqual(result.status, 0);
316
- assert.match(result.stderr, /Forbidden log field.*password/);
317
204
  });
318
205
 
319
- test('日志消息换行被编码而不是注入新日志行', () => {
320
- const entry = toLogEntry('info', 'first\nsecond\rthird token=secret-value', {
321
- logType: 'application',
322
- params: { note: 'Bearer abc.def', orderId: 'order-1' },
323
- }, fixedDate);
324
- assert.equal(entry.message, 'first\\nsecond\\rthird token=[REMOVED]');
325
- assert.deepEqual(entry.params, { note: 'Bearer [REMOVED]', orderId: 'order-1' });
326
- assert.equal(JSON.stringify(entry).split('\n').length, 1);
206
+ test('旧 access 与 application 类别被显式拒绝', async () => {
207
+ assert.throws(() => toLogEntry('info', 'legacy', { logType: 'access' }, fixedDate), /Deprecated logType/);
208
+ assert.throws(() => toLogEntry('info', 'legacy', { logType: 'application' }, fixedDate), /Deprecated logType/);
209
+ const legacy = validRequestEntry({ logType: 'access' });
210
+ const validation = await validate(legacy);
211
+ assert.notEqual(validation.status, 0);
212
+ assert.match(validation.stderr, /Deprecated logType/);
327
213
  });
328
214
 
329
- test('前端 trace 在缺少 Web Crypto 时仍有效且不会阻断请求', () => {
330
- const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
331
- Object.defineProperty(globalThis, 'crypto', { configurable: true, value: undefined });
332
- try {
333
- const trace = createFrontendTraceContext();
334
- assert.match(trace.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
335
- } finally {
336
- if (descriptor) Object.defineProperty(globalThis, 'crypto', descriptor);
337
- else delete globalThis.crypto;
338
- }
215
+ test('W3C trace 上下文有效延续、无效替换且不阻断请求', () => {
216
+ const valid = parseTraceParent('00-11111111111111111111111111111111-2222222222222222-01');
217
+ assert.equal(valid?.traceId, '11111111111111111111111111111111');
218
+ assert.notEqual(valid?.spanId, '2222222222222222');
219
+ assert.equal(parseTraceParent('bad'), undefined);
220
+ assert.equal(parseTraceParent('00-00000000000000000000000000000000-2222222222222222-01'), undefined);
221
+ const generated = createTraceContext(undefined);
222
+ assert.match(generated.traceId, /^[0-9a-f]{32}$/);
223
+ assert.match(generated.spanId, /^[0-9a-f]{16}$/);
224
+ const frontendTrace = createFrontendTraceContext();
225
+ assert.match(frontendTrace.traceId, /^[0-9a-f]{32}$/);
226
+ assert.deepEqual(
227
+ sanitizeFrontendErrorReport({ errorName: 'TypeError', message: 'password=abc', stack: 'Bearer abc', path: '/page?token=1' }),
228
+ { errorName: 'TypeError', message: 'password=[REMOVED]', stack: 'Bearer [REMOVED]', path: '/page' },
229
+ );
339
230
  });
340
231
 
341
- test('前端异常上报移除查询串和常见凭据', () => {
342
- const report = sanitizeFrontendErrorReport({
343
- errorName: 'FetchError',
344
- message: 'authorization: Bearer abc.def password=hunter2',
345
- stack: 'token=secret-value',
346
- path: '/login?token=secret#fragment',
347
- occurredAt: '2026-07-09T00:00:00.000Z',
232
+ test('业务功能目录、主体和来源 IP 不接受猜测值', () => {
233
+ const catalog = new BusinessFunctionCatalog([
234
+ { method: 'GET', route: '/patients/:patientId', functionCode: 'patient.query', functionName: '患者信息查询', action: 'read' },
235
+ ]);
236
+ assert.deepEqual(catalog.resolve('get', '/patients/:patientId'), {
237
+ functionCode: 'patient.query',
238
+ functionName: '患者信息查询',
239
+ action: 'read',
348
240
  });
349
- assert.equal(report.path, '/login');
350
- assert.doesNotMatch(JSON.stringify(report), /hunter2|secret-value|abc\.def/);
241
+ assert.throws(() => catalog.resolve('GET', '/unknown'), /Missing business function mapping/);
242
+ assert.deepEqual(resolveActorContext(undefined), { actorId: 'anonymous', actorType: 'anonymous' });
243
+ assert.throws(() => resolveActorContext({ id: 'user-1' }), /both id and type/);
244
+ assert.throws(() => requireSourceIp('unknown'), /valid IPv4 or IPv6/);
351
245
  });
352
246
 
353
- test('真实写入使用最小权限、单行 JSON 和可验证哈希链', async () => {
354
- const directory = await createTemporaryDirectory();
355
- const result = runWriter(directory, `
356
- await writer.write(toLogEntry('info', 'one', { logType: 'application' }, fixedDate));
357
- await writer.write(toLogEntry('info', 'two', { logType: 'application' }, fixedDate));
358
- `);
359
- assert.equal(result.status, 0, result.stderr);
360
-
361
- const logsDirectory = join(directory, 'logs');
362
- const files = await readdir(logsDirectory);
363
- assert.deepEqual(files, ['2026-07-09_08-00-00.log']);
364
- assert.equal((await stat(logsDirectory)).mode & 0o777, 0o700);
365
- const logPath = join(logsDirectory, files[0]);
366
- assert.equal((await stat(logPath)).mode & 0o777, 0o600);
367
- const source = await readFile(logPath, 'utf8');
368
- assert.equal(source.trim().split('\n').length, 2);
369
- const validation = await validate(source, true);
247
+ test('哈希链 transport 输出单行 JSON 并可被完整校验', async () => {
248
+ const requestEntry = validRequestEntry();
249
+ const responseEntry = validResponseEntry({
250
+ traceId: requestEntry.traceId,
251
+ functionCode: 'patient.query',
252
+ functionName: '患者信息查询',
253
+ action: 'read',
254
+ });
255
+ const chain = new HashChainTransform();
256
+ const chunks = [];
257
+ chain.on('data', (chunk) => chunks.push(chunk.toString('utf8')));
258
+ chain.end(`${JSON.stringify(requestEntry)}\n${JSON.stringify(responseEntry)}\n`);
259
+ await once(chain, 'end');
260
+
261
+ const source = chunks.join('');
262
+ const lines = source.trim().split('\n');
263
+ assert.equal(lines.length, 2);
264
+ const first = JSON.parse(lines[0]);
265
+ const second = JSON.parse(lines[1]);
266
+ assert.equal(first.sequence, 1);
267
+ assert.equal(first.previousHash, null);
268
+ assert.equal(second.sequence, 2);
269
+ assert.equal(second.previousHash, first.entryHash);
270
+ assert.match(first.entryHash, /^[0-9a-f]{64}$/);
271
+
272
+ const validation = await validate(`${source}\n`, 'stored');
370
273
  assert.equal(validation.status, 0, validation.stderr);
371
- });
372
274
 
373
- test('多进程同秒排他创建且不覆盖既有文件', async () => {
374
- const directory = await createTemporaryDirectory();
375
- const body = "await writer.write(toLogEntry('info', 'entry', { logType: 'application' }, fixedDate));";
376
- const first = runWriter(directory, body);
377
- const second = runWriter(directory, body);
378
- assert.equal(first.status, 0, first.stderr);
379
- assert.equal(second.status, 0, second.stderr);
380
- assert.deepEqual((await readdir(join(directory, 'logs'))).sort(), [
381
- '2026-07-09_08-00-00.log',
382
- '2026-07-09_08-00-00_1.log',
383
- ]);
275
+ first.message = 'tampered';
276
+ const tampered = await validate(`${JSON.stringify(first)}\n${lines[1]}\n`, 'stored');
277
+ assert.notEqual(tampered.status, 0);
278
+ assert.match(tampered.stderr, /integrity verification/);
384
279
  });
385
280
 
386
- test('超过 2MB 时创建新文件并保留既有文件', async () => {
387
- const directory = await createTemporaryDirectory();
388
- const result = runWriter(directory, `
389
- const payload = 'x'.repeat(1_200_000);
390
- await writer.write(toLogEntry('info', 'first', { logType: 'application', body: { payload } }, fixedDate));
391
- await writer.write(toLogEntry('info', 'second', { logType: 'application', body: { payload } }, fixedDate));
392
- `);
393
- assert.equal(result.status, 0, result.stderr);
394
- assert.deepEqual((await readdir(join(directory, 'logs'))).sort(), [
395
- '2026-07-09_08-00-00.log',
396
- '2026-07-09_08-00-00_1.log',
397
- ]);
398
- });
281
+ test('pino-roll 存储选项固定为 2MB、0600 且禁止删除历史日志', async () => {
282
+ const options = createPinoRollTransportOptions();
283
+ assert.equal(options.target, 'pino-roll');
284
+ assert.equal(options.options.file, './logs/backend');
285
+ assert.equal(options.options.size, '2m');
286
+ assert.equal(options.options.mode, 0o600);
287
+ assert.equal(options.options.mkdir, false);
399
288
 
400
- test('跨秒轮转使用新的基础文件名而非沿用冲突后缀', async () => {
401
289
  const directory = await createTemporaryDirectory();
402
- const source = `
403
- import { SecureLogWriter, toLogEntry } from ${JSON.stringify(loggerTemplate.href)};
404
- process.env.TZ = 'Asia/Shanghai';
405
- const firstDate = new Date('2026-07-09T08:00:00+08:00');
406
- const secondDate = new Date('2026-07-09T08:00:01+08:00');
407
- const dates = [firstDate, secondDate];
408
- const writer = new SecureLogWriter(() => dates.shift() ?? secondDate);
409
- const payload = 'x'.repeat(1_200_000);
410
- await writer.write(toLogEntry('info', 'first', { logType: 'application', body: { payload } }, firstDate));
411
- await writer.write(toLogEntry('info', 'second', { logType: 'application', body: { payload } }, secondDate));
412
- await writer.close();
413
- `;
414
- const result = spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', source], {
415
- cwd: directory,
416
- encoding: 'utf8',
417
- env: { ...process.env, TZ: 'Asia/Shanghai' },
418
- });
419
- assert.equal(result.status, 0, result.stderr);
420
- assert.deepEqual((await readdir(join(directory, 'logs'))).sort(), [
421
- '2026-07-09_08-00-00.log',
422
- '2026-07-09_08-00-01.log',
423
- ]);
290
+ const validPath = join(directory, 'options.json');
291
+ await writeFile(validPath, JSON.stringify(options.options));
292
+ const validation = spawnSync(
293
+ process.execPath,
294
+ [validator.pathname, '--storage-options', validPath],
295
+ { encoding: 'utf8', env: { ...process.env, TZ: 'Asia/Shanghai' } },
296
+ );
297
+ assert.equal(validation.status, 0, validation.stderr);
298
+
299
+ for (const bad of [
300
+ { ...options.options, size: '10m' },
301
+ { ...options.options, mode: 0o644 },
302
+ { ...options.options, mkdir: true },
303
+ { ...options.options, file: './logs/audit/backend.log' },
304
+ { ...options.options, limit: { removeOtherLogFiles: true } },
305
+ { ...options.options, limit: { count: 3 } },
306
+ ]) {
307
+ assert.throws(() => assertPinoRollStorageOptions(bad));
308
+ }
424
309
  });
425
310
 
426
- test('篡改落盘记录后完整性验证失败', async () => {
311
+ test('落盘日志目录必须预先以 0700 创建', async () => {
427
312
  const directory = await createTemporaryDirectory();
428
- const result = runWriter(directory, `
429
- await writer.write(toLogEntry('info', 'original', { logType: 'application' }, fixedDate));
430
- `);
313
+ const template = new URL('../templates/logger.template.ts', import.meta.url);
314
+ const result = spawnSync(
315
+ process.execPath,
316
+ ['--experimental-strip-types', '--input-type=module', '--eval', `
317
+ import { ensureLogDirectory } from ${JSON.stringify(template.href)};
318
+ await ensureLogDirectory();
319
+ `],
320
+ { cwd: directory, encoding: 'utf8', env: { ...process.env, TZ: 'Asia/Shanghai' } },
321
+ );
431
322
  assert.equal(result.status, 0, result.stderr);
432
- const logPath = join(directory, 'logs', '2026-07-09_08-00-00.log');
433
- const entry = JSON.parse((await readFile(logPath, 'utf8')).trim());
434
- entry.message = 'tampered';
435
- const validation = await validate(`${JSON.stringify(entry)}\n`, true);
436
- assert.notEqual(validation.status, 0);
437
- assert.match(validation.stderr, /integrity verification/);
323
+ const logsDirectory = join(directory, 'logs');
324
+ assert.equal((await stat(logsDirectory)).mode & 0o777, 0o700);
325
+ assert.deepEqual(await readdir(logsDirectory), []);
438
326
  });
439
327
 
440
- test('HTTP 与安全审计模板包含真实事件入口且不记录原始 URL', async () => {
441
- const middleware = await readFile(new URL('templates/nestjs-http-logging.middleware.template.ts', skillRoot), 'utf8');
328
+ test('nestjs-pino 与安全审计模板包含真实接入入口且不记录原始 URL', async () => {
329
+ const http = await readFile(new URL('templates/nestjs-http-logging.middleware.template.ts', skillRoot), 'utf8');
442
330
  const securityLogger = await readFile(new URL('templates/security-audit-logger.template.ts', skillRoot), 'utf8');
443
- assert.match(middleware, /response\.once\('finish'/);
444
- assert.match(middleware, /traceparent/);
445
- assert.match(middleware, /functionCatalog\.resolve/);
446
- assert.match(middleware, /resolveActorContext/);
447
- assert.match(middleware, /requireSourceIp/);
448
- assert.doesNotMatch(middleware, /originalUrl/);
449
- assert.doesNotMatch(middleware, /['"]unknown['"]/);
450
- assert.match(securityLogger, /functionCode/);
451
- assert.match(securityLogger, /functionName/);
331
+ const pinoLogger = await readFile(new URL('templates/pino-logger.template.ts', skillRoot), 'utf8');
332
+ assert.match(http, /LoggerModule\.forRoot/);
333
+ assert.match(http, /pinoHttp/);
334
+ assert.match(http, /customProps/);
335
+ assert.match(http, /logType: 'request'/);
336
+ assert.match(http, /logType: 'response'/);
337
+ assert.match(http, /traceparent/);
338
+ assert.match(http, /functionCatalog\.resolve/);
339
+ assert.match(http, /resolveActorContext/);
340
+ assert.match(http, /requireSourceIp/);
341
+ assert.doesNotMatch(http, /originalUrl/);
342
+ assert.doesNotMatch(http, /['"]unknown['"]/);
343
+ assert.match(securityLogger, /pino/);
344
+ assert.match(securityLogger, /assertSecurityAuditContext/);
452
345
  for (const eventName of ['auth.login.failure', 'access.denied', 'permission.changed', 'sensitive-data.export']) {
453
346
  assert.match(securityLogger, new RegExp(eventName.replaceAll('.', '\\.')));
454
347
  }
348
+ assert.match(pinoLogger, /pino\.transport/);
349
+ assert.match(pinoLogger, /HashChainTransform/);
350
+ assert.match(pinoLogger, /ensureLogDirectory/);
351
+ assert.match(pinoLogger, /createPinoRollTransportOptions/);
455
352
  });