@slicedearth/whoisleuth-cli 1.47.4 → 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.
Files changed (235) 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/provider-policy-admission.mjs +3 -1
  100. package/lib/rdap-bootstrap.mjs +76 -16
  101. package/lib/rdap-client.mjs +20 -12
  102. package/lib/rdap-normalization.mjs +306 -75
  103. package/lib/rdap-transport.mjs +2 -2
  104. package/lib/rdap-validation.mjs +65 -14
  105. package/lib/rdap.mjs +2 -2
  106. package/lib/registrable-domain.mjs +36 -0
  107. package/lib/registry-insights.mjs +7 -0
  108. package/lib/response-policy.mjs +27 -4
  109. package/lib/risk-calibration-summary.mjs +35 -33
  110. package/lib/risk-scoring.mjs +4 -3
  111. package/lib/safe-fetch.mjs +62 -40
  112. package/lib/security-txt.mjs +1 -1
  113. package/lib/smtp-transport-review.mjs +8 -5
  114. package/lib/sslbl-intelligence.mjs +6 -8
  115. package/lib/static-html-analysis.mjs +301 -4
  116. package/lib/structured-data-identity.mjs +51 -19
  117. package/lib/threat-intelligence-fixtures.mjs +2 -4
  118. package/lib/threat-intelligence-manifests.mjs +2 -7
  119. package/lib/threat-intelligence-runtime.mjs +3 -5
  120. package/lib/threat-intelligence-types.mjs +3 -0
  121. package/lib/threatfox-intelligence.mjs +7 -3
  122. package/lib/tls-intelligence.mjs +4 -4
  123. package/lib/urlhaus-intelligence.mjs +4 -2
  124. package/lib/urlscan-intelligence.mjs +6 -5
  125. package/lib/website-security-posture.mjs +1 -1
  126. package/lib/website-technology.mjs +1 -1
  127. package/lib/whois-authority.mjs +43 -8
  128. package/lib/whois-chain.mjs +25 -3
  129. package/lib/whois-normalization.mjs +3 -1
  130. package/lib/whois-transport.mjs +12 -3
  131. package/lib/zone-intent-review.mjs +1 -1
  132. package/package.json +12 -4
  133. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  134. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  135. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  136. package/packages/cases/case-migration-model.mjs +476 -0
  137. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  138. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  139. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  140. package/packages/cases/case-record-model.mjs +9 -0
  141. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  142. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  143. package/packages/cases/case-response-model.mjs +1604 -0
  144. package/packages/cases/case-response-packet.mjs +1312 -0
  145. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  146. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  147. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  148. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  149. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  150. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  151. package/packages/comparison/favicon-similarity.mjs +78 -0
  152. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  153. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  154. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  155. package/packages/contracts/analyst-review-state.mjs +50 -0
  156. package/packages/contracts/case-portability.mjs +1310 -0
  157. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  158. package/packages/contracts/cli-lookup.mjs +394 -0
  159. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  160. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  161. package/packages/contracts/domain-control-monitor.mjs +310 -0
  162. package/packages/contracts/domain-control-review.mjs +828 -0
  163. package/packages/contracts/domain-name.mjs +4 -0
  164. package/packages/contracts/external-observation-interchange.mjs +224 -0
  165. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  166. package/packages/contracts/investigation-portability.mjs +331 -0
  167. package/packages/contracts/offline-comparison.mjs +160 -0
  168. package/packages/contracts/risk-calibration.mjs +1288 -0
  169. package/packages/contracts/schema-compatibility.mjs +71 -0
  170. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  171. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  172. package/packages/contracts/workspace-portability.mjs +1347 -0
  173. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  174. package/packages/evidence/decision-fact.mjs +735 -0
  175. package/packages/evidence/domain-control-runtime.mjs +547 -0
  176. package/packages/evidence/domain-name.mjs +41 -0
  177. package/packages/evidence/observation.mjs +384 -0
  178. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  179. package/packages/interchange/retained-text.mjs +8 -0
  180. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  181. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  182. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  183. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  184. package/packages/investigation/bulk-review-policy.mjs +1 -0
  185. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  186. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  187. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  188. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  189. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  190. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  191. package/packages/monitoring/analyst-review-state.mjs +680 -0
  192. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
  193. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  194. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  195. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  196. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  197. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  198. package/packages/workspace/hostile-input.mjs +172 -0
  199. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  200. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  201. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  202. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  203. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  204. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  205. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  206. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  207. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  208. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  209. package/third-party-notices.txt +14 -14
  210. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  211. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  212. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  213. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  214. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  215. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  216. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  217. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  218. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  219. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  220. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  221. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  222. package/frontend/src/lib/analysis/scoring.js +0 -55
  223. package/frontend/src/lib/analysis/utils.js +0 -255
  224. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  225. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  226. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  227. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  228. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  229. package/lib/bounded-json-response.mjs +0 -170
  230. package/lib/lookup-response-contract.mjs +0 -1034
  231. package/lib/observation.mjs +0 -115
  232. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  233. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
