@wdyy/skills 0.1.28 → 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.
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: wdyy-safety-review
3
3
  description: 对整个软件执行证据驱动的安全审核并生成四列 Markdown 检查清单。Use when 审查或设计身份认证与权限、Web/API、文件上传下载、敏感数据、源码与依赖、安全日志、漏洞扫描或漏洞验证;即使用户只说“安全检查”“代码安全”“接口安全”“扫描漏洞”也应使用本 Skill。
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # 软件安全审核规范
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdyy/skills",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "企业内部开发 Skill 与项目初始化命令",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@12.1.0",