@pentoshi/clai 3.8.44 → 3.8.46
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/README.md +249 -251
- package/dist/attachments/clipboard-image.d.ts +10 -0
- package/dist/attachments/clipboard-image.js +188 -0
- package/dist/attachments/clipboard-image.js.map +1 -0
- package/dist/attachments/image-content.d.ts +3 -0
- package/dist/attachments/image-content.js +51 -0
- package/dist/attachments/image-content.js.map +1 -0
- package/dist/attachments/service.js +6 -2
- package/dist/attachments/service.js.map +1 -1
- package/dist/commands/providers.js +4 -2
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/search-providers.d.ts +1 -0
- package/dist/commands/search-providers.js +4 -2
- package/dist/commands/search-providers.js.map +1 -1
- package/dist/llm/capabilities.js +17 -4
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/prompts/embedded.js +2 -2
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/index.js +1 -1
- package/dist/prompts/system.agent.md +5 -3
- package/dist/prompts/system.ask.md +1 -1
- package/dist/repl/prompt-line.js +39 -6
- package/dist/repl/prompt-line.js.map +1 -1
- package/dist/repl.js +1 -1
- package/dist/repl.js.map +1 -1
- package/dist/store/config.d.ts +5 -1
- package/dist/store/config.js +8 -0
- package/dist/store/config.js.map +1 -1
- package/dist/store/history.js +43 -5
- package/dist/store/history.js.map +1 -1
- package/dist/store/keys.js +1 -0
- package/dist/store/keys.js.map +1 -1
- package/dist/tools/definitions.js +13 -7
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/http.d.ts +6 -1
- package/dist/tools/http.js +247 -68
- package/dist/tools/http.js.map +1 -1
- package/dist/tools/registry.js +2 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/web/decode.d.ts +17 -0
- package/dist/tools/web/decode.js +65 -0
- package/dist/tools/web/decode.js.map +1 -0
- package/dist/tools/web/fetch-core.js +14 -20
- package/dist/tools/web/fetch-core.js.map +1 -1
- package/dist/tools/web/providers/exa.d.ts +51 -0
- package/dist/tools/web/providers/exa.js +303 -0
- package/dist/tools/web/providers/exa.js.map +1 -0
- package/dist/tools/web/readable.d.ts +5 -5
- package/dist/tools/web/readable.js +34 -12
- package/dist/tools/web/readable.js.map +1 -1
- package/dist/tools/web/search.d.ts +3 -1
- package/dist/tools/web/search.js +6 -4
- package/dist/tools/web/search.js.map +1 -1
- package/dist/tools/web/types.d.ts +25 -2
- package/dist/tools/web/types.js +32 -0
- package/dist/tools/web/types.js.map +1 -1
- package/dist/tui-v2/app/commands/key-commands.js +1 -1
- package/dist/tui-v2/app/commands/key-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +59 -8
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/components/transcript/intro-card.js +11 -5
- package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
- package/dist/tui-v2/composer/composer-editor.js +11 -9
- package/dist/tui-v2/composer/composer-editor.js.map +1 -1
- package/dist/tui-v2/composer/composer-image-paste.d.ts +19 -0
- package/dist/tui-v2/composer/composer-image-paste.js +56 -0
- package/dist/tui-v2/composer/composer-image-paste.js.map +1 -0
- package/dist/ui/keys.d.ts +1 -0
- package/dist/ui/keys.js +4 -0
- package/dist/ui/keys.js.map +1 -1
- package/dist/ui/mentions.d.ts +2 -16
- package/dist/ui/mentions.js +132 -102
- package/dist/ui/mentions.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +1 -1
package/dist/tools/http.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { lookup } from "node:dns/promises";
|
|
3
4
|
import { Agent } from "undici";
|
|
4
5
|
import { isBlockedAddress } from "./web/ssrf-guard.js";
|
|
6
|
+
import { decodeTextBody } from "./web/decode.js";
|
|
5
7
|
import { toReadableText } from "./web/readable.js";
|
|
6
8
|
import { selectOutput, } from "./output-selection.js";
|
|
7
9
|
/** Shared undici agent that skips cert verification (authorized pentest only). */
|
|
@@ -16,6 +18,53 @@ function getInsecureTlsAgent() {
|
|
|
16
18
|
}
|
|
17
19
|
return insecureTlsAgent;
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Pin Fetch to addresses that already passed policy. If policy pre-resolution
|
|
23
|
+
* was unavailable (for example, an injected test transport), the dispatcher's
|
|
24
|
+
* own lookup performs the SSRF classification immediately before connect so
|
|
25
|
+
* there is still no unchecked second DNS resolution.
|
|
26
|
+
*/
|
|
27
|
+
function createPinnedAgent(addresses, insecureTls, allowBlockedAddresses) {
|
|
28
|
+
const preResolved = toLookupAddresses(addresses);
|
|
29
|
+
return new Agent({
|
|
30
|
+
connect: {
|
|
31
|
+
rejectUnauthorized: !insecureTls,
|
|
32
|
+
lookup: (hostname, options, callback) => {
|
|
33
|
+
const send = (resolved) => {
|
|
34
|
+
if (resolved.length === 0) {
|
|
35
|
+
callback(new Error(`DNS resolution returned no addresses for ${hostname}`), "");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!allowBlockedAddresses &&
|
|
39
|
+
(isBlockedAddress(hostname) ||
|
|
40
|
+
resolved.some((entry) => isBlockedAddress(entry.address)))) {
|
|
41
|
+
callback(new Error(`Refusing private/loopback/metadata DNS result for ${hostname}`), "");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (options && typeof options === "object" && options.all === true) {
|
|
45
|
+
callback(null, resolved);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const selected = resolved[0];
|
|
49
|
+
callback(null, selected.address, selected.family);
|
|
50
|
+
};
|
|
51
|
+
if (preResolved.length > 0) {
|
|
52
|
+
send(preResolved);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
void lookup(hostname, { all: true }).then((results) => send(toLookupAddresses(results.map((entry) => entry.address))), (error) => callback(error instanceof Error ? error : new Error(String(error)), ""));
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function toLookupAddresses(addresses) {
|
|
61
|
+
return addresses
|
|
62
|
+
.map((address) => ({ address, family: net.isIP(address) }))
|
|
63
|
+
.filter((entry) => entry.family === 4 || entry.family === 6);
|
|
64
|
+
}
|
|
65
|
+
async function closeAgents(agents) {
|
|
66
|
+
await Promise.allSettled(agents.map(async (agent) => agent.close()));
|
|
67
|
+
}
|
|
19
68
|
/** Flatten error + nested cause(s) for TLS code matching. */
|
|
20
69
|
function errorText(error, depth = 0) {
|
|
21
70
|
if (depth > 4 || error == null)
|
|
@@ -77,8 +126,10 @@ function formatTlsNetworkError(error, url) {
|
|
|
77
126
|
` 3. shell: curl -k -i ${url}\n` +
|
|
78
127
|
`Evidence taken with insecureTls is still valid for recon; record that TLS verification was disabled.`);
|
|
79
128
|
}
|
|
80
|
-
/** Capture budget for body bytes (artifact + evidence build).
|
|
129
|
+
/** Capture budget for decoded response-body bytes (artifact + evidence build). */
|
|
81
130
|
const DEFAULT_MAX_BYTES = 128 * 1024;
|
|
131
|
+
/** Hard memory ceiling for one response capture; raise maxBytes up to this cap. */
|
|
132
|
+
const MAX_CAPTURE_BYTES = 16 * 1024 * 1024;
|
|
82
133
|
/**
|
|
83
134
|
* Default retries for remote evidence: 0 so intentional 5xx/probe responses are
|
|
84
135
|
* not silently rewritten. Callers may pass retries; owned loopback still soft-
|
|
@@ -217,6 +268,7 @@ function clampTimeoutMs(value) {
|
|
|
217
268
|
return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, Math.floor(value)));
|
|
218
269
|
}
|
|
219
270
|
export async function httpFetch(url, options = {}) {
|
|
271
|
+
const startedAt = Date.now();
|
|
220
272
|
let target;
|
|
221
273
|
try {
|
|
222
274
|
target = new URL(url);
|
|
@@ -241,12 +293,14 @@ export async function httpFetch(url, options = {}) {
|
|
|
241
293
|
}
|
|
242
294
|
const startHost = target.hostname.replace(/^\[|\]$/g, "");
|
|
243
295
|
const ownedLoopback = Boolean(options.iOwnThis && isLoopbackHost(startHost));
|
|
296
|
+
const authorizedAddresses = new Map();
|
|
244
297
|
// SSRF and engagement-policy checks run for the initial destination and
|
|
245
298
|
// every redirect hop. Owned loopback (local app verify) skips engagement
|
|
246
299
|
// hop checks — a leftover pentest scope must not block http://localhost:5173.
|
|
247
300
|
const authorizeDestination = async (destination) => {
|
|
248
301
|
const hostname = destination.hostname.replace(/^\[|\]$/g, "");
|
|
249
302
|
const resolvedAddresses = await resolveHosts(hostname);
|
|
303
|
+
authorizedAddresses.set(hostname.toLowerCase(), resolvedAddresses);
|
|
250
304
|
const blocked = isBlockedAddress(hostname) || resolvedAddresses.some(isBlockedAddress);
|
|
251
305
|
const destLoopback = isLoopbackHost(hostname);
|
|
252
306
|
if (blocked && !options.iOwnThis) {
|
|
@@ -285,18 +339,20 @@ export async function httpFetch(url, options = {}) {
|
|
|
285
339
|
if (options.body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
286
340
|
init.body = options.body;
|
|
287
341
|
}
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
342
|
+
// The insecure dispatcher is selected per hop below because redirects may
|
|
343
|
+
// change scheme. A single shared dispatcher keeps authorized lab probes
|
|
344
|
+
// cheap without weakening unrelated requests.
|
|
292
345
|
// Local dev: try localhost / 127.0.0.1 / ::1 until one accepts (Vite dual-stack).
|
|
293
346
|
const urlCandidates = ownedLoopback && (method === "GET" || method === "HEAD")
|
|
294
347
|
? loopbackUrlCandidates(url)
|
|
295
348
|
: [url];
|
|
296
349
|
let response;
|
|
350
|
+
const pinnedAgents = [];
|
|
297
351
|
let attempts = 0;
|
|
298
352
|
let lastNetworkError;
|
|
353
|
+
// The active URL may advance through redirects within this attempt.
|
|
299
354
|
let usedUrl = url;
|
|
355
|
+
let finalMethod = method;
|
|
300
356
|
let redirectHops = [];
|
|
301
357
|
let responseCleanup;
|
|
302
358
|
const timeoutMs = clampTimeoutMs(options.timeoutMs);
|
|
@@ -334,31 +390,81 @@ export async function httpFetch(url, options = {}) {
|
|
|
334
390
|
}
|
|
335
391
|
try {
|
|
336
392
|
let requestUrl = candidateUrl;
|
|
393
|
+
let requestMethod = method;
|
|
394
|
+
let requestBody = init.body;
|
|
395
|
+
let requestHeaders = new Headers(headers);
|
|
337
396
|
let redirects = 0;
|
|
338
397
|
redirectHops = [];
|
|
339
398
|
for (;;) {
|
|
340
|
-
|
|
399
|
+
const hopInit = {
|
|
341
400
|
...init,
|
|
401
|
+
method: requestMethod,
|
|
402
|
+
headers: requestHeaders,
|
|
342
403
|
signal: controller.signal,
|
|
343
|
-
}
|
|
404
|
+
};
|
|
405
|
+
if (requestBody === undefined || requestBody === null) {
|
|
406
|
+
delete hopInit.body;
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
hopInit.body = requestBody;
|
|
410
|
+
}
|
|
411
|
+
const hopUrl = new URL(requestUrl);
|
|
412
|
+
const hopHost = hopUrl.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
413
|
+
const pinned = authorizedAddresses.get(hopHost) ?? [];
|
|
414
|
+
if (!isLoopbackHost(hopHost)) {
|
|
415
|
+
const dispatcher = createPinnedAgent(pinned, insecureTls && hopUrl.protocol === "https:", options.iOwnThis === true);
|
|
416
|
+
pinnedAgents.push(dispatcher);
|
|
417
|
+
hopInit.dispatcher = dispatcher;
|
|
418
|
+
}
|
|
419
|
+
else if (insecureTls && hopUrl.protocol === "https:") {
|
|
420
|
+
// Owned loopback keeps the shared dispatcher so localhost can
|
|
421
|
+
// retain its IPv4/IPv6 fallback behavior.
|
|
422
|
+
hopInit.dispatcher = getInsecureTlsAgent();
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
delete hopInit.dispatcher;
|
|
426
|
+
}
|
|
427
|
+
usedUrl = requestUrl;
|
|
428
|
+
response = await fetch(requestUrl, hopInit);
|
|
429
|
+
finalMethod = requestMethod;
|
|
344
430
|
const location = response.headers.get("location");
|
|
345
431
|
if (!(response.status >= 300 && response.status < 400 && location))
|
|
346
432
|
break;
|
|
347
433
|
if (redirects >= 10)
|
|
348
434
|
throw new Error("Too many redirects (maximum 10)");
|
|
349
|
-
const setCookies = typeof response.headers.getSetCookie === "function"
|
|
350
|
-
? response.headers.getSetCookie()
|
|
351
|
-
: multiHeader(response.headers, "set-cookie");
|
|
352
435
|
redirectHops.push({
|
|
353
436
|
status: response.status,
|
|
437
|
+
statusText: response.statusText,
|
|
438
|
+
method: requestMethod,
|
|
354
439
|
url: requestUrl,
|
|
355
440
|
location,
|
|
356
|
-
|
|
441
|
+
headers: snapshotHeaders(response.headers),
|
|
357
442
|
});
|
|
358
443
|
const next = new URL(location, requestUrl);
|
|
359
444
|
const denial = await authorizeDestination(next);
|
|
360
445
|
if (denial)
|
|
361
446
|
throw new Error(denial);
|
|
447
|
+
if (!options.forwardSensitiveHeaders &&
|
|
448
|
+
new URL(requestUrl).origin !== next.origin) {
|
|
449
|
+
requestHeaders = new Headers(requestHeaders);
|
|
450
|
+
requestHeaders.delete("authorization");
|
|
451
|
+
requestHeaders.delete("proxy-authorization");
|
|
452
|
+
requestHeaders.delete("cookie");
|
|
453
|
+
}
|
|
454
|
+
// Match widely deployed user-agent redirect semantics while
|
|
455
|
+
// preserving methods for 307/308. This avoids replaying a POST
|
|
456
|
+
// body onto a 303 destination and records the actual method used.
|
|
457
|
+
if (requestMethod !== "HEAD" &&
|
|
458
|
+
(response.status === 303 ||
|
|
459
|
+
((response.status === 301 || response.status === 302) &&
|
|
460
|
+
requestMethod === "POST"))) {
|
|
461
|
+
requestMethod = "GET";
|
|
462
|
+
requestBody = undefined;
|
|
463
|
+
requestHeaders = new Headers(requestHeaders);
|
|
464
|
+
requestHeaders.delete("content-length");
|
|
465
|
+
requestHeaders.delete("content-type");
|
|
466
|
+
requestHeaders.delete("transfer-encoding");
|
|
467
|
+
}
|
|
362
468
|
await drainResponse(response);
|
|
363
469
|
requestUrl = next.toString();
|
|
364
470
|
redirects += 1;
|
|
@@ -410,6 +516,7 @@ export async function httpFetch(url, options = {}) {
|
|
|
410
516
|
}
|
|
411
517
|
}
|
|
412
518
|
catch (error) {
|
|
519
|
+
await closeAgents(pinnedAgents);
|
|
413
520
|
const tried = urlCandidates.length > 1
|
|
414
521
|
? ` (tried ${urlCandidates.join(" → ")})`
|
|
415
522
|
: "";
|
|
@@ -421,15 +528,15 @@ export async function httpFetch(url, options = {}) {
|
|
|
421
528
|
};
|
|
422
529
|
}
|
|
423
530
|
if (!response) {
|
|
531
|
+
await closeAgents(pinnedAgents);
|
|
424
532
|
return {
|
|
425
533
|
ok: false,
|
|
426
534
|
output: "Network error: no response was received",
|
|
427
535
|
exitCode: 1,
|
|
428
536
|
};
|
|
429
537
|
}
|
|
430
|
-
const limit = options.maxBytes
|
|
431
|
-
const
|
|
432
|
-
let collected = "";
|
|
538
|
+
const limit = clampCaptureBytes(options.maxBytes);
|
|
539
|
+
const chunks = [];
|
|
433
540
|
let bytesRead = 0;
|
|
434
541
|
let truncated = false;
|
|
435
542
|
let bodyReadError;
|
|
@@ -471,9 +578,12 @@ export async function httpFetch(url, options = {}) {
|
|
|
471
578
|
}
|
|
472
579
|
break;
|
|
473
580
|
}
|
|
581
|
+
const captured = value.byteLength > remaining
|
|
582
|
+
? value.subarray(0, remaining)
|
|
583
|
+
: value;
|
|
584
|
+
chunks.push(Buffer.from(captured));
|
|
585
|
+
bytesRead += captured.byteLength;
|
|
474
586
|
if (value.byteLength > remaining) {
|
|
475
|
-
collected += decoder.decode(value.subarray(0, remaining), { stream: true });
|
|
476
|
-
bytesRead += remaining;
|
|
477
587
|
truncated = true;
|
|
478
588
|
try {
|
|
479
589
|
await reader.cancel();
|
|
@@ -483,10 +593,7 @@ export async function httpFetch(url, options = {}) {
|
|
|
483
593
|
}
|
|
484
594
|
break;
|
|
485
595
|
}
|
|
486
|
-
collected += decoder.decode(value, { stream: true });
|
|
487
|
-
bytesRead += value.byteLength;
|
|
488
596
|
}
|
|
489
|
-
collected += decoder.decode();
|
|
490
597
|
}
|
|
491
598
|
finally {
|
|
492
599
|
try {
|
|
@@ -497,9 +604,6 @@ export async function httpFetch(url, options = {}) {
|
|
|
497
604
|
}
|
|
498
605
|
}
|
|
499
606
|
}
|
|
500
|
-
else {
|
|
501
|
-
collected = "";
|
|
502
|
-
}
|
|
503
607
|
}
|
|
504
608
|
catch (error) {
|
|
505
609
|
bodyReadError = error;
|
|
@@ -512,6 +616,7 @@ export async function httpFetch(url, options = {}) {
|
|
|
512
616
|
const timedOut = bodyReadError instanceof Error &&
|
|
513
617
|
bodyReadError.name === "AbortError" &&
|
|
514
618
|
!options.signal?.aborted;
|
|
619
|
+
await closeAgents(pinnedAgents);
|
|
515
620
|
return {
|
|
516
621
|
ok: false,
|
|
517
622
|
output: timedOut
|
|
@@ -520,11 +625,15 @@ export async function httpFetch(url, options = {}) {
|
|
|
520
625
|
exitCode: timedOut ? 124 : options.signal?.aborted ? 130 : 1,
|
|
521
626
|
};
|
|
522
627
|
}
|
|
628
|
+
const capturedBody = Buffer.concat(chunks, bytesRead);
|
|
523
629
|
const contentType = response.headers.get("content-type") ?? "";
|
|
524
|
-
const
|
|
630
|
+
const decoded = decodeTextBody(capturedBody, contentType);
|
|
631
|
+
const isBinary = isBinaryContentType(contentType) ||
|
|
632
|
+
(!isTextualContentType(contentType) && isBinaryContent(capturedBody));
|
|
525
633
|
const finalUrl = response.url || usedUrl;
|
|
526
634
|
const evidence = formatHttpEvidence({
|
|
527
|
-
method,
|
|
635
|
+
requestedMethod: method,
|
|
636
|
+
finalMethod,
|
|
528
637
|
requestedUrl: url,
|
|
529
638
|
usedUrl,
|
|
530
639
|
finalUrl,
|
|
@@ -532,24 +641,36 @@ export async function httpFetch(url, options = {}) {
|
|
|
532
641
|
statusText: response.statusText,
|
|
533
642
|
attempts,
|
|
534
643
|
bytesRead,
|
|
644
|
+
bodySha256: createHash("sha256").update(capturedBody).digest("hex"),
|
|
645
|
+
bodyCharset: decoded.charset,
|
|
646
|
+
charsetSource: decoded.charsetSource,
|
|
647
|
+
unsupportedCharset: decoded.unsupportedCharset,
|
|
535
648
|
truncated,
|
|
536
649
|
limit,
|
|
537
650
|
contentType,
|
|
538
651
|
headers: response.headers,
|
|
539
|
-
body:
|
|
652
|
+
body: decoded.text,
|
|
540
653
|
isBinary,
|
|
541
654
|
redirectHops,
|
|
542
655
|
lastNetworkError,
|
|
543
656
|
insecureTls,
|
|
544
|
-
|
|
657
|
+
forwardSensitiveHeaders: options.forwardSensitiveHeaders === true,
|
|
658
|
+
responseMode: options.responseMode ?? "raw",
|
|
545
659
|
responsePart: options.responsePart ?? "full",
|
|
546
660
|
});
|
|
547
661
|
const output = selectOutput(evidence, options);
|
|
662
|
+
await closeAgents(pinnedAgents);
|
|
548
663
|
return {
|
|
549
664
|
ok: true,
|
|
550
665
|
output,
|
|
551
666
|
exitCode: 0,
|
|
552
667
|
truncated: truncated || output !== evidence,
|
|
668
|
+
stats: {
|
|
669
|
+
bytesRead,
|
|
670
|
+
bytesDropped: 0,
|
|
671
|
+
linesRead: decoded.text.split("\n").length,
|
|
672
|
+
elapsedMs: Date.now() - startedAt,
|
|
673
|
+
},
|
|
553
674
|
};
|
|
554
675
|
}
|
|
555
676
|
function multiHeader(headers, name) {
|
|
@@ -560,57 +681,100 @@ function multiHeader(headers, name) {
|
|
|
560
681
|
});
|
|
561
682
|
return out;
|
|
562
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* Snapshot every response header exposed by the Fetch runtime. Header names
|
|
686
|
+
* and most repeat values are normalized by Fetch; Set-Cookie is recovered as
|
|
687
|
+
* separate lines when Node/undici exposes getSetCookie().
|
|
688
|
+
*/
|
|
689
|
+
function snapshotHeaders(headers) {
|
|
690
|
+
const out = [];
|
|
691
|
+
headers.forEach((value, key) => {
|
|
692
|
+
if (key.toLowerCase() !== "set-cookie")
|
|
693
|
+
out.push([key, value]);
|
|
694
|
+
});
|
|
695
|
+
const setCookies = typeof headers.getSetCookie === "function"
|
|
696
|
+
? headers.getSetCookie()
|
|
697
|
+
: multiHeader(headers, "set-cookie");
|
|
698
|
+
for (const value of setCookies)
|
|
699
|
+
out.push(["set-cookie", value]);
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
563
702
|
/** Headers always kept in evidence (security / fingerprint / cookies). */
|
|
564
703
|
const PRIORITY_HEADER_RE = /^(set-cookie|server|x-powered-by|x-aspnet|x-generator|x-frame-options|x-content-type-options|x-xss-protection|content-security-policy|content-type|location|www-authenticate|access-control-|strict-transport|referrer-policy|permissions-policy|cross-origin-|cache-control|via|cf-ray|x-request-id|x-amz-|x-served|link|allow|retry-after)$/i;
|
|
565
704
|
function formatHttpEvidence(input) {
|
|
566
705
|
const lines = [];
|
|
567
|
-
lines.push(`${input.status} ${input.statusText} ${input.
|
|
706
|
+
lines.push(`${input.status} ${input.statusText} ${input.finalMethod} ${input.finalUrl}`);
|
|
568
707
|
if (input.insecureTls) {
|
|
569
708
|
lines.push("TLS: verification DISABLED (insecureTls=true) — hostname/cert not validated; authorized-test only.");
|
|
570
709
|
}
|
|
571
710
|
if (input.redirectHops.length > 0) {
|
|
572
711
|
const chain = [
|
|
573
|
-
...input.redirectHops.map((h) => `${h.status} ${h.url} → ${h.location}`),
|
|
574
|
-
`${input.status} ${input.finalUrl}`,
|
|
712
|
+
...input.redirectHops.map((h) => `${h.status} ${h.method} ${h.url} → ${h.location}`),
|
|
713
|
+
`${input.status} ${input.finalMethod} ${input.finalUrl}`,
|
|
575
714
|
];
|
|
576
715
|
lines.push(`redirects: ${chain.join(" → ")}`);
|
|
716
|
+
const crossedOrigin = input.redirectHops.some((hop) => {
|
|
717
|
+
try {
|
|
718
|
+
return new URL(hop.url).origin !== new URL(hop.location, hop.url).origin;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
if (crossedOrigin) {
|
|
725
|
+
lines.push(input.forwardSensitiveHeaders
|
|
726
|
+
? "redirect credentials: explicitly forwarded across origin change"
|
|
727
|
+
: "redirect credentials: Authorization, Proxy-Authorization, and Cookie stripped across origin change");
|
|
728
|
+
}
|
|
729
|
+
lines.push("");
|
|
730
|
+
lines.push("Redirect responses (headers runtime-normalized):");
|
|
577
731
|
for (const hop of input.redirectHops) {
|
|
578
|
-
|
|
579
|
-
|
|
732
|
+
lines.push(` ${hop.status} ${hop.statusText} ${hop.method} ${hop.url}`, ` location: ${hop.location}`);
|
|
733
|
+
for (const [key, value] of hop.headers) {
|
|
734
|
+
lines.push(` ${key}: ${value}`);
|
|
580
735
|
}
|
|
736
|
+
lines.push("");
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const metaBits = [`attempts=${input.attempts}`];
|
|
740
|
+
if (input.responsePart === "headers") {
|
|
741
|
+
metaBits.push("bodyCapture=skipped(responsePart=headers)");
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
metaBits.push(`bodyBytes=${input.bytesRead}`, `bodySha256=${input.bodySha256}`, `charset=${input.bodyCharset}(${input.charsetSource})`);
|
|
745
|
+
if (input.unsupportedCharset) {
|
|
746
|
+
metaBits.push(`unsupportedCharset=${input.unsupportedCharset}`);
|
|
581
747
|
}
|
|
748
|
+
if (input.truncated)
|
|
749
|
+
metaBits.push(`truncated@${input.limit}`);
|
|
582
750
|
}
|
|
583
|
-
const metaBits = [
|
|
584
|
-
`attempts=${input.attempts}`,
|
|
585
|
-
`bytes=${input.bytesRead}`,
|
|
586
|
-
];
|
|
587
|
-
if (input.truncated)
|
|
588
|
-
metaBits.push(`truncated@${input.limit}`);
|
|
589
751
|
if (input.usedUrl !== input.requestedUrl)
|
|
590
752
|
metaBits.push(`via=${input.usedUrl}`);
|
|
591
|
-
if (input.
|
|
592
|
-
|
|
753
|
+
if (input.requestedMethod !== input.finalMethod ||
|
|
754
|
+
(input.requestedUrl !== input.finalUrl && input.redirectHops.length === 0)) {
|
|
755
|
+
metaBits.push(`requested=${input.requestedMethod} ${input.requestedUrl}`);
|
|
593
756
|
}
|
|
594
757
|
lines.push(metaBits.join(" "));
|
|
758
|
+
if (input.responsePart !== "headers") {
|
|
759
|
+
lines.push("capture: response-body bytes after Fetch transfer/content decoding; SHA-256 covers exactly the captured bytes");
|
|
760
|
+
}
|
|
595
761
|
if (input.finalUrl.startsWith("https:") && !input.insecureTls) {
|
|
596
762
|
lines.push("TLS: leaf cert not captured by http.fetch — use web.fetch with includeTls=true for fingerprint/SAN.");
|
|
597
763
|
}
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
const allHeaders =
|
|
601
|
-
input.headers.forEach((v, k) => allHeaders.push([k, v]));
|
|
602
|
-
allHeaders.sort(([a], [b]) => a.localeCompare(b));
|
|
764
|
+
// Fetch normalizes names and most repeated values. Preserve every exposed
|
|
765
|
+
// field, with individual Set-Cookie lines, and be explicit about the limit.
|
|
766
|
+
const allHeaders = snapshotHeaders(input.headers);
|
|
603
767
|
const priority = [];
|
|
604
768
|
const rest = [];
|
|
605
|
-
for (const [
|
|
606
|
-
const line = `${
|
|
607
|
-
if (PRIORITY_HEADER_RE.test(
|
|
769
|
+
for (const [key, value] of allHeaders) {
|
|
770
|
+
const line = `${key}: ${value}`;
|
|
771
|
+
if (PRIORITY_HEADER_RE.test(key))
|
|
608
772
|
priority.push(line);
|
|
609
773
|
else
|
|
610
774
|
rest.push(line);
|
|
611
775
|
}
|
|
612
776
|
lines.push("");
|
|
613
|
-
lines.push("
|
|
777
|
+
lines.push("Final response headers (runtime-normalized; Set-Cookie preserved separately):");
|
|
614
778
|
for (const line of priority)
|
|
615
779
|
lines.push(line);
|
|
616
780
|
for (const line of rest)
|
|
@@ -620,21 +784,21 @@ function formatHttpEvidence(input) {
|
|
|
620
784
|
lines.push("");
|
|
621
785
|
lines.push(`Tech hints: ${tech}`);
|
|
622
786
|
}
|
|
623
|
-
if (input.
|
|
787
|
+
if (input.finalMethod !== "HEAD") {
|
|
624
788
|
lines.push("");
|
|
625
789
|
lines.push("Body:");
|
|
626
790
|
if (input.isBinary) {
|
|
627
|
-
lines.push(`[Binary content (${input.contentType || "unknown content type"}) —
|
|
791
|
+
lines.push(`[Binary content (${input.contentType || "unknown content type"}) — textual rendering suppressed; use bodySha256 and headers as evidence]`);
|
|
628
792
|
}
|
|
629
793
|
else {
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
//
|
|
794
|
+
const isHtml = input.contentType.toLowerCase().includes("html");
|
|
795
|
+
// Raw is the forensic default and preserves source comments, tags,
|
|
796
|
+
// attributes, values, whitespace, and malformed markup after decoding.
|
|
633
797
|
const bodyText = isHtml && input.responseMode === "readable"
|
|
634
|
-
? toReadableText(input.body) || input.body
|
|
798
|
+
? toReadableText(input.body, input.finalUrl) || input.body
|
|
635
799
|
: input.body;
|
|
636
800
|
if (input.truncated) {
|
|
637
|
-
lines.push(`${bodyText || "(empty)"}\n... (
|
|
801
|
+
lines.push(`${bodyText || "(empty)"}\n... (capture stopped at ${input.limit.toLocaleString()} decoded response-body bytes — raise maxBytes for more body)`);
|
|
638
802
|
}
|
|
639
803
|
else {
|
|
640
804
|
lines.push(bodyText || "(empty)");
|
|
@@ -700,6 +864,11 @@ function deriveTechHints(headers, body, contentType) {
|
|
|
700
864
|
}
|
|
701
865
|
return bits.join("; ");
|
|
702
866
|
}
|
|
867
|
+
function clampCaptureBytes(value) {
|
|
868
|
+
if (value === undefined || !Number.isFinite(value))
|
|
869
|
+
return DEFAULT_MAX_BYTES;
|
|
870
|
+
return Math.max(0, Math.min(MAX_CAPTURE_BYTES, Math.floor(value)));
|
|
871
|
+
}
|
|
703
872
|
function clampRetries(value) {
|
|
704
873
|
if (!Number.isFinite(value))
|
|
705
874
|
return DEFAULT_RETRIES;
|
|
@@ -713,10 +882,10 @@ function sleep(ms) {
|
|
|
713
882
|
}
|
|
714
883
|
async function drainResponse(response) {
|
|
715
884
|
try {
|
|
716
|
-
await response.
|
|
885
|
+
await response.body?.cancel();
|
|
717
886
|
}
|
|
718
887
|
catch {
|
|
719
|
-
// Best effort only;
|
|
888
|
+
// Best effort only; redirect/retry can proceed after a socket-close race.
|
|
720
889
|
}
|
|
721
890
|
}
|
|
722
891
|
function isBinaryContentType(contentType) {
|
|
@@ -734,19 +903,29 @@ function isBinaryContentType(contentType) {
|
|
|
734
903
|
}
|
|
735
904
|
return false;
|
|
736
905
|
}
|
|
737
|
-
function
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
906
|
+
function isTextualContentType(contentType) {
|
|
907
|
+
const ct = contentType.toLowerCase().split(";", 1)[0]?.trim() ?? "";
|
|
908
|
+
return (ct.startsWith("text/") ||
|
|
909
|
+
ct === "image/svg+xml" ||
|
|
910
|
+
ct === "application/json" ||
|
|
911
|
+
ct.endsWith("+json") ||
|
|
912
|
+
ct === "application/xml" ||
|
|
913
|
+
ct.endsWith("+xml") ||
|
|
914
|
+
ct === "application/javascript" ||
|
|
915
|
+
ct === "application/x-javascript" ||
|
|
916
|
+
ct === "application/x-www-form-urlencoded");
|
|
917
|
+
}
|
|
918
|
+
function isBinaryContent(body) {
|
|
919
|
+
const sample = body.subarray(0, 2_048);
|
|
741
920
|
let nonPrintableCount = 0;
|
|
742
|
-
for (
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
921
|
+
for (const byte of sample) {
|
|
922
|
+
if (byte === 0)
|
|
923
|
+
return true;
|
|
924
|
+
// Control bytes other than tab (9), lf (10), cr (13).
|
|
925
|
+
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
|
926
|
+
nonPrintableCount += 1;
|
|
927
|
+
if (nonPrintableCount > 5)
|
|
748
928
|
return true;
|
|
749
|
-
}
|
|
750
929
|
}
|
|
751
930
|
}
|
|
752
931
|
return false;
|