package/lib/ct-search.mjs CHANGED
@@ -10,8 +10,9 @@ import { parse } from 'tldts';
10
10
  import { normalizeCtQuery } from "./ct-query.mjs";
11
11
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
12
12
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
13
- import { createObservation } from "./observation.mjs";
13
+ import { createObservation } from "../packages/evidence/observation.mjs";
14
14
  import { isValidAsciiHostname } from "./hostname.mjs";
15
+ import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
15
16
  import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "./ct-response-bounds.mjs";
16
17
  const CRT_SH_TIMEOUT_MS = 20000;
17
18
  const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
@@ -107,7 +108,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
107
108
  ? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
108
109
  : `crt.sh returned ${res.status}`);
109
110
  }
110
- const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES);
111
+ const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
111
112
  if (truncated) {
112
113
  throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
113
114
  }
@@ -155,6 +156,16 @@ function resolveCertId(row, index) {
155
156
  }
156
157
  return `row:${index}`;
157
158
  }
159
+ function suppliedCertificateIdentityMalformed(row) {
160
+ const hasId = Object.hasOwn(row, 'id') && row.id !== null && row.id !== undefined;
161
+ if (hasId)
162
+ return canonicalCertId(row.id) === null;
163
+ const hasIssuer = Object.hasOwn(row, 'issuer_ca_id') && row.issuer_ca_id !== null && row.issuer_ca_id !== undefined;
164
+ const hasSerial = Object.hasOwn(row, 'serial_number') && row.serial_number !== null && row.serial_number !== undefined;
165
+ return hasIssuer || hasSerial
166
+ ? canonicalCertId(row.issuer_ca_id) === null || normalizeSerial(row.serial_number) === null
167
+ : false;
168
+ }
158
169
  /**
159
170
  * Canonicalises a crt.sh certificate ID field for use in identity comparison.
160
171
  * Accepts only safe positive integers or digit-only strings that can be
@@ -230,20 +241,9 @@ function normalizeSerial(value) {
230
241
  * @returns {string | null}
231
242
  */
232
243
  function validateEntryTimestamp(value) {
233
- if (typeof value !== 'string')
234
- return null;
235
- if (value.length === 0 || value.length > MAX_TIMESTAMP_LENGTH)
236
- return null;
237
- // Reject control characters and overlong strings before any parsing.
238
- if (/[\x00-\x1f\x7f]/.test(value))
239
- return null;
240
- const trimmed = value.trim();
241
- if (trimmed.length === 0)
242
- return null;
243
- const parsed = new Date(trimmed);
244
- if (Number.isNaN(parsed.getTime()))
245
- return null;
246
- return parsed.toISOString();
244
+ return typeof value === 'string' && value.length <= MAX_TIMESTAMP_LENGTH
245
+ ? normalizeCtTimestamp(value)
246
+ : null;
247
247
  }
248
248
  // ---------------------------------------------------------------------------
249
249
  // Pure summarizer
