@wdyy/skills 0.1.23 → 0.1.24
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 +3 -3
- package/.well-known/skills/wdyy-database-standard/SKILL.md +12 -8
- package/.well-known/skills/wdyy-database-standard/reference/database-rules.md +7 -0
- package/.well-known/skills/wdyy-database-standard/scripts/validate-table-design.mjs +7 -0
- package/.well-known/skills/wdyy-database-standard/scripts/validate-table-design.test.mjs +24 -0
- package/.well-known/skills/wdyy-database-standard/templates/table-design.template.md +11 -0
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +33 -45
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +51 -14
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.mjs +66 -11
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +395 -119
- package/.well-known/skills/wdyy-logging-standard/templates/frontend-error-report.template.ts +42 -12
- package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +437 -22
- package/.well-known/skills/wdyy-logging-standard/templates/nestjs-http-logging.middleware.template.ts +68 -0
- package/.well-known/skills/wdyy-logging-standard/templates/security-audit-logger.template.ts +61 -0
- package/README.md +20 -7
- package/lib/wdyy-cli.js +231 -26
- package/package.json +1 -1
|
@@ -1,8 +1,37 @@
|
|
|
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';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
|
|
6
|
+
export type LogType = 'access' | 'application' | 'security' | 'audit';
|
|
7
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
8
|
+
export type LogResult = 'success' | 'failure';
|
|
9
|
+
|
|
10
|
+
export type BusinessFunction = {
|
|
11
|
+
functionCode: string;
|
|
12
|
+
functionName: string;
|
|
13
|
+
action: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BusinessFunctionCatalogEntry = BusinessFunction & {
|
|
17
|
+
method: string;
|
|
18
|
+
route: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ActorContext = {
|
|
22
|
+
actorId: string;
|
|
23
|
+
actorType: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
1
26
|
export type LogContext = {
|
|
27
|
+
logType: LogType;
|
|
2
28
|
traceId?: string;
|
|
3
|
-
|
|
29
|
+
spanId?: string;
|
|
30
|
+
parentSpanId?: string;
|
|
4
31
|
method?: string;
|
|
5
|
-
|
|
32
|
+
route?: string;
|
|
33
|
+
sourceIp?: string;
|
|
34
|
+
userAgent?: string;
|
|
6
35
|
statusCode?: number;
|
|
7
36
|
query?: Record<string, unknown>;
|
|
8
37
|
body?: unknown;
|
|
@@ -10,38 +39,167 @@ export type LogContext = {
|
|
|
10
39
|
durationMs?: number;
|
|
11
40
|
errorCode?: string;
|
|
12
41
|
params?: Record<string, unknown>;
|
|
42
|
+
eventId?: string;
|
|
43
|
+
eventType?: string;
|
|
44
|
+
actorId?: string;
|
|
45
|
+
actorType?: string;
|
|
46
|
+
functionCode?: string;
|
|
47
|
+
functionName?: string;
|
|
48
|
+
action?: string;
|
|
49
|
+
targetType?: string;
|
|
50
|
+
targetId?: string;
|
|
51
|
+
result?: LogResult;
|
|
52
|
+
reason?: string;
|
|
53
|
+
before?: unknown;
|
|
54
|
+
after?: unknown;
|
|
55
|
+
[key: string]: unknown;
|
|
13
56
|
};
|
|
14
57
|
|
|
15
58
|
export type LogEntry = LogContext & {
|
|
16
59
|
timestamp: string;
|
|
17
|
-
|
|
60
|
+
timestampEpochMs: number;
|
|
61
|
+
timezone: '+08:00';
|
|
62
|
+
level: LogLevel;
|
|
18
63
|
service: string;
|
|
19
64
|
instanceId: string;
|
|
20
65
|
env: string;
|
|
21
66
|
message: string;
|
|
22
|
-
result?: 'success' | 'failure';
|
|
23
67
|
};
|
|
24
68
|
|
|
25
|
-
|
|
69
|
+
export type StoredLogEntry = LogEntry & {
|
|
70
|
+
chainId: string;
|
|
71
|
+
sequence: number;
|
|
72
|
+
previousHash: string | null;
|
|
73
|
+
entryHash: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type TraceContext = {
|
|
77
|
+
traceId: string;
|
|
78
|
+
spanId: string;
|
|
79
|
+
parentSpanId?: string;
|
|
80
|
+
traceparent: string;
|
|
81
|
+
};
|
|
26
82
|
|
|
27
83
|
export const LOG_DIRECTORY = './logs' as const;
|
|
28
84
|
export const MAX_LOG_FILE_SIZE_BYTES = 2 * 1024 * 1024;
|
|
85
|
+
export const MIN_NETWORK_LOG_RETENTION_MONTHS = 6;
|
|
29
86
|
|
|
30
|
-
|
|
87
|
+
const REQUIRED_BY_TYPE: Record<LogType, readonly string[]> = {
|
|
88
|
+
access: [
|
|
89
|
+
'traceId', 'method', 'route', 'sourceIp', 'actorId', 'actorType',
|
|
90
|
+
'functionCode', 'functionName', 'action', 'statusCode', 'result', 'durationMs',
|
|
91
|
+
],
|
|
92
|
+
application: [],
|
|
93
|
+
security: ['eventId', 'eventType', 'actorId', 'actorType', 'action', 'targetType', 'targetId', 'result'],
|
|
94
|
+
audit: ['eventId', 'eventType', 'actorId', 'actorType', 'action', 'targetType', 'targetId', 'result'],
|
|
95
|
+
};
|
|
31
96
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
97
|
+
const normalizeFieldName = (value: string): string => value.toLowerCase().replaceAll(/[^a-z0-9]/g, '');
|
|
98
|
+
|
|
99
|
+
export const FORBIDDEN_LOG_FIELD_NAMES = new Set([
|
|
100
|
+
'password',
|
|
101
|
+
'passwd',
|
|
102
|
+
'pwd',
|
|
103
|
+
'authorization',
|
|
104
|
+
'proxyAuthorization',
|
|
105
|
+
'cookie',
|
|
106
|
+
'setCookie',
|
|
107
|
+
'sessionId',
|
|
108
|
+
'token',
|
|
109
|
+
'accessToken',
|
|
110
|
+
'refreshToken',
|
|
111
|
+
'idToken',
|
|
112
|
+
'apiKey',
|
|
113
|
+
'secret',
|
|
114
|
+
'clientSecret',
|
|
115
|
+
'privateKey',
|
|
116
|
+
'databaseUrl',
|
|
117
|
+
'databasePassword',
|
|
118
|
+
'dbPassword',
|
|
119
|
+
'connectionString',
|
|
120
|
+
'idCard',
|
|
121
|
+
'bankCard',
|
|
122
|
+
'medicalRecord',
|
|
123
|
+
'medicalRecordNumber',
|
|
124
|
+
'diagnosis',
|
|
125
|
+
'healthData',
|
|
126
|
+
].map(normalizeFieldName));
|
|
127
|
+
|
|
128
|
+
const omitted = Symbol('omitted-log-field');
|
|
129
|
+
|
|
130
|
+
export const sanitizeLogText = (value: string): string => value
|
|
131
|
+
.replaceAll(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REMOVED]')
|
|
132
|
+
.replaceAll(/\b(password|passwd|pwd|token|secret|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi, '$1=[REMOVED]')
|
|
133
|
+
.replaceAll(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REMOVED_JWT]');
|
|
134
|
+
|
|
135
|
+
const sanitizeValue = (
|
|
136
|
+
value: unknown,
|
|
137
|
+
forbiddenNames: ReadonlySet<string>,
|
|
138
|
+
): unknown | typeof omitted => {
|
|
139
|
+
if (Array.isArray(value)) {
|
|
140
|
+
return value
|
|
141
|
+
.map((item) => sanitizeValue(item, forbiddenNames))
|
|
142
|
+
.filter((item) => item !== omitted);
|
|
143
|
+
}
|
|
144
|
+
if (value instanceof Date) return value.toISOString();
|
|
145
|
+
if (typeof value === 'string') return sanitizeLogText(value);
|
|
146
|
+
if (!value || typeof value !== 'object') return value;
|
|
147
|
+
|
|
148
|
+
const sanitized: Record<string, unknown> = {};
|
|
149
|
+
for (const [key, item] of Object.entries(value)) {
|
|
150
|
+
if (forbiddenNames.has(normalizeFieldName(key))) continue;
|
|
151
|
+
const sanitizedItem = sanitizeValue(item, forbiddenNames);
|
|
152
|
+
if (sanitizedItem !== omitted) sanitized[key] = sanitizedItem;
|
|
153
|
+
}
|
|
154
|
+
return sanitized;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const sanitizeLogValue = (
|
|
158
|
+
value: unknown,
|
|
159
|
+
additionalForbiddenNames: Iterable<string> = [],
|
|
160
|
+
): unknown => {
|
|
161
|
+
const forbiddenNames = new Set(FORBIDDEN_LOG_FIELD_NAMES);
|
|
162
|
+
for (const name of additionalForbiddenNames) forbiddenNames.add(normalizeFieldName(name));
|
|
163
|
+
const sanitized = sanitizeValue(value, forbiddenNames);
|
|
164
|
+
return sanitized === omitted ? undefined : sanitized;
|
|
37
165
|
};
|
|
38
166
|
|
|
167
|
+
export const findForbiddenLogFieldPath = (
|
|
168
|
+
value: unknown,
|
|
169
|
+
path = '$',
|
|
170
|
+
): string | undefined => {
|
|
171
|
+
if (Array.isArray(value)) {
|
|
172
|
+
for (const [index, item] of value.entries()) {
|
|
173
|
+
const found = findForbiddenLogFieldPath(item, `${path}[${index}]`);
|
|
174
|
+
if (found) return found;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
179
|
+
for (const [key, item] of Object.entries(value)) {
|
|
180
|
+
const childPath = `${path}.${key}`;
|
|
181
|
+
if (FORBIDDEN_LOG_FIELD_NAMES.has(normalizeFieldName(key))) return childPath;
|
|
182
|
+
const found = findForbiddenLogFieldPath(item, childPath);
|
|
183
|
+
if (found) return found;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
|
|
189
|
+
|
|
39
190
|
export const formatLocalTimestamp = (date = new Date()): string => (
|
|
40
191
|
[date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate())].join('-')
|
|
41
192
|
+ ' '
|
|
42
193
|
+ [pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds())].join(':')
|
|
43
194
|
);
|
|
44
195
|
|
|
196
|
+
export const formatTimezoneOffset = (date = new Date()): string => {
|
|
197
|
+
const totalMinutes = -date.getTimezoneOffset();
|
|
198
|
+
const sign = totalMinutes >= 0 ? '+' : '-';
|
|
199
|
+
const absoluteMinutes = Math.abs(totalMinutes);
|
|
200
|
+
return `${sign}${pad(Math.floor(absoluteMinutes / 60))}:${pad(absoluteMinutes % 60)}`;
|
|
201
|
+
};
|
|
202
|
+
|
|
45
203
|
export const createLogFileName = (date = new Date(), collisionIndex = 0): string => {
|
|
46
204
|
const baseName = formatLocalTimestamp(date).replace(' ', '_').replaceAll(':', '-');
|
|
47
205
|
return `${baseName}${collisionIndex > 0 ? `_${collisionIndex}` : ''}.log`;
|
|
@@ -51,25 +209,282 @@ export const createLogFilePath = (date = new Date(), collisionIndex = 0): string
|
|
|
51
209
|
`${LOG_DIRECTORY}/${createLogFileName(date, collisionIndex)}`
|
|
52
210
|
);
|
|
53
211
|
|
|
54
|
-
const
|
|
212
|
+
const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
213
|
+
const NON_ZERO_TRACE_ID = /[1-9a-f]/;
|
|
214
|
+
|
|
215
|
+
export const parseTraceParent = (value: string | undefined): TraceContext | undefined => {
|
|
216
|
+
if (!value) return undefined;
|
|
217
|
+
const match = TRACEPARENT_PATTERN.exec(value);
|
|
218
|
+
if (!match || !NON_ZERO_TRACE_ID.test(match[1]) || !NON_ZERO_TRACE_ID.test(match[2])) return undefined;
|
|
219
|
+
const spanId = randomBytes(8).toString('hex');
|
|
220
|
+
return {
|
|
221
|
+
traceId: match[1],
|
|
222
|
+
spanId,
|
|
223
|
+
parentSpanId: match[2],
|
|
224
|
+
traceparent: `00-${match[1]}-${spanId}-${match[3]}`,
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const createTraceContext = (incomingTraceParent?: string): TraceContext => {
|
|
229
|
+
const continued = parseTraceParent(incomingTraceParent);
|
|
230
|
+
if (continued) return continued;
|
|
231
|
+
const traceId = randomBytes(16).toString('hex');
|
|
232
|
+
const spanId = randomBytes(8).toString('hex');
|
|
233
|
+
return { traceId, spanId, traceparent: `00-${traceId}-${spanId}-01` };
|
|
234
|
+
};
|
|
235
|
+
|
|
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
|
+
const isPresent = (value: unknown): boolean => (
|
|
244
|
+
value !== undefined && value !== null && (typeof value !== 'string' || value.trim() !== '')
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const BUSINESS_IDENTIFIER_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
248
|
+
const UNRESOLVED_BUSINESS_VALUES = new Set(['unknown', 'unresolved', '未知', '待确认']);
|
|
249
|
+
|
|
250
|
+
const isUnresolvedBusinessValue = (value: string): boolean => (
|
|
251
|
+
UNRESOLVED_BUSINESS_VALUES.has(value.trim().toLowerCase())
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const assertRouteTemplate = (route: unknown): void => {
|
|
255
|
+
if (typeof route !== 'string' || route.trim() === '' || route.includes('?') || route.includes('#')) {
|
|
256
|
+
throw new Error('route must be a route template without query parameters or fragments');
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const assertSourceIp = (sourceIp: unknown): void => {
|
|
261
|
+
if (typeof sourceIp !== 'string' || isIP(sourceIp) === 0) {
|
|
262
|
+
throw new Error('sourceIp must be a valid IPv4 or IPv6 address');
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const assertBusinessFunction = (value: Partial<BusinessFunction>): void => {
|
|
267
|
+
if (
|
|
268
|
+
typeof value.functionCode !== 'string'
|
|
269
|
+
|| !BUSINESS_IDENTIFIER_PATTERN.test(value.functionCode)
|
|
270
|
+
|| isUnresolvedBusinessValue(value.functionCode)
|
|
271
|
+
) {
|
|
272
|
+
throw new Error('functionCode must be a stable lowercase business identifier');
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
typeof value.functionName !== 'string'
|
|
276
|
+
|| !isPresent(value.functionName)
|
|
277
|
+
|| isUnresolvedBusinessValue(value.functionName)
|
|
278
|
+
) {
|
|
279
|
+
throw new Error('functionName must be a resolved non-empty business name');
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
typeof value.action !== 'string'
|
|
283
|
+
|| !BUSINESS_IDENTIFIER_PATTERN.test(value.action)
|
|
284
|
+
|| isUnresolvedBusinessValue(value.action)
|
|
285
|
+
) {
|
|
286
|
+
throw new Error('action must be a lowercase business action identifier');
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export class BusinessFunctionCatalog {
|
|
291
|
+
private readonly entries = new Map<string, BusinessFunction>();
|
|
292
|
+
|
|
293
|
+
constructor(entries: readonly BusinessFunctionCatalogEntry[]) {
|
|
294
|
+
for (const entry of entries) {
|
|
295
|
+
const method = entry.method.trim().toUpperCase();
|
|
296
|
+
assertRouteTemplate(entry.route);
|
|
297
|
+
assertBusinessFunction(entry);
|
|
298
|
+
const key = `${method} ${entry.route}`;
|
|
299
|
+
if (this.entries.has(key)) throw new Error(`Duplicate business function mapping: ${key}`);
|
|
300
|
+
this.entries.set(key, {
|
|
301
|
+
functionCode: entry.functionCode,
|
|
302
|
+
functionName: entry.functionName,
|
|
303
|
+
action: entry.action,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
resolve(method: string, route: string): BusinessFunction {
|
|
309
|
+
const key = `${method.trim().toUpperCase()} ${route}`;
|
|
310
|
+
const value = this.entries.get(key);
|
|
311
|
+
if (!value) throw new Error(`Missing business function mapping: ${key}`);
|
|
312
|
+
return { ...value };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const resolveActorContext = (actor?: { id?: string; type?: string }): ActorContext => {
|
|
317
|
+
if (!actor?.id && !actor?.type) return { actorId: 'anonymous', actorType: 'anonymous' };
|
|
318
|
+
if (!isPresent(actor.id) || !isPresent(actor.type)) {
|
|
319
|
+
throw new Error('Authenticated actor requires both id and type');
|
|
320
|
+
}
|
|
321
|
+
return { actorId: actor.id as string, actorType: actor.type as string };
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export const requireSourceIp = (sourceIp: string | undefined): string => {
|
|
325
|
+
assertSourceIp(sourceIp);
|
|
326
|
+
return sourceIp as string;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const resultForStatusCode = (statusCode: number): LogResult | undefined => {
|
|
55
330
|
if (statusCode >= 100 && statusCode <= 399) return 'success';
|
|
56
331
|
if (statusCode >= 400 && statusCode <= 599) return 'failure';
|
|
57
332
|
return undefined;
|
|
58
333
|
};
|
|
59
334
|
|
|
60
|
-
|
|
335
|
+
const assertTraceId = (traceId: unknown): void => {
|
|
336
|
+
if (typeof traceId !== 'string' || !/^[0-9a-f]{32}$/.test(traceId) || !NON_ZERO_TRACE_ID.test(traceId)) {
|
|
337
|
+
throw new Error('traceId must be 32 non-zero lowercase hexadecimal characters');
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export const assertLogEntry = (entry: LogEntry): void => {
|
|
342
|
+
const commonRequired = [
|
|
343
|
+
'timestamp', 'timestampEpochMs', 'timezone', 'level', 'service', 'instanceId', 'env', 'logType', 'message',
|
|
344
|
+
];
|
|
345
|
+
const missing = commonRequired.filter((key) => !isPresent(entry[key]));
|
|
346
|
+
if (missing.length) throw new Error(`Missing log fields: ${missing.join(', ')}`);
|
|
347
|
+
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(entry.timestamp)) {
|
|
348
|
+
throw new Error('timestamp must use local YYYY-MM-DD HH:mm:ss format');
|
|
349
|
+
}
|
|
350
|
+
if (!Number.isSafeInteger(entry.timestampEpochMs) || entry.timestampEpochMs <= 0) {
|
|
351
|
+
throw new Error('timestampEpochMs must be a positive safe integer');
|
|
352
|
+
}
|
|
353
|
+
if (entry.timezone !== '+08:00') throw new Error('timezone must be +08:00');
|
|
354
|
+
if (!Object.hasOwn(REQUIRED_BY_TYPE, entry.logType)) throw new Error('Unknown logType');
|
|
355
|
+
|
|
356
|
+
const typeMissing = REQUIRED_BY_TYPE[entry.logType].filter((key) => !isPresent(entry[key]));
|
|
357
|
+
if (typeMissing.length) throw new Error(`Missing ${entry.logType} log fields: ${typeMissing.join(', ')}`);
|
|
358
|
+
if (entry.logType === 'access') {
|
|
359
|
+
assertTraceId(entry.traceId);
|
|
360
|
+
assertRouteTemplate(entry.route);
|
|
361
|
+
assertSourceIp(entry.sourceIp);
|
|
362
|
+
assertBusinessFunction(entry);
|
|
363
|
+
const expectedResult = resultForStatusCode(Number(entry.statusCode));
|
|
364
|
+
if (!expectedResult || entry.result !== expectedResult) {
|
|
365
|
+
throw new Error('statusCode and result must match');
|
|
366
|
+
}
|
|
367
|
+
if (typeof entry.durationMs !== 'number' || entry.durationMs < 0) {
|
|
368
|
+
throw new Error('durationMs must be a non-negative number');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
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');
|
|
385
|
+
}
|
|
386
|
+
if (entry.traceId !== undefined) assertTraceId(entry.traceId);
|
|
387
|
+
const forbiddenPath = findForbiddenLogFieldPath(entry);
|
|
388
|
+
if (forbiddenPath) throw new Error(`Forbidden log field: ${forbiddenPath}`);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
export const toLogEntry = (
|
|
392
|
+
level: LogLevel,
|
|
393
|
+
message: string,
|
|
394
|
+
context: LogContext,
|
|
395
|
+
date = new Date(),
|
|
396
|
+
additionalForbiddenNames: Iterable<string> = [],
|
|
397
|
+
): LogEntry => {
|
|
61
398
|
const environment = getRuntimeEnvironment();
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
399
|
+
const sanitizedContext = sanitizeLogValue(context, additionalForbiddenNames) as LogContext;
|
|
400
|
+
const statusResult = sanitizedContext.logType === 'access' && typeof sanitizedContext.statusCode === 'number'
|
|
401
|
+
? resultForStatusCode(sanitizedContext.statusCode)
|
|
402
|
+
: undefined;
|
|
403
|
+
const entry = {
|
|
404
|
+
timestamp: formatLocalTimestamp(date),
|
|
405
|
+
timestampEpochMs: date.getTime(),
|
|
406
|
+
timezone: formatTimezoneOffset(date),
|
|
67
407
|
level,
|
|
68
408
|
service: environment.SERVICE_NAME ?? 'backend',
|
|
69
409
|
instanceId: environment.INSTANCE_ID ?? 'local',
|
|
70
410
|
env: environment.NODE_ENV ?? 'development',
|
|
71
|
-
message,
|
|
72
|
-
...
|
|
73
|
-
...(
|
|
74
|
-
};
|
|
411
|
+
message: sanitizeLogText(message).replaceAll('\r', '\\r').replaceAll('\n', '\\n'),
|
|
412
|
+
...sanitizedContext,
|
|
413
|
+
...(statusResult ? { result: statusResult } : {}),
|
|
414
|
+
} as LogEntry;
|
|
415
|
+
assertLogEntry(entry);
|
|
416
|
+
return entry;
|
|
75
417
|
};
|
|
418
|
+
|
|
419
|
+
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
|
|
420
|
+
|
|
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;
|
|
429
|
+
|
|
430
|
+
constructor(now: () => Date = () => new Date()) {
|
|
431
|
+
this.now = now;
|
|
432
|
+
}
|
|
433
|
+
|
|
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);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async close(): Promise<void> {
|
|
444
|
+
await this.pending;
|
|
445
|
+
if (this.currentHandle) {
|
|
446
|
+
await this.currentHandle.close();
|
|
447
|
+
this.currentHandle = undefined;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async writeNow(entry: LogEntry): Promise<StoredLogEntry> {
|
|
452
|
+
assertLogEntry(entry);
|
|
453
|
+
const sequence = this.sequence + 1;
|
|
454
|
+
const unsigned = { ...entry, chainId: this.chainId, sequence, previousHash: this.previousHash };
|
|
455
|
+
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
|
+
this.sequence = sequence;
|
|
466
|
+
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;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Inject, Injectable, type NestMiddleware } from '@nestjs/common';
|
|
2
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
3
|
+
import {
|
|
4
|
+
BusinessFunctionCatalog,
|
|
5
|
+
createTraceContext,
|
|
6
|
+
requireSourceIp,
|
|
7
|
+
resolveActorContext,
|
|
8
|
+
SecureLogWriter,
|
|
9
|
+
toLogEntry,
|
|
10
|
+
type TraceContext,
|
|
11
|
+
} from './logger';
|
|
12
|
+
|
|
13
|
+
export const BUSINESS_FUNCTION_CATALOG = Symbol('BUSINESS_FUNCTION_CATALOG');
|
|
14
|
+
|
|
15
|
+
type LoggedRequest = Request & {
|
|
16
|
+
traceContext?: TraceContext;
|
|
17
|
+
user?: { id?: string; type?: string };
|
|
18
|
+
};
|
|
19
|
+
|
|
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
|
+
) {}
|
|
27
|
+
|
|
28
|
+
use(request: LoggedRequest, response: Response, next: NextFunction): void {
|
|
29
|
+
const incomingTraceParent = request.header('traceparent');
|
|
30
|
+
const trace = createTraceContext(incomingTraceParent);
|
|
31
|
+
request.traceContext = trace;
|
|
32
|
+
response.setHeader('traceparent', trace.traceparent);
|
|
33
|
+
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
|
+
next();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { SecureLogWriter, toLogEntry, type LogResult } from './logger';
|
|
3
|
+
|
|
4
|
+
export const REQUIRED_SECURITY_EVENTS = [
|
|
5
|
+
'auth.login.success',
|
|
6
|
+
'auth.login.failure',
|
|
7
|
+
'auth.logout',
|
|
8
|
+
'account.locked',
|
|
9
|
+
'access.denied',
|
|
10
|
+
'input.rejected',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export const REQUIRED_AUDIT_EVENTS = [
|
|
14
|
+
'account.changed',
|
|
15
|
+
'role.changed',
|
|
16
|
+
'permission.changed',
|
|
17
|
+
'configuration.changed',
|
|
18
|
+
'sensitive-data.read',
|
|
19
|
+
'sensitive-data.export',
|
|
20
|
+
'sensitive-data.delete',
|
|
21
|
+
'audit.configuration.changed',
|
|
22
|
+
'audit.log.accessed',
|
|
23
|
+
'audit.integrity.failed',
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export type SecurityAuditEvent = {
|
|
27
|
+
logType: 'security' | 'audit';
|
|
28
|
+
eventType: string;
|
|
29
|
+
actorId: string;
|
|
30
|
+
actorType: string;
|
|
31
|
+
action: string;
|
|
32
|
+
targetType: string;
|
|
33
|
+
targetId: string;
|
|
34
|
+
result: LogResult;
|
|
35
|
+
reason?: string;
|
|
36
|
+
traceId?: string;
|
|
37
|
+
sourceIp?: string;
|
|
38
|
+
route?: string;
|
|
39
|
+
functionCode?: string;
|
|
40
|
+
functionName?: string;
|
|
41
|
+
before?: unknown;
|
|
42
|
+
after?: unknown;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class SecurityAuditLogger {
|
|
46
|
+
constructor(private readonly writer: SecureLogWriter) {}
|
|
47
|
+
|
|
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);
|
|
60
|
+
}
|
|
61
|
+
}
|