@slicedearth/whoisleuth-cli 1.32.0
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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { calibrateExternalIntelligenceRisk } from "./external-intelligence-risk.mjs";
|
|
2
|
+
const RISK_STATES = new Set(['registered', 'for_sale', 'expiring']);
|
|
3
|
+
const STATE_LABELS = Object.freeze({
|
|
4
|
+
for_sale: 'for sale',
|
|
5
|
+
expiring: 'expiring/pending delete',
|
|
6
|
+
registered: 'registered',
|
|
7
|
+
});
|
|
8
|
+
// Version 6 preserves the grouped external-evidence factor and recognizes the
|
|
9
|
+
// newly generated addition, plural, embedded-TLD, advanced Unicode, and
|
|
10
|
+
// analyst-directed token-replacement families conservatively. Older scores
|
|
11
|
+
// remain readable, while case and watchlist comparisons gate numeric changes
|
|
12
|
+
// on matching model versions so formula changes are not mistaken for changes
|
|
13
|
+
// in the observed domain.
|
|
14
|
+
export const RISK_MODEL_VERSION = 6;
|
|
15
|
+
export const RISK_REVIEW_THRESHOLD = 70;
|
|
16
|
+
const RISK_STATE_BASE = Object.freeze({
|
|
17
|
+
registered: 10,
|
|
18
|
+
for_sale: 5,
|
|
19
|
+
expiring: 8,
|
|
20
|
+
});
|
|
21
|
+
const HIGH_CONTEXT_MUTATIONS = new Set([
|
|
22
|
+
'unicode_homoglyph',
|
|
23
|
+
'unicode_homoglyph_depth_2',
|
|
24
|
+
'dictionary',
|
|
25
|
+
'dictionary_token_replacement',
|
|
26
|
+
]);
|
|
27
|
+
const MEDIUM_CONTEXT_MUTATIONS = new Set(['ascii_homoglyph', 'bitsquatting', 'tld_embedding', 'tld_typo', 'tld_substitution']);
|
|
28
|
+
const LOW_CONTEXT_MUTATIONS = new Set([
|
|
29
|
+
'character_addition',
|
|
30
|
+
'character_omission',
|
|
31
|
+
'character_duplication',
|
|
32
|
+
'character_transposition',
|
|
33
|
+
'pluralization',
|
|
34
|
+
'www_prefix',
|
|
35
|
+
'hyphenation',
|
|
36
|
+
'separator_omission',
|
|
37
|
+
'word_reordering',
|
|
38
|
+
'keyboard_substitution',
|
|
39
|
+
'keyboard_insertion',
|
|
40
|
+
'vowel_swap',
|
|
41
|
+
]);
|
|
42
|
+
export const RISK_MUTATION_TYPES = Object.freeze([
|
|
43
|
+
...HIGH_CONTEXT_MUTATIONS,
|
|
44
|
+
...MEDIUM_CONTEXT_MUTATIONS,
|
|
45
|
+
...LOW_CONTEXT_MUTATIONS,
|
|
46
|
+
]);
|
|
47
|
+
export function normalizeRiskModelVersion(value) {
|
|
48
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= 1000 ? value : null;
|
|
49
|
+
}
|
|
50
|
+
function formatAge(days) {
|
|
51
|
+
if (days < 60)
|
|
52
|
+
return `${days}d old`;
|
|
53
|
+
const years = days / 365.25;
|
|
54
|
+
return years < 1 ? `${Math.round(days / 30)}mo old` : `${years.toFixed(1)}y old`;
|
|
55
|
+
}
|
|
56
|
+
function mutationContext(mutationTypes) {
|
|
57
|
+
if (!Array.isArray(mutationTypes))
|
|
58
|
+
return null;
|
|
59
|
+
const bounded = mutationTypes.slice(0, 30);
|
|
60
|
+
if (bounded.some((value) => typeof value === 'string' && HIGH_CONTEXT_MUTATIONS.has(value))) {
|
|
61
|
+
return { label: 'High-similarity or phishing-term candidate context', delta: 18 };
|
|
62
|
+
}
|
|
63
|
+
if (bounded.some((value) => typeof value === 'string' && MEDIUM_CONTEXT_MUTATIONS.has(value))) {
|
|
64
|
+
return { label: 'Lookalike candidate context', delta: 12 };
|
|
65
|
+
}
|
|
66
|
+
if (bounded.some((value) => typeof value === 'string' && LOW_CONTEXT_MUTATIONS.has(value))) {
|
|
67
|
+
return { label: 'Generated variation candidate context', delta: 8 };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
// Risk prioritizes a registered lookalike/typosquat domain for analyst review.
|
|
72
|
+
// It is a heuristic indicator, never a maliciousness or safety verdict.
|
|
73
|
+
export function explainRiskScore(r) {
|
|
74
|
+
const state = r.availability ?? r.state;
|
|
75
|
+
if (typeof state !== 'string' || !RISK_STATES.has(state))
|
|
76
|
+
return null;
|
|
77
|
+
const base = RISK_STATE_BASE[state] ?? 0;
|
|
78
|
+
const factors = [{ label: `Base score for "${STATE_LABELS[state] || state}"`, delta: base }];
|
|
79
|
+
let score = base;
|
|
80
|
+
const contextualFamilies = new Set();
|
|
81
|
+
const addContextualFactor = (family, label, delta) => {
|
|
82
|
+
contextualFamilies.add(family);
|
|
83
|
+
factors.push({ label, delta });
|
|
84
|
+
score += delta;
|
|
85
|
+
};
|
|
86
|
+
const context = mutationContext(r.mutationTypes);
|
|
87
|
+
if (context)
|
|
88
|
+
addContextualFactor('domain-resemblance', context.label, context.delta);
|
|
89
|
+
if (r.faviconMatch === true) {
|
|
90
|
+
addContextualFactor('brand-presentation', 'Favicon matches your Brand Profile official site', 18);
|
|
91
|
+
}
|
|
92
|
+
else if (r.faviconNearMatch === true) {
|
|
93
|
+
addContextualFactor('brand-presentation', 'Favicon resembles your official site (perceptual match)', 14);
|
|
94
|
+
}
|
|
95
|
+
if (r.reusesOfficialAssets === true) {
|
|
96
|
+
addContextualFactor('brand-presentation', 'Official asset host relationship observed', 6);
|
|
97
|
+
}
|
|
98
|
+
if (typeof r.phishingLanguageMatch === 'string' && r.phishingLanguageMatch.trim()) {
|
|
99
|
+
addContextualFactor('credential-lure', 'Suspicious urgency language observed', 8);
|
|
100
|
+
}
|
|
101
|
+
if (r.hasPasswordField === true) {
|
|
102
|
+
addContextualFactor('credential-lure', 'Login/password form present', 5);
|
|
103
|
+
}
|
|
104
|
+
if (contextualFamilies.size >= 2) {
|
|
105
|
+
const bonus = contextualFamilies.size >= 3 ? 20 : 10;
|
|
106
|
+
factors.push({ label: `Corroborating context across ${contextualFamilies.size} distinct evidence families`, delta: bonus });
|
|
107
|
+
score += bonus;
|
|
108
|
+
}
|
|
109
|
+
const externalEvidence = calibrateExternalIntelligenceRisk(r.threatIntelligence);
|
|
110
|
+
if (externalEvidence.factor) {
|
|
111
|
+
factors.push(externalEvidence.factor);
|
|
112
|
+
score += externalEvidence.contribution;
|
|
113
|
+
}
|
|
114
|
+
if (r.activityStatus === 'active') {
|
|
115
|
+
factors.push({ label: 'Active site in use', delta: 8 });
|
|
116
|
+
score += 8;
|
|
117
|
+
}
|
|
118
|
+
if (r.hasMx === true) {
|
|
119
|
+
factors.push({ label: 'Mail server configured', delta: 8 });
|
|
120
|
+
score += 8;
|
|
121
|
+
}
|
|
122
|
+
if (r.hasSpf === true && r.hasDmarc === true) {
|
|
123
|
+
factors.push({ label: 'SPF + DMARC records present', delta: 3 });
|
|
124
|
+
score += 3;
|
|
125
|
+
}
|
|
126
|
+
else if (r.hasSpf === true || r.hasDmarc === true) {
|
|
127
|
+
factors.push({ label: 'SPF or DMARC record present', delta: 1 });
|
|
128
|
+
score += 1;
|
|
129
|
+
}
|
|
130
|
+
if (r.privacyProtected === true) {
|
|
131
|
+
factors.push({ label: 'WHOIS privacy protected', delta: 3 });
|
|
132
|
+
score += 3;
|
|
133
|
+
}
|
|
134
|
+
if (typeof r.domainAgeDays === 'number' && Number.isFinite(r.domainAgeDays) && r.domainAgeDays >= 0) {
|
|
135
|
+
if (r.domainAgeDays < 90) {
|
|
136
|
+
factors.push({ label: `Recently registered (${formatAge(r.domainAgeDays)})`, delta: 10 });
|
|
137
|
+
score += 10;
|
|
138
|
+
}
|
|
139
|
+
else if (r.domainAgeDays < 365) {
|
|
140
|
+
factors.push({ label: `Registered under a year ago (${formatAge(r.domainAgeDays)})`, delta: 4 });
|
|
141
|
+
score += 4;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { modelVersion: RISK_MODEL_VERSION, score: Math.max(0, Math.min(100, Math.round(score))), factors };
|
|
145
|
+
}
|
|
146
|
+
export function computeRiskScore(r) {
|
|
147
|
+
return explainRiskScore(r)?.score ?? null;
|
|
148
|
+
}
|
|
149
|
+
export function riskTone(score) {
|
|
150
|
+
if (score === null)
|
|
151
|
+
return 'neutral';
|
|
152
|
+
if (score >= RISK_REVIEW_THRESHOLD)
|
|
153
|
+
return 'danger';
|
|
154
|
+
if (score >= 40)
|
|
155
|
+
return 'warn';
|
|
156
|
+
return 'neutral';
|
|
157
|
+
}
|
|
158
|
+
export function formatScoreBreakdown(explained, separator = '\n') {
|
|
159
|
+
if (!explained)
|
|
160
|
+
return '';
|
|
161
|
+
const parts = explained.factors.map((factor) => `${factor.label} ${factor.delta >= 0 ? '+' : ''}${Math.round(factor.delta)}`);
|
|
162
|
+
parts.push(`Total ${explained.score}`);
|
|
163
|
+
return parts.join(separator);
|
|
164
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// Guards against SSRF via DNS rebinding: a caller can supply any domain name
|
|
2
|
+
// to /api/availability, which (in deep-check mode) fetches that domain's own
|
|
3
|
+
// homepage to look for parking/for-sale signals. Nothing stops that domain's
|
|
4
|
+
// DNS from pointing at a private/loopback/link-local address (including
|
|
5
|
+
// cloud metadata endpoints like 169.254.169.254) instead of a real public
|
|
6
|
+
// site, and an HTTP redirect could do the same after the initial check.
|
|
7
|
+
//
|
|
8
|
+
// Resolving the hostname and checking the result is NOT enough on its own -
|
|
9
|
+
// fetch() performs its own, separate DNS resolution when it actually opens
|
|
10
|
+
// the connection, and nothing guarantees a second lookup answers the same
|
|
11
|
+
// way as the first. A malicious authoritative DNS server can simply answer
|
|
12
|
+
// differently on successive queries (classic DNS rebinding), returning a
|
|
13
|
+
// public address for the validation lookup and a private one moments later
|
|
14
|
+
// for fetch()'s own lookup. So this resolves the hostname once, validates
|
|
15
|
+
// every address it maps to, and then *pins* the actual connection to one of
|
|
16
|
+
// those already-validated addresses via a custom `dns.lookup`-compatible
|
|
17
|
+
// resolver on a per-request undici Agent - there is no second, independent
|
|
18
|
+
// DNS query left for a rebinding attacker to answer differently. The
|
|
19
|
+
// hostname itself is still used for the TLS SNI/certificate check and the
|
|
20
|
+
// HTTP Host header; only the IP a socket actually connects to is pinned.
|
|
21
|
+
import { promises as dns } from 'node:dns';
|
|
22
|
+
import * as net from 'node:net';
|
|
23
|
+
import * as crypto from 'node:crypto';
|
|
24
|
+
import { Agent, fetch as undiciFetch } from 'undici';
|
|
25
|
+
const MAX_REDIRECTS = 5;
|
|
26
|
+
const MAX_SAFE_FETCH_URL_LENGTH = 4096;
|
|
27
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
28
|
+
async function defaultSafeFetchRequest(url, options) {
|
|
29
|
+
// TypeScript's DOM and undici fetch declarations model equivalent web
|
|
30
|
+
// primitives with different iterator and Blob types. Keep that declaration
|
|
31
|
+
// mismatch isolated at this runtime boundary; callers continue to receive a
|
|
32
|
+
// standards-compatible Response.
|
|
33
|
+
const response = await undiciFetch(url, options);
|
|
34
|
+
return response;
|
|
35
|
+
}
|
|
36
|
+
function isPrivateIpv4(ip) {
|
|
37
|
+
const parts = ip.split('.').map(Number);
|
|
38
|
+
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p)))
|
|
39
|
+
return true; // malformed - fail closed
|
|
40
|
+
const [a = -1, b = -1, c = -1] = parts;
|
|
41
|
+
if (a === 0)
|
|
42
|
+
return true; // "this network"
|
|
43
|
+
if (a === 10)
|
|
44
|
+
return true; // RFC1918
|
|
45
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
46
|
+
return true; // RFC6598 shared address space (carrier-grade NAT) - routes inside provider networks, not the open internet
|
|
47
|
+
if (a === 127)
|
|
48
|
+
return true; // loopback
|
|
49
|
+
if (a === 169 && b === 254)
|
|
50
|
+
return true; // link-local, incl. cloud metadata services
|
|
51
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
52
|
+
return true; // RFC1918
|
|
53
|
+
if (a === 192 && b === 0 && c === 0)
|
|
54
|
+
return true; // RFC6890 IETF protocol assignments
|
|
55
|
+
if (a === 192 && b === 0 && c === 2)
|
|
56
|
+
return true; // RFC5737 TEST-NET-1 (documentation-only, never routed)
|
|
57
|
+
if (a === 192 && b === 88 && c === 99)
|
|
58
|
+
return true; // RFC7526 deprecated 6to4 relay anycast
|
|
59
|
+
if (a === 192 && b === 168)
|
|
60
|
+
return true; // RFC1918
|
|
61
|
+
if (a === 198 && b >= 18 && b <= 19)
|
|
62
|
+
return true; // RFC2544 benchmarking
|
|
63
|
+
if (a === 198 && b === 51 && c === 100)
|
|
64
|
+
return true; // RFC5737 TEST-NET-2 (documentation-only, never routed)
|
|
65
|
+
if (a === 203 && b === 0 && c === 113)
|
|
66
|
+
return true; // RFC5737 TEST-NET-3 (documentation-only, never routed)
|
|
67
|
+
if (a >= 224)
|
|
68
|
+
return true; // multicast/reserved
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
// Expands a valid IPv6 address string to its full 8-group, zero-padded hex
|
|
72
|
+
// form - e.g. "::1" -> all-zero groups ending in "0001". Needed because the
|
|
73
|
+
// same address can be written multiple ways (a "::" shorthand for a run of
|
|
74
|
+
// zero groups, an embedded IPv4 tail as either dotted-decimal or two hex
|
|
75
|
+
// groups), and a hostile authoritative DNS server - the exact threat this
|
|
76
|
+
// module exists to guard against - is free to choose whichever string form
|
|
77
|
+
// it wants. Checking string prefixes on the as-written form (the previous
|
|
78
|
+
// approach) only catches whichever form happened to be anticipated; e.g.
|
|
79
|
+
// "::ffff:127.0.0.1" and "::ffff:7f00:1" are the same address, but only the
|
|
80
|
+
// first was recognized as IPv4-mapped loopback.
|
|
81
|
+
function expandIpv6Groups(ip) {
|
|
82
|
+
const hasDoubleColon = ip.includes('::');
|
|
83
|
+
const [headStr, tailStr] = hasDoubleColon ? ip.split('::') : [ip, undefined];
|
|
84
|
+
// A dotted-decimal segment (the tail of "::ffff:127.0.0.1") is two
|
|
85
|
+
// 16-bit groups' worth of bits - convert it to hex groups first so the
|
|
86
|
+
// rest of this function only ever deals with plain hex.
|
|
87
|
+
const toHexGroups = (part) => {
|
|
88
|
+
if (!part)
|
|
89
|
+
return [];
|
|
90
|
+
return part.split(':').flatMap((group) => {
|
|
91
|
+
if (!group.includes('.'))
|
|
92
|
+
return [group];
|
|
93
|
+
const bytes = group.split('.').map(Number);
|
|
94
|
+
const [first = -1, second = -1, third = -1, fourth = -1] = bytes;
|
|
95
|
+
if (bytes.length !== 4
|
|
96
|
+
|| [first, second, third, fourth].some((byte) => !Number.isInteger(byte)))
|
|
97
|
+
return [];
|
|
98
|
+
return [
|
|
99
|
+
(((first << 8) | second) >>> 0).toString(16),
|
|
100
|
+
(((third << 8) | fourth) >>> 0).toString(16),
|
|
101
|
+
];
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const headGroups = toHexGroups(headStr);
|
|
105
|
+
const tailGroups = hasDoubleColon ? toHexGroups(tailStr) : [];
|
|
106
|
+
if (!hasDoubleColon)
|
|
107
|
+
return headGroups.map((g) => g.padStart(4, '0').toLowerCase());
|
|
108
|
+
const missing = 8 - headGroups.length - tailGroups.length;
|
|
109
|
+
const middleGroups = new Array(Math.max(missing, 0)).fill('0');
|
|
110
|
+
return [...headGroups, ...middleGroups, ...tailGroups].map((g) => g.padStart(4, '0').toLowerCase());
|
|
111
|
+
}
|
|
112
|
+
function groupsToIpv4(hiGroup, loGroup) {
|
|
113
|
+
const hi = parseInt(hiGroup, 16);
|
|
114
|
+
const lo = parseInt(loGroup, 16);
|
|
115
|
+
if (Number.isNaN(hi) || Number.isNaN(lo))
|
|
116
|
+
return null;
|
|
117
|
+
return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join('.');
|
|
118
|
+
}
|
|
119
|
+
function isPrivateIpv6(ip) {
|
|
120
|
+
const groups = expandIpv6Groups(ip.toLowerCase());
|
|
121
|
+
if (groups.length !== 8)
|
|
122
|
+
return true; // couldn't parse cleanly - fail closed
|
|
123
|
+
const [g0 = '', g1 = '', g2 = '', g3 = '', g4 = '', g5 = '', g6 = '', g7 = '',] = groups;
|
|
124
|
+
if (groups.every((g) => g === '0000'))
|
|
125
|
+
return true; // :: (unspecified)
|
|
126
|
+
if (groups.slice(0, 7).every((g) => g === '0000') && g7 === '0001')
|
|
127
|
+
return true; // ::1 (loopback)
|
|
128
|
+
if (g0.startsWith('ff'))
|
|
129
|
+
return true; // multicast ff00::/8
|
|
130
|
+
// IPv4-mapped (::ffff:0:0/96) and the deprecated IPv4-compatible
|
|
131
|
+
// (::0:0/96) forms both put the embedded address in the last two groups -
|
|
132
|
+
// checked on the canonical expanded form above, so it doesn't matter
|
|
133
|
+
// whether the address was originally written in dotted-decimal or hex.
|
|
134
|
+
if ([g0, g1, g2, g3, g4].every((g) => g === '0000') && (g5 === 'ffff' || g5 === '0000')) {
|
|
135
|
+
const embedded = groupsToIpv4(g6, g7);
|
|
136
|
+
if (embedded && isPrivateIpv4(embedded))
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
// NAT64 well-known prefix (64:ff9b::/96) - same tail embedding, different
|
|
140
|
+
// prefix; some IPv6-only networks synthesize these to reach IPv4 hosts.
|
|
141
|
+
if (g0 === '0064' && g1 === 'ff9b' && [g2, g3, g4, g5].every((g) => g === '0000')) {
|
|
142
|
+
const embedded = groupsToIpv4(g6, g7);
|
|
143
|
+
if (embedded && isPrivateIpv4(embedded))
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
// RFC 8215 local-use NAT64 prefix (64:ff9b:1::/48). Unlike the well-known
|
|
147
|
+
// /96 above, operators may choose how IPv4 bits are embedded within this
|
|
148
|
+
// larger prefix. The whole range is non-global by definition, so reject it
|
|
149
|
+
// rather than attempting to decode one layout and leaving others open.
|
|
150
|
+
if (g0 === '0064' && g1 === 'ff9b' && g2 === '0001')
|
|
151
|
+
return true;
|
|
152
|
+
// 6to4 (2002::/16) - the embedded IPv4 lives in groups 1-2, not the tail.
|
|
153
|
+
if (g0 === '2002') {
|
|
154
|
+
const embedded = groupsToIpv4(g1, g2);
|
|
155
|
+
if (embedded && isPrivateIpv4(embedded))
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
// Teredo (2001:0000::/32) carries both a server IPv4 address and an
|
|
159
|
+
// obfuscated client IPv4 address. Decode both so a transition address
|
|
160
|
+
// cannot smuggle a private/loopback target past the IPv4 guard.
|
|
161
|
+
if (g0 === '2001' && g1 === '0000') {
|
|
162
|
+
const server = groupsToIpv4(g2, g3);
|
|
163
|
+
const client = groupsToIpv4(((~parseInt(g6, 16)) & 0xffff).toString(16), ((~parseInt(g7, 16)) & 0xffff).toString(16));
|
|
164
|
+
if ((server && isPrivateIpv4(server)) || (client && isPrivateIpv4(client)))
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (/^fe[89ab][0-9a-f]$/.test(g0))
|
|
168
|
+
return true; // link-local fe80::/10
|
|
169
|
+
if (/^fe[cdef][0-9a-f]$/.test(g0))
|
|
170
|
+
return true; // deprecated site-local fec0::/10
|
|
171
|
+
if (/^f[cd][0-9a-f]{2}$/.test(g0))
|
|
172
|
+
return true; // unique local fc00::/7
|
|
173
|
+
if (g0 === '0100' && [g1, g2, g3].every((g) => g === '0000'))
|
|
174
|
+
return true; // discard-only 100::/64
|
|
175
|
+
if (g0 === '2001' && g1 === '0db8')
|
|
176
|
+
return true; // documentation 2001:db8::/32
|
|
177
|
+
if (g0 === '2001' && g1 === '0002' && g2 === '0000')
|
|
178
|
+
return true; // benchmarking 2001:2::/48
|
|
179
|
+
if (g0 === '2001' && /^00[1-2][0-9a-f]$/.test(g1))
|
|
180
|
+
return true; // ORCHID/ORCHIDv2
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
function isPrivateAddress(ip) {
|
|
184
|
+
const version = net.isIP(ip);
|
|
185
|
+
if (version === 4)
|
|
186
|
+
return isPrivateIpv4(ip);
|
|
187
|
+
if (version === 6)
|
|
188
|
+
return isPrivateIpv6(ip);
|
|
189
|
+
return true; // not a recognizable IP literal - fail closed
|
|
190
|
+
}
|
|
191
|
+
// Resolves the hostname and returns every address it maps to, having
|
|
192
|
+
// verified all of them are public. Throws (fails closed) if any address is
|
|
193
|
+
// private/reserved, or if there are no records at all.
|
|
194
|
+
async function resolvePublicAddresses(hostname) {
|
|
195
|
+
let records;
|
|
196
|
+
try {
|
|
197
|
+
records = await dns.lookup(hostname, { all: true });
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
throw new Error(`DNS resolution failed for ${hostname}: ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
}
|
|
202
|
+
if (records.length === 0)
|
|
203
|
+
throw new Error(`No DNS records for ${hostname}`);
|
|
204
|
+
for (const { address } of records) {
|
|
205
|
+
if (isPrivateAddress(address)) {
|
|
206
|
+
throw new Error(`Refusing to fetch ${hostname}: resolves to a private/reserved address (${address})`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return records;
|
|
210
|
+
}
|
|
211
|
+
// A per-request undici Agent whose connections are pinned to the given,
|
|
212
|
+
// already-validated address(es) - overriding the `lookup` function used at
|
|
213
|
+
// connect time instead of letting undici resolve the hostname itself.
|
|
214
|
+
// Matches the dns.lookup callback contract net.connect/tls.connect expect:
|
|
215
|
+
// `callback(err, address, family)`, or `callback(err, addressObjects)` when
|
|
216
|
+
// called with `{ all: true }` (Node's Happy Eyeballs dual-stack racing).
|
|
217
|
+
function pinnedDispatcher(records) {
|
|
218
|
+
const firstRecord = records[0];
|
|
219
|
+
if (!firstRecord)
|
|
220
|
+
throw new Error('Cannot pin a request without a validated public address');
|
|
221
|
+
return new Agent({
|
|
222
|
+
connect: {
|
|
223
|
+
lookup(_hostname, options, callback) {
|
|
224
|
+
if (options && options.all) {
|
|
225
|
+
callback(null, records.map(({ address, family }) => ({ address, family })));
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
callback(null, firstRecord.address, firstRecord.family);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function normalizedFetchUrl(value) {
|
|
235
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SAFE_FETCH_URL_LENGTH) {
|
|
236
|
+
throw new Error('Refusing to fetch an empty or oversized URL');
|
|
237
|
+
}
|
|
238
|
+
const parsed = new URL(value);
|
|
239
|
+
if (!['http:', 'https:'].includes(parsed.protocol))
|
|
240
|
+
throw new Error('Refusing to fetch a non-HTTP URL');
|
|
241
|
+
if (!parsed.hostname)
|
|
242
|
+
throw new Error('Refusing to fetch a URL without a hostname');
|
|
243
|
+
if (parsed.username || parsed.password)
|
|
244
|
+
throw new Error('Refusing to fetch a URL containing credentials');
|
|
245
|
+
// Keep every safe-fetch caller on the ordinary web-service boundary. URL
|
|
246
|
+
// normalisation removes explicit default ports (:80 for HTTP and :443 for
|
|
247
|
+
// HTTPS), so any remaining port is non-default and must be rejected here.
|
|
248
|
+
// Applying the rule centrally also covers redirect targets and page-declared
|
|
249
|
+
// resources rather than relying on each entry-point parser to repeat it.
|
|
250
|
+
if (parsed.port)
|
|
251
|
+
throw new Error('Refusing to fetch a URL with a non-default port');
|
|
252
|
+
const normalized = parsed.toString();
|
|
253
|
+
if (normalized.length > MAX_SAFE_FETCH_URL_LENGTH)
|
|
254
|
+
throw new Error('Refusing to fetch an oversized URL');
|
|
255
|
+
return normalized;
|
|
256
|
+
}
|
|
257
|
+
function boundedDuration(value) {
|
|
258
|
+
return Math.max(0, Math.min(120_000, Math.round(Number(value) || 0)));
|
|
259
|
+
}
|
|
260
|
+
async function closeDispatcher(dispatcher) {
|
|
261
|
+
if (!dispatcher || typeof dispatcher.close !== 'function')
|
|
262
|
+
return;
|
|
263
|
+
try {
|
|
264
|
+
await dispatcher.close();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Cleanup must never replace the request result or its more useful error.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Detailed form of the shared safe request engine. It follows redirects
|
|
271
|
+
// manually under the same DNS validation and connection-pinning policy as
|
|
272
|
+
// safeFetch(), while retaining a bounded hop trace for consumers that need
|
|
273
|
+
// provenance. The optional dependencies object exists for deterministic local
|
|
274
|
+
// tests; production callers use the defaults.
|
|
275
|
+
async function safeFetchDetailed(url, options = {}, dependencies = {}) {
|
|
276
|
+
const resolveAddresses = dependencies.resolvePublicAddresses || resolvePublicAddresses;
|
|
277
|
+
const makeDispatcher = dependencies.pinnedDispatcher || pinnedDispatcher;
|
|
278
|
+
// Keep the request function and dispatcher on the same undici runtime.
|
|
279
|
+
// Hosting platforms may wrap or replace Node's global fetch implementation;
|
|
280
|
+
// passing an Agent created by this package across that runtime boundary can
|
|
281
|
+
// fail before a socket is opened. The matching undici fetch implementation
|
|
282
|
+
// preserves the pinned DNS connection while avoiding that incompatibility.
|
|
283
|
+
const request = dependencies.fetch || defaultSafeFetchRequest;
|
|
284
|
+
const now = dependencies.now || Date.now;
|
|
285
|
+
const requestedMaxRedirects = Number(dependencies.maxRedirects);
|
|
286
|
+
const maxRedirects = Number.isInteger(requestedMaxRedirects)
|
|
287
|
+
? Math.max(0, Math.min(MAX_REDIRECTS, requestedMaxRedirects))
|
|
288
|
+
: MAX_REDIRECTS;
|
|
289
|
+
const requestedUrl = normalizedFetchUrl(url);
|
|
290
|
+
const startedAt = now();
|
|
291
|
+
const hops = [];
|
|
292
|
+
let currentUrl = requestedUrl;
|
|
293
|
+
let redirectCount = 0;
|
|
294
|
+
while (true) {
|
|
295
|
+
const parsed = new URL(currentUrl);
|
|
296
|
+
const records = await resolveAddresses(parsed.hostname);
|
|
297
|
+
const dispatcher = makeDispatcher(records);
|
|
298
|
+
const hopStartedAt = now();
|
|
299
|
+
// `dispatcher` is a supported undici extension used to pin the connection.
|
|
300
|
+
// Keep the non-standard property local to this request boundary.
|
|
301
|
+
const fetchOptions = { ...options, redirect: 'manual', dispatcher };
|
|
302
|
+
let response;
|
|
303
|
+
try {
|
|
304
|
+
response = await request(currentUrl, fetchOptions);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
await closeDispatcher(dispatcher);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
const location = REDIRECT_STATUSES.has(response.status) ? response.headers.get('location') : null;
|
|
311
|
+
let nextUrl = null;
|
|
312
|
+
if (location) {
|
|
313
|
+
try {
|
|
314
|
+
nextUrl = normalizedFetchUrl(new URL(location, currentUrl).toString());
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
await response.body?.cancel().catch(() => { });
|
|
318
|
+
await closeDispatcher(dispatcher);
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
hops.push({
|
|
323
|
+
url: currentUrl,
|
|
324
|
+
status: response.status,
|
|
325
|
+
location: nextUrl,
|
|
326
|
+
durationMs: boundedDuration(now() - hopStartedAt),
|
|
327
|
+
});
|
|
328
|
+
if (nextUrl && redirectCount < maxRedirects) {
|
|
329
|
+
await response.body?.cancel().catch(() => { });
|
|
330
|
+
await closeDispatcher(dispatcher);
|
|
331
|
+
redirectCount += 1;
|
|
332
|
+
currentUrl = nextUrl;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
// This Agent is unique to this request, so it must never retain an idle
|
|
336
|
+
// keep-alive socket. Undici's graceful close waits for the active response
|
|
337
|
+
// to finish: initiate it now without awaiting, letting the caller consume
|
|
338
|
+
// or cancel the body while the dispatcher retires deterministically.
|
|
339
|
+
void closeDispatcher(dispatcher);
|
|
340
|
+
return {
|
|
341
|
+
response,
|
|
342
|
+
requestedUrl,
|
|
343
|
+
finalUrl: currentUrl,
|
|
344
|
+
redirected: redirectCount > 0,
|
|
345
|
+
redirectCount,
|
|
346
|
+
redirectLimitReached: Boolean(nextUrl && redirectCount >= maxRedirects),
|
|
347
|
+
hops,
|
|
348
|
+
durationMs: boundedDuration(now() - startedAt),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Compatibility form: existing callers still receive a Response, but it is
|
|
353
|
+
// produced by the detailed engine above rather than a separate request path.
|
|
354
|
+
async function safeFetch(url, options = {}, redirectsLeft = MAX_REDIRECTS) {
|
|
355
|
+
const result = await safeFetchDetailed(url, options, { maxRedirects: redirectsLeft });
|
|
356
|
+
return result.response;
|
|
357
|
+
}
|
|
358
|
+
// Reads at most maxBytes of a response body, cancelling the underlying
|
|
359
|
+
// stream as soon as that cap is hit instead of buffering the whole thing -
|
|
360
|
+
// shared by every caller that fetches a response body it doesn't control
|
|
361
|
+
// the size of (a domain's own homepage, a public search API), so none of
|
|
362
|
+
// them can be made to hold an arbitrarily large body in memory. `truncated`
|
|
363
|
+
// tells the caller whether the real body was actually cut short - callers
|
|
364
|
+
// that need well-formed content (e.g. JSON) should treat that as a hard
|
|
365
|
+
// failure rather than parsing a partial blob; callers that only need to
|
|
366
|
+
// scan for a substring (e.g. plain-text/HTML matching) can safely ignore it.
|
|
367
|
+
async function readTextCapped(res, maxBytes, options = {}) {
|
|
368
|
+
const hasher = options.includeSha256 === true ? crypto.createHash('sha256') : null;
|
|
369
|
+
const result = (text, truncated, bytesRead) => ({
|
|
370
|
+
text,
|
|
371
|
+
truncated,
|
|
372
|
+
bytesRead,
|
|
373
|
+
...(hasher ? { sha256: hasher.digest('hex') } : {}),
|
|
374
|
+
});
|
|
375
|
+
const reader = res.body && res.body.getReader ? res.body.getReader() : null;
|
|
376
|
+
if (!reader) {
|
|
377
|
+
const full = Buffer.from(await res.arrayBuffer());
|
|
378
|
+
const captured = full.subarray(0, maxBytes);
|
|
379
|
+
hasher?.update(captured);
|
|
380
|
+
return result(new TextDecoder().decode(captured), full.length > maxBytes, captured.length);
|
|
381
|
+
}
|
|
382
|
+
const decoder = new TextDecoder();
|
|
383
|
+
let text = '';
|
|
384
|
+
let received = 0;
|
|
385
|
+
let truncated = false;
|
|
386
|
+
while (received < maxBytes) {
|
|
387
|
+
const { done, value } = await reader.read();
|
|
388
|
+
if (done)
|
|
389
|
+
break;
|
|
390
|
+
const remaining = maxBytes - received;
|
|
391
|
+
const captured = value.subarray(0, remaining);
|
|
392
|
+
hasher?.update(captured);
|
|
393
|
+
received += captured.byteLength;
|
|
394
|
+
text += decoder.decode(captured, { stream: true });
|
|
395
|
+
if (captured.byteLength < value.byteLength) {
|
|
396
|
+
truncated = true;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// If the cap was hit, one more read() tells us whether there was actually
|
|
401
|
+
// more data left (truncated) or the body just happened to end right there.
|
|
402
|
+
if (!truncated && received >= maxBytes) {
|
|
403
|
+
const { done } = await reader.read();
|
|
404
|
+
if (!done)
|
|
405
|
+
truncated = true;
|
|
406
|
+
}
|
|
407
|
+
reader.cancel().catch(() => { });
|
|
408
|
+
text += decoder.decode();
|
|
409
|
+
return result(text, truncated, received);
|
|
410
|
+
}
|
|
411
|
+
// Binary-safe counterpart to readTextCapped, for a response body that isn't
|
|
412
|
+
// text (a favicon image) - collects raw bytes into a Buffer instead of
|
|
413
|
+
// decoding as UTF-8, which would corrupt non-text bytes.
|
|
414
|
+
async function readBytesCapped(res, maxBytes) {
|
|
415
|
+
const reader = res.body && res.body.getReader ? res.body.getReader() : null;
|
|
416
|
+
if (!reader) {
|
|
417
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
418
|
+
const bytes = buf.subarray(0, maxBytes);
|
|
419
|
+
return { bytes, truncated: buf.length > maxBytes, bytesRead: bytes.length };
|
|
420
|
+
}
|
|
421
|
+
const chunks = [];
|
|
422
|
+
let received = 0;
|
|
423
|
+
let truncated = false;
|
|
424
|
+
while (received < maxBytes) {
|
|
425
|
+
const { done, value } = await reader.read();
|
|
426
|
+
if (done)
|
|
427
|
+
break;
|
|
428
|
+
const remaining = maxBytes - received;
|
|
429
|
+
const captured = value.subarray(0, remaining);
|
|
430
|
+
received += captured.byteLength;
|
|
431
|
+
chunks.push(Buffer.from(captured));
|
|
432
|
+
if (captured.byteLength < value.byteLength) {
|
|
433
|
+
truncated = true;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Same "one more read() to check for leftover data" trick as readTextCapped.
|
|
438
|
+
if (!truncated && received >= maxBytes) {
|
|
439
|
+
const { done } = await reader.read();
|
|
440
|
+
if (!done)
|
|
441
|
+
truncated = true;
|
|
442
|
+
}
|
|
443
|
+
reader.cancel().catch(() => { });
|
|
444
|
+
return { bytes: Buffer.concat(chunks), truncated, bytesRead: received };
|
|
445
|
+
}
|
|
446
|
+
export { MAX_REDIRECTS, MAX_SAFE_FETCH_URL_LENGTH, safeFetch, safeFetchDetailed, readTextCapped, readBytesCapped, isPrivateAddress, resolvePublicAddresses, };
|