@@ -288,10 +288,24 @@ function summarizeCtResults(rows) {
288
288
  const certificateMap = new Map();
289
289
  let namesExamined = 0;
290
290
  let workTruncated = false;
291
+ let rejectedRows = 0;
291
292
  rowLoop: for (let i = 0; i < rows.length; i++) {
292
293
  const row = rows[i];
293
- if (!row || typeof row !== 'object')
294
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
295
+ rejectedRows += 1;
294
296
  continue;
297
+ }
298
+ const missingNameFields = ![row.name_value, row.common_name]
299
+ .some((value) => typeof value === 'string'
300
+ && /[^\p{White_Space}\p{Default_Ignorable_Code_Point}\u0000-\u001f\u007f-\u009f]/u.test(value));
301
+ const malformedNameField = ['name_value', 'common_name'].some((field) => (Object.hasOwn(row, field) && typeof row[field] !== 'string'));
302
+ const malformedTimestamp = Object.hasOwn(row, 'entry_timestamp')
303
+ && row.entry_timestamp !== null
304
+ && row.entry_timestamp !== undefined
305
+ && validateEntryTimestamp(row.entry_timestamp) === null;
306
+ if (missingNameFields || malformedNameField || malformedTimestamp || suppliedCertificateIdentityMalformed(row)) {
307
+ rejectedRows += 1;
308
+ }
295
309
  const certId = resolveCertId(row, i);
296
310
  // Collect only direct string fields. Implicitly stringifying a nested
297
311
  // array or object would traverse attacker-controlled structure before any
@@ -442,7 +456,8 @@ function summarizeCtResults(rows) {
442
456
  certificateGroupsTruncated,
443
457
  namesExamined,
444
458
  workTruncated,
445
- truncated: workTruncated || legacyTruncated || matchTruncated || perMatchTruncated,
459
+ rejectedRows,
460
+ truncated: rejectedRows > 0 || workTruncated || legacyTruncated || matchTruncated || perMatchTruncated,
446
461
  };
447
462
  }
448
463
  // ---------------------------------------------------------------------------
@@ -454,12 +469,12 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
454
469
  if (!trimmed) {
455
470
  return {
456
471
  domains: [], certCount: 0, truncated: false, matches: [], certificateGroups: [], certificateGroupsTruncated: false,
457
- namesExamined: 0, workTruncated: false,
472
+ namesExamined: 0, workTruncated: false, rejectedRows: 0,
458
473
  observation: createObservation({
459
474
  status: 'success', observedAt: new Date().toISOString(), source: 'certificate_transparency',
460
475
  durationMs: Date.now() - startedAt, complete: true, truncated: false,
461
476
  limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
462
- diagnostics: { certificateRows: 0, namesExamined: 0, workTruncated: false, matches: 0, certificateGroups: 0 },
477
+ diagnostics: { certificateRows: 0, rejectedRows: 0, namesExamined: 0, workTruncated: false, matches: 0, certificateGroups: 0 },
463
478
  }),
464
479
  };
465
480
  }
@@ -477,10 +492,11 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
477
492
  truncated: summary.truncated || summary.certificateGroupsTruncated,
478
493
  limitations: [
479
494
  'Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.',
495
+ ...(summary.rejectedRows ? [`${summary.rejectedRows} certificate row${summary.rejectedRows === 1 ? '' : 's'} contained rejected content; unusable rows or malformed fields were excluded, so retained results are partial.`] : []),
480
496
  ...(summary.workTruncated ? ['The response contained more hostname entries than the bounded local analysis could examine; retained results are partial.'] : []),
481
497
  ...(summary.certificateGroupsTruncated ? ['The optional certificate-group projection was capped independently of the registrable-domain result set.'] : []),
482
498
  ],
483
- diagnostics: { certificateRows: data.length, namesExamined: summary.namesExamined, workTruncated: summary.workTruncated, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
499
+ diagnostics: { certificateRows: data.length, rejectedRows: summary.rejectedRows, namesExamined: summary.namesExamined, workTruncated: summary.workTruncated, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
484
500
  }),
485
501
  };
486
502
  }
@@ -8,6 +8,8 @@ import crypto from 'node:crypto';
8
8
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
9
9
  const DEFAULT_NAMESPACE = 'whoisleuth:operation-budget:v1';
10
10
  const DEFAULT_LEASE_TTL_MS = 5 * 60 * 1000;
11
+ const MIN_LEASE_TTL_MS = 30_000;
12
+ const MAX_LEASE_TTL_MS = 15 * 60 * 1000;
11
13
  const REQUEST_TIMEOUT_MS = 4_000;
12
14
  const MAX_RESPONSE_BYTES = 16 * 1024;
13
15
  const PROVIDER_RETRY_AFTER_SECONDS = 5;
@@ -150,7 +152,7 @@ function normalizedNamespace(value = DEFAULT_NAMESPACE) {
150
152
  }
