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