@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +326 -3
- package/cli/artifact-verify.mjs +60 -14
- package/cli/bulk.mjs +1 -2
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +36 -2
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +14 -4
- package/cli/investigation-plan.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +48 -3
- package/cli/offline-evidence-review.mjs +159 -0
- package/cli/registry-doctor.mjs +125 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +342 -15
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -847
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +30 -10
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/bulk-limits.mjs +13 -0
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +382 -0
- package/lib/domain-posture.mjs +11 -10
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +4 -1
- package/lib/favicon.mjs +3 -8
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +87 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +247 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +15 -14
- package/lib/registry-insights.mjs +6 -6
- package/lib/response-policy.mjs +81 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +11 -6
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +11 -6
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +16 -9
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +6 -2
- package/third-party-notices.txt +911 -19
|
@@ -83,7 +83,7 @@ function disclosure(source, parsedValue, status) {
|
|
|
83
83
|
return {
|
|
84
84
|
source,
|
|
85
85
|
state: 'unavailable',
|
|
86
|
-
detail: 'The source did not provide a usable
|
|
86
|
+
detail: 'The source did not provide a usable normalised publication.',
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
const parsed = record(parsedValue);
|
|
@@ -211,11 +211,11 @@ function publicationDiagnostic(source, parsedValue, status, observedAt) {
|
|
|
211
211
|
if (parsed.serverTruncated === true)
|
|
212
212
|
issues.push('The RDAP server declared a truncated response.');
|
|
213
213
|
if (parsed.entitiesTruncated === true)
|
|
214
|
-
issues.push('The
|
|
214
|
+
issues.push('The normalised entity inventory was capped.');
|
|
215
215
|
if (parsed.statusesTruncated === true)
|
|
216
|
-
issues.push('The
|
|
216
|
+
issues.push('The normalised status inventory was capped.');
|
|
217
217
|
if (strings(parsed.fieldsTruncated, 20).length)
|
|
218
|
-
issues.push('One or more
|
|
218
|
+
issues.push('One or more normalised WHOIS fields were capped.');
|
|
219
219
|
if (parsed.chainStatus === 'partial')
|
|
220
220
|
issues.push('The WHOIS referral chain was incomplete or conflicting.');
|
|
221
221
|
const conformance = strings(parsed.conformance, 30);
|
|
@@ -306,9 +306,9 @@ export function buildRegistryInsights(input) {
|
|
|
306
306
|
: unavailableCount
|
|
307
307
|
? 'The comparison is partial because at least one publication was unavailable or incomplete.'
|
|
308
308
|
: sourceOnlyCount || redactedCount
|
|
309
|
-
? 'The sources differ in publication or disclosure without a
|
|
309
|
+
? 'The sources differ in publication or disclosure without a normalised conflict.'
|
|
310
310
|
: counts.equivalent
|
|
311
|
-
? 'The comparable fields are
|
|
311
|
+
? 'The comparable fields are normalised as equivalent.'
|
|
312
312
|
: 'No comparable fields were available.',
|
|
313
313
|
},
|
|
314
314
|
publications: [
|
package/lib/response-policy.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Privacy-minimized interpretation of selected response policies. Header and
|
|
2
2
|
// cookie values are read transiently from the already-selected homepage
|
|
3
3
|
// response and reduced to fixed signal identifiers plus bounded counts.
|
|
4
|
-
export const RESPONSE_POLICY_VERSION =
|
|
4
|
+
export const RESPONSE_POLICY_VERSION = 2;
|
|
5
5
|
export const MAX_RESPONSE_POLICY_HEADER_BYTES = 8 * 1024;
|
|
6
|
+
export const MAX_CSP_META_POLICIES = 4;
|
|
6
7
|
export const MAX_RESPONSE_POLICY_DIRECTIVES = 64;
|
|
7
8
|
export const MAX_RESPONSE_POLICY_TOKENS = 128;
|
|
8
9
|
export const MAX_RESPONSE_COOKIES = 32;
|
|
@@ -56,7 +57,7 @@ function unavailableHeaderState(header) {
|
|
|
56
57
|
}
|
|
57
58
|
function analyzeCsp(header, signals) {
|
|
58
59
|
if (header.state !== 'present' || !header.value) {
|
|
59
|
-
return unavailableHeaderState(header);
|
|
60
|
+
return { state: unavailableHeaderState(header), inlineControl: 'unknown' };
|
|
60
61
|
}
|
|
61
62
|
const directives = new Map();
|
|
62
63
|
let tokenCount = 0;
|
|
@@ -68,7 +69,7 @@ function analyzeCsp(header, signals) {
|
|
|
68
69
|
const [rawName = '', ...rawTokens] = normalized.split(/\s+/u);
|
|
69
70
|
const name = rawName.toLowerCase();
|
|
70
71
|
if (!DIRECTIVE_NAME_RE.test(name))
|
|
71
|
-
return 'malformed';
|
|
72
|
+
return { state: 'malformed', inlineControl: 'unknown' };
|
|
72
73
|
if (directives.has(name))
|
|
73
74
|
continue;
|
|
74
75
|
if (directives.size >= MAX_RESPONSE_POLICY_DIRECTIVES) {
|
|
@@ -83,7 +84,7 @@ function analyzeCsp(header, signals) {
|
|
|
83
84
|
directives.set(name, tokens);
|
|
84
85
|
}
|
|
85
86
|
if (!directives.size)
|
|
86
|
-
return 'malformed';
|
|
87
|
+
return { state: 'malformed', inlineControl: 'unknown' };
|
|
87
88
|
const defaultSources = directives.get('default-src');
|
|
88
89
|
const scriptSources = directives.get('script-src') || defaultSources;
|
|
89
90
|
if (!defaultSources)
|
|
@@ -92,6 +93,7 @@ function analyzeCsp(header, signals) {
|
|
|
92
93
|
addSignal(signals, 'csp_base_uri_missing');
|
|
93
94
|
if (!directives.has('object-src') && !defaultSources)
|
|
94
95
|
addSignal(signals, 'csp_object_source_unbounded');
|
|
96
|
+
let inlineControl = scriptSources ? 'restricted' : 'uncontrolled';
|
|
95
97
|
if (scriptSources) {
|
|
96
98
|
if (scriptSources.some((token) => ['*', 'http:', 'https:', 'data:', 'blob:'].includes(token))) {
|
|
97
99
|
addSignal(signals, 'csp_permissive_script_source');
|
|
@@ -101,9 +103,79 @@ function analyzeCsp(header, signals) {
|
|
|
101
103
|
if (scriptSources.includes("'unsafe-inline'")
|
|
102
104
|
&& !scriptSources.some((token) => CSP_NONCE_OR_HASH_RE.test(token))) {
|
|
103
105
|
addSignal(signals, 'csp_unsafe_inline');
|
|
106
|
+
inlineControl = 'unqualified';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
state: partial ? 'partial' : 'parsed',
|
|
111
|
+
inlineControl: partial ? 'unknown' : inlineControl,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export function analyzeCspMetaPolicies(value, sourceTruncated = false) {
|
|
115
|
+
const candidates = Array.isArray(value) ? value : [];
|
|
116
|
+
const policies = candidates.slice(0, MAX_CSP_META_POLICIES);
|
|
117
|
+
let policiesParsed = 0;
|
|
118
|
+
let inlineScriptConstrained = false;
|
|
119
|
+
let truncated = sourceTruncated || candidates.length > MAX_CSP_META_POLICIES;
|
|
120
|
+
for (const rawPolicy of policies) {
|
|
121
|
+
const policy = rawPolicy && typeof rawPolicy === 'object' && !Array.isArray(rawPolicy)
|
|
122
|
+
? rawPolicy
|
|
123
|
+
: null;
|
|
124
|
+
if (!policy || typeof policy.content !== 'string') {
|
|
125
|
+
truncated = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const policyContent = policy.content;
|
|
129
|
+
const content = readHeader({ get: () => policyContent }, 'content-security-policy');
|
|
130
|
+
const analysis = analyzeCsp(content, []);
|
|
131
|
+
if (analysis.state !== 'parsed') {
|
|
132
|
+
truncated = true;
|
|
133
|
+
continue;
|
|
104
134
|
}
|
|
135
|
+
policiesParsed += 1;
|
|
136
|
+
if (policy.beforeScript === true && analysis.inlineControl === 'restricted') {
|
|
137
|
+
inlineScriptConstrained = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
cspMetaPolicyVersion: 1,
|
|
142
|
+
policiesObserved: boundedCount(candidates.length, MAX_CSP_META_POLICIES),
|
|
143
|
+
policiesParsed,
|
|
144
|
+
inlineScriptConstrained,
|
|
145
|
+
truncated,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function qualifyResponsePolicyWithCspMeta(value, meta) {
|
|
149
|
+
if (!value || !meta || meta.cspMetaPolicyVersion !== 1)
|
|
150
|
+
return value;
|
|
151
|
+
const constrained = meta.inlineScriptConstrained
|
|
152
|
+
&& value.signals.some((signal) => signal.id === 'csp_unsafe_inline');
|
|
153
|
+
const signals = constrained
|
|
154
|
+
? [
|
|
155
|
+
...value.signals.filter((signal) => signal.id !== 'csp_unsafe_inline'),
|
|
156
|
+
{ id: 'csp_inline_constrained_by_meta' },
|
|
157
|
+
]
|
|
158
|
+
: [...value.signals];
|
|
159
|
+
const limitations = [...value.limitations];
|
|
160
|
+
if (meta.policiesObserved > 0) {
|
|
161
|
+
limitations.push(constrained
|
|
162
|
+
? 'A bounded CSP meta policy observed before page scripts further constrained inline script allowed by the response header.'
|
|
163
|
+
: 'CSP meta policy observations did not qualify the selected response-header finding; policies after scripts, malformed policies, and capped policies remain non-authoritative for this comparison.');
|
|
105
164
|
}
|
|
106
|
-
|
|
165
|
+
if (meta.truncated)
|
|
166
|
+
limitations.push('CSP meta policy analysis reached a configured count, position, or byte boundary.');
|
|
167
|
+
return {
|
|
168
|
+
...value,
|
|
169
|
+
signals: signals.slice(0, 16),
|
|
170
|
+
diagnostics: {
|
|
171
|
+
...value.diagnostics,
|
|
172
|
+
signalCount: Math.min(signals.length, 16),
|
|
173
|
+
cspMetaPoliciesObserved: boundedCount(meta.policiesObserved, MAX_CSP_META_POLICIES),
|
|
174
|
+
cspMetaPoliciesParsed: boundedCount(meta.policiesParsed, MAX_CSP_META_POLICIES),
|
|
175
|
+
cspMetaPoliciesTruncated: meta.truncated,
|
|
176
|
+
},
|
|
177
|
+
limitations,
|
|
178
|
+
};
|
|
107
179
|
}
|
|
108
180
|
function analyzeHsts(header, signals) {
|
|
109
181
|
if (header.state !== 'present' || !header.value) {
|
|
@@ -221,7 +293,7 @@ function analyzeCookies(headers, signals) {
|
|
|
221
293
|
}
|
|
222
294
|
export function analyzeResponsePolicyHeaders(headers) {
|
|
223
295
|
const signals = [];
|
|
224
|
-
const contentSecurityPolicy = analyzeCsp(readHeader(headers, 'content-security-policy'), signals);
|
|
296
|
+
const contentSecurityPolicy = analyzeCsp(readHeader(headers, 'content-security-policy'), signals).state;
|
|
225
297
|
const strictTransportSecurity = analyzeHsts(readHeader(headers, 'strict-transport-security'), signals);
|
|
226
298
|
const referrerPolicy = analyzeReferrerPolicy(readHeader(headers, 'referrer-policy'), signals);
|
|
227
299
|
const cookies = analyzeCookies(headers, signals);
|
|
@@ -250,6 +322,9 @@ export function analyzeResponsePolicyHeaders(headers) {
|
|
|
250
322
|
signalCount: Math.min(signals.length, 16),
|
|
251
323
|
cookieCount: boundedCount(cookies.count, MAX_RESPONSE_COOKIES),
|
|
252
324
|
cookiesTruncated: cookies.truncated,
|
|
325
|
+
cspMetaPoliciesObserved: 0,
|
|
326
|
+
cspMetaPoliciesParsed: 0,
|
|
327
|
+
cspMetaPoliciesTruncated: false,
|
|
253
328
|
},
|
|
254
329
|
limitations,
|
|
255
330
|
};
|
package/lib/risk-scoring.mjs
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import { calibrateExternalIntelligenceRisk } from "./external-intelligence-risk.mjs";
|
|
2
|
+
import { buildScoreEvidenceQuality, } from "./scoring-evidence-quality.mjs";
|
|
2
3
|
const RISK_STATES = new Set(['registered', 'for_sale', 'expiring']);
|
|
3
4
|
const STATE_LABELS = Object.freeze({
|
|
4
5
|
for_sale: 'for sale',
|
|
5
6
|
expiring: 'expiring/pending delete',
|
|
6
7
|
registered: 'registered',
|
|
7
8
|
});
|
|
8
|
-
// Version
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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;
|
|
9
|
+
// Version 7 caps each independent evidence family, treats generic operational
|
|
10
|
+
// properties as supporting context, and reports collection quality separately
|
|
11
|
+
// from the heuristic score. Missing evidence never contributes points.
|
|
12
|
+
export const RISK_MODEL_VERSION = 7;
|
|
15
13
|
export const RISK_REVIEW_THRESHOLD = 70;
|
|
16
14
|
const RISK_STATE_BASE = Object.freeze({
|
|
17
|
-
registered:
|
|
18
|
-
for_sale:
|
|
19
|
-
expiring:
|
|
15
|
+
registered: 6,
|
|
16
|
+
for_sale: 4,
|
|
17
|
+
expiring: 5,
|
|
18
|
+
});
|
|
19
|
+
const FAMILY_CAPS = Object.freeze({
|
|
20
|
+
'domain-resemblance': 20,
|
|
21
|
+
'brand-presentation': 24,
|
|
22
|
+
'credential-lure': 18,
|
|
23
|
+
'external-intelligence': 18,
|
|
24
|
+
'operational-support': 12,
|
|
20
25
|
});
|
|
21
26
|
const HIGH_CONTEXT_MUTATIONS = new Set([
|
|
22
27
|
'unicode_homoglyph',
|
|
@@ -49,7 +54,7 @@ export function normalizeRiskModelVersion(value) {
|
|
|
49
54
|
}
|
|
50
55
|
function formatAge(days) {
|
|
51
56
|
if (days < 60)
|
|
52
|
-
return `${days}d old`;
|
|
57
|
+
return `${Math.round(days)}d old`;
|
|
53
58
|
const years = days / 365.25;
|
|
54
59
|
return years < 1 ? `${Math.round(days / 30)}mo old` : `${years.toFixed(1)}y old`;
|
|
55
60
|
}
|
|
@@ -68,83 +73,201 @@ function mutationContext(mutationTypes) {
|
|
|
68
73
|
}
|
|
69
74
|
return null;
|
|
70
75
|
}
|
|
76
|
+
function hasBoolean(value) {
|
|
77
|
+
return typeof value === 'boolean';
|
|
78
|
+
}
|
|
79
|
+
function scoreQuality(input) {
|
|
80
|
+
const observedFamilies = ['registration'];
|
|
81
|
+
const expectedFamilies = ['registration', 'operational-support'];
|
|
82
|
+
if (Array.isArray(input.mutationTypes) || hasBoolean(input.idnReferenceMatch))
|
|
83
|
+
observedFamilies.push('domain-resemblance');
|
|
84
|
+
if (input.hasActiveBrandProfile === true)
|
|
85
|
+
expectedFamilies.push('domain-resemblance', 'brand-presentation');
|
|
86
|
+
if ([input.faviconMatch, input.faviconNearMatch, input.reusesOfficialAssets, input.pageBaselineMatch].some(hasBoolean)) {
|
|
87
|
+
observedFamilies.push('brand-presentation');
|
|
88
|
+
}
|
|
89
|
+
if ([input.hasPasswordField, input.hasExternalFormAction].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
|
|
90
|
+
observedFamilies.push('credential-lure');
|
|
91
|
+
}
|
|
92
|
+
if (input.threatIntelligence !== undefined && input.threatIntelligence !== null)
|
|
93
|
+
observedFamilies.push('external-intelligence');
|
|
94
|
+
if ([input.hasMx, input.hasSpf, input.hasDmarc].some(hasBoolean)
|
|
95
|
+
|| typeof input.activityStatus === 'string'
|
|
96
|
+
|| typeof input.domainAgeDays === 'number')
|
|
97
|
+
observedFamilies.push('operational-support');
|
|
98
|
+
if (input.scanDepth === 'deep')
|
|
99
|
+
expectedFamilies.push('credential-lure');
|
|
100
|
+
return buildScoreEvidenceQuality(input, {
|
|
101
|
+
expectedDepth: 'deep',
|
|
102
|
+
observedFamilies,
|
|
103
|
+
expectedFamilies,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
71
106
|
// Risk prioritizes a registered lookalike/typosquat domain for analyst review.
|
|
72
107
|
// It is a heuristic indicator, never a maliciousness or safety verdict.
|
|
73
|
-
export function explainRiskScore(
|
|
74
|
-
const state =
|
|
108
|
+
export function explainRiskScore(input) {
|
|
109
|
+
const state = input.availability ?? input.state;
|
|
75
110
|
if (typeof state !== 'string' || !RISK_STATES.has(state))
|
|
76
111
|
return null;
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
const factors = [];
|
|
113
|
+
const familyTotals = new Map();
|
|
114
|
+
const add = (family, label, requestedDelta) => {
|
|
115
|
+
const current = familyTotals.get(family) ?? 0;
|
|
116
|
+
const cap = FAMILY_CAPS[family] ?? Number.POSITIVE_INFINITY;
|
|
117
|
+
const delta = Math.max(0, Math.min(requestedDelta, Math.max(0, cap - current)));
|
|
118
|
+
const capped = delta !== requestedDelta;
|
|
119
|
+
factors.push({ family, label: capped ? `${label} (family cap reached)` : label, delta });
|
|
120
|
+
familyTotals.set(family, current + delta);
|
|
121
|
+
return delta;
|
|
122
|
+
};
|
|
123
|
+
add('registration', `Base context for “${STATE_LABELS[state] ?? state}”`, RISK_STATE_BASE[state] ?? 0);
|
|
124
|
+
const mutation = mutationContext(input.mutationTypes);
|
|
125
|
+
if (mutation)
|
|
126
|
+
add('domain-resemblance', mutation.label, mutation.delta);
|
|
127
|
+
if (input.idnReferenceMatch === true)
|
|
128
|
+
add('domain-resemblance', 'IDN skeleton matches an official Brand Profile domain', 20);
|
|
129
|
+
if (input.faviconMatch === true)
|
|
130
|
+
add('brand-presentation', 'Favicon matches an official Brand Profile site', 18);
|
|
131
|
+
else if (input.faviconNearMatch === true)
|
|
132
|
+
add('brand-presentation', 'Favicon resembles an official Brand Profile site', 14);
|
|
133
|
+
if (input.reusesOfficialAssets === true)
|
|
134
|
+
add('brand-presentation', 'Official asset-host relationship observed', 6);
|
|
135
|
+
if (input.pageBaselineMatch === true)
|
|
136
|
+
add('brand-presentation', 'Multiple independently compared page-identity components match the official baseline', 14);
|
|
137
|
+
if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
|
|
138
|
+
add('credential-lure', 'Suspicious urgency language observed', 8);
|
|
139
|
+
}
|
|
140
|
+
if (input.hasPasswordField === true && input.hasExternalFormAction === true) {
|
|
141
|
+
add('credential-lure', 'Password form submits to an external origin', 10);
|
|
142
|
+
}
|
|
143
|
+
else if (input.hasPasswordField === true) {
|
|
144
|
+
add('credential-lure', 'Login/password form present', 5);
|
|
145
|
+
}
|
|
146
|
+
else if (input.hasExternalFormAction === true) {
|
|
147
|
+
add('credential-lure', 'External form destination observed without a password field', 0);
|
|
148
|
+
}
|
|
149
|
+
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
|
|
150
|
+
if (external.factor)
|
|
151
|
+
add('external-intelligence', external.factor.label, external.contribution);
|
|
152
|
+
const primaryFamilies = ['domain-resemblance', 'brand-presentation', 'credential-lure', 'external-intelligence']
|
|
153
|
+
.filter((family) => (familyTotals.get(family) ?? 0) > 0);
|
|
154
|
+
if (primaryFamilies.length >= 2) {
|
|
155
|
+
add('corroboration', `Corroborating context across ${primaryFamilies.length} independent evidence families`, primaryFamilies.length >= 3 ? 18 : 10);
|
|
156
|
+
}
|
|
157
|
+
const hasIndependentContext = primaryFamilies.length > 0;
|
|
158
|
+
const supporting = (label, delta) => {
|
|
159
|
+
add('operational-support', hasIndependentContext ? label : `${label}; neutral without independent suspicious context`, hasIndependentContext ? delta : 0);
|
|
160
|
+
};
|
|
161
|
+
if (input.activityStatus === 'active')
|
|
162
|
+
supporting('Active website observed', 4);
|
|
163
|
+
if (input.hasMx === true)
|
|
164
|
+
supporting('Mail server configured', 4);
|
|
165
|
+
if (input.hasSpf === true && input.hasDmarc === true)
|
|
166
|
+
supporting('SPF and DMARC records present', 1);
|
|
167
|
+
else if (input.hasSpf === true || input.hasDmarc === true)
|
|
168
|
+
supporting('SPF or DMARC record present', 0);
|
|
169
|
+
if (typeof input.domainAgeDays === 'number' && Number.isFinite(input.domainAgeDays) && input.domainAgeDays >= 0) {
|
|
170
|
+
if (input.domainAgeDays < 90)
|
|
171
|
+
supporting(`Recently registered (${formatAge(input.domainAgeDays)})`, 6);
|
|
172
|
+
else if (input.domainAgeDays < 365)
|
|
173
|
+
supporting(`Registered under a year ago (${formatAge(input.domainAgeDays)})`, 2);
|
|
174
|
+
}
|
|
175
|
+
if (typeof input.privacyProtected === 'boolean') {
|
|
176
|
+
add('registration', 'Registration privacy is neutral context', 0);
|
|
177
|
+
}
|
|
178
|
+
const rawScore = Math.round(factors.reduce((total, factor) => total + factor.delta, 0));
|
|
179
|
+
const score = Math.max(0, Math.min(100, rawScore));
|
|
180
|
+
const familyOrder = [
|
|
181
|
+
'registration', 'domain-resemblance', 'brand-presentation', 'credential-lure',
|
|
182
|
+
'external-intelligence', 'corroboration', 'operational-support',
|
|
183
|
+
];
|
|
184
|
+
return {
|
|
185
|
+
modelVersion: RISK_MODEL_VERSION,
|
|
186
|
+
score,
|
|
187
|
+
rawScore,
|
|
188
|
+
capped: score !== rawScore,
|
|
189
|
+
factors,
|
|
190
|
+
families: familyOrder
|
|
191
|
+
.filter((id) => familyTotals.has(id))
|
|
192
|
+
.map((id) => ({ id, contribution: familyTotals.get(id) ?? 0, cap: FAMILY_CAPS[id] ?? null })),
|
|
193
|
+
evidenceQuality: scoreQuality(input),
|
|
85
194
|
};
|
|
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
195
|
}
|
|
146
|
-
export function computeRiskScore(
|
|
147
|
-
return explainRiskScore(
|
|
196
|
+
export function computeRiskScore(input) {
|
|
197
|
+
return explainRiskScore(input)?.score ?? null;
|
|
198
|
+
}
|
|
199
|
+
// Retained only for deterministic offline comparison of reviewed calibration
|
|
200
|
+
// datasets. Runtime lookups always use the current model above.
|
|
201
|
+
export function explainRiskScoreV6(input) {
|
|
202
|
+
const state = input.availability ?? input.state;
|
|
203
|
+
if (typeof state !== 'string' || !RISK_STATES.has(state))
|
|
204
|
+
return null;
|
|
205
|
+
const legacyBase = { registered: 10, for_sale: 5, expiring: 8 };
|
|
206
|
+
const factors = [];
|
|
207
|
+
const totals = new Map();
|
|
208
|
+
const add = (family, label, delta) => {
|
|
209
|
+
factors.push({ family, label, delta });
|
|
210
|
+
totals.set(family, (totals.get(family) ?? 0) + delta);
|
|
211
|
+
};
|
|
212
|
+
add('registration', `Base score for “${STATE_LABELS[state] ?? state}”`, legacyBase[state] ?? 0);
|
|
213
|
+
const contextual = new Set();
|
|
214
|
+
const context = mutationContext(input.mutationTypes);
|
|
215
|
+
if (context) {
|
|
216
|
+
add('domain-resemblance', context.label, context.delta);
|
|
217
|
+
contextual.add('domain-resemblance');
|
|
218
|
+
}
|
|
219
|
+
if (input.faviconMatch === true) {
|
|
220
|
+
add('brand-presentation', 'Favicon matches an official Brand Profile site', 18);
|
|
221
|
+
contextual.add('brand-presentation');
|
|
222
|
+
}
|
|
223
|
+
else if (input.faviconNearMatch === true) {
|
|
224
|
+
add('brand-presentation', 'Favicon resembles an official Brand Profile site', 14);
|
|
225
|
+
contextual.add('brand-presentation');
|
|
226
|
+
}
|
|
227
|
+
if (input.reusesOfficialAssets === true) {
|
|
228
|
+
add('brand-presentation', 'Official asset-host relationship observed', 6);
|
|
229
|
+
contextual.add('brand-presentation');
|
|
230
|
+
}
|
|
231
|
+
if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
|
|
232
|
+
add('credential-lure', 'Suspicious urgency language observed', 8);
|
|
233
|
+
contextual.add('credential-lure');
|
|
234
|
+
}
|
|
235
|
+
if (input.hasPasswordField === true) {
|
|
236
|
+
add('credential-lure', 'Login/password form present', 5);
|
|
237
|
+
contextual.add('credential-lure');
|
|
238
|
+
}
|
|
239
|
+
if (contextual.size >= 2)
|
|
240
|
+
add('corroboration', `Corroborating context across ${contextual.size} distinct evidence families`, contextual.size >= 3 ? 20 : 10);
|
|
241
|
+
const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
|
|
242
|
+
if (external.factor)
|
|
243
|
+
add('external-intelligence', external.factor.label, external.contribution);
|
|
244
|
+
if (input.activityStatus === 'active')
|
|
245
|
+
add('operational-support', 'Active site in use', 8);
|
|
246
|
+
if (input.hasMx === true)
|
|
247
|
+
add('operational-support', 'Mail server configured', 8);
|
|
248
|
+
if (input.hasSpf === true && input.hasDmarc === true)
|
|
249
|
+
add('operational-support', 'SPF and DMARC records present', 3);
|
|
250
|
+
else if (input.hasSpf === true || input.hasDmarc === true)
|
|
251
|
+
add('operational-support', 'SPF or DMARC record present', 1);
|
|
252
|
+
if (input.privacyProtected === true)
|
|
253
|
+
add('registration', 'WHOIS privacy protected', 3);
|
|
254
|
+
if (typeof input.domainAgeDays === 'number' && Number.isFinite(input.domainAgeDays) && input.domainAgeDays >= 0) {
|
|
255
|
+
if (input.domainAgeDays < 90)
|
|
256
|
+
add('operational-support', `Recently registered (${formatAge(input.domainAgeDays)})`, 10);
|
|
257
|
+
else if (input.domainAgeDays < 365)
|
|
258
|
+
add('operational-support', `Registered under a year ago (${formatAge(input.domainAgeDays)})`, 4);
|
|
259
|
+
}
|
|
260
|
+
const rawScore = Math.round(factors.reduce((total, factor) => total + factor.delta, 0));
|
|
261
|
+
const score = Math.max(0, Math.min(100, rawScore));
|
|
262
|
+
return {
|
|
263
|
+
modelVersion: 6,
|
|
264
|
+
score,
|
|
265
|
+
rawScore,
|
|
266
|
+
capped: score !== rawScore,
|
|
267
|
+
factors,
|
|
268
|
+
families: [...totals].map(([id, contribution]) => ({ id, contribution, cap: null })),
|
|
269
|
+
evidenceQuality: scoreQuality(input),
|
|
270
|
+
};
|
|
148
271
|
}
|
|
149
272
|
export function riskTone(score) {
|
|
150
273
|
if (score === null)
|
|
@@ -159,6 +282,6 @@ export function formatScoreBreakdown(explained, separator = '\n') {
|
|
|
159
282
|
if (!explained)
|
|
160
283
|
return '';
|
|
161
284
|
const parts = explained.factors.map((factor) => `${factor.label} ${factor.delta >= 0 ? '+' : ''}${Math.round(factor.delta)}`);
|
|
162
|
-
parts.push(`Total ${explained.score}`);
|
|
285
|
+
parts.push(`Total ${explained.score} · Risk model v${explained.modelVersion} · evidence ${explained.evidenceQuality.state}`);
|
|
163
286
|
return parts.join(separator);
|
|
164
287
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parseIpPrefix, prefixContains } from "./ip-prefix.mjs";
|
|
2
|
+
const RPKI_EVIDENCE_SCHEMA = 'whoisleuth.rpki-route-review';
|
|
3
|
+
const RPKI_EVIDENCE_VERSION = 1;
|
|
4
|
+
const MAX_RPKI_RECORDS = 100_000;
|
|
5
|
+
const MAX_RPKI_MATCHES = 50;
|
|
6
|
+
function record(value) {
|
|
7
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: null;
|
|
10
|
+
}
|
|
11
|
+
function normalizeAsn(value) {
|
|
12
|
+
const text = typeof value === 'string' ? value.trim().replace(/^AS/iu, '') : value;
|
|
13
|
+
const number = Number(text);
|
|
14
|
+
return Number.isInteger(number) && number >= 0 && number <= 4_294_967_295 ? number : null;
|
|
15
|
+
}
|
|
16
|
+
function normalizeAuthorization(value) {
|
|
17
|
+
const source = record(value);
|
|
18
|
+
if (!source)
|
|
19
|
+
return null;
|
|
20
|
+
const prefix = typeof source.prefix === 'string' ? source.prefix : typeof source.route === 'string' ? source.route : '';
|
|
21
|
+
const parsedPrefix = parseIpPrefix(prefix);
|
|
22
|
+
const asn = normalizeAsn(source.asn ?? source.asID ?? source.origin);
|
|
23
|
+
const maxLengthValue = source.maxLength ?? source.max_length ?? parsedPrefix?.length;
|
|
24
|
+
const maxLength = Number(maxLengthValue);
|
|
25
|
+
const maximum = parsedPrefix?.family === 4 ? 32 : 128;
|
|
26
|
+
if (!parsedPrefix || asn === null || !Number.isInteger(maxLength)
|
|
27
|
+
|| maxLength < parsedPrefix.length || maxLength > maximum)
|
|
28
|
+
return null;
|
|
29
|
+
return Object.freeze({ prefix: `${parsedPrefix.address}/${parsedPrefix.length}`, parsedPrefix, maxLength, asn });
|
|
30
|
+
}
|
|
31
|
+
function authorizationRows(value) {
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value;
|
|
34
|
+
const source = record(value);
|
|
35
|
+
if (!source)
|
|
36
|
+
return null;
|
|
37
|
+
if (Array.isArray(source.roas))
|
|
38
|
+
return source.roas;
|
|
39
|
+
if (Array.isArray(source.vrps))
|
|
40
|
+
return source.vrps;
|
|
41
|
+
if (Array.isArray(source.validated_route_origins))
|
|
42
|
+
return source.validated_route_origins;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function reviewRpkiRoute(input) {
|
|
46
|
+
const route = parseIpPrefix(input.routePrefix);
|
|
47
|
+
const originAsn = normalizeAsn(input.originAsn);
|
|
48
|
+
const rows = authorizationRows(input.authorizations);
|
|
49
|
+
const baseLimitations = Object.freeze([
|
|
50
|
+
'This offline review evaluates an explicitly supplied route prefix and origin ASN against an analyst-supplied VRP snapshot.',
|
|
51
|
+
'It does not collect BGP announcements, establish route ownership, or prove that the snapshot was current or complete.',
|
|
52
|
+
]);
|
|
53
|
+
if (!route || originAsn === null || !rows) {
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
schema: RPKI_EVIDENCE_SCHEMA,
|
|
56
|
+
version: RPKI_EVIDENCE_VERSION,
|
|
57
|
+
state: 'invalid_input',
|
|
58
|
+
routePrefix: null,
|
|
59
|
+
originAsn,
|
|
60
|
+
coveringAuthorizationCount: 0,
|
|
61
|
+
matchingAuthorizationCount: 0,
|
|
62
|
+
rejectedCount: 0,
|
|
63
|
+
truncated: false,
|
|
64
|
+
matches: Object.freeze([]),
|
|
65
|
+
limitations: baseLimitations,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const truncated = rows.length > MAX_RPKI_RECORDS;
|
|
69
|
+
const normalized = rows.slice(0, MAX_RPKI_RECORDS).map(normalizeAuthorization);
|
|
70
|
+
const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, rows.length - MAX_RPKI_RECORDS);
|
|
71
|
+
const authorizations = normalized.filter((item) => item !== null);
|
|
72
|
+
const covering = authorizations.filter((item) => prefixContains(item.parsedPrefix, route));
|
|
73
|
+
const matches = covering
|
|
74
|
+
.sort((left, right) => right.parsedPrefix.length - left.parsedPrefix.length || left.asn - right.asn)
|
|
75
|
+
.slice(0, MAX_RPKI_MATCHES)
|
|
76
|
+
.map((item) => Object.freeze({
|
|
77
|
+
prefix: item.prefix,
|
|
78
|
+
maxLength: item.maxLength,
|
|
79
|
+
asn: item.asn,
|
|
80
|
+
state: item.asn !== originAsn ? 'asn_mismatch'
|
|
81
|
+
: route.length > item.maxLength ? 'max_length_exceeded'
|
|
82
|
+
: 'valid',
|
|
83
|
+
}));
|
|
84
|
+
const matchingAuthorizationCount = matches.filter((item) => item.state === 'valid').length;
|
|
85
|
+
const state = covering.length === 0
|
|
86
|
+
? 'not_found'
|
|
87
|
+
: matchingAuthorizationCount > 0
|
|
88
|
+
? truncated || rejectedCount > 0 || covering.length > MAX_RPKI_MATCHES ? 'partial'
|
|
89
|
+
: 'valid'
|
|
90
|
+
: 'invalid';
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
schema: RPKI_EVIDENCE_SCHEMA,
|
|
93
|
+
version: RPKI_EVIDENCE_VERSION,
|
|
94
|
+
state,
|
|
95
|
+
routePrefix: `${route.address}/${route.length}`,
|
|
96
|
+
originAsn,
|
|
97
|
+
coveringAuthorizationCount: covering.length,
|
|
98
|
+
matchingAuthorizationCount,
|
|
99
|
+
rejectedCount,
|
|
100
|
+
truncated,
|
|
101
|
+
matches: Object.freeze(matches),
|
|
102
|
+
limitations: baseLimitations,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export { MAX_RPKI_RECORDS, RPKI_EVIDENCE_SCHEMA, RPKI_EVIDENCE_VERSION, reviewRpkiRoute, };
|
package/lib/safe-fetch.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import { promises as dns } from 'node:dns';
|
|
|
22
22
|
import * as net from 'node:net';
|
|
23
23
|
import * as crypto from 'node:crypto';
|
|
24
24
|
import { Agent, fetch as undiciFetch } from 'undici';
|
|
25
|
-
|
|
25
|
+
import { MAX_OUTBOUND_REDIRECTS } from "./outbound-request-bounds.mjs";
|
|
26
|
+
const MAX_REDIRECTS = MAX_OUTBOUND_REDIRECTS;
|
|
26
27
|
const MAX_SAFE_FETCH_URL_LENGTH = 4096;
|
|
27
28
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
28
29
|
async function defaultSafeFetchRequest(url, options) {
|