@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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/README.md +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- 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 +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- 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 +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- 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-session-model.js +7 -2
- 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-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- 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 +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -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 +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- 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 +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
package/lib/html-signals.mjs
CHANGED
|
@@ -9,13 +9,13 @@ import { analyzeCredentialSurfaceProfile } from "./credential-surface-profile.mj
|
|
|
9
9
|
import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
|
|
10
10
|
import { createObservation } from "./observation.mjs";
|
|
11
11
|
import { createPageFingerprints } from "./page-fingerprints.mjs";
|
|
12
|
+
import { detectPageLanguageSignal } from "./page-language-signals.mjs";
|
|
12
13
|
import { analyzePageRole } from "./page-role-profile.mjs";
|
|
13
14
|
import { analyzeCspMetaPolicies } from "./response-policy.mjs";
|
|
14
15
|
import { analyzeStaticHtml } from "./static-html-analysis.mjs";
|
|
15
16
|
import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
|
|
16
17
|
import { analyzeWebsiteTechnology } from "./website-technology.mjs";
|
|
17
18
|
const MAX_TITLE_LENGTH = 200;
|
|
18
|
-
const MAX_PHISHING_MATCH_LENGTH = 200;
|
|
19
19
|
const MAX_EXTERNAL_ASSET_HOSTS = 20;
|
|
20
20
|
const MAX_IDENTITY_TAGS = 512;
|
|
21
21
|
const MAX_IDENTITY_TAG_LENGTH = 4096;
|
|
@@ -40,12 +40,6 @@ const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
|
|
40
40
|
// A password input is the single strongest static-HTML tell that a page is
|
|
41
41
|
// asking for credentials, regardless of what it claims to be.
|
|
42
42
|
const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
|
|
43
|
-
// Common urgency/social-engineering phrasing used to pressure a visitor
|
|
44
|
-
// into entering credentials - not exhaustive, and legitimate sites
|
|
45
|
-
// occasionally use similar language too, so this is one signal among
|
|
46
|
-
// several, not a verdict on its own (same framing as FOR_SALE_TEXT_RE in
|
|
47
|
-
// availability.js).
|
|
48
|
-
const PHISHING_LANGUAGE_RE = /(verify your account|confirm your identity|unusual (?:sign-?in|login) activity|account has been (?:suspended|limited|locked|restricted)|your account will be (?:suspended|closed|locked|terminated)|click here to (?:verify|confirm|update|restore)|security alert|immediate action required|re-?activate your account|unauthorized access detected|update your (?:payment|billing) (?:information|details)|confirm your password|your password (?:has expired|will expire soon)|connect your wallet|enter your (?:seed|recovery) phrase|verify your (?:seed|recovery) phrase|enter your private key)/i;
|
|
49
43
|
// <img>/<script>/<link> tags loading a resource from an absolute, external
|
|
50
44
|
// URL - a common phishing-kit tell is hotlinking the real brand's own logo/
|
|
51
45
|
// CSS/JS instead of copying it. Relative URLs (the common case, same
|
|
@@ -625,7 +619,6 @@ function extractPageIdentity(html, domain, options = {}) {
|
|
|
625
619
|
};
|
|
626
620
|
}
|
|
627
621
|
function extractHtmlSignals(html, domain, options = {}) {
|
|
628
|
-
const phishingMatch = html.match(PHISHING_LANGUAGE_RE);
|
|
629
622
|
const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, options);
|
|
630
623
|
const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
|
|
631
624
|
const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
|
|
@@ -633,12 +626,13 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
633
626
|
const includeDerivedPageProfiles = Boolean(pageIdentity);
|
|
634
627
|
const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
|
|
635
628
|
const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
|
|
636
|
-
? analyzeStaticHtml(html, { baseUrl })
|
|
629
|
+
? analyzeStaticHtml(html, { baseUrl, includeVisibleText: true })
|
|
637
630
|
: null;
|
|
631
|
+
const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis ?? undefined);
|
|
638
632
|
return {
|
|
639
633
|
pageTitle: extractPageTitle(html),
|
|
640
634
|
hasPasswordField: PASSWORD_FIELD_RE.test(html),
|
|
641
|
-
phishingLanguageMatch:
|
|
635
|
+
phishingLanguageMatch: pageLanguageSignal?.label ?? null,
|
|
642
636
|
hasExternalFormAction: pageIdentity
|
|
643
637
|
? pageIdentity.forms.externalActionOrigins.length > 0
|
|
644
638
|
: null,
|
|
@@ -681,4 +675,4 @@ function extractHtmlSignals(html, domain, options = {}) {
|
|
|
681
675
|
}) : null,
|
|
682
676
|
};
|
|
683
677
|
}
|
|
684
|
-
export { PAGE_IDENTITY_VERSION, MAX_IDENTITY_TAGS, MAX_FORMS, MAX_FORM_ACTION_ORIGINS, MAX_RESOURCE_TAGS, MAX_RESOURCE_ORIGINS, MAX_EMBEDDED_ORIGINS, MAX_CONTACT_DOMAINS, MAX_TRACKING_IDENTIFIERS, extractHtmlSignals, extractPageIdentity,
|
|
678
|
+
export { PAGE_IDENTITY_VERSION, MAX_IDENTITY_TAGS, MAX_FORMS, MAX_FORM_ACTION_ORIGINS, MAX_RESOURCE_TAGS, MAX_RESOURCE_ORIGINS, MAX_EMBEDDED_ORIGINS, MAX_CONTACT_DOMAINS, MAX_TRACKING_IDENTIFIERS, extractHtmlSignals, extractPageIdentity, };
|
package/lib/ip-prefix.mjs
CHANGED
|
@@ -51,4 +51,37 @@ function prefixContains(container, candidate) {
|
|
|
51
51
|
&& container.length <= candidate.length
|
|
52
52
|
&& (candidate.value & container.mask) === container.value;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
function formatIpPrefix(prefix) {
|
|
55
|
+
if (prefix.family === 4) {
|
|
56
|
+
const parts = [24n, 16n, 8n, 0n].map((shift) => Number((prefix.value >> shift) & 255n));
|
|
57
|
+
return `${parts.join('.')}/${prefix.length}`;
|
|
58
|
+
}
|
|
59
|
+
const parts = Array.from({ length: 8 }, (_, index) => {
|
|
60
|
+
const shift = BigInt((7 - index) * 16);
|
|
61
|
+
return ((prefix.value >> shift) & 0xffffn).toString(16);
|
|
62
|
+
});
|
|
63
|
+
let bestStart = -1;
|
|
64
|
+
let bestLength = 0;
|
|
65
|
+
for (let start = 0; start < parts.length;) {
|
|
66
|
+
if (parts[start] !== '0') {
|
|
67
|
+
start += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
let end = start + 1;
|
|
71
|
+
while (end < parts.length && parts[end] === '0')
|
|
72
|
+
end += 1;
|
|
73
|
+
const length = end - start;
|
|
74
|
+
if (length > bestLength && length >= 2) {
|
|
75
|
+
bestStart = start;
|
|
76
|
+
bestLength = length;
|
|
77
|
+
}
|
|
78
|
+
start = end;
|
|
79
|
+
}
|
|
80
|
+
if (bestStart === -1)
|
|
81
|
+
return `${parts.join(':')}/${prefix.length}`;
|
|
82
|
+
const left = parts.slice(0, bestStart).join(':');
|
|
83
|
+
const right = parts.slice(bestStart + bestLength).join(':');
|
|
84
|
+
const address = left && right ? `${left}::${right}` : left ? `${left}::` : right ? `::${right}` : '::';
|
|
85
|
+
return `${address}/${prefix.length}`;
|
|
86
|
+
}
|
|
87
|
+
export { parseIpPrefix, prefixContains, formatIpPrefix };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isIP } from 'node:net';
|
|
2
|
-
import { parseIpPrefix, prefixContains } from "./ip-prefix.mjs";
|
|
2
|
+
import { formatIpPrefix, parseIpPrefix, prefixContains } from "./ip-prefix.mjs";
|
|
3
3
|
const LOCAL_GEOIP_SCHEMA = 'whoisleuth.local-geoip-evidence';
|
|
4
4
|
const LOCAL_GEOIP_VERSION = 1;
|
|
5
5
|
const MAX_GEOIP_RECORDS = 100_000;
|
|
@@ -25,7 +25,7 @@ function normalizeGeoIpEntry(value) {
|
|
|
25
25
|
if (asnValue !== null && (!Number.isInteger(asnValue) || asnValue < 0 || asnValue > 4_294_967_295))
|
|
26
26
|
return null;
|
|
27
27
|
return Object.freeze({
|
|
28
|
-
network:
|
|
28
|
+
network: formatIpPrefix(parsedNetwork),
|
|
29
29
|
parsedNetwork,
|
|
30
30
|
countryCode: country,
|
|
31
31
|
region: boundedText(source.region, 120),
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Shared HTTP response boundary for the unified Lookup API.
|
|
2
|
+
//
|
|
3
|
+
// The backend owns the evidence collectors, but an HTTP response is still an
|
|
4
|
+
// untrusted value when it reaches the browser. This module keeps the response
|
|
5
|
+
// envelope typed and validates the small set of fields the Lookup controller
|
|
6
|
+
// relies on before any evidence is derived or rendered. Nested source records
|
|
7
|
+
// stay separately attributed and additive; their own normalizers remain the
|
|
8
|
+
// authority for source-specific fields.
|
|
9
|
+
const INVALID_LOOKUP_RESPONSE = 'INVALID_LOOKUP_RESPONSE';
|
|
10
|
+
const INVALID_LOOKUP_RESPONSE_MESSAGE = 'Lookup returned an invalid response.';
|
|
11
|
+
const INVALID_COMPACT_LOOKUP_RESPONSE = 'INVALID_COMPACT_LOOKUP_RESPONSE';
|
|
12
|
+
const INVALID_COMPACT_LOOKUP_RESPONSE_MESSAGE = 'Bulk lookup returned an invalid response.';
|
|
13
|
+
const MAX_LOOKUP_RESPONSE_QUERY_LENGTH = 4096;
|
|
14
|
+
const MAX_LOOKUP_RESPONSE_HOST_LENGTH = 253;
|
|
15
|
+
const MAX_LOOKUP_RESPONSE_TOP_LEVEL_KEYS = 32;
|
|
16
|
+
const MAX_LOOKUP_RESPONSE_ERROR_LENGTH = 240;
|
|
17
|
+
const MAX_COMPACT_LOOKUP_RESPONSE_TOP_LEVEL_KEYS = 8;
|
|
18
|
+
const MAX_COMPACT_LOOKUP_AVAILABILITY_KEYS = 128;
|
|
19
|
+
const MAX_COMPACT_LOOKUP_DIAGNOSTIC_KEYS = 16;
|
|
20
|
+
const MAX_COMPACT_BULK_TECHNOLOGY_IDS = 12;
|
|
21
|
+
const MAX_THREAT_INTELLIGENCE_PROVIDERS = 10;
|
|
22
|
+
const MAX_LOOKUP_TIMING_MS = 120_000;
|
|
23
|
+
const MAX_LOOKUP_TIMING_SOURCES = 10;
|
|
24
|
+
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/u;
|
|
25
|
+
const QUERY_TYPES = new Set(['domain', 'ipv4', 'ipv6', 'asn']);
|
|
26
|
+
const COMPACT_AVAILABILITY_STATES = new Set([
|
|
27
|
+
'available',
|
|
28
|
+
'expiring',
|
|
29
|
+
'for_sale',
|
|
30
|
+
'registered',
|
|
31
|
+
'unknown',
|
|
32
|
+
]);
|
|
33
|
+
const COMPACT_CONFIDENCE_LEVELS = new Set(['high', 'low', 'medium']);
|
|
34
|
+
const COMPACT_AVAILABILITY_DIAGNOSTIC_STATES = new Set(['complete', 'disabled', 'error']);
|
|
35
|
+
const COMPACT_BULK_COMPARISON_STATES = new Set([
|
|
36
|
+
'error',
|
|
37
|
+
'not_found',
|
|
38
|
+
'partial',
|
|
39
|
+
'success',
|
|
40
|
+
'unavailable',
|
|
41
|
+
]);
|
|
42
|
+
const COMPACT_BULK_TECHNOLOGY_ID_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/u;
|
|
43
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
44
|
+
const LOOKUP_TIMING_SOURCES = new Set([
|
|
45
|
+
'rdap',
|
|
46
|
+
'whois',
|
|
47
|
+
'domain_evidence',
|
|
48
|
+
'reverse_dns',
|
|
49
|
+
'registrar_rdap',
|
|
50
|
+
'network_context',
|
|
51
|
+
'security_txt',
|
|
52
|
+
'external_intelligence',
|
|
53
|
+
'malware_host_intelligence',
|
|
54
|
+
'malware_ioc_intelligence',
|
|
55
|
+
]);
|
|
56
|
+
const LOOKUP_TIMING_OUTCOMES = new Set(['fulfilled', 'rejected']);
|
|
57
|
+
const EMPTY_RECORD = Object.freeze({});
|
|
58
|
+
function isJsonObject(value) {
|
|
59
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
function record(value) {
|
|
62
|
+
return isJsonObject(value) ? value : EMPTY_RECORD;
|
|
63
|
+
}
|
|
64
|
+
function optionalBoundedText(value, maxLength) {
|
|
65
|
+
return value === undefined || (typeof value === 'string'
|
|
66
|
+
&& value.length <= maxLength
|
|
67
|
+
&& !CONTROL_CHAR_RE.test(value));
|
|
68
|
+
}
|
|
69
|
+
function normalizedDomain(value) {
|
|
70
|
+
if (typeof value !== 'string'
|
|
71
|
+
|| !value.trim()
|
|
72
|
+
|| value.length > MAX_LOOKUP_RESPONSE_HOST_LENGTH
|
|
73
|
+
|| CONTROL_CHAR_RE.test(value)
|
|
74
|
+
|| /[\s/?#@\\:]/u.test(value)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = new URL(`https://${value.trim().replace(/\.$/u, '')}/`);
|
|
79
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
80
|
+
const labels = hostname.split('.');
|
|
81
|
+
return labels.length >= 2
|
|
82
|
+
&& hostname.length <= MAX_LOOKUP_RESPONSE_HOST_LENGTH
|
|
83
|
+
&& labels.every((label) => (label.length <= 63
|
|
84
|
+
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label)))
|
|
85
|
+
? hostname
|
|
86
|
+
: null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function compactDomainMatches(value, expectedDomain) {
|
|
93
|
+
const domain = normalizedDomain(value);
|
|
94
|
+
const expected = normalizedDomain(expectedDomain);
|
|
95
|
+
return Boolean(domain && expected && (domain === expected || expected.endsWith(`.${domain}`)));
|
|
96
|
+
}
|
|
97
|
+
function validCompactBulkComparison(value) {
|
|
98
|
+
if (!isJsonObject(value)
|
|
99
|
+
|| Object.keys(value).length > 3
|
|
100
|
+
|| value.version !== 1
|
|
101
|
+
|| !isJsonObject(value.technology)
|
|
102
|
+
|| Object.keys(value.technology).length > 3
|
|
103
|
+
|| typeof value.technology.state !== 'string'
|
|
104
|
+
|| !COMPACT_BULK_COMPARISON_STATES.has(value.technology.state)
|
|
105
|
+
|| !Array.isArray(value.technology.ids)
|
|
106
|
+
|| value.technology.ids.length > MAX_COMPACT_BULK_TECHNOLOGY_IDS
|
|
107
|
+
|| typeof value.technology.truncated !== 'boolean'
|
|
108
|
+
|| !isJsonObject(value.tls)
|
|
109
|
+
|| Object.keys(value.tls).length > 3
|
|
110
|
+
|| typeof value.tls.state !== 'string'
|
|
111
|
+
|| !COMPACT_BULK_COMPARISON_STATES.has(value.tls.state)
|
|
112
|
+
|| (value.tls.issuerLabel !== null && !optionalBoundedText(value.tls.issuerLabel, 240))
|
|
113
|
+
|| (value.tls.spkiSha256 !== null
|
|
114
|
+
&& (typeof value.tls.spkiSha256 !== 'string' || !SHA256_RE.test(value.tls.spkiSha256)))) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const ids = value.technology.ids;
|
|
118
|
+
return ids.every((id) => typeof id === 'string' && COMPACT_BULK_TECHNOLOGY_ID_RE.test(id))
|
|
119
|
+
&& new Set(ids).size === ids.length;
|
|
120
|
+
}
|
|
121
|
+
function invalidLookupResponse() {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
errorCode: INVALID_LOOKUP_RESPONSE,
|
|
125
|
+
error: INVALID_LOOKUP_RESPONSE_MESSAGE,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function invalidCompactLookupResponse() {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
errorCode: INVALID_COMPACT_LOOKUP_RESPONSE,
|
|
132
|
+
error: INVALID_COMPACT_LOOKUP_RESPONSE_MESSAGE,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function parseLookupHttpResponse(value) {
|
|
136
|
+
if (!isJsonObject(value) || Object.keys(value).length > MAX_LOOKUP_RESPONSE_TOP_LEVEL_KEYS) {
|
|
137
|
+
return invalidLookupResponse();
|
|
138
|
+
}
|
|
139
|
+
if (typeof value.query !== 'string'
|
|
140
|
+
|| !value.query.trim()
|
|
141
|
+
|| value.query.length > MAX_LOOKUP_RESPONSE_QUERY_LENGTH
|
|
142
|
+
|| CONTROL_CHAR_RE.test(value.query)
|
|
143
|
+
|| typeof value.type !== 'string'
|
|
144
|
+
|| !QUERY_TYPES.has(value.type)
|
|
145
|
+
|| !isJsonObject(value.rdap)
|
|
146
|
+
|| !isJsonObject(value.whois)
|
|
147
|
+
|| !isJsonObject(value.availability)
|
|
148
|
+
|| !isJsonObject(value.diagnostics)
|
|
149
|
+
|| !optionalBoundedText(value.inputHostname, MAX_LOOKUP_RESPONSE_HOST_LENGTH)
|
|
150
|
+
|| !optionalBoundedText(value.registrableDomain, MAX_LOOKUP_RESPONSE_HOST_LENGTH)
|
|
151
|
+
|| (value.isSubdomain !== undefined && typeof value.isSubdomain !== 'boolean')) {
|
|
152
|
+
return invalidLookupResponse();
|
|
153
|
+
}
|
|
154
|
+
for (const key of ['reverseDns', 'networkContext', 'securityTxt', 'sslbl', 'threatIntelligence', 'registryInsights']) {
|
|
155
|
+
const section = value[key];
|
|
156
|
+
if (section !== undefined && !isJsonObject(section))
|
|
157
|
+
return invalidLookupResponse();
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, value: value };
|
|
160
|
+
}
|
|
161
|
+
function parseCompactLookupHttpResponse(value, expectedDomain) {
|
|
162
|
+
if (!isJsonObject(value) || Object.keys(value).length > MAX_COMPACT_LOOKUP_RESPONSE_TOP_LEVEL_KEYS) {
|
|
163
|
+
return invalidCompactLookupResponse();
|
|
164
|
+
}
|
|
165
|
+
const availability = value.availability;
|
|
166
|
+
const diagnostics = value.diagnostics;
|
|
167
|
+
if (!isJsonObject(availability)
|
|
168
|
+
|| Object.keys(availability).length > MAX_COMPACT_LOOKUP_AVAILABILITY_KEYS
|
|
169
|
+
|| availability.applicable !== true
|
|
170
|
+
|| !compactDomainMatches(availability.domain, expectedDomain)
|
|
171
|
+
|| typeof availability.state !== 'string'
|
|
172
|
+
|| !COMPACT_AVAILABILITY_STATES.has(availability.state)
|
|
173
|
+
|| typeof availability.confidence !== 'string'
|
|
174
|
+
|| !COMPACT_CONFIDENCE_LEVELS.has(availability.confidence)
|
|
175
|
+
|| (availability.deepScanComplete !== undefined && typeof availability.deepScanComplete !== 'boolean')
|
|
176
|
+
|| (availability.bulkComparison !== undefined
|
|
177
|
+
&& !validCompactBulkComparison(availability.bulkComparison))
|
|
178
|
+
|| (value.query !== undefined && !compactDomainMatches(value.query, expectedDomain))
|
|
179
|
+
|| (value.type !== undefined && value.type !== 'domain')
|
|
180
|
+
|| (value.inputHostname !== undefined && !compactDomainMatches(value.inputHostname, expectedDomain))
|
|
181
|
+
|| (value.registrableDomain !== undefined
|
|
182
|
+
&& normalizedDomain(value.registrableDomain) !== normalizedDomain(availability.domain))
|
|
183
|
+
|| (value.isSubdomain !== undefined && typeof value.isSubdomain !== 'boolean')
|
|
184
|
+
|| !isJsonObject(diagnostics)
|
|
185
|
+
|| Object.keys(diagnostics).length > MAX_COMPACT_LOOKUP_DIAGNOSTIC_KEYS
|
|
186
|
+
|| diagnostics.version !== 7
|
|
187
|
+
|| !isJsonObject(diagnostics.rdap)
|
|
188
|
+
|| !isJsonObject(diagnostics.whois)
|
|
189
|
+
|| !isJsonObject(diagnostics.availability)
|
|
190
|
+
|| typeof diagnostics.availability.status !== 'string'
|
|
191
|
+
|| !COMPACT_AVAILABILITY_DIAGNOSTIC_STATES.has(diagnostics.availability.status)) {
|
|
192
|
+
return invalidCompactLookupResponse();
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, value: value };
|
|
195
|
+
}
|
|
196
|
+
function lookupHttpErrorMessage(value, status) {
|
|
197
|
+
const source = record(value);
|
|
198
|
+
const message = typeof source.error === 'string'
|
|
199
|
+
? source.error
|
|
200
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, ' ')
|
|
201
|
+
.replace(/\s+/gu, ' ')
|
|
202
|
+
.trim()
|
|
203
|
+
.slice(0, MAX_LOOKUP_RESPONSE_ERROR_LENGTH)
|
|
204
|
+
: '';
|
|
205
|
+
return message || `Lookup failed (${status})`;
|
|
206
|
+
}
|
|
207
|
+
function isBoundedTimingMs(value, totalMs) {
|
|
208
|
+
return Number.isInteger(value)
|
|
209
|
+
&& Number(value) >= 0
|
|
210
|
+
&& Number(value) <= totalMs
|
|
211
|
+
&& Number(value) <= MAX_LOOKUP_TIMING_MS;
|
|
212
|
+
}
|
|
213
|
+
function normalizeLookupTiming(value) {
|
|
214
|
+
if (!isJsonObject(value)
|
|
215
|
+
|| value.version !== 1
|
|
216
|
+
|| !Number.isInteger(value.totalMs)
|
|
217
|
+
|| Number(value.totalMs) < 0
|
|
218
|
+
|| Number(value.totalMs) > MAX_LOOKUP_TIMING_MS
|
|
219
|
+
|| !Array.isArray(value.sources)
|
|
220
|
+
|| value.sources.length > MAX_LOOKUP_TIMING_SOURCES) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const totalMs = Number(value.totalMs);
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const sources = [];
|
|
226
|
+
for (const candidate of value.sources) {
|
|
227
|
+
if (!isJsonObject(candidate)
|
|
228
|
+
|| typeof candidate.source !== 'string'
|
|
229
|
+
|| !LOOKUP_TIMING_SOURCES.has(candidate.source)
|
|
230
|
+
|| seen.has(candidate.source)
|
|
231
|
+
|| typeof candidate.outcome !== 'string'
|
|
232
|
+
|| !LOOKUP_TIMING_OUTCOMES.has(candidate.outcome)
|
|
233
|
+
|| !isBoundedTimingMs(candidate.durationMs, totalMs)
|
|
234
|
+
|| !isBoundedTimingMs(candidate.completedAfterMs, totalMs)
|
|
235
|
+
|| Number(candidate.durationMs) > Number(candidate.completedAfterMs)) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const source = candidate.source;
|
|
239
|
+
seen.add(source);
|
|
240
|
+
sources.push({
|
|
241
|
+
source,
|
|
242
|
+
outcome: candidate.outcome,
|
|
243
|
+
durationMs: Number(candidate.durationMs),
|
|
244
|
+
completedAfterMs: Number(candidate.completedAfterMs),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
version: 1,
|
|
249
|
+
totalMs,
|
|
250
|
+
sources,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function createLookupHttpResponse(query, classified, result) {
|
|
254
|
+
return {
|
|
255
|
+
...result,
|
|
256
|
+
query,
|
|
257
|
+
type: classified.type,
|
|
258
|
+
inputHostname: classified.inputHostname,
|
|
259
|
+
registrableDomain: classified.registrableDomain,
|
|
260
|
+
isSubdomain: classified.isSubdomain,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function createLookupViewModel(response) {
|
|
264
|
+
const availability = record(response?.availability);
|
|
265
|
+
const rdap = record(response?.rdap);
|
|
266
|
+
const registrarRdap = record(rdap.registrarRdap);
|
|
267
|
+
const whois = record(response?.whois);
|
|
268
|
+
const diagnostics = record(response?.diagnostics);
|
|
269
|
+
const reverseDns = record(response?.reverseDns);
|
|
270
|
+
const observedNetworkContext = record(response?.networkContext);
|
|
271
|
+
const registryInsights = record(response?.registryInsights);
|
|
272
|
+
const securityTxt = record(response?.securityTxt);
|
|
273
|
+
const sslbl = record(response?.sslbl);
|
|
274
|
+
const threatIntelligence = record(response?.threatIntelligence);
|
|
275
|
+
const providers = Array.isArray(threatIntelligence.providers)
|
|
276
|
+
? threatIntelligence.providers
|
|
277
|
+
.filter(isJsonObject)
|
|
278
|
+
.slice(0, MAX_THREAT_INTELLIGENCE_PROVIDERS)
|
|
279
|
+
: [];
|
|
280
|
+
const dnsEvidence = record(availability.dns);
|
|
281
|
+
const httpEvidence = record(availability.http);
|
|
282
|
+
const httpResponse = record(httpEvidence.response);
|
|
283
|
+
const tlsEvidence = record(availability.tls);
|
|
284
|
+
const tlsCertificate = record(tlsEvidence.certificate);
|
|
285
|
+
const pageIdentity = record(availability.pageIdentity);
|
|
286
|
+
const pageOpenGraph = record(pageIdentity.openGraph);
|
|
287
|
+
const pageResources = record(pageIdentity.resources);
|
|
288
|
+
const securityPosture = record(availability.securityPosture);
|
|
289
|
+
return {
|
|
290
|
+
availability,
|
|
291
|
+
rdap,
|
|
292
|
+
registrarRdap,
|
|
293
|
+
registrarRdapParsed: record(registrarRdap.parsed),
|
|
294
|
+
whois,
|
|
295
|
+
rdapParsed: record(rdap.parsed),
|
|
296
|
+
whoisParsed: record(whois.parsed),
|
|
297
|
+
diagnostics,
|
|
298
|
+
timing: normalizeLookupTiming(diagnostics.timing),
|
|
299
|
+
registryAccess: record(diagnostics.registryAccess),
|
|
300
|
+
registryInsights,
|
|
301
|
+
reverseDns,
|
|
302
|
+
reverseDnsRecords: record(reverseDns.records),
|
|
303
|
+
observedNetworkContext,
|
|
304
|
+
observedNetworkEndpoint: record(observedNetworkContext.endpoint),
|
|
305
|
+
observedNetworkRdap: record(observedNetworkContext.rdap),
|
|
306
|
+
observedNetwork: record(observedNetworkContext.network),
|
|
307
|
+
securityTxt,
|
|
308
|
+
sslbl,
|
|
309
|
+
threatIntelligence,
|
|
310
|
+
threatIntelligenceProviders: providers,
|
|
311
|
+
dnsEvidence,
|
|
312
|
+
dnsRecords: record(dnsEvidence.records),
|
|
313
|
+
httpEvidence,
|
|
314
|
+
httpResponse,
|
|
315
|
+
httpSecurityHeaders: record(httpResponse.securityHeaders),
|
|
316
|
+
tlsEvidence,
|
|
317
|
+
tlsCertificate,
|
|
318
|
+
tlsSubject: record(tlsCertificate.subject),
|
|
319
|
+
tlsIssuer: record(tlsCertificate.issuer),
|
|
320
|
+
tlsAltNames: record(tlsCertificate.subjectAltNames),
|
|
321
|
+
tlsPublicKey: record(tlsCertificate.publicKey),
|
|
322
|
+
tlsCipher: record(tlsEvidence.cipher),
|
|
323
|
+
tlsAuthorization: record(tlsEvidence.authorization),
|
|
324
|
+
tlsHostname: record(tlsEvidence.hostname),
|
|
325
|
+
tlsValidity: record(tlsEvidence.validity),
|
|
326
|
+
tlsDiagnostics: record(tlsEvidence.diagnostics),
|
|
327
|
+
pageIdentity,
|
|
328
|
+
pageCanonical: record(pageIdentity.canonical),
|
|
329
|
+
pageMetaRefresh: record(pageIdentity.metaRefresh),
|
|
330
|
+
pageOpenGraph,
|
|
331
|
+
pageOpenGraphUrl: record(pageOpenGraph.url),
|
|
332
|
+
pageForms: record(pageIdentity.forms),
|
|
333
|
+
pageResources,
|
|
334
|
+
pageResourceTypes: record(pageResources.byType),
|
|
335
|
+
pageDownloads: record(pageIdentity.downloads),
|
|
336
|
+
pageFingerprints: record(pageIdentity.fingerprints),
|
|
337
|
+
credentialSurfaceProfile: record(availability.credentialSurfaceProfile),
|
|
338
|
+
structuredDataIdentity: record(availability.structuredDataIdentity),
|
|
339
|
+
technologyProfile: record(availability.technologyProfile),
|
|
340
|
+
pageRoleProfile: record(availability.pageRoleProfile),
|
|
341
|
+
clientBehaviorProfile: record(availability.clientBehaviorProfile),
|
|
342
|
+
securityPosture,
|
|
343
|
+
securityPostureSummary: record(securityPosture.summary),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export { INVALID_COMPACT_LOOKUP_RESPONSE, INVALID_COMPACT_LOOKUP_RESPONSE_MESSAGE, INVALID_LOOKUP_RESPONSE, INVALID_LOOKUP_RESPONSE_MESSAGE, MAX_COMPACT_LOOKUP_AVAILABILITY_KEYS, MAX_COMPACT_LOOKUP_DIAGNOSTIC_KEYS, MAX_COMPACT_LOOKUP_RESPONSE_TOP_LEVEL_KEYS, MAX_LOOKUP_RESPONSE_ERROR_LENGTH, MAX_LOOKUP_RESPONSE_HOST_LENGTH, MAX_LOOKUP_RESPONSE_QUERY_LENGTH, MAX_LOOKUP_RESPONSE_TOP_LEVEL_KEYS, MAX_LOOKUP_TIMING_MS, MAX_LOOKUP_TIMING_SOURCES, MAX_THREAT_INTELLIGENCE_PROVIDERS, createLookupHttpResponse, createLookupViewModel, isJsonObject, lookupHttpErrorMessage, normalizeLookupTiming, parseCompactLookupHttpResponse, parseLookupHttpResponse, record as lookupRecord, };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
3
|
+
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
4
|
+
export const NAMESERVER_PREFLIGHT_INPUT_SCHEMA = 'whoisleuth.nameserver-preflight.input';
|
|
5
|
+
export const NAMESERVER_PREFLIGHT_REVIEW_SCHEMA = 'whoisleuth.nameserver-preflight.review';
|
|
6
|
+
export const NAMESERVER_PREFLIGHT_REVIEW_VERSION = 1;
|
|
7
|
+
export const MAX_PREFLIGHT_NAMESERVERS = 8;
|
|
8
|
+
export const MAX_PREFLIGHT_ADDRESSES = 4;
|
|
9
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'intendedNameservers', 'observations']);
|
|
10
|
+
const OBSERVATION_KEYS = new Set(['nameserver', 'state', 'source', 'observedAt', 'addresses', 'authoritative', 'servedNameservers', 'soaPrimary', 'soaSerial']);
|
|
11
|
+
function text(value, label, maximum = 240) {
|
|
12
|
+
return requireBoundedString(value, label, maximum);
|
|
13
|
+
}
|
|
14
|
+
function hostname(value, label) {
|
|
15
|
+
return requireDomainName(value, label, 'hostname');
|
|
16
|
+
}
|
|
17
|
+
function timestamp(value, label) {
|
|
18
|
+
return requireIsoTimestamp(value, label);
|
|
19
|
+
}
|
|
20
|
+
function state(value, label) {
|
|
21
|
+
if (value !== 'observed' && value !== 'partial' && value !== 'unavailable')
|
|
22
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function uniqueHostnames(value, label, minimum = 1) {
|
|
26
|
+
if (!Array.isArray(value) || value.length < minimum || value.length > MAX_PREFLIGHT_NAMESERVERS) {
|
|
27
|
+
throw new TypeError(`${label} must contain from ${minimum} to ${MAX_PREFLIGHT_NAMESERVERS} hostnames.`);
|
|
28
|
+
}
|
|
29
|
+
const names = value.map((item, index) => hostname(item, `${label}[${index}]`));
|
|
30
|
+
if (new Set(names).size !== names.length)
|
|
31
|
+
throw new TypeError(`${label} must not contain duplicate hostnames.`);
|
|
32
|
+
return Object.freeze(names.sort());
|
|
33
|
+
}
|
|
34
|
+
function addresses(value, label) {
|
|
35
|
+
if (!Array.isArray(value) || value.length > MAX_PREFLIGHT_ADDRESSES)
|
|
36
|
+
throw new TypeError(`${label} must contain no more than ${MAX_PREFLIGHT_ADDRESSES} addresses.`);
|
|
37
|
+
const normalised = value.map((item, index) => {
|
|
38
|
+
const supplied = text(item, `${label}[${index}]`, 80).toLowerCase();
|
|
39
|
+
if (!isIP(supplied) || supplied.includes('%'))
|
|
40
|
+
throw new TypeError(`${label}[${index}] must be an IPv4 or IPv6 address without a zone identifier.`);
|
|
41
|
+
return supplied;
|
|
42
|
+
});
|
|
43
|
+
return Object.freeze([...new Set(normalised)].sort());
|
|
44
|
+
}
|
|
45
|
+
function sameSet(left, right) {
|
|
46
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
47
|
+
}
|
|
48
|
+
export function reviewNameserverPreflight(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
49
|
+
const input = requireRecord(inputRaw, 'Nameserver preflight input');
|
|
50
|
+
if (input.schema !== NAMESERVER_PREFLIGHT_INPUT_SCHEMA || input.version !== 1) {
|
|
51
|
+
throw new TypeError(`Nameserver preflight input must use ${NAMESERVER_PREFLIGHT_INPUT_SCHEMA} version 1.`);
|
|
52
|
+
}
|
|
53
|
+
exactKeys(input, ROOT_KEYS, 'Nameserver preflight input');
|
|
54
|
+
const domain = hostname(input.domain, 'domain');
|
|
55
|
+
const intendedNameservers = uniqueHostnames(input.intendedNameservers, 'intendedNameservers');
|
|
56
|
+
if (!Array.isArray(input.observations) || input.observations.length > MAX_PREFLIGHT_NAMESERVERS) {
|
|
57
|
+
throw new TypeError(`observations must contain no more than ${MAX_PREFLIGHT_NAMESERVERS} entries.`);
|
|
58
|
+
}
|
|
59
|
+
const observations = input.observations.map((raw, index) => {
|
|
60
|
+
const item = requireRecord(raw, `observations[${index}]`);
|
|
61
|
+
exactKeys(item, OBSERVATION_KEYS, `observations[${index}]`);
|
|
62
|
+
const observationState = state(item.state, `observations[${index}].state`);
|
|
63
|
+
const nameserver = hostname(item.nameserver, `observations[${index}].nameserver`);
|
|
64
|
+
const observedAddresses = addresses(item.addresses ?? [], `observations[${index}].addresses`);
|
|
65
|
+
const servedNameservers = item.servedNameservers === undefined || item.servedNameservers === null
|
|
66
|
+
? Object.freeze([])
|
|
67
|
+
: uniqueHostnames(item.servedNameservers, `observations[${index}].servedNameservers`, 0);
|
|
68
|
+
const authoritative = item.authoritative === null || item.authoritative === undefined
|
|
69
|
+
? null
|
|
70
|
+
: typeof item.authoritative === 'boolean'
|
|
71
|
+
? item.authoritative
|
|
72
|
+
: (() => { throw new TypeError(`observations[${index}].authoritative must be true, false, or null.`); })();
|
|
73
|
+
const soaPrimary = item.soaPrimary === null || item.soaPrimary === undefined ? null : hostname(item.soaPrimary, `observations[${index}].soaPrimary`);
|
|
74
|
+
const soaSerial = item.soaSerial === null || item.soaSerial === undefined
|
|
75
|
+
? null
|
|
76
|
+
: Number.isSafeInteger(item.soaSerial) && Number(item.soaSerial) >= 0 && Number(item.soaSerial) <= 0xffff_ffff
|
|
77
|
+
? Number(item.soaSerial)
|
|
78
|
+
: (() => { throw new TypeError(`observations[${index}].soaSerial is outside its supported range.`); })();
|
|
79
|
+
if (observationState === 'unavailable' && (observedAddresses.length || servedNameservers.length || authoritative !== null || soaPrimary !== null || soaSerial !== null)) {
|
|
80
|
+
throw new TypeError(`observations[${index}] cannot contain observed values when unavailable.`);
|
|
81
|
+
}
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
nameserver,
|
|
84
|
+
state: observationState,
|
|
85
|
+
source: text(item.source, `observations[${index}].source`, 240),
|
|
86
|
+
observedAt: timestamp(item.observedAt, `observations[${index}].observedAt`),
|
|
87
|
+
addresses: observedAddresses,
|
|
88
|
+
authoritative,
|
|
89
|
+
servedNameservers,
|
|
90
|
+
soaPrimary,
|
|
91
|
+
soaSerial,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
if (new Set(observations.map((item) => item.nameserver)).size !== observations.length)
|
|
95
|
+
throw new TypeError('observations must use unique nameservers.');
|
|
96
|
+
if (observations.some((item) => !intendedNameservers.includes(item.nameserver)))
|
|
97
|
+
throw new TypeError('observations can describe only intended nameservers.');
|
|
98
|
+
const rows = intendedNameservers.map((nameserver) => {
|
|
99
|
+
const observation = observations.find((item) => item.nameserver === nameserver) ?? null;
|
|
100
|
+
const inBailiwick = nameserver === domain || nameserver.endsWith(`.${domain}`);
|
|
101
|
+
const publicAddresses = observation?.addresses.filter((address) => !isPrivateAddress(address)) ?? [];
|
|
102
|
+
const addressState = !observation || observation.state === 'unavailable'
|
|
103
|
+
? 'unknown'
|
|
104
|
+
: !observation.addresses.length
|
|
105
|
+
? inBailiwick ? 'missing' : 'not_supplied'
|
|
106
|
+
: publicAddresses.length ? 'ready' : 'non_public';
|
|
107
|
+
const authorityState = !observation || observation.state !== 'observed' || observation.authoritative === null
|
|
108
|
+
? 'unknown'
|
|
109
|
+
: observation.authoritative ? 'authoritative' : 'not_authoritative';
|
|
110
|
+
const servedSetState = !observation || observation.state !== 'observed' || !observation.servedNameservers.length
|
|
111
|
+
? 'unknown'
|
|
112
|
+
: sameSet(observation.servedNameservers, intendedNameservers) ? 'aligned' : 'different';
|
|
113
|
+
const soaState = !observation || observation.state !== 'observed' || !observation.soaPrimary || observation.soaSerial === null
|
|
114
|
+
? 'unknown' : 'observed';
|
|
115
|
+
const ready = observation?.state === 'observed'
|
|
116
|
+
&& authorityState === 'authoritative'
|
|
117
|
+
&& servedSetState === 'aligned'
|
|
118
|
+
&& soaState === 'observed'
|
|
119
|
+
&& (!inBailiwick || addressState === 'ready');
|
|
120
|
+
return Object.freeze({
|
|
121
|
+
nameserver,
|
|
122
|
+
inBailiwick,
|
|
123
|
+
observationState: observation?.state ?? 'not_supplied',
|
|
124
|
+
addressState,
|
|
125
|
+
authorityState,
|
|
126
|
+
servedSetState,
|
|
127
|
+
soaState,
|
|
128
|
+
publicAddresses: Object.freeze(publicAddresses),
|
|
129
|
+
ready,
|
|
130
|
+
source: observation?.source ?? null,
|
|
131
|
+
observedAt: observation?.observedAt ?? null,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
const reasons = rows.flatMap((row) => [
|
|
135
|
+
...(!row.ready && row.observationState !== 'observed' ? [`${row.nameserver} evidence is ${row.observationState}.`] : []),
|
|
136
|
+
...(row.addressState === 'missing' ? [`${row.nameserver} is in-bailiwick but no glue address was supplied.`] : []),
|
|
137
|
+
...(row.addressState === 'non_public' ? [`${row.nameserver} has no observed public address suitable for delegation.`] : []),
|
|
138
|
+
...(row.authorityState !== 'authoritative' ? [`${row.nameserver} authoritative service is ${row.authorityState}.`] : []),
|
|
139
|
+
...(row.servedSetState !== 'aligned' ? [`${row.nameserver} served NS set is ${row.servedSetState}.`] : []),
|
|
140
|
+
...(row.soaState !== 'observed' ? [`${row.nameserver} SOA evidence is ${row.soaState}.`] : []),
|
|
141
|
+
]);
|
|
142
|
+
const pass = rows.every((row) => row.ready);
|
|
143
|
+
return Object.freeze({
|
|
144
|
+
schema: NAMESERVER_PREFLIGHT_REVIEW_SCHEMA,
|
|
145
|
+
version: NAMESERVER_PREFLIGHT_REVIEW_VERSION,
|
|
146
|
+
generatedAt: timestamp(generatedAtValue, 'generatedAt'),
|
|
147
|
+
domain,
|
|
148
|
+
state: pass ? 'ready' : 'review',
|
|
149
|
+
intendedNameservers,
|
|
150
|
+
rows: Object.freeze(rows),
|
|
151
|
+
gate: Object.freeze({ pass, reasons: Object.freeze(reasons) }),
|
|
152
|
+
limitations: Object.freeze([
|
|
153
|
+
'This local preflight uses only analyst-supplied observations and makes no DNS or registry request.',
|
|
154
|
+
'A ready result describes the supplied collection moment; it does not apply a delegation, publish glue, or prove continued reachability.',
|
|
155
|
+
'Partial, unavailable, and omitted observations remain unknown and cannot satisfy the preflight gate.',
|
|
156
|
+
'Only in-bailiwick nameservers require supplied public glue evidence in this review; registry-specific policy must be checked separately.',
|
|
157
|
+
]),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -297,7 +297,7 @@ async function collectObservedNetworkContext(availabilityValue, options = {}) {
|
|
|
297
297
|
});
|
|
298
298
|
const parsed = record(rdapRecord.parsed);
|
|
299
299
|
if (rdap.httpStatus !== 200 || !Object.keys(parsed).length)
|
|
300
|
-
throw new Error('IP RDAP returned no usable
|
|
300
|
+
throw new Error('IP RDAP returned no usable normalised object');
|
|
301
301
|
const summary = networkSummary(parsed, selection.family);
|
|
302
302
|
const partial = summary.truncated;
|
|
303
303
|
const sourceObservedAt = rdap.fetchedAt || observedAt();
|