@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +74 -124
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +7 -4
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +450 -173
- package/cli/artifact-structure.mjs +2507 -0
- package/cli/artifact-verify.mjs +286 -64
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +35 -23
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +435 -46
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +948 -106
- package/cli/completion.mjs +504 -218
- package/cli/config-profile.mjs +13 -3
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +13 -14
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +215 -36
- package/cli/domain-control-observations.mjs +34 -17
- package/cli/errors.mjs +8 -3
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +68 -50
- package/cli/export-evidence.mjs +11 -4
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +586 -22
- package/cli/input.mjs +62 -0
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +80 -24
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +96 -33
- package/cli/lookup-diff.mjs +11 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +45 -14
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +18 -10
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +343 -61
- package/cli/registry-doctor.mjs +67 -28
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +375 -0
- package/cli/risk-calibration.mjs +89 -40
- package/cli/runner.mjs +322 -246
- package/cli/saved-lookup.mjs +439 -49
- package/cli/sharing-review.mjs +18 -7
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1334
- package/docs/cli.md +87 -236
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
- package/frontend/src/lib/analysis/domain-control-records.js +3 -0
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +72 -52
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +91 -4
- package/lib/bounded-json.mjs +287 -0
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +173 -0
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +120 -64
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +4 -3
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1052 -0
- package/lib/dnssec-evidence-validation.mjs +23 -10
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -171
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/domain-posture.mjs +7 -4
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +1243 -88
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/favicon.mjs +8 -5
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +332 -6
- package/lib/interchange-fidelity-registry.mjs +124 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup.mjs +27 -4
- package/lib/observed-network-context.mjs +1 -1
- package/lib/outbound-identity.mjs +2 -3
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +29 -0
- package/lib/provider-policy-admission.mjs +11 -4
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +324 -80
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +340 -0
- package/lib/risk-scoring.mjs +4 -3
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +121 -50
- package/lib/security-txt.mjs +27 -4
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +787 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +5 -15
- package/lib/threat-intelligence-types.mjs +21 -1
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +15 -9
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/packages/cases/case-brand-profile-references.mjs +85 -0
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
- package/packages/cases/case-investigation-branch-model.mjs +132 -0
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/packages/cases/opaque-reference-id.mjs +9 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
- package/packages/comparison/comparison-ledger-contract.mjs +241 -0
- package/packages/comparison/comparison-ledger-json.mjs +80 -0
- package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/packages/evidence/artifact-integrity.mjs +65 -0
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/packages/investigation/lookup-claim-passport.mjs +232 -0
- package/packages/investigation/lookup-claim-readiness.mjs +287 -0
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
- package/third-party-notices.txt +112 -5834
- package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -253
- package/frontend/src/lib/analysis/case-record-model.js +0 -7
- package/frontend/src/lib/analysis/case-response-model.js +0 -669
- package/frontend/src/lib/analysis/case-response-packet.js +0 -573
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -232
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
- package/frontend/src/lib/analysis/visualization-models.js +0 -369
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
- package/lib/bounded-json-response.mjs +0 -157
- package/lib/lookup-response-contract.mjs +0 -346
- package/lib/observation.mjs +0 -114
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
package/lib/evidence-export.mjs
CHANGED
|
@@ -1,25 +1,576 @@
|
|
|
1
1
|
import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
|
|
2
2
|
import { buildRegistryInsights } from "./registry-insights.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { buildPortableGeneratorMetadata, isUriShapedLabel } from "./portable-generator.mjs";
|
|
4
|
+
import { assertBoundedJsonStructure, isSafeJsonObjectKey } from "./bounded-json.mjs";
|
|
5
|
+
import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "./homepage-metadata-contract.mjs";
|
|
6
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
7
|
+
import { defineSchemaCompatibility } from "../packages/contracts/schema-compatibility.mjs";
|
|
8
|
+
import { isValidAsciiHostname } from "./hostname.mjs";
|
|
5
9
|
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
6
|
-
export const
|
|
10
|
+
export const PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 26;
|
|
11
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 27;
|
|
12
|
+
export const SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([
|
|
13
|
+
PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
14
|
+
LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
15
|
+
]);
|
|
16
|
+
export const LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
export const LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES = 20_000;
|
|
18
|
+
export const LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH = 24;
|
|
19
|
+
export const LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS = 10_000;
|
|
20
|
+
export const LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH = 256;
|
|
21
|
+
export const LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH = 1024 * 1024;
|
|
22
|
+
export const LOOKUP_EVIDENCE_COMPATIBILITY = defineSchemaCompatibility({
|
|
23
|
+
id: 'export.lookup-evidence',
|
|
24
|
+
kind: 'export',
|
|
25
|
+
schema: LOOKUP_EVIDENCE_SCHEMA,
|
|
26
|
+
currentVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
27
|
+
supportedVersions: SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS,
|
|
28
|
+
acceptsUnversionedLegacy: false,
|
|
29
|
+
futureVersionBehavior: 'reject',
|
|
30
|
+
migration: 'read_only',
|
|
31
|
+
writeSemantics: 'read_only',
|
|
32
|
+
byteBudget: LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES,
|
|
33
|
+
owner: 'lib/evidence-export.mts',
|
|
34
|
+
note: 'Exact v1.47.4 version 26 remains replayable; version 27 is the single v2 successor with bounded homepage and privacy-minimised registration projections.',
|
|
35
|
+
});
|
|
7
36
|
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
8
37
|
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
9
38
|
]);
|
|
39
|
+
const LOOKUP_QUERY_TYPES = new Set(['domain', 'ipv4', 'ipv6', 'asn']);
|
|
40
|
+
const RDAP_SOURCE_STATUSES = new Set([
|
|
41
|
+
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
42
|
+
]);
|
|
43
|
+
const WHOIS_SOURCE_STATUSES = new Set([
|
|
44
|
+
'complete', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
45
|
+
]);
|
|
46
|
+
const AVAILABILITY_DIAGNOSTIC_STATUSES = new Set(['complete', 'error', 'disabled', 'not_applicable']);
|
|
10
47
|
const NETWORK_CONTEXT_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'error']);
|
|
11
48
|
const NETWORK_ADDRESS_SOURCES = new Set(['tls_connection', 'dns_a', 'dns_aaaa']);
|
|
12
49
|
const REVERSE_DNS_STATUSES = new Set(['success', 'partial', 'not_found', 'unsupported', 'skipped', 'error']);
|
|
13
50
|
const SECURITY_TXT_STATES = new Set(['present', 'stale', 'partial', 'absent', 'malformed', 'unsupported', 'unavailable']);
|
|
14
51
|
const SSLBL_STATES = new Set(['success', 'stale', 'unavailable']);
|
|
15
52
|
const SSLBL_VERDICTS = new Set(['listed', 'not_listed', 'inconclusive']);
|
|
53
|
+
const RDAP_ATTEMPT_OUTCOMES = new Set([
|
|
54
|
+
'success', 'not_found', 'no_results', 'unsupported', 'rate_limited',
|
|
55
|
+
'server_error', 'client_error', 'invalid_json', 'invalid_response',
|
|
56
|
+
'timeout', 'network_error',
|
|
57
|
+
]);
|
|
58
|
+
const WHOIS_QUERY_PROFILES = new Set([
|
|
59
|
+
'plain-domain', 'denic-domain-ace', 'jprs-domain-english',
|
|
60
|
+
'registry-domain-unicode', 'not-issued',
|
|
61
|
+
]);
|
|
62
|
+
const LOOKUP_TIMING_SOURCES = new Set([
|
|
63
|
+
'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
|
|
64
|
+
'network_context', 'security_txt', 'external_intelligence',
|
|
65
|
+
'malware_host_intelligence', 'malware_ioc_intelligence',
|
|
66
|
+
]);
|
|
67
|
+
const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
|
|
68
|
+
'applicable', 'type', 'domain', 'state', 'confidence', 'detail', 'source',
|
|
69
|
+
'rdapServer', 'nameservers', 'statuses', 'registrar', 'registrant', 'abuse',
|
|
70
|
+
'createdDate', 'expiryDate', 'createdDateIso', 'expiryDateIso', 'domainAgeDays',
|
|
71
|
+
'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
|
|
72
|
+
'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
|
|
73
|
+
'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
|
|
74
|
+
'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
|
|
75
|
+
'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
|
|
76
|
+
'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
|
|
77
|
+
'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
|
|
78
|
+
'hasDmarc', 'bulkComparison', 'limitations',
|
|
79
|
+
]);
|
|
80
|
+
const LOOKUP_AVAILABILITY_ANALYSIS_KEYS = new Set([
|
|
81
|
+
...[...PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS]
|
|
82
|
+
.filter((key) => !['registrar', 'registrant', 'abuse'].includes(key)),
|
|
83
|
+
'registryContactsExcluded',
|
|
84
|
+
]);
|
|
85
|
+
const LOOKUP_IDN_ANALYSIS_KEYS = new Set([
|
|
86
|
+
'version', 'mappingVersion', 'asciiDomain', 'unicodeDomain', 'hasIdn',
|
|
87
|
+
'scripts', 'labels', 'mixedScript', 'skeleton', 'referenceMatches', 'findings',
|
|
88
|
+
'truncated', 'limitations',
|
|
89
|
+
]);
|
|
90
|
+
const PRIVATE_EVIDENCE_KEYS = new Set([
|
|
91
|
+
'authorization', 'proxyauthorization', 'cookie', 'cookies', 'setcookie',
|
|
92
|
+
'session', 'sessionid', 'sessiontoken', 'token', 'accesstoken', 'refreshtoken',
|
|
93
|
+
'apikey', 'password', 'secret', 'credentials', 'requestheaders', 'responseheaders',
|
|
94
|
+
'headers', 'rawbody', 'responsebody',
|
|
95
|
+
]);
|
|
96
|
+
const SAFE_EVIDENCE_KEYS = new Set([
|
|
97
|
+
'credentialsurfaceprofile', 'credentialsurfaceversion', 'haspasswordfield', 'tokencount',
|
|
98
|
+
]);
|
|
99
|
+
const PORTABLE_URL_KEYS = new Set([
|
|
100
|
+
'endpoint', 'url', 'uri', 'href', 'rdapserver', 'finalurl', 'requestedurl',
|
|
101
|
+
'requesturl', 'officiallookupurl', 'declaredorigin', 'actionurl', 'from', 'to',
|
|
102
|
+
]);
|
|
103
|
+
const PORTABLE_ORIGIN_COLLECTION_KEYS = new Set([
|
|
104
|
+
'embeddedorigins', 'externalactionorigins', 'externalorigins',
|
|
105
|
+
]);
|
|
106
|
+
// Availability is assembled from a finite set of versioned, bounded analysis
|
|
107
|
+
// models. Portable exports retain only their declared output vocabulary rather
|
|
108
|
+
// than recursively copying arbitrary browser-local extensions. This set is
|
|
109
|
+
// intentionally exact and case-sensitive; new model fields must be reviewed at
|
|
110
|
+
// this privacy boundary before they can enter a portable artefact.
|
|
111
|
+
const LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS = new Set([
|
|
112
|
+
// Shared observation and summary fields.
|
|
113
|
+
'version', 'profileVersion', 'status', 'state', 'source', 'observedAt',
|
|
114
|
+
'scanMode', 'durationMs', 'complete', 'truncated', 'limitations',
|
|
115
|
+
'diagnostics', 'detail', 'error', 'count', 'total', 'discarded', 'summary',
|
|
116
|
+
'findings', 'id', 'label', 'tone', 'category', 'confidence', 'evidence',
|
|
117
|
+
'value', 'values', 'name', 'type', 'types', 'mode', 'owner', 'target',
|
|
118
|
+
'url', 'uri', 'href',
|
|
119
|
+
'priority', 'ttl', 'records',
|
|
120
|
+
// HTTP observation and retained response metadata.
|
|
121
|
+
'requestUrl', 'finalUrl', 'transportSecurity', 'redirectCount',
|
|
122
|
+
'redirectLimitReached', 'redirects', 'crossOriginRedirect', 'httpsDowngrade',
|
|
123
|
+
'attempts', 'response', 'contentType', 'contentLanguage', 'server',
|
|
124
|
+
'declaredContentLength', 'capturedBodyBytes', 'bodyInspected', 'bodyTruncated',
|
|
125
|
+
'bodyHash', 'securityHeaders', 'strictTransportSecurity',
|
|
126
|
+
'contentSecurityPolicy', 'xFrameOptions', 'xContentTypeOptions',
|
|
127
|
+
'referrerPolicy', 'algorithm', 'scope', 'bytes', 'from', 'to',
|
|
128
|
+
'queryOmitted', 'pathTruncated', 'outcome', 'httpStatus', 'attemptCount',
|
|
129
|
+
// DNS observation, effective CAA, and HTTPS/SVCB parameter summaries.
|
|
130
|
+
'a', 'aaaa', 'caa', 'cname', 'dmarc', 'https', 'mx', 'ns', 'ptr', 'soa',
|
|
131
|
+
'spf', 'delegation', 'caaPolicy', 'policyVersion', 'queryLimit',
|
|
132
|
+
'queriedOwners', 'effectiveOwner', 'inherited', 'tree', 'registryEvidence',
|
|
133
|
+
'exchange', 'nsname', 'hostmaster', 'serial', 'refresh', 'retry', 'expire', 'minttl',
|
|
134
|
+
'tag', 'critical', 'parameters', 'parametersIgnored', 'mandatory', 'alpn',
|
|
135
|
+
'noDefaultAlpn', 'port', 'ipv4hint', 'ipv6hint', 'opaque', 'key', 'length',
|
|
136
|
+
'unknownKeys', 'unsupportedMandatoryKeys', 'targetIsOwner',
|
|
137
|
+
'serviceUnavailable', 'compatible', 'effectiveOwner', 'queriedOwners',
|
|
138
|
+
// TLS profile, certificate, extension, and connection summaries. Runtime
|
|
139
|
+
// authorisation objects are deliberately absent from this portable set.
|
|
140
|
+
'connectedAddress', 'connectedFamily', 'sniHost', 'protocol', 'alpnProtocol',
|
|
141
|
+
'cipher', 'ephemeralKey', 'hostname', 'matches', 'validity', 'certificate',
|
|
142
|
+
'chain', 'chainTruncated', 'standardName', 'size', 'subject', 'issuer',
|
|
143
|
+
'serialNumber', 'validFrom', 'validTo', 'fingerprintSha1',
|
|
144
|
+
'fingerprintSha256', 'isCertificateAuthority', 'subjectAltNames',
|
|
145
|
+
'dnsNames', 'ipAddresses', 'otherNames', 'publicKey', 'bits', 'curve',
|
|
146
|
+
'signature', 'oid', 'extendedKeyUsage', 'authorityInformationAccess',
|
|
147
|
+
'ocsp', 'caIssuers', 'unknownMethods', 'extensionProfile', 'parsed', 'partial',
|
|
148
|
+
'certificatePolicies', 'crlDistributionPoints', 'commonNames',
|
|
149
|
+
'organizations', 'organizationalUnits', 'countries', 'localities', 'states',
|
|
150
|
+
'connectionAttempts', 'resolvedAddressCount', 'discardedFields',
|
|
151
|
+
// Page identity, relationships, and bounded fingerprints.
|
|
152
|
+
'identityVersion', 'documentLanguage', 'canonical', 'metaRefresh',
|
|
153
|
+
'openGraph', 'title', 'siteName', 'generator', 'forms', 'postCount',
|
|
154
|
+
'insecureActionCount', 'externalActionOrigins', 'resources', 'byType',
|
|
155
|
+
'image', 'script', 'stylesheet', 'link', 'frame', 'media', 'object',
|
|
156
|
+
'externalOrigins', 'embeddedOrigins', 'contactDomains', 'downloads',
|
|
157
|
+
'explicitCount', 'riskyCount', 'riskyFileTypes', 'trackingIdentifiers',
|
|
158
|
+
'fingerprints', 'fingerprintVersion', 'exact', 'normalizedHtml',
|
|
159
|
+
'visibleText', 'domStructure', 'formStructure', 'resourceHosts',
|
|
160
|
+
'identifiers', 'featureCount', 'nodeCount', 'formCount', 'controlCount',
|
|
161
|
+
'parser', 'tokenCount', 'tagsExamined', 'discardedUrls', 'formsObserved',
|
|
162
|
+
'relationshipTagsExamined', 'relationshipUrlsDiscarded',
|
|
163
|
+
'trackingIdentifiersTruncated',
|
|
164
|
+
// Credential, structured identity, role, behaviour, technology, and posture
|
|
165
|
+
// projections. Only fixed categories/counts and bounded descriptions enter.
|
|
166
|
+
'credentialSurfaceVersion', 'methods', 'actions', 'missing', 'get', 'post',
|
|
167
|
+
'dialog', 'other', 'sameOrigin', 'external', 'cleartext', 'unclassified',
|
|
168
|
+
'inputs', 'classifiedCount', 'categories', 'password', 'email', 'username',
|
|
169
|
+
'one_time_code', 'payment', 'inputsObserved', 'classifiedInputs',
|
|
170
|
+
'unclassifiedActions', 'structuredDataVersion', 'scriptsObserved',
|
|
171
|
+
'scriptsExamined', 'charactersExamined', 'documentsParsed', 'malformedScripts',
|
|
172
|
+
'externalScriptsSkipped', 'objectsExamined', 'arrayItemsExamined',
|
|
173
|
+
'discardedProperties', 'entities', 'declaredOrigin', 'sameAsHosts',
|
|
174
|
+
'pageRoleProfileVersion', 'primaryRole', 'role', 'rolesObserved',
|
|
175
|
+
'clientBehaviorProfileVersion', 'scriptSummary', 'elementsObserved',
|
|
176
|
+
'referencedScripts', 'inlineScripts', 'moduleScripts', 'indicators',
|
|
177
|
+
'evidenceClass', 'occurrences', 'explanation', 'indicatorsObserved',
|
|
178
|
+
'scriptElementsExamined', 'inlineCharactersExamined', 'technologyProfile',
|
|
179
|
+
'browserLibraryProfile', 'catalog', 'knownExploitedCatalog', 'releasedAt',
|
|
180
|
+
'sourceRevision', 'apparentVersion', 'detectionMethods', 'advisoryCount',
|
|
181
|
+
'highestSeverity', 'advisoryIdentifiers', 'knownExploitedCount',
|
|
182
|
+
'knownExploitedIdentifiers', 'weaknessClasses', 'htmlEvaluated',
|
|
183
|
+
'generatorEvaluated', 'serverEvaluated', 'resourceOriginsEvaluated',
|
|
184
|
+
'passiveHeadersEvaluated', 'tagLimitReached', 'referencesExamined',
|
|
185
|
+
'inlineScriptsExamined', 'inlineSignatureCharactersExamined',
|
|
186
|
+
'inlineSignatureTimedOut', 'inlineSignatureUnavailable', 'catalogComponents',
|
|
187
|
+
'advisoryMatches', 'knownExploitedMatches', 'postureVersion',
|
|
188
|
+
'potentialExposure', 'observedAbsence', 'unavailable',
|
|
189
|
+
// Bounded cross-row comparison evidence.
|
|
190
|
+
'bulkComparison', 'technology', 'tls', 'ids', 'issuerLabel', 'spkiSha256',
|
|
191
|
+
]);
|
|
192
|
+
const LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS = new Set([
|
|
193
|
+
'password', 'email', 'username', 'one_time_code', 'payment',
|
|
194
|
+
]);
|
|
195
|
+
const LOOKUP_AVAILABILITY_VALUE_PATHS = new Set([
|
|
196
|
+
'dns.records.caa',
|
|
197
|
+
'dns.caaPolicy.records',
|
|
198
|
+
'http.response.bodyHash',
|
|
199
|
+
'pageIdentity.trackingIdentifiers',
|
|
200
|
+
'pageIdentity.fingerprints.exact',
|
|
201
|
+
'pageIdentity.fingerprints.normalizedHtml',
|
|
202
|
+
'pageIdentity.fingerprints.visibleText',
|
|
203
|
+
'pageIdentity.fingerprints.domStructure',
|
|
204
|
+
'pageIdentity.fingerprints.domStructure.similarity',
|
|
205
|
+
'pageIdentity.fingerprints.formStructure',
|
|
206
|
+
'pageIdentity.fingerprints.resourceHosts',
|
|
207
|
+
'pageIdentity.fingerprints.identifiers',
|
|
208
|
+
'pageIdentity.fingerprints.identifiers.values',
|
|
209
|
+
]);
|
|
210
|
+
const LOOKUP_AVAILABILITY_OWNER_PATHS = new Set([
|
|
211
|
+
'dns.records.https',
|
|
212
|
+
'dns.caaPolicy.queriedOwners',
|
|
213
|
+
]);
|
|
16
214
|
function recordOrNull(value) {
|
|
17
215
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
18
216
|
}
|
|
19
|
-
function
|
|
20
|
-
|
|
217
|
+
function normalizedEvidenceKey(value) {
|
|
218
|
+
return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
|
|
219
|
+
}
|
|
220
|
+
function privateEvidenceKey(value, item) {
|
|
221
|
+
const normalized = normalizedEvidenceKey(value);
|
|
222
|
+
if (SAFE_EVIDENCE_KEYS.has(normalized))
|
|
223
|
+
return false;
|
|
224
|
+
if (normalized === 'password' && (typeof item === 'number' || typeof item === 'boolean'))
|
|
225
|
+
return false;
|
|
226
|
+
return PRIVATE_EVIDENCE_KEYS.has(normalized)
|
|
227
|
+
|| normalized.includes('authorization')
|
|
228
|
+
|| normalized.includes('authheader')
|
|
229
|
+
|| normalized.includes('cookie')
|
|
230
|
+
|| normalized.includes('apikey')
|
|
231
|
+
|| normalized.includes('secret')
|
|
232
|
+
|| normalized.includes('sessionkey')
|
|
233
|
+
|| normalized.includes('passwordvalue')
|
|
234
|
+
|| normalized.endsWith('password')
|
|
235
|
+
|| /(?:access|auth|bearer|csrf|refresh|session)token$/u.test(normalized)
|
|
236
|
+
|| /(?:request|response|authorization|proxy)headers?$/u.test(normalized)
|
|
237
|
+
|| (normalized.includes('credential') && !normalized.startsWith('credentialsurface'));
|
|
238
|
+
}
|
|
239
|
+
function portableUri(value) {
|
|
240
|
+
const text = boundedString(value, 2048);
|
|
241
|
+
if (!text)
|
|
21
242
|
return null;
|
|
22
|
-
|
|
243
|
+
try {
|
|
244
|
+
const url = new URL(text);
|
|
245
|
+
if (!['http:', 'https:', 'mailto:', 'tel:', 'dns:', 'openpgp4fpr:'].includes(url.protocol)
|
|
246
|
+
|| url.username
|
|
247
|
+
|| url.password)
|
|
248
|
+
return null;
|
|
249
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
250
|
+
url.search = '';
|
|
251
|
+
url.hash = '';
|
|
252
|
+
}
|
|
253
|
+
return url.toString().slice(0, 2048);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function portableOrigin(value) {
|
|
260
|
+
const text = boundedString(value, 2048);
|
|
261
|
+
if (!text)
|
|
262
|
+
return null;
|
|
263
|
+
try {
|
|
264
|
+
const url = new URL(text);
|
|
265
|
+
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
|
|
266
|
+
? url.origin
|
|
267
|
+
: null;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function consumePortableProjectionEntry(state, depth) {
|
|
274
|
+
state.entries += 1;
|
|
275
|
+
if (state.entries > LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES) {
|
|
276
|
+
throw new TypeError(`Lookup evidence projection exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES.toLocaleString('en')}-entry portable limit.`);
|
|
277
|
+
}
|
|
278
|
+
if (depth > LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH) {
|
|
279
|
+
throw new TypeError(`Lookup evidence projection exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH}-level portable nesting limit.`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function projectPortableString(value) {
|
|
283
|
+
const sanitized = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, ' ');
|
|
284
|
+
return sanitized.length <= LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH
|
|
285
|
+
? sanitized
|
|
286
|
+
: sanitized.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH);
|
|
287
|
+
}
|
|
288
|
+
function projectLookupEvidenceAvailabilityString(value) {
|
|
289
|
+
const sanitized = projectPortableString(value)
|
|
290
|
+
.replace(/[\u0080-\u009f]/gu, ' ')
|
|
291
|
+
.replace(/\p{Default_Ignorable_Code_Point}/gu, '');
|
|
292
|
+
return isUriShapedLabel(value) ? null : sanitized;
|
|
293
|
+
}
|
|
294
|
+
function projectLookupEvidencePrivacySafeTreeValue(value, state, depth) {
|
|
295
|
+
consumePortableProjectionEntry(state, depth);
|
|
296
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
297
|
+
return value;
|
|
298
|
+
if (typeof value === 'string')
|
|
299
|
+
return projectPortableString(value);
|
|
300
|
+
if (Array.isArray(value)) {
|
|
301
|
+
return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
|
|
302
|
+
.map((item) => projectLookupEvidencePrivacySafeTreeValue(item, state, depth + 1));
|
|
303
|
+
}
|
|
304
|
+
const source = recordOrNull(value);
|
|
305
|
+
if (!source)
|
|
306
|
+
return null;
|
|
307
|
+
const output = {};
|
|
308
|
+
for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
|
|
309
|
+
if (!isSafeJsonObjectKey(key))
|
|
310
|
+
throw new TypeError('Lookup evidence projection contains an unsafe object key.');
|
|
311
|
+
const normalized = normalizedEvidenceKey(key);
|
|
312
|
+
if (privateEvidenceKey(normalized, item))
|
|
313
|
+
continue;
|
|
314
|
+
if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized)) {
|
|
315
|
+
consumePortableProjectionEntry(state, depth + 1);
|
|
316
|
+
output[key] = portableUri(item);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
output[key] = projectLookupEvidencePrivacySafeTreeValue(item, state, depth + 1);
|
|
320
|
+
}
|
|
321
|
+
return output;
|
|
322
|
+
}
|
|
323
|
+
export function projectLookupEvidencePrivacySafeTree(value) {
|
|
324
|
+
return projectLookupEvidencePrivacySafeTreeValue(value, { entries: 0 }, 0);
|
|
325
|
+
}
|
|
326
|
+
function projectLookupEvidenceAvailabilityPublicValue(value, parentKey, state, depth) {
|
|
327
|
+
consumePortableProjectionEntry(state, depth);
|
|
328
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
329
|
+
return value;
|
|
330
|
+
if (typeof value === 'string') {
|
|
331
|
+
const normalizedParent = normalizedEvidenceKey(parentKey || '');
|
|
332
|
+
if (PORTABLE_URL_KEYS.has(normalizedParent))
|
|
333
|
+
return portableUri(value);
|
|
334
|
+
if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
|
|
335
|
+
return portableOrigin(value);
|
|
336
|
+
return projectPortableString(value);
|
|
337
|
+
}
|
|
338
|
+
if (Array.isArray(value)) {
|
|
339
|
+
return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
|
|
340
|
+
.map((item) => projectLookupEvidenceAvailabilityPublicValue(item, parentKey, state, depth + 1));
|
|
341
|
+
}
|
|
342
|
+
const source = recordOrNull(value);
|
|
343
|
+
if (!source)
|
|
344
|
+
return null;
|
|
345
|
+
const output = {};
|
|
346
|
+
for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
|
|
347
|
+
if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
|
|
348
|
+
|| privateEvidenceKey(key, item))
|
|
349
|
+
continue;
|
|
350
|
+
output[key] = projectLookupEvidenceAvailabilityPublicValue(item, key, state, depth + 1);
|
|
351
|
+
}
|
|
352
|
+
return output;
|
|
353
|
+
}
|
|
354
|
+
function projectLookupEvidenceAvailabilityValue(value, path, state, depth) {
|
|
355
|
+
consumePortableProjectionEntry(state, depth);
|
|
356
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number')
|
|
357
|
+
return value;
|
|
358
|
+
if (typeof value === 'string') {
|
|
359
|
+
const normalizedParent = normalizedEvidenceKey(path.at(-1) || '');
|
|
360
|
+
if (PORTABLE_URL_KEYS.has(normalizedParent))
|
|
361
|
+
return portableUri(value);
|
|
362
|
+
if (PORTABLE_ORIGIN_COLLECTION_KEYS.has(normalizedParent))
|
|
363
|
+
return portableOrigin(value);
|
|
364
|
+
return projectLookupEvidenceAvailabilityString(value);
|
|
365
|
+
}
|
|
366
|
+
if (Array.isArray(value)) {
|
|
367
|
+
return value.slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)
|
|
368
|
+
.map((item) => projectLookupEvidenceAvailabilityValue(item, path, state, depth + 1));
|
|
369
|
+
}
|
|
370
|
+
const source = recordOrNull(value);
|
|
371
|
+
if (!source)
|
|
372
|
+
return null;
|
|
373
|
+
const output = {};
|
|
374
|
+
for (const [key, item] of Object.entries(source).slice(0, LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS)) {
|
|
375
|
+
if (!LOOKUP_AVAILABILITY_PORTABLE_NESTED_KEYS.has(key)
|
|
376
|
+
|| privateEvidenceKey(key, item))
|
|
377
|
+
continue;
|
|
378
|
+
const parentPath = path.join('.');
|
|
379
|
+
if (LOOKUP_AVAILABILITY_CREDENTIAL_CATEGORY_KEYS.has(key)
|
|
380
|
+
&& (parentPath !== 'credentialSurfaceProfile.inputs.categories'
|
|
381
|
+
|| !Number.isSafeInteger(item)
|
|
382
|
+
|| Number(item) < 0
|
|
383
|
+
|| Number(item) > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS))
|
|
384
|
+
continue;
|
|
385
|
+
if (key === 'owner'
|
|
386
|
+
&& (!LOOKUP_AVAILABILITY_OWNER_PATHS.has(parentPath) || typeof item !== 'string'))
|
|
387
|
+
continue;
|
|
388
|
+
if (key === 'value'
|
|
389
|
+
&& (!LOOKUP_AVAILABILITY_VALUE_PATHS.has(parentPath)
|
|
390
|
+
|| (item !== null && typeof item !== 'string')))
|
|
391
|
+
continue;
|
|
392
|
+
output[key] = projectLookupEvidenceAvailabilityValue(item, [...path, key], state, depth + 1);
|
|
393
|
+
}
|
|
394
|
+
return output;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Canonical portable projection for the bounded Lookup availability models.
|
|
398
|
+
* The root and every nested record are positive allowlists, so an imported
|
|
399
|
+
* extension cannot become authenticated evidence merely by avoiding a
|
|
400
|
+
* credential-key denylist.
|
|
401
|
+
*/
|
|
402
|
+
function projectLookupEvidenceAvailabilityWithKeys(value, rootKeys, registryContactsExcluded, currentPrivacyRules) {
|
|
403
|
+
const source = recordOrNull(value);
|
|
404
|
+
if (!source)
|
|
405
|
+
return null;
|
|
406
|
+
const state = { entries: 0 };
|
|
407
|
+
consumePortableProjectionEntry(state, 0);
|
|
408
|
+
const output = {};
|
|
409
|
+
for (const key of rootKeys) {
|
|
410
|
+
if (!Object.hasOwn(source, key))
|
|
411
|
+
continue;
|
|
412
|
+
const item = source[key];
|
|
413
|
+
if (privateEvidenceKey(key, item))
|
|
414
|
+
continue;
|
|
415
|
+
output[key] = currentPrivacyRules
|
|
416
|
+
? projectLookupEvidenceAvailabilityValue(item, [key], state, 1)
|
|
417
|
+
: projectLookupEvidenceAvailabilityPublicValue(item, key, state, 1);
|
|
418
|
+
}
|
|
419
|
+
if (registryContactsExcluded)
|
|
420
|
+
output.registryContactsExcluded = true;
|
|
421
|
+
const pageIdentity = recordOrNull(source.pageIdentity);
|
|
422
|
+
const publicationMetadata = pageIdentity?.publicationMetadata;
|
|
423
|
+
if (publicationMetadata !== undefined) {
|
|
424
|
+
if (!['success', 'partial'].includes(String(pageIdentity?.status))
|
|
425
|
+
|| !validPagePublicationMetadata(publicationMetadata)) {
|
|
426
|
+
throw new TypeError('Lookup evidence contains invalid page publication metadata.');
|
|
427
|
+
}
|
|
428
|
+
const projectedPageIdentity = recordOrNull(output.pageIdentity);
|
|
429
|
+
if (projectedPageIdentity) {
|
|
430
|
+
projectedPageIdentity.publicationMetadata = projectLookupEvidencePrivacySafeTree(publicationMetadata);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const httpResponse = recordOrNull(recordOrNull(source.http)?.response);
|
|
434
|
+
const sourceHttp = recordOrNull(source.http);
|
|
435
|
+
const deliveryMetadata = httpResponse?.deliveryMetadata;
|
|
436
|
+
if (deliveryMetadata !== undefined) {
|
|
437
|
+
if (!['success', 'partial'].includes(String(sourceHttp?.status))
|
|
438
|
+
|| !validHttpDeliveryMetadata(deliveryMetadata)) {
|
|
439
|
+
throw new TypeError('Lookup evidence contains invalid HTTP delivery metadata.');
|
|
440
|
+
}
|
|
441
|
+
const projectedHttp = recordOrNull(output.http);
|
|
442
|
+
const projectedResponse = recordOrNull(projectedHttp?.response);
|
|
443
|
+
if (projectedResponse) {
|
|
444
|
+
projectedResponse.deliveryMetadata = projectLookupEvidencePrivacySafeTree(deliveryMetadata);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return output;
|
|
448
|
+
}
|
|
449
|
+
/** Exact availability projection used only to validate public schema 26. */
|
|
450
|
+
export function projectLookupEvidenceAvailabilityPublic(value) {
|
|
451
|
+
return projectLookupEvidenceAvailabilityWithKeys(value, PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS, false, false);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Current availability projection. Registry-derived registrar, registrant,
|
|
455
|
+
* and abuse contact routes are deliberately excluded at the root boundary;
|
|
456
|
+
* the marker prevents that privacy omission from being read as source absence.
|
|
457
|
+
*/
|
|
458
|
+
export function projectLookupEvidenceAvailability(value) {
|
|
459
|
+
return projectLookupEvidenceAvailabilityWithKeys(value, LOOKUP_AVAILABILITY_ANALYSIS_KEYS, true, true);
|
|
460
|
+
}
|
|
461
|
+
export function assertLookupEvidencePrivacySafeTree(value) {
|
|
462
|
+
assertLookupEvidencePortableTree(value);
|
|
463
|
+
const pending = [value];
|
|
464
|
+
while (pending.length) {
|
|
465
|
+
const current = pending.pop();
|
|
466
|
+
if (current === null || typeof current === 'boolean' || typeof current === 'number')
|
|
467
|
+
continue;
|
|
468
|
+
if (typeof current === 'string') {
|
|
469
|
+
if (projectLookupEvidencePrivacySafeTree(current) !== current) {
|
|
470
|
+
throw new TypeError('Lookup evidence contains a non-portable string value.');
|
|
471
|
+
}
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (Array.isArray(current)) {
|
|
475
|
+
for (const item of current)
|
|
476
|
+
pending.push(item);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const source = recordOrNull(current);
|
|
480
|
+
if (!source)
|
|
481
|
+
throw new TypeError('Lookup evidence contains a non-JSON value.');
|
|
482
|
+
for (const [key, item] of Object.entries(source)) {
|
|
483
|
+
const normalized = normalizedEvidenceKey(key);
|
|
484
|
+
if (privateEvidenceKey(normalized, item)) {
|
|
485
|
+
throw new TypeError('Lookup evidence contains excluded private request or session material.');
|
|
486
|
+
}
|
|
487
|
+
if (typeof item === 'string' && PORTABLE_URL_KEYS.has(normalized) && portableUri(item) !== item) {
|
|
488
|
+
throw new TypeError('Lookup evidence contains a credential-bearing or non-portable URL.');
|
|
489
|
+
}
|
|
490
|
+
pending.push(item);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function projectedKnownRecord(value, keys) {
|
|
495
|
+
const source = recordOrNull(value);
|
|
496
|
+
if (!source)
|
|
497
|
+
return null;
|
|
498
|
+
const output = {};
|
|
499
|
+
for (const key of keys) {
|
|
500
|
+
if (Object.hasOwn(source, key))
|
|
501
|
+
output[key] = projectLookupEvidencePrivacySafeTree(source[key]);
|
|
502
|
+
}
|
|
503
|
+
return output;
|
|
504
|
+
}
|
|
505
|
+
export function assertLookupEvidencePortableTree(value) {
|
|
506
|
+
const pending = [{ value, depth: 0 }];
|
|
507
|
+
let entries = 0;
|
|
508
|
+
while (pending.length) {
|
|
509
|
+
const current = pending.pop();
|
|
510
|
+
if (!current)
|
|
511
|
+
break;
|
|
512
|
+
entries += 1;
|
|
513
|
+
if (entries > LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES) {
|
|
514
|
+
throw new TypeError(`Lookup evidence exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES.toLocaleString('en')}-entry portable limit.`);
|
|
515
|
+
}
|
|
516
|
+
if (current.depth > LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH) {
|
|
517
|
+
throw new TypeError(`Lookup evidence exceeds the ${LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH}-level portable nesting limit.`);
|
|
518
|
+
}
|
|
519
|
+
if (typeof current.value === 'string') {
|
|
520
|
+
if (current.value.length > LOOKUP_EVIDENCE_PORTABLE_MAX_STRING_LENGTH) {
|
|
521
|
+
throw new TypeError('Lookup evidence contains an over-bound string.');
|
|
522
|
+
}
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (current.value === null || typeof current.value === 'boolean')
|
|
526
|
+
continue;
|
|
527
|
+
if (typeof current.value === 'number') {
|
|
528
|
+
if (!Number.isFinite(current.value)) {
|
|
529
|
+
throw new TypeError('Lookup evidence contains a non-finite number.');
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (Array.isArray(current.value)) {
|
|
534
|
+
if (current.value.length > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS) {
|
|
535
|
+
throw new TypeError('Lookup evidence contains an over-bound array.');
|
|
536
|
+
}
|
|
537
|
+
for (const item of current.value)
|
|
538
|
+
pending.push({ value: item, depth: current.depth + 1 });
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (current.value && typeof current.value === 'object') {
|
|
542
|
+
const source = current.value;
|
|
543
|
+
const prototype = Object.getPrototypeOf(source);
|
|
544
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
545
|
+
throw new TypeError('Lookup evidence contains a non-JSON object.');
|
|
546
|
+
}
|
|
547
|
+
const keys = Object.keys(source);
|
|
548
|
+
if (keys.length > LOOKUP_EVIDENCE_PORTABLE_MAX_ARRAY_ITEMS
|
|
549
|
+
|| keys.some((key) => key.length > LOOKUP_EVIDENCE_PORTABLE_MAX_KEY_LENGTH
|
|
550
|
+
|| /[\u0000-\u001f\u007f]/u.test(key)
|
|
551
|
+
|| !isSafeJsonObjectKey(key))) {
|
|
552
|
+
throw new TypeError('Lookup evidence contains an over-bound object.');
|
|
553
|
+
}
|
|
554
|
+
for (const item of Object.values(source))
|
|
555
|
+
pending.push({ value: item, depth: current.depth + 1 });
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
throw new TypeError('Lookup evidence contains a non-JSON value.');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
export function serializeLookupEvidence(value, pretty = false) {
|
|
562
|
+
assertLookupEvidencePortableTree(value);
|
|
563
|
+
let output;
|
|
564
|
+
try {
|
|
565
|
+
output = JSON.stringify(value, null, pretty ? 2 : 0);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
throw new TypeError('Lookup evidence could not be serialized as JSON.');
|
|
569
|
+
}
|
|
570
|
+
if (new TextEncoder().encode(output).byteLength > LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES) {
|
|
571
|
+
throw new TypeError('Lookup evidence exceeds the 5 MiB portable file limit.');
|
|
572
|
+
}
|
|
573
|
+
return output;
|
|
23
574
|
}
|
|
24
575
|
function boundedString(value, maximum) {
|
|
25
576
|
if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/.test(value))
|
|
@@ -34,18 +585,7 @@ function boundedHttpStatus(value) {
|
|
|
34
585
|
}
|
|
35
586
|
function boundedTimestamp(value) {
|
|
36
587
|
const text = boundedString(value, 64);
|
|
37
|
-
|
|
38
|
-
return null;
|
|
39
|
-
const time = Date.parse(text);
|
|
40
|
-
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
41
|
-
}
|
|
42
|
-
function boundedSemanticVersion(value) {
|
|
43
|
-
try {
|
|
44
|
-
return normalizeBoundedSemanticVersion(value, 'Evidence application');
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
588
|
+
return text ? normalizeExplicitIsoTimestamp(text) : null;
|
|
49
589
|
}
|
|
50
590
|
function boundedEndpoint(value) {
|
|
51
591
|
const text = boundedString(value, 2048);
|
|
@@ -70,11 +610,76 @@ function boundedStringList(value, count, length) {
|
|
|
70
610
|
.filter((item) => item !== null))];
|
|
71
611
|
}
|
|
72
612
|
function boundedHostname(value) {
|
|
73
|
-
const text = boundedString(value, 253)?.
|
|
74
|
-
|
|
613
|
+
const text = boundedString(value, 253)?.replace(/\.+$/u, '') || null;
|
|
614
|
+
return text && isValidAsciiHostname(text, { requireDot: false }) ? text.toLowerCase() : null;
|
|
615
|
+
}
|
|
616
|
+
function canonicalIpv4(value) {
|
|
617
|
+
const text = boundedString(value, 64);
|
|
618
|
+
if (!text || !/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(text))
|
|
619
|
+
return null;
|
|
620
|
+
const octets = text.split('.').map(Number);
|
|
621
|
+
if (octets.some((octet) => octet > 255))
|
|
622
|
+
return null;
|
|
623
|
+
return octets.join('.');
|
|
624
|
+
}
|
|
625
|
+
function canonicalIpv6(value) {
|
|
626
|
+
const text = boundedString(value, 64)?.toLowerCase();
|
|
627
|
+
if (!text || !text.includes(':') || text.includes('%'))
|
|
75
628
|
return null;
|
|
629
|
+
try {
|
|
630
|
+
const hostname = new URL(`http://[${text}]/`).hostname;
|
|
631
|
+
return hostname.startsWith('[') && hostname.endsWith(']')
|
|
632
|
+
? hostname.slice(1, -1).toLowerCase()
|
|
633
|
+
: null;
|
|
76
634
|
}
|
|
77
|
-
|
|
635
|
+
catch {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function canonicalAsn(value) {
|
|
640
|
+
const text = boundedString(value, 32);
|
|
641
|
+
const match = text?.match(/^(?:AS)?(\d+)$/iu);
|
|
642
|
+
if (!match)
|
|
643
|
+
return null;
|
|
644
|
+
const number = Number(match[1]);
|
|
645
|
+
return Number.isInteger(number) && number >= 0 && number <= 4_294_967_295
|
|
646
|
+
? `AS${number}`
|
|
647
|
+
: null;
|
|
648
|
+
}
|
|
649
|
+
export function projectLookupEvidenceQuery(value) {
|
|
650
|
+
const source = recordOrNull(value) || {};
|
|
651
|
+
const type = typeof source.type === 'string' && LOOKUP_QUERY_TYPES.has(source.type)
|
|
652
|
+
? source.type
|
|
653
|
+
: 'domain';
|
|
654
|
+
if (type === 'domain') {
|
|
655
|
+
const inputHostname = [source.inputHostname, source.registrableDomain, source.submitted, source.query]
|
|
656
|
+
.map(boundedHostname)
|
|
657
|
+
.find((item) => Boolean(item)) || 'unknown.invalid';
|
|
658
|
+
const suppliedRegistrableDomain = boundedHostname(source.registrableDomain);
|
|
659
|
+
const registrableDomain = suppliedRegistrableDomain
|
|
660
|
+
&& (inputHostname === suppliedRegistrableDomain || inputHostname.endsWith(`.${suppliedRegistrableDomain}`))
|
|
661
|
+
? suppliedRegistrableDomain
|
|
662
|
+
: inputHostname;
|
|
663
|
+
return {
|
|
664
|
+
submitted: inputHostname,
|
|
665
|
+
type,
|
|
666
|
+
inputHostname,
|
|
667
|
+
registrableDomain,
|
|
668
|
+
isSubdomain: inputHostname !== registrableDomain,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const candidate = source.submitted ?? source.query;
|
|
672
|
+
const submitted = type === 'ipv4'
|
|
673
|
+
? canonicalIpv4(candidate)
|
|
674
|
+
: type === 'ipv6'
|
|
675
|
+
? canonicalIpv6(candidate)
|
|
676
|
+
: canonicalAsn(candidate);
|
|
677
|
+
if (!submitted)
|
|
678
|
+
throw new TypeError(`Lookup evidence contains an invalid ${type} query.`);
|
|
679
|
+
return { submitted, type, inputHostname: null, registrableDomain: null, isSubdomain: false };
|
|
680
|
+
}
|
|
681
|
+
function completeTerminalObservation(status, truncated) {
|
|
682
|
+
return truncated !== true && (status === 'success' || status === 'not_found');
|
|
78
683
|
}
|
|
79
684
|
function boundedPublishedUri(value, protocols) {
|
|
80
685
|
const text = boundedString(value, 2048);
|
|
@@ -204,18 +809,189 @@ function sslblSource(value) {
|
|
|
204
809
|
limitations: boundedStringList(source.limitations, 10, 300),
|
|
205
810
|
};
|
|
206
811
|
}
|
|
207
|
-
function
|
|
812
|
+
function rdapAttempt(value) {
|
|
208
813
|
const attempt = recordOrNull(value) || {};
|
|
209
814
|
const endpoint = boundedEndpoint(attempt.endpoint);
|
|
210
815
|
return {
|
|
211
816
|
endpoint,
|
|
212
817
|
transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
213
818
|
status: boundedHttpStatus(attempt.status),
|
|
214
|
-
outcome:
|
|
819
|
+
outcome: typeof attempt.outcome === 'string' && RDAP_ATTEMPT_OUTCOMES.has(attempt.outcome)
|
|
820
|
+
? attempt.outcome
|
|
821
|
+
: 'unknown',
|
|
215
822
|
detail: boundedString(attempt.detail, 240),
|
|
216
823
|
selected: attempt.selected === true,
|
|
217
824
|
};
|
|
218
825
|
}
|
|
826
|
+
function boundedRdapAttempts(value, maximum) {
|
|
827
|
+
return (Array.isArray(value) ? value : [])
|
|
828
|
+
.slice(0, maximum)
|
|
829
|
+
.map(rdapAttempt);
|
|
830
|
+
}
|
|
831
|
+
function whoisChainHop(value) {
|
|
832
|
+
const hop = recordOrNull(value) || {};
|
|
833
|
+
const queryProfile = typeof hop.queryProfile === 'string' && WHOIS_QUERY_PROFILES.has(hop.queryProfile)
|
|
834
|
+
? hop.queryProfile
|
|
835
|
+
: null;
|
|
836
|
+
const error = boundedString(hop.error, 240);
|
|
837
|
+
return {
|
|
838
|
+
server: boundedHostname(hop.server),
|
|
839
|
+
address: boundedString(hop.address, 64),
|
|
840
|
+
queriedAt: boundedTimestamp(hop.queriedAt),
|
|
841
|
+
queryProfile,
|
|
842
|
+
responseEncoding: hop.responseEncoding === 'utf-8' ? 'utf-8' : null,
|
|
843
|
+
status: queryProfile === 'not-issued'
|
|
844
|
+
? 'not_issued'
|
|
845
|
+
: error
|
|
846
|
+
? 'error'
|
|
847
|
+
: typeof hop.response === 'string'
|
|
848
|
+
? 'success'
|
|
849
|
+
: 'unknown',
|
|
850
|
+
detail: error,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
function boundedWhoisChain(value) {
|
|
854
|
+
return (Array.isArray(value) ? value : [])
|
|
855
|
+
.slice(0, 16)
|
|
856
|
+
.map(whoisChainHop);
|
|
857
|
+
}
|
|
858
|
+
function lookupTimingDiagnostics(value) {
|
|
859
|
+
const source = recordOrNull(value);
|
|
860
|
+
if (source?.version !== 1)
|
|
861
|
+
return null;
|
|
862
|
+
return {
|
|
863
|
+
version: 1,
|
|
864
|
+
totalMs: boundedInteger(source.totalMs, 120_000),
|
|
865
|
+
sources: (Array.isArray(source.sources) ? source.sources : [])
|
|
866
|
+
.slice(0, LOOKUP_TIMING_SOURCES.size)
|
|
867
|
+
.flatMap((item) => {
|
|
868
|
+
const entry = recordOrNull(item);
|
|
869
|
+
if (!entry
|
|
870
|
+
|| typeof entry.source !== 'string'
|
|
871
|
+
|| !LOOKUP_TIMING_SOURCES.has(entry.source)
|
|
872
|
+
|| (entry.outcome !== 'fulfilled' && entry.outcome !== 'rejected'))
|
|
873
|
+
return [];
|
|
874
|
+
return [{
|
|
875
|
+
source: entry.source,
|
|
876
|
+
outcome: entry.outcome,
|
|
877
|
+
durationMs: boundedInteger(entry.durationMs, 120_000),
|
|
878
|
+
completedAfterMs: boundedInteger(entry.completedAfterMs, 120_000),
|
|
879
|
+
}];
|
|
880
|
+
}),
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
function registryAccessDiagnostics(value) {
|
|
884
|
+
const source = recordOrNull(value);
|
|
885
|
+
if (!source)
|
|
886
|
+
return null;
|
|
887
|
+
const officialLookupUrl = portableUri(source.officialLookupUrl);
|
|
888
|
+
return {
|
|
889
|
+
suffix: boundedHostname(source.suffix),
|
|
890
|
+
coverageState: boundedString(source.coverageState, 40),
|
|
891
|
+
whoisAccessProfile: boundedString(source.whoisAccessProfile, 80),
|
|
892
|
+
rdapAccessProfile: boundedString(source.rdapAccessProfile, 80),
|
|
893
|
+
...(officialLookupUrl ? { officialLookupUrl } : {}),
|
|
894
|
+
limitation: boundedString(source.limitation, 500),
|
|
895
|
+
authority: source.authority === 'context_only' ? 'context_only' : null,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function registrarRdapDiagnostics(value) {
|
|
899
|
+
const source = recordOrNull(value);
|
|
900
|
+
if (!source)
|
|
901
|
+
return null;
|
|
902
|
+
const endpoint = boundedEndpoint(source.endpoint);
|
|
903
|
+
return {
|
|
904
|
+
status: sourceStatus(source.status, REGISTRAR_RDAP_STATUSES, 'error'),
|
|
905
|
+
endpoint,
|
|
906
|
+
transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
907
|
+
httpStatus: boundedHttpStatus(source.httpStatus),
|
|
908
|
+
fetchedAt: boundedTimestamp(source.fetchedAt),
|
|
909
|
+
attempt: source.attempt === null || source.attempt === undefined ? null : rdapAttempt(source.attempt),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function lookupDiagnostics(value) {
|
|
913
|
+
const source = recordOrNull(value) || {};
|
|
914
|
+
const rdap = recordOrNull(source.rdap) || {};
|
|
915
|
+
const whois = recordOrNull(source.whois) || {};
|
|
916
|
+
const availability = recordOrNull(source.availability) || {};
|
|
917
|
+
const reverseDns = recordOrNull(source.reverseDns);
|
|
918
|
+
const network = recordOrNull(source.network);
|
|
919
|
+
const securityTxt = recordOrNull(source.securityTxt);
|
|
920
|
+
const sslbl = recordOrNull(source.sslbl);
|
|
921
|
+
const rdapEndpoint = boundedEndpoint(rdap.endpoint);
|
|
922
|
+
const networkEndpoint = boundedEndpoint(network?.endpoint);
|
|
923
|
+
const networkPublicationAvailable = network?.status === 'success' || network?.status === 'partial';
|
|
924
|
+
const securityTxtEndpoint = boundedPublishedUri(securityTxt?.endpoint, ['https:']);
|
|
925
|
+
const version = boundedInteger(source.version, 10);
|
|
926
|
+
const timing = lookupTimingDiagnostics(source.timing);
|
|
927
|
+
const registryAccess = registryAccessDiagnostics(source.registryAccess);
|
|
928
|
+
const registrar = registrarRdapDiagnostics(rdap.registrar);
|
|
929
|
+
return {
|
|
930
|
+
...(version === null ? {} : { version }),
|
|
931
|
+
...(timing ? { timing } : {}),
|
|
932
|
+
...(registryAccess ? { registryAccess } : {}),
|
|
933
|
+
rdap: {
|
|
934
|
+
status: sourceStatus(rdap.status, RDAP_SOURCE_STATUSES, 'error'),
|
|
935
|
+
errorCode: boundedString(rdap.errorCode, 80),
|
|
936
|
+
endpoint: rdapEndpoint,
|
|
937
|
+
transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
938
|
+
httpStatus: boundedHttpStatus(rdap.httpStatus),
|
|
939
|
+
fetchedAt: boundedTimestamp(rdap.fetchedAt),
|
|
940
|
+
attempts: boundedRdapAttempts(rdap.attempts, 16),
|
|
941
|
+
...(registrar ? { registrar } : {}),
|
|
942
|
+
},
|
|
943
|
+
whois: {
|
|
944
|
+
status: sourceStatus(whois.status, WHOIS_SOURCE_STATUSES, 'error'),
|
|
945
|
+
errorCode: boundedString(whois.errorCode, 80),
|
|
946
|
+
queriedAt: boundedTimestamp(whois.queriedAt),
|
|
947
|
+
authoritativeHop: boundedHostname(whois.authoritativeHop),
|
|
948
|
+
failedHop: boundedHostname(whois.failedHop),
|
|
949
|
+
conflictingHop: boundedHostname(whois.conflictingHop),
|
|
950
|
+
},
|
|
951
|
+
...(recordOrNull(source.availability) ? { availability: {
|
|
952
|
+
status: sourceStatus(availability.status, AVAILABILITY_DIAGNOSTIC_STATUSES, 'error'),
|
|
953
|
+
errorCode: boundedString(availability.errorCode, 80),
|
|
954
|
+
resultState: boundedString(availability.resultState, 40),
|
|
955
|
+
} } : {}),
|
|
956
|
+
...(reverseDns ? { reverseDns: {
|
|
957
|
+
status: sourceStatus(reverseDns.status, REVERSE_DNS_STATUSES, 'error'),
|
|
958
|
+
observedAt: boundedTimestamp(reverseDns.observedAt),
|
|
959
|
+
complete: completeTerminalObservation(sourceStatus(reverseDns.status, REVERSE_DNS_STATUSES, 'error'), reverseDns.truncated),
|
|
960
|
+
truncated: reverseDns.truncated === true,
|
|
961
|
+
} } : {}),
|
|
962
|
+
...(network ? { network: {
|
|
963
|
+
status: sourceStatus(network.status, NETWORK_CONTEXT_STATUSES, 'error'),
|
|
964
|
+
address: networkPublicationAvailable ? boundedString(network.address, 64) : null,
|
|
965
|
+
family: networkPublicationAvailable && (network.family === 4 || network.family === 6) ? network.family : null,
|
|
966
|
+
addressSource: networkPublicationAvailable && typeof network.addressSource === 'string' && NETWORK_ADDRESS_SOURCES.has(network.addressSource)
|
|
967
|
+
? network.addressSource
|
|
968
|
+
: null,
|
|
969
|
+
endpoint: networkPublicationAvailable ? networkEndpoint : null,
|
|
970
|
+
transportSecurity: networkPublicationAvailable && networkEndpoint ? networkEndpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
971
|
+
httpStatus: networkPublicationAvailable ? boundedHttpStatus(network.httpStatus) : null,
|
|
972
|
+
fetchedAt: networkPublicationAvailable ? boundedTimestamp(network.fetchedAt) : null,
|
|
973
|
+
attempts: networkPublicationAvailable ? boundedRdapAttempts(network.attempts, 3) : [],
|
|
974
|
+
} } : {}),
|
|
975
|
+
...(securityTxt ? { securityTxt: {
|
|
976
|
+
status: boundedString(securityTxt.status, 40),
|
|
977
|
+
state: typeof securityTxt.state === 'string' && SECURITY_TXT_STATES.has(securityTxt.state)
|
|
978
|
+
? securityTxt.state
|
|
979
|
+
: null,
|
|
980
|
+
endpoint: securityTxtEndpoint,
|
|
981
|
+
httpStatus: boundedHttpStatus(securityTxt.httpStatus),
|
|
982
|
+
observedAt: boundedTimestamp(securityTxt.observedAt),
|
|
983
|
+
complete: securityTxt.complete === true,
|
|
984
|
+
truncated: securityTxt.truncated === true,
|
|
985
|
+
} } : {}),
|
|
986
|
+
...(sslbl ? { sslbl: {
|
|
987
|
+
status: typeof sslbl.status === 'string' && SSLBL_STATES.has(sslbl.status) ? sslbl.status : null,
|
|
988
|
+
verdict: typeof sslbl.verdict === 'string' && SSLBL_VERDICTS.has(sslbl.verdict) ? sslbl.verdict : null,
|
|
989
|
+
observedAt: boundedTimestamp(sslbl.observedAt),
|
|
990
|
+
complete: sslbl.complete === true,
|
|
991
|
+
snapshotUpdatedAt: boundedTimestamp(sslbl.snapshotUpdatedAt),
|
|
992
|
+
} } : {}),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
219
995
|
function networkSource(value) {
|
|
220
996
|
const source = recordOrNull(value);
|
|
221
997
|
if (!source
|
|
@@ -228,6 +1004,7 @@ function networkSource(value) {
|
|
|
228
1004
|
const network = recordOrNull(source.network);
|
|
229
1005
|
const diagnostics = recordOrNull(source.diagnostics);
|
|
230
1006
|
const rdapEndpoint = boundedEndpoint(rdap?.endpoint);
|
|
1007
|
+
const publicationAvailable = source.status === 'success' || source.status === 'partial';
|
|
231
1008
|
return {
|
|
232
1009
|
contextVersion: 1,
|
|
233
1010
|
version: source.version === 1 ? 1 : null,
|
|
@@ -236,7 +1013,7 @@ function networkSource(value) {
|
|
|
236
1013
|
scanMode: source.scanMode === 'deep' ? 'deep' : null,
|
|
237
1014
|
source: source.source === 'ip_rdap' ? 'ip_rdap' : null,
|
|
238
1015
|
durationMs: boundedInteger(source.durationMs, 120_000),
|
|
239
|
-
complete: source.
|
|
1016
|
+
complete: completeTerminalObservation(source.status, source.truncated),
|
|
240
1017
|
truncated: source.truncated === true,
|
|
241
1018
|
limitations: boundedStringList(source.limitations, 10, 300),
|
|
242
1019
|
diagnostics: diagnostics ? {
|
|
@@ -249,7 +1026,7 @@ function networkSource(value) {
|
|
|
249
1026
|
cidrCount: boundedInteger(diagnostics.cidrCount, 16),
|
|
250
1027
|
} : null,
|
|
251
1028
|
detail: boundedString(source.detail, 300),
|
|
252
|
-
endpoint: endpoint ? {
|
|
1029
|
+
endpoint: publicationAvailable && endpoint ? {
|
|
253
1030
|
address: boundedString(endpoint.address, 64),
|
|
254
1031
|
family: endpoint.family === 4 || endpoint.family === 6 ? endpoint.family : null,
|
|
255
1032
|
selectedFrom: typeof endpoint.selectedFrom === 'string'
|
|
@@ -257,14 +1034,14 @@ function networkSource(value) {
|
|
|
257
1034
|
? endpoint.selectedFrom
|
|
258
1035
|
: null,
|
|
259
1036
|
} : null,
|
|
260
|
-
rdap: rdap ? {
|
|
1037
|
+
rdap: publicationAvailable && rdap ? {
|
|
261
1038
|
endpoint: rdapEndpoint,
|
|
262
1039
|
transportSecurity: rdapEndpoint ? rdapEndpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
263
1040
|
httpStatus: boundedHttpStatus(rdap.httpStatus),
|
|
264
1041
|
fetchedAt: boundedTimestamp(rdap.fetchedAt),
|
|
265
|
-
attempts: (
|
|
1042
|
+
attempts: boundedRdapAttempts(rdap.attempts, 3),
|
|
266
1043
|
} : null,
|
|
267
|
-
network: network ? {
|
|
1044
|
+
network: publicationAvailable && network ? {
|
|
268
1045
|
handle: boundedString(network.handle, 300),
|
|
269
1046
|
name: boundedString(network.name, 300),
|
|
270
1047
|
holder: boundedString(network.holder, 300),
|
|
@@ -289,7 +1066,8 @@ function reverseDnsSource(value) {
|
|
|
289
1066
|
const records = recordOrNull(source.records);
|
|
290
1067
|
const diagnostics = recordOrNull(source.diagnostics);
|
|
291
1068
|
const ptrDiagnostics = recordOrNull(diagnostics?.ptr);
|
|
292
|
-
const
|
|
1069
|
+
const publicationAvailable = source.status === 'success' || source.status === 'partial';
|
|
1070
|
+
const ptr = [...new Set((publicationAvailable && Array.isArray(records?.ptr) ? records.ptr : [])
|
|
293
1071
|
.slice(0, 8)
|
|
294
1072
|
.map(boundedHostname)
|
|
295
1073
|
.filter((item) => item !== null))].sort();
|
|
@@ -300,7 +1078,7 @@ function reverseDnsSource(value) {
|
|
|
300
1078
|
scanMode: source.scanMode === 'deep' ? 'deep' : null,
|
|
301
1079
|
source: 'reverse_dns',
|
|
302
1080
|
durationMs: boundedInteger(source.durationMs, 120_000),
|
|
303
|
-
complete: source.
|
|
1081
|
+
complete: completeTerminalObservation(source.status, source.truncated),
|
|
304
1082
|
truncated: source.truncated === true,
|
|
305
1083
|
limitations: boundedStringList(source.limitations, 10, 300),
|
|
306
1084
|
diagnostics: ptrDiagnostics ? {
|
|
@@ -314,40 +1092,405 @@ function reverseDnsSource(value) {
|
|
|
314
1092
|
records: { ptr },
|
|
315
1093
|
};
|
|
316
1094
|
}
|
|
317
|
-
function
|
|
1095
|
+
function boundedSourceError(value, fallback) {
|
|
1096
|
+
const normalized = String(value ?? '')
|
|
1097
|
+
.replace(/[\u0000-\u001f\u007f]+/gu, ' ')
|
|
1098
|
+
.replace(/\s+/gu, ' ')
|
|
1099
|
+
.trim();
|
|
1100
|
+
return normalized.slice(0, 10_000) || fallback;
|
|
1101
|
+
}
|
|
1102
|
+
function nullableText(maximum) {
|
|
1103
|
+
return (value) => value === null ? null : boundedString(value, maximum) ?? undefined;
|
|
1104
|
+
}
|
|
1105
|
+
function nullableTimestamp(value) {
|
|
1106
|
+
return value === null ? null : boundedTimestamp(value) ?? undefined;
|
|
1107
|
+
}
|
|
1108
|
+
function booleanValue(nullable = false) {
|
|
1109
|
+
return (value) => typeof value === 'boolean' ? value : nullable && value === null ? null : undefined;
|
|
1110
|
+
}
|
|
1111
|
+
function nullableInteger(maximum) {
|
|
1112
|
+
return (value) => value === null ? null : boundedInteger(value, maximum) ?? undefined;
|
|
1113
|
+
}
|
|
1114
|
+
function enumeration(values, nullable = false) {
|
|
1115
|
+
return (value) => typeof value === 'string' && values.has(value)
|
|
1116
|
+
? value
|
|
1117
|
+
: nullable && value === null ? null : undefined;
|
|
1118
|
+
}
|
|
1119
|
+
const LOOKUP_RDAP_PORTABLE_SCALARS = Object.freeze({
|
|
1120
|
+
objectClassName: nullableText(80),
|
|
1121
|
+
language: nullableText(35),
|
|
1122
|
+
conformanceTruncated: booleanValue(),
|
|
1123
|
+
redactionsTruncated: booleanValue(),
|
|
1124
|
+
port43: nullableText(300),
|
|
1125
|
+
parentHandle: nullableText(300),
|
|
1126
|
+
linksTruncated: booleanValue(),
|
|
1127
|
+
noticesTruncated: booleanValue(),
|
|
1128
|
+
remarksTruncated: booleanValue(),
|
|
1129
|
+
serverTruncated: booleanValue(),
|
|
1130
|
+
statusesTruncated: booleanValue(),
|
|
1131
|
+
eventsTruncated: booleanValue(),
|
|
1132
|
+
domain: nullableText(253),
|
|
1133
|
+
unicodeDomain: nullableText(253),
|
|
1134
|
+
handle: nullableText(300),
|
|
1135
|
+
nameserversTruncated: booleanValue(),
|
|
1136
|
+
nameserverAddressesTruncated: booleanValue(),
|
|
1137
|
+
dnssec: enumeration(new Set(['Signed', 'Unsigned', 'Unknown']), true),
|
|
1138
|
+
zoneSigned: booleanValue(true),
|
|
1139
|
+
delegationSigned: booleanValue(true),
|
|
1140
|
+
dsDataTruncated: booleanValue(),
|
|
1141
|
+
variantsTruncated: booleanValue(),
|
|
1142
|
+
registrarIanaId: nullableText(300),
|
|
1143
|
+
entitiesTruncated: booleanValue(),
|
|
1144
|
+
name: nullableText(300),
|
|
1145
|
+
startAddress: nullableText(80),
|
|
1146
|
+
endAddress: nullableText(80),
|
|
1147
|
+
cidrsTruncated: booleanValue(),
|
|
1148
|
+
country: nullableText(2),
|
|
1149
|
+
networkType: nullableText(160),
|
|
1150
|
+
startAutnum: nullableInteger(4_294_967_295),
|
|
1151
|
+
endAutnum: nullableInteger(4_294_967_295),
|
|
1152
|
+
autnumType: nullableText(160),
|
|
1153
|
+
});
|
|
1154
|
+
const LOOKUP_RDAP_PORTABLE_STRING_LISTS = Object.freeze({
|
|
1155
|
+
conformance: [50, 160],
|
|
1156
|
+
serverTruncationReasons: [8, 160],
|
|
1157
|
+
statuses: [100, 160],
|
|
1158
|
+
nameservers: [200, 253],
|
|
1159
|
+
truncatedEntityRoles: [11, 80],
|
|
1160
|
+
cidrs: [200, 160],
|
|
1161
|
+
});
|
|
1162
|
+
const LOOKUP_RDAP_EVENT_SCALARS = Object.freeze({
|
|
1163
|
+
action: nullableText(100),
|
|
1164
|
+
date: nullableText(64),
|
|
1165
|
+
});
|
|
1166
|
+
const LOOKUP_RDAP_LIFECYCLE_SCALARS = Object.freeze({
|
|
1167
|
+
createdDate: nullableText(64),
|
|
1168
|
+
reregistrationDate: nullableText(64),
|
|
1169
|
+
expiryDate: nullableText(64),
|
|
1170
|
+
updatedDate: nullableText(64),
|
|
1171
|
+
transferDate: nullableText(64),
|
|
1172
|
+
deletionDate: nullableText(64),
|
|
1173
|
+
reinstantiationDate: nullableText(64),
|
|
1174
|
+
databaseUpdatedDate: nullableText(64),
|
|
1175
|
+
createdDateIso: nullableTimestamp,
|
|
1176
|
+
reregistrationDateIso: nullableTimestamp,
|
|
1177
|
+
expiryDateIso: nullableTimestamp,
|
|
1178
|
+
updatedDateIso: nullableTimestamp,
|
|
1179
|
+
transferDateIso: nullableTimestamp,
|
|
1180
|
+
deletionDateIso: nullableTimestamp,
|
|
1181
|
+
reinstantiationDateIso: nullableTimestamp,
|
|
1182
|
+
databaseUpdatedDateIso: nullableTimestamp,
|
|
1183
|
+
});
|
|
1184
|
+
const LOOKUP_RDAP_REDACTION_SCALARS = Object.freeze({
|
|
1185
|
+
name: nullableText(300),
|
|
1186
|
+
reason: nullableText(300),
|
|
1187
|
+
method: nullableText(80),
|
|
1188
|
+
pathLanguage: nullableText(80),
|
|
1189
|
+
prePath: nullableText(512),
|
|
1190
|
+
postPath: nullableText(512),
|
|
1191
|
+
replacementPath: nullableText(512),
|
|
1192
|
+
});
|
|
1193
|
+
const LOOKUP_RDAP_REGISTRAR_SCALARS = Object.freeze({
|
|
1194
|
+
handle: nullableText(200),
|
|
1195
|
+
name: nullableText(300),
|
|
1196
|
+
org: nullableText(300),
|
|
1197
|
+
truncated: booleanValue(),
|
|
1198
|
+
});
|
|
1199
|
+
const LOOKUP_RDAP_PUBLIC_ID_SCALARS = Object.freeze({
|
|
1200
|
+
type: nullableText(160),
|
|
1201
|
+
identifier: nullableText(300),
|
|
1202
|
+
});
|
|
1203
|
+
const LOOKUP_RDAP_NAMESERVER_SCALARS = Object.freeze({
|
|
1204
|
+
name: nullableText(253),
|
|
1205
|
+
});
|
|
1206
|
+
const LOOKUP_RDAP_DS_SCALARS = Object.freeze({
|
|
1207
|
+
keyTag: nullableInteger(65_535),
|
|
1208
|
+
algorithm: nullableInteger(255),
|
|
1209
|
+
digestType: nullableInteger(255),
|
|
1210
|
+
digest: nullableText(512),
|
|
1211
|
+
});
|
|
1212
|
+
const LOOKUP_RDAP_VARIANT_SCALARS = Object.freeze({
|
|
1213
|
+
idnTable: nullableText(300),
|
|
1214
|
+
});
|
|
1215
|
+
const LOOKUP_RDAP_VARIANT_NAME_SCALARS = Object.freeze({
|
|
1216
|
+
ldhName: nullableText(253),
|
|
1217
|
+
unicodeName: nullableText(253),
|
|
1218
|
+
});
|
|
1219
|
+
const LOOKUP_WHOIS_PORTABLE_SCALARS = Object.freeze({
|
|
1220
|
+
domainName: nullableText(253),
|
|
1221
|
+
registryDomainId: nullableText(300),
|
|
1222
|
+
registrar: nullableText(300),
|
|
1223
|
+
registrarIanaId: nullableText(300),
|
|
1224
|
+
createdDate: nullableText(100),
|
|
1225
|
+
expiryDate: nullableText(100),
|
|
1226
|
+
updatedDate: nullableText(100),
|
|
1227
|
+
createdDateIso: nullableTimestamp,
|
|
1228
|
+
expiryDateIso: nullableTimestamp,
|
|
1229
|
+
updatedDateIso: nullableTimestamp,
|
|
1230
|
+
statusesTruncated: booleanValue(),
|
|
1231
|
+
nameserversTruncated: booleanValue(),
|
|
1232
|
+
dnssec: nullableText(300),
|
|
1233
|
+
chainStatus: enumeration(new Set(['complete', 'partial'])),
|
|
1234
|
+
authoritativeHop: nullableText(253),
|
|
1235
|
+
failedHop: nullableText(253),
|
|
1236
|
+
conflictingHop: nullableText(253),
|
|
1237
|
+
});
|
|
1238
|
+
const LOOKUP_REGISTRY_INSIGHT_PORTABLE_KEYS = new Set([
|
|
1239
|
+
'version', 'contactDisclosure', 'lifecycle', 'reconciliation', 'publications',
|
|
1240
|
+
'rdapCapabilities',
|
|
1241
|
+
]);
|
|
1242
|
+
function projectKnownArray(value, projectors, maximum) {
|
|
1243
|
+
if (!Array.isArray(value))
|
|
1244
|
+
return [];
|
|
1245
|
+
return value.slice(0, maximum)
|
|
1246
|
+
.map((item) => projectedKnownScalarRecord(item, projectors))
|
|
1247
|
+
.filter((item) => item !== null);
|
|
1248
|
+
}
|
|
1249
|
+
function projectedKnownScalarRecord(value, projectors) {
|
|
1250
|
+
const source = recordOrNull(value);
|
|
1251
|
+
if (!source)
|
|
1252
|
+
return null;
|
|
1253
|
+
const output = {};
|
|
1254
|
+
for (const [key, projector] of Object.entries(projectors)) {
|
|
1255
|
+
if (!Object.hasOwn(source, key))
|
|
1256
|
+
continue;
|
|
1257
|
+
const item = projector(source[key]);
|
|
1258
|
+
if (item !== undefined)
|
|
1259
|
+
output[key] = item;
|
|
1260
|
+
}
|
|
1261
|
+
return output;
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Positive portable projection for normalized RDAP. Contact entities, vCards,
|
|
1265
|
+
* entity inventories, arbitrary links/notices/remarks, and raw payloads never
|
|
1266
|
+
* cross this boundary. The marker prevents deliberate omission from becoming
|
|
1267
|
+
* an apparent source assertion that contacts were absent.
|
|
1268
|
+
*/
|
|
1269
|
+
export function projectLookupEvidenceRdapPublication(value) {
|
|
1270
|
+
const source = recordOrNull(value);
|
|
1271
|
+
if (!source)
|
|
1272
|
+
return null;
|
|
1273
|
+
const output = projectedKnownScalarRecord(source, LOOKUP_RDAP_PORTABLE_SCALARS) || {};
|
|
1274
|
+
for (const [key, [count, length]] of Object.entries(LOOKUP_RDAP_PORTABLE_STRING_LISTS)) {
|
|
1275
|
+
if (Object.hasOwn(source, key))
|
|
1276
|
+
output[key] = boundedStringList(source[key], count, length);
|
|
1277
|
+
}
|
|
1278
|
+
if (Object.hasOwn(source, 'events')) {
|
|
1279
|
+
output.events = projectKnownArray(source.events, LOOKUP_RDAP_EVENT_SCALARS, 100)
|
|
1280
|
+
.filter((event) => typeof event.action === 'string' || typeof event.date === 'string');
|
|
1281
|
+
}
|
|
1282
|
+
if (Object.hasOwn(source, 'lifecycle')) {
|
|
1283
|
+
output.lifecycle = projectedKnownScalarRecord(source.lifecycle, LOOKUP_RDAP_LIFECYCLE_SCALARS);
|
|
1284
|
+
}
|
|
1285
|
+
if (Object.hasOwn(source, 'redactions')) {
|
|
1286
|
+
const inputRedactions = Array.isArray(source.redactions) ? source.redactions : [];
|
|
1287
|
+
const redactions = projectKnownArray(inputRedactions, LOOKUP_RDAP_REDACTION_SCALARS, 100)
|
|
1288
|
+
.filter((redaction) => Object.values(redaction).some((item) => item !== null));
|
|
1289
|
+
output.redactions = redactions;
|
|
1290
|
+
output.redactionsTruncated = source.redactionsTruncated === true
|
|
1291
|
+
|| !Array.isArray(source.redactions)
|
|
1292
|
+
|| inputRedactions.length > 100
|
|
1293
|
+
|| redactions.length < Math.min(inputRedactions.length, 100);
|
|
1294
|
+
}
|
|
1295
|
+
if (Object.hasOwn(source, 'nameserverDetails')) {
|
|
1296
|
+
output.nameserverDetails = (Array.isArray(source.nameserverDetails) ? source.nameserverDetails : [])
|
|
1297
|
+
.slice(0, 200)
|
|
1298
|
+
.map((item) => {
|
|
1299
|
+
const nameserver = projectedKnownScalarRecord(item, LOOKUP_RDAP_NAMESERVER_SCALARS);
|
|
1300
|
+
const original = recordOrNull(item);
|
|
1301
|
+
if (!nameserver || !original || typeof nameserver.name !== 'string')
|
|
1302
|
+
return null;
|
|
1303
|
+
nameserver.addresses = boundedStringList(original.addresses, 20, 80);
|
|
1304
|
+
return nameserver;
|
|
1305
|
+
})
|
|
1306
|
+
.filter((item) => item !== null);
|
|
1307
|
+
}
|
|
1308
|
+
if (Object.hasOwn(source, 'dsData')) {
|
|
1309
|
+
output.dsData = projectKnownArray(source.dsData, LOOKUP_RDAP_DS_SCALARS, 50)
|
|
1310
|
+
.filter((item) => Number.isInteger(item.keyTag)
|
|
1311
|
+
&& Number.isInteger(item.algorithm)
|
|
1312
|
+
&& Number.isInteger(item.digestType)
|
|
1313
|
+
&& typeof item.digest === 'string'
|
|
1314
|
+
&& item.digest.length % 2 === 0
|
|
1315
|
+
&& /^[0-9a-f]+$/iu.test(item.digest));
|
|
1316
|
+
}
|
|
1317
|
+
if (Object.hasOwn(source, 'variants') && Array.isArray(source.variants)) {
|
|
1318
|
+
output.variants = source.variants.slice(0, 20).map((item) => {
|
|
1319
|
+
const group = projectedKnownScalarRecord(item, LOOKUP_RDAP_VARIANT_SCALARS);
|
|
1320
|
+
if (!group)
|
|
1321
|
+
return null;
|
|
1322
|
+
const original = recordOrNull(item);
|
|
1323
|
+
const variantNames = projectKnownArray(original?.variantNames, LOOKUP_RDAP_VARIANT_NAME_SCALARS, 50)
|
|
1324
|
+
.filter((name) => typeof name.ldhName === 'string' || typeof name.unicodeName === 'string');
|
|
1325
|
+
const relation = boundedStringList(original?.relation, 20, 100);
|
|
1326
|
+
group.variantNames = variantNames;
|
|
1327
|
+
group.relation = relation;
|
|
1328
|
+
return typeof group.idnTable === 'string' || variantNames.length || relation.length
|
|
1329
|
+
? group
|
|
1330
|
+
: null;
|
|
1331
|
+
}).filter((item) => item !== null);
|
|
1332
|
+
}
|
|
1333
|
+
if (Object.hasOwn(source, 'registrar')) {
|
|
1334
|
+
const registrar = projectedKnownScalarRecord(source.registrar, LOOKUP_RDAP_REGISTRAR_SCALARS);
|
|
1335
|
+
if (registrar) {
|
|
1336
|
+
const original = recordOrNull(source.registrar);
|
|
1337
|
+
registrar.roles = boundedStringList(original?.roles, 12, 80);
|
|
1338
|
+
registrar.publicIds = projectKnownArray(original?.publicIds, LOOKUP_RDAP_PUBLIC_ID_SCALARS, 20)
|
|
1339
|
+
.filter((publicId) => typeof publicId.type === 'string'
|
|
1340
|
+
&& typeof publicId.identifier === 'string');
|
|
1341
|
+
}
|
|
1342
|
+
output.registrar = registrar;
|
|
1343
|
+
}
|
|
1344
|
+
output.contactsExcluded = true;
|
|
1345
|
+
return output;
|
|
1346
|
+
}
|
|
1347
|
+
/** Positive portable projection for normalized WHOIS publication fields. */
|
|
1348
|
+
export function projectLookupEvidenceWhoisPublication(value) {
|
|
1349
|
+
const input = recordOrNull(value);
|
|
1350
|
+
if (!input)
|
|
1351
|
+
return null;
|
|
1352
|
+
const output = projectedKnownScalarRecord(input, LOOKUP_WHOIS_PORTABLE_SCALARS) || {};
|
|
1353
|
+
if (Object.hasOwn(input, 'lifecycle')) {
|
|
1354
|
+
output.lifecycle = projectedKnownScalarRecord(input.lifecycle, LOOKUP_RDAP_LIFECYCLE_SCALARS);
|
|
1355
|
+
}
|
|
1356
|
+
for (const [key, count, length] of [
|
|
1357
|
+
['statuses', 100, 160],
|
|
1358
|
+
['nameservers', 200, 253],
|
|
1359
|
+
['fieldsTruncated', 64, 80],
|
|
1360
|
+
]) {
|
|
1361
|
+
if (Object.hasOwn(input, key))
|
|
1362
|
+
output[key] = boundedStringList(input[key], count, length);
|
|
1363
|
+
}
|
|
1364
|
+
output.contactsExcluded = true;
|
|
1365
|
+
return output;
|
|
1366
|
+
}
|
|
1367
|
+
/** Exact positive projection for one current-schema RDAP source wrapper. */
|
|
1368
|
+
export function projectLookupEvidenceRdapSourcePublication(value) {
|
|
1369
|
+
const source = recordOrNull(value);
|
|
1370
|
+
if (!source)
|
|
1371
|
+
return null;
|
|
1372
|
+
const status = sourceStatus(source.status, RDAP_SOURCE_STATUSES, 'error');
|
|
1373
|
+
const attempts = boundedRdapAttempts(source.attempts, 16);
|
|
1374
|
+
if (status === 'error')
|
|
1375
|
+
return {
|
|
1376
|
+
status,
|
|
1377
|
+
error: boundedSourceError(source.error, 'RDAP source reported an error.'),
|
|
1378
|
+
attempts,
|
|
1379
|
+
};
|
|
1380
|
+
const endpoint = boundedEndpoint(source.endpoint);
|
|
1381
|
+
const hasPublication = status === 'success' || status === 'partial';
|
|
1382
|
+
const parsed = hasPublication ? projectLookupEvidenceRdapPublication(source.parsed) : null;
|
|
1383
|
+
if (status === 'success' && parsed === null)
|
|
1384
|
+
return null;
|
|
1385
|
+
return {
|
|
1386
|
+
status,
|
|
1387
|
+
endpoint,
|
|
1388
|
+
transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
1389
|
+
httpStatus: boundedHttpStatus(source.httpStatus),
|
|
1390
|
+
fetchedAt: boundedTimestamp(source.fetchedAt),
|
|
1391
|
+
attempts,
|
|
1392
|
+
parsed,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
function projectedWhoisChainHop(value) {
|
|
1396
|
+
const hop = recordOrNull(value);
|
|
1397
|
+
if (!hop)
|
|
1398
|
+
return null;
|
|
1399
|
+
const queryProfile = typeof hop.queryProfile === 'string' && WHOIS_QUERY_PROFILES.has(hop.queryProfile)
|
|
1400
|
+
? hop.queryProfile
|
|
1401
|
+
: null;
|
|
1402
|
+
const status = typeof hop.status === 'string'
|
|
1403
|
+
&& ['success', 'error', 'not_issued', 'unknown'].includes(hop.status)
|
|
1404
|
+
? hop.status
|
|
1405
|
+
: 'unknown';
|
|
1406
|
+
return {
|
|
1407
|
+
server: boundedHostname(hop.server),
|
|
1408
|
+
address: boundedString(hop.address, 64),
|
|
1409
|
+
queriedAt: boundedTimestamp(hop.queriedAt),
|
|
1410
|
+
queryProfile,
|
|
1411
|
+
responseEncoding: hop.responseEncoding === 'utf-8' ? 'utf-8' : null,
|
|
1412
|
+
status,
|
|
1413
|
+
detail: boundedString(hop.detail, 240),
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
/** Exact positive projection for one current-schema WHOIS source wrapper. */
|
|
1417
|
+
export function projectLookupEvidenceWhoisSourcePublication(value) {
|
|
1418
|
+
const source = recordOrNull(value);
|
|
1419
|
+
if (!source)
|
|
1420
|
+
return null;
|
|
1421
|
+
const status = sourceStatus(source.status, WHOIS_SOURCE_STATUSES, 'error');
|
|
1422
|
+
if (status === 'error')
|
|
1423
|
+
return {
|
|
1424
|
+
status,
|
|
1425
|
+
error: boundedSourceError(source.error, 'WHOIS source reported an error.'),
|
|
1426
|
+
};
|
|
1427
|
+
const hasPublication = status === 'complete' || status === 'partial';
|
|
1428
|
+
const parsed = hasPublication ? projectLookupEvidenceWhoisPublication(source.parsed) : null;
|
|
1429
|
+
if (status === 'complete' && parsed === null)
|
|
1430
|
+
return null;
|
|
1431
|
+
return {
|
|
1432
|
+
status,
|
|
1433
|
+
queriedAt: boundedTimestamp(source.queriedAt),
|
|
1434
|
+
authoritativeHop: boundedHostname(source.authoritativeHop),
|
|
1435
|
+
failedHop: boundedHostname(source.failedHop),
|
|
1436
|
+
conflictingHop: boundedHostname(source.conflictingHop),
|
|
1437
|
+
parsed,
|
|
1438
|
+
chain: hasPublication
|
|
1439
|
+
? (Array.isArray(source.chain) ? source.chain : [])
|
|
1440
|
+
.slice(0, 16)
|
|
1441
|
+
.map(projectedWhoisChainHop)
|
|
1442
|
+
.filter((hop) => hop !== null)
|
|
1443
|
+
: [],
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
export function projectLookupEvidenceRegistryInsights(value) {
|
|
1447
|
+
return projectedKnownRecord(value, LOOKUP_REGISTRY_INSIGHT_PORTABLE_KEYS);
|
|
1448
|
+
}
|
|
1449
|
+
function sourceStatus(value, allowed, fallback) {
|
|
1450
|
+
return typeof value === 'string' && allowed.has(value) ? value : fallback;
|
|
1451
|
+
}
|
|
1452
|
+
function rdapSource(rdap, diagnostics) {
|
|
318
1453
|
const source = recordOrNull(rdap) || {};
|
|
319
|
-
|
|
1454
|
+
const status = sourceStatus(diagnostics?.status, RDAP_SOURCE_STATUSES, 'error');
|
|
1455
|
+
const attempts = boundedRdapAttempts(source.attempts ?? diagnostics?.attempts, 16);
|
|
1456
|
+
if (status === 'error')
|
|
320
1457
|
return {
|
|
321
1458
|
status: 'error',
|
|
322
|
-
error:
|
|
323
|
-
attempts
|
|
1459
|
+
error: boundedSourceError(source.error ?? diagnostics?.errorCode, 'RDAP source reported an error.'),
|
|
1460
|
+
attempts,
|
|
324
1461
|
};
|
|
1462
|
+
const hasPublication = status === 'success' || status === 'partial';
|
|
1463
|
+
const endpoint = boundedEndpoint(source.rdapServer ?? diagnostics?.endpoint);
|
|
325
1464
|
return {
|
|
326
|
-
status
|
|
327
|
-
endpoint
|
|
328
|
-
transportSecurity:
|
|
329
|
-
httpStatus: source.upstreamStatus ??
|
|
330
|
-
fetchedAt: source.fetchedAt
|
|
331
|
-
attempts
|
|
332
|
-
parsed:
|
|
333
|
-
raw: cloneJson(source.data),
|
|
1465
|
+
status,
|
|
1466
|
+
endpoint,
|
|
1467
|
+
transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
|
|
1468
|
+
httpStatus: boundedHttpStatus(source.upstreamStatus ?? diagnostics?.httpStatus),
|
|
1469
|
+
fetchedAt: boundedTimestamp(source.fetchedAt ?? diagnostics?.fetchedAt),
|
|
1470
|
+
attempts,
|
|
1471
|
+
parsed: hasPublication ? projectLookupEvidenceRdapPublication(source.parsed) : null,
|
|
334
1472
|
};
|
|
335
1473
|
}
|
|
336
|
-
function whoisSource(whois) {
|
|
1474
|
+
function whoisSource(whois, diagnostics) {
|
|
337
1475
|
const source = recordOrNull(whois) || {};
|
|
338
|
-
|
|
339
|
-
|
|
1476
|
+
const status = sourceStatus(diagnostics?.status, WHOIS_SOURCE_STATUSES, 'error');
|
|
1477
|
+
if (status === 'error')
|
|
1478
|
+
return {
|
|
1479
|
+
status: 'error',
|
|
1480
|
+
error: boundedSourceError(source.error ?? diagnostics?.errorCode, 'WHOIS source reported an error.'),
|
|
1481
|
+
};
|
|
340
1482
|
const parsed = recordOrNull(source.parsed);
|
|
341
|
-
const
|
|
1483
|
+
const hasPublication = status === 'complete' || status === 'partial';
|
|
1484
|
+
const chain = hasPublication ? boundedWhoisChain(source.chain) : [];
|
|
342
1485
|
const firstHop = recordOrNull(chain[0]);
|
|
343
1486
|
return {
|
|
344
|
-
status
|
|
345
|
-
queriedAt: firstHop?.queriedAt
|
|
346
|
-
authoritativeHop: parsed?.authoritativeHop
|
|
347
|
-
failedHop: parsed?.failedHop
|
|
348
|
-
conflictingHop: parsed?.conflictingHop
|
|
349
|
-
parsed:
|
|
350
|
-
chain
|
|
1487
|
+
status,
|
|
1488
|
+
queriedAt: boundedTimestamp(firstHop?.queriedAt ?? diagnostics?.queriedAt),
|
|
1489
|
+
authoritativeHop: boundedHostname(parsed?.authoritativeHop ?? diagnostics?.authoritativeHop),
|
|
1490
|
+
failedHop: boundedHostname(parsed?.failedHop ?? diagnostics?.failedHop),
|
|
1491
|
+
conflictingHop: boundedHostname(parsed?.conflictingHop ?? diagnostics?.conflictingHop),
|
|
1492
|
+
parsed: hasPublication ? projectLookupEvidenceWhoisPublication(parsed) : null,
|
|
1493
|
+
chain,
|
|
351
1494
|
};
|
|
352
1495
|
}
|
|
353
1496
|
function registrarPublicationComparison(body, registryParsed) {
|
|
@@ -359,7 +1502,9 @@ function registrarPublicationComparison(body, registryParsed) {
|
|
|
359
1502
|
if (!registrar && !registrarDiagnostics)
|
|
360
1503
|
return null;
|
|
361
1504
|
const reportedStatus = registrar?.status ?? registrarDiagnostics?.status;
|
|
362
|
-
const parsed =
|
|
1505
|
+
const parsed = ['success', 'partial'].includes(String(reportedStatus))
|
|
1506
|
+
? projectLookupEvidenceRdapPublication(registrar?.parsed)
|
|
1507
|
+
: null;
|
|
363
1508
|
const registrarStatus = typeof reportedStatus === 'string' && REGISTRAR_RDAP_STATUSES.has(reportedStatus)
|
|
364
1509
|
? (reportedStatus === 'success' && !parsed ? 'partial' : reportedStatus)
|
|
365
1510
|
: 'error';
|
|
@@ -369,17 +1514,35 @@ function registrarPublicationComparison(body, registryParsed) {
|
|
|
369
1514
|
});
|
|
370
1515
|
}
|
|
371
1516
|
export function buildLookupEvidence(response, options = {}) {
|
|
372
|
-
|
|
1517
|
+
assertBoundedJsonStructure(response, 'Lookup response');
|
|
1518
|
+
const { generatedAt: generatedAtValue = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
|
|
1519
|
+
const generatedAt = normalizeExplicitIsoTimestamp(generatedAtValue);
|
|
1520
|
+
if (!generatedAt) {
|
|
1521
|
+
throw new TypeError('Lookup evidence generation time must be valid and include an explicit timezone.');
|
|
1522
|
+
}
|
|
373
1523
|
const body = recordOrNull(response) || {};
|
|
374
1524
|
const rdap = recordOrNull(body.rdap);
|
|
375
1525
|
const whois = recordOrNull(body.whois);
|
|
376
1526
|
const diagnostics = recordOrNull(body.diagnostics);
|
|
377
|
-
const
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
|
|
1527
|
+
const projectedDiagnostics = lookupDiagnostics(diagnostics);
|
|
1528
|
+
const rdapDiagnostics = recordOrNull(projectedDiagnostics.rdap);
|
|
1529
|
+
const whoisDiagnostics = recordOrNull(projectedDiagnostics.whois);
|
|
1530
|
+
const projectedRdap = projectLookupEvidenceRdapSourcePublication(rdapSource(rdap, rdapDiagnostics));
|
|
1531
|
+
const projectedWhois = projectLookupEvidenceWhoisSourcePublication(whoisSource(whois, whoisDiagnostics));
|
|
1532
|
+
if (!projectedRdap) {
|
|
1533
|
+
throw new TypeError('Lookup evidence cannot publish successful RDAP without normalized publication data.');
|
|
1534
|
+
}
|
|
1535
|
+
if (!projectedWhois) {
|
|
1536
|
+
throw new TypeError('Lookup evidence cannot publish complete WHOIS without normalized publication data.');
|
|
1537
|
+
}
|
|
1538
|
+
const projectedRdapRecord = recordOrNull(projectedRdap) || {};
|
|
1539
|
+
const projectedWhoisRecord = recordOrNull(projectedWhois) || {};
|
|
1540
|
+
const rdapParsed = ['success', 'partial'].includes(String(projectedRdapRecord.status))
|
|
1541
|
+
? recordOrNull(projectedRdapRecord.parsed)
|
|
1542
|
+
: null;
|
|
1543
|
+
const whoisParsed = ['complete', 'partial'].includes(String(projectedWhoisRecord.status))
|
|
1544
|
+
? recordOrNull(projectedWhoisRecord.parsed)
|
|
1545
|
+
: null;
|
|
383
1546
|
// Rebuild the pure interpretation from bounded source fields instead of
|
|
384
1547
|
// trusting a supplied derived object. This adds no collection or network
|
|
385
1548
|
// work and lets saved or fast responses carry current source-health-aware
|
|
@@ -387,50 +1550,42 @@ export function buildLookupEvidence(response, options = {}) {
|
|
|
387
1550
|
const registryInsights = buildRegistryInsights({
|
|
388
1551
|
rdapParsed,
|
|
389
1552
|
rdapStatus: rdapDiagnostics?.status,
|
|
390
|
-
rdapFetchedAt:
|
|
1553
|
+
rdapFetchedAt: projectedRdapRecord.fetchedAt,
|
|
391
1554
|
whoisParsed,
|
|
392
1555
|
whoisStatus: whoisDiagnostics?.status,
|
|
393
|
-
whoisQueriedAt:
|
|
394
|
-
registrarRdapParsed,
|
|
395
|
-
registrarRdapStatus: registrarRdap?.status ?? recordOrNull(rdapDiagnostics?.registrar)?.status,
|
|
396
|
-
registrarRdapFetchedAt: registrarRdap?.fetchedAt,
|
|
1556
|
+
whoisQueriedAt: projectedWhoisRecord.queriedAt,
|
|
397
1557
|
});
|
|
398
|
-
|
|
1558
|
+
const query = projectLookupEvidenceQuery(body);
|
|
1559
|
+
const availabilityAnalysis = projectLookupEvidenceAvailability(body.availability);
|
|
1560
|
+
const idn = projectedKnownRecord(idnAnalysis, LOOKUP_IDN_ANALYSIS_KEYS);
|
|
1561
|
+
const evidence = {
|
|
399
1562
|
schema: LOOKUP_EVIDENCE_SCHEMA,
|
|
400
1563
|
schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
401
1564
|
generatedAt,
|
|
402
|
-
application:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
projectUrl: WHOISLEUTH_SOURCE_REPOSITORY_URL,
|
|
406
|
-
},
|
|
407
|
-
query: {
|
|
408
|
-
submitted: body.query || null,
|
|
409
|
-
type: body.type || null,
|
|
410
|
-
inputHostname: body.inputHostname || null,
|
|
411
|
-
registrableDomain: body.registrableDomain || null,
|
|
412
|
-
isSubdomain: body.isSubdomain === true,
|
|
413
|
-
},
|
|
414
|
-
diagnostics: cloneJson(body.diagnostics),
|
|
1565
|
+
application: buildPortableGeneratorMetadata(applicationVersion),
|
|
1566
|
+
query,
|
|
1567
|
+
diagnostics: projectedDiagnostics,
|
|
415
1568
|
sources: {
|
|
416
|
-
rdap:
|
|
417
|
-
whois:
|
|
1569
|
+
rdap: projectedRdap,
|
|
1570
|
+
whois: projectedWhois,
|
|
418
1571
|
reverseDns: reverseDnsSource(body.reverseDns),
|
|
419
1572
|
network: networkSource(body.networkContext),
|
|
420
1573
|
securityTxt: securityTxtSource(body.securityTxt),
|
|
421
1574
|
sslbl: sslblSource(body.sslbl),
|
|
422
1575
|
},
|
|
423
1576
|
analysis: {
|
|
424
|
-
availability:
|
|
425
|
-
idn
|
|
426
|
-
registryInsights,
|
|
427
|
-
registryComparison: compareRegistrySources(rdapParsed, whoisParsed, {
|
|
1577
|
+
availability: availabilityAnalysis,
|
|
1578
|
+
idn,
|
|
1579
|
+
registryInsights: projectLookupEvidenceRegistryInsights(registryInsights),
|
|
1580
|
+
registryComparison: projectLookupEvidencePrivacySafeTree(compareRegistrySources(rdapParsed, whoisParsed, {
|
|
428
1581
|
rdapStatus: rdapDiagnostics?.status,
|
|
429
1582
|
whoisStatus: whoisDiagnostics?.status,
|
|
430
|
-
}),
|
|
431
|
-
registrarPublicationComparison: registrarPublicationComparison(body, rdapParsed),
|
|
1583
|
+
})),
|
|
1584
|
+
registrarPublicationComparison: projectLookupEvidencePrivacySafeTree(registrarPublicationComparison(body, rdapParsed)),
|
|
432
1585
|
},
|
|
433
1586
|
};
|
|
1587
|
+
assertLookupEvidencePortableTree(evidence);
|
|
1588
|
+
return evidence;
|
|
434
1589
|
}
|
|
435
1590
|
export function evidenceFilename(response, now = Date.now()) {
|
|
436
1591
|
const record = recordOrNull(response);
|