@slicedearth/whoisleuth-cli 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,114 @@
1
+ // Compact provenance envelope for network-derived evidence. Type-specific
2
+ // payloads remain beside this object; the envelope only standardizes source
3
+ // health, timing, completeness, truncation, and bounded limitations.
4
+ const OBSERVATION_VERSION = 1;
5
+ const STATUSES = new Set([
6
+ 'success',
7
+ 'partial',
8
+ 'not_found',
9
+ 'skipped',
10
+ 'error',
11
+ 'unsupported',
12
+ 'not_applicable',
13
+ ]);
14
+ const SCAN_MODES = new Set(['fast', 'deep']);
15
+ const MAX_LIMITATIONS = 10;
16
+ const MAX_LIMITATION_LENGTH = 300;
17
+ const MAX_DIAGNOSTICS = 20;
18
+ const MAX_DIAGNOSTIC_KEY = 40;
19
+ const MAX_DIAGNOSTIC_STRING = 240;
20
+ const MAX_DURATION_MS = 120_000;
21
+ function isObservationStatus(value) {
22
+ return typeof value === 'string' && STATUSES.has(value);
23
+ }
24
+ function isScanMode(value) {
25
+ return typeof value === 'string' && SCAN_MODES.has(value);
26
+ }
27
+ function safeString(value, maxLength) {
28
+ if (typeof value !== 'string')
29
+ return null;
30
+ const normalized = value.trim().replace(/[\u0000-\u001f\u007f]/g, ' ');
31
+ return normalized ? normalized.slice(0, maxLength) : null;
32
+ }
33
+ function isoTimestamp(value) {
34
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/.test(value))
35
+ return null;
36
+ const time = Date.parse(value);
37
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
38
+ }
39
+ function normalizeDiagnosticValue(value) {
40
+ if (typeof value === 'boolean')
41
+ return value;
42
+ if (typeof value === 'number')
43
+ return Number.isFinite(value) ? value : null;
44
+ if (typeof value === 'string')
45
+ return safeString(value, MAX_DIAGNOSTIC_STRING);
46
+ if (!value || typeof value !== 'object' || Array.isArray(value))
47
+ return null;
48
+ const record = value;
49
+ const output = {};
50
+ for (const field of ['status', 'error', 'detail', 'truncated', 'discarded', 'count']) {
51
+ if (!Object.prototype.hasOwnProperty.call(record, field))
52
+ continue;
53
+ const normalized = normalizeDiagnosticValue(record[field]);
54
+ if (normalized !== null)
55
+ output[field] = normalized;
56
+ }
57
+ return Object.keys(output).length ? output : null;
58
+ }
59
+ function normalizeDiagnostics(value) {
60
+ if (!value || typeof value !== 'object' || Array.isArray(value))
61
+ return {};
62
+ const record = value;
63
+ const output = {};
64
+ for (const key of Object.keys(record).sort().slice(0, MAX_DIAGNOSTICS)) {
65
+ if (!key || key.length > MAX_DIAGNOSTIC_KEY || !/^[a-z0-9_-]+$/i.test(key))
66
+ continue;
67
+ const normalized = normalizeDiagnosticValue(record[key]);
68
+ if (normalized !== null)
69
+ output[key] = normalized;
70
+ }
71
+ return output;
72
+ }
73
+ function createObservation(input = {}) {
74
+ const status = isObservationStatus(input.status) ? input.status : 'error';
75
+ const source = safeString(input.source, 40) || 'unknown';
76
+ const observedAt = isoTimestamp(input.observedAt) || new Date().toISOString();
77
+ const duration = Number(input.durationMs);
78
+ const limitations = [...new Set((Array.isArray(input.limitations) ? input.limitations : [])
79
+ .map((item) => safeString(item, MAX_LIMITATION_LENGTH))
80
+ .filter((item) => item !== null))]
81
+ .slice(0, MAX_LIMITATIONS);
82
+ return {
83
+ version: OBSERVATION_VERSION,
84
+ status,
85
+ observedAt,
86
+ scanMode: isScanMode(input.scanMode) ? input.scanMode : null,
87
+ source,
88
+ durationMs: Number.isFinite(duration) ? Math.max(0, Math.min(MAX_DURATION_MS, Math.round(duration))) : null,
89
+ complete: input.complete === true,
90
+ truncated: input.truncated === true,
91
+ limitations,
92
+ diagnostics: normalizeDiagnostics(input.diagnostics),
93
+ };
94
+ }
95
+ function readObservationEnvelope(value) {
96
+ if (value === undefined || value === null)
97
+ return { state: 'absent', observation: null };
98
+ if (!value || typeof value !== 'object' || Array.isArray(value))
99
+ return { state: 'invalid', observation: null };
100
+ const record = value;
101
+ if (typeof record.version === 'number'
102
+ && Number.isInteger(record.version)
103
+ && record.version > OBSERVATION_VERSION) {
104
+ return { state: 'unsupported', observation: null };
105
+ }
106
+ if (record.version !== OBSERVATION_VERSION
107
+ || !isObservationStatus(record.status)
108
+ || !safeString(record.source, 40)
109
+ || !isoTimestamp(record.observedAt)) {
110
+ return { state: 'invalid', observation: null };
111
+ }
112
+ return { state: 'supported', observation: createObservation(record) };
113
+ }
114
+ export { OBSERVATION_VERSION, createObservation, readObservationEnvelope, };
@@ -0,0 +1,355 @@
1
+ // Bounded network-registration context for one public address already selected
2
+ // by a deep domain lookup. The collector prefers the successful TLS connection
3
+ // address and falls back deterministically to retained public A then AAAA
4
+ // evidence. It performs one logical IP RDAP enrichment through the existing
5
+ // cached, SSRF-safe RDAP boundary and never participates in domain availability.
6
+ import * as net from 'node:net';
7
+ import { createObservation } from "./observation.mjs";
8
+ import { fetchRdapRecord } from "./rdap.mjs";
9
+ import { isPrivateAddress } from "./safe-fetch.mjs";
10
+ const OBSERVED_NETWORK_CONTEXT_VERSION = 1;
11
+ const MAX_NETWORK_CIDRS = 16;
12
+ const MAX_NETWORK_ATTEMPTS = 3;
13
+ const MAX_NETWORK_ABUSE_ROUTES = 6;
14
+ const MAX_ENDPOINT_LENGTH = 2048;
15
+ const MAX_DETAIL_LENGTH = 240;
16
+ const MAX_NAME_LENGTH = 300;
17
+ const MAX_CONTACT_LENGTH = 320;
18
+ const EMAIL_RE = /^[^\s@/:]+@[^\s@/:]+\.[^\s@/:]+$/u;
19
+ const PHONE_RE = /^[+\d][\d ().-]{4,63}$/u;
20
+ function record(value) {
21
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
22
+ }
23
+ function boundedString(value, maximum) {
24
+ if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/.test(value))
25
+ return null;
26
+ return value.replace(/\s+/g, ' ').trim() || null;
27
+ }
28
+ function isoTimestamp(value) {
29
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/.test(value))
30
+ return null;
31
+ const time = Date.parse(value);
32
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
33
+ }
34
+ function publicAddress(value, family) {
35
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u0020\u007f%]/.test(value))
36
+ return null;
37
+ const normalized = value.toLowerCase();
38
+ const detected = net.isIP(normalized);
39
+ if (![4, 6].includes(detected) || (family && detected !== family) || isPrivateAddress(normalized))
40
+ return null;
41
+ return normalized;
42
+ }
43
+ function eligibleDnsAddresses(value, family) {
44
+ return [...new Set((Array.isArray(value) ? value : [])
45
+ .slice(0, 16)
46
+ .map((item) => publicAddress(item, family))
47
+ .filter((item) => item !== null))]
48
+ .sort((left, right) => left.localeCompare(right));
49
+ }
50
+ function selectObservedNetworkAddress(availabilityValue) {
51
+ const availability = record(availabilityValue);
52
+ const tls = record(availability.tls);
53
+ if (tls.source === 'tls' && ['success', 'partial'].includes(String(tls.status))) {
54
+ const address = publicAddress(tls.connectedAddress);
55
+ if (address)
56
+ return {
57
+ address,
58
+ family: net.isIP(address),
59
+ selectedFrom: 'tls_connection',
60
+ };
61
+ }
62
+ const dns = record(availability.dns);
63
+ if (dns.source !== 'dns' || !['success', 'partial'].includes(String(dns.status)))
64
+ return null;
65
+ const records = record(dns.records);
66
+ const diagnostics = record(dns.diagnostics);
67
+ const a = record(diagnostics.a);
68
+ const aaaa = record(diagnostics.aaaa);
69
+ if (a.status === 'success') {
70
+ const address = eligibleDnsAddresses(records.a, 4)[0];
71
+ if (address)
72
+ return { address, family: 4, selectedFrom: 'dns_a' };
73
+ }
74
+ if (aaaa.status === 'success') {
75
+ const address = eligibleDnsAddresses(records.aaaa, 6)[0];
76
+ if (address)
77
+ return { address, family: 6, selectedFrom: 'dns_aaaa' };
78
+ }
79
+ return null;
80
+ }
81
+ function normalizedEndpoint(value) {
82
+ if (typeof value !== 'string' || value.length > MAX_ENDPOINT_LENGTH || /[\u0000-\u001f\u007f]/.test(value))
83
+ return null;
84
+ try {
85
+ const url = new URL(value);
86
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password)
87
+ return null;
88
+ url.search = '';
89
+ url.hash = '';
90
+ return url.toString().slice(0, MAX_ENDPOINT_LENGTH);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ function boundedHttpStatus(value) {
97
+ const status = Number(value);
98
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? status : null;
99
+ }
100
+ function normalizeAttempts(value) {
101
+ return (Array.isArray(value) ? value : []).slice(0, MAX_NETWORK_ATTEMPTS).map((entry) => {
102
+ const attempt = record(entry);
103
+ const endpoint = normalizedEndpoint(attempt.endpoint);
104
+ return {
105
+ endpoint,
106
+ transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
107
+ status: boundedHttpStatus(attempt.status),
108
+ outcome: boundedString(attempt.outcome, 40),
109
+ detail: boundedString(attempt.detail, MAX_DETAIL_LENGTH),
110
+ selected: attempt.selected === true,
111
+ };
112
+ });
113
+ }
114
+ function normalizedCidr(value, family) {
115
+ if (typeof value !== 'string' || value.length > 96 || /[\u0000-\u0020\u007f]/.test(value))
116
+ return null;
117
+ const [prefix, lengthValue, ...rest] = value.split('/');
118
+ if (rest.length || !prefix || lengthValue === undefined || net.isIP(prefix) !== family || !/^\d{1,3}$/.test(lengthValue))
119
+ return null;
120
+ const length = Number(lengthValue);
121
+ if (!Number.isInteger(length) || length < 0 || length > (family === 4 ? 32 : 128))
122
+ return null;
123
+ return `${prefix.toLowerCase()}/${length}`;
124
+ }
125
+ function networkHolder(value) {
126
+ const holder = record(value);
127
+ const organizations = Array.isArray(holder.organizations) ? holder.organizations : [];
128
+ return boundedString(holder.name, MAX_NAME_LENGTH)
129
+ || boundedString(holder.org, MAX_NAME_LENGTH)
130
+ || boundedString(organizations[0], MAX_NAME_LENGTH)
131
+ || boundedString(holder.handle, 200);
132
+ }
133
+ function normalizedAbuseContact(value, channel) {
134
+ const contact = boundedString(value, MAX_CONTACT_LENGTH);
135
+ if (!contact)
136
+ return null;
137
+ if (channel === 'email')
138
+ return EMAIL_RE.test(contact) ? contact.toLowerCase() : null;
139
+ return PHONE_RE.test(contact) ? contact : null;
140
+ }
141
+ function networkAbuseEntities(parsed) {
142
+ const entitiesByRole = record(parsed.entitiesByRole);
143
+ const roleEntities = Array.isArray(entitiesByRole.abuse) ? entitiesByRole.abuse : [];
144
+ const fallback = parsed.abuse && typeof parsed.abuse === 'object' && !Array.isArray(parsed.abuse)
145
+ ? [parsed.abuse]
146
+ : [];
147
+ return [...roleEntities, ...fallback]
148
+ .filter((item) => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
149
+ .slice(0, MAX_NETWORK_ABUSE_ROUTES);
150
+ }
151
+ function projectNetworkAbuseRoutes(parsed, provenance) {
152
+ const routes = new Map();
153
+ for (const entity of networkAbuseEntities(parsed)) {
154
+ const emails = Array.isArray(entity.emails) ? entity.emails : [];
155
+ const phones = Array.isArray(entity.phones) ? entity.phones : [];
156
+ const candidates = [
157
+ { channel: 'email', value: entity.email },
158
+ ...emails.slice(0, MAX_NETWORK_ABUSE_ROUTES).map((value) => ({ channel: 'email', value })),
159
+ { channel: 'phone', value: entity.phone },
160
+ ...phones.slice(0, MAX_NETWORK_ABUSE_ROUTES).map((value) => ({ channel: 'phone', value })),
161
+ ];
162
+ for (const candidate of candidates) {
163
+ const contact = normalizedAbuseContact(candidate.value, candidate.channel);
164
+ if (!contact)
165
+ continue;
166
+ const key = `${candidate.channel}:${contact.toLowerCase()}`;
167
+ if (!routes.has(key)) {
168
+ routes.set(key, {
169
+ kind: 'network_hosting',
170
+ channel: candidate.channel,
171
+ contact,
172
+ source: 'IP RDAP abuse entity',
173
+ rdapEndpoint: provenance.endpoint,
174
+ observedAt: provenance.observedAt,
175
+ selectedAddress: provenance.selection.address,
176
+ selectedFrom: provenance.selection.selectedFrom,
177
+ complete: provenance.complete,
178
+ truncated: provenance.truncated,
179
+ limitations: [
180
+ 'The route is published for the registered network of one observed endpoint address.',
181
+ 'Network registration does not prove that the recipient hosts the site, controls its origin, or is responsible for the reported content.',
182
+ 'Publication does not verify that the destination is monitored, reachable, or appropriate for this incident.',
183
+ ...(provenance.truncated
184
+ ? ['The IP RDAP observation was incomplete or truncated; other routes may exist.']
185
+ : []),
186
+ ],
187
+ });
188
+ }
189
+ if (routes.size >= MAX_NETWORK_ABUSE_ROUTES)
190
+ return [...routes.values()];
191
+ }
192
+ }
193
+ return [...routes.values()];
194
+ }
195
+ function networkSummary(parsedValue, family) {
196
+ const parsed = record(parsedValue);
197
+ const lifecycle = record(parsed.lifecycle);
198
+ const sourceCidrs = Array.isArray(parsed.cidrs) ? parsed.cidrs : [];
199
+ const cidrs = [...new Set(sourceCidrs
200
+ .slice(0, MAX_NETWORK_CIDRS)
201
+ .map((item) => normalizedCidr(item, family))
202
+ .filter((item) => item !== null))]
203
+ .sort((left, right) => left.localeCompare(right));
204
+ const startAddress = publicAddress(parsed.startAddress, family);
205
+ const endAddress = publicAddress(parsed.endAddress, family);
206
+ const country = boundedString(parsed.country, 2);
207
+ const truncated = parsed.serverTruncated === true
208
+ || parsed.cidrsTruncated === true
209
+ || parsed.entitiesTruncated === true
210
+ || sourceCidrs.length > MAX_NETWORK_CIDRS;
211
+ return {
212
+ value: {
213
+ handle: boundedString(parsed.handle, MAX_NAME_LENGTH),
214
+ name: boundedString(parsed.name, MAX_NAME_LENGTH),
215
+ holder: networkHolder(parsed.org),
216
+ cidrs,
217
+ startAddress,
218
+ endAddress,
219
+ country: country && /^[a-z]{2}$/i.test(country) ? country.toUpperCase() : null,
220
+ networkType: boundedString(parsed.networkType, 160),
221
+ databaseUpdatedAt: isoTimestamp(lifecycle.databaseUpdatedDateIso || lifecycle.databaseUpdatedDate),
222
+ },
223
+ truncated,
224
+ serverTruncated: parsed.serverTruncated === true,
225
+ };
226
+ }
227
+ function baseContext(selection, input) {
228
+ return {
229
+ contextVersion: OBSERVED_NETWORK_CONTEXT_VERSION,
230
+ ...createObservation({
231
+ status: input.status,
232
+ observedAt: input.observedAt,
233
+ scanMode: 'deep',
234
+ source: 'ip_rdap',
235
+ durationMs: input.durationMs,
236
+ complete: input.complete,
237
+ truncated: input.truncated,
238
+ limitations: input.limitations,
239
+ diagnostics: input.diagnostics,
240
+ }),
241
+ detail: input.detail,
242
+ endpoint: selection,
243
+ rdap: input.rdap || null,
244
+ network: input.network || null,
245
+ abuseRouting: Array.isArray(input.abuseRouting) ? input.abuseRouting : [],
246
+ };
247
+ }
248
+ async function collectObservedNetworkContext(availabilityValue, options = {}) {
249
+ const selection = selectObservedNetworkAddress(availabilityValue);
250
+ const now = options.now || Date.now;
251
+ const observedAt = options.observedAt || (() => new Date().toISOString());
252
+ if (!selection)
253
+ return baseContext(null, {
254
+ status: 'unsupported', observedAt: observedAt(), durationMs: 0, complete: false, truncated: false,
255
+ detail: 'No validated public endpoint address was available from the retained TLS or DNS evidence.',
256
+ limitations: [
257
+ 'No IP RDAP request was made because the deep lookup did not retain an eligible public address.',
258
+ 'Missing network context is not evidence that the domain has no website, network registration, or hosting provider.',
259
+ ],
260
+ diagnostics: { requestCount: 0, addressSource: 'none' },
261
+ });
262
+ const started = now();
263
+ const fetchRdap = options.fetchRdapRecord || fetchRdapRecord;
264
+ const type = selection.family === 4 ? 'ipv4' : 'ipv6';
265
+ try {
266
+ const source = await fetchRdap(type, selection.address);
267
+ const durationMs = Math.max(0, now() - started);
268
+ if (!source)
269
+ return baseContext(selection, {
270
+ status: 'unsupported', observedAt: observedAt(), durationMs, complete: false, truncated: false,
271
+ detail: 'No IP RDAP service was available for the selected public address.',
272
+ limitations: [
273
+ 'The selected address remains a point-in-time endpoint observation even when IP RDAP is unsupported.',
274
+ 'Network registration does not identify a definitive origin host or prove control, ownership, or intent.',
275
+ ],
276
+ diagnostics: { requestCount: 1, addressSource: selection.selectedFrom },
277
+ });
278
+ const rdapRecord = record(source);
279
+ const endpoint = normalizedEndpoint(rdapRecord.rdapServer);
280
+ const rdap = {
281
+ endpoint,
282
+ transportSecurity: endpoint ? endpoint.startsWith('https:') ? 'https' : 'http' : null,
283
+ httpStatus: boundedHttpStatus(rdapRecord.upstreamStatus),
284
+ fetchedAt: isoTimestamp(rdapRecord.fetchedAt),
285
+ attempts: normalizeAttempts(rdapRecord.attempts),
286
+ };
287
+ if (rdap.httpStatus === 404)
288
+ return baseContext(selection, {
289
+ status: 'not_found', observedAt: rdap.fetchedAt || observedAt(), durationMs, complete: true, truncated: false,
290
+ detail: 'The authoritative IP RDAP service reported no matching network object.',
291
+ limitations: [
292
+ 'An IP RDAP not-found response is source evidence only and is not a domain availability or safety finding.',
293
+ 'The selected address is a point-in-time endpoint observation and may represent shared edge infrastructure.',
294
+ ],
295
+ diagnostics: { requestCount: 1, addressSource: selection.selectedFrom, httpStatus: 404 },
296
+ rdap,
297
+ });
298
+ const parsed = record(rdapRecord.parsed);
299
+ if (rdap.httpStatus !== 200 || !Object.keys(parsed).length)
300
+ throw new Error('IP RDAP returned no usable normalized object');
301
+ const summary = networkSummary(parsed, selection.family);
302
+ const partial = summary.truncated;
303
+ const sourceObservedAt = rdap.fetchedAt || observedAt();
304
+ const abuseRouting = projectNetworkAbuseRoutes(parsed, {
305
+ endpoint: rdap.endpoint,
306
+ observedAt: sourceObservedAt,
307
+ selection,
308
+ complete: !partial,
309
+ truncated: partial,
310
+ });
311
+ return baseContext(selection, {
312
+ status: partial ? 'partial' : 'success',
313
+ observedAt: sourceObservedAt,
314
+ durationMs,
315
+ complete: !partial,
316
+ truncated: partial,
317
+ detail: 'The selected public endpoint address was mapped to its separately attributed IP RDAP registration.',
318
+ limitations: [
319
+ 'This identifies the registered network for one point-in-time endpoint address, not a definitive origin host or hosting provider.',
320
+ 'CDNs, reverse proxies, load balancers, shared hosting, and location-dependent DNS can present different networks.',
321
+ 'Network registration is an investigative lead and does not prove control, ownership, intent, or maliciousness.',
322
+ ...(summary.serverTruncated ? ['The RDAP server declared that part of its response was truncated.'] : []),
323
+ ...(sourceCidrsWereCapped(parsed) ? ['The network CIDR summary reached its local retention limit.'] : []),
324
+ ],
325
+ diagnostics: {
326
+ requestCount: 1,
327
+ addressSource: selection.selectedFrom,
328
+ httpStatus: rdap.httpStatus || 200,
329
+ cidrCount: summary.value.cidrs.length,
330
+ },
331
+ rdap,
332
+ network: summary.value,
333
+ abuseRouting,
334
+ });
335
+ }
336
+ catch (error) {
337
+ const durationMs = Math.max(0, now() - started);
338
+ const attempts = normalizeAttempts(record(error).attempts);
339
+ return baseContext(selection, {
340
+ status: 'error', observedAt: observedAt(), durationMs, complete: false, truncated: false,
341
+ detail: 'IP RDAP enrichment could not be completed for the selected public address.',
342
+ limitations: [
343
+ 'An IP RDAP failure is inconclusive and is not evidence that the address lacks a registered network or hosting service.',
344
+ 'The selected address remains a point-in-time endpoint observation and may represent shared edge infrastructure.',
345
+ ],
346
+ diagnostics: { requestCount: 1, addressSource: selection.selectedFrom },
347
+ rdap: attempts.length ? { endpoint: null, transportSecurity: null, httpStatus: null, fetchedAt: null, attempts } : null,
348
+ });
349
+ }
350
+ }
351
+ function sourceCidrsWereCapped(parsed) {
352
+ return parsed.cidrsTruncated === true
353
+ || (Array.isArray(parsed.cidrs) && parsed.cidrs.length > MAX_NETWORK_CIDRS);
354
+ }
355
+ export { MAX_NETWORK_ATTEMPTS, MAX_NETWORK_ABUSE_ROUTES, MAX_NETWORK_CIDRS, OBSERVED_NETWORK_CONTEXT_VERSION, collectObservedNetworkContext, selectObservedNetworkAddress, };