@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.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +16 -13
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +17 -8
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.mjs +23 -4
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +200 -303
- package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +167 -89
- package/.well-known/skills/wdyy-logging-standard/templates/nestjs-http-logging.middleware.template.ts +68 -46
- package/.well-known/skills/wdyy-logging-standard/templates/pino-logger.template.ts +38 -0
- package/.well-known/skills/wdyy-logging-standard/templates/security-audit-logger.template.ts +9 -14
- package/.well-known/skills/wdyy-safety-review/SKILL.md +1 -0
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir
|
|
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 = '
|
|
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
|
-
|
|
103
|
+
request: [
|
|
89
104
|
'traceId', 'method', 'route', 'sourceIp', 'actorId', 'actorType',
|
|
90
|
-
'functionCode', 'functionName', 'action',
|
|
105
|
+
'functionCode', 'functionName', 'action',
|
|
91
106
|
],
|
|
92
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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 === '
|
|
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
|
-
|
|
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 === '
|
|
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:
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
+
};
|
package/.well-known/skills/wdyy-logging-standard/templates/security-audit-logger.template.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
|
47
|
+
constructor(private readonly logger: Logger) {}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
}
|