@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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.
Files changed (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -115,18 +115,17 @@ const PRIVACY_MARKERS = [
115
115
  /not disclosed/i,
116
116
  /withheld for privacy/i,
117
117
  ];
118
- // Only ever called after a lookup has already succeeded (this runs inside
119
- // checkDomainAvailability once RDAP or WHOIS has returned real data), so a
120
- // missing/empty registrant here isn't "we don't know" - it means the
121
- // registry gave us a response with no usable contact in it, which for
122
- // sourcing purposes (can I reach this owner from this data alone?) is the
123
- // same practical answer as an explicit privacy-proxy service: no.
118
+ // Explicit privacy/redaction markers establish a positive privacy signal.
119
+ // Usable public contact data establishes a negative one. A missing or blank
120
+ // contact can instead reflect registry policy, access tier, parser coverage,
121
+ // or ordinary omission, so it remains inconclusive rather than being promoted
122
+ // to an affirmative privacy claim.
124
123
  function isPrivacyProtected(registrant) {
125
124
  if (!registrant)
126
- return true;
125
+ return null;
127
126
  const blob = [registrant.name, registrant.org, registrant.email].filter(Boolean).join(' ');
128
127
  if (!blob)
129
- return true; // record exists but every contact field is blank - that's redaction
128
+ return null;
130
129
  return PRIVACY_MARKERS.some((re) => re.test(blob));
131
130
  }
132
131
  // RDAP Lookup retains a bounded multi-value contact inventory, but Bulk and
@@ -197,7 +196,10 @@ async function checkDnsDelegation(domain, { resolver = dns.resolveNs } = {}) {
197
196
  // domain has "no site": transient DNS/TLS/network failures, a slow origin, or
198
197
  // an HTTP error can all produce the same null body. Keeping that distinction
199
198
  // avoids turning an inconclusive probe into a false inactivity claim.
200
- async function fetchHomepage(domain, { fetcher = safeFetchDetailed } = {}) {
199
+ async function fetchHomepage(domain, { fetcher = safeFetchDetailed, timeoutMs = HOMEPAGE_FETCH_TIMEOUT_MS } = {}) {
200
+ const requestTimeoutMs = Number.isInteger(timeoutMs) && timeoutMs >= 10 && timeoutMs <= HOMEPAGE_FETCH_TIMEOUT_MS
201
+ ? timeoutMs
202
+ : HOMEPAGE_FETCH_TIMEOUT_MS;
201
203
  const headers = whoisleuthRequestHeaders();
202
204
  const failures = [];
203
205
  const probeStartedAt = Date.now();
@@ -205,7 +207,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed } = {}) {
205
207
  const requestUrl = `${scheme}://${domain}`;
206
208
  const attemptStartedAt = Date.now();
207
209
  const controller = new AbortController();
208
- const timeout = setTimeout(() => controller.abort(), HOMEPAGE_FETCH_TIMEOUT_MS);
210
+ const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
209
211
  try {
210
212
  const fetched = await fetcher(requestUrl, { signal: controller.signal, headers });
211
213
  const fetchedResponse = fetched instanceof Response ? fetched : fetched.response;
@@ -274,7 +276,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed } = {}) {
274
276
  catch (err) {
275
277
  const error = errorRecord(err);
276
278
  const reason = error.name === 'AbortError'
277
- ? 'timed out after 6 seconds'
279
+ ? `timed out after ${requestTimeoutMs} milliseconds`
278
280
  : nonEmptyErrorMessage(err, 'request failed')
279
281
  .replace(/[\u0000-\u001f\u007f]+/g, ' ')
280
282
  .slice(0, 180);
@@ -733,4 +735,4 @@ async function checkDomainAvailability(domain, options = {}) {
733
735
  hasDmarc: dnsIntelligence.hasDmarc,
734
736
  };
735
737
  }
736
- export { checkDomainAvailability, checkDnsDelegation, fetchHomepage, deriveWebsiteActivity, forSaleRedirectSignal, parseRegistryDate as parseWhoisDate, };
738
+ export { checkDomainAvailability, checkDnsDelegation, fetchHomepage, isPrivacyProtected, deriveWebsiteActivity, forSaleRedirectSignal, parseRegistryDate as parseWhoisDate, };
@@ -0,0 +1,82 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ const MAX_BOUNDED_FILE_BYTES = 64 * 1024 * 1024;
4
+ function boundedSize(value, fallback) {
5
+ return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= MAX_BOUNDED_FILE_BYTES
6
+ ? Number(value)
7
+ : fallback;
8
+ }
9
+ function throwIfAborted(signal) {
10
+ if (!signal?.aborted)
11
+ return;
12
+ throw signal.reason instanceof Error
13
+ ? signal.reason
14
+ : new DOMException('The operation was aborted.', 'AbortError');
15
+ }
16
+ export async function readBoundedRegularFile(filePath, options, dependencies = {}) {
17
+ const label = options.label?.trim().slice(0, 120) || 'Input file';
18
+ const maximumBytes = boundedSize(options.maximumBytes, 0);
19
+ const minimumBytes = boundedSize(options.minimumBytes, 0);
20
+ if (maximumBytes < 1 || minimumBytes > maximumBytes) {
21
+ throw new TypeError(`${label} has an invalid byte boundary.`);
22
+ }
23
+ const expectedBytes = options.expectedBytes === null || options.expectedBytes === undefined
24
+ ? null
25
+ : boundedSize(options.expectedBytes, -1);
26
+ if (expectedBytes !== null && (expectedBytes < minimumBytes || expectedBytes > maximumBytes)) {
27
+ throw new TypeError(`${label} expected size is outside its byte boundary.`);
28
+ }
29
+ throwIfAborted(options.signal);
30
+ // Non-blocking open prevents a named pipe from stalling the process before
31
+ // the descriptor can be classified. Final-component symlinks remain denied
32
+ // unless an analyst-facing input explicitly opts in; artefacts resolved
33
+ // inside a verified directory must retain the default confinement boundary.
34
+ const flags = constants.O_RDONLY
35
+ | constants.O_NONBLOCK
36
+ | (options.allowSymbolicLink ? 0 : constants.O_NOFOLLOW);
37
+ const handle = await (dependencies.openFile ?? open)(filePath, flags);
38
+ try {
39
+ throwIfAborted(options.signal);
40
+ const before = await handle.stat();
41
+ throwIfAborted(options.signal);
42
+ if (!before.isFile())
43
+ throw new TypeError(`${label} must be a regular file.`);
44
+ if (before.size < minimumBytes)
45
+ throw new TypeError(`${label} is smaller than its ${minimumBytes}-byte minimum.`);
46
+ if (before.size > maximumBytes)
47
+ throw new TypeError(`${label} exceeds its ${maximumBytes}-byte maximum.`);
48
+ if (expectedBytes !== null && before.size !== expectedBytes) {
49
+ throw new TypeError(`${label} size does not match its declared size.`);
50
+ }
51
+ // Read from the already-opened descriptor and reserve only one sentinel
52
+ // byte beyond the admitted size. A concurrent append is detected without
53
+ // allocating from an attacker-controlled post-stat file length.
54
+ const storage = Buffer.allocUnsafe(Math.min(maximumBytes + 1, before.size + 1));
55
+ let offset = 0;
56
+ while (offset < storage.length) {
57
+ throwIfAborted(options.signal);
58
+ const { bytesRead } = await handle.read(storage, offset, storage.length - offset, null);
59
+ if (bytesRead === 0)
60
+ break;
61
+ offset += bytesRead;
62
+ }
63
+ throwIfAborted(options.signal);
64
+ const after = await handle.stat();
65
+ throwIfAborted(options.signal);
66
+ if (offset > maximumBytes
67
+ || after.size !== before.size
68
+ || offset !== after.size
69
+ || after.mtimeMs !== before.mtimeMs
70
+ || after.ctimeMs !== before.ctimeMs) {
71
+ throw new TypeError(`${label} changed while it was being read.`);
72
+ }
73
+ return Buffer.from(storage.subarray(0, offset));
74
+ }
75
+ finally {
76
+ await handle.close();
77
+ }
78
+ }
79
+ export async function readBoundedRegularTextFile(filePath, options, dependencies = {}) {
80
+ return (await readBoundedRegularFile(filePath, options, dependencies)).toString('utf8');
81
+ }
82
+ export { MAX_BOUNDED_FILE_BYTES };
@@ -0,0 +1,170 @@
1
+ // Environment-neutral browser/server JSON response boundary. It enforces a
2
+ // declared and streamed byte ceiling before parsing and can apply one deadline
3
+ // across both the fetch and body-read phases.
4
+ export const SMALL_JSON_RESPONSE_BYTES = 64 * 1024;
5
+ export const STANDARD_JSON_RESPONSE_BYTES = 2 * 1024 * 1024;
6
+ export const LARGE_JSON_RESPONSE_BYTES = 8 * 1024 * 1024;
7
+ export const DEFAULT_JSON_RESPONSE_TIMEOUT_MS = 40_000;
8
+ const MAX_JSON_RESPONSE_TIMEOUT_MS = 120_000;
9
+ export class BoundedJsonResponseError extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = 'BoundedJsonResponseError';
14
+ this.code = code;
15
+ }
16
+ }
17
+ async function raceWithAbort(promise, signal, onLateResolution) {
18
+ if (signal.aborted)
19
+ throw signal.reason ?? new Error('Request aborted.');
20
+ return new Promise((resolve, reject) => {
21
+ let aborted = false;
22
+ const onAbort = () => {
23
+ aborted = true;
24
+ reject(signal.reason ?? new Error('Request aborted.'));
25
+ };
26
+ signal.addEventListener('abort', onAbort, { once: true });
27
+ void promise.then((value) => {
28
+ signal.removeEventListener('abort', onAbort);
29
+ if (aborted) {
30
+ void Promise.resolve(onLateResolution?.(value)).catch(() => { });
31
+ return;
32
+ }
33
+ resolve(value);
34
+ }, (cause) => {
35
+ signal.removeEventListener('abort', onAbort);
36
+ reject(cause);
37
+ });
38
+ });
39
+ }
40
+ function boundedPositiveInteger(value, fallback, maximum) {
41
+ return typeof value === 'number' && Number.isFinite(value)
42
+ ? Math.max(1, Math.min(maximum, Math.round(value)))
43
+ : fallback;
44
+ }
45
+ export async function readJsonResponseCapped(response, maximumBytes = STANDARD_JSON_RESPONSE_BYTES, signal, validateRawJson) {
46
+ const maxBytes = boundedPositiveInteger(maximumBytes, STANDARD_JSON_RESPONSE_BYTES, LARGE_JSON_RESPONSE_BYTES);
47
+ const declared = response.headers.get('content-length');
48
+ if (declared && /^\d+$/u.test(declared)) {
49
+ const bytes = Number(declared);
50
+ if (!Number.isSafeInteger(bytes) || bytes > maxBytes) {
51
+ await response.body?.cancel().catch(() => { });
52
+ throw new BoundedJsonResponseError('response_too_large', `JSON response exceeded ${maxBytes} bytes.`);
53
+ }
54
+ }
55
+ if (!response.body)
56
+ return null;
57
+ const reader = response.body.getReader();
58
+ const cancelOnAbort = () => { void reader.cancel(signal?.reason).catch(() => { }); };
59
+ signal?.addEventListener('abort', cancelOnAbort, { once: true });
60
+ const chunks = [];
61
+ let total = 0;
62
+ try {
63
+ while (true) {
64
+ const read = reader.read();
65
+ const { done, value } = signal ? await raceWithAbort(read, signal) : await read;
66
+ if (done)
67
+ break;
68
+ if (!value)
69
+ continue;
70
+ total += value.byteLength;
71
+ if (total > maxBytes) {
72
+ await reader.cancel().catch(() => { });
73
+ throw new BoundedJsonResponseError('response_too_large', `JSON response exceeded ${maxBytes} bytes.`);
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ }
78
+ finally {
79
+ signal?.removeEventListener('abort', cancelOnAbort);
80
+ reader.releaseLock();
81
+ }
82
+ const bytes = new Uint8Array(total);
83
+ let offset = 0;
84
+ for (const chunk of chunks) {
85
+ bytes.set(chunk, offset);
86
+ offset += chunk.byteLength;
87
+ }
88
+ let raw;
89
+ try {
90
+ raw = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
91
+ }
92
+ catch {
93
+ throw new BoundedJsonResponseError('invalid_json', 'Response body was not valid UTF-8.');
94
+ }
95
+ try {
96
+ validateRawJson?.(raw);
97
+ }
98
+ catch {
99
+ throw new BoundedJsonResponseError('invalid_json', 'Response body did not satisfy the bounded JSON structure contract.');
100
+ }
101
+ try {
102
+ return JSON.parse(raw);
103
+ }
104
+ catch {
105
+ throw new BoundedJsonResponseError('invalid_json', 'Response body was not valid JSON.');
106
+ }
107
+ }
108
+ export async function requestJsonCapped(input, init = {}, options = {}) {
109
+ const timeoutMs = boundedPositiveInteger(options.timeoutMs, DEFAULT_JSON_RESPONSE_TIMEOUT_MS, MAX_JSON_RESPONSE_TIMEOUT_MS);
110
+ const controller = new AbortController();
111
+ const upstreamSignal = init.signal;
112
+ const forwardAbort = () => controller.abort(upstreamSignal?.reason);
113
+ if (upstreamSignal?.aborted)
114
+ forwardAbort();
115
+ else
116
+ upstreamSignal?.addEventListener('abort', forwardAbort, { once: true });
117
+ const timeoutReason = Object.freeze({ type: 'bounded-json-timeout' });
118
+ const timeout = setTimeout(() => controller.abort(timeoutReason), timeoutMs);
119
+ try {
120
+ if (controller.signal.aborted) {
121
+ throw new BoundedJsonResponseError('aborted', 'Request was cancelled.');
122
+ }
123
+ const fetchRequest = (options.fetchImpl ?? fetch)(input, {
124
+ ...init,
125
+ signal: controller.signal,
126
+ });
127
+ const response = await raceWithAbort(fetchRequest, controller.signal, async (lateResponse) => lateResponse.body?.cancel().catch(() => { }));
128
+ const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
129
+ const plausibleJson = !contentType
130
+ || contentType.includes('application/json')
131
+ || contentType.includes('+json');
132
+ if (options.allowNonJsonErrorResponse !== false && !response.ok && !plausibleJson) {
133
+ await response.body?.cancel().catch(() => { });
134
+ return { response, body: null };
135
+ }
136
+ let body;
137
+ try {
138
+ body = await readJsonResponseCapped(response, options.maximumBytes, controller.signal, options.validateRawJson);
139
+ }
140
+ catch (cause) {
141
+ // Reverse proxies and hosting adapters can replace an upstream JSON
142
+ // error with a bounded HTML or plain-text response. Preserve the HTTP
143
+ // status so callers can apply retry and generic error handling, while
144
+ // continuing to require valid JSON for successful responses.
145
+ if (options.allowNonJsonErrorResponse !== false
146
+ && !response.ok
147
+ && cause instanceof BoundedJsonResponseError
148
+ && cause.code === 'invalid_json') {
149
+ body = null;
150
+ }
151
+ else {
152
+ throw cause;
153
+ }
154
+ }
155
+ return { response, body };
156
+ }
157
+ catch (cause) {
158
+ if (controller.signal.aborted) {
159
+ const timedOut = controller.signal.reason === timeoutReason;
160
+ throw new BoundedJsonResponseError(timedOut ? 'timeout' : 'aborted', timedOut ? `Request timed out after ${timeoutMs} ms.` : 'Request was cancelled.');
161
+ }
162
+ if (cause instanceof BoundedJsonResponseError)
163
+ throw cause;
164
+ throw cause;
165
+ }
166
+ finally {
167
+ clearTimeout(timeout);
168
+ upstreamSignal?.removeEventListener('abort', forwardAbort);
169
+ }
170
+ }
@@ -0,0 +1,249 @@
1
+ export const MAX_BOUNDED_JSON_DEPTH = 48;
2
+ export const MAX_BOUNDED_JSON_KEYS = 50_000;
3
+ export const MAX_BOUNDED_JSON_VALUES = 100_000;
4
+ export const MAX_BOUNDED_JSON_CONTAINER_ITEMS = 10_000;
5
+ export const UNSAFE_JSON_OBJECT_KEYS = Object.freeze(['__proto__']);
6
+ function syntaxError() {
7
+ throw new TypeError('Artefact input is not valid JSON.');
8
+ }
9
+ export function isSafeJsonObjectKey(value) {
10
+ return !UNSAFE_JSON_OBJECT_KEYS.includes(value);
11
+ }
12
+ export function assertBoundedJsonStructure(value, label = 'JSON value', limits = {}) {
13
+ const maximumDepth = limits.maximumDepth ?? MAX_BOUNDED_JSON_DEPTH;
14
+ const maximumKeys = limits.maximumKeys ?? MAX_BOUNDED_JSON_KEYS;
15
+ const maximumValues = limits.maximumValues ?? MAX_BOUNDED_JSON_VALUES;
16
+ const maximumContainerItems = limits.maximumContainerItems ?? MAX_BOUNDED_JSON_CONTAINER_ITEMS;
17
+ let keys = 0;
18
+ let values = 0;
19
+ const ancestors = new Set();
20
+ const visit = (current, depth) => {
21
+ values += 1;
22
+ if (values > maximumValues) {
23
+ throw new TypeError(`${label} exceeds the ${maximumValues}-value limit.`);
24
+ }
25
+ if (depth > maximumDepth) {
26
+ throw new TypeError(`${label} exceeds the ${maximumDepth}-level nesting limit.`);
27
+ }
28
+ if (current === null || typeof current === 'string' || typeof current === 'boolean')
29
+ return;
30
+ if (typeof current === 'number') {
31
+ if (!Number.isFinite(current))
32
+ throw new TypeError(`${label} contains a non-JSON number.`);
33
+ return;
34
+ }
35
+ if (typeof current !== 'object')
36
+ throw new TypeError(`${label} contains a non-JSON value.`);
37
+ if (ancestors.has(current))
38
+ throw new TypeError(`${label} contains a cyclic object reference.`);
39
+ ancestors.add(current);
40
+ if (Array.isArray(current)) {
41
+ if (current.length > maximumContainerItems) {
42
+ throw new TypeError(`${label} contains a container with more than ${maximumContainerItems} items.`);
43
+ }
44
+ for (const item of current)
45
+ visit(item, depth + 1);
46
+ }
47
+ else {
48
+ const entries = Object.entries(current);
49
+ if (entries.length > maximumContainerItems) {
50
+ throw new TypeError(`${label} contains a container with more than ${maximumContainerItems} items.`);
51
+ }
52
+ keys += entries.length;
53
+ if (keys > maximumKeys) {
54
+ throw new TypeError(`${label} exceeds the ${maximumKeys}-key limit.`);
55
+ }
56
+ for (const [key, item] of entries) {
57
+ if (!isSafeJsonObjectKey(key))
58
+ throw new TypeError(`${label} contains an unsafe object key.`);
59
+ visit(item, depth + 1);
60
+ }
61
+ }
62
+ ancestors.delete(current);
63
+ };
64
+ visit(value, 0);
65
+ }
66
+ export function scanBoundedJson(raw, limits = {}) {
67
+ const maximumDepth = limits.maximumDepth ?? MAX_BOUNDED_JSON_DEPTH;
68
+ const maximumKeys = limits.maximumKeys ?? MAX_BOUNDED_JSON_KEYS;
69
+ const maximumValues = limits.maximumValues ?? MAX_BOUNDED_JSON_VALUES;
70
+ const maximumContainerItems = limits.maximumContainerItems ?? MAX_BOUNDED_JSON_CONTAINER_ITEMS;
71
+ let index = 0;
72
+ let keys = 0;
73
+ let values = 0;
74
+ const whitespace = () => {
75
+ while (index < raw.length && /[\t\n\r ]/u.test(raw[index]))
76
+ index += 1;
77
+ };
78
+ const stringToken = () => {
79
+ const start = index;
80
+ index += 1;
81
+ while (index < raw.length) {
82
+ const character = raw[index];
83
+ if (character === '"') {
84
+ index += 1;
85
+ try {
86
+ return JSON.parse(raw.slice(start, index));
87
+ }
88
+ catch {
89
+ syntaxError();
90
+ }
91
+ }
92
+ if (character === '\\') {
93
+ index += 1;
94
+ if (index >= raw.length)
95
+ syntaxError();
96
+ if (raw[index] === 'u') {
97
+ if (!/^[a-fA-F0-9]{4}$/u.test(raw.slice(index + 1, index + 5)))
98
+ syntaxError();
99
+ index += 5;
100
+ }
101
+ else {
102
+ if (!/["\\/bfnrt]/u.test(raw[index]))
103
+ syntaxError();
104
+ index += 1;
105
+ }
106
+ continue;
107
+ }
108
+ if (character.charCodeAt(0) <= 0x1f)
109
+ syntaxError();
110
+ index += 1;
111
+ }
112
+ return syntaxError();
113
+ };
114
+ const literal = (token) => {
115
+ if (raw.slice(index, index + token.length) !== token)
116
+ syntaxError();
117
+ index += token.length;
118
+ };
119
+ const number = () => {
120
+ const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(raw.slice(index));
121
+ if (!match)
122
+ syntaxError();
123
+ if (!Number.isFinite(Number(match[0]))) {
124
+ throw new TypeError('Artefact JSON contains a non-finite number.');
125
+ }
126
+ index += match[0].length;
127
+ };
128
+ const value = (depth) => {
129
+ values += 1;
130
+ if (values > maximumValues) {
131
+ throw new TypeError(`Artefact JSON exceeds the ${maximumValues}-value limit.`);
132
+ }
133
+ if (depth > maximumDepth) {
134
+ throw new TypeError(`Artefact JSON exceeds the ${maximumDepth}-level nesting limit.`);
135
+ }
136
+ whitespace();
137
+ const character = raw[index];
138
+ if (character === '"') {
139
+ stringToken();
140
+ return;
141
+ }
142
+ if (character === '{') {
143
+ index += 1;
144
+ whitespace();
145
+ const seen = new Set();
146
+ if (raw[index] === '}') {
147
+ index += 1;
148
+ return;
149
+ }
150
+ while (index < raw.length) {
151
+ if (raw[index] !== '"')
152
+ syntaxError();
153
+ const key = stringToken();
154
+ if (seen.size >= maximumContainerItems) {
155
+ throw new TypeError(`Artefact JSON contains a container with more than ${maximumContainerItems} items.`);
156
+ }
157
+ keys += 1;
158
+ if (keys > maximumKeys) {
159
+ throw new TypeError(`Artefact JSON exceeds the ${maximumKeys}-key limit.`);
160
+ }
161
+ if (seen.has(key))
162
+ throw new TypeError('Artefact JSON contains a duplicate object key.');
163
+ if (!isSafeJsonObjectKey(key))
164
+ throw new TypeError('Artefact JSON contains an unsafe object key.');
165
+ seen.add(key);
166
+ whitespace();
167
+ if (raw[index] !== ':')
168
+ syntaxError();
169
+ index += 1;
170
+ value(depth + 1);
171
+ whitespace();
172
+ if (raw[index] === '}') {
173
+ index += 1;
174
+ return;
175
+ }
176
+ if (raw[index] !== ',')
177
+ syntaxError();
178
+ index += 1;
179
+ whitespace();
180
+ }
181
+ syntaxError();
182
+ }
183
+ if (character === '[') {
184
+ index += 1;
185
+ whitespace();
186
+ if (raw[index] === ']') {
187
+ index += 1;
188
+ return;
189
+ }
190
+ let items = 0;
191
+ while (index < raw.length) {
192
+ items += 1;
193
+ if (items > maximumContainerItems) {
194
+ throw new TypeError(`Artefact JSON contains a container with more than ${maximumContainerItems} items.`);
195
+ }
196
+ value(depth + 1);
197
+ whitespace();
198
+ if (raw[index] === ']') {
199
+ index += 1;
200
+ return;
201
+ }
202
+ if (raw[index] !== ',')
203
+ syntaxError();
204
+ index += 1;
205
+ whitespace();
206
+ }
207
+ syntaxError();
208
+ }
209
+ if (character === 't') {
210
+ literal('true');
211
+ return;
212
+ }
213
+ if (character === 'f') {
214
+ literal('false');
215
+ return;
216
+ }
217
+ if (character === 'n') {
218
+ literal('null');
219
+ return;
220
+ }
221
+ number();
222
+ };
223
+ whitespace();
224
+ value(0);
225
+ whitespace();
226
+ if (index !== raw.length)
227
+ syntaxError();
228
+ }
229
+ export function parseBoundedJsonObject(raw, options) {
230
+ const label = options.label ?? 'Artefact input';
231
+ if (typeof raw !== 'string')
232
+ throw new TypeError(`${label} must be UTF-8 JSON text.`);
233
+ const bytes = new TextEncoder().encode(raw).byteLength;
234
+ if (bytes < 1 || bytes > options.maximumBytes) {
235
+ throw new TypeError(`${label} must be between 1 byte and ${options.maximumBytes} bytes.`);
236
+ }
237
+ scanBoundedJson(raw);
238
+ let parsed;
239
+ try {
240
+ parsed = JSON.parse(raw);
241
+ }
242
+ catch {
243
+ syntaxError();
244
+ }
245
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
246
+ throw new TypeError(`${label} must contain one JSON object.`);
247
+ }
248
+ return parsed;
249
+ }