@evomap/evolver-webui 2.0.0-beta.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,3 @@
1
+ export declare function redactSensitiveHttpHeaders(value: string): string;
1
2
  export declare function redactDiagnosticText(value: unknown, maxChars?: number): string;
2
3
  export declare function sanitizeDiagnosticValue(value: unknown, depth?: number): unknown;
@@ -1,28 +1,767 @@
1
+ import { hub } from '@evomap/evolver-core';
2
+ // The package main entry is a UMD wrapper whose shadowed `require` leaves
3
+ // './impl/format' unresolved inside bun standalone binaries. Import the scanner
4
+ // impl via the shim so bundlers inline it statically.
5
+ import { createScanner } from './jsoncScannerShim.js';
1
6
  const MAX_TEXT = 500;
2
7
  const MAX_ARRAY = 50;
3
8
  const MAX_KEYS = 50;
4
9
  const MAX_DEPTH = 5;
5
- const SECRET_KEY = /(?:authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|private[_-]?key)/i;
10
+ const MAX_SERIALIZED_JSON_DEPTH = 10;
11
+ const MAX_JSON_CONTAINER_DEPTH = 10_000;
12
+ const SECRET_KEY = /(?:authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|secret|api[_-]?key|account[_-]?key|instrumentation[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|private[_-]?key)/i;
13
+ const SENSITIVE_HTTP_HEADER_NAME = /\b(proxy[-_]?authorization|authorization|set[-_]?cookie|cookie)\b/gi;
14
+ const SENSITIVE_HTTP_HEADER_CANDIDATE = /\b(?:proxy[-_]?authorization|authorization|set[-_]?cookie|cookie)\b/i;
15
+ const SENSITIVE_HTTP_HEADER_KEY = /^(?:(?:proxy[-_]?)?authorization|(?:set[-_]?)?cookie)$/i;
16
+ const REDACTED_HEADER_VALUE = '[redacted]';
17
+ const REDACTED_JSON_VALUE = JSON.stringify(REDACTED_HEADER_VALUE);
18
+ // jsonc-parser declares SyntaxKind as an ambient const enum, which cannot be imported with verbatimModuleSyntax.
19
+ const JSON_TOKEN = {
20
+ openBrace: 1,
21
+ closeBrace: 2,
22
+ openBracket: 3,
23
+ closeBracket: 4,
24
+ comma: 5,
25
+ colon: 6,
26
+ null: 7,
27
+ true: 8,
28
+ false: 9,
29
+ string: 10,
30
+ number: 11,
31
+ unknown: 16,
32
+ eof: 17,
33
+ };
34
+ function isHeaderFraming(char) {
35
+ return char === ' ' || char === '\t';
36
+ }
37
+ function physicalLineEnd(value, start) {
38
+ for (let index = start; index < value.length; index += 1) {
39
+ const code = value.charCodeAt(index);
40
+ if (code === 0x0d || code === 0x0a)
41
+ return index;
42
+ }
43
+ return value.length;
44
+ }
45
+ function isPhysicalHeaderNamePosition(value, lineStart, nameStart) {
46
+ for (let index = lineStart; index < nameStart; index += 1) {
47
+ if (value[index] !== ' ' && value[index] !== '\t')
48
+ return false;
49
+ }
50
+ return true;
51
+ }
52
+ function physicalHeaderEnd(value, start) {
53
+ return physicalHeaderEndFromLineEnd(value, physicalLineEnd(value, start));
54
+ }
55
+ function physicalHeaderEndFromLineEnd(value, firstLineEnd, physicalHeaderEndCache) {
56
+ const cached = physicalHeaderEndCache?.get(firstLineEnd);
57
+ if (cached !== undefined)
58
+ return cached;
59
+ const traversedLineEnds = [];
60
+ let end = firstLineEnd;
61
+ while (end < value.length) {
62
+ traversedLineEnds.push(end);
63
+ const nextLine = value[end] === '\r' && value[end + 1] === '\n' ? end + 2 : end + 1;
64
+ if (value[nextLine] !== ' ' && value[nextLine] !== '\t')
65
+ break;
66
+ end = physicalLineEnd(value, nextLine);
67
+ }
68
+ for (const lineEnd of traversedLineEnds)
69
+ physicalHeaderEndCache?.set(lineEnd, end);
70
+ return end;
71
+ }
72
+ function looksLikeSensitiveHttpHeaderValue(headerName, value, start, end) {
73
+ const rawCandidate = value.slice(start, Math.min(end, start + 512)).trim();
74
+ const candidate = rawCandidate[0] === '\x22' || rawCandidate[0] === '\x27'
75
+ ? rawCandidate.slice(1)
76
+ : rawCandidate;
77
+ const normalizedName = headerName.toLowerCase().replace(/[-_]/g, '');
78
+ if (normalizedName.endsWith('authorization')) {
79
+ const auth = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+)[ \t]+(.+)$/.exec(candidate);
80
+ if (!auth)
81
+ return false;
82
+ const credentials = (auth[2] ?? '').trim();
83
+ return /^[A-Za-z0-9._~+/-]+={0,}(?:[ \t]*(?:[,;)\]}>\x27\x22]|$))/.test(credentials)
84
+ || /\b[!#$%&'*+.^_`|~0-9A-Za-z-]+\s*=/.test(credentials);
85
+ }
86
+ return /^[!#$%&'*+.^_`|~0-9A-Za-z-]+\s*=\s*(?:\x22[^\x22]*\x22|[^\s;,\x22\x27]*)(?:[ \t]*(?:[,;)\]}>;\x27\x22]|$))/.test(candidate);
87
+ }
88
+ function needsFoldedHeaderInspection(headerName, value, start, lineEnd) {
89
+ const boundedEnd = Math.min(lineEnd, start + 512);
90
+ if (looksLikeSensitiveHttpHeaderValue(headerName, value, start, boundedEnd))
91
+ return true;
92
+ const rawCandidate = value.slice(start, boundedEnd).trim();
93
+ if (rawCandidate.length === 0)
94
+ return true;
95
+ const candidate = rawCandidate[0] === '\x22' || rawCandidate[0] === '\x27'
96
+ ? rawCandidate.slice(1)
97
+ : rawCandidate;
98
+ return candidate.startsWith('[')
99
+ || /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(candidate)
100
+ || /[,;]$/.test(candidate)
101
+ || rawCandidate[0] === '\x22'
102
+ || rawCandidate[0] === '\x27';
103
+ }
104
+ function quotedValueEnd(value, start, lineEnd) {
105
+ const quote = value[start];
106
+ if (quote !== '\x22' && quote !== '\x27')
107
+ return undefined;
108
+ let ambiguous = false;
109
+ for (let index = start + 1; index < lineEnd; index += 1) {
110
+ if (value[index] === '\\') {
111
+ index += 1;
112
+ continue;
113
+ }
114
+ if (value[index] !== quote)
115
+ continue;
116
+ const next = value[index + 1];
117
+ if (next === undefined || isHeaderFraming(next) || /[,;)\]}>/]/.test(next)) {
118
+ return { end: index + 1, ambiguous };
119
+ }
120
+ ambiguous = true;
121
+ }
122
+ return undefined;
123
+ }
124
+ function token68ValueEnd(headerName, value, start, lineEnd) {
125
+ const normalizedName = headerName.toLowerCase().replace(/[-_]/g, '');
126
+ if (!normalizedName.endsWith('authorization'))
127
+ return undefined;
128
+ const match = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+[ \t]+[A-Za-z0-9._~+/-]+={0,}/.exec(value.slice(start, Math.min(lineEnd, start + 512)));
129
+ if (!match)
130
+ return undefined;
131
+ const end = start + match[0].length;
132
+ if (match[0].endsWith('=') && (value[end] === '\x22' || value[end] === '\x27'))
133
+ return undefined;
134
+ let boundary = end;
135
+ while (isHeaderFraming(value[boundary]))
136
+ boundary += 1;
137
+ return boundary >= lineEnd || /[,;)\]}>\x27\x22]/.test(value[boundary] ?? '') ? end : undefined;
138
+ }
139
+ function cookieValueEnd(headerName, value, start, lineEnd) {
140
+ const normalizedName = headerName.toLowerCase().replace(/[-_]/g, '');
141
+ if (normalizedName !== 'cookie')
142
+ return undefined;
143
+ let quoted;
144
+ for (let index = start; index < lineEnd; index += 1) {
145
+ const char = value[index];
146
+ if (char === '\\' && quoted) {
147
+ index += 1;
148
+ continue;
149
+ }
150
+ if (char === '\x22' || char === '\x27') {
151
+ quoted = quoted === char ? undefined : (quoted ?? char);
152
+ continue;
153
+ }
154
+ if (!quoted && char === ',')
155
+ return index;
156
+ }
157
+ return undefined;
158
+ }
159
+ function inspectArrayValueEnd(headerName, value, start, end, budget) {
160
+ if (value[start] !== '[')
161
+ return undefined;
162
+ let depth = 0;
163
+ let quote;
164
+ let quoteStart = -1;
165
+ let credentialFound = false;
166
+ for (let index = start; index < end; index += 1) {
167
+ budget.remaining -= 1;
168
+ if (budget.remaining < 0)
169
+ return { end };
170
+ const char = value[index];
171
+ if (quote) {
172
+ if (char === '\\') {
173
+ index += 1;
174
+ continue;
175
+ }
176
+ if (char !== quote)
177
+ continue;
178
+ if (depth === 1) {
179
+ let before = quoteStart - 1;
180
+ while (isHeaderFraming(value[before]))
181
+ before -= 1;
182
+ let after = index + 1;
183
+ while (isHeaderFraming(value[after]))
184
+ after += 1;
185
+ if ((value[before] === '[' || value[before] === ',')
186
+ && (value[after] === ',' || value[after] === ']')) {
187
+ const decoded = value.slice(quoteStart + 1, index).replace(/\\([\\'"])/g, '$1');
188
+ if (looksLikeSensitiveHttpHeaderValue(headerName, decoded, 0, decoded.length)) {
189
+ credentialFound = true;
190
+ }
191
+ }
192
+ }
193
+ quote = undefined;
194
+ continue;
195
+ }
196
+ if (char === '\x22' || char === '\x27') {
197
+ quote = char;
198
+ quoteStart = index;
199
+ continue;
200
+ }
201
+ if (char === '[') {
202
+ depth += 1;
203
+ continue;
204
+ }
205
+ if (char !== ']')
206
+ continue;
207
+ depth -= 1;
208
+ if (depth === 0)
209
+ return credentialFound ? { end: index + 1 } : undefined;
210
+ if (depth < 0)
211
+ return undefined;
212
+ }
213
+ return depth > 0 ? { end } : undefined;
214
+ }
215
+ function collectFoldedCandidate(value, start, end, budget) {
216
+ let cursor = start;
217
+ const spendFraming = () => {
218
+ budget.remaining -= 1;
219
+ return budget.remaining >= 0;
220
+ };
221
+ while (cursor < end && isHeaderFraming(value[cursor])) {
222
+ if (!spendFraming())
223
+ return { text: '', failClosed: true };
224
+ cursor += 1;
225
+ }
226
+ let text = '';
227
+ while (cursor < end && text.length < 512) {
228
+ if (value[cursor] !== '\r' && value[cursor] !== '\n') {
229
+ text += value[cursor];
230
+ cursor += 1;
231
+ continue;
232
+ }
233
+ if (!spendFraming())
234
+ return { text, failClosed: true };
235
+ cursor += value[cursor] === '\r' && value[cursor + 1] === '\n' ? 2 : 1;
236
+ let skipped = false;
237
+ while (cursor < end && isHeaderFraming(value[cursor])) {
238
+ if (!spendFraming())
239
+ return { text, failClosed: true };
240
+ cursor += 1;
241
+ skipped = true;
242
+ }
243
+ if (!skipped)
244
+ break;
245
+ if (text.length > 0 && text[text.length - 1] !== ' ')
246
+ text += ' ';
247
+ }
248
+ return { text, failClosed: false };
249
+ }
250
+ function foldedValueStart(value, start, end, budget) {
251
+ let cursor = start;
252
+ while (cursor < end) {
253
+ if (isHeaderFraming(value[cursor])) {
254
+ budget.remaining -= 1;
255
+ if (budget.remaining < 0)
256
+ return undefined;
257
+ cursor += 1;
258
+ continue;
259
+ }
260
+ if (value[cursor] !== '\r' && value[cursor] !== '\n')
261
+ break;
262
+ const width = value[cursor] === '\r' && value[cursor + 1] === '\n' ? 2 : 1;
263
+ budget.remaining -= width;
264
+ if (budget.remaining < 0)
265
+ return undefined;
266
+ cursor += width;
267
+ }
268
+ return cursor;
269
+ }
270
+ function rawHeaderSpan(value, headerName, lineStart, lineEnd, physicalEnd, nameStart, nameEnd, embedded) {
271
+ if (!isPhysicalHeaderNamePosition(value, lineStart, nameStart))
272
+ return undefined;
273
+ let cursor = nameEnd;
274
+ while (isHeaderFraming(value[cursor]))
275
+ cursor += 1;
276
+ if (value[cursor] !== ':')
277
+ return undefined;
278
+ let separatorEnd = cursor + 1;
279
+ while (value[separatorEnd] === ' ' || value[separatorEnd] === '\t')
280
+ separatorEnd += 1;
281
+ if (embedded
282
+ && !looksLikeSensitiveHttpHeaderValue(headerName, value, separatorEnd, lineEnd))
283
+ return undefined;
284
+ return {
285
+ start: nameStart,
286
+ end: physicalEnd,
287
+ replacement: value.slice(nameStart, separatorEnd) + REDACTED_HEADER_VALUE,
288
+ resumeAt: physicalEnd,
289
+ };
290
+ }
291
+ function inlineHeaderSpan(value, headerName, lineEnd, physicalEnd, nameStart, nameEnd, budget) {
292
+ const labelQuote = value[nameStart - 1] === '\x22' || value[nameStart - 1] === '\x27'
293
+ ? value[nameStart - 1]
294
+ : undefined;
295
+ let cursor = nameEnd;
296
+ if (labelQuote && value[cursor] === labelQuote)
297
+ cursor += 1;
298
+ while (value[cursor] === ' ' || value[cursor] === '\t')
299
+ cursor += 1;
300
+ if (labelQuote && value[cursor] === ']') {
301
+ cursor += 1;
302
+ while (value[cursor] === ' ' || value[cursor] === '\t')
303
+ cursor += 1;
304
+ }
305
+ let separatorEnd;
306
+ if (value[cursor] === ':' || value[cursor] === '=') {
307
+ separatorEnd = cursor + 1;
308
+ if (value[cursor] === '=' && value[separatorEnd] === '>')
309
+ separatorEnd += 1;
310
+ }
311
+ else if (labelQuote && value[cursor] === ',') {
312
+ separatorEnd = cursor + 1;
313
+ }
314
+ else {
315
+ return undefined;
316
+ }
317
+ while (value[separatorEnd] === ' ' || value[separatorEnd] === '\t')
318
+ separatorEnd += 1;
319
+ let shapeStart = separatorEnd;
320
+ const folded = physicalEnd > lineEnd;
321
+ if (separatorEnd >= lineEnd && physicalEnd > lineEnd) {
322
+ shapeStart = value[lineEnd] === '\r' && value[lineEnd + 1] === '\n'
323
+ ? lineEnd + 2
324
+ : lineEnd + 1;
325
+ }
326
+ if (folded
327
+ && separatorEnd < lineEnd
328
+ && !needsFoldedHeaderInspection(headerName, value, separatorEnd, lineEnd))
329
+ return undefined;
330
+ const advancedFoldedStart = folded
331
+ ? foldedValueStart(value, shapeStart, physicalEnd, budget)
332
+ : shapeStart;
333
+ const foldedFramingExhausted = advancedFoldedStart === undefined;
334
+ if (advancedFoldedStart !== undefined)
335
+ shapeStart = advancedFoldedStart;
336
+ const inspectArray = foldedFramingExhausted
337
+ ? undefined
338
+ : inspectArrayValueEnd(headerName, value, shapeStart, physicalEnd, budget);
339
+ const unfoldedCandidate = folded && !foldedFramingExhausted
340
+ ? collectFoldedCandidate(value, shapeStart, physicalEnd, budget)
341
+ : (foldedFramingExhausted ? { text: '', failClosed: true } : undefined);
342
+ if (!inspectArray
343
+ && !unfoldedCandidate?.failClosed
344
+ && !(unfoldedCandidate
345
+ ? looksLikeSensitiveHttpHeaderValue(headerName, unfoldedCandidate.text, 0, unfoldedCandidate.text.length)
346
+ : looksLikeSensitiveHttpHeaderValue(headerName, value, shapeStart, physicalEnd)))
347
+ return undefined;
348
+ const candidateQuotedValue = folded
349
+ ? undefined
350
+ : quotedValueEnd(value, separatorEnd, physicalEnd);
351
+ const quotedEnd = candidateQuotedValue && !candidateQuotedValue.ambiguous
352
+ ? candidateQuotedValue.end
353
+ : undefined;
354
+ const preciseEnd = (folded ? undefined : inspectArray?.end)
355
+ ?? quotedEnd
356
+ ?? (folded ? undefined : token68ValueEnd(headerName, value, separatorEnd, lineEnd))
357
+ ?? (folded ? undefined : cookieValueEnd(headerName, value, separatorEnd, lineEnd));
358
+ const end = preciseEnd ?? physicalEnd;
359
+ const quote = quotedEnd
360
+ ? value[separatorEnd]
361
+ : (inspectArray ? (labelQuote ?? '\x22') : undefined);
362
+ return {
363
+ start: separatorEnd,
364
+ end,
365
+ replacement: quote ? quote + REDACTED_HEADER_VALUE + quote : REDACTED_HEADER_VALUE,
366
+ resumeAt: end,
367
+ };
368
+ }
369
+ function jsonContainerForOpen(kind) {
370
+ if (kind === JSON_TOKEN.openBracket)
371
+ return 'array';
372
+ if (kind === JSON_TOKEN.openBrace)
373
+ return 'object';
374
+ return undefined;
375
+ }
376
+ function jsonContainerForClose(kind) {
377
+ if (kind === JSON_TOKEN.closeBracket)
378
+ return 'array';
379
+ if (kind === JSON_TOKEN.closeBrace)
380
+ return 'object';
381
+ return undefined;
382
+ }
383
+ function isJsonScalar(kind) {
384
+ return kind === JSON_TOKEN.string
385
+ || kind === JSON_TOKEN.number
386
+ || kind === JSON_TOKEN.null
387
+ || kind === JSON_TOKEN.true
388
+ || kind === JSON_TOKEN.false;
389
+ }
390
+ function mightContainStructuredHeader(value) {
391
+ return /['"]/.test(value)
392
+ && (SENSITIVE_HTTP_HEADER_CANDIDATE.test(value) || /\\u[0-9a-f]{4}/i.test(value));
393
+ }
394
+ function applyRedactionSpans(value, spans) {
395
+ if (spans.length === 0)
396
+ return value;
397
+ spans.sort((left, right) => left.start - right.start || right.end - left.end);
398
+ let cursor = 0;
399
+ let output = '';
400
+ for (const span of spans) {
401
+ if (span.start < cursor)
402
+ continue;
403
+ output += value.slice(cursor, span.start) + span.replacement;
404
+ cursor = span.end;
405
+ }
406
+ return output + value.slice(cursor);
407
+ }
408
+ function redactStructuredJsonText(value, serializedDepth = 0) {
409
+ if (serializedDepth >= MAX_SERIALIZED_JSON_DEPTH)
410
+ return REDACTED_HEADER_VALUE;
411
+ if (!mightContainStructuredHeader(value))
412
+ return value;
413
+ const scanner = createScanner(value, true);
414
+ const containers = [];
415
+ const spans = [];
416
+ let headerLabel;
417
+ let awaitingHeaderValue;
418
+ let activeComposite;
419
+ let depthOverflow = false;
420
+ let structuralMismatch = false;
421
+ let structuredCoveredUntil = 0;
422
+ const pushContainer = (container) => {
423
+ if (containers.length >= MAX_JSON_CONTAINER_DEPTH) {
424
+ depthOverflow = true;
425
+ return false;
426
+ }
427
+ containers.push(container);
428
+ return true;
429
+ };
430
+ const popContainer = (container) => {
431
+ if (containers.at(-1) !== container) {
432
+ structuralMismatch = true;
433
+ return;
434
+ }
435
+ containers.pop();
436
+ };
437
+ const redactValue = (kind, offset, length, headerName, requireCredentialShape) => {
438
+ if (requireCredentialShape && kind === JSON_TOKEN.string) {
439
+ const decoded = scanner.getTokenValue();
440
+ if (!looksLikeSensitiveHttpHeaderValue(headerName, decoded, 0, decoded.length))
441
+ return false;
442
+ }
443
+ const container = jsonContainerForOpen(kind);
444
+ if (container) {
445
+ if (requireCredentialShape && container !== 'array')
446
+ return false;
447
+ activeComposite = {
448
+ start: offset,
449
+ baseDepth: containers.length,
450
+ ...(requireCredentialShape
451
+ ? { credentialHeaderName: headerName, credentialFound: false }
452
+ : {}),
453
+ };
454
+ pushContainer(container);
455
+ return true;
456
+ }
457
+ if (requireCredentialShape && kind !== JSON_TOKEN.string)
458
+ return false;
459
+ if (isJsonScalar(kind)) {
460
+ spans.push({
461
+ start: offset,
462
+ end: offset + length,
463
+ replacement: REDACTED_JSON_VALUE,
464
+ resumeAt: offset + length,
465
+ });
466
+ return true;
467
+ }
468
+ if (kind === JSON_TOKEN.unknown) {
469
+ if (offset < structuredCoveredUntil)
470
+ return true;
471
+ const end = physicalHeaderEnd(value, offset);
472
+ spans.push({ start: offset, end, replacement: REDACTED_JSON_VALUE, resumeAt: end });
473
+ structuredCoveredUntil = end;
474
+ return true;
475
+ }
476
+ return false;
477
+ };
478
+ for (let kind = scanner.scan(); kind !== JSON_TOKEN.eof; kind = scanner.scan()) {
479
+ const offset = scanner.getTokenOffset();
480
+ const length = scanner.getTokenLength();
481
+ const openedContainer = jsonContainerForOpen(kind);
482
+ const closedContainer = jsonContainerForClose(kind);
483
+ if (activeComposite) {
484
+ if (activeComposite.credentialHeaderName
485
+ && containers.length === activeComposite.baseDepth + 1
486
+ && kind === JSON_TOKEN.string) {
487
+ const decoded = stripUnsafeControls(stripTerminalEscapes(scanner.getTokenValue()));
488
+ if (looksLikeSensitiveHttpHeaderValue(activeComposite.credentialHeaderName, decoded, 0, decoded.length))
489
+ activeComposite.credentialFound = true;
490
+ }
491
+ if (openedContainer)
492
+ pushContainer(openedContainer);
493
+ if (closedContainer) {
494
+ popContainer(closedContainer);
495
+ if (containers.length <= activeComposite.baseDepth) {
496
+ const completed = activeComposite;
497
+ if (!completed.credentialHeaderName || completed.credentialFound) {
498
+ spans.push({
499
+ start: completed.start,
500
+ end: offset + length,
501
+ replacement: REDACTED_JSON_VALUE,
502
+ resumeAt: offset + length,
503
+ });
504
+ }
505
+ else {
506
+ const original = value.slice(completed.start, offset + length);
507
+ const nested = redactStructuredJsonText(original, serializedDepth + 1);
508
+ if (nested !== original) {
509
+ spans.push({
510
+ start: completed.start,
511
+ end: offset + length,
512
+ replacement: nested === REDACTED_HEADER_VALUE ? REDACTED_JSON_VALUE : nested,
513
+ resumeAt: offset + length,
514
+ });
515
+ }
516
+ }
517
+ activeComposite = undefined;
518
+ }
519
+ }
520
+ if (depthOverflow || structuralMismatch)
521
+ break;
522
+ continue;
523
+ }
524
+ if (awaitingHeaderValue) {
525
+ const pending = awaitingHeaderValue;
526
+ awaitingHeaderValue = undefined;
527
+ if (redactValue(kind, offset, length, pending.name, pending.requireCredentialShape)) {
528
+ if (depthOverflow)
529
+ break;
530
+ continue;
531
+ }
532
+ }
533
+ else if (headerLabel) {
534
+ const isSeparator = (headerLabel.container === 'object' && kind === JSON_TOKEN.colon)
535
+ || (headerLabel.container === 'array' && kind === JSON_TOKEN.comma);
536
+ const pending = headerLabel;
537
+ headerLabel = undefined;
538
+ if (isSeparator) {
539
+ awaitingHeaderValue = {
540
+ name: pending.name,
541
+ requireCredentialShape: kind === JSON_TOKEN.comma,
542
+ };
543
+ continue;
544
+ }
545
+ }
546
+ if (openedContainer) {
547
+ pushContainer(openedContainer);
548
+ if (depthOverflow)
549
+ break;
550
+ continue;
551
+ }
552
+ if (closedContainer) {
553
+ popContainer(closedContainer);
554
+ if (structuralMismatch)
555
+ break;
556
+ continue;
557
+ }
558
+ if (kind !== JSON_TOKEN.string)
559
+ continue;
560
+ const decoded = scanner.getTokenValue();
561
+ const normalizedDecoded = stripUnsafeControls(stripTerminalEscapes(decoded));
562
+ const currentContainer = containers.at(-1);
563
+ if (currentContainer && SENSITIVE_HTTP_HEADER_KEY.test(normalizedDecoded)) {
564
+ headerLabel = { name: normalizedDecoded, container: currentContainer };
565
+ continue;
566
+ }
567
+ if (!SENSITIVE_HTTP_HEADER_CANDIDATE.test(normalizedDecoded)
568
+ && !/\\u[0-9a-f]{4}/i.test(normalizedDecoded)) {
569
+ if (normalizedDecoded !== decoded) {
570
+ spans.push({
571
+ start: offset,
572
+ end: offset + length,
573
+ replacement: JSON.stringify(normalizedDecoded),
574
+ resumeAt: offset + length,
575
+ });
576
+ }
577
+ continue;
578
+ }
579
+ const structured = redactStructuredJsonText(normalizedDecoded, serializedDepth + 1);
580
+ const redacted = redactUnstructuredHttpHeaders(structured, true);
581
+ if (redacted === decoded)
582
+ continue;
583
+ spans.push({
584
+ start: offset,
585
+ end: offset + length,
586
+ replacement: JSON.stringify(redacted),
587
+ resumeAt: offset + length,
588
+ });
589
+ }
590
+ if (depthOverflow || structuralMismatch)
591
+ return REDACTED_HEADER_VALUE;
592
+ if (activeComposite && (!activeComposite.credentialHeaderName || activeComposite.credentialFound)) {
593
+ spans.push({
594
+ start: activeComposite.start,
595
+ end: value.length,
596
+ replacement: REDACTED_JSON_VALUE,
597
+ resumeAt: value.length,
598
+ });
599
+ }
600
+ return applyRedactionSpans(value, spans);
601
+ }
6
602
  function stripUnsafeControls(value) {
7
603
  let out = '';
8
604
  for (const char of value) {
9
605
  const code = char.codePointAt(0) ?? 0;
10
- if (code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d)
606
+ if ((code >= 0x20 && (code < 0x7f || code > 0x9f))
607
+ || code === 0x09 || code === 0x0a || code === 0x0d)
11
608
  out += char;
12
609
  }
13
610
  return out;
14
611
  }
612
+ function stripTerminalEscapes(value) {
613
+ const skipCsi = (start) => {
614
+ let cursor = start;
615
+ while (cursor < value.length) {
616
+ const code = value.charCodeAt(cursor);
617
+ cursor += 1;
618
+ if (code >= 0x40 && code <= 0x7e)
619
+ break;
620
+ }
621
+ return cursor;
622
+ };
623
+ const skipControlString = (start, allowBell) => {
624
+ let cursor = start;
625
+ while (cursor < value.length) {
626
+ const code = value.charCodeAt(cursor);
627
+ if ((allowBell && code === 0x07) || code === 0x9c)
628
+ return cursor + 1;
629
+ if (code === 0x1b && value.charCodeAt(cursor + 1) === 0x5c)
630
+ return cursor + 2;
631
+ cursor += 1;
632
+ }
633
+ return cursor;
634
+ };
635
+ const skipEscapeSequence = (start) => {
636
+ let cursor = start;
637
+ while (cursor < value.length) {
638
+ const code = value.charCodeAt(cursor);
639
+ if (code < 0x20 || code > 0x2f)
640
+ break;
641
+ cursor += 1;
642
+ }
643
+ const final = value.charCodeAt(cursor);
644
+ if (final >= 0x30 && final <= 0x7e)
645
+ return cursor + 1;
646
+ return cursor > start ? value.length : start;
647
+ };
648
+ let output = '';
649
+ for (let index = 0; index < value.length;) {
650
+ const code = value.charCodeAt(index);
651
+ if (code === 0x9b) {
652
+ index = skipCsi(index + 1);
653
+ continue;
654
+ }
655
+ if (code === 0x9d) {
656
+ index = skipControlString(index + 1, true);
657
+ continue;
658
+ }
659
+ if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
660
+ index = skipControlString(index + 1, false);
661
+ continue;
662
+ }
663
+ if (code === 0x1b) {
664
+ const next = value.charCodeAt(index + 1);
665
+ if (next === 0x5b) {
666
+ index = skipCsi(index + 2);
667
+ }
668
+ else if (next === 0x5d) {
669
+ index = skipControlString(index + 2, true);
670
+ }
671
+ else if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
672
+ index = skipControlString(index + 2, false);
673
+ }
674
+ else {
675
+ index = skipEscapeSequence(index + 1);
676
+ }
677
+ continue;
678
+ }
679
+ output += value[index];
680
+ index += 1;
681
+ }
682
+ return output;
683
+ }
684
+ function redactUnstructuredHttpHeaders(value, embedded) {
685
+ const spans = [];
686
+ const budget = { remaining: Math.max(value.length * 2, 1_024) };
687
+ SENSITIVE_HTTP_HEADER_NAME.lastIndex = 0;
688
+ let lineStart = 0;
689
+ let lineEnd = physicalLineEnd(value, 0);
690
+ let cachedLineEnd = -1;
691
+ let cachedPhysicalEnd = lineEnd;
692
+ const physicalHeaderEndCache = new Map();
693
+ let rawPositionAvailable = true;
694
+ let match;
695
+ while ((match = SENSITIVE_HTTP_HEADER_NAME.exec(value)) !== null) {
696
+ const headerName = match[1];
697
+ if (!headerName)
698
+ continue;
699
+ const nameStart = match.index;
700
+ const nameEnd = nameStart + headerName.length;
701
+ while (nameStart > lineEnd && lineEnd < value.length) {
702
+ lineStart = value[lineEnd] === '\r' && value[lineEnd + 1] === '\n'
703
+ ? lineEnd + 2
704
+ : lineEnd + 1;
705
+ lineEnd = physicalLineEnd(value, lineStart);
706
+ rawPositionAvailable = true;
707
+ }
708
+ if (cachedLineEnd !== lineEnd) {
709
+ cachedLineEnd = lineEnd;
710
+ cachedPhysicalEnd = physicalHeaderEndFromLineEnd(value, lineEnd, physicalHeaderEndCache);
711
+ }
712
+ const span = (rawPositionAvailable
713
+ ? rawHeaderSpan(value, headerName, lineStart, lineEnd, cachedPhysicalEnd, nameStart, nameEnd, embedded)
714
+ : undefined)
715
+ ?? inlineHeaderSpan(value, headerName, lineEnd, cachedPhysicalEnd, nameStart, nameEnd, budget);
716
+ rawPositionAvailable = false;
717
+ if (!span) {
718
+ SENSITIVE_HTTP_HEADER_NAME.lastIndex = nameEnd;
719
+ continue;
720
+ }
721
+ spans.push(span);
722
+ SENSITIVE_HTTP_HEADER_NAME.lastIndex = Math.max(span.resumeAt, nameEnd);
723
+ }
724
+ SENSITIVE_HTTP_HEADER_NAME.lastIndex = 0;
725
+ if (spans.length === 0)
726
+ return value;
727
+ let cursor = 0;
728
+ let output = '';
729
+ for (const span of spans) {
730
+ output += value.slice(cursor, span.start) + span.replacement;
731
+ cursor = span.end;
732
+ }
733
+ return output + value.slice(cursor);
734
+ }
735
+ export function redactSensitiveHttpHeaders(value) {
736
+ const couldBeStructured = value.includes('{')
737
+ || value.includes('[')
738
+ || value.includes('\x22')
739
+ || value.includes('\x27');
740
+ const structured = couldBeStructured
741
+ ? redactStructuredJsonText(value)
742
+ : value;
743
+ const normalized = stripUnsafeControls(stripTerminalEscapes(structured));
744
+ return redactUnstructuredHttpHeaders(normalized, false);
745
+ }
15
746
  export function redactDiagnosticText(value, maxChars = MAX_TEXT) {
16
747
  const raw = typeof value === 'string' ? value : value == null ? '' : String(value);
17
- const redacted = stripUnsafeControls(raw)
748
+ const diagnosticRedacted = redactSensitiveHttpHeaders(raw)
749
+ .replace(/^([ \t]*)(proxy[-_]?authorization|authorization|set[-_]?cookie|cookie)\b\s*:\s*\[redacted\]/gim, '$1$2=[redacted]')
18
750
  .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, '[redacted private key]')
19
751
  .replace(/\b(https?:\/\/)[^/\s@]+@/gi, '$1')
20
752
  .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[redacted]')
21
- .replace(/\b(authorization|proxy-authorization|cookie|set-cookie|api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret)\b\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
753
+ .replace(/\b(proxy[-_]?authorization|authorization)\b\s*=\s*(?!(?:\x22|')?\[redacted\](?:\x22|')?)(?:\x22[^\x22]*\x22|'[^']*'|[^\s,;\x22']+)/gi, '$1=[redacted]')
754
+ .replace(/\b(cookie|set[-_]?cookie)\b\s*=\s*(?!(?:\x22|')?\[redacted\](?:\x22|')?)(?:\x22[^\x22]*\x22|'[^']*'|[^\s,;\x22']+)/gi, '$1=[redacted]')
755
+ .replace(/\b(api[_-]?key|account[_-]?key|instrumentation[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|private[_-]?key|node[_-]?secret|token|password|passwd|secret)\b\s*[:=]\s*(?!(?:\x22|')?\[redacted\](?:\x22|')?)(?:\x22[^\x22]*\x22|'[^']*'|[^\s,;\x22']+)/gi, '$1=[redacted]')
22
756
  .replace(/\b[A-Za-z]:\\[^\s"']+/g, '[path]')
23
757
  .replace(/\\\\[^\\\s"']+\\[^\\\s"']+(?:\\[^\\\s"']+)*/g, '[path]')
24
758
  .replace(/(?:\/(?:Users|home|var|tmp|private|opt)\/[^\s"']+)+/g, '[path]')
25
759
  .trim();
760
+ const redacted = diagnosticRedacted
761
+ .split('[redacted]')
762
+ .map((segment) => hub.redactString(segment))
763
+ .join('[redacted]')
764
+ .trim();
26
765
  return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}…` : redacted;
27
766
  }
28
767
  export function sanitizeDiagnosticValue(value, depth = 0) {
@@ -14,5 +14,6 @@ export declare class EventSnapshotCache {
14
14
  constructor(source: EventSnapshotSource);
15
15
  read(): Promise<readonly ev.ReportEvent[]>;
16
16
  private readOnce;
17
+ private readVersioned;
17
18
  }
18
19
  export {};
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { Worker } from 'node:worker_threads';
4
4
  import { events as ev } from '@evomap/evolver-core';
5
5
  const ARCHIVE_SEGMENT_PATTERN = /^root-events-\d{16}-\d{16}\.jsonl$/;
6
+ const MAX_VERSIONED_READ_ATTEMPTS = 2;
6
7
  const EVENT_SNAPSHOT_WORKER_SOURCE = `
7
8
  import { parentPort, workerData } from 'node:worker_threads';
8
9
 
@@ -132,6 +133,9 @@ export class EventSnapshotCache {
132
133
  catch {
133
134
  return await this.source.read();
134
135
  }
136
+ return await this.readVersioned(before, MAX_VERSIONED_READ_ATTEMPTS);
137
+ }
138
+ async readVersioned(before, attemptsRemaining) {
135
139
  if (this.cached?.version === before)
136
140
  return this.cached.events;
137
141
  const events = await this.source.read();
@@ -142,8 +146,12 @@ export class EventSnapshotCache {
142
146
  catch {
143
147
  return events;
144
148
  }
145
- if (before === after)
149
+ if (before === after) {
146
150
  this.cached = { version: after, events };
147
- return events;
151
+ return events;
152
+ }
153
+ if (attemptsRemaining === 1)
154
+ return events;
155
+ return await this.readVersioned(after, attemptsRemaining - 1);
148
156
  }
149
157
  }
@@ -0,0 +1,7 @@
1
+ export interface JsoncScanner {
2
+ scan(): number;
3
+ getTokenOffset(): number;
4
+ getTokenLength(): number;
5
+ getTokenValue(): string;
6
+ }
7
+ export declare const createScanner: (text: string, ignoreTrivia?: boolean) => JsoncScanner;
@@ -0,0 +1,10 @@
1
+ // jsonc-parser's main entry is a UMD wrapper whose shadowed `require` leaves
2
+ // './impl/format' unresolved inside bun standalone binaries, and its ESM entry
3
+ // uses extensionless imports that Node ESM rejects. Depend on the scanner impl
4
+ // directly: it has no sibling dependencies, so bundlers can inline it safely.
5
+ // Use a default import because the impl is CommonJS and named CJS exports are
6
+ // not reliably detected by Node's ESM loader. The explicit JsoncScanner type
7
+ // keeps the emitted .d.ts self-contained so consumers never resolve the deep
8
+ // specifier.
9
+ import scannerImpl from 'jsonc-parser/lib/umd/impl/scanner.js';
10
+ export const createScanner = scannerImpl.createScanner;
@@ -1,4 +1,6 @@
1
1
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from 'node:fs';
2
+ import { hub } from '@evomap/evolver-core';
3
+ import { redactSensitiveHttpHeaders } from './diagnosticSanitize.js';
2
4
  export const LOG_DIAGNOSTICS_MAX_BYTES = 128 * 1024;
3
5
  export const LOG_DIAGNOSTICS_MAX_LINES = 200;
4
6
  const LOG_DIAGNOSTICS_HARD_MAX_BYTES = 1024 * 1024;
@@ -10,13 +12,19 @@ function boundedInteger(value, fallback, maximum) {
10
12
  return Math.max(1, Math.min(maximum, Math.floor(value)));
11
13
  }
12
14
  function redactSecrets(input) {
13
- let text = input;
15
+ let text = replaceUnsafeControls(input);
14
16
  text = text.replace(/-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/gi, '[redacted private key]');
15
17
  text = text.replace(/-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*$/gi, '[redacted private key]');
16
18
  text = text.replace(/\bBearer\s+[^\s,;"']+/gi, 'Bearer [redacted]');
17
- text = text.replace(/\b(authorization|proxy-authorization|api[_ -]?key|token|access[_ -]?token|refresh[_ -]?token|password|passwd|cookie|set-cookie)\b(\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, '$1$2[redacted]');
19
+ text = text.replace(/\b(proxy[-_]?authorization|authorization)\b(\s*=\s*)(?!(?:"|')?\[redacted\](?:"|')?)(?:"[^"]*"|'[^']*'|[^\s,;"']+)/gi, '$1$2[redacted]');
20
+ text = text.replace(/\b(cookie|set[-_]?cookie)\b(\s*=\s*)(?!(?:"|')?\[redacted\](?:"|')?)(?:"[^"]*"|'[^']*'|[^\s,;"']+)/gi, '$1$2[redacted]');
21
+ text = text.replace(/\b(api[_ -]?key|token|access[_ -]?token|refresh[_ -]?token|password|passwd)\b(\s*[=:]\s*)(?!(?:"|')?\[redacted\](?:"|')?)(?:"[^"]*"|'[^']*'|[^\s,;"']+)/gi, '$1$2[redacted]');
18
22
  text = text.replace(/([?&](?:api[_-]?key|token|access_token|password)=)[^&\s]+/gi, '$1[redacted]');
19
- return text;
23
+ text = text.replace(/\b(id[_ -]?token|private[_ -]?key|node[_ -]?secret|account[_ -]?key|instrumentation[_ -]?key)\b(\s*[=:]\s*)[^\s,;]+/gi, '$1$2[redacted]');
24
+ return text
25
+ .split('[redacted]')
26
+ .map((segment) => hub.redactString(segment))
27
+ .join('[redacted]');
20
28
  }
21
29
  function replaceUnsafeControls(value) {
22
30
  let out = '';
@@ -32,6 +40,9 @@ function safeLine(line) {
32
40
  return '[redacted private key material]';
33
41
  return normalized.slice(0, MAX_LINE_CHARS);
34
42
  }
43
+ function redactTruncatedLeadingContinuations(value) {
44
+ return value.replace(/^(?:[ \t]+[^\r\n]*(?:\r?\n|$))+/, '[redacted truncated continuation]\n');
45
+ }
35
46
  export function readLogDiagnostics(logFile, options = {}) {
36
47
  const maxBytes = boundedInteger(options.maxBytes, LOG_DIAGNOSTICS_MAX_BYTES, LOG_DIAGNOSTICS_HARD_MAX_BYTES);
37
48
  const maxLines = boundedInteger(options.maxLines, LOG_DIAGNOSTICS_MAX_LINES, LOG_DIAGNOSTICS_HARD_MAX_LINES);
@@ -54,10 +65,21 @@ export function readLogDiagnostics(logFile, options = {}) {
54
65
  const buffer = Buffer.alloc(bytes);
55
66
  if (bytes > 0)
56
67
  readSync(fd, buffer, 0, bytes, start);
68
+ let startsAtLineBoundary = start === 0;
69
+ if (start > 0) {
70
+ const previous = Buffer.allocUnsafe(1);
71
+ startsAtLineBoundary = readSync(fd, previous, 0, 1, start - 1) === 1 && previous[0] === 0x0a;
72
+ }
57
73
  let text = buffer.toString('utf8');
58
74
  if (start > 0) {
59
- const firstNewline = text.indexOf('\n');
60
- text = firstNewline >= 0 ? text.slice(firstNewline + 1) : '';
75
+ if (!startsAtLineBoundary) {
76
+ const firstNewline = text.indexOf('\n');
77
+ text = firstNewline >= 0 ? text.slice(firstNewline + 1) : '';
78
+ }
79
+ text = redactTruncatedLeadingContinuations(replaceUnsafeControls(redactSensitiveHttpHeaders(text)));
80
+ }
81
+ else {
82
+ text = replaceUnsafeControls(redactSensitiveHttpHeaders(text));
61
83
  }
62
84
  const redacted = redactSecrets(text);
63
85
  const allLines = redacted.split(/\r?\n/).filter((line) => line.length > 0);
package/dist/server.js CHANGED
@@ -256,6 +256,39 @@ export class WebUIServer {
256
256
  }
257
257
  if (p === '/api/triggers')
258
258
  return this.json(res, ev.listTriggers(await this.eventSnapshots.read()));
259
+ if (p === '/api/evolution-graph') {
260
+ // Read-only projection of the SAME event snapshot the other cards read. The WebUI never re-derives lineage:
261
+ // core owns the projector, the server only bounds the window and serializes the summary + edge list.
262
+ if (!requireGet(req, res))
263
+ return;
264
+ try {
265
+ const graph = ops.projectEvolutionGraph(await this.eventSnapshots.read(), {
266
+ maxEvents: positiveIntParam(url.searchParams.get('maxEvents')),
267
+ });
268
+ return this.json(res, {
269
+ available: true,
270
+ graphId: graph.graphId,
271
+ generatedAt: graph.generatedAt,
272
+ dashboard: graph.dashboard,
273
+ nodes: graph.nodes.map((node) => ({ id: node.id, kind: node.kind, label: redactDiagnosticText(node.label) })),
274
+ edges: graph.edges.map((edge) => ({
275
+ id: edge.id,
276
+ kind: edge.kind,
277
+ from: edge.from,
278
+ to: edge.to,
279
+ ...(edge.reason ? { reason: redactDiagnosticText(edge.reason) } : {}),
280
+ ...(edge.metricDelta ? { metricDelta: edge.metricDelta } : {}),
281
+ provenance: edge.provenance.map((entry) => ({ kind: entry.kind, ref: entry.ref })),
282
+ })),
283
+ });
284
+ }
285
+ catch {
286
+ return this.send(res, 503, 'application/json', JSON.stringify({
287
+ available: false,
288
+ error: 'evolution_graph_unavailable',
289
+ }));
290
+ }
291
+ }
259
292
  if (p === '/api/daily-summary') {
260
293
  const day = url.searchParams.get('day') ?? new Date(this.now()).toISOString().slice(0, 10);
261
294
  return this.json(res, ev.dailySummary(await this.eventSnapshots.read(), day));
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@evomap/evolver-webui",
3
- "version": "2.0.0-beta.9",
3
+ "version": "2.0.1",
4
4
  "private": false,
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": "^22.13.0 || >=23.4.0"
8
+ },
6
9
  "description": "保活/可视化 WebUI",
7
10
  "main": "./dist/index.js",
8
11
  "types": "./dist/index.d.ts",
@@ -13,7 +16,8 @@
13
16
  }
14
17
  },
15
18
  "dependencies": {
16
- "@evomap/evolver-core": "2.0.0-beta.9"
19
+ "@evomap/evolver-core": "2.0.1",
20
+ "jsonc-parser": "3.3.1"
17
21
  },
18
22
  "repository": {
19
23
  "type": "git",
@@ -21,7 +25,7 @@
21
25
  },
22
26
  "publishConfig": {
23
27
  "access": "public",
24
- "tag": "v2-beta"
28
+ "tag": "latest"
25
29
  },
26
30
  "files": [
27
31
  "dist/",