@slicedearth/whoisleuth-cli 2.3.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.
- package/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -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
|
+
}
|