@crouton-kit/capture 1.3.18 → 1.3.19
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/bin/capture +4109 -3229
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/cdp/args.ts +14 -2
- package/src/cdp/browser-process.ts +510 -0
- package/src/cdp/client.ts +10 -1
- package/src/cdp/commands/measure/snap.ts +37 -2
- package/src/cdp/commands/motion/mask.ts +8 -2
- package/src/cdp/commands/page/click.ts +14 -0
- package/src/cdp/commands/page/elements.ts +2 -2
- package/src/cdp/commands/page/shot.ts +26 -5
- package/src/cdp/commands/tab/index.ts +19 -8
- package/src/cdp/commands/tab/launch.ts +73 -0
- package/src/cdp/commands/tab/list.ts +14 -2
- package/src/cdp/commands/tab/quit.ts +78 -0
- package/src/cdp/har-recorder.ts +14 -20
- package/src/cdp/measure/collectors/ax.ts +3 -4
- package/src/cdp/measure/collectors/forms.ts +4 -6
- package/src/cdp/measure/collectors/media.ts +1 -2
- package/src/cdp/measure/collectors/states.ts +1 -6
- package/src/cdp/measure/collectors/style-provenance.ts +4 -16
- package/src/cdp/measure/collectors/styles.ts +21 -10
- package/src/cdp/measure/collectors/text.ts +3 -5
- package/src/cdp/measure/snapshot.ts +195 -29
- package/src/cdp/measure/types.ts +32 -2
- package/src/cdp/motion/jank.ts +8 -1
- package/src/cdp/screenshot.ts +83 -8
- package/src/cdp/targets.ts +5 -0
- package/src/cdp/types.ts +4 -0
- package/src/har-manager.ts +16 -15
- package/src/interact.ts +20 -4
- package/src/output/render.ts +2 -0
- package/src/registry.ts +1 -1
- package/src/session/commands.ts +223 -43
- package/src/session/har-redact.ts +125 -0
package/src/session/commands.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
validateHarFile,
|
|
9
9
|
type HarFile,
|
|
10
10
|
type HAREntry,
|
|
11
|
+
type Header,
|
|
12
|
+
type IncompleteLifecycle,
|
|
11
13
|
} from '../har-manager.js';
|
|
12
14
|
import {
|
|
13
15
|
getActiveSession,
|
|
@@ -49,6 +51,7 @@ import {
|
|
|
49
51
|
} from './artifacts.js';
|
|
50
52
|
import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
|
|
51
53
|
import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
|
|
54
|
+
import { redactUrlCredentials } from './har-redact.js';
|
|
52
55
|
|
|
53
56
|
type Session = ActiveSessionState;
|
|
54
57
|
|
|
@@ -327,32 +330,40 @@ export async function waitForPageLoad(
|
|
|
327
330
|
|
|
328
331
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
329
332
|
export const COMMAND_BLOCK = `<command name="session">
|
|
330
|
-
the artifact container — a session
|
|
333
|
+
the artifact container — a session records HAR and bundles every artifact; it may open a tab or adopt one later; while active, every command auto-targets its tab
|
|
331
334
|
use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
|
|
332
335
|
start · stop · list · view · har · log — \`capture session -h\`
|
|
333
336
|
</command>`;
|
|
334
337
|
|
|
335
|
-
const START_USAGE = `capture session start [--url <url>] [--hold] — open a tab, record HAR, and set the active capture context.
|
|
338
|
+
const START_USAGE = `capture session start [--url <url>] [--hold] — open or prepare a tab, record HAR, and set the active capture context.
|
|
336
339
|
|
|
337
340
|
input:
|
|
338
|
-
--url <url> Absolute URL to open in a fresh tab
|
|
339
|
-
|
|
341
|
+
--url <url> Absolute URL to open in a fresh tab through CDP's
|
|
342
|
+
Target.createTarget. Some endpoints, including Electron,
|
|
343
|
+
expose existing tabs but reject Target.createTarget; on those
|
|
344
|
+
endpoints --url cannot be used.
|
|
340
345
|
--hold Hold one CDP browser connection open for the session's
|
|
341
346
|
lifetime, so browser-level state (permission grants,
|
|
342
347
|
ServiceWorker enablement) survives across separate commands.
|
|
343
|
-
With no --url,
|
|
344
|
-
|
|
348
|
+
With no --url, start a held session and adopt an existing tab
|
|
349
|
+
with this supported workflow:
|
|
350
|
+
capture session start --hold --port <port>
|
|
351
|
+
capture tab list --port <port>
|
|
352
|
+
capture page navigate <url> --target <target-id> --port <port>
|
|
353
|
+
The navigate command adopts that target for the session; later
|
|
354
|
+
page commands then auto-target it.
|
|
345
355
|
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
346
356
|
|
|
347
357
|
Output:
|
|
348
358
|
One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
|
|
349
|
-
tab, HAR recording id, and held-bridge pid when
|
|
350
|
-
the same fields.
|
|
359
|
+
tab when --url succeeds, HAR recording id, and held-bridge pid when
|
|
360
|
+
applicable. --json mirrors the same fields.
|
|
351
361
|
|
|
352
362
|
effects:
|
|
353
|
-
Creates a private session dir with shots/, starts a HAR recording, opens
|
|
354
|
-
tab
|
|
355
|
-
the active capture context so subsequent commands
|
|
363
|
+
Creates a private session dir with shots/, starts a HAR recording, opens a
|
|
364
|
+
fresh tab only when --url is supplied, optionally holds a CDP bridge, and
|
|
365
|
+
registers the session as the active capture context so subsequent commands
|
|
366
|
+
auto-target it.`;
|
|
356
367
|
|
|
357
368
|
const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
|
|
358
369
|
|
|
@@ -400,30 +411,45 @@ Output:
|
|
|
400
411
|
effects:
|
|
401
412
|
None — reads bundle.json only.`;
|
|
402
413
|
|
|
414
|
+
/** Default row bound for one `session har` read. A busy page produces
|
|
415
|
+
* thousands of records; an unbounded list buries the row the agent came for.
|
|
416
|
+
* `--limit` overrides it in either direction. */
|
|
417
|
+
const DEFAULT_HAR_ROWS = 100;
|
|
418
|
+
|
|
403
419
|
const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
|
|
404
420
|
|
|
405
421
|
input:
|
|
406
422
|
<session-id> session to read; defaults to the active session. A
|
|
407
423
|
running session reads its live accumulating HAR; a
|
|
408
424
|
stopped one reads the bundled har.json.
|
|
409
|
-
--filter-url <pattern> substring or regex match on the request URL
|
|
425
|
+
--filter-url <pattern> substring or regex match on the request URL (matched
|
|
426
|
+
against the URL as captured, before redaction)
|
|
410
427
|
--filter-status <spec> exact status (100-599), one-digit class prefix
|
|
411
428
|
(1-5, e.g. 4 = every 4xx), or ordered range
|
|
412
|
-
(e.g. 400-499); any other token is invalid_filter
|
|
429
|
+
(e.g. 400-499); any other token is invalid_filter.
|
|
430
|
+
A record with no observed response never matches.
|
|
413
431
|
--filter-method <method> HTTP method (GET, POST, …)
|
|
414
|
-
--limit <n>
|
|
415
|
-
--full inline per-
|
|
432
|
+
--limit <n> list at most n matching rows; default ${DEFAULT_HAR_ROWS}
|
|
433
|
+
--full inline per-record detail (headers, post data,
|
|
416
434
|
response body — escaped and capped); bodies are
|
|
417
435
|
never inlined without it
|
|
418
436
|
|
|
419
437
|
output:
|
|
420
|
-
One <session-har id=… path=… source=live|bundle entries=…
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
438
|
+
One <session-har id=… path=… source=live|bundle entries=… incomplete=…
|
|
439
|
+
total=… total-incomplete=… [truncated=true]> block listing BOTH populations
|
|
440
|
+
in one chronological selection: completed entries (method, status, URL, body
|
|
441
|
+
size, start time) and the incomplete lifecycle records the recorder retained
|
|
442
|
+
for requests that never completed — rendered as "incomplete: <kind>" (an
|
|
443
|
+
out-of-order clock also names its violation, e.g.
|
|
444
|
+
invalid_clock_order/terminal_before_response). The path attribute is the HAR
|
|
445
|
+
file's absolute path — the full-fidelity pointer, which holds every record
|
|
446
|
+
including the ones this list bounds away. --json mirrors the same fields.
|
|
447
|
+
Credential-like query parameter values (key, token, secret, signature, auth,
|
|
448
|
+
password, oauth code, …) render as REDACTED; the HAR file keeps the real
|
|
449
|
+
value. WebSockets opened while a command was recording appear as entries with
|
|
450
|
+
_resourceType "websocket" and their frames in _webSocketMessages (capped at
|
|
451
|
+
200 frames/socket, 4KB/frame); sockets opened before recording started are
|
|
452
|
+
not visible.
|
|
427
453
|
|
|
428
454
|
effects:
|
|
429
455
|
None — reads recorded HAR data only.`;
|
|
@@ -449,17 +475,17 @@ effects:
|
|
|
449
475
|
the identity-bearing tailer record is registered in the session metadata.`;
|
|
450
476
|
|
|
451
477
|
function printSessionHelp(): void {
|
|
452
|
-
console.log(`capture session — the artifact container:
|
|
478
|
+
console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open a tab or adopt one later.
|
|
453
479
|
|
|
454
480
|
An active session auto-targets its tab and auto-appends recorded traffic — no
|
|
455
481
|
--target/--har threading. \`stop\` finalizes the session and writes its bundle
|
|
456
482
|
manifest; \`view\` reads that manifest back.
|
|
457
483
|
|
|
458
|
-
<subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open a tab, start HAR, and set the active capture context"/>
|
|
484
|
+
<subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open or prepare a tab, start HAR, and set the active capture context"/>
|
|
459
485
|
<subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
|
|
460
486
|
<subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
|
|
461
487
|
<subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
|
|
462
|
-
<subcommand name="har" args="[<session-id>] [--filter-url <pattern>] [--filter-status <code>] [--filter-method <method>] [--limit <n>] [--full]" whenToUse="inspect recorded traffic — the live accumulating HAR of a running session or a stopped session's bundled har.json"/>
|
|
488
|
+
<subcommand name="har" args="[<session-id>] [--filter-url <pattern>] [--filter-status <code>] [--filter-method <method>] [--limit <n>] [--full]" whenToUse="inspect recorded traffic — completed entries and retained incomplete lifecycle records, from the live accumulating HAR of a running session or a stopped session's bundled har.json"/>
|
|
463
489
|
<subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
|
|
464
490
|
|
|
465
491
|
capture session <leaf> -h Per-leaf usage`);
|
|
@@ -618,7 +644,18 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
618
644
|
}
|
|
619
645
|
|
|
620
646
|
if (url) {
|
|
621
|
-
|
|
647
|
+
try {
|
|
648
|
+
target = await startWorld.openTab(cdpPort!, url);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
651
|
+
if (message.toLowerCase().includes('not supported')) {
|
|
652
|
+
throw worldFailure(
|
|
653
|
+
`Target.createTarget is unsupported on port ${cdpPort}. Use existing tab: \`capture page navigate <url> --target <target-id> --port ${cdpPort}\` after \`capture session start --hold --port ${cdpPort}\` and \`capture tab list\`.`,
|
|
654
|
+
error,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
622
659
|
const openedPort = cdpPort!;
|
|
623
660
|
const openedTargetId = target.id;
|
|
624
661
|
acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
|
|
@@ -887,6 +924,89 @@ async function locateSessionHar(session: Session): Promise<HarSource | { unavail
|
|
|
887
924
|
}
|
|
888
925
|
}
|
|
889
926
|
|
|
927
|
+
/**
|
|
928
|
+
* The `session har` selection population is BOTH of the HAR file's records:
|
|
929
|
+
* the completed entries under `log.entries` and the incomplete lifecycle
|
|
930
|
+
* records under `incompleteLifecycles`. A request whose lifecycle the recorder
|
|
931
|
+
* could not complete (the page was still in flight at session stop, or Chrome
|
|
932
|
+
* reported clocks out of order) is retained evidence, not lost traffic —
|
|
933
|
+
* rendering only `log.entries` hid it from every read, which is exactly when an
|
|
934
|
+
* agent is looking for the request that did not finish.
|
|
935
|
+
*/
|
|
936
|
+
type HarSelection = {
|
|
937
|
+
/** Original captured URL — what every filter matches against. */
|
|
938
|
+
url: string;
|
|
939
|
+
method: string;
|
|
940
|
+
/** Response status when one was observed; null when the record has no response. */
|
|
941
|
+
status: number | null;
|
|
942
|
+
startedDateTime: string;
|
|
943
|
+
/** Capture order, the deterministic tie-breaker for equal start times. */
|
|
944
|
+
order: number;
|
|
945
|
+
} & (
|
|
946
|
+
| { kind: 'complete'; entry: HAREntry }
|
|
947
|
+
| { kind: 'incomplete'; record: IncompleteLifecycle }
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
/** The response an incomplete record observed before it was cut short, if any. */
|
|
951
|
+
function incompleteResponse(record: IncompleteLifecycle): { status: number; headers: Header[] } | null {
|
|
952
|
+
switch (record.kind) {
|
|
953
|
+
case 'stopped_before_terminal':
|
|
954
|
+
return record._capture.response;
|
|
955
|
+
case 'invalid_clock_order':
|
|
956
|
+
return record.response;
|
|
957
|
+
case 'stopped_during_body':
|
|
958
|
+
return record.response;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** The incomplete record's kind, qualified by the clock violation when it has one. */
|
|
963
|
+
function incompleteLabel(record: IncompleteLifecycle): string {
|
|
964
|
+
return record.kind === 'invalid_clock_order' ? `${record.kind}/${record.violation}` : record.kind;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Merges completed entries and incomplete lifecycle records into one
|
|
969
|
+
* chronologically ordered population. Ordering is deterministic for a given
|
|
970
|
+
* HAR file: start time first, then capture order (entries before incomplete
|
|
971
|
+
* records at an identical timestamp).
|
|
972
|
+
*/
|
|
973
|
+
function buildHarSelection(har: HarFile): HarSelection[] {
|
|
974
|
+
const items: HarSelection[] = [];
|
|
975
|
+
for (const entry of har.log.entries) {
|
|
976
|
+
items.push({
|
|
977
|
+
kind: 'complete',
|
|
978
|
+
entry,
|
|
979
|
+
url: entry.request.url,
|
|
980
|
+
method: entry.request.method,
|
|
981
|
+
status: entry.response.status,
|
|
982
|
+
startedDateTime: entry.startedDateTime,
|
|
983
|
+
order: items.length,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
for (const record of har.incompleteLifecycles) {
|
|
987
|
+
items.push({
|
|
988
|
+
kind: 'incomplete',
|
|
989
|
+
record,
|
|
990
|
+
url: record.request.url,
|
|
991
|
+
method: record.request.method,
|
|
992
|
+
status: incompleteResponse(record)?.status ?? null,
|
|
993
|
+
startedDateTime: record.startedDateTime,
|
|
994
|
+
order: items.length,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return items.sort((a, b) =>
|
|
998
|
+
a.startedDateTime === b.startedDateTime
|
|
999
|
+
? a.order - b.order
|
|
1000
|
+
: (a.startedDateTime < b.startedDateTime ? -1 : 1),
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Every rendered URL passes through render-time credential redaction; the HAR
|
|
1005
|
+
* artifact and the `--filter-url` match target keep the original value. */
|
|
1006
|
+
function harUrl(url: string): FactLine {
|
|
1007
|
+
return data(redactUrlCredentials(url), 300);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
890
1010
|
/** One selection-list row: method, status, URL, body size, start time. Body
|
|
891
1011
|
* content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
|
|
892
1012
|
function harEntryRow(e: HAREntry): FactLine {
|
|
@@ -896,13 +1016,27 @@ function harEntryRow(e: HAREntry): FactLine {
|
|
|
896
1016
|
: text`body not captured`;
|
|
897
1017
|
return line(
|
|
898
1018
|
fact`${e.request.method} ${e.response.status} `,
|
|
899
|
-
|
|
1019
|
+
harUrl(e.request.url),
|
|
900
1020
|
text` — `,
|
|
901
1021
|
sizePart,
|
|
902
1022
|
fact` — started ${e.startedDateTime}`,
|
|
903
1023
|
);
|
|
904
1024
|
}
|
|
905
1025
|
|
|
1026
|
+
/** One selection-list row for an incomplete lifecycle record: it names the
|
|
1027
|
+
* kind (and clock violation) in place of the body size a completed entry
|
|
1028
|
+
* reports, so a row is never mistaken for finished traffic. */
|
|
1029
|
+
function harIncompleteRow(record: IncompleteLifecycle): FactLine {
|
|
1030
|
+
const response = incompleteResponse(record);
|
|
1031
|
+
return line(
|
|
1032
|
+
fact`${record.request.method} ${response ? response.status : 'no-response'} `,
|
|
1033
|
+
harUrl(record.request.url),
|
|
1034
|
+
text` — `,
|
|
1035
|
+
fact`incomplete: ${incompleteLabel(record)}`,
|
|
1036
|
+
fact` — started ${record.startedDateTime}`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
906
1040
|
/** `--full` inline detail for one entry: headers, post data, and response
|
|
907
1041
|
* body — every value escaped and capped through data()/fact. */
|
|
908
1042
|
function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
@@ -925,6 +1059,33 @@ function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
|
925
1059
|
return lineList(rows);
|
|
926
1060
|
}
|
|
927
1061
|
|
|
1062
|
+
/** `--full` inline detail for one incomplete lifecycle record: the same
|
|
1063
|
+
* headers/post data a completed entry shows, plus the provenance that explains
|
|
1064
|
+
* why the lifecycle never completed. */
|
|
1065
|
+
function harIncompleteDetail(record: IncompleteLifecycle, index: number): FactLine {
|
|
1066
|
+
const rows: FactLine[] = [line(fact`${index + 1}. `, harIncompleteRow(record))];
|
|
1067
|
+
rows.push(fact` request ${record.requestId} (generation ${record.generation})`);
|
|
1068
|
+
for (const h of record.request.headers ?? []) {
|
|
1069
|
+
rows.push(fact` req ${h.name}: ${h.value}`);
|
|
1070
|
+
}
|
|
1071
|
+
if (record.request.postData?.text !== undefined) {
|
|
1072
|
+
rows.push(fact` post data: ${capped(record.request.postData.text, 2000)}`);
|
|
1073
|
+
}
|
|
1074
|
+
const response = incompleteResponse(record);
|
|
1075
|
+
for (const h of response?.headers ?? []) {
|
|
1076
|
+
rows.push(fact` res ${h.name}: ${h.value}`);
|
|
1077
|
+
}
|
|
1078
|
+
if (record.kind === 'invalid_clock_order') {
|
|
1079
|
+
rows.push(fact` terminal: ${record.terminal.kind}`);
|
|
1080
|
+
if (record.terminal.kind === 'failed') {
|
|
1081
|
+
rows.push(fact` error: ${record.terminal.errorText} (canceled=${String(record.terminal.canceled)})`);
|
|
1082
|
+
}
|
|
1083
|
+
} else {
|
|
1084
|
+
rows.push(text` terminal: never observed`);
|
|
1085
|
+
}
|
|
1086
|
+
return lineList(rows);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
928
1089
|
async function har(parsed: ParsedArgs): Promise<void> {
|
|
929
1090
|
if (parsed.help) {
|
|
930
1091
|
console.log(HAR_USAGE);
|
|
@@ -993,41 +1154,57 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
993
1154
|
return;
|
|
994
1155
|
}
|
|
995
1156
|
|
|
996
|
-
let
|
|
997
|
-
const total = entries.length;
|
|
1157
|
+
let selection = buildHarSelection(located.har);
|
|
1158
|
+
const total = located.har.log.entries.length;
|
|
1159
|
+
const totalIncomplete = located.har.incompleteLifecycles.length;
|
|
998
1160
|
const filters: string[] = [];
|
|
999
1161
|
|
|
1162
|
+
// Every filter matches the ORIGINAL captured URL/method/status — redaction is
|
|
1163
|
+
// applied at render time only, so a `--filter-url` pattern still selects on
|
|
1164
|
+
// what the browser actually requested.
|
|
1000
1165
|
if (parsed.filterUrl) {
|
|
1001
1166
|
const pattern = parsed.filterUrl;
|
|
1002
1167
|
let re: RegExp | null = null;
|
|
1003
1168
|
try { re = new RegExp(pattern, 'i'); } catch { re = null; }
|
|
1004
|
-
|
|
1005
|
-
re ? re.test(
|
|
1169
|
+
selection = selection.filter((item) =>
|
|
1170
|
+
re ? re.test(item.url) : item.url.toLowerCase().includes(pattern.toLowerCase()),
|
|
1006
1171
|
);
|
|
1007
1172
|
filters.push(`url~${pattern}`);
|
|
1008
1173
|
}
|
|
1009
1174
|
if (statusMatch) {
|
|
1010
1175
|
const matches = statusMatch;
|
|
1011
|
-
|
|
1176
|
+
// A record with no observed response cannot satisfy a status filter.
|
|
1177
|
+
selection = selection.filter((item) => item.status !== null && matches(item.status));
|
|
1012
1178
|
filters.push(`status=${parsed.filterStatus}`);
|
|
1013
1179
|
}
|
|
1014
1180
|
if (parsed.filterMethod) {
|
|
1015
1181
|
const m = parsed.filterMethod.toUpperCase();
|
|
1016
|
-
|
|
1182
|
+
selection = selection.filter((item) => item.method.toUpperCase() === m);
|
|
1017
1183
|
filters.push(`method=${m}`);
|
|
1018
1184
|
}
|
|
1019
|
-
if (typeof parsed.limit === 'number' && parsed.limit > 0) {
|
|
1020
|
-
entries = entries.slice(0, parsed.limit);
|
|
1021
|
-
filters.push(`limit=${parsed.limit}`);
|
|
1022
|
-
}
|
|
1023
1185
|
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1186
|
+
const requestedLimit = typeof parsed.limit === 'number' && parsed.limit > 0 ? parsed.limit : null;
|
|
1187
|
+
if (requestedLimit !== null) filters.push(`limit=${requestedLimit}`);
|
|
1188
|
+
const bound = requestedLimit ?? DEFAULT_HAR_ROWS;
|
|
1189
|
+
const matched = selection.length;
|
|
1190
|
+
const shown = selection.slice(0, bound);
|
|
1191
|
+
const truncated = matched > shown.length;
|
|
1192
|
+
|
|
1193
|
+
const shownEntries = shown.filter((item) => item.kind === 'complete').length;
|
|
1194
|
+
const shownIncomplete = shown.length - shownEntries;
|
|
1195
|
+
|
|
1196
|
+
const filterNote = filters.length > 0 ? ` (${filters.join(', ')})` : '';
|
|
1197
|
+
const truncationNote = truncated
|
|
1198
|
+
? ` ${matched - shown.length} further matching rows are not listed — raise the bound with --limit.`
|
|
1199
|
+
: '';
|
|
1200
|
+
const summary = fact`${shownEntries} of ${total} complete entries and ${shownIncomplete} of ${totalIncomplete} incomplete lifecycle records listed${filterNote}.${truncationNote}`;
|
|
1027
1201
|
|
|
1028
1202
|
const sections = parsed.full
|
|
1029
|
-
?
|
|
1030
|
-
: [lineList(
|
|
1203
|
+
? shown.map((item, i) => (item.kind === 'complete' ? harEntryDetail(item.entry, i) : harIncompleteDetail(item.record, i)))
|
|
1204
|
+
: [lineList(shown.map((item, i) => line(
|
|
1205
|
+
fact`${i + 1}. `,
|
|
1206
|
+
item.kind === 'complete' ? harEntryRow(item.entry) : harIncompleteRow(item.record),
|
|
1207
|
+
)))];
|
|
1031
1208
|
|
|
1032
1209
|
emitResult({
|
|
1033
1210
|
tag: 'session-har',
|
|
@@ -1035,8 +1212,11 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
1035
1212
|
id,
|
|
1036
1213
|
path: located.path,
|
|
1037
1214
|
source: located.source,
|
|
1038
|
-
entries:
|
|
1215
|
+
entries: shownEntries,
|
|
1216
|
+
incomplete: shownIncomplete,
|
|
1039
1217
|
total,
|
|
1218
|
+
'total-incomplete': totalIncomplete,
|
|
1219
|
+
truncated: truncated ? true : undefined,
|
|
1040
1220
|
},
|
|
1041
1221
|
summary,
|
|
1042
1222
|
sections,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-time credential redaction for HAR request URLs — the ONE narrow
|
|
3
|
+
* carve-out in Capture's otherwise no-redaction posture.
|
|
4
|
+
*
|
|
5
|
+
* Capture preserves browser evidence rather than redacting it, and the HAR
|
|
6
|
+
* artifact on disk remains full fidelity: this module is applied only where a
|
|
7
|
+
* URL is rendered into a `session har` block, never on collection, never on
|
|
8
|
+
* the stored `har.json`/live NDJSON, and never on the value a `--filter-url`
|
|
9
|
+
* pattern is matched against. Its whole job is to keep a dashboard/API session
|
|
10
|
+
* token out of an agent's transcript and terminal scrollback while leaving the
|
|
11
|
+
* request itself identifiable — the parameter name, ordering, and every
|
|
12
|
+
* non-credential value survive untouched, so the row still joins to the
|
|
13
|
+
* artifact record it came from.
|
|
14
|
+
*
|
|
15
|
+
* Matching is on the parameter NAME only; no value is ever inspected, so this
|
|
16
|
+
* cannot depend on the shape of any observed secret.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Replacement text for a credential-like parameter value in rendered output. */
|
|
20
|
+
export const REDACTED_VALUE = 'REDACTED';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parameter names that are credential-bearing on their own. Compared after
|
|
24
|
+
* normalization (lowercased, every non-alphanumeric character dropped), so
|
|
25
|
+
* `api_key`, `API-Key`, and `apiKey` all reduce to `apikey`.
|
|
26
|
+
*/
|
|
27
|
+
const CREDENTIAL_PARAM_NAMES: ReadonlySet<string> = new Set([
|
|
28
|
+
'key',
|
|
29
|
+
'apikey',
|
|
30
|
+
'accesskey',
|
|
31
|
+
'secretkey',
|
|
32
|
+
'privatekey',
|
|
33
|
+
'sig',
|
|
34
|
+
'signature',
|
|
35
|
+
'auth',
|
|
36
|
+
'authorization',
|
|
37
|
+
'credential',
|
|
38
|
+
'credentials',
|
|
39
|
+
'password',
|
|
40
|
+
'passwd',
|
|
41
|
+
'pwd',
|
|
42
|
+
'token',
|
|
43
|
+
'secret',
|
|
44
|
+
'code',
|
|
45
|
+
'authcode',
|
|
46
|
+
'authorizationcode',
|
|
47
|
+
'sessionid',
|
|
48
|
+
'sessionkey',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fragments that make ANY containing parameter name credential-bearing —
|
|
53
|
+
* `access_token`, `refresh-token`, `clientSecret`, `x-amz-signature`, and the
|
|
54
|
+
* rest of the long tail no exact list keeps up with.
|
|
55
|
+
*/
|
|
56
|
+
const CREDENTIAL_PARAM_FRAGMENTS: readonly string[] = [
|
|
57
|
+
'token',
|
|
58
|
+
'secret',
|
|
59
|
+
'password',
|
|
60
|
+
'passwd',
|
|
61
|
+
'credential',
|
|
62
|
+
'apikey',
|
|
63
|
+
'accesskey',
|
|
64
|
+
'privatekey',
|
|
65
|
+
'signature',
|
|
66
|
+
'authorization',
|
|
67
|
+
'sessionid',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function normalizeParamName(raw: string): string {
|
|
71
|
+
let decoded = raw;
|
|
72
|
+
try {
|
|
73
|
+
decoded = decodeURIComponent(raw.replace(/\+/g, ' '));
|
|
74
|
+
} catch {
|
|
75
|
+
decoded = raw;
|
|
76
|
+
}
|
|
77
|
+
return decoded.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a query/fragment parameter name names a credential. */
|
|
81
|
+
export function isCredentialParamName(raw: string): boolean {
|
|
82
|
+
const name = normalizeParamName(raw);
|
|
83
|
+
if (name === '') return false;
|
|
84
|
+
if (CREDENTIAL_PARAM_NAMES.has(name)) return true;
|
|
85
|
+
return CREDENTIAL_PARAM_FRAGMENTS.some((fragment) => name.includes(fragment));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Redacts credential values in one `a=1&b=2` parameter section. */
|
|
89
|
+
function redactParamSection(section: string): string {
|
|
90
|
+
return section
|
|
91
|
+
.split('&')
|
|
92
|
+
.map((pair) => {
|
|
93
|
+
const eq = pair.indexOf('=');
|
|
94
|
+
if (eq < 0) return pair;
|
|
95
|
+
const name = pair.slice(0, eq);
|
|
96
|
+
return isCredentialParamName(name) ? `${name}=${REDACTED_VALUE}` : pair;
|
|
97
|
+
})
|
|
98
|
+
.join('&');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns the URL with credential-like query (and parameter-shaped fragment)
|
|
103
|
+
* values replaced by `REDACTED`. Everything else — scheme, host, path, other
|
|
104
|
+
* parameters, ordering — is returned verbatim; a URL carrying no credential
|
|
105
|
+
* parameter comes back byte-identical. Operates on the raw string rather than
|
|
106
|
+
* `new URL()` so a relative or malformed URL is never re-encoded or dropped.
|
|
107
|
+
*/
|
|
108
|
+
export function redactUrlCredentials(url: string): string {
|
|
109
|
+
const hashIndex = url.indexOf('#');
|
|
110
|
+
const base = hashIndex < 0 ? url : url.slice(0, hashIndex);
|
|
111
|
+
const fragment = hashIndex < 0 ? null : url.slice(hashIndex + 1);
|
|
112
|
+
|
|
113
|
+
const queryIndex = base.indexOf('?');
|
|
114
|
+
const head = queryIndex < 0 ? base : base.slice(0, queryIndex);
|
|
115
|
+
const query = queryIndex < 0 ? null : base.slice(queryIndex + 1);
|
|
116
|
+
|
|
117
|
+
const renderedQuery = query === null ? '' : `?${redactParamSection(query)}`;
|
|
118
|
+
// A fragment is only treated as parameters when it actually looks like them;
|
|
119
|
+
// an ordinary `#section-2` anchor is left exactly as captured.
|
|
120
|
+
const renderedFragment = fragment === null
|
|
121
|
+
? ''
|
|
122
|
+
: `#${fragment.includes('=') ? redactParamSection(fragment) : fragment}`;
|
|
123
|
+
|
|
124
|
+
return `${head}${renderedQuery}${renderedFragment}`;
|
|
125
|
+
}
|