151
153
  function normalizedLeaseTtl(value = DEFAULT_LEASE_TTL_MS) {
152
154
  const ttl = Number(value);
153
- if (!Number.isSafeInteger(ttl) || ttl < 30_000 || ttl > 15 * 60 * 1000) {
155
+ if (!Number.isSafeInteger(ttl) || ttl < MIN_LEASE_TTL_MS || ttl > MAX_LEASE_TTL_MS) {
154
156
  throw new Error('The distributed-budget lease TTL must be between 30 seconds and 15 minutes');
155
157
  }
156
158
  return ttl;
@@ -458,4 +460,4 @@ function createDistributedOperationBudget(config, dependencies = {}) {
458
460
  status,
459
461
  };
460
462
  }
461
- export { DEFAULT_NAMESPACE, DEFAULT_LEASE_TTL_MS, PROVIDER_RETRY_AFTER_SECONDS, DAY_WINDOW_MS, THIRTY_DAY_WINDOW_MS, ACQUIRE_SCRIPT, ACQUIRE_WITH_USAGE_SCRIPT, RELEASE_SCRIPT, STATUS_SCRIPT, normalizedRestUrl, normalizedToken, normalizedNamespace, normalizedLimits, normalizedUsageLimits, normalizedOperationFeature, createRestCommandClient, createDistributedOperationBudget, };
463
+ export { DEFAULT_NAMESPACE, DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS, REQUEST_TIMEOUT_MS, MAX_RESPONSE_BYTES, PROVIDER_RETRY_AFTER_SECONDS, MAX_USAGE_LIMIT, DAY_WINDOW_MS, THIRTY_DAY_WINDOW_MS, ACQUIRE_SCRIPT, ACQUIRE_WITH_USAGE_SCRIPT, RELEASE_SCRIPT, STATUS_SCRIPT, normalizedRestUrl, normalizedToken, normalizedNamespace, normalizedLimits, normalizedUsageLimits, normalizedOperationFeature, createRestCommandClient, createDistributedOperationBudget, };
@@ -4,7 +4,8 @@
4
4
  // availability and never treats a failed query as record absence.
5
5
  import { promises as dns } from 'node:dns';
6
6
  import * as net from 'node:net';
7
- import { createObservation } from "./observation.mjs";
7
+ import { createObservation } from "../packages/evidence/observation.mjs";
8
+ import { isValidAsciiHostname } from "./hostname.mjs";
8
9
  import { isPrivateAddress } from "./safe-fetch.mjs";
9
10
  const DNS_DELEGATION_HEALTH_VERSION = 1;
10
11
  const MAX_AUTHORITIES = 4;
@@ -26,10 +27,8 @@ function boundedText(value, maximum) {
26
27
  return value.replace(/\s+/gu, ' ').trim() || null;
27
28
  }
28
29
  function hostname(value) {
29
- const normalized = boundedText(value, 253)?.toLowerCase().replace(/\.+$/u, '') ?? '';
30
- if (!normalized || !normalized.includes('.'))
31
- return null;
32
- return normalized.split('.').every((label) => (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu.test(label))) ? normalized : null;
30
+ const candidate = boundedText(value, 253)?.replace(/\.+$/u, '') ?? '';
31
+ return isValidAsciiHostname(candidate) ? candidate.toLowerCase() : null;
33
32
  }
34
33
  function hostnames(value, maximum = MAX_NAMESERVERS) {
35
34
  const output = new Set();
@@ -529,7 +528,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
529
528
  ? 'Fewer than two complete authority observations were available for one or more record types'
530
529
  : 'Observed A, AAAA, CAA, and MX answers agree across selected authorities', recordMatrix.map((row) => `${row.type}: ${row.state}`).join(' · '), 'Review propagation timing and the intended zone values before relying on a DNS change.')] : []),
531
530
  ];
532
- const collectionIncomplete = parentStatus === 'error'
531
+ const collectionIncomplete = parentStatus !== 'success'
532
+ || candidates.length === 0
533
533
  || parentQuery.truncated === true
534
534
  || registry.truncated
535
535
  || authorities.some((authority) => authority.state !== 'success')
@@ -557,6 +557,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
557
557
  'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
558
558
  'DNS health does not decide registration availability, ownership, control, intent, safety, or maliciousness.',
559
559
  'The DNSSEC check compares bounded registry publication fields; it does not validate the full cryptographic chain.',
560
+ ...(!candidates.length
561
+ ? ['No eligible authority was identified, so the delegation-health observation is incomplete.']
562
+ : []),
560
563
  ],
