@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -8,7 +8,7 @@ async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {})
8
8
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
9
9
  try {
10
10
  const response = await (dependencies.fetch ?? safeFetch)(url, { ...options, signal: controller.signal });
11
- const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES);
11
+ const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES, { fatalUtf8: true });
12
12
  if (truncated)
13
13
  throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
14
14
  return { status: response.status, ok: response.ok, text };
@@ -22,7 +22,7 @@ async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencie
22
22
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
23
23
  try {
24
24
  const result = await (dependencies.fetchDetailed ?? safeFetchDetailed)(url, { ...options, signal: controller.signal });
25
- const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES);
25
+ const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES, { fatalUtf8: true });
26
26
  if (truncated)
27
27
  throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
28
28
  return {
@@ -1,24 +1,75 @@
1
- import net from 'node:net';
1
+ import { isIP } from 'node:net';
2
2
  import { domainToASCII } from 'node:url';
3
- import { ipv4ToLong, ipv6ToBigInt } from "./rdap-bootstrap.mjs";
3
+ import { admitRdapEndpoint, ipv4ToLong, ipv6ToBigInt } from "./rdap-bootstrap.mjs";
4
4
  export function canonicalRdapDomain(value) {
5
5
  if (typeof value !== 'string')
6
6
  return null;
7
7
  const ascii = domainToASCII(value.trim().replace(/\.+$/, ''));
8
8
  return ascii ? ascii.toLowerCase() : null;
9
9
  }
10
- function ipv6ToComparableBigInt(ip) {
11
- let normalized = ip;
12
- if (ip.includes('.')) {
13
- const lastColon = ip.lastIndexOf(':');
14
- const embedded = ip.slice(lastColon + 1);
15
- if (net.isIP(embedded) !== 4)
10
+ function decodedTerminalPathParts(value) {
11
+ try {
12
+ const parts = new URL(value).pathname.split('/').filter(Boolean);
13
+ if (parts.length < 2)
16
14
  return null;
17
- const value = ipv4ToLong(embedded);
18
- normalized = `${ip.slice(0, lastColon)}:${(value >>> 16).toString(16)}:${(value & 0xffff).toString(16)}`;
15
+ const objectType = decodeURIComponent(parts.at(-2) ?? '');
16
+ const objectValue = decodeURIComponent(parts.at(-1) ?? '');
17
+ return objectType && objectValue ? [objectType, objectValue] : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * Admit final RDAP provenance only when its terminal path still identifies the
25
+ * requested object. Safe redirect transport proves where bytes came from; this
26
+ * binding separately prevents a redirected response for another object from
27
+ * deciding a lookup, especially when a bodyless 404 is otherwise authoritative.
28
+ */
29
+ export function admitRdapObjectEndpoint(type, requestedValue, value) {
30
+ const admitted = admitRdapEndpoint(value);
31
+ if (!admitted)
32
+ return null;
33
+ const terminal = decodedTerminalPathParts(admitted);
34
+ if (!terminal)
35
+ return null;
36
+ const [objectType, objectValue] = terminal;
37
+ if (type === 'domain') {
38
+ const requested = canonicalRdapDomain(requestedValue);
39
+ const selected = canonicalRdapDomain(objectValue);
40
+ return objectType === 'domain' && requested && selected === requested ? admitted : null;
19
41
  }
42
+ if (type === 'ipv4' || type === 'ipv6') {
43
+ const version = type === 'ipv4' ? 4 : 6;
44
+ if (objectType !== 'ip' || isIP(requestedValue) !== version || isIP(objectValue) !== version)
45
+ return null;
46
+ try {
47
+ const convert = version === 4 ? ipv4ToLong : ipv6ToBigInt;
48
+ return convert(requestedValue) === convert(objectValue) ? admitted : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ if (type === 'asn') {
55
+ const requestedMatch = /^(?:AS)?([0-9]{1,10})$/iu.exec(requestedValue);
56
+ if (objectType !== 'autnum' || !requestedMatch || !/^[0-9]{1,10}$/u.test(objectValue))
57
+ return null;
58
+ const requested = Number(requestedMatch[1]);
59
+ const selected = Number(objectValue);
60
+ return Number.isSafeInteger(requested)
61
+ && requested >= 0
62
+ && requested <= 4_294_967_295
63
+ && selected === requested
64
+ && objectValue === String(selected)
65
+ ? admitted
66
+ : null;
67
+ }
68
+ return null;
69
+ }
70
+ function ipv6ToComparableBigInt(ip) {
20
71
  try {
21
- return ipv6ToBigInt(normalized);
72
+ return ipv6ToBigInt(ip);
22
73
  }
23
74
  catch {
24
75
  return null;
@@ -71,11 +122,11 @@ export function validateRdapResponse(type, requestedValue, parsed) {
71
122
  const version = type === 'ipv4' ? 4 : 6;
72
123
  const startAddress = parsed.startAddress;
73
124
  const endAddress = parsed.endAddress;
74
- if (net.isIP(requestedValue) !== version ||
125
+ if (isIP(requestedValue) !== version ||
75
126
  typeof startAddress !== 'string' ||
76
- net.isIP(startAddress) !== version ||
127
+ isIP(startAddress) !== version ||
77
128
  typeof endAddress !== 'string' ||
78
- net.isIP(endAddress) !== version) {
129
+ isIP(endAddress) !== version) {
79
130
  return { valid: false, detail: 'The response did not contain a compatible IP range.' };
80
131
  }
81
132
  const convert = version === 4 ? ipv4ToLong : ipv6ToComparableBigInt;
package/lib/rdap.mjs CHANGED
@@ -3,9 +3,9 @@ import { BOOTSTRAP_STALE_TTL_MS, BOOTSTRAP_TTL_MS, clearRdapBootstrapCache, fetc
3
3
  import { fetchRdapFromBasesWithParser, fetchRdapRecordWithParser, } from "./rdap-client.mjs";
4
4
  import { normalizeRdapEvents, parseRdap, summarizeLifecycle, } from "./rdap-normalization.mjs";
5
5
  import { fetchRegistrarRdapRecordWithParser, selectRegistrarRdapLink, } from "./rdap-registrar.mjs";
6
- import { fetchRdapWithTimeout, } from "./rdap-transport.mjs";
6
+ import { fetchRdapDetailedWithTimeout, } from "./rdap-transport.mjs";
7
7
  import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
8
- async function fetchRdapFromBases(type, value, bases, fetchUpstream = fetchRdapWithTimeout) {
8
+ async function fetchRdapFromBases(type, value, bases, fetchUpstream = fetchRdapDetailedWithTimeout) {
9
9
  return fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream);
10
10
  }
11
11
  async function fetchRdapRecord(type, value, options = {}) {
@@ -0,0 +1,36 @@
1
+ import { getDomain } from 'tldts';
2
+ import { isValidAsciiDomainName } from "./hostname.mjs";
3
+ // Returns one canonical registrable-domain identity for an already public DNS
4
+ // hostname. Callers retain the original hostname separately when subdomain
5
+ // context matters; authority, evidence binding and scoring use this value.
6
+ export function canonicalRegistrableDomain(value) {
7
+ if (typeof value !== 'string')
8
+ return null;
9
+ const trimmed = value.trim();
10
+ if (!trimmed || /[\u0000-\u001f\u007f]/u.test(trimmed))
11
+ return null;
12
+ const withoutScheme = trimmed.replace(/^[a-z]+:\/\//iu, '').split(/[/?#]/u)[0] ?? '';
13
+ if (withoutScheme.endsWith('..'))
14
+ return null;
15
+ const candidate = withoutScheme.replace(/\.$/u, '');
16
+ if (!candidate)
17
+ return null;
18
+ let hostname;
19
+ try {
20
+ hostname = new URL(`https://${candidate}/`).hostname.toLowerCase().replace(/\.$/u, '');
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ if (!isValidAsciiDomainName(hostname, { requireDot: true, requireLowercase: true }))
26
+ return null;
27
+ // Keep this boundary identical to classifyQuery() and the collection
28
+ // runtime: registrable identity follows the ICANN Public Suffix List, while
29
+ // privately operated suffixes remain ordinary host context.
30
+ const domain = getDomain(hostname);
31
+ return domain
32
+ && isValidAsciiDomainName(domain, { requireDot: true, requireLowercase: true })
33
+ && (hostname === domain || hostname.endsWith(`.${domain}`))
34
+ ? domain
35
+ : null;
36
+ }
@@ -87,6 +87,13 @@ function disclosure(source, parsedValue, status) {
87
87
  };
88
88
  }
89
89
  const parsed = record(parsedValue);
90
+ if (parsed.contactsExcluded === true) {
91
+ return {
92
+ source,
93
+ state: 'unavailable',
94
+ detail: 'Contact fields are deliberately excluded from this portable projection, so publication disclosure remains unavailable.',
95
+ };
96
+ }
90
97
  const partial = source === 'whois'
91
98
  ? parsed.chainStatus === 'partial' || strings(parsed.fieldsTruncated, 20).length > 0
92
99
  : parsed.serverTruncated === true
@@ -184,9 +184,23 @@ function analyzeHsts(header, signals) {
184
184
  const directives = header.value.split(';').map((part) => part.trim()).filter(Boolean);
185
185
  if (directives.length > MAX_RESPONSE_POLICY_DIRECTIVES)
186
186
  return 'partial';
187
- const maxAge = directives
188
- .map((part) => part.match(/^max-age\s*=\s*(\d+)$/iu)?.[1] || null)
189
- .find((value) => value !== null);
187
+ const seen = new Set();
188
+ let maxAge = null;
189
+ for (const directive of directives) {
190
+ const match = /^([a-z][a-z0-9-]{0,63})(?:\s*=\s*(.*))?$/iu.exec(directive);
191
+ if (!match)
192
+ return 'malformed';
193
+ const name = match[1]?.toLowerCase() ?? '';
194
+ if (seen.has(name))
195
+ return 'malformed';
196
+ seen.add(name);
197
+ if (name === 'max-age') {
198
+ const value = match[2];
199
+ if (!value || !/^\d+$/u.test(value))
200
+ return 'malformed';
201
+ maxAge = value;
202
+ }
203
+ }
190
204
  if (!maxAge || maxAge.length > 12)
191
205
  return 'malformed';
192
206
  const seconds = Number(maxAge);
@@ -256,10 +270,15 @@ function analyzeCookies(headers, signals) {
256
270
  let missingHttpOnly = 0;
257
271
  let missingSameSite = 0;
258
272
  let sameSiteNoneWithoutSecure = 0;
273
+ let attributesTruncated = false;
259
274
  for (const value of result.values) {
260
275
  const segments = value.split(';');
261
276
  if (!segments[0]?.includes('='))
262
277
  return { state: 'malformed', count: result.values.length, truncated: result.truncated };
278
+ if (segments.length - 1 > MAX_RESPONSE_POLICY_TOKENS) {
279
+ attributesTruncated = true;
280
+ continue;
281
+ }
263
282
  const attributes = new Map();
264
283
  for (const rawAttribute of segments.slice(1, MAX_RESPONSE_POLICY_TOKENS + 1)) {
265
284
  const [rawName = '', ...rawValue] = rawAttribute.trim().split('=');
@@ -289,7 +308,11 @@ function analyzeCookies(headers, signals) {
289
308
  if (sameSiteNoneWithoutSecure) {
290
309
  addSignal(signals, 'cookies_same_site_none_without_secure', sameSiteNoneWithoutSecure);
291
310
  }
292
- return { state: result.state, count: result.values.length, truncated: result.truncated };
311
+ return {
312
+ state: result.state === 'partial' || attributesTruncated ? 'partial' : result.state,
313
+ count: result.values.length,
314
+ truncated: result.truncated || attributesTruncated,
315
+ };
293
316
  }
294
317
  export function analyzeResponsePolicyHeaders(headers) {
295
318
  const signals = [];
@@ -1,35 +1,25 @@
1
1
  import { ANALYST_REVIEW_REASON_VALUES } from "./analyst-taxonomy.mjs";
2
2
  import { scanBoundedJson } from "./bounded-json.mjs";
3
3
  import { RISK_REVIEW_THRESHOLD } from "./risk-scoring.mjs";
4
- export const RISK_CALIBRATION_SUMMARY_SCHEMA = 'whoisleuth.cli.risk-calibration';
5
- export const RISK_CALIBRATION_SUMMARY_VERSION = 3;
6
- export const RISK_CALIBRATION_SUMMARY_MAX_BYTES = 512 * 1024;
7
- export const RISK_CALIBRATION_SUMMARY_MAX_RECORDS = 500;
4
+ import { MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MODEL_VERSION, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SUMMARY_BYTES, MAX_RISK_CALIBRATION_SUMMARY_STRATA, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_CONFIDENCE_KEYS, RISK_CALIBRATION_REPORT_DATASET_KEYS, RISK_CALIBRATION_REPORT_INTERPRETATION_KEYS, RISK_CALIBRATION_REPORT_INTERVAL_KEYS, RISK_CALIBRATION_REPORT_METRIC_KEYS, RISK_CALIBRATION_REPORT_MODEL_COMPARISON_KEYS, RISK_CALIBRATION_REPORT_PRIVACY_KEYS, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_SCORE_BAND_KEYS, RISK_CALIBRATION_REPORT_STRATUM_KEYS, RISK_CALIBRATION_REPORT_SUMMARY_KEYS, RISK_CALIBRATION_REPORT_VERSION, RISK_CALIBRATION_REPORT_V3_SUMMARY_ROOT_KEYS, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
5
+ export const RISK_CALIBRATION_SUMMARY_SCHEMA = RISK_CALIBRATION_REPORT_SCHEMA;
6
+ export const RISK_CALIBRATION_SUMMARY_VERSION = RISK_CALIBRATION_REPORT_VERSION;
7
+ export const RISK_CALIBRATION_SUMMARY_MAX_BYTES = MAX_RISK_CALIBRATION_SUMMARY_BYTES;
8
+ export const RISK_CALIBRATION_SUMMARY_MAX_RECORDS = MAX_RISK_CALIBRATION_RECORDS;
8
9
  export const RISK_CALIBRATION_SUMMARY_MIN_STRATUM_SAMPLE = 20;
9
10
  export const RISK_CALIBRATION_SUMMARY_THRESHOLDS = Object.freeze([...new Set([40, 50, 60, RISK_REVIEW_THRESHOLD, 80, 90])].sort((left, right) => left - right));
10
11
  export const RISK_CALIBRATION_SUMMARY_INTERPRETATION = 'This offline replay compares heuristic Risk scores with analyst dispositions. It does not prove maliciousness or safety.';
11
- const ROOT_KEYS = new Set([
12
- 'schema', 'version', 'mode', 'generatedAt', 'dataset', 'riskModelVersion',
13
- 'currentReviewThreshold', 'summary', 'thresholds', 'strata',
14
- 'modelComparison', 'privacy', 'interpretation',
15
- ]);
16
- const DATASET_KEYS = new Set(['schema', 'version', 'recordCount']);
17
- const SUMMARY_KEYS = new Set(['total', 'positive', 'negative', 'excluded', 'scoreBands']);
18
- const SCORE_BAND_KEYS = new Set(['not_scored', '0_39', '40_69', '70_100']);
19
- const METRIC_KEYS = new Set([
20
- 'threshold', 'truePositive', 'falsePositive', 'trueNegative', 'falseNegative',
21
- 'precision', 'recall', 'specificity', 'falsePositiveRate', 'f1',
22
- 'balancedAccuracy', 'confidence95',
23
- ]);
24
- const CONFIDENCE_KEYS = new Set(['precision', 'recall', 'specificity']);
25
- const INTERVAL_KEYS = new Set(['lower', 'upper']);
26
- const STRATUM_KEYS = new Set(['dimension', 'value', 'sampleCount', 'insufficientSample', 'metrics']);
27
- const MODEL_COMPARISON_KEYS = new Set([
28
- 'available', 'previousModelVersion', 'currentModelVersion', 'scoresChanged',
29
- 'bandsChanged', 'thresholdClassificationsChanged',
30
- ]);
31
- const PRIVACY_KEYS = new Set(['targetsRetained', 'identifiersRetained', 'rawEvidenceRetained']);
32
- const INTERPRETATION_KEYS = new Set(['authority', 'statement', 'automaticTuning', 'networkRequests', 'persisted']);
12
+ const ROOT_KEYS = new Set(RISK_CALIBRATION_REPORT_V3_SUMMARY_ROOT_KEYS);
13
+ const DATASET_KEYS = new Set(RISK_CALIBRATION_REPORT_DATASET_KEYS);
14
+ const SUMMARY_KEYS = new Set(RISK_CALIBRATION_REPORT_SUMMARY_KEYS);
15
+ const SCORE_BAND_KEYS = new Set(RISK_CALIBRATION_REPORT_SCORE_BAND_KEYS);
16
+ const METRIC_KEYS = new Set(RISK_CALIBRATION_REPORT_METRIC_KEYS);
17
+ const CONFIDENCE_KEYS = new Set(RISK_CALIBRATION_REPORT_CONFIDENCE_KEYS);
18
+ const INTERVAL_KEYS = new Set(RISK_CALIBRATION_REPORT_INTERVAL_KEYS);
19
+ const STRATUM_KEYS = new Set(RISK_CALIBRATION_REPORT_STRATUM_KEYS);
20
+ const MODEL_COMPARISON_KEYS = new Set(RISK_CALIBRATION_REPORT_MODEL_COMPARISON_KEYS);
21
+ const PRIVACY_KEYS = new Set(RISK_CALIBRATION_REPORT_PRIVACY_KEYS);
22
+ const INTERPRETATION_KEYS = new Set(RISK_CALIBRATION_REPORT_INTERPRETATION_KEYS);
33
23
  const REVIEW_REASON_VALUES = new Set([...ANALYST_REVIEW_REASON_VALUES, 'not_recorded']);
34
24
  const SCAN_DEPTH_VALUES = new Set(['deep', 'fast', 'unknown']);
35
25
  const CONTROL_RE = /[\x00-\x1f\x7f]/u;
@@ -51,13 +41,15 @@ function boundedCount(value, label, maximum = RISK_CALIBRATION_SUMMARY_MAX_RECOR
51
41
  return Number(value);
52
42
  }
53
43
  function modelVersion(value, label) {
54
- if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100) {
44
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > MAX_RISK_CALIBRATION_MODEL_VERSION) {
55
45
  throw new Error(`${label} must be a supported positive model version.`);
56
46
  }
57
47
  return Number(value);
58
48
  }
59
49
  function canonicalTimestamp(value, label) {
60
- if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
50
+ if (typeof value !== 'string'
51
+ || value.length > MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH
52
+ || !Number.isFinite(Date.parse(value))) {
61
53
  throw new Error(`${label} must be a valid date and time.`);
62
54
  }
63
55
  const canonical = new Date(value).toISOString();
@@ -154,7 +146,12 @@ export function parseRiskCalibrationSummaryReport(raw) {
154
146
  let parsed;
155
147
  try {
156
148
  const withoutBom = raw.replace(/^\uFEFF/u, '');
157
- scanBoundedJson(withoutBom);
149
+ scanBoundedJson(withoutBom, {
150
+ maximumDepth: MAX_RISK_CALIBRATION_JSON_DEPTH,
151
+ maximumKeys: MAX_RISK_CALIBRATION_JSON_KEYS,
152
+ maximumValues: MAX_RISK_CALIBRATION_JSON_VALUES,
153
+ maximumContainerItems: MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS,
154
+ });
158
155
  parsed = JSON.parse(withoutBom);
159
156
  }
160
157
  catch (reason) {
@@ -172,7 +169,9 @@ export function parseRiskCalibrationSummaryReport(raw) {
172
169
  }
173
170
  const dataset = object(root.dataset, 'Risk calibration dataset metadata');
174
171
  exactKeys(dataset, DATASET_KEYS, 'Risk calibration dataset metadata');
175
- if (dataset.schema !== 'whoisleuth.risk-calibration-dataset' || (dataset.version !== 1 && dataset.version !== 2)) {
172
+ if (dataset.schema !== RISK_CALIBRATION_DATASET_SCHEMA
173
+ || typeof dataset.version !== 'number'
174
+ || !SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.includes(dataset.version)) {
176
175
  throw new Error('Risk calibration dataset metadata has an unsupported schema or version.');
177
176
  }
178
177
  const recordCount = boundedCount(dataset.recordCount, 'Risk calibration dataset record count');
@@ -210,7 +209,7 @@ export function parseRiskCalibrationSummaryReport(raw) {
210
209
  throw new Error('Risk calibration summary must contain the fixed threshold set exactly once.');
211
210
  }
212
211
  const thresholds = Object.freeze(root.thresholds.map((value, index) => thresholdMetrics(value, `Threshold ${index + 1}`, RISK_CALIBRATION_SUMMARY_THRESHOLDS[index], positive + negative, positive, negative)));
213
- if (!Array.isArray(root.strata) || root.strata.length > 32) {
212
+ if (!Array.isArray(root.strata) || root.strata.length > MAX_RISK_CALIBRATION_SUMMARY_STRATA) {
214
213
  throw new Error('Risk calibration strata must be a bounded array.');
215
214
  }
216
215
  const seenStrata = new Set();
@@ -221,7 +220,10 @@ export function parseRiskCalibrationSummaryReport(raw) {
221
220
  if (stratum.dimension !== 'review_reason' && stratum.dimension !== 'scan_depth') {
222
221
  throw new Error(`${label} has an unsupported dimension.`);
223
222
  }
224
- if (typeof stratum.value !== 'string' || !stratum.value || stratum.value.length > 64 || CONTROL_RE.test(stratum.value)) {
223
+ if (typeof stratum.value !== 'string'
224
+ || !stratum.value
225
+ || stratum.value.length > MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH
226
+ || CONTROL_RE.test(stratum.value)) {
225
227
  throw new Error(`${label} has an invalid value.`);
226
228
  }
227
229
  const allowedValues = stratum.dimension === 'review_reason' ? REVIEW_REASON_VALUES : SCAN_DEPTH_VALUES;
@@ -290,7 +292,7 @@ export function parseRiskCalibrationSummaryReport(raw) {
290
292
  mode: 'summary',
291
293
  generatedAt: canonicalTimestamp(root.generatedAt, 'Risk calibration generation time'),
292
294
  dataset: Object.freeze({
293
- schema: 'whoisleuth.risk-calibration-dataset',
295
+ schema: RISK_CALIBRATION_DATASET_SCHEMA,
294
296
  version: dataset.version,
295
297
  recordCount,
296
298
  }),
@@ -97,8 +97,9 @@ function scoreQuality(input) {
97
97
  if ([input.hasPasswordField, input.hasExternalFormAction].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
98
98
  observedFamilies.push('credential-lure');
99
99
  }
100
- if (input.threatIntelligence !== undefined && input.threatIntelligence !== null)
100
+ if (calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain).eligibleProviderCount > 0) {
101
101
  observedFamilies.push('external-intelligence');
102
+ }
102
103
  if ([input.hasMx, input.hasSpf, input.hasDmarc].some(hasBoolean)
103
104
  || typeof input.activityStatus === 'string'
104
105
  || typeof input.domainAgeDays === 'number')
@@ -156,7 +157,7 @@ function explainRiskScoreInternal(input, excludedFamily) {
156
157
  else if (input.hasExternalFormAction === true) {
157
158
  add('credential-lure', 'External form destination observed without a password field', 0);
158
159
  }
159
- const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
160
+ const external = calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain);
160
161
  if (external.factor)
161
162
  add('external-intelligence', external.factor.label, external.contribution);
162
163
  const primaryFamilies = ['domain-resemblance', 'brand-presentation', 'credential-lure', 'external-intelligence']
@@ -279,7 +280,7 @@ export function explainRiskScoreV6(input) {
279
280
  }
280
281
  if (contextual.size >= 2)
281
282
  add('corroboration', `Corroborating context across ${contextual.size} distinct evidence families`, contextual.size >= 3 ? 20 : 10);
282
- const external = calibrateExternalIntelligenceRisk(input.threatIntelligence);
283
+ const external = calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain);
283
284
  if (external.factor)
284
285
  add('external-intelligence', external.factor.label, external.contribution);
285
286
  if (input.activityStatus === 'active')
@@ -434,34 +434,43 @@ async function readTextCapped(res, maxBytes, options = {}) {
434
434
  throw new Error('Response body does not expose a bounded readable stream');
435
435
  }
436
436
  const reader = res.body.getReader();
437
- const decoder = new TextDecoder();
437
+ const decoder = new TextDecoder('utf-8', { fatal: options.fatalUtf8 === true });
438
438
  let text = '';
439
439
  let received = 0;
440
440
  let truncated = false;
441
- while (received < maxBytes) {
442
- const { done, value } = await reader.read();
443
- if (done)
444
- break;
445
- const remaining = maxBytes - received;
446
- const captured = value.subarray(0, remaining);
447
- hasher?.update(captured);
448
- received += captured.byteLength;
449
- text += decoder.decode(captured, { stream: true });
450
- if (captured.byteLength < value.byteLength) {
451
- truncated = true;
452
- break;
441
+ try {
442
+ while (received < maxBytes) {
443
+ const { done, value } = await reader.read();
444
+ if (done)
445
+ break;
446
+ const remaining = maxBytes - received;
447
+ const captured = value.subarray(0, remaining);
448
+ hasher?.update(captured);
449
+ received += captured.byteLength;
450
+ text += decoder.decode(captured, { stream: true });
451
+ if (captured.byteLength < value.byteLength) {
452
+ truncated = true;
453
+ break;
454
+ }
455
+ }
456
+ // If the cap was hit, one more read() tells us whether there was actually
457
+ // more data left (truncated) or the body just happened to end right there.
458
+ if (!truncated && received >= maxBytes) {
459
+ const { done } = await reader.read();
460
+ if (!done)
461
+ truncated = true;
453
462
  }
463
+ text += decoder.decode();
464
+ return result(text, truncated, received);
454
465
  }
455
- // If the cap was hit, one more read() tells us whether there was actually
456
- // more data left (truncated) or the body just happened to end right there.
457
- if (!truncated && received >= maxBytes) {
458
- const { done } = await reader.read();
459
- if (!done)
460
- truncated = true;
466
+ finally {
467
+ try {
468
+ reader.cancel().catch(() => { });
469
+ }
470
+ finally {
471
+ reader.releaseLock();
472
+ }
461
473
  }
462
- reader.cancel().catch(() => { });
463
- text += decoder.decode();
464
- return result(text, truncated, received);
465
474
  }
466
475
  // Binary-safe counterpart to readTextCapped, for a response body that isn't
467
476
  // text (a favicon image) - collects raw bytes into a Buffer instead of
@@ -476,26 +485,39 @@ async function readBytesCapped(res, maxBytes) {
476
485
  const chunks = [];
477
486
  let received = 0;
478
487
  let truncated = false;
479
- while (received < maxBytes) {
480
- const { done, value } = await reader.read();
481
- if (done)
482
- break;
483
- const remaining = maxBytes - received;
484
- const captured = value.subarray(0, remaining);
485
- received += captured.byteLength;
486
- chunks.push(Buffer.from(captured));
487
- if (captured.byteLength < value.byteLength) {
488
- truncated = true;
489
- break;
488
+ // Cancel unread body data and release the exclusive reader lock on every
489
+ // exit path, exactly as readTextCapped does. A mid-stream read failure (an
490
+ // upstream reset while fetching an untrusted favicon) must not leave the
491
+ // response locked to this reader.
492
+ try {
493
+ while (received < maxBytes) {
494
+ const { done, value } = await reader.read();
495
+ if (done)
496
+ break;
497
+ const remaining = maxBytes - received;
498
+ const captured = value.subarray(0, remaining);
499
+ received += captured.byteLength;
500
+ chunks.push(Buffer.from(captured));
501
+ if (captured.byteLength < value.byteLength) {
502
+ truncated = true;
503
+ break;
504
+ }
505
+ }
506
+ // Same "one more read() to check for leftover data" trick as readTextCapped.
507
+ if (!truncated && received >= maxBytes) {
508
+ const { done } = await reader.read();
509
+ if (!done)
510
+ truncated = true;
490
511
  }
512
+ return { bytes: Buffer.concat(chunks), truncated, bytesRead: received };
491
513
  }
492
- // Same "one more read() to check for leftover data" trick as readTextCapped.
493
- if (!truncated && received >= maxBytes) {
494
- const { done } = await reader.read();
495
- if (!done)
496
- truncated = true;
514
+ finally {
515
+ try {
516
+ reader.cancel().catch(() => { });
517
+ }
518
+ finally {
519
+ reader.releaseLock();
520
+ }
497
521
  }
498
- reader.cancel().catch(() => { });
499
- return { bytes: Buffer.concat(chunks), truncated, bytesRead: received };
500
522
  }
501
523
  export { MAX_REDIRECTS, MAX_SAFE_FETCH_URL_LENGTH, MAX_SAFE_FETCH_ADDRESS_CANDIDATES, safeFetch, safeFetchDetailed, readTextCapped, readBytesCapped, isPrivateAddress, resolvePublicAddresses, };
@@ -4,7 +4,7 @@
4
4
  // Risk. Published contact and policy values are normalized into a small,
5
5
  // bounded record; the response body itself is discarded after parsing.
6
6
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
7
- import { createObservation } from "./observation.mjs";
7
+ import { createObservation } from "../packages/evidence/observation.mjs";
8
8
  const SECURITY_TXT_VERSION = 1;
9
9
  const SECURITY_TXT_PATH = '/.well-known/security.txt';
10
10
  const SECURITY_TXT_TIMEOUT_MS = 6_000;
@@ -11,6 +11,7 @@ import * as tls from 'node:tls';
11
11
  import { DNS_TYPE_A, DNS_TYPE_AAAA, DNS_TYPE_CNAME, DNS_TYPE_TLSA, DnssecQuerySession, collectValidatedRrset, normalizeDnsName, normalizeResolverEndpoint, parseDnssecTrustAnchor, validateDnssecChainWithContext, } from "./dnssec-chain-validation.mjs";
12
12
  import { isPrivateAddress } from "./safe-fetch.mjs";
13
13
  import { analyzeTlsaEvidence } from "./tlsa-evidence.mjs";
14
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
14
15
  const MAIL_TRANSPORT_INPUT_SCHEMA = 'whoisleuth.mail-transport.input';
15
16
  const MAIL_TRANSPORT_INPUT_VERSION = 1;
16
17
  const MAIL_TRANSPORT_REVIEW_SCHEMA = 'whoisleuth.cli.mail-transport-review';
@@ -45,10 +46,12 @@ function exactRecord(value, keys, label) {
45
46
  throw new TypeError(`${label} contains unsupported field "${unexpected}".`);
46
47
  return record;
47
48
  }
48
- function timestamp(value, label) {
49
- if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value)))
50
- throw new TypeError(`${label} must be one ISO-compatible timestamp.`);
51
- return new Date(value).toISOString();
49
+ function timestamp(value, label, legacy = false) {
50
+ const normalized = normalizeExplicitIsoTimestamp(value)
51
+ ?? (legacy ? normalizeLegacyIsoTimestamp(value) : null);
52
+ if (!normalized)
53
+ throw new TypeError(`${label} must be one bounded ISO timestamp${legacy ? '' : ' with an explicit timezone'}.`);
54
+ return normalized;
52
55
  }
53
56
  function text(value, label, maximum = 160) {
54
57
  if (typeof value !== 'string' || value.length > maximum * 4)
@@ -70,7 +73,7 @@ function policyEvidence(value, label) {
70
73
  if (!allowedStates.includes(state) || !['complete', 'partial', 'unavailable'].includes(completeness))
71
74
  throw new TypeError(`policyContext.${label} has an unsupported state or completeness.`);
72
75
  const source = record.source === null ? null : text(record.source, `policyContext.${label}.source`, 160);
73
- const observedAt = record.observedAt === null ? null : timestamp(record.observedAt, `policyContext.${label}.observedAt`);
76
+ const observedAt = record.observedAt === null ? null : timestamp(record.observedAt, `policyContext.${label}.observedAt`, true);
74
77
  const unavailable = completeness === 'unavailable';
75
78
  if ((unavailable && (source !== null || observedAt !== null)) || (!unavailable && (source === null || observedAt === null))) {
76
79
  throw new TypeError(`policyContext.${label} unavailable evidence must omit source and time; retained evidence requires both.`);
@@ -3,6 +3,8 @@
3
3
  // positive match, but a stale miss remains inconclusive.
4
4
  import { SSLBL_CERTIFICATE_SNAPSHOT } from "./sslbl-certificates.generated.mjs";
5
5
  import { createHash } from 'node:crypto';
6
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
7
+ import { SSLBL_SNAPSHOT_SCHEMA, SSLBL_SNAPSHOT_VERSION, } from "../packages/contracts/sslbl-snapshot.mjs";
6
8
  export const SSLBL_INTELLIGENCE_VERSION = 1;
7
9
  export const SSLBL_SNAPSHOT_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
8
10
  export const SSLBL_SNAPSHOT_EXPIRED_AGE_MS = 45 * 24 * 60 * 60 * 1000;
@@ -11,7 +13,6 @@ export const MAX_SSLBL_FINGERPRINT_CHUNKS = 1_000;
11
13
  export const SSLBL_SOURCE_PAGE = 'https://sslbl.abuse.ch/blacklist/';
12
14
  const SHA1_RE = /^[a-f0-9]{40}$/u;
13
15
  const SHA256_RE = /^[a-f0-9]{64}$/u;
14
- const SNAPSHOT_SCHEMA = 'whoisleuth.sslbl-certificate-snapshot';
15
16
  const SNAPSHOT_SOURCE = 'https://sslbl.abuse.ch/blacklist/sslblacklist.csv';
16
17
  function record(value) {
17
18
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -19,10 +20,7 @@ function record(value) {
19
20
  : {};
20
21
  }
21
22
  function timestamp(value) {
22
- if (typeof value !== 'string' || value.length > 64)
23
- return null;
24
- const parsed = Date.parse(value);
25
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
23
+ return normalizeExplicitIsoTimestamp(value);
26
24
  }
27
25
  function normalizedFingerprint(value) {
28
26
  if (typeof value !== 'string')
@@ -39,8 +37,8 @@ function validateSnapshot(value) {
39
37
  const candidate = record(value);
40
38
  const sourceUpdatedAt = timestamp(candidate.sourceUpdatedAt);
41
39
  const generatedAt = timestamp(candidate.generatedAt);
42
- if (candidate.schema !== SNAPSHOT_SCHEMA
43
- || candidate.version !== 1
40
+ if (candidate.schema !== SSLBL_SNAPSHOT_SCHEMA
41
+ || candidate.version !== SSLBL_SNAPSHOT_VERSION
44
42
  || candidate.source !== SNAPSHOT_SOURCE
45
43
  || !sourceUpdatedAt
46
44
  || !generatedAt
@@ -95,7 +93,7 @@ function effectiveNow(value) {
95
93
  : typeof value === 'number'
96
94
  ? value
97
95
  : typeof value === 'string'
98
- ? Date.parse(value)
96
+ ? Date.parse(normalizeExplicitIsoTimestamp(value) ?? '')
99
97
  : Date.now();
100
98
  return Number.isFinite(candidate) ? candidate : Date.now();
101
99
  }