@slicedearth/whoisleuth-cli 2.0.1 → 2.2.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/cli/arguments.mjs +21 -5
- package/cli/artifact-validation/case-response.mjs +771 -0
- package/cli/artifact-validation/investigation-capsule.mjs +426 -0
- package/cli/artifact-validation/lookup-evidence.mjs +718 -0
- package/cli/artifact-validation/signed-review.mjs +598 -0
- package/cli/artifact-validation/structure-primitives.mjs +105 -0
- package/cli/artifact-verify.mjs +1 -1
- package/cli/assurance-command-runner.mjs +324 -0
- package/cli/command-reference.mjs +1175 -843
- package/cli/completion.mjs +56 -9
- package/cli/discriminated-command-handlers.mjs +11 -0
- package/cli/doctor.mjs +2 -2
- package/cli/evidence-command-runner.mjs +41 -31
- package/cli/export-evidence.mjs +1 -1
- package/cli/fail-policy.mjs +33 -8
- package/cli/formatters/terminal-command-formats.mjs +437 -0
- package/cli/formatters/terminal-lookup.mjs +758 -0
- package/cli/formatters/terminal-metadata.mjs +58 -0
- package/cli/formatters/terminal-shared.mjs +83 -0
- package/cli/formatters/terminal.mjs +5 -1232
- package/cli/history-command-runner.mjs +132 -0
- package/cli/inline-command-families.mjs +45 -0
- package/cli/inline-command-runner.mjs +55 -0
- package/cli/investigation-plan.mjs +9 -2
- package/cli/investigation-run.mjs +114 -9
- package/cli/lookup-brief.mjs +141 -23
- package/cli/lookup-browser-view.mjs +289 -0
- package/cli/lookup-browser.mjs +3 -288
- package/cli/mail-review.mjs +2 -6
- package/cli/offline-artifact-validation.mjs +19 -0
- package/cli/review-command-runner.mjs +269 -0
- package/cli/runner.mjs +15 -972
- package/cli/saved-lookup.mjs +19 -2
- package/cli/support-command-runner.mjs +229 -0
- package/cli/workflow-command-runner.mjs +129 -0
- package/docs/cli-reference.md +16 -9
- package/docs/cli.md +16 -2
- package/lib/availability.mjs +12 -9
- package/lib/bounded-contract-normalizers.mjs +2 -9
- package/lib/browser-library-profile.mjs +1 -2
- package/lib/client-behavior-profile.mjs +1 -2
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-posture-analysis.mjs +10 -1
- package/lib/domain-posture.mjs +51 -23
- package/lib/evidence-export.mjs +47 -5
- package/lib/evidence-report-markdown.mjs +4 -0
- package/lib/evidence-report.mjs +50 -0
- package/lib/generated/cisa-kev-catalog.mjs +34 -4
- package/lib/generated/registrar-standing-catalogue.mjs +367 -0
- package/lib/json-record.mjs +12 -0
- package/lib/lookup-child-profile-contract.mjs +858 -0
- package/lib/lookup-contract-primitives.mjs +98 -0
- package/lib/lookup-diagnostics.mjs +96 -0
- package/lib/lookup-network-evidence-bounds.mjs +1 -0
- package/lib/lookup-response.mjs +367 -0
- package/lib/lookup.mjs +28 -443
- package/lib/page-role-profile.mjs +1 -3
- package/lib/registrar-standing-catalogue-contract.mjs +70 -0
- package/lib/registrar-standing-contract.mjs +300 -0
- package/lib/registrar-standing.mjs +269 -0
- package/lib/registry-insights.mjs +2 -8
- package/lib/risk-scoring.mjs +26 -10
- package/lib/sslbl-certificates.generated.mjs +138 -136
- package/lib/structured-data-identity.mjs +1 -3
- package/lib/technology-evidence-role.mjs +51 -0
- package/lib/threat-intelligence-runtime.mjs +3 -3
- package/lib/threat-intelligence-types.mjs +13 -0
- package/lib/threatfox-intelligence.mjs +1 -3
- package/lib/tls-intelligence.mjs +1 -2
- package/lib/urlhaus-intelligence.mjs +1 -3
- package/lib/urlscan-intelligence.mjs +1 -3
- package/lib/website-security-posture.mjs +1 -2
- package/lib/website-technology.mjs +72 -23
- package/lib/whois-common-formats.mjs +276 -0
- package/lib/whois-dialects.mjs +534 -0
- package/lib/whois-parser-support.mjs +305 -0
- package/lib/whois-parser.mjs +135 -0
- package/lib/whois.mjs +6 -1187
- package/package.json +2 -2
- package/packages/cases/case-evidence-model.mjs +9 -0
- package/packages/cases/case-migration-model.mjs +17 -11
- package/packages/cases/case-record-operations.mjs +235 -0
- package/packages/cases/case-response-model.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +297 -112
- package/packages/comparison/page-similarity.mjs +23 -0
- package/packages/contracts/case-portability.mjs +235 -61
- package/packages/contracts/cli-lookup.mjs +4 -3
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/investigation-portability.mjs +1 -1
- package/packages/contracts/risk-calibration.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +34 -6
- package/packages/interchange/external-findings-import.mjs +113 -45
- package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
- package/packages/investigation/lookup-claim-readiness.mjs +2 -2
- package/packages/workspace/website-snapshot-model.mjs +79 -6
- package/third-party-notices.txt +1 -1
- package/cli/artifact-structure.mjs +0 -2507
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { ACQUISITION_DECISIONS, ACQUISITION_MANUAL_CHECKS, ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../../packages/investigation/acquisition-decision-packet.mjs";
|
|
2
|
+
import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../../packages/investigation/bulk-domain-comparison.mjs";
|
|
3
|
+
import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../../packages/investigation/bulk-mail-exposure.mjs";
|
|
4
|
+
import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../../packages/investigation/bulk-review-export.mjs";
|
|
5
|
+
import { LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, LOOKUP_CLAIM_PASSPORT_VERSION, MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, } from "../../packages/investigation/lookup-claim-passport.mjs";
|
|
6
|
+
import { LOOKUP_CLAIM_IDS, LOOKUP_CLAIM_READINESS_VERSION, LOOKUP_CLAIM_REQUIREMENT_IDS, } from "../../packages/investigation/lookup-claim-readiness.mjs";
|
|
7
|
+
import { normalizeDomainControlManifestDocument } from "../../packages/evidence/domain-control-runtime.mjs";
|
|
8
|
+
import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION } from "../../lib/domain-change-packet.mjs";
|
|
9
|
+
import { DOMAIN_CONTROL_MANIFEST_SCHEMA } from "../../packages/contracts/domain-control-manifest.mjs";
|
|
10
|
+
import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, } from "../investigation-manifest.mjs";
|
|
11
|
+
import { array, boolean, domain, digest, enumeration, exact, fail, integer, iso, optionalText, sameValues, strings, text, validateIntegrity, } from "./structure-primitives.mjs";
|
|
12
|
+
const CLAIM_PASSPORT_SOURCE_STATES = ['complete', 'not_found', 'partial', 'skipped', 'unavailable', 'unknown', 'unsupported'];
|
|
13
|
+
const COMPARISON_STATES = ['conflicting', 'different', 'equal', 'missing', 'not_recorded', 'unavailable'];
|
|
14
|
+
const SOURCE_STATES = ['complete', 'error', 'not_found', 'not_recorded', 'partial', 'skipped', 'unavailable', 'unsupported'];
|
|
15
|
+
const DOMAIN_COMPARISON_ROW_IDS = [
|
|
16
|
+
'registration', 'registrar', 'created', 'expires', 'nameservers', 'dns-a', 'dns-aaaa', 'dns-cname',
|
|
17
|
+
'dns-caa', 'dnssec', 'ip-addresses', 'tls-source', 'certificate', 'tls-issuer', 'tls-spki', 'mail',
|
|
18
|
+
'null-mx', 'spf', 'dmarc', 'website', 'page-title', 'favicon', 'tracking', 'password-field',
|
|
19
|
+
'phishing-language', 'official-assets', 'technology', 'source-health',
|
|
20
|
+
];
|
|
21
|
+
const MAIL_STATES = ['authenticated_mail', 'evidence_incomplete', 'mail_auth_gap', 'mail_auth_incomplete', 'no_explicit_mx', 'null_mx'];
|
|
22
|
+
const DNS_TYPES = ['A', 'AAAA', 'CAA', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'HTTPS', 'MX', 'NS', 'SRV', 'SVCB', 'TLSA', 'TXT'];
|
|
23
|
+
function validateAcquisitionItem(value, label) {
|
|
24
|
+
const item = exact(value, ['id', 'label', 'state', 'detail', 'provenance'], label);
|
|
25
|
+
const id = enumeration(item.id, ['availability', 'contacts', 'lifecycle', 'mail', 'nameservers', 'operations', 'policy_eligibility', 'policy_lifecycle', 'policy_transfer', 'tls', 'transfer', 'web'], label);
|
|
26
|
+
enumeration(item.state, ['authoritative', 'observed', 'review', 'unavailable'], label);
|
|
27
|
+
text(item.label, label, 200);
|
|
28
|
+
text(item.detail, label, 2_000);
|
|
29
|
+
text(item.provenance, label, 300);
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
function validateAcquisition(value) {
|
|
33
|
+
const root = exact(value, ['schema', 'version', 'generatedAt', 'target', 'synthetic', 'evidenceObservedAt', 'analystReview', 'evidenceReview', 'limitations', 'integrity'], 'Acquisition decision packet');
|
|
34
|
+
iso(root.generatedAt, 'Acquisition decision packet generatedAt');
|
|
35
|
+
domain(root.target, 'Acquisition decision packet target');
|
|
36
|
+
boolean(root.synthetic, 'Acquisition decision packet synthetic');
|
|
37
|
+
iso(root.evidenceObservedAt, 'Acquisition decision packet evidenceObservedAt', true);
|
|
38
|
+
const review = exact(root.analystReview, ['decision', 'rationale', 'reviewedChecks', 'outstandingChecks', 'state'], 'Acquisition analyst review');
|
|
39
|
+
const decision = enumeration(review.decision, ACQUISITION_DECISIONS, 'Acquisition analyst review decision');
|
|
40
|
+
text(review.rationale, 'Acquisition analyst review rationale', 2_000, true);
|
|
41
|
+
const reviewed = strings(review.reviewedChecks, 'Acquisition reviewed checks', ACQUISITION_MANUAL_CHECKS.length, 40);
|
|
42
|
+
const outstanding = strings(review.outstandingChecks, 'Acquisition outstanding checks', ACQUISITION_MANUAL_CHECKS.length, 40);
|
|
43
|
+
const expectedReviewed = ACQUISITION_MANUAL_CHECKS.filter((item) => reviewed.includes(item));
|
|
44
|
+
const expectedOutstanding = ACQUISITION_MANUAL_CHECKS.filter((item) => !reviewed.includes(item));
|
|
45
|
+
if (!sameValues(reviewed, expectedReviewed) || !sameValues(outstanding, expectedOutstanding))
|
|
46
|
+
fail('Acquisition analyst review checks');
|
|
47
|
+
const expectedState = decision === 'unresolved' || reviewed.length < ACQUISITION_MANUAL_CHECKS.length ? 'draft' : 'reviewed';
|
|
48
|
+
if (review.state !== expectedState)
|
|
49
|
+
fail('Acquisition analyst review state');
|
|
50
|
+
const evidence = exact(root.evidenceReview, ['version', 'label', 'state', 'items', 'transitionDependencies', 'policyChecks', 'nextSteps', 'limitations'], 'Acquisition evidence review');
|
|
51
|
+
if (evidence.version !== 2)
|
|
52
|
+
fail('Acquisition evidence review');
|
|
53
|
+
text(evidence.label, 'Acquisition evidence review label', 200);
|
|
54
|
+
enumeration(evidence.state, ['incomplete', 'registered', 'review_transition', 'sale_signal', 'unregistered_observation'], 'Acquisition evidence review state');
|
|
55
|
+
const expectedEvidenceIds = {
|
|
56
|
+
items: ['availability', 'lifecycle', 'transfer', 'operations', 'contacts'],
|
|
57
|
+
transitionDependencies: ['nameservers', 'web', 'mail', 'tls'],
|
|
58
|
+
policyChecks: ['policy_eligibility', 'policy_lifecycle', 'policy_transfer'],
|
|
59
|
+
};
|
|
60
|
+
for (const key of ['items', 'transitionDependencies', 'policyChecks']) {
|
|
61
|
+
const expected = expectedEvidenceIds[key];
|
|
62
|
+
const values = array(evidence[key], `Acquisition evidence review ${key}`, expected.length, expected.length);
|
|
63
|
+
const ids = values.map((item, index) => validateAcquisitionItem(item, `Acquisition evidence review ${key}[${index}]`));
|
|
64
|
+
if (!sameValues(ids, expected))
|
|
65
|
+
fail(`Acquisition evidence review ${key}`);
|
|
66
|
+
}
|
|
67
|
+
strings(evidence.nextSteps, 'Acquisition next steps', 6, 600);
|
|
68
|
+
strings(evidence.limitations, 'Acquisition evidence limitations', 12, 600);
|
|
69
|
+
strings(root.limitations, 'Acquisition limitations', 8, 600);
|
|
70
|
+
validateIntegrity(root.integrity, 'Acquisition integrity', root.version, ACQUISITION_DECISION_PACKET_VERSION);
|
|
71
|
+
}
|
|
72
|
+
function validateClaimPassportTarget(type, value) {
|
|
73
|
+
const candidate = text(value, 'Claim passport target', 253);
|
|
74
|
+
if (type === 'domain') {
|
|
75
|
+
domain(candidate, 'Claim passport target');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (type === 'ipv4') {
|
|
79
|
+
const parts = candidate.split('.');
|
|
80
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part) || Number(part) > 255 || String(Number(part)) !== part))
|
|
81
|
+
fail('Claim passport target');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (type === 'ipv6') {
|
|
85
|
+
if (!candidate.includes(':') || /[\[\]%/?#@]/u.test(candidate))
|
|
86
|
+
fail('Claim passport target');
|
|
87
|
+
try {
|
|
88
|
+
const hostname = new URL(`http://[${candidate}]/`).hostname;
|
|
89
|
+
const normalized = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
90
|
+
if (normalized !== candidate)
|
|
91
|
+
fail('Claim passport target');
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
fail('Claim passport target');
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!/^AS(?:[1-9]\d{0,9})$/u.test(candidate) || Number(candidate.slice(2)) > 4_294_967_295)
|
|
99
|
+
fail('Claim passport target');
|
|
100
|
+
}
|
|
101
|
+
function validateLookupClaimPassport(value) {
|
|
102
|
+
const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'target', 'observation', 'claim', 'models', 'limitations', 'integrity'], 'Lookup claim passport');
|
|
103
|
+
if (root.version !== LOOKUP_CLAIM_PASSPORT_VERSION)
|
|
104
|
+
fail('Lookup claim passport');
|
|
105
|
+
iso(root.generatedAt, 'Lookup claim passport generatedAt');
|
|
106
|
+
const application = exact(root.application, ['name', 'version'], 'Lookup claim passport application');
|
|
107
|
+
if (application.name !== 'WHOISleuth' || typeof application.version !== 'string'
|
|
108
|
+
|| !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
|
|
109
|
+
fail('Lookup claim passport application');
|
|
110
|
+
const target = exact(root.target, ['type', 'value'], 'Lookup claim passport target');
|
|
111
|
+
const type = enumeration(target.type, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, 'Lookup claim passport target');
|
|
112
|
+
validateClaimPassportTarget(type, target.value);
|
|
113
|
+
const observation = exact(root.observation, ['observedAt', 'lookupDepth'], 'Lookup claim passport observation');
|
|
114
|
+
iso(observation.observedAt, 'Lookup claim passport observation', true);
|
|
115
|
+
enumeration(observation.lookupDepth, ['fast', 'deep'], 'Lookup claim passport observation');
|
|
116
|
+
const claim = exact(root.claim, ['id', 'label', 'state', 'conclusion', 'requiredEvidenceIds', 'missingEvidenceIds', 'requirements', 'limitations'], 'Lookup claim passport claim');
|
|
117
|
+
enumeration(claim.id, LOOKUP_CLAIM_IDS, 'Lookup claim passport claim');
|
|
118
|
+
text(claim.label, 'Lookup claim passport claim label', 160);
|
|
119
|
+
text(claim.conclusion, 'Lookup claim passport claim conclusion', 600);
|
|
120
|
+
const requirements = array(claim.requirements, 'Lookup claim passport requirements', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 1);
|
|
121
|
+
const requirementIds = [];
|
|
122
|
+
const missingIds = [];
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
let observedCount = 0;
|
|
125
|
+
let hasLimited = false;
|
|
126
|
+
for (const [index, candidate] of requirements.entries()) {
|
|
127
|
+
const item = exact(candidate, ['id', 'label', 'evidenceId', 'mode', 'state', 'observedAt', 'limitations'], `Lookup claim passport requirement ${index + 1}`);
|
|
128
|
+
const id = enumeration(item.id, LOOKUP_CLAIM_REQUIREMENT_IDS, 'Lookup claim passport requirement id');
|
|
129
|
+
if (seen.has(id))
|
|
130
|
+
fail('Lookup claim passport requirements');
|
|
131
|
+
seen.add(id);
|
|
132
|
+
requirementIds.push(id);
|
|
133
|
+
text(item.label, 'Lookup claim passport requirement label', 160);
|
|
134
|
+
if (item.evidenceId !== null && (typeof item.evidenceId !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/u.test(item.evidenceId)))
|
|
135
|
+
fail('Lookup claim passport evidence id');
|
|
136
|
+
enumeration(item.mode, ['network_collection', 'local_review'], 'Lookup claim passport requirement mode');
|
|
137
|
+
const state = enumeration(item.state, CLAIM_PASSPORT_SOURCE_STATES, 'Lookup claim passport requirement state');
|
|
138
|
+
if (state !== 'complete')
|
|
139
|
+
missingIds.push(id);
|
|
140
|
+
if (state !== 'skipped' && state !== 'unsupported' && state !== 'unknown')
|
|
141
|
+
observedCount += 1;
|
|
142
|
+
if (state === 'partial' || state === 'unavailable' || state === 'unknown')
|
|
143
|
+
hasLimited = true;
|
|
144
|
+
iso(item.observedAt, 'Lookup claim passport requirement observedAt', true);
|
|
145
|
+
if (item.evidenceId === null && item.observedAt !== null)
|
|
146
|
+
fail('Lookup claim passport requirement observedAt');
|
|
147
|
+
strings(item.limitations, 'Lookup claim passport requirement limitations', 8, 400);
|
|
148
|
+
}
|
|
149
|
+
const required = strings(claim.requiredEvidenceIds, 'Lookup claim passport required evidence', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 64);
|
|
150
|
+
const missing = strings(claim.missingEvidenceIds, 'Lookup claim passport missing evidence', MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, 64);
|
|
151
|
+
if (!sameValues(required, requirementIds) || !sameValues(missing, missingIds))
|
|
152
|
+
fail('Lookup claim passport evidence identifiers');
|
|
153
|
+
const expectedState = missingIds.length === 0 ? 'ready' : observedCount > 0 && hasLimited ? 'limited' : 'not_ready';
|
|
154
|
+
if (claim.state !== expectedState)
|
|
155
|
+
fail('Lookup claim passport claim state');
|
|
156
|
+
strings(claim.limitations, 'Lookup claim passport claim limitations', MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, 400);
|
|
157
|
+
const models = exact(root.models, ['claimReadiness', 'risk'], 'Lookup claim passport models');
|
|
158
|
+
if (models.claimReadiness !== LOOKUP_CLAIM_READINESS_VERSION)
|
|
159
|
+
fail('Lookup claim passport models');
|
|
160
|
+
if (models.risk !== null)
|
|
161
|
+
integer(models.risk, 'Lookup claim passport Risk model', 1, 1_000);
|
|
162
|
+
strings(root.limitations, 'Lookup claim passport limitations', MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, 600);
|
|
163
|
+
validateIntegrity(root.integrity, 'Lookup claim passport integrity', root.version, LOOKUP_CLAIM_PASSPORT_VERSION);
|
|
164
|
+
}
|
|
165
|
+
function validateDomainComparison(value) {
|
|
166
|
+
const root = exact(value, ['schema', 'version', 'generatedAt', 'comparison', 'integrity'], 'Domain comparison artefact');
|
|
167
|
+
iso(root.generatedAt, 'Domain comparison generatedAt');
|
|
168
|
+
const comparison = exact(root.comparison, ['version', 'leftDomain', 'rightDomain', 'observedAt', 'freshness', 'rows', 'counts', 'limitations'], 'Domain comparison');
|
|
169
|
+
if (comparison.version !== 3)
|
|
170
|
+
fail('Domain comparison');
|
|
171
|
+
domain(comparison.leftDomain, 'Domain comparison left domain');
|
|
172
|
+
domain(comparison.rightDomain, 'Domain comparison right domain');
|
|
173
|
+
iso(comparison.observedAt, 'Domain comparison observedAt', true);
|
|
174
|
+
const freshness = exact(comparison.freshness, ['state', 'ageDays'], 'Domain comparison freshness');
|
|
175
|
+
enumeration(freshness.state, ['current', 'stale', 'unknown'], 'Domain comparison freshness');
|
|
176
|
+
if (freshness.ageDays !== null)
|
|
177
|
+
integer(freshness.ageDays, 'Domain comparison age', 0, 1_000_000);
|
|
178
|
+
const rows = array(comparison.rows, 'Domain comparison rows', DOMAIN_COMPARISON_ROW_IDS.length, DOMAIN_COMPARISON_ROW_IDS.length);
|
|
179
|
+
const counts = exact(comparison.counts, [...COMPARISON_STATES], 'Domain comparison counts');
|
|
180
|
+
const actual = new Map(COMPARISON_STATES.map((state) => [state, 0]));
|
|
181
|
+
for (const [index, candidate] of rows.entries()) {
|
|
182
|
+
const row = exact(candidate, ['id', 'category', 'label', 'left', 'right', 'state', 'method', 'source', 'leftSourceState', 'rightSourceState', 'observedAt', 'leftEvidenceHref', 'rightEvidenceHref', 'limitations'], `Domain comparison row ${index + 1}`);
|
|
183
|
+
const id = text(row.id, 'Domain comparison row id', 80);
|
|
184
|
+
if (id !== DOMAIN_COMPARISON_ROW_IDS[index])
|
|
185
|
+
fail('Domain comparison row order');
|
|
186
|
+
enumeration(row.category, ['certificate', 'dns', 'identity', 'infrastructure', 'lifecycle', 'mail', 'registration', 'source', 'technology', 'web'], 'Domain comparison category');
|
|
187
|
+
text(row.label, 'Domain comparison label', 120);
|
|
188
|
+
text(row.left, 'Domain comparison left value', 2_000);
|
|
189
|
+
text(row.right, 'Domain comparison right value', 2_000);
|
|
190
|
+
const state = enumeration(row.state, COMPARISON_STATES, 'Domain comparison state');
|
|
191
|
+
actual.set(state, (actual.get(state) ?? 0) + 1);
|
|
192
|
+
text(row.method, 'Domain comparison method', 320);
|
|
193
|
+
text(row.source, 'Domain comparison source', 80);
|
|
194
|
+
enumeration(row.leftSourceState, SOURCE_STATES, 'Domain comparison left source state');
|
|
195
|
+
enumeration(row.rightSourceState, SOURCE_STATES, 'Domain comparison right source state');
|
|
196
|
+
iso(row.observedAt, 'Domain comparison row observedAt', true);
|
|
197
|
+
if (row.observedAt !== comparison.observedAt)
|
|
198
|
+
fail('Domain comparison row observedAt');
|
|
199
|
+
if (typeof row.leftEvidenceHref !== 'string' || !/^(?:|#bulk-result-\d{1,4})$/u.test(row.leftEvidenceHref))
|
|
200
|
+
fail('Domain comparison evidence link');
|
|
201
|
+
if (typeof row.rightEvidenceHref !== 'string' || !/^(?:|#bulk-result-\d{1,4})$/u.test(row.rightEvidenceHref))
|
|
202
|
+
fail('Domain comparison evidence link');
|
|
203
|
+
strings(row.limitations, 'Domain comparison row limitations', 6, 320);
|
|
204
|
+
}
|
|
205
|
+
for (const state of COMPARISON_STATES) {
|
|
206
|
+
if (integer(counts[state], `Domain comparison ${state} count`, 0, rows.length) !== actual.get(state))
|
|
207
|
+
fail('Domain comparison counts');
|
|
208
|
+
}
|
|
209
|
+
strings(comparison.limitations, 'Domain comparison limitations', 12, 600);
|
|
210
|
+
validateIntegrity(root.integrity, 'Domain comparison integrity', root.version, BULK_DOMAIN_COMPARISON_EXPORT_VERSION);
|
|
211
|
+
}
|
|
212
|
+
function validateSourceCoverage(value, label) {
|
|
213
|
+
const source = exact(value, ['source', 'state'], label);
|
|
214
|
+
if (typeof source.source !== 'string' || !/^[a-z][a-z0-9_-]{0,39}$/u.test(source.source))
|
|
215
|
+
fail(label);
|
|
216
|
+
enumeration(source.state, SOURCE_STATES.filter((state) => state !== 'not_recorded'), label);
|
|
217
|
+
}
|
|
218
|
+
function validateProfileContext(value, label) {
|
|
219
|
+
const context = exact(value, ['sourceState', 'activeProfileId', 'profileUpdatedAt', 'limitation'], label);
|
|
220
|
+
enumeration(context.sourceState, ['mixed', 'ready', 'unavailable'], label);
|
|
221
|
+
optionalText(context.activeProfileId, label, 128);
|
|
222
|
+
iso(context.profileUpdatedAt, label, true);
|
|
223
|
+
text(context.limitation, label, 300, true);
|
|
224
|
+
}
|
|
225
|
+
function validateMailExposure(value) {
|
|
226
|
+
const root = exact(value, ['schema', 'version', 'report', 'integrity'], 'Bulk mail exposure artefact');
|
|
227
|
+
const report = exact(root.report, ['version', 'generatedAt', 'observedAt', 'baseline', 'rows', 'counts', 'profileContextUnevaluatedCount', 'limitations'], 'Bulk mail exposure report');
|
|
228
|
+
if (report.version !== 1)
|
|
229
|
+
fail('Bulk mail exposure report');
|
|
230
|
+
iso(report.generatedAt, 'Bulk mail exposure generatedAt');
|
|
231
|
+
iso(report.observedAt, 'Bulk mail exposure observedAt', true);
|
|
232
|
+
const baseline = exact(report.baseline, ['profile', 'officialDomains', 'label', 'limitations'], 'Bulk mail exposure baseline');
|
|
233
|
+
if (baseline.profile !== null)
|
|
234
|
+
enumeration(baseline.profile, ['defensive_no_mail', 'parked', 'standard'], 'Bulk mail exposure baseline');
|
|
235
|
+
const officialDomains = strings(baseline.officialDomains, 'Bulk mail exposure official domains', 20, 253);
|
|
236
|
+
officialDomains.forEach((item) => domain(item, 'Bulk mail exposure official domain'));
|
|
237
|
+
text(baseline.label, 'Bulk mail exposure baseline label', 200);
|
|
238
|
+
strings(baseline.limitations, 'Bulk mail exposure baseline limitations', 8, 600);
|
|
239
|
+
const rows = array(report.rows, 'Bulk mail exposure rows', MAX_BULK_MAIL_EXPOSURE_ROWS);
|
|
240
|
+
const counts = exact(report.counts, [...MAIL_STATES], 'Bulk mail exposure counts');
|
|
241
|
+
const actual = new Map(MAIL_STATES.map((state) => [state, 0]));
|
|
242
|
+
let unevaluated = 0;
|
|
243
|
+
for (const [index, candidate] of rows.entries()) {
|
|
244
|
+
const row = exact(candidate, ['domain', 'state', 'label', 'detail', 'baselineRelation', 'baselineDetail', 'mutationTypes', 'registration', 'sourceCoverage', 'profileContextState', 'profileContextLimitation', 'limitations'], `Bulk mail exposure row ${index + 1}`);
|
|
245
|
+
domain(row.domain, 'Bulk mail exposure row domain');
|
|
246
|
+
const state = enumeration(row.state, MAIL_STATES, 'Bulk mail exposure row state');
|
|
247
|
+
actual.set(state, (actual.get(state) ?? 0) + 1);
|
|
248
|
+
text(row.label, 'Bulk mail exposure row label', 200);
|
|
249
|
+
text(row.detail, 'Bulk mail exposure row detail', 600);
|
|
250
|
+
enumeration(row.baselineRelation, ['aligned', 'inconclusive', 'review'], 'Bulk mail exposure baseline relation');
|
|
251
|
+
text(row.baselineDetail, 'Bulk mail exposure baseline detail', 600);
|
|
252
|
+
strings(row.mutationTypes, 'Bulk mail exposure mutation types', 40, 120);
|
|
253
|
+
text(row.registration, 'Bulk mail exposure registration', 160, true);
|
|
254
|
+
array(row.sourceCoverage, 'Bulk mail exposure source coverage', 12).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk mail exposure source ${sourceIndex + 1}`));
|
|
255
|
+
const profileState = enumeration(row.profileContextState, ['mixed', 'ready', 'unavailable'], 'Bulk mail exposure profile context state');
|
|
256
|
+
if (profileState !== 'ready')
|
|
257
|
+
unevaluated += 1;
|
|
258
|
+
text(row.profileContextLimitation, 'Bulk mail exposure profile context limitation', 300, true);
|
|
259
|
+
strings(row.limitations, 'Bulk mail exposure row limitations', 6, 600);
|
|
260
|
+
}
|
|
261
|
+
for (const state of MAIL_STATES) {
|
|
262
|
+
if (integer(counts[state], `Bulk mail exposure ${state} count`, 0, rows.length) !== actual.get(state))
|
|
263
|
+
fail('Bulk mail exposure counts');
|
|
264
|
+
}
|
|
265
|
+
const declaredUnevaluated = integer(report.profileContextUnevaluatedCount, 'Bulk mail exposure unevaluated count', 0, rows.length);
|
|
266
|
+
if (declaredUnevaluated < unevaluated)
|
|
267
|
+
fail('Bulk mail exposure unevaluated count');
|
|
268
|
+
strings(report.limitations, 'Bulk mail exposure limitations', 12, 600);
|
|
269
|
+
validateIntegrity(root.integrity, 'Bulk mail exposure integrity', root.version, BULK_MAIL_EXPOSURE_EXPORT_VERSION);
|
|
270
|
+
}
|
|
271
|
+
function validateBulkView(value, label) {
|
|
272
|
+
const view = exact(value, ['primaryFilter', 'mutationFilter', 'signalFilters', 'sourceFilter', 'lifecycleFilter', 'ageFilter', 'mailFilter', 'registrarFilter', 'caseDispositionFilter', 'reviewStateFilter', 'groupBy', 'sortKey', 'sortDirection'], label);
|
|
273
|
+
text(view.primaryFilter, label, 60);
|
|
274
|
+
text(view.mutationFilter, label, 60, true);
|
|
275
|
+
strings(view.signalFilters, label, 5, 40);
|
|
276
|
+
text(view.sourceFilter, label, 60, true);
|
|
277
|
+
text(view.lifecycleFilter, label, 60, true);
|
|
278
|
+
text(view.ageFilter, label, 60, true);
|
|
279
|
+
text(view.mailFilter, label, 60, true);
|
|
280
|
+
text(view.registrarFilter, label, 200, true);
|
|
281
|
+
text(view.caseDispositionFilter, label, 60, true);
|
|
282
|
+
enumeration(view.reviewStateFilter, ['', 'unreviewed', 'reviewing', 'reviewed', 'deferred'], label);
|
|
283
|
+
text(view.groupBy, label, 60, true);
|
|
284
|
+
enumeration(view.sortKey, ['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation'], label);
|
|
285
|
+
if (view.sortDirection !== 1 && view.sortDirection !== -1)
|
|
286
|
+
fail(label);
|
|
287
|
+
}
|
|
288
|
+
function validateBulkReviewManifest(value) {
|
|
289
|
+
const root = exact(value, ['schema', 'version', 'generatedAt', 'observedAt', 'lookupProfile', 'selection', 'view', 'rows', 'limitations', 'integrity'], 'Bulk review manifest');
|
|
290
|
+
iso(root.generatedAt, 'Bulk review manifest generatedAt');
|
|
291
|
+
iso(root.observedAt, 'Bulk review manifest observedAt');
|
|
292
|
+
enumeration(root.lookupProfile, ['deep', 'fast'], 'Bulk review manifest lookup profile');
|
|
293
|
+
const selection = exact(root.selection, ['count', 'domains'], 'Bulk review manifest selection');
|
|
294
|
+
const domains = strings(selection.domains, 'Bulk review manifest selected domains', 2_000, 253);
|
|
295
|
+
domains.forEach((item) => domain(item, 'Bulk review manifest selected domain'));
|
|
296
|
+
if (integer(selection.count, 'Bulk review manifest selection count', 0, 2_000) !== domains.length)
|
|
297
|
+
fail('Bulk review manifest selection');
|
|
298
|
+
validateBulkView(root.view, 'Bulk review manifest view');
|
|
299
|
+
const rows = array(root.rows, 'Bulk review manifest rows', 2_000);
|
|
300
|
+
for (const [index, candidate] of rows.entries()) {
|
|
301
|
+
const row = exact(candidate, ['domain', 'reviewState', 'resultState', 'scanDepth', 'sourceCoverage', 'profileContext'], `Bulk review manifest row ${index + 1}`);
|
|
302
|
+
domain(row.domain, 'Bulk review manifest row domain');
|
|
303
|
+
enumeration(row.reviewState, ['unreviewed', 'reviewing', 'reviewed', 'deferred'], 'Bulk review manifest review state');
|
|
304
|
+
enumeration(row.resultState, ['complete', 'error'], 'Bulk review manifest result state');
|
|
305
|
+
enumeration(row.scanDepth, ['deep', 'fast'], 'Bulk review manifest scan depth');
|
|
306
|
+
array(row.sourceCoverage, 'Bulk review manifest source coverage', 12).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk review manifest source ${sourceIndex + 1}`));
|
|
307
|
+
validateProfileContext(row.profileContext, 'Bulk review manifest profile context');
|
|
308
|
+
if (domains[index] !== row.domain)
|
|
309
|
+
fail('Bulk review manifest selection');
|
|
310
|
+
}
|
|
311
|
+
if (rows.length !== domains.length)
|
|
312
|
+
fail('Bulk review manifest selection');
|
|
313
|
+
strings(root.limitations, 'Bulk review manifest limitations', 8, 600);
|
|
314
|
+
validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version, BULK_REVIEW_MANIFEST_VERSION);
|
|
315
|
+
}
|
|
316
|
+
function validateInvestigationManifest(value) {
|
|
317
|
+
const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity'], 'Investigation manifest');
|
|
318
|
+
iso(root.generatedAt, 'Investigation manifest generatedAt');
|
|
319
|
+
const application = exact(root.application, ['name', 'version'], 'Investigation manifest application');
|
|
320
|
+
if (application.name !== 'WHOISleuth CLI' || typeof application.version !== 'string'
|
|
321
|
+
|| !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
|
|
322
|
+
fail('Investigation manifest application');
|
|
323
|
+
text(root.workflow, 'Investigation manifest workflow', 160);
|
|
324
|
+
const configuration = exact(root.configuration, ['digestSha256'], 'Investigation manifest configuration');
|
|
325
|
+
if (configuration.digestSha256 !== null)
|
|
326
|
+
digest(configuration.digestSha256, 'Investigation manifest configuration');
|
|
327
|
+
const artifacts = array(root.artifacts, 'Investigation manifest artifacts', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
|
|
328
|
+
const steps = array(root.steps, 'Investigation manifest steps', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
|
|
329
|
+
let totalBytes = 0;
|
|
330
|
+
for (const [index, candidate] of artifacts.entries()) {
|
|
331
|
+
const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256'], `Investigation manifest artifact ${index + 1}`);
|
|
332
|
+
if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1
|
|
333
|
+
|| item.id !== `artifact-${index + 1}`)
|
|
334
|
+
fail('Investigation manifest artifact order');
|
|
335
|
+
if (item.schema !== null)
|
|
336
|
+
text(item.schema, 'Investigation manifest artifact schema', 160);
|
|
337
|
+
if (item.version !== null)
|
|
338
|
+
integer(item.version, 'Investigation manifest artifact version', 1, 1_000);
|
|
339
|
+
totalBytes += integer(item.byteLength, 'Investigation manifest artifact bytes', 1, 15 * 1024 * 1024);
|
|
340
|
+
digest(item.contentDigestSha256, 'Investigation manifest content digest');
|
|
341
|
+
digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
|
|
342
|
+
const step = exact(steps[index], ['sequence', 'artifactId', 'contentDigestSha256'], `Investigation manifest step ${index + 1}`);
|
|
343
|
+
if (step.sequence !== item.sequence || step.artifactId !== item.id || step.contentDigestSha256 !== item.contentDigestSha256)
|
|
344
|
+
fail('Investigation manifest step linkage');
|
|
345
|
+
}
|
|
346
|
+
const summary = exact(root.summary, ['artifactCount', 'totalBytes'], 'Investigation manifest summary');
|
|
347
|
+
if (integer(summary.artifactCount, 'Investigation manifest artifact count', 1, MAX_INVESTIGATION_MANIFEST_ARTIFACTS) !== artifacts.length
|
|
348
|
+
|| integer(summary.totalBytes, 'Investigation manifest total bytes', 1, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) !== totalBytes)
|
|
349
|
+
fail('Investigation manifest summary');
|
|
350
|
+
strings(root.limitations, 'Investigation manifest limitations', 8, 600);
|
|
351
|
+
validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version, INVESTIGATION_MANIFEST_VERSION);
|
|
352
|
+
}
|
|
353
|
+
function validateReviewMatrix(value, label) {
|
|
354
|
+
for (const [rowIndex, candidate] of array(value, label, 500).entries()) {
|
|
355
|
+
const row = exact(candidate, ['owner', 'type', 'state', 'observations'], `${label} row ${rowIndex + 1}`);
|
|
356
|
+
text(row.owner, `${label} owner`, 253);
|
|
357
|
+
enumeration(row.type, DNS_TYPES, `${label} type`);
|
|
358
|
+
const observations = array(row.observations, `${label} observations`, 16);
|
|
359
|
+
let complete = 0;
|
|
360
|
+
const signatures = new Set();
|
|
361
|
+
for (const observationCandidate of observations) {
|
|
362
|
+
const observation = exact(observationCandidate, ['label', 'source', 'state', 'values', 'ttlRange'], `${label} observation`);
|
|
363
|
+
text(observation.label, `${label} observation label`, 120);
|
|
364
|
+
text(observation.source, `${label} observation source`, 240);
|
|
365
|
+
const state = enumeration(observation.state, ['observed', 'partial', 'unavailable'], `${label} observation state`);
|
|
366
|
+
const values = strings(observation.values, `${label} observation values`, 500, 16_384);
|
|
367
|
+
if (state === 'observed') {
|
|
368
|
+
complete += 1;
|
|
369
|
+
signatures.add(JSON.stringify(values));
|
|
370
|
+
}
|
|
371
|
+
if (observation.ttlRange !== null) {
|
|
372
|
+
const range = exact(observation.ttlRange, ['minimum', 'maximum'], `${label} TTL range`);
|
|
373
|
+
const minimum = integer(range.minimum, `${label} minimum TTL`, 0, 0x7fff_ffff);
|
|
374
|
+
const maximum = integer(range.maximum, `${label} maximum TTL`, minimum, 0x7fff_ffff);
|
|
375
|
+
if (maximum < minimum)
|
|
376
|
+
fail(`${label} TTL range`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const expected = complete < 2 ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different';
|
|
380
|
+
if (row.state !== expected)
|
|
381
|
+
fail(`${label} state`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function validateDomainChangeReview(value, label) {
|
|
385
|
+
const review = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'state', 'authoritativeRecordMatrix', 'resolverDivergenceMatrix', 'dnssecAutomation', 'acmeDependencies', 'certificate', 'services', 'hsts', 'gate', 'limitations'], label);
|
|
386
|
+
if (review.schema !== 'whoisleuth.domain-change.review' || review.version !== 1)
|
|
387
|
+
fail(label);
|
|
388
|
+
iso(review.generatedAt, `${label} generatedAt`);
|
|
389
|
+
domain(review.domain, `${label} domain`);
|
|
390
|
+
enumeration(review.state, ['ready', 'review'], `${label} state`);
|
|
391
|
+
validateReviewMatrix(review.authoritativeRecordMatrix, `${label} authority matrix`);
|
|
392
|
+
validateReviewMatrix(review.resolverDivergenceMatrix, `${label} resolver matrix`);
|
|
393
|
+
const automation = exact(review.dnssecAutomation, ['state', 'cdsObserved', 'cdnskeyObserved', 'csyncObserved', 'conflictingTypes', 'detail'], `${label} DNSSEC automation`);
|
|
394
|
+
enumeration(automation.state, ['not_observed', 'conflict', 'partial', 'review_ready'], `${label} DNSSEC automation`);
|
|
395
|
+
boolean(automation.cdsObserved, `${label} CDS observed`);
|
|
396
|
+
boolean(automation.cdnskeyObserved, `${label} CDNSKEY observed`);
|
|
397
|
+
boolean(automation.csyncObserved, `${label} CSYNC observed`);
|
|
398
|
+
strings(automation.conflictingTypes, `${label} conflicting types`, 3, 16).forEach((item) => enumeration(item, ['CDS', 'CDNSKEY', 'CSYNC'], `${label} conflicting type`));
|
|
399
|
+
text(automation.detail, `${label} DNSSEC detail`, 600);
|
|
400
|
+
for (const candidate of array(review.acmeDependencies, `${label} ACME dependencies`, 64)) {
|
|
401
|
+
const dependency = exact(candidate, ['method', 'owner', 'target', 'provider', 'state'], `${label} ACME dependency`);
|
|
402
|
+
enumeration(dependency.method, ['dns-01', 'http-01', 'tls-alpn-01'], `${label} ACME method`);
|
|
403
|
+
text(dependency.owner, `${label} ACME owner`, 253);
|
|
404
|
+
optionalText(dependency.target, `${label} ACME target`, 253);
|
|
405
|
+
optionalText(dependency.provider, `${label} ACME provider`, 120);
|
|
406
|
+
enumeration(dependency.state, ['confirmed', 'partial', 'unknown'], `${label} ACME state`);
|
|
407
|
+
}
|
|
408
|
+
const certificate = exact(review.certificate, ['state', 'continuity', 'findings'], `${label} certificate`);
|
|
409
|
+
enumeration(certificate.state, ['not_supplied', 'observed', 'partial', 'unavailable'], `${label} certificate state`);
|
|
410
|
+
enumeration(certificate.continuity, ['unknown', 'retained', 'changes'], `${label} certificate continuity`);
|
|
411
|
+
strings(certificate.findings, `${label} certificate findings`, 8, 600);
|
|
412
|
+
for (const candidate of array(review.services, `${label} services`, 1_000)) {
|
|
413
|
+
const service = exact(candidate, ['owner', 'value', 'source', 'state', 'observedAt'], `${label} service`);
|
|
414
|
+
text(service.owner, `${label} service owner`, 253);
|
|
415
|
+
text(service.value, `${label} service value`, 16_384);
|
|
416
|
+
text(service.source, `${label} service source`, 240);
|
|
417
|
+
enumeration(service.state, ['observed', 'partial', 'unavailable'], `${label} service state`);
|
|
418
|
+
iso(service.observedAt, `${label} service observedAt`);
|
|
419
|
+
}
|
|
420
|
+
if (review.hsts !== null) {
|
|
421
|
+
const hsts = exact(review.hsts, ['state', 'observedAt', 'header', 'preloadState', 'source'], `${label} HSTS`);
|
|
422
|
+
enumeration(hsts.state, ['observed', 'partial', 'unavailable'], `${label} HSTS state`);
|
|
423
|
+
iso(hsts.observedAt, `${label} HSTS observedAt`);
|
|
424
|
+
optionalText(hsts.header, `${label} HSTS header`, 1_024);
|
|
425
|
+
enumeration(hsts.preloadState, ['listed', 'not_listed', 'unavailable'], `${label} HSTS preload`);
|
|
426
|
+
text(hsts.source, `${label} HSTS source`, 240);
|
|
427
|
+
}
|
|
428
|
+
const gate = exact(review.gate, ['pass', 'reasons'], `${label} gate`);
|
|
429
|
+
boolean(gate.pass, `${label} gate`);
|
|
430
|
+
const reasons = strings(gate.reasons, `${label} gate reasons`, 2_000, 600);
|
|
431
|
+
if (gate.pass !== (reasons.length === 0) || review.state !== (gate.pass ? 'ready' : 'review'))
|
|
432
|
+
fail(`${label} gate`);
|
|
433
|
+
strings(review.limitations, `${label} limitations`, 8, 600);
|
|
434
|
+
return review;
|
|
435
|
+
}
|
|
436
|
+
function validatePlannedAssurance(value, label) {
|
|
437
|
+
const assurance = exact(value, ['schema', 'version', 'generatedAt', 'result', 'limitations'], label);
|
|
438
|
+
if (assurance.schema !== 'whoisleuth.domain-assurance' || assurance.version !== 2)
|
|
439
|
+
fail(label);
|
|
440
|
+
iso(assurance.generatedAt, `${label} generatedAt`);
|
|
441
|
+
const result = exact(assurance.result, ['kind', 'domain', 'reference', 'window', 'milestones', 'rollbackCriteria', 'postChangeChecks', 'review'], `${label} result`);
|
|
442
|
+
if (result.kind !== 'planned-change')
|
|
443
|
+
fail(`${label} result`);
|
|
444
|
+
domain(result.domain, `${label} domain`);
|
|
445
|
+
text(result.reference, `${label} reference`, 120);
|
|
446
|
+
const window = exact(result.window, ['startsAt', 'endsAt'], `${label} window`);
|
|
447
|
+
iso(window.startsAt, `${label} startsAt`);
|
|
448
|
+
iso(window.endsAt, `${label} endsAt`);
|
|
449
|
+
if (Date.parse(window.endsAt) <= Date.parse(window.startsAt))
|
|
450
|
+
fail(`${label} window`);
|
|
451
|
+
const ids = new Set();
|
|
452
|
+
for (const candidate of array(result.milestones, `${label} milestones`, 24, 1)) {
|
|
453
|
+
const item = exact(candidate, ['id', 'label', 'expectedBy', 'evidenceSource', 'state', 'observedAt', 'evidenceReference'], `${label} milestone`);
|
|
454
|
+
const id = text(item.id, `${label} milestone id`, 64);
|
|
455
|
+
if (ids.has(id))
|
|
456
|
+
fail(`${label} item ids`);
|
|
457
|
+
ids.add(id);
|
|
458
|
+
text(item.label, `${label} milestone label`, 180);
|
|
459
|
+
iso(item.expectedBy, `${label} milestone expectedBy`);
|
|
460
|
+
text(item.evidenceSource, `${label} milestone source`, 120);
|
|
461
|
+
const state = enumeration(item.state, ['missed', 'not_checked', 'observed', 'planned'], `${label} milestone state`);
|
|
462
|
+
iso(item.observedAt, `${label} milestone observedAt`, true);
|
|
463
|
+
optionalText(item.evidenceReference, `${label} milestone reference`, 300);
|
|
464
|
+
if (['observed', 'missed'].includes(state) !== (item.observedAt !== null && item.evidenceReference !== null))
|
|
465
|
+
fail(`${label} milestone evidence`);
|
|
466
|
+
}
|
|
467
|
+
for (const candidate of array(result.rollbackCriteria, `${label} rollback criteria`, 16, 1)) {
|
|
468
|
+
const item = exact(candidate, ['id', 'condition', 'owner', 'state'], `${label} rollback criterion`);
|
|
469
|
+
const id = text(item.id, `${label} rollback id`, 64);
|
|
470
|
+
if (ids.has(id))
|
|
471
|
+
fail(`${label} item ids`);
|
|
472
|
+
ids.add(id);
|
|
473
|
+
text(item.condition, `${label} rollback condition`, 240);
|
|
474
|
+
text(item.owner, `${label} rollback owner`, 120);
|
|
475
|
+
enumeration(item.state, ['met', 'not_checked', 'not_met'], `${label} rollback state`);
|
|
476
|
+
}
|
|
477
|
+
for (const candidate of array(result.postChangeChecks, `${label} post-change checks`, 24, 1)) {
|
|
478
|
+
const item = exact(candidate, ['id', 'label', 'expectedState', 'evidenceSource', 'state', 'evidenceReference'], `${label} post-change check`);
|
|
479
|
+
const id = text(item.id, `${label} post-change id`, 64);
|
|
480
|
+
if (ids.has(id))
|
|
481
|
+
fail(`${label} item ids`);
|
|
482
|
+
ids.add(id);
|
|
483
|
+
text(item.label, `${label} post-change label`, 180);
|
|
484
|
+
text(item.expectedState, `${label} expected state`, 240);
|
|
485
|
+
text(item.evidenceSource, `${label} post-change source`, 120);
|
|
486
|
+
const state = enumeration(item.state, ['matched', 'not_checked', 'unexpected'], `${label} post-change state`);
|
|
487
|
+
optionalText(item.evidenceReference, `${label} post-change reference`, 300);
|
|
488
|
+
if ((state === 'not_checked') !== (item.evidenceReference === null))
|
|
489
|
+
fail(`${label} post-change evidence`);
|
|
490
|
+
}
|
|
491
|
+
const review = exact(result.review, ['state', 'reasons'], `${label} review`);
|
|
492
|
+
enumeration(review.state, ['incomplete', 'needs_review', 'ready'], `${label} review state`);
|
|
493
|
+
const reasons = strings(review.reasons, `${label} review reasons`, 20, 600);
|
|
494
|
+
if ((review.state === 'ready') !== (reasons.length === 0))
|
|
495
|
+
fail(`${label} review`);
|
|
496
|
+
strings(assurance.limitations, `${label} limitations`, 8, 600);
|
|
497
|
+
return assurance;
|
|
498
|
+
}
|
|
499
|
+
function expectedDomainChangeSummary(before, after) {
|
|
500
|
+
const rows = (review) => new Map(review.authoritativeRecordMatrix.map((row) => [`${row.owner}\u0000${row.type}`, row]));
|
|
501
|
+
const beforeRows = rows(before);
|
|
502
|
+
const afterRows = rows(after);
|
|
503
|
+
const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
|
|
504
|
+
return keys.slice(0, 500).flatMap((key) => {
|
|
505
|
+
const left = beforeRows.get(key);
|
|
506
|
+
const right = afterRows.get(key);
|
|
507
|
+
const values = (row) => [...new Set((row?.observations ?? [])
|
|
508
|
+
.flatMap((observation) => observation.values))].sort();
|
|
509
|
+
const beforeValues = values(left);
|
|
510
|
+
const afterValues = values(right);
|
|
511
|
+
if (sameValues(beforeValues, afterValues))
|
|
512
|
+
return [];
|
|
513
|
+
const [owner, type] = key.split('\u0000');
|
|
514
|
+
return [{ owner: owner ?? '', type: type ?? '', beforeValues, afterValues }];
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function validateDomainChangePacket(value) {
|
|
518
|
+
const root = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'reference', 'state', 'gate', 'summary', 'evidence', 'limitations', 'integrity'], 'Domain change packet');
|
|
519
|
+
iso(root.generatedAt, 'Domain change packet generatedAt');
|
|
520
|
+
domain(root.domain, 'Domain change packet domain');
|
|
521
|
+
text(root.reference, 'Domain change packet reference', 200);
|
|
522
|
+
enumeration(root.state, ['ready', 'review'], 'Domain change packet state');
|
|
523
|
+
const gate = exact(root.gate, ['pass', 'reasons'], 'Domain change packet gate');
|
|
524
|
+
boolean(gate.pass, 'Domain change packet gate');
|
|
525
|
+
const gateReasons = strings(gate.reasons, 'Domain change packet gate reasons', 100, 700);
|
|
526
|
+
if (gate.pass !== (gateReasons.length === 0) || root.state !== (gate.pass ? 'ready' : 'review'))
|
|
527
|
+
fail('Domain change packet gate');
|
|
528
|
+
const summary = exact(root.summary, ['changedAuthoritativeRecordSets', 'preChangeState', 'postChangeState', 'assuranceState'], 'Domain change packet summary');
|
|
529
|
+
const changed = array(summary.changedAuthoritativeRecordSets, 'Domain change packet changes', 500);
|
|
530
|
+
for (const candidate of changed) {
|
|
531
|
+
const item = exact(candidate, ['owner', 'type', 'beforeValues', 'afterValues'], 'Domain change packet changed record set');
|
|
532
|
+
text(item.owner, 'Domain change packet changed owner', 253);
|
|
533
|
+
enumeration(item.type, DNS_TYPES, 'Domain change packet changed type');
|
|
534
|
+
strings(item.beforeValues, 'Domain change packet before values', 500, 16_384);
|
|
535
|
+
strings(item.afterValues, 'Domain change packet after values', 500, 16_384);
|
|
536
|
+
}
|
|
537
|
+
enumeration(summary.preChangeState, ['ready', 'review'], 'Domain change packet pre-change state');
|
|
538
|
+
enumeration(summary.postChangeState, ['ready', 'review'], 'Domain change packet post-change state');
|
|
539
|
+
enumeration(summary.assuranceState, ['incomplete', 'needs_review', 'ready'], 'Domain change packet assurance state');
|
|
540
|
+
const evidence = exact(root.evidence, ['preChange', 'postChange', 'assurance'], 'Domain change packet evidence');
|
|
541
|
+
const pre = validateDomainChangeReview(evidence.preChange, 'Domain change packet pre-change review');
|
|
542
|
+
const post = validateDomainChangeReview(evidence.postChange, 'Domain change packet post-change review');
|
|
543
|
+
const assurance = validatePlannedAssurance(evidence.assurance, 'Domain change packet assurance');
|
|
544
|
+
const assuranceResult = assurance.result;
|
|
545
|
+
if (pre.generatedAt !== root.generatedAt || post.generatedAt !== root.generatedAt || assurance.generatedAt !== root.generatedAt
|
|
546
|
+
|| pre.domain !== root.domain || post.domain !== root.domain || assuranceResult.domain !== root.domain
|
|
547
|
+
|| pre.state !== summary.preChangeState || post.state !== summary.postChangeState
|
|
548
|
+
|| assuranceResult.review.state !== summary.assuranceState)
|
|
549
|
+
fail('Domain change packet evidence linkage');
|
|
550
|
+
const expectedReasons = [
|
|
551
|
+
...pre.gate.reasons,
|
|
552
|
+
].map((reason) => `Pre-change evidence: ${reason}`);
|
|
553
|
+
expectedReasons.push(...post.gate.reasons.map((reason) => `Post-change evidence: ${reason}`), ...(assuranceResult.review.reasons.map((reason) => `Change plan: ${reason}`)));
|
|
554
|
+
const boundedReasons = expectedReasons.slice(0, 100);
|
|
555
|
+
if (!sameValues(gateReasons, boundedReasons)
|
|
556
|
+
|| gate.pass !== (boundedReasons.length === 0)
|
|
557
|
+
|| root.state !== (boundedReasons.length === 0 ? 'ready' : 'review'))
|
|
558
|
+
fail('Domain change packet gate');
|
|
559
|
+
const expectedChanges = expectedDomainChangeSummary(pre, post);
|
|
560
|
+
if (changed.length !== expectedChanges.length || changed.some((candidate, index) => {
|
|
561
|
+
const item = candidate;
|
|
562
|
+
const expected = expectedChanges[index];
|
|
563
|
+
return item.owner !== expected.owner || item.type !== expected.type
|
|
564
|
+
|| !sameValues(item.beforeValues, expected.beforeValues)
|
|
565
|
+
|| !sameValues(item.afterValues, expected.afterValues);
|
|
566
|
+
}))
|
|
567
|
+
fail('Domain change packet summary');
|
|
568
|
+
strings(root.limitations, 'Domain change packet limitations', 8, 600);
|
|
569
|
+
validateIntegrity(root.integrity, 'Domain change packet integrity', root.version, DOMAIN_CHANGE_PACKET_VERSION);
|
|
570
|
+
}
|
|
571
|
+
export function validateSignedDigestArtifactStructure(schema, value) {
|
|
572
|
+
if (value.schema !== schema)
|
|
573
|
+
fail('Signed review artefact');
|
|
574
|
+
if (schema === ACQUISITION_DECISION_PACKET_SCHEMA)
|
|
575
|
+
validateAcquisition(value);
|
|
576
|
+
else if (schema === LOOKUP_CLAIM_PASSPORT_SCHEMA)
|
|
577
|
+
validateLookupClaimPassport(value);
|
|
578
|
+
else if (schema === BULK_DOMAIN_COMPARISON_SCHEMA)
|
|
579
|
+
validateDomainComparison(value);
|
|
580
|
+
else if (schema === BULK_MAIL_EXPOSURE_SCHEMA)
|
|
581
|
+
validateMailExposure(value);
|
|
582
|
+
else if (schema === BULK_REVIEW_MANIFEST_SCHEMA)
|
|
583
|
+
validateBulkReviewManifest(value);
|
|
584
|
+
else if (schema === DOMAIN_CONTROL_MANIFEST_SCHEMA) {
|
|
585
|
+
try {
|
|
586
|
+
normalizeDomainControlManifestDocument(value);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
fail('Domain control manifest');
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
else if (schema === DOMAIN_CHANGE_PACKET_SCHEMA)
|
|
593
|
+
validateDomainChangePacket(value);
|
|
594
|
+
else if (schema === INVESTIGATION_MANIFEST_SCHEMA)
|
|
595
|
+
validateInvestigationManifest(value);
|
|
596
|
+
else
|
|
597
|
+
fail('Signed review artefact');
|
|
598
|
+
}
|