@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,179 +1,455 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import {
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { afterEach, test } from 'node:test';
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import {
|
|
8
|
+
BusinessFunctionCatalog,
|
|
8
9
|
createLogFileName,
|
|
9
10
|
createLogFilePath,
|
|
11
|
+
createTraceContext,
|
|
10
12
|
LOG_DIRECTORY,
|
|
11
13
|
MAX_LOG_FILE_SIZE_BYTES,
|
|
14
|
+
MIN_NETWORK_LOG_RETENTION_MONTHS,
|
|
15
|
+
requireSourceIp,
|
|
16
|
+
resolveActorContext,
|
|
17
|
+
sanitizeLogValue,
|
|
18
|
+
SecureLogWriter,
|
|
19
|
+
toLogEntry,
|
|
12
20
|
} from '../templates/logger.template.ts';
|
|
21
|
+
import {
|
|
22
|
+
createTraceContext as createFrontendTraceContext,
|
|
23
|
+
sanitizeFrontendErrorReport,
|
|
24
|
+
} from '../templates/frontend-error-report.template.ts';
|
|
25
|
+
|
|
26
|
+
process.env.TZ = 'Asia/Shanghai';
|
|
13
27
|
|
|
14
28
|
const validator = new URL('./validate-log-entry.mjs', import.meta.url);
|
|
29
|
+
const loggerTemplate = new URL('../templates/logger.template.ts', import.meta.url);
|
|
30
|
+
const skillRoot = new URL('..', import.meta.url);
|
|
15
31
|
const temporaryDirectories = [];
|
|
32
|
+
const fixedDate = new Date('2026-07-09T08:00:00+08:00');
|
|
16
33
|
|
|
17
34
|
afterEach(async () => {
|
|
18
35
|
await Promise.all(
|
|
19
|
-
temporaryDirectories.splice(0).map((directory) =>
|
|
20
|
-
rm(directory, { force: true, recursive: true }),
|
|
21
|
-
),
|
|
36
|
+
temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
|
|
22
37
|
);
|
|
23
38
|
});
|
|
24
39
|
|
|
25
|
-
async function
|
|
26
|
-
const directory = await mkdtemp(join(tmpdir(), 'log-
|
|
40
|
+
async function createTemporaryDirectory() {
|
|
41
|
+
const directory = await mkdtemp(join(tmpdir(), 'secure-log-'));
|
|
27
42
|
temporaryDirectories.push(directory);
|
|
28
|
-
|
|
29
|
-
await writeFile(input, JSON.stringify(entry));
|
|
30
|
-
return spawnSync(process.execPath, [validator.pathname, input], { encoding: 'utf8' });
|
|
43
|
+
return directory;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
async function validate(entry, stored = false) {
|
|
47
|
+
const directory = await createTemporaryDirectory();
|
|
48
|
+
const input = join(directory, 'entry.log');
|
|
49
|
+
await writeFile(input, stored ? entry : JSON.stringify(entry));
|
|
50
|
+
return spawnSync(
|
|
51
|
+
process.execPath,
|
|
52
|
+
[...(stored ? ['--experimental-strip-types'] : []), validator.pathname, ...(stored ? ['--stored'] : []), input],
|
|
53
|
+
{ encoding: 'utf8', env: { ...process.env, TZ: 'Asia/Shanghai' } },
|
|
39
54
|
);
|
|
40
|
-
}
|
|
55
|
+
}
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
function validAccessEntry(overrides = {}) {
|
|
58
|
+
return {
|
|
59
|
+
timestamp: '2026-07-09 08:00:00',
|
|
60
|
+
timestampEpochMs: fixedDate.getTime(),
|
|
61
|
+
timezone: '+08:00',
|
|
62
|
+
level: 'info',
|
|
63
|
+
service: 'backend',
|
|
64
|
+
instanceId: 'blue',
|
|
65
|
+
env: 'production',
|
|
66
|
+
logType: 'access',
|
|
67
|
+
message: 'request completed',
|
|
68
|
+
traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
69
|
+
method: 'GET',
|
|
70
|
+
route: '/patients/:patientId',
|
|
71
|
+
sourceIp: '10.0.0.8',
|
|
72
|
+
actorId: 'user-1',
|
|
73
|
+
actorType: 'employee',
|
|
74
|
+
functionCode: 'patient.query',
|
|
75
|
+
functionName: '患者信息查询',
|
|
76
|
+
action: 'read',
|
|
77
|
+
statusCode: 200,
|
|
78
|
+
result: 'success',
|
|
79
|
+
durationMs: 12.5,
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validSecurityEntry(overrides = {}) {
|
|
85
|
+
return {
|
|
86
|
+
timestamp: '2026-07-09 08:00:00',
|
|
87
|
+
timestampEpochMs: fixedDate.getTime(),
|
|
88
|
+
timezone: '+08:00',
|
|
89
|
+
level: 'warn',
|
|
90
|
+
service: 'backend',
|
|
91
|
+
instanceId: 'blue',
|
|
92
|
+
env: 'production',
|
|
93
|
+
logType: 'security',
|
|
94
|
+
message: 'access denied',
|
|
95
|
+
eventId: 'event-1',
|
|
96
|
+
eventType: 'access.denied',
|
|
97
|
+
actorId: 'user-1',
|
|
98
|
+
actorType: 'employee',
|
|
99
|
+
action: 'patient.read',
|
|
100
|
+
targetType: 'patient',
|
|
101
|
+
targetId: 'patient-1',
|
|
102
|
+
result: 'failure',
|
|
103
|
+
reason: 'permission denied',
|
|
104
|
+
...overrides,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runWriter(directory, body) {
|
|
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
|
+
}
|
|
44
123
|
|
|
124
|
+
test('固定根日志路径、2MB 阈值和六个月留存合同', () => {
|
|
45
125
|
assert.equal(LOG_DIRECTORY, './logs');
|
|
126
|
+
assert.equal(MAX_LOG_FILE_SIZE_BYTES, 2 * 1024 * 1024);
|
|
127
|
+
assert.equal(MIN_NETWORK_LOG_RETENTION_MONTHS, 6);
|
|
128
|
+
assert.equal(createLogFileName(fixedDate), '2026-07-09_08-00-00.log');
|
|
129
|
+
assert.equal(createLogFilePath(fixedDate, 2), './logs/2026-07-09_08-00-00_2.log');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('完整业务结构保留但秘密和敏感个人信息被递归删除', () => {
|
|
133
|
+
const sanitized = sanitizeLogValue({
|
|
134
|
+
orderId: 'order-1',
|
|
135
|
+
nested: {
|
|
136
|
+
password: 'not-allowed',
|
|
137
|
+
Authorization: 'Bearer not-allowed',
|
|
138
|
+
idCard: '110101199001011234',
|
|
139
|
+
medicalRecord: { diagnosis: 'not-allowed' },
|
|
140
|
+
items: [{ sku: 'A', token: 'not-allowed' }],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.deepEqual(sanitized, {
|
|
145
|
+
orderId: 'order-1',
|
|
146
|
+
nested: { items: [{ sku: 'A' }] },
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('项目确认的附加敏感字段也会被删除', () => {
|
|
46
151
|
assert.deepEqual(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'./logs/2026-07-28_09-05-03.log',
|
|
50
|
-
'./logs/2026-07-28_09-05-03_1.log',
|
|
51
|
-
'./logs/2026-07-28_09-05-03_2.log',
|
|
52
|
-
],
|
|
152
|
+
sanitizeLogValue({ orderId: '1', hospitalSecretField: 'remove-me' }, ['hospitalSecretField']),
|
|
153
|
+
{ orderId: '1' },
|
|
53
154
|
);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('访问日志强制关键字段并映射 HTTP 结果', () => {
|
|
158
|
+
const trace = createTraceContext();
|
|
159
|
+
const entry = toLogEntry('info', 'request completed', {
|
|
160
|
+
logType: 'access',
|
|
161
|
+
traceId: trace.traceId,
|
|
162
|
+
spanId: trace.spanId,
|
|
163
|
+
method: 'POST',
|
|
164
|
+
route: '/inventory/:inventoryId',
|
|
165
|
+
sourceIp: '10.0.0.8',
|
|
166
|
+
actorId: 'user-1',
|
|
167
|
+
actorType: 'employee',
|
|
168
|
+
functionCode: 'inventory.update',
|
|
169
|
+
functionName: '库存更新',
|
|
170
|
+
action: 'update',
|
|
171
|
+
statusCode: 422,
|
|
172
|
+
durationMs: 7,
|
|
173
|
+
}, fixedDate);
|
|
174
|
+
|
|
175
|
+
assert.equal(entry.result, 'failure');
|
|
176
|
+
assert.equal(entry.timezone, '+08:00');
|
|
177
|
+
assert.throws(
|
|
178
|
+
() => toLogEntry('info', 'incomplete', { logType: 'access' }, fixedDate),
|
|
179
|
+
/Missing access log fields/,
|
|
57
180
|
);
|
|
58
181
|
});
|
|
59
182
|
|
|
60
|
-
test('
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
183
|
+
test('显式功能目录按 method 与路由解析且拒绝缺失或重复映射', () => {
|
|
184
|
+
const catalog = new BusinessFunctionCatalog([{
|
|
185
|
+
method: 'GET',
|
|
186
|
+
route: '/patients/:patientId',
|
|
187
|
+
functionCode: 'patient.query',
|
|
188
|
+
functionName: '患者信息查询',
|
|
189
|
+
action: 'read',
|
|
190
|
+
}]);
|
|
191
|
+
assert.deepEqual(catalog.resolve('get', '/patients/:patientId'), {
|
|
192
|
+
functionCode: 'patient.query',
|
|
193
|
+
functionName: '患者信息查询',
|
|
194
|
+
action: 'read',
|
|
195
|
+
});
|
|
196
|
+
assert.throws(() => catalog.resolve('POST', '/patients/:patientId'), /Missing business function mapping/);
|
|
197
|
+
assert.throws(() => new BusinessFunctionCatalog([
|
|
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
|
+
});
|
|
65
206
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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);
|
|
76
227
|
}
|
|
228
|
+
assert.equal(requireSourceIp('2001:db8::1'), '2001:db8::1');
|
|
229
|
+
assert.throws(() => requireSourceIp('unknown'), /valid IPv4 or IPv6/);
|
|
77
230
|
});
|
|
78
231
|
|
|
79
|
-
test('
|
|
80
|
-
const
|
|
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/);
|
|
81
236
|
|
|
82
|
-
|
|
83
|
-
assert.
|
|
237
|
+
const invalidTrace = await validate(validAccessEntry({ traceId: '00000000000000000000000000000000' }));
|
|
238
|
+
assert.notEqual(invalidTrace.status, 0);
|
|
239
|
+
assert.match(invalidTrace.stderr, /traceId/);
|
|
84
240
|
});
|
|
85
241
|
|
|
86
|
-
test('
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
env: 'production',
|
|
93
|
-
message: 'request completed',
|
|
94
|
-
});
|
|
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$/);
|
|
95
248
|
|
|
96
|
-
|
|
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');
|
|
97
252
|
});
|
|
98
253
|
|
|
99
|
-
test('
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
254
|
+
test('安全与审计失败事件必须包含原因', () => {
|
|
255
|
+
assert.doesNotThrow(() => toLogEntry('warn', 'denied', {
|
|
256
|
+
logType: 'security',
|
|
257
|
+
eventId: 'event-1',
|
|
258
|
+
eventType: 'access.denied',
|
|
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));
|
|
267
|
+
assert.throws(
|
|
268
|
+
() => toLogEntry('warn', 'denied', {
|
|
269
|
+
logType: 'security',
|
|
270
|
+
eventId: 'event-1',
|
|
271
|
+
eventType: 'access.denied',
|
|
272
|
+
actorId: 'user-1',
|
|
273
|
+
actorType: 'employee',
|
|
274
|
+
action: 'patient.read',
|
|
275
|
+
targetType: 'patient',
|
|
276
|
+
targetId: 'patient-1',
|
|
277
|
+
result: 'failure',
|
|
278
|
+
}, fixedDate),
|
|
279
|
+
/require reason/,
|
|
280
|
+
);
|
|
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
|
+
});
|
|
107
312
|
|
|
313
|
+
test('校验器拒绝绕过净化的秘密字段', async () => {
|
|
314
|
+
const result = await validate(validAccessEntry({ body: { password: 'not-allowed' } }));
|
|
108
315
|
assert.notEqual(result.status, 0);
|
|
109
|
-
assert.match(result.stderr, /
|
|
316
|
+
assert.match(result.stderr, /Forbidden log field.*password/);
|
|
110
317
|
});
|
|
111
318
|
|
|
112
|
-
test('
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
result: 'failure',
|
|
122
|
-
});
|
|
319
|
+
test('日志消息换行被编码而不是注入新日志行', () => {
|
|
320
|
+
const entry = toLogEntry('info', 'first\nsecond\rthird token=secret-value', {
|
|
321
|
+
logType: 'application',
|
|
322
|
+
params: { note: 'Bearer abc.def', orderId: 'order-1' },
|
|
323
|
+
}, fixedDate);
|
|
324
|
+
assert.equal(entry.message, 'first\\nsecond\\rthird token=[REMOVED]');
|
|
325
|
+
assert.deepEqual(entry.params, { note: 'Bearer [REMOVED]', orderId: 'order-1' });
|
|
326
|
+
assert.equal(JSON.stringify(entry).split('\n').length, 1);
|
|
327
|
+
});
|
|
123
328
|
|
|
124
|
-
|
|
125
|
-
|
|
329
|
+
test('前端 trace 在缺少 Web Crypto 时仍有效且不会阻断请求', () => {
|
|
330
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
|
|
331
|
+
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: undefined });
|
|
332
|
+
try {
|
|
333
|
+
const trace = createFrontendTraceContext();
|
|
334
|
+
assert.match(trace.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
|
335
|
+
} finally {
|
|
336
|
+
if (descriptor) Object.defineProperty(globalThis, 'crypto', descriptor);
|
|
337
|
+
else delete globalThis.crypto;
|
|
338
|
+
}
|
|
126
339
|
});
|
|
127
340
|
|
|
128
|
-
test('
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
message: 'request completed',
|
|
136
|
-
query: { page: '1' },
|
|
137
|
-
body: {
|
|
138
|
-
password: 'unredacted',
|
|
139
|
-
idCard: '110101199001011234',
|
|
140
|
-
permissions: Array.from({ length: 501 }, (_, index) => index),
|
|
141
|
-
},
|
|
142
|
-
response: { code: 'OK', message: '处理成功' },
|
|
143
|
-
statusCode: 201,
|
|
144
|
-
result: 'success',
|
|
341
|
+
test('前端异常上报移除查询串和常见凭据', () => {
|
|
342
|
+
const report = sanitizeFrontendErrorReport({
|
|
343
|
+
errorName: 'FetchError',
|
|
344
|
+
message: 'authorization: Bearer abc.def password=hunter2',
|
|
345
|
+
stack: 'token=secret-value',
|
|
346
|
+
path: '/login?token=secret#fragment',
|
|
347
|
+
occurredAt: '2026-07-09T00:00:00.000Z',
|
|
145
348
|
});
|
|
349
|
+
assert.equal(report.path, '/login');
|
|
350
|
+
assert.doesNotMatch(JSON.stringify(report), /hunter2|secret-value|abc\.def/);
|
|
351
|
+
});
|
|
146
352
|
|
|
353
|
+
test('真实写入使用最小权限、单行 JSON 和可验证哈希链', async () => {
|
|
354
|
+
const directory = await createTemporaryDirectory();
|
|
355
|
+
const result = runWriter(directory, `
|
|
356
|
+
await writer.write(toLogEntry('info', 'one', { logType: 'application' }, fixedDate));
|
|
357
|
+
await writer.write(toLogEntry('info', 'two', { logType: 'application' }, fixedDate));
|
|
358
|
+
`);
|
|
147
359
|
assert.equal(result.status, 0, result.stderr);
|
|
360
|
+
|
|
361
|
+
const logsDirectory = join(directory, 'logs');
|
|
362
|
+
const files = await readdir(logsDirectory);
|
|
363
|
+
assert.deepEqual(files, ['2026-07-09_08-00-00.log']);
|
|
364
|
+
assert.equal((await stat(logsDirectory)).mode & 0o777, 0o700);
|
|
365
|
+
const logPath = join(logsDirectory, files[0]);
|
|
366
|
+
assert.equal((await stat(logPath)).mode & 0o777, 0o600);
|
|
367
|
+
const source = await readFile(logPath, 'utf8');
|
|
368
|
+
assert.equal(source.trim().split('\n').length, 2);
|
|
369
|
+
const validation = await validate(source, true);
|
|
370
|
+
assert.equal(validation.status, 0, validation.stderr);
|
|
148
371
|
});
|
|
149
372
|
|
|
150
|
-
test('
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
});
|
|
373
|
+
test('多进程同秒排他创建且不覆盖既有文件', async () => {
|
|
374
|
+
const directory = await createTemporaryDirectory();
|
|
375
|
+
const body = "await writer.write(toLogEntry('info', 'entry', { logType: 'application' }, fixedDate));";
|
|
376
|
+
const first = runWriter(directory, body);
|
|
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
|
+
]);
|
|
384
|
+
});
|
|
163
385
|
|
|
386
|
+
test('超过 2MB 时创建新文件并保留既有文件', async () => {
|
|
387
|
+
const directory = await createTemporaryDirectory();
|
|
388
|
+
const result = runWriter(directory, `
|
|
389
|
+
const payload = 'x'.repeat(1_200_000);
|
|
390
|
+
await writer.write(toLogEntry('info', 'first', { logType: 'application', body: { payload } }, fixedDate));
|
|
391
|
+
await writer.write(toLogEntry('info', 'second', { logType: 'application', body: { payload } }, fixedDate));
|
|
392
|
+
`);
|
|
164
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
|
+
]);
|
|
165
398
|
});
|
|
166
399
|
|
|
167
|
-
test('
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
400
|
+
test('跨秒轮转使用新的基础文件名而非沿用冲突后缀', async () => {
|
|
401
|
+
const directory = await createTemporaryDirectory();
|
|
402
|
+
const source = `
|
|
403
|
+
import { SecureLogWriter, toLogEntry } from ${JSON.stringify(loggerTemplate.href)};
|
|
404
|
+
process.env.TZ = 'Asia/Shanghai';
|
|
405
|
+
const firstDate = new Date('2026-07-09T08:00:00+08:00');
|
|
406
|
+
const secondDate = new Date('2026-07-09T08:00:01+08:00');
|
|
407
|
+
const dates = [firstDate, secondDate];
|
|
408
|
+
const writer = new SecureLogWriter(() => dates.shift() ?? secondDate);
|
|
409
|
+
const payload = 'x'.repeat(1_200_000);
|
|
410
|
+
await writer.write(toLogEntry('info', 'first', { logType: 'application', body: { payload } }, firstDate));
|
|
411
|
+
await writer.write(toLogEntry('info', 'second', { logType: 'application', body: { payload } }, secondDate));
|
|
412
|
+
await writer.close();
|
|
413
|
+
`;
|
|
414
|
+
const result = spawnSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', source], {
|
|
415
|
+
cwd: directory,
|
|
416
|
+
encoding: 'utf8',
|
|
417
|
+
env: { ...process.env, TZ: 'Asia/Shanghai' },
|
|
175
418
|
});
|
|
419
|
+
assert.equal(result.status, 0, result.stderr);
|
|
420
|
+
assert.deepEqual((await readdir(join(directory, 'logs'))).sort(), [
|
|
421
|
+
'2026-07-09_08-00-00.log',
|
|
422
|
+
'2026-07-09_08-00-01.log',
|
|
423
|
+
]);
|
|
424
|
+
});
|
|
176
425
|
|
|
177
|
-
|
|
178
|
-
|
|
426
|
+
test('篡改落盘记录后完整性验证失败', async () => {
|
|
427
|
+
const directory = await createTemporaryDirectory();
|
|
428
|
+
const result = runWriter(directory, `
|
|
429
|
+
await writer.write(toLogEntry('info', 'original', { logType: 'application' }, fixedDate));
|
|
430
|
+
`);
|
|
431
|
+
assert.equal(result.status, 0, result.stderr);
|
|
432
|
+
const logPath = join(directory, 'logs', '2026-07-09_08-00-00.log');
|
|
433
|
+
const entry = JSON.parse((await readFile(logPath, 'utf8')).trim());
|
|
434
|
+
entry.message = 'tampered';
|
|
435
|
+
const validation = await validate(`${JSON.stringify(entry)}\n`, true);
|
|
436
|
+
assert.notEqual(validation.status, 0);
|
|
437
|
+
assert.match(validation.stderr, /integrity verification/);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('HTTP 与安全审计模板包含真实事件入口且不记录原始 URL', async () => {
|
|
441
|
+
const middleware = await readFile(new URL('templates/nestjs-http-logging.middleware.template.ts', skillRoot), 'utf8');
|
|
442
|
+
const securityLogger = await readFile(new URL('templates/security-audit-logger.template.ts', skillRoot), 'utf8');
|
|
443
|
+
assert.match(middleware, /response\.once\('finish'/);
|
|
444
|
+
assert.match(middleware, /traceparent/);
|
|
445
|
+
assert.match(middleware, /functionCatalog\.resolve/);
|
|
446
|
+
assert.match(middleware, /resolveActorContext/);
|
|
447
|
+
assert.match(middleware, /requireSourceIp/);
|
|
448
|
+
assert.doesNotMatch(middleware, /originalUrl/);
|
|
449
|
+
assert.doesNotMatch(middleware, /['"]unknown['"]/);
|
|
450
|
+
assert.match(securityLogger, /functionCode/);
|
|
451
|
+
assert.match(securityLogger, /functionName/);
|
|
452
|
+
for (const eventName of ['auth.login.failure', 'access.denied', 'permission.changed', 'sensitive-data.export']) {
|
|
453
|
+
assert.match(securityLogger, new RegExp(eventName.replaceAll('.', '\\.')));
|
|
454
|
+
}
|
|
179
455
|
});
|
package/.well-known/skills/wdyy-logging-standard/templates/frontend-error-report.template.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
type
|
|
1
|
+
export type FrontendTraceContext = {
|
|
2
|
+
traceId: string;
|
|
3
|
+
spanId: string;
|
|
4
|
+
traceparent: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type FrontendErrorReport = {
|
|
8
|
+
errorName: string;
|
|
2
9
|
message: string;
|
|
3
10
|
stack?: string;
|
|
4
11
|
path: string;
|
|
@@ -6,22 +13,45 @@ type FrontendErrorReport = {
|
|
|
6
13
|
occurredAt: string;
|
|
7
14
|
};
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const randomHex = (byteLength: number): string => {
|
|
17
|
+
const bytes = new Uint8Array(byteLength);
|
|
18
|
+
if (typeof globalThis.crypto?.getRandomValues === 'function') {
|
|
19
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
20
|
+
} else {
|
|
21
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
22
|
+
bytes[index] = Math.floor(Math.random() * 256);
|
|
23
|
+
}
|
|
17
24
|
}
|
|
25
|
+
if (bytes.every((value) => value === 0)) bytes[bytes.length - 1] = 1;
|
|
26
|
+
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function createTraceContext(): FrontendTraceContext {
|
|
30
|
+
const traceId = randomHex(16);
|
|
31
|
+
const spanId = randomHex(8);
|
|
32
|
+
return { traceId, spanId, traceparent: `00-${traceId}-${spanId}-01` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sanitizeErrorText = (value: string): string => value
|
|
36
|
+
.replaceAll(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REMOVED]')
|
|
37
|
+
.replaceAll(/\b(password|passwd|pwd|token|secret|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi, '$1=[REMOVED]')
|
|
38
|
+
.replaceAll(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REMOVED_JWT]');
|
|
18
39
|
|
|
19
|
-
|
|
40
|
+
export function sanitizeFrontendErrorReport(report: FrontendErrorReport): FrontendErrorReport {
|
|
41
|
+
return {
|
|
42
|
+
...report,
|
|
43
|
+
errorName: sanitizeErrorText(report.errorName),
|
|
44
|
+
message: sanitizeErrorText(report.message),
|
|
45
|
+
stack: report.stack ? sanitizeErrorText(report.stack) : undefined,
|
|
46
|
+
path: report.path.split(/[?#]/, 1)[0],
|
|
47
|
+
};
|
|
20
48
|
}
|
|
21
49
|
|
|
22
50
|
// Axios 请求拦截器示例:
|
|
23
51
|
// api.interceptors.request.use((config) => {
|
|
24
|
-
//
|
|
52
|
+
// const trace = createTraceContext();
|
|
53
|
+
// config.headers.set('traceparent', trace.traceparent);
|
|
54
|
+
// config.headers.set('x-trace-id', trace.traceId);
|
|
25
55
|
// return config;
|
|
26
56
|
// });
|
|
27
57
|
|
|
@@ -30,7 +60,7 @@ export async function reportFrontendError(report: FrontendErrorReport): Promise<
|
|
|
30
60
|
method: 'POST',
|
|
31
61
|
headers: { 'Content-Type': 'application/json' },
|
|
32
62
|
credentials: 'same-origin',
|
|
33
|
-
body: JSON.stringify(report),
|
|
63
|
+
body: JSON.stringify(sanitizeFrontendErrorReport(report)),
|
|
34
64
|
});
|
|
35
65
|
if (!response.ok) throw new Error(`Frontend error report failed: ${response.status}`);
|
|
36
66
|
}
|