@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.
- package/NOTICE +6 -0
- package/README.md +20 -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 +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- 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 +112 -72
- 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-checkpoint.mjs +34 -3
- 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 +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- 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 +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -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 +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -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/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- 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-header-review.mjs +467 -0
- 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 +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- 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 +77 -24
- package/docs/cli.md +300 -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 +51 -8
- 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-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- 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 +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- 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 +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- 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-capabilities.mjs +1 -1
- 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 +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -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 +178 -151
- 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 +153 -45
- 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/lib/zone-intent-review.mjs +16 -6
- 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 +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- 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 +74 -572
- 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 +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- 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 +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -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 +15 -11
- 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 +183 -211
- 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 +201 -33
- 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 +92 -51
- 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 +193 -165
- 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 +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- 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 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -39,7 +39,13 @@ function canonicalArtifactJsonV2(value) {
|
|
|
39
39
|
}
|
|
40
40
|
async function sha256ArtifactDigestV2(value) {
|
|
41
41
|
const bytes = new TextEncoder().encode(canonicalArtifactJsonV2(value));
|
|
42
|
-
|
|
42
|
+
return sha256ArtifactBytes(bytes);
|
|
43
|
+
}
|
|
44
|
+
async function sha256ArtifactBytes(bytes) {
|
|
45
|
+
const buffer = bytes.buffer;
|
|
46
|
+
if (!(buffer instanceof ArrayBuffer))
|
|
47
|
+
throw new TypeError('Artefact digests require non-shared bytes.');
|
|
48
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new Uint8Array(buffer, bytes.byteOffset, bytes.byteLength));
|
|
43
49
|
return `sha256:${[...new Uint8Array(digest)]
|
|
44
50
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
45
51
|
.join('')}`;
|
|
@@ -62,4 +68,4 @@ function sha256ArtifactDigestFor(value, canonicalization) {
|
|
|
62
68
|
? sha256ArtifactDigest(value)
|
|
63
69
|
: sha256ArtifactDigestV2(value);
|
|
64
70
|
}
|
|
65
|
-
export { SORTED_JSON_V1, SORTED_JSON_V2, canonicalArtifactJson, canonicalArtifactJsonFor, canonicalArtifactJsonV2, resolveArtifactCanonicalization, sha256ArtifactDigest, sha256ArtifactDigestFor, sha256ArtifactDigestV2, };
|
|
71
|
+
export { SORTED_JSON_V1, SORTED_JSON_V2, canonicalArtifactJson, canonicalArtifactJsonFor, canonicalArtifactJsonV2, resolveArtifactCanonicalization, sha256ArtifactDigest, sha256ArtifactDigestFor, sha256ArtifactDigestV2, sha256ArtifactBytes, };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
2
|
+
const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
3
|
+
const DOMAIN_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
4
|
+
export const HEX_DIGEST_RE = /^[a-f0-9]{64}$/u;
|
|
5
|
+
export const SEMANTIC_VERSION_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
6
|
+
export function fail(label) {
|
|
7
|
+
throw new TypeError(`${label} has an unsupported or malformed structure.`);
|
|
8
|
+
}
|
|
9
|
+
export function record(value, label) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
fail(label);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function exact(value, keys, label) {
|
|
15
|
+
const source = record(value, label);
|
|
16
|
+
const actual = Object.keys(source);
|
|
17
|
+
if (actual.length !== keys.length || actual.some((key) => !keys.includes(key)))
|
|
18
|
+
fail(label);
|
|
19
|
+
return source;
|
|
20
|
+
}
|
|
21
|
+
export function exactOptional(value, required, optional, label) {
|
|
22
|
+
const source = record(value, label);
|
|
23
|
+
const actual = Object.keys(source);
|
|
24
|
+
if (required.some((key) => !actual.includes(key))
|
|
25
|
+
|| actual.some((key) => !required.includes(key) && !optional.includes(key)))
|
|
26
|
+
fail(label);
|
|
27
|
+
return source;
|
|
28
|
+
}
|
|
29
|
+
export function text(value, label, maximum = 2_000, allowEmpty = false) {
|
|
30
|
+
if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value) || (!allowEmpty && !value))
|
|
31
|
+
fail(label);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
export function optionalText(value, label, maximum = 2_000) {
|
|
35
|
+
if (value !== null)
|
|
36
|
+
text(value, label, maximum);
|
|
37
|
+
}
|
|
38
|
+
export function iso(value, label, nullable = false) {
|
|
39
|
+
if (nullable && value === null)
|
|
40
|
+
return;
|
|
41
|
+
const candidate = text(value, label, 64);
|
|
42
|
+
const parsed = Date.parse(candidate);
|
|
43
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== candidate)
|
|
44
|
+
fail(label);
|
|
45
|
+
}
|
|
46
|
+
export function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
|
|
47
|
+
if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum)
|
|
48
|
+
fail(label);
|
|
49
|
+
return Number(value);
|
|
50
|
+
}
|
|
51
|
+
export function boolean(value, label) {
|
|
52
|
+
if (typeof value !== 'boolean')
|
|
53
|
+
fail(label);
|
|
54
|
+
}
|
|
55
|
+
export function enumeration(value, allowed, label) {
|
|
56
|
+
if (typeof value !== 'string' || !allowed.includes(value))
|
|
57
|
+
fail(label);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
export function array(value, label, maximum, minimum = 0) {
|
|
61
|
+
if (!Array.isArray(value) || value.length < minimum || value.length > maximum)
|
|
62
|
+
fail(label);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
export function strings(value, label, maximum, textMaximum = 2_000) {
|
|
66
|
+
const values = array(value, label, maximum);
|
|
67
|
+
for (const item of values)
|
|
68
|
+
text(item, label, textMaximum);
|
|
69
|
+
return values;
|
|
70
|
+
}
|
|
71
|
+
export function digest(value, label) {
|
|
72
|
+
if (typeof value !== 'string' || !DIGEST_RE.test(value))
|
|
73
|
+
fail(label);
|
|
74
|
+
}
|
|
75
|
+
export function sameValues(left, right) {
|
|
76
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
77
|
+
}
|
|
78
|
+
export function domain(value, label) {
|
|
79
|
+
if (typeof value !== 'string' || !DOMAIN_RE.test(value))
|
|
80
|
+
fail(label);
|
|
81
|
+
}
|
|
82
|
+
export function absoluteUrl(value, label, protocols) {
|
|
83
|
+
const candidate = text(value, label, 2_048);
|
|
84
|
+
try {
|
|
85
|
+
const parsed = new URL(candidate);
|
|
86
|
+
if (!protocols.includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash)
|
|
87
|
+
fail(label);
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return fail(label);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function nullableRecord(value, label) {
|
|
95
|
+
if (value !== null)
|
|
96
|
+
record(value, label);
|
|
97
|
+
}
|
|
98
|
+
export function validateIntegrity(value, label, version, currentVersion) {
|
|
99
|
+
if (version !== currentVersion)
|
|
100
|
+
fail(label);
|
|
101
|
+
const integrity = exact(value, ['algorithm', 'canonicalization', 'digestSha256'], label);
|
|
102
|
+
if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2')
|
|
103
|
+
fail(label);
|
|
104
|
+
digest(integrity.digestSha256, label);
|
|
105
|
+
}
|
|
@@ -352,7 +352,10 @@ function boundedDataArray(value, maximum, label) {
|
|
|
352
352
|
|| Number(length) < 0) {
|
|
353
353
|
throw new TypeError(`${label} must have a stable non-negative length.`);
|
|
354
354
|
}
|
|
355
|
-
|
|
355
|
+
if (Number(length) > maximum) {
|
|
356
|
+
throw new TypeError(`${label} exceeds its ${maximum}-item admission bound; no partial fact was retained.`);
|
|
357
|
+
}
|
|
358
|
+
const retainedLength = Number(length);
|
|
356
359
|
const output = [];
|
|
357
360
|
for (let index = 0; index < retainedLength; index += 1) {
|
|
358
361
|
const descriptor = stableOwnDescriptor(value, String(index), label);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { analystReviewAgeAt, ANALYST_REVIEW_STALE_AFTER_DAYS } from "./review-age.mjs";
|
|
1
2
|
import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, } from "./artifact-integrity.mjs";
|
|
2
3
|
import { normalizeDomain } from "./domain-name.mjs";
|
|
3
4
|
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "./observation.mjs";
|
|
4
|
-
import {
|
|
5
|
+
import { MAX_POSTURE_CHECK_RECORDS } from "./domain-posture-context.mjs";
|
|
6
|
+
import { DOMAIN_CONTROL_CAA_RECORD_KEYS, DOMAIN_CONTROL_DIGEST_SHA256_HEX_LENGTH, DOMAIN_CONTROL_DS_RECORD_KEYS, DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA as DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_INPUT_KEYS, DOMAIN_CONTROL_MANIFEST_INPUT_VERSION, DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, DOMAIN_CONTROL_MANIFEST_INTEGRITY_KEYS, DOMAIN_CONTROL_MANIFEST_LIMITATIONS, DOMAIN_CONTROL_MANIFEST_ROOT_KEYS, DOMAIN_CONTROL_MANIFEST_SCHEMA as DOMAIN_CONTROL_PASSPORT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MX_RECORD_KEYS, DOMAIN_CONTROL_RECORD_LIST_FIELDS, DOMAIN_CONTROL_RECORD_MODE_OPTIONS, DOMAIN_CONTROL_SPKI_SHA256_HEX_LENGTH, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_CURRENT_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_CAA_FLAGS, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_CAA_TAG_LENGTH, MAX_DOMAIN_CONTROL_CAA_VALUE_LENGTH, MAX_DOMAIN_CONTROL_DOMAIN_LENGTH, MAX_DOMAIN_CONTROL_DS_ALGORITHM, MAX_DOMAIN_CONTROL_DS_DIGEST_LENGTH, MAX_DOMAIN_CONTROL_DS_DIGEST_TYPE, MAX_DOMAIN_CONTROL_DS_KEY_TAG, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_JSON_DEPTH, MAX_DOMAIN_CONTROL_JSON_VALUES, MAX_DOMAIN_CONTROL_MANIFEST_BYTES, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_MX_PRIORITY, MAX_DOMAIN_CONTROL_MX_TEXT_LENGTH, MAX_DOMAIN_CONTROL_NAME_INPUT_LENGTH, MAX_DOMAIN_CONTROL_NOTE_LENGTH, MAX_DOMAIN_CONTROL_RECORD_INTEGER_TEXT_LENGTH, MAX_DOMAIN_CONTROL_TEXT_LENGTH, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, MIN_DOMAIN_CONTROL_DS_DIGEST_LENGTH, MIN_DOMAIN_CONTROL_MANIFEST_ENTRIES, MIN_DOMAIN_CONTROL_RECORDS, PUBLIC_DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS, PUBLIC_DOMAIN_CONTROL_MANIFEST_INPUT_VERSION, PUBLIC_DOMAIN_CONTROL_MANIFEST_LIMITATIONS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION, SUPPORTED_DOMAIN_CONTROL_MANIFEST_INPUT_VERSIONS, } from "../contracts/domain-control-manifest.mjs";
|
|
5
7
|
const DOMAIN_CONTROL_PASSPORT_VERSION = DOMAIN_CONTROL_MANIFEST_INPUT_VERSION;
|
|
6
8
|
export { DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_PASSPORT_SCHEMA, DOMAIN_CONTROL_PASSPORT_VERSION, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_BYTES, };
|
|
7
9
|
export const MAX_DOMAIN_CONTROL_PASSPORT_BYTES = MAX_DOMAIN_CONTROL_MANIFEST_BYTES;
|
|
@@ -10,6 +12,8 @@ export const DOMAIN_CONTROL_PASSPORT_LIMITATIONS = DOMAIN_CONTROL_MANIFEST_LIMIT
|
|
|
10
12
|
const INPUT_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_INPUT_KEYS);
|
|
11
13
|
const MANIFEST_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_ROOT_KEYS);
|
|
12
14
|
const ENTRY_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS);
|
|
15
|
+
const PUBLIC_ENTRY_KEYS = new Set(PUBLIC_DOMAIN_CONTROL_MANIFEST_ENTRY_KEYS);
|
|
16
|
+
const RECORD_MODE_KEYS = new Set(DOMAIN_CONTROL_RECORD_LIST_FIELDS);
|
|
13
17
|
const INTEGRITY_KEYS = new Set(DOMAIN_CONTROL_MANIFEST_INTEGRITY_KEYS);
|
|
14
18
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
15
19
|
const RECORD_LIST_FIELDS = DOMAIN_CONTROL_RECORD_LIST_FIELDS;
|
|
@@ -82,6 +86,7 @@ function boundedDataArray(value, label, minimum, maximum, maximumMessage) {
|
|
|
82
86
|
return result;
|
|
83
87
|
}
|
|
84
88
|
const MX_RECORD_KEYS = new Set(DOMAIN_CONTROL_MX_RECORD_KEYS);
|
|
89
|
+
const RESOLVER_MX_RECORD_KEYS = new Set([...MX_RECORD_KEYS, 'type']);
|
|
85
90
|
const CAA_RECORD_KEYS = new Set(DOMAIN_CONTROL_CAA_RECORD_KEYS);
|
|
86
91
|
const DS_RECORD_KEYS = new Set(DOMAIN_CONTROL_DS_RECORD_KEYS);
|
|
87
92
|
const DS_DIGEST_PATTERN = new RegExp(`^[a-f0-9]{${MIN_DOMAIN_CONTROL_DS_DIGEST_LENGTH},${MAX_DOMAIN_CONTROL_DS_DIGEST_LENGTH}}$`, 'u');
|
|
@@ -155,31 +160,90 @@ function canonicalIntegerAlias(source, keys, maximum, label, fallback = null) {
|
|
|
155
160
|
}
|
|
156
161
|
return normalised[0] ?? null;
|
|
157
162
|
}
|
|
158
|
-
|
|
159
|
-
const item = structuredRecord(value, MX_RECORD_KEYS, 'Domain control MX record');
|
|
163
|
+
function mxRecordParts(value, resolverRecord = false) {
|
|
164
|
+
const item = structuredRecord(value, resolverRecord ? RESOLVER_MX_RECORD_KEYS : MX_RECORD_KEYS, 'Domain control MX record');
|
|
160
165
|
if (item) {
|
|
161
|
-
|
|
166
|
+
if (resolverRecord && Object.hasOwn(item, 'type') && item.type !== 'MX')
|
|
167
|
+
return null;
|
|
168
|
+
const host = canonicalStringAlias(item, ['exchange', 'host', 'value'], (value) => value === '' ? '.' : exchange(value), 'Domain control MX host');
|
|
162
169
|
const priority = canonicalIntegerAlias(item, ['priority', 'preference'], MAX_DOMAIN_CONTROL_MX_PRIORITY, 'Domain control MX priority');
|
|
163
|
-
return host && priority !== null ?
|
|
170
|
+
return host && priority !== null ? { priority, exchange: host } : null;
|
|
164
171
|
}
|
|
165
172
|
const candidate = recordText(value, MAX_DOMAIN_CONTROL_MX_TEXT_LENGTH).toLowerCase();
|
|
166
173
|
const match = /^(\d{1,5})\s+(.+)$/u.exec(candidate);
|
|
167
174
|
if (match) {
|
|
168
175
|
const priority = recordInteger(match[1], MAX_DOMAIN_CONTROL_MX_PRIORITY);
|
|
169
176
|
const host = exchange(match[2]);
|
|
170
|
-
return priority !== null && host ?
|
|
177
|
+
return priority !== null && host ? { priority, exchange: host } : null;
|
|
178
|
+
}
|
|
179
|
+
const host = exchange(candidate);
|
|
180
|
+
return host ? { priority: null, exchange: host } : null;
|
|
181
|
+
}
|
|
182
|
+
export function canonicalMxRecord(value) {
|
|
183
|
+
const parts = mxRecordParts(value);
|
|
184
|
+
return !parts ? '' : parts.priority === null ? parts.exchange : `${parts.priority} ${parts.exchange}`;
|
|
185
|
+
}
|
|
186
|
+
function completeMxRecord(value, resolverRecord) {
|
|
187
|
+
try {
|
|
188
|
+
const parts = mxRecordParts(value, resolverRecord);
|
|
189
|
+
return parts && parts.priority !== null
|
|
190
|
+
? { priority: parts.priority, exchange: parts.exchange } : null;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (!(error instanceof TypeError))
|
|
194
|
+
throw error;
|
|
195
|
+
return null;
|
|
171
196
|
}
|
|
172
|
-
return exchange(candidate);
|
|
173
197
|
}
|
|
174
|
-
|
|
175
|
-
|
|
198
|
+
/** Complete canonical records require a preference and retain the root exchange as '.'. */
|
|
199
|
+
export function normalizeMxRecord(value) {
|
|
200
|
+
return completeMxRecord(value, false);
|
|
201
|
+
}
|
|
202
|
+
/** Accept the native resolver's optional record type without broadening import shapes. */
|
|
203
|
+
export function normalizeResolverMxRecord(value) {
|
|
204
|
+
return completeMxRecord(value, true);
|
|
205
|
+
}
|
|
206
|
+
function caaValue(value, tag) {
|
|
207
|
+
const candidate = recordText(value, MAX_DOMAIN_CONTROL_CAA_VALUE_LENGTH)
|
|
208
|
+
.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2').trim();
|
|
209
|
+
if (!candidate)
|
|
210
|
+
return '';
|
|
211
|
+
if (tag === 'issue' || tag === 'issuewild') {
|
|
212
|
+
const [issuer = '', ...parameters] = candidate.split(';');
|
|
213
|
+
const normalizedIssuer = issuer.trim().toLowerCase().replace(/\.$/u, '');
|
|
214
|
+
return [normalizedIssuer, ...parameters.map((parameter) => parameter.trim())].join('; ');
|
|
215
|
+
}
|
|
216
|
+
const scheme = /^([a-z][a-z0-9+.-]*):(.*)$/isu.exec(candidate);
|
|
217
|
+
if (!scheme)
|
|
218
|
+
return candidate;
|
|
219
|
+
const normalizedScheme = scheme[1].toLowerCase();
|
|
220
|
+
if (normalizedScheme === 'http' || normalizedScheme === 'https') {
|
|
221
|
+
try {
|
|
222
|
+
const url = new URL(candidate);
|
|
223
|
+
url.protocol = normalizedScheme;
|
|
224
|
+
url.hostname = url.hostname.toLowerCase();
|
|
225
|
+
return url.toString();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return candidate;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (normalizedScheme === 'mailto') {
|
|
232
|
+
const [address = '', ...query] = scheme[2].split('?');
|
|
233
|
+
const at = address.lastIndexOf('@');
|
|
234
|
+
const normalizedAddress = at > 0
|
|
235
|
+
? `${address.slice(0, at)}@${address.slice(at + 1).toLowerCase()}`
|
|
236
|
+
: address;
|
|
237
|
+
return `${normalizedScheme}:${normalizedAddress}${query.length ? `?${query.join('?')}` : ''}`;
|
|
238
|
+
}
|
|
239
|
+
return `${normalizedScheme}:${scheme[2]}`;
|
|
176
240
|
}
|
|
177
241
|
export function canonicalCaaRecord(value) {
|
|
178
242
|
const item = structuredRecord(value, CAA_RECORD_KEYS, 'Domain control CAA record');
|
|
179
243
|
if (item) {
|
|
180
244
|
const flags = canonicalIntegerAlias(item, ['critical', 'flags'], MAX_DOMAIN_CONTROL_CAA_FLAGS, 'Domain control CAA flags', 0);
|
|
181
245
|
const tag = recordText(item.tag, MAX_DOMAIN_CONTROL_CAA_TAG_LENGTH).toLowerCase();
|
|
182
|
-
const payload = caaValue(item.value);
|
|
246
|
+
const payload = caaValue(item.value, tag);
|
|
183
247
|
return flags !== null && /^(?:issue|issuewild|iodef)$/u.test(tag) && payload
|
|
184
248
|
? `${flags} ${tag} ${payload}`
|
|
185
249
|
: '';
|
|
@@ -190,7 +254,7 @@ export function canonicalCaaRecord(value) {
|
|
|
190
254
|
return '';
|
|
191
255
|
const flags = recordInteger(match[1], MAX_DOMAIN_CONTROL_CAA_FLAGS);
|
|
192
256
|
const tag = match[2]?.toLowerCase() ?? '';
|
|
193
|
-
const payload = caaValue(match[3]);
|
|
257
|
+
const payload = caaValue(match[3], tag);
|
|
194
258
|
return flags !== null && payload ? `${flags} ${tag} ${payload}` : '';
|
|
195
259
|
}
|
|
196
260
|
export function canonicalDsRecord(value) {
|
|
@@ -233,6 +297,88 @@ export function canonicalDomainControlRecordList(value, kind) {
|
|
|
233
297
|
.sort(compareCodeUnits)
|
|
234
298
|
.slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS);
|
|
235
299
|
}
|
|
300
|
+
/** Complete field values for posture review; an invalid value never becomes an absent record. */
|
|
301
|
+
export function canonicalPostureRecords(checkId, values) {
|
|
302
|
+
if (values.length > MAX_POSTURE_CHECK_RECORDS)
|
|
303
|
+
return null;
|
|
304
|
+
const normalise = checkId === 'nameservers' ? domainControlName
|
|
305
|
+
: checkId === 'mx' ? canonicalMxRecord : checkId === 'caa' ? canonicalCaaRecord
|
|
306
|
+
: checkId === 'registration_lock'
|
|
307
|
+
? (value) => typeof value === 'string' && value.length <= 120 && !/[\u0000-\u001f\u007f]/u.test(value)
|
|
308
|
+
? value.trim().toLowerCase().replace(/[\s_-]+/gu, '') : ''
|
|
309
|
+
: null;
|
|
310
|
+
if (!normalise)
|
|
311
|
+
return null;
|
|
312
|
+
try {
|
|
313
|
+
const records = values.map(normalise);
|
|
314
|
+
if (records.some((value) => !value))
|
|
315
|
+
return null;
|
|
316
|
+
return [...new Set(records)].sort(compareCodeUnits);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Complete current record inputs are rejected, not shortened, when invalid or over capacity. */
|
|
323
|
+
export function canonicalDomainControlRecords(value, field) {
|
|
324
|
+
if (value === undefined)
|
|
325
|
+
return [];
|
|
326
|
+
const rows = boundedDataArray(value, `Domain control ${field}`, 0, MAX_DOMAIN_CONTROL_INPUT_RECORDS);
|
|
327
|
+
const normalise = field === 'nameservers' ? domainControlName : field === 'ds' ? canonicalDsRecord
|
|
328
|
+
: field === 'mx' ? canonicalMxRecord : canonicalCaaRecord;
|
|
329
|
+
const values = rows.map(normalise);
|
|
330
|
+
if (values.some((item) => !item))
|
|
331
|
+
throw new TypeError(`Domain control ${field} contains an invalid record.`);
|
|
332
|
+
const records = [...new Set(values)].sort(compareCodeUnits);
|
|
333
|
+
if (records.length > MAX_CURRENT_DOMAIN_CONTROL_RECORDS)
|
|
334
|
+
throw new TypeError(`Domain control ${field} exceeds ${MAX_CURRENT_DOMAIN_CONTROL_RECORDS} records.`);
|
|
335
|
+
return records;
|
|
336
|
+
}
|
|
337
|
+
export function domainControlRecordMode(entry, field) {
|
|
338
|
+
return entry.recordModes?.[field] ?? (entry[field].length ? 'expect_records' : 'unconfigured');
|
|
339
|
+
}
|
|
340
|
+
export function normalizeDomainControlRecordModes(value, records) {
|
|
341
|
+
const source = normalizeDeclaredDomainControlRecordModes(value);
|
|
342
|
+
return Object.freeze(Object.fromEntries(DOMAIN_CONTROL_RECORD_LIST_FIELDS.map((field) => {
|
|
343
|
+
const mode = Object.hasOwn(source, field) ? source[field] : (records[field].length ? 'expect_records' : 'unconfigured');
|
|
344
|
+
if ((mode === 'expect_records') !== (records[field].length > 0)) {
|
|
345
|
+
throw new TypeError(`Domain control ${field} requires records only in the specified-records mode.`);
|
|
346
|
+
}
|
|
347
|
+
return [field, mode];
|
|
348
|
+
})));
|
|
349
|
+
}
|
|
350
|
+
export function normalizeDeclaredDomainControlRecordModes(value) {
|
|
351
|
+
const source = value === undefined ? {} : boundedDataRecord(value, RECORD_MODE_KEYS, 'Domain control record modes');
|
|
352
|
+
if (!source)
|
|
353
|
+
throw new TypeError('Domain control record modes must be an ordinary object.');
|
|
354
|
+
const modes = {};
|
|
355
|
+
for (const field of DOMAIN_CONTROL_RECORD_LIST_FIELDS) {
|
|
356
|
+
if (!Object.hasOwn(source, field))
|
|
357
|
+
continue;
|
|
358
|
+
const mode = source[field];
|
|
359
|
+
if (!DOMAIN_CONTROL_RECORD_MODE_OPTIONS.some((option) => option.value === mode))
|
|
360
|
+
throw new TypeError(`Domain control ${field} has an unsupported record mode.`);
|
|
361
|
+
modes[field] = mode;
|
|
362
|
+
}
|
|
363
|
+
return Object.freeze(modes);
|
|
364
|
+
}
|
|
365
|
+
export function normalizeDomainControlRecordSettings(value) {
|
|
366
|
+
const records = {
|
|
367
|
+
nameservers: canonicalDomainControlRecords(value.nameservers, 'nameservers'),
|
|
368
|
+
ds: canonicalDomainControlRecords(value.ds, 'ds'),
|
|
369
|
+
mx: canonicalDomainControlRecords(value.mx, 'mx'),
|
|
370
|
+
caa: canonicalDomainControlRecords(value.caa, 'caa'),
|
|
371
|
+
};
|
|
372
|
+
return { ...records, recordModes: normalizeDomainControlRecordModes(value.recordModes, records) };
|
|
373
|
+
}
|
|
374
|
+
export function domainControlEvidenceAdmission(observedAt, now) {
|
|
375
|
+
const age = analystReviewAgeAt(observedAt, now);
|
|
376
|
+
if (age === 'unknown')
|
|
377
|
+
return 'A valid source observation time at or before the review is required.';
|
|
378
|
+
if (age === 'stale')
|
|
379
|
+
return `The source observation is more than ${ANALYST_REVIEW_STALE_AFTER_DAYS} days old; collect a current observation before comparing expected settings.`;
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
236
382
|
class DomainControlJsonBudgetError extends Error {
|
|
237
383
|
}
|
|
238
384
|
class DomainControlJsonStructureError extends Error {
|
|
@@ -365,10 +511,10 @@ export function domainControlPassportSerialisedBytes(value, label = 'Domain cont
|
|
|
365
511
|
export function assertDomainControlPassportByteBudget(value, label = 'Domain control manifest') {
|
|
366
512
|
domainControlPassportSerialisedBytes(value, label);
|
|
367
513
|
}
|
|
368
|
-
function boundedDomainControlPassportEntries(value, label) {
|
|
514
|
+
function boundedDomainControlPassportEntries(value, label, legacy = false) {
|
|
369
515
|
const entries = boundedDataArray(value, label, MIN_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES);
|
|
370
516
|
return entries.map((entry) => {
|
|
371
|
-
const source = boundedDataRecord(entry, ENTRY_KEYS, `${label} entry`);
|
|
517
|
+
const source = boundedDataRecord(entry, legacy ? PUBLIC_ENTRY_KEYS : ENTRY_KEYS, `${label} entry`);
|
|
372
518
|
if (!source)
|
|
373
519
|
return entry;
|
|
374
520
|
for (const field of RECORD_LIST_FIELDS) {
|
|
@@ -376,6 +522,11 @@ function boundedDomainControlPassportEntries(value, label) {
|
|
|
376
522
|
continue;
|
|
377
523
|
source[field] = boundedDataArray(source[field], `${label} entry ${field}`, MIN_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_INPUT_RECORDS, `${label} entry ${field} exceeds ${MAX_DOMAIN_CONTROL_INPUT_RECORDS} input records.`);
|
|
378
524
|
}
|
|
525
|
+
if (Object.hasOwn(source, 'recordModes')) {
|
|
526
|
+
source.recordModes = boundedDataRecord(source.recordModes, RECORD_MODE_KEYS, `${label} entry record modes`);
|
|
527
|
+
if (!source.recordModes)
|
|
528
|
+
throw new TypeError(`${label} entry record modes must be an ordinary object.`);
|
|
529
|
+
}
|
|
379
530
|
return source;
|
|
380
531
|
});
|
|
381
532
|
}
|
|
@@ -405,48 +556,62 @@ function hostnames(value) {
|
|
|
405
556
|
return [...new Set(names)].sort(compareCodeUnits).slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS);
|
|
406
557
|
}
|
|
407
558
|
function digest(value) {
|
|
408
|
-
|
|
559
|
+
if (typeof value !== 'string' || CONTROL_RE.test(value))
|
|
560
|
+
return null;
|
|
561
|
+
const candidate = value.trim().toLowerCase();
|
|
409
562
|
return SPKI_DIGEST_PATTERN.test(candidate) ? candidate : null;
|
|
410
563
|
}
|
|
411
|
-
function normalizeEntry(value) {
|
|
564
|
+
function normalizeEntry(value, legacyRecords, includeModes) {
|
|
412
565
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
413
566
|
return null;
|
|
414
567
|
const source = value;
|
|
415
568
|
const domain = domainControlName(source.domain);
|
|
416
569
|
if (!domain)
|
|
417
570
|
return null;
|
|
571
|
+
const tlsSpkiSha256 = digest(source.tlsSpkiSha256);
|
|
572
|
+
if (source.tlsSpkiSha256 !== undefined && source.tlsSpkiSha256 !== null
|
|
573
|
+
&& (typeof source.tlsSpkiSha256 !== 'string' || source.tlsSpkiSha256.trim())
|
|
574
|
+
&& !tlsSpkiSha256) {
|
|
575
|
+
throw new TypeError('Domain control TLS public-key fingerprint must be exactly 64 hexadecimal characters.');
|
|
576
|
+
}
|
|
577
|
+
const records = {
|
|
578
|
+
nameservers: Object.freeze(legacyRecords ? hostnames(source.nameservers) : canonicalDomainControlRecords(source.nameservers, 'nameservers')),
|
|
579
|
+
ds: Object.freeze(legacyRecords ? canonicalDomainControlRecordList(source.ds, 'ds') : canonicalDomainControlRecords(source.ds, 'ds')),
|
|
580
|
+
mx: Object.freeze(legacyRecords ? canonicalDomainControlRecordList(source.mx, 'mx') : canonicalDomainControlRecords(source.mx, 'mx')),
|
|
581
|
+
caa: Object.freeze(legacyRecords ? canonicalDomainControlRecordList(source.caa, 'caa') : canonicalDomainControlRecords(source.caa, 'caa')),
|
|
582
|
+
};
|
|
418
583
|
return Object.freeze({
|
|
419
584
|
domain,
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
mx: Object.freeze(canonicalDomainControlRecordList(source.mx, 'mx')),
|
|
423
|
-
caa: Object.freeze(canonicalDomainControlRecordList(source.caa, 'caa')),
|
|
585
|
+
...records,
|
|
586
|
+
...(includeModes ? { recordModes: normalizeDomainControlRecordModes(source.recordModes, records) } : {}),
|
|
424
587
|
tlsIssuer: text(source.tlsIssuer, MAX_DOMAIN_CONTROL_TEXT_LENGTH)?.toLowerCase() ?? null,
|
|
425
|
-
tlsSpkiSha256
|
|
588
|
+
tlsSpkiSha256,
|
|
426
589
|
registrarLock: source.registrarLock === 'required' || source.registrarLock === 'not_required'
|
|
427
590
|
? source.registrarLock
|
|
428
591
|
: null,
|
|
429
|
-
renewalReviewAt: timestamp(source.renewalReviewAt,
|
|
592
|
+
renewalReviewAt: timestamp(source.renewalReviewAt, legacyRecords),
|
|
430
593
|
note: text(source.note, MAX_DOMAIN_CONTROL_NOTE_LENGTH),
|
|
431
594
|
});
|
|
432
595
|
}
|
|
433
596
|
function buildUnsignedDomainControlPassportInternal(input, generatedAtValue, options = {}) {
|
|
434
597
|
const source = boundedDataRecord(input, INPUT_KEYS, 'Domain control manifest input');
|
|
435
|
-
if (!source || source.schema !== DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA ||
|
|
436
|
-
throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA} version ${
|
|
598
|
+
if (!source || source.schema !== DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_MANIFEST_INPUT_VERSIONS.some((version) => version === source.version)) {
|
|
599
|
+
throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA} version ${SUPPORTED_DOMAIN_CONTROL_MANIFEST_INPUT_VERSIONS.join(' or ')}.`);
|
|
437
600
|
}
|
|
438
|
-
const
|
|
601
|
+
const legacy = source.version === PUBLIC_DOMAIN_CONTROL_MANIFEST_INPUT_VERSION;
|
|
602
|
+
const version = options.manifestVersion ?? DOMAIN_CONTROL_MANIFEST_VERSION;
|
|
603
|
+
const inputEntries = boundedDomainControlPassportEntries(source.entries, 'Domain control manifest input', legacy);
|
|
439
604
|
source.entries = inputEntries;
|
|
440
605
|
assertDomainControlPassportByteBudget(source, 'Domain control manifest input');
|
|
441
606
|
const generatedAt = timestamp(generatedAtValue, options.legacyGeneratedAt === true);
|
|
442
|
-
const expiresAt = timestamp(source.expiresAt,
|
|
607
|
+
const expiresAt = timestamp(source.expiresAt, legacy);
|
|
443
608
|
if (!generatedAt) {
|
|
444
609
|
throw new TypeError('Domain control manifest generation time must be valid and include an explicit timezone.');
|
|
445
610
|
}
|
|
446
611
|
if (!expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
|
|
447
612
|
throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
|
|
448
613
|
}
|
|
449
|
-
const entries = inputEntries.map(normalizeEntry);
|
|
614
|
+
const entries = inputEntries.map((entry) => normalizeEntry(entry, legacy, version === DOMAIN_CONTROL_MANIFEST_VERSION));
|
|
450
615
|
if (entries.some((entry) => entry === null))
|
|
451
616
|
throw new TypeError('Domain control manifest contains an invalid entry.');
|
|
452
617
|
const normalizedEntries = entries;
|
|
@@ -458,11 +623,11 @@ function buildUnsignedDomainControlPassportInternal(input, generatedAtValue, opt
|
|
|
458
623
|
: [...normalizedEntries].sort((left, right) => compareCodeUnits(left.domain, right.domain));
|
|
459
624
|
return Object.freeze({
|
|
460
625
|
schema: DOMAIN_CONTROL_PASSPORT_SCHEMA,
|
|
461
|
-
version
|
|
626
|
+
version,
|
|
462
627
|
generatedAt,
|
|
463
628
|
expiresAt,
|
|
464
629
|
entries: Object.freeze(orderedEntries),
|
|
465
|
-
limitations: DOMAIN_CONTROL_PASSPORT_LIMITATIONS,
|
|
630
|
+
limitations: version === PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION ? PUBLIC_DOMAIN_CONTROL_MANIFEST_LIMITATIONS : DOMAIN_CONTROL_PASSPORT_LIMITATIONS,
|
|
466
631
|
});
|
|
467
632
|
}
|
|
468
633
|
export function buildUnsignedDomainControlPassport(input, generatedAtValue, options = {}) {
|
|
@@ -479,29 +644,32 @@ function normalizeDomainControlPassportDocumentInternal(value) {
|
|
|
479
644
|
|| !MANIFEST_DIGEST_PATTERN.test(integrity.digestSha256)) {
|
|
480
645
|
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
481
646
|
}
|
|
647
|
+
const canonicalization = resolveArtifactCanonicalization(source.version, integrity.canonicalization, DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, 'Domain control manifest');
|
|
648
|
+
const legacy = source.version === PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION;
|
|
649
|
+
const expectedLimitations = legacy ? PUBLIC_DOMAIN_CONTROL_MANIFEST_LIMITATIONS : DOMAIN_CONTROL_PASSPORT_LIMITATIONS;
|
|
482
650
|
let limitations;
|
|
483
651
|
try {
|
|
484
|
-
limitations = boundedDataArray(source.limitations, 'Domain control manifest limitations',
|
|
652
|
+
limitations = boundedDataArray(source.limitations, 'Domain control manifest limitations', expectedLimitations.length, expectedLimitations.length);
|
|
485
653
|
}
|
|
486
654
|
catch {
|
|
487
655
|
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
488
656
|
}
|
|
489
|
-
if (limitations.some((item, index) => item !==
|
|
657
|
+
if (limitations.some((item, index) => item !== expectedLimitations[index])) {
|
|
490
658
|
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
491
659
|
}
|
|
492
660
|
source.limitations = limitations;
|
|
493
|
-
const manifestEntries = boundedDomainControlPassportEntries(source.entries, 'Domain control manifest');
|
|
661
|
+
const manifestEntries = boundedDomainControlPassportEntries(source.entries, 'Domain control manifest', legacy);
|
|
494
662
|
source.entries = manifestEntries;
|
|
495
663
|
source.integrity = integrity;
|
|
496
664
|
assertDomainControlPassportByteBudget(source);
|
|
497
|
-
const canonicalization = resolveArtifactCanonicalization(source.version, integrity.canonicalization, DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, 'Domain control manifest');
|
|
498
665
|
const baseUnsigned = buildUnsignedDomainControlPassportInternal({
|
|
499
666
|
schema: DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA,
|
|
500
|
-
version: DOMAIN_CONTROL_MANIFEST_INPUT_VERSION,
|
|
667
|
+
version: legacy ? PUBLIC_DOMAIN_CONTROL_MANIFEST_INPUT_VERSION : DOMAIN_CONTROL_MANIFEST_INPUT_VERSION,
|
|
501
668
|
expiresAt: source.expiresAt,
|
|
502
669
|
entries: manifestEntries,
|
|
503
670
|
}, source.generatedAt, {
|
|
504
671
|
preserveEntryOrder: true,
|
|
672
|
+
manifestVersion: legacy ? PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION : DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
505
673
|
});
|
|
506
674
|
const unsigned = baseUnsigned;
|
|
507
675
|
const { integrity: _integrity, ...suppliedUnsigned } = source;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
|
|
2
|
+
import { normalizeDomain } from "./domain-name.mjs";
|
|
3
|
+
export const DOMAIN_POSTURE_COMPARISON_VERSION = 1;
|
|
4
|
+
export const MAX_POSTURE_CHECKS = 32;
|
|
5
|
+
export const MAX_POSTURE_CHECK_RECORDS = 64;
|
|
6
|
+
export const MAX_POSTURE_RECORD_LENGTH = 4096;
|
|
7
|
+
function contextRecord(value, keys, label) {
|
|
8
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
9
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
10
|
+
|| Reflect.ownKeys(value).length !== keys.length)
|
|
11
|
+
throw new TypeError(`${label} has an invalid structure.`);
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const key of keys) {
|
|
14
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
15
|
+
if (!descriptor?.enumerable || !('value' in descriptor))
|
|
16
|
+
throw new TypeError(`${label} requires ordinary data fields.`);
|
|
17
|
+
result[key] = descriptor.value;
|
|
18
|
+
}
|
|
19
|
+
if (result.version !== DOMAIN_POSTURE_COMPARISON_VERSION)
|
|
20
|
+
throw new TypeError(`${label} has an unsupported comparison version.`);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
/** Missing legacy provenance stays missing; declared malformed or future context is rejected. */
|
|
24
|
+
export function normalizeDomainPostureSourceContext(value) {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
const source = contextRecord(value, ['version', 'source', 'observedAt', 'state', 'omittedRecords'], 'Posture source context');
|
|
28
|
+
const observedAt = normalizeExplicitIsoTimestamp(source.observedAt);
|
|
29
|
+
if (typeof source.source !== 'string' || !['dns_ns', 'dns_mx', 'dns_caa', 'registry_rdap'].includes(source.source)
|
|
30
|
+
|| typeof source.state !== 'string' || !['complete', 'partial', 'unavailable'].includes(source.state)
|
|
31
|
+
|| (source.observedAt !== null && observedAt === null)
|
|
32
|
+
|| (source.omittedRecords !== null && (!Number.isSafeInteger(source.omittedRecords) || Number(source.omittedRecords) < 0))
|
|
33
|
+
|| (source.state === 'complete' && source.omittedRecords !== 0)) {
|
|
34
|
+
throw new TypeError('Posture source context has invalid provenance or omission counts.');
|
|
35
|
+
}
|
|
36
|
+
return Object.freeze({ ...source, observedAt });
|
|
37
|
+
}
|
|
38
|
+
export function normalizeDomainPostureProfileContext(value) {
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const source = contextRecord(value, ['version', 'domain', 'profileId', 'profileFingerprint'], 'Posture profile context');
|
|
42
|
+
const domain = typeof source.domain === 'string' ? normalizeDomain(source.domain) : '';
|
|
43
|
+
if (!domain || domain !== source.domain || typeof source.profileId !== 'string'
|
|
44
|
+
|| !source.profileId.trim() || source.profileId.length > 200 || /[\u0000-\u001f\u007f]/u.test(source.profileId)
|
|
45
|
+
|| typeof source.profileFingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(source.profileFingerprint)) {
|
|
46
|
+
throw new TypeError('Posture profile context has an invalid target or collection identity.');
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze(source);
|
|
49
|
+
}
|
|
50
|
+
export const POSTURE_CHECK_SOURCES = Object.freeze({
|
|
51
|
+
nameservers: 'dns_ns', mx: 'dns_mx', caa: 'dns_caa', registration_lock: 'registry_rdap',
|
|
52
|
+
});
|
|
53
|
+
export const POSTURE_SOURCE_LABELS = Object.freeze({
|
|
54
|
+
dns_ns: 'DNS NS', dns_mx: 'DNS MX', dns_caa: 'DNS CAA', registry_rdap: 'Registry RDAP',
|
|
55
|
+
});
|
|
56
|
+
export function postureTransferRestriction(records) {
|
|
57
|
+
if (!records.length)
|
|
58
|
+
return null;
|
|
59
|
+
return records.some((value) => value === 'clienttransferprohibited' || value === 'servertransferprohibited')
|
|
60
|
+
? 'required' : 'not_required';
|
|
61
|
+
}
|
|
62
|
+
export function postureSourceAdmission(check, capturedAt, now) {
|
|
63
|
+
if (!check?.sourceContext)
|
|
64
|
+
return 'Source comparison context is unavailable for this retained check.';
|
|
65
|
+
const context = check.sourceContext;
|
|
66
|
+
const expected = POSTURE_CHECK_SOURCES[check.id];
|
|
67
|
+
if (context.version !== DOMAIN_POSTURE_COMPARISON_VERSION || !expected || expected !== context.source)
|
|
68
|
+
return 'The source or comparison version does not match this field.';
|
|
69
|
+
const captured = normalizeExplicitIsoTimestamp(capturedAt);
|
|
70
|
+
const observed = normalizeExplicitIsoTimestamp(context.observedAt);
|
|
71
|
+
const reviewed = normalizeExplicitIsoTimestamp(now);
|
|
72
|
+
if (!captured || !observed || !reviewed)
|
|
73
|
+
return 'A source, capture or review time is unavailable.';
|
|
74
|
+
if (Date.parse(observed) > Date.parse(captured) || Date.parse(captured) > Date.parse(reviewed))
|
|
75
|
+
return 'The source or capture time is later than its review.';
|
|
76
|
+
if (context.state !== 'complete' || context.omittedRecords !== 0)
|
|
77
|
+
return 'The source observation is incomplete; retained values cannot establish alignment or drift.';
|
|
78
|
+
return null;
|
|
79
|
+
}
|