@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -0,0 +1,56 @@
1
+ import { array, enumeration, exact, integer, text } from "./artifact-structure.mjs";
2
+ import { readRetainedFileReference } from "./retained-file.mjs";
3
+ // The decoded RGBA surface is bounded independently of compressed file bytes.
4
+ export const MAX_EVIDENCE_IMAGE_PIXELS = 16 * 1024 * 1024;
5
+ export const MAX_EVIDENCE_IMAGE_DIMENSION = 10_000;
6
+ // Each selected region emits at most four solid rectangles; no nested effects.
7
+ export const MAX_EVIDENCE_IMAGE_REGIONS = 64;
8
+ export function readEvidenceImageDimensions(width, height) {
9
+ const result = {
10
+ width: integer(width, 'Image width', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
11
+ height: integer(height, 'Image height', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
12
+ };
13
+ if (result.width * result.height > MAX_EVIDENCE_IMAGE_PIXELS)
14
+ throw new TypeError('This image exceeds the decoded-image pixel bound. Its original bytes are unchanged.');
15
+ return Object.freeze(result);
16
+ }
17
+ /** Pixel coordinates are integers, remain inside the source, and are never clipped silently. */
18
+ export function readImageRegionPlan(raw) {
19
+ const value = exact(raw, ['width', 'height', 'regions'], 'Image region plan');
20
+ const dimensions = readEvidenceImageDimensions(value.width, value.height);
21
+ const regions = Array.from(array(value.regions, 'Image regions', MAX_EVIDENCE_IMAGE_REGIONS, 1), candidate => {
22
+ const region = exact(candidate, ['kind', 'x', 'y', 'width', 'height'], 'Image region');
23
+ const kind = enumeration(region.kind, ['redact', 'outline'], 'Image region kind');
24
+ const x = integer(region.x, 'Region left coordinate', 0, dimensions.width - 1);
25
+ const y = integer(region.y, 'Region top coordinate', 0, dimensions.height - 1);
26
+ const width = integer(region.width, 'Region width', 1, dimensions.width - x);
27
+ const height = integer(region.height, 'Region height', 1, dimensions.height - y);
28
+ return Object.freeze({ kind, x, y, width, height });
29
+ });
30
+ return Object.freeze({ ...dimensions, regions: Object.freeze(regions) });
31
+ }
32
+ export function readImageDerivation(raw) {
33
+ const value = exact(raw, ['method', 'sourceAttachmentId', 'source', 'plan'], 'Image derivation');
34
+ if (value.method !== 'png-regions-v1')
35
+ throw new TypeError('This image derivation method is unsupported.');
36
+ return Object.freeze({
37
+ method: value.method,
38
+ sourceAttachmentId: text(value.sourceAttachmentId, 'Source attachment ID', 128),
39
+ source: readRetainedFileReference(value.source),
40
+ plan: readImageRegionPlan(value.plan),
41
+ });
42
+ }
43
+ /** Opaque redactions are always last, including where annotations overlap them. */
44
+ export function imageRegionPaintRectangles(raw) {
45
+ const plan = readImageRegionPlan(raw);
46
+ const result = [];
47
+ for (const region of plan.regions.filter(region => region.kind === 'outline')) {
48
+ const thickness = Math.min(3, region.width, region.height);
49
+ const { x, y, width, height } = region;
50
+ result.push({ x, y, width, height: thickness, colour: '#ffbf00' }, { x, y: y + height - thickness, width, height: thickness, colour: '#ffbf00' }, { x, y, width: thickness, height, colour: '#ffbf00' }, { x: x + width - thickness, y, width: thickness, height, colour: '#ffbf00' });
51
+ }
52
+ for (const region of plan.regions.filter(region => region.kind === 'redact')) {
53
+ result.push({ x: region.x, y: region.y, width: region.width, height: region.height, colour: '#000000' });
54
+ }
55
+ return result;
56
+ }
@@ -0,0 +1,28 @@
1
+ import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
2
+ /** Select a time cohort from an already bounded source; never rank undated evidence as older. */
3
+ export function latestObservationCohort(values, observedAt) {
4
+ let latestTime = null;
5
+ let latestMillis = -Infinity;
6
+ let latest = [];
7
+ const undated = [];
8
+ let superseded = 0;
9
+ for (const value of values) {
10
+ const time = normalizeExplicitIsoTimestamp(observedAt(value));
11
+ if (time === null) {
12
+ undated.push(value);
13
+ continue;
14
+ }
15
+ const millis = Date.parse(time);
16
+ if (millis > latestMillis) {
17
+ superseded += latest.length;
18
+ latestTime = time;
19
+ latestMillis = millis;
20
+ latest = [value];
21
+ }
22
+ else if (millis === latestMillis)
23
+ latest.push(value);
24
+ else
25
+ superseded += 1;
26
+ }
27
+ return { observedAt: latestTime, latest, undated, superseded };
28
+ }
@@ -0,0 +1,104 @@
1
+ // Ordinary collection retains a hostname, not a pasted URL's private path or query.
2
+ // Registration classification and network-address safety remain separate.
3
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
4
+ import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../contracts/http-url.mjs";
5
+ export const MAX_LOOKUP_INPUT_CHARACTERS = 2 * 1024 * 1024;
6
+ const URL_SCHEME = /^[a-z][a-z\d+.-]*:\/\//iu;
7
+ const CONTROL = /[\u0000-\u001f\u007f]/u;
8
+ export function validWebObservationMode(value) {
9
+ return value === undefined || value === 'selected_url';
10
+ }
11
+ /** A separate deliberate action; ordinary URL input never selects its path. */
12
+ export function prepareSelectedLookupUrl(value, hostname) {
13
+ if (typeof value !== 'string' || value.includes('\\'))
14
+ throw new TypeError('Select a valid HTTP(S) URL.');
15
+ const parsed = parseCredentialFreeHttpUrl(value, MAX_OUTBOUND_HTTP_URL_CHARACTERS);
16
+ if (!parsed || parsed.port || !isValidAsciiHostname(parsed.hostname)
17
+ || /^\d+\.\d+\.\d+\.\d+$/u.test(parsed.hostname)
18
+ || hostname !== undefined && parsed.hostname !== hostname) {
19
+ throw new TypeError('Selected URL must use the submitted hostname and a default HTTP(S) port, without credentials.');
20
+ }
21
+ parsed.hash = '';
22
+ const url = parsed.toString();
23
+ if (url.length > MAX_OUTBOUND_HTTP_URL_CHARACTERS)
24
+ throw new TypeError('Selected URL exceeds the request bound.');
25
+ return url;
26
+ }
27
+ /** Older results collected supporting evidence at the registrable domain. */
28
+ export function lookupObservationHostname(availability) {
29
+ const value = availability.observationHostname === undefined ? availability.domain : availability.observationHostname;
30
+ return typeof value === 'string' && isValidAsciiHostname(value) && value === value.toLowerCase() ? value : null;
31
+ }
32
+ function validLookupObservationHostname(value, query) {
33
+ return typeof value === 'string' && isValidAsciiHostname(value) && value === value.toLowerCase()
34
+ && value === (query.inputHostname ?? query.submitted ?? query.registrableDomain)
35
+ && typeof query.registrableDomain === 'string'
36
+ && (value === query.registrableDomain || value.endsWith(`.${query.registrableDomain}`));
37
+ }
38
+ /** Bind collection identities without treating an omitted historical identity as new evidence. */
39
+ export function validLookupObservationScope(availability, query) {
40
+ if (availability.observationHostname !== undefined && !validLookupObservationHostname(availability.observationHostname, query))
41
+ return false;
42
+ if (!validWebObservationMode(availability.webObservationMode))
43
+ return false;
44
+ if (availability.webObservationMode === 'selected_url') {
45
+ if (!validLookupObservationHostname(availability.observationHostname, query))
46
+ return false;
47
+ const http = availability.http;
48
+ const requestUrl = http && typeof http === 'object' && !Array.isArray(http) ? Reflect.get(http, 'requestUrl') : null;
49
+ const parsed = parseCredentialFreeHttpUrl(requestUrl, MAX_OUTBOUND_HTTP_URL_CHARACTERS);
50
+ if (!parsed || parsed.hostname !== availability.observationHostname || parsed.port || parsed.search || parsed.hash)
51
+ return false;
52
+ }
53
+ const dns = availability.dns;
54
+ const delegation = dns && typeof dns === 'object' && !Array.isArray(dns) ? Reflect.get(dns, 'delegation') : null;
55
+ const registrationDomain = delegation && typeof delegation === 'object' && !Array.isArray(delegation) ? Reflect.get(delegation, 'domain') : null;
56
+ return registrationDomain == null || (typeof registrationDomain === 'string'
57
+ && isValidAsciiHostname(registrationDomain) && registrationDomain === query.registrableDomain);
58
+ }
59
+ export function parseCredentialFreeHttpUrl(value, maximumLength) {
60
+ if (typeof value !== 'string' || !value || value.length > maximumLength
61
+ || value.trim() !== value || CONTROL.test(value))
62
+ return null;
63
+ try {
64
+ const parsed = new URL(value);
65
+ return (parsed.protocol === 'https:' || parsed.protocol === 'http:')
66
+ && !parsed.username && !parsed.password && parsed.hostname ? parsed : null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ export function prepareLookupCollectionTarget(value) {
73
+ if (value.length > MAX_LOOKUP_INPUT_CHARACTERS || CONTROL.test(value) || value.includes('\\')) {
74
+ throw new TypeError('Lookup input exceeds its bound or contains control characters.');
75
+ }
76
+ const raw = value.trim();
77
+ if (!raw)
78
+ throw new TypeError('Enter a domain, IP address or ASN.');
79
+ const absolute = URL_SCHEME.test(raw);
80
+ // An unbracketed IPv6 literal is not a URL with a port. Use the same URL
81
+ // parser to admit its syntax, then leave address safety and ASN bounds to
82
+ // the classifier; malformed colon-separated text must not become a request.
83
+ if (!absolute && !/[\s/@?#\\]/u.test(raw)) {
84
+ if (/^(?:AS)?\d+$/iu.test(raw))
85
+ return raw;
86
+ if ((raw.match(/:/gu)?.length ?? 0) > 1 && !raw.startsWith('[')) {
87
+ if (!parseCredentialFreeHttpUrl(`https://[${raw}]`, MAX_LOOKUP_INPUT_CHARACTERS + 10)) {
88
+ throw new TypeError('Enter a valid IPv6 address without a scope identifier.');
89
+ }
90
+ return raw;
91
+ }
92
+ }
93
+ const parsed = parseCredentialFreeHttpUrl(absolute ? raw : `https://${raw}`, MAX_LOOKUP_INPUT_CHARACTERS + 'https://'.length);
94
+ if (!parsed)
95
+ throw new TypeError('Enter a valid domain, IP, or ASN. URLs must use HTTP(S) without credentials.');
96
+ if (/^\d+\.\d+\.\d+\.\d+$/u.test(parsed.hostname)) {
97
+ const authority = (absolute ? raw.slice(raw.indexOf('://') + 3) : raw).split(/[/?#]/u)[0];
98
+ const originalHost = authority.split(':')[0].replace(/\.$/u, '');
99
+ if (originalHost !== parsed.hostname)
100
+ throw new TypeError('Use a complete canonical IP address, not an abbreviated or encoded address.');
101
+ }
102
+ // WHATWG URLs bracket IPv6; the existing classifier accepts the bare address.
103
+ return parsed.hostname.replace(/^\[|\]$/gu, '').toLowerCase();
104
+ }
@@ -68,6 +68,7 @@ const REGISTERED_DIAGNOSTIC_KEYS = [
68
68
  'discarded_relationships',
69
69
  'dmarc',
70
70
  'documentsParsed',
71
+ 'eligibleAuthorityCount',
71
72
  'entities',
72
73
  'error',
73
74
  'externalScriptsSkipped',
@@ -96,11 +97,13 @@ const REGISTERED_DIAGNOSTIC_KEYS = [
96
97
  'objectsExamined',
97
98
  'observed',
98
99
  'observedAbsence',
100
+ 'omittedAuthorityCount',
99
101
  'partialAuthorityCount',
100
102
  'passiveHeadersEvaluated',
101
103
  'potentialExposure',
102
104
  'ptr',
103
105
  'queriedAddressCount',
106
+ 'queriedAuthorityCount',
104
107
  'redirectCount',
105
108
  'referencesExamined',
106
109
  'rejectedRows',
@@ -238,6 +241,18 @@ function canonicalTimestamp(value, expression, assignUtcWhenMissing) {
238
241
  function normalizeExplicitIsoTimestamp(value) {
239
242
  return canonicalTimestamp(value, ISO_DATE_TIME_RE, false);
240
243
  }
244
+ /** Retain a source's timestamp; missing, invalid or future times have no usable age. */
245
+ function readObservationTime(value, now) {
246
+ const observedAt = normalizeExplicitIsoTimestamp(value);
247
+ const reviewedAt = normalizeExplicitIsoTimestamp(now);
248
+ const elapsed = observedAt && reviewedAt
249
+ ? Date.parse(reviewedAt) - Date.parse(observedAt)
250
+ : Number.NaN;
251
+ return {
252
+ observedAt,
253
+ ageDays: Number.isFinite(elapsed) && elapsed >= 0 ? Math.floor(elapsed / 86_400_000) : null,
254
+ };
255
+ }
241
256
  // Frozen legacy schemas accepted zone-less ISO date-times. Preserve that
242
257
  // migration path deterministically by assigning UTC instead of consulting the
243
258
  // host timezone. Current schemas must use normalizeExplicitIsoTimestamp.
@@ -381,4 +396,4 @@ function readObservationEnvelope(value) {
381
396
  }
382
397
  return { state: 'supported', observation: createObservationFromSnapshot(record) };
383
398
  }
384
- export { MAX_OBSERVATION_DIAGNOSTICS, MAX_OBSERVATION_LIMITATIONS, MAX_OBSERVATION_LIMITATION_LENGTH, OBSERVATION_VERSION, createObservation, normalizeCtTimestamp, normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp, readObservationEnvelope, };
399
+ export { MAX_OBSERVATION_DIAGNOSTICS, MAX_OBSERVATION_LIMITATIONS, MAX_OBSERVATION_LIMITATION_LENGTH, OBSERVATION_VERSION, createObservation, normalizeCtTimestamp, normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp, readObservationEnvelope, readObservationTime, };
@@ -0,0 +1,44 @@
1
+ // Passphrase policy and native key derivation shared by portable encrypted files.
2
+ import { MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES, MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS } from "../contracts/case-portability.mjs";
3
+ export const AES_KEY_BITS = 256;
4
+ const encoder = new TextEncoder();
5
+ export function arrayBuffer(bytes) {
6
+ return new Uint8Array(bytes).buffer;
7
+ }
8
+ export function assertPassphrase(passphrase) {
9
+ if (typeof passphrase !== 'string') {
10
+ throw new Error(`Use a backup passphrase with at least ${MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS} characters.`);
11
+ }
12
+ if (passphrase.length > MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES) {
13
+ throw new Error('Backup passphrases are limited to 1024 UTF-8 bytes.');
14
+ }
15
+ if (Array.from(passphrase).length < MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS) {
16
+ throw new Error(`Use a backup passphrase with at least ${MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS} characters.`);
17
+ }
18
+ const bytes = encoder.encode(passphrase);
19
+ if (bytes.byteLength > MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES) {
20
+ bytes.fill(0);
21
+ throw new Error('Backup passphrases are limited to 1024 UTF-8 bytes.');
22
+ }
23
+ return bytes;
24
+ }
25
+ export function cryptoProvider(provider = globalThis.crypto) {
26
+ if (!provider
27
+ || typeof provider.getRandomValues !== 'function'
28
+ || typeof provider.subtle?.importKey !== 'function'
29
+ || typeof provider.subtle?.deriveKey !== 'function'
30
+ || typeof provider.subtle?.encrypt !== 'function'
31
+ || typeof provider.subtle?.decrypt !== 'function') {
32
+ throw new Error('Encrypted workspace archives are unavailable in this browser.');
33
+ }
34
+ return provider;
35
+ }
36
+ export async function deriveArchiveKey(provider, passphraseBytes, salt, usages) {
37
+ const material = await provider.subtle.importKey('raw', arrayBuffer(passphraseBytes), 'PBKDF2', false, ['deriveKey']);
38
+ return provider.subtle.deriveKey({
39
+ name: 'PBKDF2',
40
+ hash: 'SHA-256',
41
+ salt: arrayBuffer(salt),
42
+ iterations: WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS,
43
+ }, material, { name: 'AES-GCM', length: AES_KEY_BITS }, false, usages);
44
+ }
@@ -0,0 +1,72 @@
1
+ // Shared collision-resistant identities for bounded, already normalised records.
2
+ // Platform cryptography remains the owner of signatures and authentication.
3
+ export const MAX_IDENTITY_DIGEST_BYTES = 1024 * 1024;
4
+ const SHA256_CONSTANTS = Object.freeze([
5
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
6
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
7
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
8
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
9
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
10
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
11
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
12
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
13
+ ]);
14
+ function rotateRight(value, bits) {
15
+ return (value >>> bits) | (value << (32 - bits));
16
+ }
17
+ export function sha256IdentityHex(bytes) {
18
+ if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
19
+ throw new RangeError('Record identity exceeds its byte limit.');
20
+ // This digest is a collision-resistant record identity, not a password,
21
+ // signature, MAC, or substitute for the platform cryptography used there.
22
+ const paddedLength = Math.ceil((bytes.byteLength + 9) / 64) * 64;
23
+ const padded = new Uint8Array(paddedLength);
24
+ padded.set(bytes);
25
+ padded[bytes.byteLength] = 0x80;
26
+ const view = new DataView(padded.buffer);
27
+ const bitLength = bytes.byteLength * 8;
28
+ view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000), false);
29
+ view.setUint32(paddedLength - 4, bitLength >>> 0, false);
30
+ const state = new Uint32Array([
31
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
32
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
33
+ ]);
34
+ const words = new Uint32Array(64);
35
+ for (let offset = 0; offset < paddedLength; offset += 64) {
36
+ for (let index = 0; index < 16; index += 1)
37
+ words[index] = view.getUint32(offset + index * 4, false);
38
+ for (let index = 16; index < 64; index += 1) {
39
+ const left = words[index - 15];
40
+ const right = words[index - 2];
41
+ const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3);
42
+ const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10);
43
+ words[index] = (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
44
+ }
45
+ let [a, b, c, d, e, f, g, h] = state;
46
+ for (let index = 0; index < 64; index += 1) {
47
+ const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
48
+ const choice = (e & f) ^ (~e & g);
49
+ const first = (h + sum1 + choice + SHA256_CONSTANTS[index] + words[index]) >>> 0;
50
+ const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
51
+ const majority = (a & b) ^ (a & c) ^ (b & c);
52
+ const second = (sum0 + majority) >>> 0;
53
+ h = g;
54
+ g = f;
55
+ f = e;
56
+ e = (d + first) >>> 0;
57
+ d = c;
58
+ c = b;
59
+ b = a;
60
+ a = (first + second) >>> 0;
61
+ }
62
+ state[0] = (state[0] + a) >>> 0;
63
+ state[1] = (state[1] + b) >>> 0;
64
+ state[2] = (state[2] + c) >>> 0;
65
+ state[3] = (state[3] + d) >>> 0;
66
+ state[4] = (state[4] + e) >>> 0;
67
+ state[5] = (state[5] + f) >>> 0;
68
+ state[6] = (state[6] + g) >>> 0;
69
+ state[7] = (state[7] + h) >>> 0;
70
+ }
71
+ return [...state].map((value) => value.toString(16).padStart(8, '0')).join('');
72
+ }
@@ -0,0 +1,190 @@
1
+ // Cross-runtime deterministic normalization for lifecycle dates published by RDAP and
2
+ // WHOIS services. Raw upstream strings remain the provenance source; these
3
+ // helpers only produce additive Date/ISO companions for comparison, storage,
4
+ // and presentation.
5
+ const REGISTRY_MONTHS = Object.freeze({
6
+ jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
7
+ jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
8
+ });
9
+ const REGISTRY_MONTH_NAMES = Object.freeze({
10
+ january: 1, february: 2, march: 3, april: 4, may: 5, june: 6,
11
+ july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
12
+ });
13
+ function utcDateFromParts(year, month, day, hour = 0, minute = 0, second = 0, millisecond = 0) {
14
+ if (![year, month, day, hour, minute, second, millisecond].every(Number.isInteger))
15
+ return null;
16
+ if (year < 1000 || year > 9999 || month < 1 || month > 12 || day < 1
17
+ || hour < 0 || hour > 23 || minute < 0 || minute > 59
18
+ || second < 0 || second > 59 || millisecond < 0 || millisecond > 999)
19
+ return null;
20
+ const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
21
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day
22
+ || date.getUTCHours() !== hour || date.getUTCMinutes() !== minute
23
+ || date.getUTCSeconds() !== second || date.getUTCMilliseconds() !== millisecond)
24
+ return null;
25
+ return date;
26
+ }
27
+ // Parse the explicit non-ISO formats observed across supported ccTLD WHOIS
28
+ // services before considering ISO-shaped input. Runtime Date parsing treats
29
+ // dotted dates ambiguously and timezone-less timestamps in the host's local
30
+ // timezone, so every accepted shape is validated and normalized through UTC.
31
+ function parseRegistryDate(input) {
32
+ if (typeof input !== 'string')
33
+ return null;
34
+ // The documented .nz WHOIS examples place one space before the RFC3339
35
+ // `T` separator. Canonicalize that registry-specific presentation quirk
36
+ // before applying the existing strict ISO-shaped parser.
37
+ const value = input.trim()
38
+ .replace(/^(\d{4}-\d{2}-\d{2})[ ]+[Tt](?=\d{2}:)/, '$1T')
39
+ // The .ee WHOIS service separates an otherwise ISO-shaped numeric
40
+ // timezone from its clock with one space.
41
+ .replace(/^(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?)[ ]+([+-]\d{2}:?\d{2})$/, '$1$2');
42
+ if (!value)
43
+ return null;
44
+ // DD.MM.YYYY[ HH:MM:SS] - e.g. 14.03.2024 10:46:48
45
+ let match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
46
+ if (match) {
47
+ const [, day = '', month = '', year = '', hour = '0', minute = '0', second = '0'] = match;
48
+ return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
49
+ }
50
+ // YYYY. MM. DD. - e.g. 2006. 09. 18.
51
+ match = value.match(/^(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.?$/);
52
+ if (match) {
53
+ const [, year = '', month = '', day = ''] = match;
54
+ return utcDateFromParts(+year, +month, +day);
55
+ }
56
+ // YYYY.MM.DD HH:MM:SS - used by NASK's .pl WHOIS service.
57
+ match = value.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})$/);
58
+ if (match) {
59
+ const [, year = '', month = '', day = '', hour = '', minute = '', second = ''] = match;
60
+ return utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
61
+ }
62
+ // YYYY/MM/DD - an unambiguous year-first form used by CIRA WHOIS.
63
+ match = value.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
64
+ if (match) {
65
+ const [, year = '', month = '', day = ''] = match;
66
+ return utcDateFromParts(+year, +month, +day);
67
+ }
68
+ // YYYYMMDD with an optional time or Registro.br contact-reference suffix.
69
+ // The raw value remains available as provenance while the validated date
70
+ // and optional clock time are projected into the additive ISO companion.
71
+ match = value.match(/^(\d{4})(\d{2})(\d{2})(?:\s+(\d{1,2}):(\d{2}):(\d{2})|\s+#[0-9]{1,20})?$/);
72
+ if (match) {
73
+ const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0'] = match;
74
+ return utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0));
75
+ }
76
+ // YYYY-Mon-DD. - e.g. 1999-Feb-16.
77
+ match = value.match(/^(\d{4})-([A-Za-z]{3})-(\d{1,2})\.?$/);
78
+ if (match) {
79
+ const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
80
+ return month ? utcDateFromParts(+(match[1] ?? ''), month, +(match[3] ?? '')) : null;
81
+ }
82
+ // DD-Mon-YYYY[ HH:MM:SS] - used by several ICANN-style ccTLD services.
83
+ match = value.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/);
84
+ if (match) {
85
+ const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
86
+ return month
87
+ ? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
88
+ : null;
89
+ }
90
+ // DD-MM-YYYY - the day-first form published by the currently profiled
91
+ // registries that use bare numeric hyphen dates. A future month-first
92
+ // source must receive its own marker-gated parser rather than reusing this
93
+ // global shape. The four-digit final year remains distinct from ISO below.
94
+ match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
95
+ if (match) {
96
+ const [, day = '', month = '', year = ''] = match;
97
+ return utcDateFromParts(+year, +month, +day);
98
+ }
99
+ // YYYY-MM-DD HH:MM:SS (GMT+H:MM) - the explicit offset form published by
100
+ // the Kazakhstan registry. Parentheses and the GMT marker are required so
101
+ // unrelated prose is not accepted as a lifecycle timestamp.
102
+ match = value.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+\(GMT([+-])(\d{1,2}):(\d{2})\)$/i);
103
+ if (match) {
104
+ const [, year = '', month = '', day = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '',] = match;
105
+ const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
106
+ if (!local || +offsetHours > 23 || +offsetMinutes > 59)
107
+ return null;
108
+ const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
109
+ return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
110
+ }
111
+ // DD-MM-YYYY HH:MM:SS GMT+H - the Tunisian registry's bounded local-time
112
+ // presentation. Accept an optional minute component but require the GMT
113
+ // marker and validate the calendar and offset before normalization.
114
+ match = value.match(/^(\d{1,2})-(\d{1,2})-(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT([+-])(\d{1,2})(?::(\d{2}))?$/i);
115
+ if (match) {
116
+ const [, day = '', month = '', year = '', hour = '', minute = '', second = '', sign = '', offsetHours = '', offsetMinutes = '0',] = match;
117
+ const local = utcDateFromParts(+year, +month, +day, +hour, +minute, +second);
118
+ if (!local || +offsetHours > 23 || +offsetMinutes > 59)
119
+ return null;
120
+ const offsetMs = (+offsetHours * 60 + +offsetMinutes) * 60000;
121
+ return new Date(local.getTime() + (sign === '+' ? -offsetMs : offsetMs));
122
+ }
123
+ // DD Mon YYYY - the compact English month form published by THNIC.
124
+ match = value.match(/^(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})$/);
125
+ if (match) {
126
+ const month = REGISTRY_MONTHS[(match[2] ?? '').toLowerCase()];
127
+ return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[1] ?? '')) : null;
128
+ }
129
+ // Ordinal-day Month YYYY[ at HH:MM:SS.sss] - the English form published
130
+ // by the Channel Islands registry. The suffix and `at` token are required
131
+ // so ordinary prose containing a month name is not accepted as lifecycle
132
+ // evidence.
133
+ match = value.match(/^(\d{1,2})(st|nd|rd|th)\s+([A-Za-z]{3,9})\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?)?$/i);
134
+ if (match) {
135
+ const day = +(match[1] ?? '');
136
+ const suffix = (match[2] ?? '').toLowerCase();
137
+ const expectedSuffix = day % 100 >= 11 && day % 100 <= 13
138
+ ? 'th'
139
+ : ({ 1: 'st', 2: 'nd', 3: 'rd' }[day % 10] || 'th');
140
+ if (suffix !== expectedSuffix)
141
+ return null;
142
+ const monthText = (match[3] ?? '').toLowerCase();
143
+ const month = REGISTRY_MONTH_NAMES[monthText] || REGISTRY_MONTHS[monthText];
144
+ const millisecond = match[8] ? Number(`${match[8]}00`.slice(0, 3)) : 0;
145
+ return month
146
+ ? utcDateFromParts(+(match[4] ?? ''), month, day, +(match[5] || 0), +(match[6] || 0), +(match[7] || 0), millisecond)
147
+ : null;
148
+ }
149
+ // Ddd Mon DD YYYY[ HH:MM:SS] - the English textual form used by the
150
+ // Belgian registry. Weekday text is presentation-only; the validated
151
+ // calendar components determine the canonical UTC companion.
152
+ match = value.match(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?$/i);
153
+ if (match) {
154
+ const month = REGISTRY_MONTHS[(match[1] ?? '').toLowerCase()];
155
+ return month
156
+ ? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? ''), +(match[4] || 0), +(match[5] || 0), +(match[6] || 0))
157
+ : null;
158
+ }
159
+ // Month DD YYYY - ISNIC publishes English month names with variable
160
+ // spacing and no timezone. Keep the accepted shape narrow and accept only
161
+ // the twelve exact English names from a fixed table.
162
+ match = value.match(/^([A-Za-z]{3,9})\s+(\d{1,2})\s+(\d{4})$/);
163
+ if (match) {
164
+ const month = REGISTRY_MONTH_NAMES[(match[1] ?? '').toLowerCase()];
165
+ return month ? utcDateFromParts(+(match[3] ?? ''), month, +(match[2] ?? '')) : null;
166
+ }
167
+ // ISO 8601-shaped dates and timestamps. A missing timezone is deliberately
168
+ // interpreted as UTC so Express and function deployments agree.
169
+ match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(?:([Zz])|([+-])(\d{2}):?(\d{2}))?)?$/);
170
+ if (!match)
171
+ return null;
172
+ const [, year = '', month = '', day = '', hour = '0', minute = '0', second = '0', fraction, zulu, offsetSign, offsetHours = '0', offsetMinutes = '0',] = match;
173
+ const millisecond = fraction ? Number(`${fraction}000`.slice(0, 3)) : 0;
174
+ const local = utcDateFromParts(+year, +month, +day, +(hour || 0), +(minute || 0), +(second || 0), millisecond);
175
+ if (!local)
176
+ return null;
177
+ if (!offsetSign || zulu)
178
+ return local;
179
+ const offsetHour = +offsetHours;
180
+ const offsetMinute = +offsetMinutes;
181
+ if (offsetHour > 23 || offsetMinute > 59)
182
+ return null;
183
+ const offsetMs = (offsetHour * 60 + offsetMinute) * 60000;
184
+ return new Date(local.getTime() + (offsetSign === '+' ? -offsetMs : offsetMs));
185
+ }
186
+ function registryDateIso(value) {
187
+ const date = parseRegistryDate(value);
188
+ return date ? date.toISOString() : null;
189
+ }
190
+ export { parseRegistryDate, registryDateIso };
@@ -0,0 +1,69 @@
1
+ import { MAX_SELECTED_FILES, MAX_SELECTED_FILE_BYTES, MAX_SELECTED_FILE_TOTAL_BYTES } from "../contracts/selected-file-limits.mjs";
2
+ import { digest, exact, integer } from "./artifact-structure.mjs";
3
+ import { sha256ArtifactBytes } from "./artifact-integrity.mjs";
4
+ export function readRetainedFileReference(value) {
5
+ const record = exact(value, ['digestSha256', 'byteLength'], 'Retained file reference');
6
+ digest(record.digestSha256, 'Retained file digest');
7
+ const byteLength = integer(record.byteLength, 'Retained file bytes', 1, MAX_SELECTED_FILE_BYTES);
8
+ return Object.freeze({ digestSha256: record.digestSha256, byteLength });
9
+ }
10
+ /** Partition already bounded collection references without capping the entire workspace at one batch. */
11
+ export function retainedFileGroups(references) {
12
+ const unique = new Map();
13
+ for (const item of references) {
14
+ const checked = readRetainedFileReference({ digestSha256: item.digestSha256, byteLength: item.byteLength });
15
+ const previous = unique.get(checked.digestSha256);
16
+ if (previous && previous.byteLength !== checked.byteLength)
17
+ throw new Error('File references have conflicting byte lengths.');
18
+ if (!previous)
19
+ unique.set(checked.digestSha256, item);
20
+ }
21
+ const groups = [];
22
+ let group = [], bytes = 0;
23
+ for (const item of unique.values()) {
24
+ if (group.length === MAX_SELECTED_FILES || item.byteLength > MAX_SELECTED_FILE_TOTAL_BYTES - bytes) {
25
+ groups.push(group);
26
+ group = [];
27
+ bytes = 0;
28
+ }
29
+ group.push(item);
30
+ bytes += item.byteLength;
31
+ }
32
+ if (group.length)
33
+ groups.push(group);
34
+ return groups;
35
+ }
36
+ /** Capture declarations synchronously; Blob contents are immutable. */
37
+ export function captureRetainedFiles(input) {
38
+ if (!Array.isArray(input) || input.length > MAX_SELECTED_FILES)
39
+ throw new TypeError(`Select at most ${MAX_SELECTED_FILES} files per operation.`);
40
+ let total = 0;
41
+ return Object.freeze(input.map(item => {
42
+ const reference = readRetainedFileReference(item?.reference);
43
+ if (!(item.file instanceof Blob) || item.file.size !== reference.byteLength)
44
+ throw new TypeError('Selected file bytes do not match their declared length.');
45
+ total += reference.byteLength;
46
+ if (total > MAX_SELECTED_FILE_TOTAL_BYTES)
47
+ throw new TypeError('Selected files exceed the combined byte limit.');
48
+ return Object.freeze({ reference, file: item.file.slice() });
49
+ }));
50
+ }
51
+ /** Check the complete selected bytes, never a prefix or a canonicalised copy. */
52
+ export async function readVerifiedRetainedFileBytes(reference, file) {
53
+ const expected = readRetainedFileReference(reference);
54
+ if (!(file instanceof Blob) || file.size !== expected.byteLength)
55
+ throw new TypeError('Retained file bytes do not match their declared length.');
56
+ const bytes = new Uint8Array(await file.arrayBuffer());
57
+ try {
58
+ if (bytes.byteLength !== expected.byteLength || await sha256ArtifactBytes(bytes) !== expected.digestSha256)
59
+ throw new TypeError('Retained file bytes do not match their declared digest.');
60
+ return bytes;
61
+ }
62
+ catch (cause) {
63
+ bytes.fill(0);
64
+ throw cause;
65
+ }
66
+ }
67
+ export async function verifyRetainedFile(reference, file) {
68
+ (await readVerifiedRetainedFileBytes(reference, file)).fill(0);
69
+ }
@@ -0,0 +1,14 @@
1
+ import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
2
+ export const ANALYST_REVIEW_AGING_AFTER_DAYS = 7;
3
+ export const ANALYST_REVIEW_STALE_AFTER_DAYS = 30;
4
+ export function analystReviewAgeAt(observedAt, now) {
5
+ const observed = normalizeExplicitIsoTimestamp(observedAt);
6
+ const reviewed = normalizeExplicitIsoTimestamp(now);
7
+ if (!observed || !reviewed)
8
+ return 'unknown';
9
+ const age = Date.parse(reviewed) - Date.parse(observed);
10
+ if (age < 0)
11
+ return 'unknown';
12
+ const days = age / 86_400_000;
13
+ return days > ANALYST_REVIEW_STALE_AFTER_DAYS ? 'stale' : days > ANALYST_REVIEW_AGING_AFTER_DAYS ? 'aging' : 'current';
14
+ }