@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
@@ -1,9 +1,9 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
- import { chmod, mkdir, open } from 'node:fs/promises';
3
- import type { FileHandle } from 'node:fs/promises';
2
+ import { chmod, mkdir } from 'node:fs/promises';
4
3
  import { isIP } from 'node:net';
4
+ import { Transform } from 'node:stream';
5
5
 
6
- export type LogType = 'access' | 'application' | 'security' | 'audit';
6
+ export type LogType = 'request' | 'response' | 'security' | 'audit';
7
7
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
8
8
  export type LogResult = 'success' | 'failure';
9
9
 
@@ -80,16 +80,31 @@ export type TraceContext = {
80
80
  traceparent: string;
81
81
  };
82
82
 
83
+ export type PinoRollStorageOptions = {
84
+ file: string;
85
+ size: string;
86
+ mode: number;
87
+ append: boolean;
88
+ mkdir: boolean;
89
+ dateFormat?: string;
90
+ limit?: { count?: number; removeOtherLogFiles?: boolean };
91
+ };
92
+
83
93
  export const LOG_DIRECTORY = './logs' as const;
94
+ export const LOG_DIRECTORY_MODE = 0o700;
95
+ export const LOG_FILE_MODE = 0o600;
84
96
  export const MAX_LOG_FILE_SIZE_BYTES = 2 * 1024 * 1024;
97
+ export const ROTATION_SIZE = '2m';
85
98
  export const MIN_NETWORK_LOG_RETENTION_MONTHS = 6;
86
99
 
100
+ const DEPRECATED_LOG_TYPES = new Set(['access', 'application']);
101
+
87
102
  const REQUIRED_BY_TYPE: Record<LogType, readonly string[]> = {
88
- access: [
103
+ request: [
89
104
  'traceId', 'method', 'route', 'sourceIp', 'actorId', 'actorType',
90
- 'functionCode', 'functionName', 'action', 'statusCode', 'result', 'durationMs',
105
+ 'functionCode', 'functionName', 'action',
91
106
  ],
92
- application: [],
107
+ response: ['traceId', 'method', 'route', 'statusCode', 'result', 'durationMs'],
93
108
  security: ['eventId', 'eventType', 'actorId', 'actorType', 'action', 'targetType', 'targetId', 'result'],
94
109
  audit: ['eventId', 'eventType', 'actorId', 'actorType', 'action', 'targetType', 'targetId', 'result'],
95
110
  };
@@ -200,14 +215,34 @@ export const formatTimezoneOffset = (date = new Date()): string => {
200
215
  return `${sign}${pad(Math.floor(absoluteMinutes / 60))}:${pad(absoluteMinutes % 60)}`;
201
216
  };
202
217
 
203
- export const createLogFileName = (date = new Date(), collisionIndex = 0): string => {
204
- const baseName = formatLocalTimestamp(date).replace(' ', '_').replaceAll(':', '-');
205
- return `${baseName}${collisionIndex > 0 ? `_${collisionIndex}` : ''}.log`;
218
+ export const assertCstRuntime = (date = new Date()): void => {
219
+ if (formatTimezoneOffset(date) !== '+08:00') {
220
+ throw new Error('logging runtime timezone must be +08:00 (Asia/Shanghai)');
221
+ }
206
222
  };
207
223
 
208
- export const createLogFilePath = (date = new Date(), collisionIndex = 0): string => (
209
- `${LOG_DIRECTORY}/${createLogFileName(date, collisionIndex)}`
210
- );
224
+ const getRuntimeEnvironment = (): Record<string, string | undefined> => {
225
+ const runtime = globalThis as typeof globalThis & {
226
+ process?: { env?: Record<string, string | undefined> };
227
+ };
228
+ return runtime.process?.env ?? {};
229
+ };
230
+
231
+ export const createLogMixin = (
232
+ now: () => Date = () => new Date(),
233
+ ): (() => Record<string, unknown>) => () => {
234
+ const date = now();
235
+ assertCstRuntime(date);
236
+ const environment = getRuntimeEnvironment();
237
+ return {
238
+ timestamp: formatLocalTimestamp(date),
239
+ timestampEpochMs: date.getTime(),
240
+ timezone: '+08:00',
241
+ service: environment.SERVICE_NAME ?? 'backend',
242
+ instanceId: environment.INSTANCE_ID ?? 'local',
243
+ env: environment.NODE_ENV ?? 'development',
244
+ };
245
+ };
211
246
 
212
247
  const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
213
248
  const NON_ZERO_TRACE_ID = /[1-9a-f]/;
@@ -233,13 +268,6 @@ export const createTraceContext = (incomingTraceParent?: string): TraceContext =
233
268
  return { traceId, spanId, traceparent: `00-${traceId}-${spanId}-01` };
234
269
  };
235
270
 
236
- const getRuntimeEnvironment = (): Record<string, string | undefined> => {
237
- const runtime = globalThis as typeof globalThis & {
238
- process?: { env?: Record<string, string | undefined> };
239
- };
240
- return runtime.process?.env ?? {};
241
- };
242
-
243
271
  const isPresent = (value: unknown): boolean => (
244
272
  value !== undefined && value !== null && (typeof value !== 'string' || value.trim() !== '')
245
273
  );
@@ -338,6 +366,22 @@ const assertTraceId = (traceId: unknown): void => {
338
366
  }
339
367
  };
