@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.
- package/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +67 -10
- package/bin/whoisleuth.mjs +22 -9
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +5 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +96 -42
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
package/lib/availability.mjs
CHANGED
|
@@ -115,18 +115,17 @@ const PRIVACY_MARKERS = [
|
|
|
115
115
|
/not disclosed/i,
|
|
116
116
|
/withheld for privacy/i,
|
|
117
117
|
];
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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
|
|
125
|
+
return null;
|
|
127
126
|
const blob = [registrant.name, registrant.org, registrant.email].filter(Boolean).join(' ');
|
|
128
127
|
if (!blob)
|
|
129
|
-
return
|
|
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(),
|
|
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
|
-
?
|
|
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
|
+
}
|