561
564
  diagnostics: {
562
565
  authorityCount: authorities.length,
@@ -6,7 +6,7 @@ import { promises as dns } from 'node:dns';
6
6
  import * as net from 'node:net';
7
7
  import { classifyMxRecords } from "./dns-mx.mjs";
8
8
  import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
9
- import { createObservation } from "./observation.mjs";
9
+ import { createObservation } from "../packages/evidence/observation.mjs";
10
10
  import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
11
11
  import { isPrivateAddress } from "./safe-fetch.mjs";
12
12
  import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
@@ -10,6 +10,7 @@ import { randomInt } from 'node:crypto';
10
10
  import { isIP } from 'node:net';
11
11
  import { domainToASCII } from 'node:url';
12
12
  import { isPrivateAddress } from "./safe-fetch.mjs";
13
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
13
14
  import { MAX_DNS_MESSAGE_BYTES, defaultTcpExchange, formatIpv6, } from "./service-binding-dns.mjs";
14
15
  const DNSSEC_CHAIN_SCHEMA = 'whoisleuth.dnssec-chain-validation';
15
16
  const DNSSEC_CHAIN_VERSION = 1;
@@ -95,9 +96,7 @@ function normalizedText(value, maximum) {
95
96
  }
96
97
  function normalizedTimestamp(value) {
97
98
  const text = normalizedText(value, 64);
98
- if (!text || !Number.isFinite(Date.parse(text)))
99
- return null;
100
- return new Date(text).toISOString();
99
+ return text ? normalizeExplicitIsoTimestamp(text) : null;
101
100
  }
102
101
  function normalizeTrustAnchorDs(value) {
103
102
  const source = value && typeof value === 'object' && !Array.isArray(value) ? value : null;
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { domainToASCII } from 'node:url';
4
+ import { normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
4
5
  const DNSSEC_EVIDENCE_SCHEMA = 'whoisleuth.dnssec-evidence-validation';
5
6
  const DNSSEC_EVIDENCE_VERSION = 1;
6
7
  const MAX_DNSSEC_RECORDS = 50;
@@ -99,20 +100,25 @@ function dsDigest(owner, dnskey, digestType) {
99
100
  }
100
101
  function normalizeRrsig(value) {
101
102
  const source = record(value);
102
- if (!source || typeof source.inception !== 'string' || typeof source.expiration !== 'string')
103
+ if (!source)
104
+ return null;
105
+ const normalizedInception = normalizeLegacyIsoTimestamp(source.inception);
106
+ const normalizedExpiration = normalizeLegacyIsoTimestamp(source.expiration);
107
+ if (!normalizedInception || !normalizedExpiration)
103
108
  return null;
104
- const inception = Date.parse(source.inception);
105
- const expiration = Date.parse(source.expiration);
106
- if (!Number.isFinite(inception) || !Number.isFinite(expiration) || inception > expiration)
109
+ const inception = Date.parse(normalizedInception);
110
+ const expiration = Date.parse(normalizedExpiration);
111
+ if (inception > expiration)
107
112
  return null;
108
113
  return Object.freeze({ inception, expiration });
109
114
  }
110
115
  function signatureTimeState(value, observedAt) {
111
- if (!value.length || typeof observedAt !== 'string')
116
+ if (!value.length)
112
117
  return 'unavailable';
113
- const observed = Date.parse(observedAt);
114
- if (!Number.isFinite(observed))
118
+ const normalizedObservedAt = normalizeLegacyIsoTimestamp(observedAt);
119
+ if (!normalizedObservedAt)
115
120
  return 'unavailable';
121
+ const observed = Date.parse(normalizedObservedAt);
116
122
  let withinWindow = 0;
117
123
  for (const item of value) {
118
124
  if (observed >= item.inception && observed <= item.expiration)
@@ -1,9 +1,10 @@
1
1
  import { domainToASCII } from 'node:url';
2
2
  import { exactKeys } from "./bounded-contract-normalizers.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
3
4
  const DOMAIN_ASSURANCE_INPUT_SCHEMA = 'whoisleuth.domain-assurance.input';
4
5
  const DOMAIN_ASSURANCE_SCHEMA = 'whoisleuth.domain-assurance';
5
6
  const DOMAIN_ASSURANCE_VERSION = 2;
6
- const DOMAIN_ASSURANCE_SUPPORTED_INPUT_VERSIONS = Object.freeze([1, 2]);
7
+ const DOMAIN_ASSURANCE_SUPPORTED_INPUT_VERSIONS = Object.freeze([DOMAIN_ASSURANCE_VERSION]);
7
8
  const MAX_ASSURANCE_INPUT_BYTES = 2 * 1024 * 1024;
8
9
  const ROOT_KEYS = Object.freeze({
9
10
  'planned-change': new Set(['schema', 'version', 'kind', 'domain', 'change']),
@@ -45,9 +46,10 @@ function optionalText(value, maximum = 160) {
45
46
  }
46
47
  function timestamp(value, field) {
47
48
  const normalized = text(value, field, 64);
48
- if (!Number.isFinite(Date.parse(normalized)))
49
- throw new Error(`${field} must be an ISO-compatible timestamp.`);
50
- return new Date(normalized).toISOString();
49
+ const canonical = normalizeExplicitIsoTimestamp(normalized);
50
+ if (canonical)
51
+ return canonical;
52
+ throw new Error(`${field} must be an ISO timestamp with an explicit timezone.`);
51
53
  }
52
54
  function optionalTimestamp(value, field) {
53
55
  if (value === null || value === undefined || value === '')
@@ -252,7 +254,7 @@ const RETIREMENT_CHECKS = Object.freeze([
252
254
  ['delegatedDnsMonitored', 'Delegated DNS remains monitored during retirement', true],
253
255
  ['reRegistrationPrevented', 'Registration continuity or defensive renewal is planned', true],
254
256
  ]);
255
- function buildRetirement(input, inputVersion) {
257
+ function buildRetirement(input) {
256
258
  const checksInput = record(input.checks);
257
259
  exactKeys(checksInput, new Set(RETIREMENT_CHECKS.map(([id]) => id)), 'checks');
258
260
  const fixedChecks = RETIREMENT_CHECKS.map(([id, checkLabel, expected]) => {
@@ -266,7 +268,7 @@ function buildRetirement(input, inputVersion) {
266
268
  });
267
269
  const fixedIds = new Set(RETIREMENT_CHECKS.map(([id]) => id));
268
270
  const customIds = new Set();
269
- const customChecks = inputVersion === 2 && input.customChecks !== undefined
271
+ const customChecks = input.customChecks !== undefined
270
272
  ? boundedArray(input.customChecks, 'customChecks', 0, 20).map((raw, index) => {
271
273
  const item = record(raw);
272
274
  exactKeys(item, CUSTOM_RETIREMENT_CHECK_KEYS, `customChecks[${index}]`);
@@ -304,18 +306,16 @@ function buildRetirement(input, inputVersion) {
304
306
  }
305
307
  function buildDomainAssurance(inputRaw, generatedAt = new Date().toISOString()) {
306
308
  const input = record(inputRaw);
307
- if (input.schema !== DOMAIN_ASSURANCE_INPUT_SCHEMA
308
- || !DOMAIN_ASSURANCE_SUPPORTED_INPUT_VERSIONS.includes(input.version)) {
309
- throw new Error(`Domain assurance input must use ${DOMAIN_ASSURANCE_INPUT_SCHEMA} version 1 or ${DOMAIN_ASSURANCE_VERSION}.`);
309
+ if (input.schema !== DOMAIN_ASSURANCE_INPUT_SCHEMA || input.version !== DOMAIN_ASSURANCE_VERSION) {
310
+ throw new Error(`Domain assurance input must use ${DOMAIN_ASSURANCE_INPUT_SCHEMA} version ${DOMAIN_ASSURANCE_VERSION}.`);
310
311
  }
311
- const inputVersion = input.version;
312
312
  const kind = enumValue(input.kind, 'kind', ['planned-change', 'recovery-dependencies', 'retirement']);
313
- exactKeys(input, kind === 'retirement' && inputVersion === 2 ? RETIREMENT_ROOT_KEYS_V2 : ROOT_KEYS[kind], 'Domain assurance input');
313
+ exactKeys(input, kind === 'retirement' ? RETIREMENT_ROOT_KEYS_V2 : ROOT_KEYS[kind], 'Domain assurance input');
314
314
  const result = kind === 'planned-change'
315
315
  ? buildPlannedChange(input)
316
316
  : kind === 'recovery-dependencies'
317
317
  ? buildRecoveryDependencies(input)
318
- : buildRetirement(input, inputVersion);
318
+ : buildRetirement(input);
319
319
  return {
320
320
  schema: DOMAIN_ASSURANCE_SCHEMA,
321
321
  version: DOMAIN_ASSURANCE_VERSION,
@@ -1,7 +1,7 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
3
3
  import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
4
- import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/artifact-integrity.mjs";
5
5
  export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
6
6
  export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
7
7
  export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;