340
368
 
369
+ export const assertSecurityAuditContext = (entry: LogContext): void => {
370
+ const requestFields = ['traceId', 'sourceIp', 'route', 'functionCode', 'functionName'] as const;
371
+ const presentRequestFields = requestFields.filter((key) => isPresent(entry[key]));
372
+ if (presentRequestFields.length > 0 && presentRequestFields.length !== requestFields.length) {
373
+ throw new Error(`Request-triggered ${entry.logType} logs require traceId, sourceIp, route, functionCode and functionName`);
374
+ }
375
+ if (presentRequestFields.length === requestFields.length) {
376
+ assertSourceIp(entry.sourceIp);
377
+ assertRouteTemplate(entry.route);
378
+ assertBusinessFunction(entry);
379
+ }
380
+ if (entry.result === 'failure' && !isPresent(entry.reason)) {
381
+ throw new Error(`Failed ${entry.logType} logs require reason`);
382
+ }
383
+ };
384
+
341
385
  export const assertLogEntry = (entry: LogEntry): void => {
342
386
  const commonRequired = [
343
387
  'timestamp', 'timestampEpochMs', 'timezone', 'level', 'service', 'instanceId', 'env', 'logType', 'message',
@@ -351,15 +395,22 @@ export const assertLogEntry = (entry: LogEntry): void => {
351
395
  throw new Error('timestampEpochMs must be a positive safe integer');
352
396
  }
353
397
  if (entry.timezone !== '+08:00') throw new Error('timezone must be +08:00');
398
+ if (DEPRECATED_LOG_TYPES.has(entry.logType)) {
399
+ throw new Error(`Deprecated logType: ${entry.logType}`);
400
+ }
354
401
  if (!Object.hasOwn(REQUIRED_BY_TYPE, entry.logType)) throw new Error('Unknown logType');
355
402
 
356
403
  const typeMissing = REQUIRED_BY_TYPE[entry.logType].filter((key) => !isPresent(entry[key]));
357
404
  if (typeMissing.length) throw new Error(`Missing ${entry.logType} log fields: ${typeMissing.join(', ')}`);
358
- if (entry.logType === 'access') {
405
+ if (entry.logType === 'request') {
359
406
  assertTraceId(entry.traceId);
360
407
  assertRouteTemplate(entry.route);
361
408
  assertSourceIp(entry.sourceIp);
362
409
  assertBusinessFunction(entry);
410
+ }
411
+ if (entry.logType === 'response') {
412
+ assertTraceId(entry.traceId);
413
+ assertRouteTemplate(entry.route);
363
414
  const expectedResult = resultForStatusCode(Number(entry.statusCode));
364
415
  if (!expectedResult || entry.result !== expectedResult) {
365
416
  throw new Error('statusCode and result must match');
@@ -369,19 +420,7 @@ export const assertLogEntry = (entry: LogEntry): void => {
369
420
  }
370
421
  }
371
422
  if (entry.logType === 'security' || entry.logType === 'audit') {
372
- const requestFields = ['traceId', 'sourceIp', 'route', 'functionCode', 'functionName'] as const;
373
- const presentRequestFields = requestFields.filter((key) => isPresent(entry[key]));
374
- if (presentRequestFields.length > 0 && presentRequestFields.length !== requestFields.length) {
375
- throw new Error(`Request-triggered ${entry.logType} logs require traceId, sourceIp, route, functionCode and functionName`);
376
- }
377
- if (presentRequestFields.length === requestFields.length) {
378
- assertSourceIp(entry.sourceIp);
379
- assertRouteTemplate(entry.route);
380
- assertBusinessFunction(entry);
381
- }
382
- }
383
- if ((entry.logType === 'security' || entry.logType === 'audit') && entry.result === 'failure' && !isPresent(entry.reason)) {
384
- throw new Error('Failed security and audit logs require reason');
423
+ assertSecurityAuditContext(entry);
385
424
  }
386
425
  if (entry.traceId !== undefined) assertTraceId(entry.traceId);
387
426
  const forbiddenPath = findForbiddenLogFieldPath(entry);
@@ -396,14 +435,15 @@ export const toLogEntry = (
396
435
  additionalForbiddenNames: Iterable<string> = [],
397
436
  ): LogEntry => {
398
437
  const environment = getRuntimeEnvironment();
438
+ assertCstRuntime(date);
399
439
  const sanitizedContext = sanitizeLogValue(context, additionalForbiddenNames) as LogContext;
400
- const statusResult = sanitizedContext.logType === 'access' && typeof sanitizedContext.statusCode === 'number'
440
+ const statusResult = sanitizedContext.logType === 'response' && typeof sanitizedContext.statusCode === 'number'
401
441
  ? resultForStatusCode(sanitizedContext.statusCode)
402
442
  : undefined;
403
443
  const entry = {
404
444
  timestamp: formatLocalTimestamp(date),
405
445
  timestampEpochMs: date.getTime(),
406
- timezone: formatTimezoneOffset(date),
446
+ timezone: '+08:00',
407
447
  level,
408
448
  service: environment.SERVICE_NAME ?? 'backend',
409
449
  instanceId: environment.INSTANCE_ID ?? 'local',
@@ -418,73 +458,111 @@ export const toLogEntry = (
418
458
 
419
459
  const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
420
460
 
421
- export class SecureLogWriter {
422
- private currentHandle?: FileHandle;
423
- private currentSize = 0;
424
- private sequence = 0;
425
- private previousHash: string | null = null;
426
- private pending: Promise<void> = Promise.resolve();
427
- private readonly chainId = randomUUID();
428
- private readonly now: () => Date;
461
+ export const createRolledLogFileName = (
462
+ baseFileName: string,
463
+ date = new Date(),
464
+ count = 1,
465
+ ): string => {
466
+ if (typeof baseFileName !== 'string' || !/^[a-z0-9][a-z0-9-]*$/i.test(baseFileName)) {
467
+ throw new Error('baseFileName must be a simple file name without path separators');
468
+ }
469
+ if (!Number.isSafeInteger(count) || count < 1) {
470
+ throw new Error('rotation count must be a positive integer');
471
+ }
472
+ const day = [date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate())].join('-');
473
+ return `${baseFileName}.${day}.${count}.log`;
474
+ };
429
475
 
430
- constructor(now: () => Date = () => new Date()) {
431
- this.now = now;
476
+ export const assertRolledLogFileName = (fileName: string): void => {
477
+ if (!/^[a-z0-9][a-z0-9-]*(?:\.\d{4}-\d{2}-\d{2})?(?:\.\d+)?\.log$/i.test(fileName)) {
478
+ throw new Error(`log file name must follow filename[.date][.count].log: ${fileName}`);
432
479
  }
480
+ };
433
481
 
434
- write(entry: LogEntry): Promise<StoredLogEntry> {
435
- let storedEntry: StoredLogEntry | undefined;
436
- const operation = this.pending.then(async () => {
437
- storedEntry = await this.writeNow(entry);
438
- });
439
- this.pending = operation;
440
- return operation.then(() => storedEntry as StoredLogEntry);
482
+ export const createPinoRollTransportOptions = (baseFileName = 'backend') => ({
483
+ target: 'pino-roll',
484
+ options: {
485
+ file: `${LOG_DIRECTORY}/${baseFileName}`,
486
+ size: ROTATION_SIZE,
487
+ mode: LOG_FILE_MODE,
488
+ append: true,
489
+ mkdir: false,
490
+ dateFormat: 'yyyy-MM-dd',
491
+ } satisfies PinoRollStorageOptions,
492
+ });
493
+
494
+ export const assertPinoRollStorageOptions = (options: PinoRollStorageOptions): void => {
495
+ if (typeof options.file !== 'string' || !options.file.startsWith(`${LOG_DIRECTORY}/`)) {
496
+ throw new Error(`log file must be directly under ${LOG_DIRECTORY}`);
497
+ }
498
+ if (options.file.slice(LOG_DIRECTORY.length + 1).includes('/')) {
499
+ throw new Error('log subdirectories are not allowed');
441
500
  }
501
+ if (options.size !== ROTATION_SIZE) {
502
+ throw new Error(`rotation size must be ${ROTATION_SIZE}`);
503
+ }
504
+ if (options.mode !== LOG_FILE_MODE) {
505
+ throw new Error('new log files must use mode 0600');
506
+ }
507
+ if (options.append !== true) {
508
+ throw new Error('log files must be append-only');
509
+ }
510
+ if (options.mkdir !== false) {
511
+ throw new Error(`create ${LOG_DIRECTORY} with mode 0700 before logger initialization`);
512
+ }
513
+ if (options.limit?.removeOtherLogFiles === true) {
514
+ throw new Error('automatic removal of other log files is not allowed');
515
+ }
516
+ if (typeof options.limit?.count === 'number' && Number.isFinite(options.limit.count)) {
517
+ throw new Error('automatic deletion by file count is not allowed');
518
+ }
519
+ };
442
520
 
443
- async close(): Promise<void> {
444
- await this.pending;
445
- if (this.currentHandle) {
446
- await this.currentHandle.close();
447
- this.currentHandle = undefined;
448
- }
521
+ export const ensureLogDirectory = async (): Promise<void> => {
522
+ await mkdir(LOG_DIRECTORY, { recursive: true, mode: LOG_DIRECTORY_MODE });
523
+ await chmod(LOG_DIRECTORY, LOG_DIRECTORY_MODE);
524
+ };
525
+
526
+ export class HashChainTransform extends Transform {
527
+ private buffer = '';
528
+ private sequence = 0;
529
+ private previousHash: string | null = null;
530
+ private readonly chainId = randomUUID();
531
+
532
+ constructor() {
533
+ super({ objectMode: false });
449
534
  }
450
535
 
451
- private async writeNow(entry: LogEntry): Promise<StoredLogEntry> {
536
+ applyChain(line: string): string {
537
+ const entry = JSON.parse(line) as LogEntry;
452
538
  assertLogEntry(entry);
453
539
  const sequence = this.sequence + 1;
454
540
  const unsigned = { ...entry, chainId: this.chainId, sequence, previousHash: this.previousHash };
455
541
  const entryHash = sha256(JSON.stringify(unsigned));
456
- const storedEntry: StoredLogEntry = { ...unsigned, entryHash };
457
- const serialized = `${JSON.stringify(storedEntry)}\n`;
458
- const serializedBytes = Buffer.byteLength(serialized);
459
-
460
- await this.ensureWritableFile(serializedBytes);
461
- const handle = this.currentHandle;
462
- if (!handle) throw new Error('Log file is not open after initialization');
463
- await handle.appendFile(serialized, 'utf8');
464
- this.currentSize += serializedBytes;
465
542
  this.sequence = sequence;
466
543
  this.previousHash = entryHash;
467
- return storedEntry;
468
- }
469
-
470
- private async ensureWritableFile(nextEntryBytes: number): Promise<void> {
471
- if (!this.currentHandle || this.currentSize + nextEntryBytes > MAX_LOG_FILE_SIZE_BYTES) {
472
- if (this.currentHandle) await this.currentHandle.close();
473
- this.currentHandle = undefined;
474
- await mkdir(LOG_DIRECTORY, { recursive: true, mode: 0o700 });
475
- await chmod(LOG_DIRECTORY, 0o700);
476
- const date = this.now();
477
- let collisionIndex = 0;
478
- while (!this.currentHandle) {
479
- const candidate = createLogFilePath(date, collisionIndex);
480
- try {
481
- this.currentHandle = await open(candidate, 'wx', 0o600);
482
- } catch (error) {
483
- if ((error as { code?: string }).code !== 'EEXIST') throw error;
484
- collisionIndex += 1;
485
- }
486
- }
487
- this.currentSize = 0;
544
+ return JSON.stringify({ ...unsigned, entryHash });
545
+ }
546
+
547
+ override _transform(
548
+ chunk: Buffer | string,
549
+ encoding: BufferEncoding,
550
+ callback: (error?: Error | null, data?: never) => void,
551
+ ): void {
552
+ this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
553
+ let newlineIndex = this.buffer.indexOf('\n');
554
+ while (newlineIndex >= 0) {
555
+ const line = this.buffer.slice(0, newlineIndex);
556
+ this.buffer = this.buffer.slice(newlineIndex + 1);
557
+ if (line !== '') this.push(`${this.applyChain(line)}\n`);
558
+ newlineIndex = this.buffer.indexOf('\n');
488
559
  }
560
+ callback();
561
+ }
562
+
563
+ override _flush(callback: (error?: Error | null, data?: never) => void): void {
564
+ if (this.buffer !== '') this.push(`${this.applyChain(this.buffer)}\n`);
565
+ this.buffer = '';
566
+ callback();
489
567
  }
490
568
  }
@@ -1,68 +1,90 @@
1
- import { Inject, Injectable, type NestMiddleware } from '@nestjs/common';
1
+ import { Injectable, type NestMiddleware } from '@nestjs/common';
2
+ import { LoggerModule } from 'nestjs-pino';
2
3
  import type { NextFunction, Request, Response } from 'express';
3
4
  import {
4
5
  BusinessFunctionCatalog,
5
6
  createTraceContext,
6
7
  requireSourceIp,
7
8
  resolveActorContext,
8
- SecureLogWriter,
9
- toLogEntry,
9
+ sanitizeLogValue,
10
10
  type TraceContext,
11
11
  } from './logger';
12
12
 
13
- export const BUSINESS_FUNCTION_CATALOG = Symbol('BUSINESS_FUNCTION_CATALOG');
14
-
15
13
  type LoggedRequest = Request & {
16
14
  traceContext?: TraceContext;
17
15
  user?: { id?: string; type?: string };
18
16
  };
19
17
 
20
- @Injectable()
21
- export class HttpLoggingMiddleware implements NestMiddleware {
22
- constructor(
23
- private readonly writer: SecureLogWriter,
24
- @Inject(BUSINESS_FUNCTION_CATALOG)
25
- private readonly functionCatalog: BusinessFunctionCatalog,
26
- ) {}
18
+ type LoggedResponse = Response & { responseTime?: number };
19
+
20
+ const resolveRouteTemplate = (request: LoggedRequest): string => {
21
+ const routePath = request.route?.path;
22
+ return typeof routePath === 'string' ? `${request.baseUrl}${routePath}` : '';
23
+ };
27
24
 
25
+ const resultForStatusCode = (statusCode: number): 'success' | 'failure' => (
26
+ statusCode >= 100 && statusCode <= 399 ? 'success' : 'failure'
27
+ );
28
+
29
+ @Injectable()
30
+ export class TraceContextMiddleware implements NestMiddleware {
28
31
  use(request: LoggedRequest, response: Response, next: NextFunction): void {
29
- const incomingTraceParent = request.header('traceparent');
30
- const trace = createTraceContext(incomingTraceParent);
32
+ const trace = createTraceContext(request.header('traceparent'));
31
33
  request.traceContext = trace;
32
34
  response.setHeader('traceparent', trace.traceparent);
33
35
  response.setHeader('x-trace-id', trace.traceId);
34
- const startedAt = performance.now();
35
-
36
- response.once('finish', () => {
37
- const routePath = request.route?.path;
38
- const route = typeof routePath === 'string'
39
- ? `${request.baseUrl}${routePath}`
40
- : '';
41
- const businessFunction = this.functionCatalog.resolve(request.method, route);
42
- const actor = resolveActorContext(request.user);
43
- const sourceIp = requireSourceIp(request.ip || request.socket.remoteAddress);
44
- const entry = toLogEntry('info', 'http request completed', {
45
- logType: 'access',
46
- traceId: trace.traceId,
47
- spanId: trace.spanId,
48
- parentSpanId: trace.parentSpanId,
49
- ...actor,
50
- ...businessFunction,
51
- method: request.method,
52
- route,
53
- sourceIp,
54
- userAgent: request.header('user-agent'),
55
- query: request.query as Record<string, unknown>,
56
- body: request.body,
57
- statusCode: response.statusCode,
58
- durationMs: Math.max(0, performance.now() - startedAt),
59
- });
60
-
61
- this.writer.write(entry).catch((error: unknown) => {
62
- queueMicrotask(() => { throw error; });
63
- });
64
- });
65
-
66
36
  next();
67
37
  }
68
38
  }
39
+
40
+ export const createNestPinoHttpOptions = (
41
+ stream: NodeJS.ReadableStream,
42
+ functionCatalog: BusinessFunctionCatalog,
43
+ ) => ({
44
+ stream,
45
+ level: 'info',
46
+ customProps: (request: LoggedRequest, response: LoggedResponse) => {
47
+ const route = resolveRouteTemplate(request);
48
+ const businessFunction = functionCatalog.resolve(request.method, route);
49
+ const actor = resolveActorContext(request.user);
50
+ const sourceIp = requireSourceIp(request.ip || request.socket.remoteAddress);
51
+ const trace = request.traceContext ?? createTraceContext(request.header('traceparent'));
52
+
53
+ const shared = {
54
+ traceId: trace.traceId,
55
+ spanId: trace.spanId,
56
+ parentSpanId: trace.parentSpanId,
57
+ method: request.method,
58
+ route,
59
+ sourceIp,
60
+ userAgent: request.header('user-agent'),
61
+ ...actor,
62
+ ...businessFunction,
63
+ };
64
+
65
+ if (!response.writableEnded) {
66
+ return {
67
+ ...shared,
68
+ logType: 'request',
69
+ query: sanitizeLogValue(request.query),
70
+ body: sanitizeLogValue(request.body),
71
+ };
72
+ }
73
+
74
+ return {
75
+ ...shared,
76
+ logType: 'response',
77
+ statusCode: response.statusCode,
78
+ result: resultForStatusCode(response.statusCode),
79
+ durationMs: response.responseTime ?? 0,
80
+ response: sanitizeLogValue(response.locals?.logResponse),
81
+ };
82
+ },
83
+ });
84
+
85
+ export const createNestHttpLoggingModule = (
86
+ stream: NodeJS.ReadableStream,
87
+ functionCatalog: BusinessFunctionCatalog,
88
+ ) => LoggerModule.forRoot({
89
+ pinoHttp: createNestPinoHttpOptions(stream, functionCatalog),
90
+ });
@@ -0,0 +1,38 @@
1
+ import pino from 'pino';
2
+ import {
3
+ assertPinoRollStorageOptions,
4
+ createLogMixin,
5
+ createPinoRollTransportOptions,
6
+ ensureLogDirectory,
7
+ HashChainTransform,
8
+ type PinoRollStorageOptions,
9
+ } from './logger';
10
+
11
+ export type SecurePinoLogger = {
12
+ logger: pino.Logger;
13
+ chain: HashChainTransform;
14
+ roll: ReturnType<typeof pino.transport>;
15
+ };
16
+
17
+ export const createSecurePinoLogger = async (
18
+ options: { baseFileName?: string } = {},
19
+ ): Promise<SecurePinoLogger> => {
20
+ const transportOptions = createPinoRollTransportOptions(options.baseFileName);
21
+ assertPinoRollStorageOptions(transportOptions.options as PinoRollStorageOptions);
22
+ await ensureLogDirectory();
23
+
24
+ const roll = pino.transport(transportOptions);
25
+ const chain = new HashChainTransform();
26
+ chain.pipe(roll);
27
+
28
+ const logger = pino(
29
+ {
30
+ base: null,
31
+ formatters: { level: (label) => ({ level: label }) },
32
+ mixin: createLogMixin(),
33
+ },
34
+ chain,
35
+ );
36
+
37
+ return { logger, chain, roll };
38
+ };
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { SecureLogWriter, toLogEntry, type LogResult } from './logger';
2
+ import type { Logger } from 'pino';
3
+ import { assertSecurityAuditContext, sanitizeLogValue, type LogContext, type LogResult } from './logger';
3
4
 
4
5
  export const REQUIRED_SECURITY_EVENTS = [
5
6
  'auth.login.success',
@@ -43,19 +44,13 @@ export type SecurityAuditEvent = {
43
44
  };
44
45
 
45
46
  export class SecurityAuditLogger {
46
- constructor(private readonly writer: SecureLogWriter) {}
47
+ constructor(private readonly logger: Logger) {}
47
48
 
48
- async record(event: SecurityAuditEvent): Promise<void> {
49
- const requestFields = ['traceId', 'sourceIp', 'route', 'functionCode', 'functionName'] as const;
50
- const presentRequestFields = requestFields.filter((field) => event[field] !== undefined && event[field] !== '');
51
- if (presentRequestFields.length > 0 && presentRequestFields.length !== requestFields.length) {
52
- throw new Error('Request-triggered security and audit events require traceId, sourceIp, route, functionCode and functionName');
53
- }
54
- const entry = toLogEntry(
55
- event.result === 'failure' ? 'warn' : 'info',
56
- event.eventType,
57
- { ...event, eventId: randomUUID() },
58
- );
59
- await this.writer.write(entry);
49
+ record(event: SecurityAuditEvent): void {
50
+ const context: LogContext = { ...event, eventId: randomUUID() };
51
+ assertSecurityAuditContext(context);
52
+ const sanitized = sanitizeLogValue(context) as SecurityAuditEvent & { eventId: string };
53
+ const level = event.result === 'failure' ? 'warn' : 'info';
54
+ this.logger[level](sanitized, event.eventType);
60
55
  }
61
56
  }
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: wdyy-safety-review
3
+ description: 对整个软件执行证据驱动的安全审核并生成四列 Markdown 检查清单。Use when 审查或设计身份认证与权限、Web/API、文件上传下载、敏感数据、源码与依赖、安全日志、漏洞扫描或漏洞验证;即使用户只说“安全检查”“代码安全”“接口安全”“扫描漏洞”也应使用本 Skill。
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # 软件安全审核规范
8
+
9
+ ## 审核边界
10
+
11
+ 本 Skill 审核整个软件,按六类组织:身份认证与权限、Web/API 安全、文件安全、数据与敏感信息安全、代码与依赖安全、安全日志与漏洞验证。公网接口是 Web/API 类中的检查项,不是唯一或默认主范围。
12
+
13
+ 默认仅执行源码、配置、依赖清单、部署文件和已提供报告的只读审查。任何会向运行目标发送流量、改变状态或处理真实数据的扫描、漏洞验证或渗透测试,均须先由工程师明确确认:资产授权、目标、环境、允许技术、速率、时间窗、联系人、停止条件和证据处理规则。任一项缺失时停止主动测试,并在清单中标记 `未检查`。
14
+
15
+ ## 输入与确认项
16
+
17
+ - 输入:代码仓库、技术栈、依赖清单与锁文件、部署配置、OpenAPI/接口文档、角色权限模型、数据分类、现有安全报告和运行证据。
18
+ - 确认:审核范围、环境、数据处理边界、主动测试授权,以及发现项的责任人和整改期限。
19
+ - 不得请求、回显或写入真实密码、Token、密钥、患者信息、身份证件或其他敏感数据;发现秘密时仅记录类型和脱敏位置。
20
+
21
+ ## 执行流程
22
+
23
+ 1. 盘点代码、服务、入口、角色、数据类型、文件处理链路、依赖、部署边界和已有安全控制;先列证据,再给结论。
24
+ 2. 完整阅读 [六类审核规则](references/security-review-rules.md),按适用技术栈选择检查项;不适用必须说明技术事实。
25
+ 3. 检查身份认证与权限:登录、密码 Hash、Token/JWT、Session、验证码、失效、RBAC、接口权限,以及水平越权、垂直越权和未授权访问。
26
+ 4. 检查 Web/API 和文件:注入、XSS、CSRF、SSRF、路径遍历、参数篡改、重放、遍历、限流、异常泄露,及上传、下载、预览、导出的类型、大小、恶意文件、覆盖和越权。
27
+ 5. 检查数据与供应链:最小必要、脱敏、HTTPS、敏感字段返回、日志泄露、硬编码秘密、SAST、SCA、npm/Maven 漏洞、过期组件、SBOM、Git 秘密、危险函数和不安全配置。
28
+ 6. 检查安全日志与漏洞验证:登录失败、权限拒绝、数据修改、敏感操作的审计证据;日志实现或整改使用 `wdyy-logging-standard`。主动验证只在授权完整时执行,并保存最小化证据和复测结果。
29
+ 7. 复制 [安全检查清单模板](templates/security-checklist.template.md),用真实证据填充后运行 `node scripts/validate-security-checklist.mjs <清单路径>`。
30
+
31
+ ## 结论规则
32
+
33
+ - 结果只能为 `通过`、`不通过`、`未检查`、`不适用`。
34
+ - `通过`:提供充分证据,修复方案写“无需修复”。
35
+ - `不通过`:给出可执行的修复方案与验证方式。
36
+ - `未检查`:说明缺少的证据、授权或执行条件;不得写成已经通过。
37
+ - `不适用`:说明当前技术栈或功能范围为何不适用。
38
+ - 扫描器结果需要结合可达性、数据流、数据敏感度和业务影响人工复核,不能直接等同最终风险结论。
39
+
40
+ ## 交付格式
41
+
42
+ 在范围和限制的简短说明后,输出一个 Markdown 表格;该表格只能使用以下四列,且六类检查均至少一项:
43
+
44
+ | 检查类型 | 检查项目 | 检查结果 | 修复方案 |
45
+ | --- | --- | --- | --- |
46
+
47
+ 检查项目中写脱敏后的证据位置;检查结果中写简短判定依据。不得增加“风险等级”“责任人”等表格列。
48
+
49
+ ## 验证
50
+
51
+ - [ ] 所有结论关联真实、脱敏的证据位置;未执行项目均为 `未检查`。
52
+ - [ ] 清单只有四列,覆盖六类检查,结果值与修复方案符合合同。
53
+ - [ ] 主动扫描、漏洞验证或渗透测试具有完整授权;否则没有主动流量。
54
+ - [ ] `node --test scripts/validate-security-checklist.test.mjs` 与清单校验器通过。
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "wdyy-safety-review"
3
+ short_description: "审核软件安全并生成四列整改清单"
4
+ default_prompt: "Use $wdyy-safety-review to review the whole software across identity and permissions, Web/API, files, sensitive data, dependencies, and security logging; produce an evidence-based four-column Markdown checklist."