@slicedearth/whoisleuth-cli 1.39.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +15 -0
  2. package/bin/whoisleuth.mjs +13 -2
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/completion.mjs +33 -10
  13. package/cli/config-profile.mjs +180 -0
  14. package/cli/ct-event-intake.mjs +159 -0
  15. package/cli/discovery-scan-command-runner.mjs +15 -1
  16. package/cli/domain-control-monitor.mjs +104 -0
  17. package/cli/domain-control-observations.mjs +235 -0
  18. package/cli/evidence-command-arguments.mjs +11 -0
  19. package/cli/evidence-command-runner.mjs +1 -0
  20. package/cli/external-observation-mapping.mjs +162 -0
  21. package/cli/fail-policy.mjs +64 -0
  22. package/cli/formatters/html.mjs +1 -1
  23. package/cli/investigation-manifest.mjs +113 -0
  24. package/cli/investigation-run.mjs +112 -0
  25. package/cli/lookup-brief.mjs +116 -0
  26. package/cli/lookup-command-runner.mjs +8 -1
  27. package/cli/lookup-diff.mjs +17 -19
  28. package/cli/network-command-runner.mjs +4 -1
  29. package/cli/offline-evidence-review.mjs +105 -8
  30. package/cli/open-asset-model-bridge.mjs +169 -0
  31. package/cli/registry-cohort.mjs +121 -0
  32. package/cli/registry-doctor.mjs +106 -11
  33. package/cli/registry-fixture-scaffold.mjs +73 -0
  34. package/cli/runner.mjs +368 -306
  35. package/docs/cli-reference.md +240 -14
  36. package/docs/cli.md +42 -7
  37. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  38. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  39. package/frontend/src/lib/analysis/case-report.js +573 -0
  40. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  41. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  42. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  43. package/frontend/src/lib/analysis/lookup-decision-support.js +7 -0
  44. package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
  45. package/frontend/src/lib/analysis/scoring.js +1 -1
  46. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  47. package/frontend/src/lib/analysis/workspace-archive.js +2 -0
  48. package/lib/abusech-auth.mjs +1 -1
  49. package/lib/availability.mjs +1 -1
  50. package/lib/bounded-contract-normalizers.mjs +33 -0
  51. package/lib/dns-convergence-review.mjs +183 -0
  52. package/lib/dns-delegation-health.mjs +196 -3
  53. package/lib/domain-change-packet.mjs +133 -0
  54. package/lib/domain-change-review.mjs +307 -0
  55. package/lib/domain-control-flight-recorder.mjs +268 -0
  56. package/lib/domain-portfolio-review.mjs +151 -0
  57. package/lib/evidence-export.mjs +1 -1
  58. package/lib/html-signals.mjs +5 -11
  59. package/lib/nameserver-preflight-review.mjs +159 -0
  60. package/lib/page-language-signals.mjs +124 -0
  61. package/lib/risk-scoring.mjs +42 -1
  62. package/lib/static-html-analysis.mjs +29 -1
  63. package/lib/trust-store-comparison.mjs +146 -0
  64. package/lib/website-technology.mjs +24 -10
  65. package/lib/zone-intent-review.mjs +534 -0
  66. package/package.json +1 -1
@@ -0,0 +1,169 @@
1
+ import { isIP } from 'node:net';
2
+ import { parseExternalFindingsDocument, } from "../frontend/src/lib/analysis/external-findings-import.js";
3
+ import { requireIsoTimestamp } from "../lib/bounded-contract-normalizers.mjs";
4
+ export const OPEN_ASSET_MODEL_BRIDGE_SCHEMA = 'whoisleuth.open-asset-model-bridge';
5
+ export const OPEN_ASSET_MODEL_BRIDGE_VERSION = 1;
6
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
7
+ function fqdnId(domain) {
8
+ return `FQDN/${domain}`;
9
+ }
10
+ function certificateId(digest) {
11
+ return `TLSCertificate/sha256:${digest}`;
12
+ }
13
+ function ipId(address) {
14
+ return `IPAddress/${address}`;
15
+ }
16
+ function earlier(left, right) {
17
+ return left.localeCompare(right) <= 0 ? left : right;
18
+ }
19
+ function later(left, right) {
20
+ return left.localeCompare(right) >= 0 ? left : right;
21
+ }
22
+ function combinedCompleteness(left, right) {
23
+ return left === right ? right : 'partial';
24
+ }
25
+ function setObservedAsset(assets, key, value, observedAt, completeness) {
26
+ const existing = assets.get(key);
27
+ const existingDiscovery = existing?.discovery && typeof existing.discovery === 'object'
28
+ ? existing.discovery
29
+ : null;
30
+ if (!existing || !existingDiscovery || typeof existingDiscovery.observed_at !== 'string') {
31
+ assets.set(key, value);
32
+ return;
33
+ }
34
+ assets.set(key, {
35
+ ...existing,
36
+ discovery: {
37
+ ...existingDiscovery,
38
+ observed_at: later(existingDiscovery.observed_at, observedAt),
39
+ completeness: combinedCompleteness(existingDiscovery.completeness, completeness),
40
+ },
41
+ });
42
+ }
43
+ function setObservedRelation(relations, key, value, observedAt, completeness) {
44
+ const existing = relations.get(key);
45
+ const existingDiscovery = existing?.discovery && typeof existing.discovery === 'object'
46
+ ? existing.discovery
47
+ : null;
48
+ if (!existing
49
+ || typeof existing.created_at !== 'string'
50
+ || typeof existing.last_seen !== 'string'
51
+ || !existingDiscovery) {
52
+ relations.set(key, value);
53
+ return;
54
+ }
55
+ relations.set(key, {
56
+ ...existing,
57
+ created_at: earlier(existing.created_at, observedAt),
58
+ last_seen: later(existing.last_seen, observedAt),
59
+ discovery: {
60
+ ...existingDiscovery,
61
+ completeness: combinedCompleteness(existingDiscovery.completeness, completeness),
62
+ },
63
+ });
64
+ }
65
+ export function buildOpenAssetModelBridge(input, generatedAtValue = new Date().toISOString()) {
66
+ const document = parseExternalFindingsDocument(input);
67
+ const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
68
+ const assets = new Map();
69
+ const relations = new Map();
70
+ for (const finding of document.findings) {
71
+ const domainAssetId = fqdnId(finding.domain);
72
+ setObservedAsset(assets, domainAssetId, {
73
+ key: domainAssetId,
74
+ type: 'FQDN',
75
+ attributes: { name: finding.domain },
76
+ discovery: { source: document.source.name, observed_at: finding.observedAt, completeness: finding.completeness },
77
+ }, finding.observedAt, finding.completeness);
78
+ const observation = finding.structuredObservation;
79
+ if (!observation)
80
+ continue;
81
+ if (observation.sourceSchema === 'whoisleuth.certificate-observation-rows'
82
+ && (observation.field === 'certificateSha256' || observation.field === 'fingerprintSha256')) {
83
+ const digest = observation.value.toLowerCase();
84
+ if (!SHA256_RE.test(digest))
85
+ throw new TypeError('Certificate bridge observations require a SHA-256 hexadecimal digest.');
86
+ const certificateAssetId = certificateId(digest);
87
+ setObservedAsset(assets, certificateAssetId, {
88
+ key: certificateAssetId,
89
+ type: 'TLSCertificate',
90
+ attributes: {
91
+ sha256: digest,
92
+ issuer_common_name: observation.issuer,
93
+ not_after: observation.notAfter,
94
+ },
95
+ discovery: { source: document.source.name, observed_at: finding.observedAt, completeness: finding.completeness },
96
+ }, finding.observedAt, finding.completeness);
97
+ const relationKey = `${certificateAssetId}\u0000san_dns_name\u0000${domainAssetId}`;
98
+ setObservedRelation(relations, relationKey, {
99
+ type: 'SimpleRelation',
100
+ label: 'san_dns_name',
101
+ source: certificateAssetId,
102
+ target: domainAssetId,
103
+ created_at: finding.observedAt,
104
+ last_seen: finding.observedAt,
105
+ discovery: { source: document.source.name, completeness: finding.completeness },
106
+ }, finding.observedAt, finding.completeness);
107
+ }
108
+ if (observation.sourceSchema === 'whoisleuth.dns-observation-rows'
109
+ && (observation.field === 'A' || observation.field === 'AAAA')) {
110
+ const ipVersion = isIP(observation.value);
111
+ const targetId = ipVersion ? ipId(observation.value) : null;
112
+ if (!targetId)
113
+ continue;
114
+ if ((observation.field === 'A' && ipVersion !== 4) || (observation.field === 'AAAA' && ipVersion !== 6))
115
+ continue;
116
+ setObservedAsset(assets, targetId, {
117
+ key: targetId,
118
+ type: 'IPAddress',
119
+ attributes: { address: observation.value, type: ipVersion === 4 ? 'IPv4' : 'IPv6' },
120
+ discovery: { source: document.source.name, observed_at: finding.observedAt, completeness: finding.completeness },
121
+ }, finding.observedAt, finding.completeness);
122
+ const relationKey = `${domainAssetId}\u0000dns_record\u0000${targetId}`;
123
+ setObservedRelation(relations, relationKey, {
124
+ type: 'BasicDNSRelation',
125
+ label: 'dns_record',
126
+ source: domainAssetId,
127
+ target: targetId,
128
+ created_at: finding.observedAt,
129
+ last_seen: finding.observedAt,
130
+ discovery: { source: document.source.name, completeness: finding.completeness },
131
+ }, finding.observedAt, finding.completeness);
132
+ }
133
+ }
134
+ return Object.freeze({
135
+ schema: OPEN_ASSET_MODEL_BRIDGE_SCHEMA,
136
+ version: OPEN_ASSET_MODEL_BRIDGE_VERSION,
137
+ generatedAt,
138
+ model: Object.freeze({
139
+ name: 'OWASP Open Asset Model',
140
+ profile: 'WHOISleuth bounded external-findings projection',
141
+ reference: 'https://github.com/owasp-amass/open-asset-model',
142
+ }),
143
+ assets: Object.freeze([...assets.values()].sort((left, right) => String(left.key).localeCompare(String(right.key)))),
144
+ relations: Object.freeze([...relations.values()].sort((left, right) => String(left.source).localeCompare(String(right.source)) || String(left.target).localeCompare(String(right.target)))),
145
+ provenance: Object.freeze({
146
+ source: document.source,
147
+ findingsReviewed: document.findings.length,
148
+ evidenceClasses: Object.freeze([...new Set(document.findings.map((finding) => finding.evidenceClass))].sort()),
149
+ }),
150
+ limitations: Object.freeze([
151
+ 'This is a versioned WHOISleuth bridge projection using documented Open Asset Model asset and relation vocabulary; receiving tools must still validate it against their supported model version.',
152
+ 'Only bounded FQDN, IPAddress, TLSCertificate, dns_record, and san_dns_name projections are emitted. Unsupported finding categories remain represented only by their FQDN asset.',
153
+ 'Source completeness is preserved as discovery metadata and is not converted into an Open Asset Model confidence score.',
154
+ 'Relations are observations and investigation pivots, not proof of ownership, control, coordination, intent, safety, or maliciousness.',
155
+ ]),
156
+ });
157
+ }
158
+ export function formatOpenAssetModelBridge(document) {
159
+ return [
160
+ 'Open Asset Model bridge',
161
+ `Assets ${document.assets.length}`,
162
+ `Relations ${document.relations.length}`,
163
+ `Findings ${document.provenance.findingsReviewed}`,
164
+ `Source ${document.provenance.source.name}`,
165
+ '',
166
+ 'Output is a bounded bridge projection; validate it against the receiving implementation before import.',
167
+ '',
168
+ ].join('\n');
169
+ }
@@ -0,0 +1,121 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { CliUsageError } from "./errors.mjs";
3
+ import { buildRegistryDoctorReport } from "./registry-doctor.mjs";
4
+ export const REGISTRY_COHORT_SCHEMA = 'whoisleuth.cli.registry-cohort';
5
+ export const REGISTRY_COHORT_VERSION = 1;
6
+ export const MAX_REGISTRY_COHORT_INPUT_BYTES = 16 * 1024 * 1024;
7
+ export const MAX_REGISTRY_COHORT_SAMPLES = 500;
8
+ export const MIN_REGISTRY_COHORT_SAMPLE = 5;
9
+ function record(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
11
+ }
12
+ function documents(text) {
13
+ if (Buffer.byteLength(text, 'utf8') > MAX_REGISTRY_COHORT_INPUT_BYTES) {
14
+ throw new CliUsageError(`Registry cohort input is limited to ${MAX_REGISTRY_COHORT_INPUT_BYTES} bytes.`);
15
+ }
16
+ const trimmed = text.replace(/^\uFEFF/u, '').trim();
17
+ if (!trimmed)
18
+ throw new CliUsageError('Registry cohort input is empty.');
19
+ let values;
20
+ if (trimmed.startsWith('[')) {
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(trimmed);
24
+ }
25
+ catch {
26
+ throw new CliUsageError('Registry cohort input must be valid JSON or JSONL.');
27
+ }
28
+ if (!Array.isArray(parsed))
29
+ throw new CliUsageError('Registry cohort JSON input must be an array.');
30
+ values = parsed;
31
+ }
32
+ else {
33
+ const lines = trimmed.split(/\r?\n/u).filter((line) => line.trim());
34
+ values = lines.map((line, index) => {
35
+ try {
36
+ return JSON.parse(line);
37
+ }
38
+ catch {
39
+ throw new CliUsageError(`Registry cohort JSONL line ${index + 1} is invalid.`);
40
+ }
41
+ });
42
+ }
43
+ if (!values.length || values.length > MAX_REGISTRY_COHORT_SAMPLES) {
44
+ throw new CliUsageError(`Registry cohort input must contain from 1 to ${MAX_REGISTRY_COHORT_SAMPLES} saved Lookups.`);
45
+ }
46
+ const output = values.map(record);
47
+ if (output.some((item) => !Object.keys(item).length))
48
+ throw new CliUsageError('Every registry cohort item must be one saved Lookup object.');
49
+ return output;
50
+ }
51
+ export function buildRegistryCohortReport(text, generatedAt = new Date().toISOString()) {
52
+ const reports = documents(text).map((item) => buildRegistryDoctorReport(JSON.stringify(item), generatedAt));
53
+ const groups = new Map();
54
+ for (const report of reports) {
55
+ const key = `${report.suffix}\u0000${report.profile.id}`;
56
+ const group = groups.get(key) ?? [];
57
+ group.push(report);
58
+ groups.set(key, group);
59
+ }
60
+ const cohorts = [...groups.values()].map((group) => {
61
+ const sample = group[0];
62
+ if (!sample)
63
+ throw new CliUsageError('Registry cohort grouping failed.');
64
+ const sampleCount = group.length;
65
+ const investigate = group.reduce((total, item) => total + item.summary.investigate, 0);
66
+ const publicationReviewItems = group.reduce((total, item) => total + item.publication.reviewItems, 0);
67
+ return Object.freeze({
68
+ suffix: sample.suffix,
69
+ profileId: sample.profile.id,
70
+ sampleCount,
71
+ state: sampleCount < MIN_REGISTRY_COHORT_SAMPLE
72
+ ? 'insufficient_sample'
73
+ : investigate || publicationReviewItems
74
+ ? 'review'
75
+ : 'consistent',
76
+ sourceAlignment: Object.freeze({
77
+ investigate,
78
+ expectedConstraints: group.reduce((total, item) => total + item.summary.expectedConstraints, 0),
79
+ observed: group.reduce((total, item) => total + item.summary.observed, 0),
80
+ }),
81
+ publication: Object.freeze({
82
+ reviewItems: publicationReviewItems,
83
+ objectIdentifiersObserved: group.filter((item) => item.publication.objectIdentifier.state === 'observed').length,
84
+ baseConformanceObserved: group.filter((item) => item.publication.baseConformance.state === 'observed').length,
85
+ selfLinksObserved: group.filter((item) => item.publication.selfLink.state === 'observed').length,
86
+ redactionMetadataObserved: group.filter((item) => item.publication.redactionMetadata.state === 'observed').length,
87
+ }),
88
+ });
89
+ }).sort((left, right) => left.suffix.localeCompare(right.suffix) || left.profileId.localeCompare(right.profileId));
90
+ return Object.freeze({
91
+ schema: REGISTRY_COHORT_SCHEMA,
92
+ version: REGISTRY_COHORT_VERSION,
93
+ generatedAt,
94
+ sampleCount: reports.length,
95
+ minimumCohortSample: MIN_REGISTRY_COHORT_SAMPLE,
96
+ cohorts: Object.freeze(cohorts),
97
+ limitations: Object.freeze([
98
+ 'The report intentionally omits domains, queries and raw evidence. It groups saved observations only by suffix and capability profile.',
99
+ `Cohorts with fewer than ${MIN_REGISTRY_COHORT_SAMPLE} observations remain insufficient sample and must not drive catalogue changes.`,
100
+ 'Repeated observations from one environment are not representative by themselves. Review fixture provenance and source health before changing a parser or access profile.',
101
+ ]),
102
+ });
103
+ }
104
+ export function formatRegistryCohortReport(report) {
105
+ const output = [
106
+ 'Registry quality cohort',
107
+ `Saved observations ${report.sampleCount}`,
108
+ `Minimum sample ${report.minimumCohortSample}`,
109
+ '',
110
+ ];
111
+ for (const cohort of report.cohorts) {
112
+ output.push(`.${cohort.suffix} · ${cohort.profileId} [${cohort.state.replaceAll('_', ' ')}]`);
113
+ output.push(` Samples: ${cohort.sampleCount}`);
114
+ output.push(` Source review items: ${cohort.sourceAlignment.investigate}`);
115
+ output.push(` Publication review items: ${cohort.publication.reviewItems}`);
116
+ }
117
+ output.push('', 'Limitations:');
118
+ for (const limitation of report.limitations)
119
+ output.push(` - ${limitation}`);
120
+ return `${output.join('\n')}\n`;
121
+ }
@@ -1,11 +1,9 @@
1
1
  import { registryCapabilityFor, REGISTRY_CAPABILITIES_VERSION } from "../lib/registry-capabilities.mjs";
2
+ import { recordOrEmpty } from "../lib/bounded-contract-normalizers.mjs";
2
3
  import { CliUsageError } from "./errors.mjs";
3
4
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
5
  const REGISTRY_DOCTOR_SCHEMA = 'whoisleuth.cli.registry-doctor';
5
- const REGISTRY_DOCTOR_VERSION = 1;
6
- function record(value) {
7
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
- }
6
+ const REGISTRY_DOCTOR_VERSION = 2;
9
7
  function status(value) {
10
8
  return typeof value === 'string'
11
9
  ? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().toLowerCase().slice(0, 40) || 'unavailable'
@@ -31,27 +29,114 @@ function normalizedObjectIdentifier(parsed) {
31
29
  const candidate = parsed.handle ?? parsed.objectIdentifier ?? parsed.registryObjectId ?? parsed.id;
32
30
  return typeof candidate === 'string' && candidate.trim() ? 'observed' : 'not_observed';
33
31
  }
32
+ function stringArray(value, limit = 100) {
33
+ return Array.isArray(value)
34
+ ? [...new Set(value.slice(0, limit).filter((item) => typeof item === 'string').map((item) => item.trim().toLowerCase()).filter(Boolean))]
35
+ : [];
36
+ }
37
+ function publicationQuality(parsed, rdapStatus) {
38
+ if (!['success', 'partial'].includes(rdapStatus)) {
39
+ const unavailable = { state: 'unavailable', value: null };
40
+ return {
41
+ objectClass: unavailable,
42
+ objectIdentifier: unavailable,
43
+ mediaType: unavailable,
44
+ baseConformance: { state: 'unavailable', declarations: [] },
45
+ redactionMetadata: { state: 'unavailable', count: 0, truncated: false },
46
+ selfLink: { state: 'unavailable', observedRelations: [] },
47
+ events: { state: 'unavailable', count: 0, conflictingActions: [] },
48
+ reviewItems: 0,
49
+ };
50
+ }
51
+ const objectClassName = typeof parsed.objectClassName === 'string' ? parsed.objectClassName.trim().toLowerCase() : '';
52
+ const identifier = [parsed.handle, parsed.objectIdentifier, parsed.registryObjectId, parsed.id]
53
+ .find((value) => typeof value === 'string' && Boolean(value.trim()))?.trim() ?? null;
54
+ const declarations = stringArray(parsed.conformance);
55
+ const redactions = Array.isArray(parsed.redactions) ? parsed.redactions.slice(0, 100) : [];
56
+ const links = Array.isArray(parsed.links) ? parsed.links.slice(0, 100).map(recordOrEmpty) : [];
57
+ const relations = [...new Set(links.flatMap((link) => stringArray(link.rel, 10).length
58
+ ? stringArray(link.rel, 10)
59
+ : typeof link.rel === 'string' ? [link.rel.trim().toLowerCase()] : []).filter(Boolean))];
60
+ const events = Array.isArray(parsed.events) ? parsed.events.slice(0, 100).map(recordOrEmpty) : [];
61
+ const actionDates = new Map();
62
+ for (const event of events) {
63
+ const action = typeof event.action === 'string' ? event.action.trim().toLowerCase() : '';
64
+ const date = typeof event.date === 'string' ? event.date.trim() : '';
65
+ if (!action || !date)
66
+ continue;
67
+ const dates = actionDates.get(action) ?? [];
68
+ dates.push(date);
69
+ actionDates.set(action, dates);
70
+ }
71
+ const lifecycle = recordOrEmpty(parsed.lifecycle);
72
+ const lifecycleMappings = [
73
+ ['registration', 'createdDateIso', false],
74
+ ['reregistration', 'reregistrationDateIso', true],
75
+ ['expiration', 'expiryDateIso', true],
76
+ ['last changed', 'updatedDateIso', true],
77
+ ['transfer', 'transferDateIso', true],
78
+ ['deletion', 'deletionDateIso', true],
79
+ ['reinstantiation', 'reinstantiationDateIso', true],
80
+ ['last update of rdap database', 'databaseUpdatedDateIso', true],
81
+ ];
82
+ const conflictingActions = lifecycleMappings.flatMap(([action, field, newest]) => {
83
+ const published = typeof lifecycle[field] === 'string' ? Date.parse(lifecycle[field]) : Number.NaN;
84
+ if (!Number.isFinite(published))
85
+ return [];
86
+ const dates = (actionDates.get(action) ?? []).map(Date.parse).filter(Number.isFinite);
87
+ if (!dates.length)
88
+ return [action];
89
+ const selected = newest ? Math.max(...dates) : Math.min(...dates);
90
+ return selected === published ? [] : [action];
91
+ }).sort();
92
+ const objectClassState = !objectClassName ? 'not_observed' : objectClassName === 'domain' ? 'observed' : 'inconsistent';
93
+ const baseConformanceState = declarations.includes('rdap_level_0') ? 'observed' : 'not_observed';
94
+ const selfLinkState = relations.includes('self') ? 'observed' : 'not_observed';
95
+ const hasLifecycle = lifecycleMappings.some(([, field]) => typeof lifecycle[field] === 'string');
96
+ const eventsState = conflictingActions.length
97
+ ? 'inconsistent'
98
+ : events.length && hasLifecycle
99
+ ? 'observed'
100
+ : events.length
101
+ ? 'unavailable'
102
+ : 'not_observed';
103
+ return {
104
+ objectClass: { state: objectClassState, value: objectClassName || null },
105
+ objectIdentifier: { state: identifier ? 'observed' : 'not_observed', value: identifier },
106
+ mediaType: { state: 'unavailable', value: null },
107
+ baseConformance: { state: baseConformanceState, declarations },
108
+ redactionMetadata: {
109
+ state: redactions.length ? 'observed' : 'not_observed',
110
+ count: redactions.length,
111
+ truncated: parsed.redactionsTruncated === true,
112
+ },
113
+ selfLink: { state: selfLinkState, observedRelations: relations.sort() },
114
+ events: { state: eventsState, count: events.length, conflictingActions },
115
+ reviewItems: [objectClassState, baseConformanceState, selfLinkState, eventsState]
116
+ .filter((state) => state === 'inconsistent' || state === 'not_observed').length,
117
+ };
118
+ }
34
119
  function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString()) {
35
120
  const document = parseSavedLookupDocument(raw, { label: 'Registry doctor input' });
36
121
  const capability = registryCapabilityFor(document.registrableDomain);
37
122
  if (!capability)
38
123
  throw new CliUsageError('Registry doctor could not resolve a capability profile for this domain.');
39
- const diagnostics = record(document.diagnostics);
40
- const rdap = record(document.rdap);
41
- const whois = record(document.whois);
42
- const rdapParsed = record(rdap.parsed);
43
- const whoisParsed = record(whois.parsed);
124
+ const diagnostics = recordOrEmpty(document.diagnostics);
125
+ const rdap = recordOrEmpty(document.rdap);
126
+ const whois = recordOrEmpty(document.whois);
127
+ const rdapParsed = recordOrEmpty(rdap.parsed);
128
+ const whoisParsed = recordOrEmpty(whois.parsed);
44
129
  const sourceInputs = [
45
130
  {
46
131
  source: 'rdap',
47
132
  expected: capability.rdapAccessProfile === 'iana-bootstrap' ? 'allowed' : 'unsupported',
48
- observed: status(record(diagnostics.rdap).status),
133
+ observed: status(recordOrEmpty(diagnostics.rdap).status),
49
134
  parsed: rdapParsed,
50
135
  },
51
136
  {
52
137
  source: 'whois',
53
138
  expected: expectedWhois(capability.whoisAccessProfile),
54
- observed: status(record(diagnostics.whois).status),
139
+ observed: status(recordOrEmpty(diagnostics.whois).status),
55
140
  parsed: whoisParsed,
56
141
  },
57
142
  ];
@@ -64,6 +149,7 @@ function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString())
64
149
  objectIdentifier: normalizedObjectIdentifier(source.parsed),
65
150
  limitation: capability.limitation.slice(0, 500),
66
151
  }));
152
+ const publication = publicationQuality(rdapParsed, sourceInputs[0]?.observed ?? 'unavailable');
67
153
  const recommendations = [
68
154
  ...sources.filter((source) => source.alignment === 'investigate').map((source) => (source.expected === 'allowed'
69
155
  ? `Review ${source.source.toUpperCase()} source health, transport attempts, and fixture coverage; the catalogue expected collection to be available.`
@@ -83,6 +169,7 @@ function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString())
83
169
  explicitSuffixProfile: capability.explicitSuffixProfile,
84
170
  coverageState: capability.coverageState,
85
171
  },
172
+ publication,
86
173
  sources,
87
174
  summary: {
88
175
  investigate: sources.filter((source) => source.alignment === 'investigate' || source.alignment === 'unexpected_observation').length,
@@ -94,6 +181,7 @@ function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString())
94
181
  'This diagnostic compares one saved observation with the local reviewed capability catalogue and makes no registry, registrar, RDAP, or WHOIS request.',
95
182
  'A catalogue alignment does not prove live reachability, parser completeness, current registry policy, registration, availability, ownership, safety, or maliciousness.',
96
183
  'A missing registry object identifier can be a publication characteristic. It remains not observed and is not converted into a collection failure.',
184
+ 'Publication-quality observations describe the saved normalised RDAP object. The saved Lookup format does not retain the response media type, so that field remains unavailable.',
97
185
  'Use fixture-based maintenance checks before changing a registry parser or access profile; automated tests must not contact live registries.',
98
186
  ],
99
187
  };
@@ -112,6 +200,13 @@ function formatRegistryDoctorReport(report) {
112
200
  output.push(` Observed: ${source.observed.replaceAll('_', ' ')}`);
113
201
  output.push(` Object identifier: ${source.objectIdentifier.replaceAll('_', ' ')}`);
114
202
  }
203
+ output.push('', 'RDAP publication quality');
204
+ output.push(` Object class: ${report.publication.objectClass.state.replaceAll('_', ' ')}`);
205
+ output.push(` Object identifier: ${report.publication.objectIdentifier.state.replaceAll('_', ' ')}`);
206
+ output.push(` Base conformance: ${report.publication.baseConformance.state.replaceAll('_', ' ')}`);
207
+ output.push(` Self link: ${report.publication.selfLink.state.replaceAll('_', ' ')}`);
208
+ output.push(` Events: ${report.publication.events.state.replaceAll('_', ' ')}`);
209
+ output.push(' Media type: unavailable in the saved Lookup contract');
115
210
  if (report.recommendations.length) {
116
211
  output.push('', 'Recommendations:');
117
212
  for (const item of report.recommendations)
@@ -0,0 +1,73 @@
1
+ import { registryCompatibilityMatrix } from "../lib/registry-capabilities.mjs";
2
+ const SAFE_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
3
+ const SAFE_SUFFIX_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
4
+ const SCENARIOS = new Set(['registered', 'not_found', 'inconclusive']);
5
+ export function buildRegistryFixtureScaffold(profileIdInput, suffixInput, scenarioInput) {
6
+ const profileId = typeof profileIdInput === 'string' ? profileIdInput.toLowerCase() : '';
7
+ const suffix = typeof suffixInput === 'string' ? suffixInput.toLowerCase().replace(/^\./u, '') : '';
8
+ const scenario = typeof scenarioInput === 'string' ? scenarioInput.toLowerCase() : '';
9
+ if (!SAFE_ID_RE.test(profileId))
10
+ throw new TypeError('The capability profile is invalid.');
11
+ if (!SAFE_SUFFIX_RE.test(suffix))
12
+ throw new TypeError('The suffix is invalid.');
13
+ if (!SCENARIOS.has(scenario))
14
+ throw new TypeError('Scenario must be registered, not_found, or inconclusive.');
15
+ const profile = registryCompatibilityMatrix().find((item) => item.id === profileId);
16
+ if (!profile)
17
+ throw new TypeError('The capability profile is not present in the registry catalogue.');
18
+ if (!profile.suffixes.includes(suffix))
19
+ throw new TypeError(`The capability profile does not cover .${suffix}.`);
20
+ const domain = `EXAMPLE.${suffix.toUpperCase()}`;
21
+ const response = scenario === 'registered'
22
+ ? [
23
+ `Domain Name: ${domain}`,
24
+ 'Registry Domain ID: EXAMPLE-SYNTHETIC-ID',
25
+ 'Registrar: Example Registrar',
26
+ 'Creation Date: 2020-01-02T03:04:05Z',
27
+ 'Updated Date: 2024-05-06T07:08:09Z',
28
+ 'Registry Expiry Date: 2030-01-02T03:04:05Z',
29
+ 'Domain Status: active',
30
+ 'Registrant Organization: Example Organisation',
31
+ 'Registrant Email: registrant@example.invalid',
32
+ 'Name Server: NS1.EXAMPLE.INVALID',
33
+ 'DNSSEC: unsigned',
34
+ ]
35
+ : scenario === 'not_found'
36
+ ? ['REPLACE WITH A SYNTHETIC AUTHORITATIVE NO-RECORD DIALECT']
37
+ : ['REPLACE WITH A SYNTHETIC RESTRICTED, RATE-LIMITED, OR TEMPORARY-FAILURE DIALECT'];
38
+ const expected = scenario === 'registered'
39
+ ? {
40
+ registrationStatus: 'registered',
41
+ domainName: domain,
42
+ registrar: 'Example Registrar',
43
+ registrantOrg: 'Example Organisation',
44
+ registrantEmail: 'registrant@example.invalid',
45
+ nameservers: ['NS1.EXAMPLE.INVALID'],
46
+ }
47
+ : scenario === 'not_found'
48
+ ? { registrationStatus: 'not_found', notFound: true }
49
+ : { registrationStatus: 'inconclusive', notFound: false };
50
+ return [
51
+ '// Synthetic scaffold only. Do not paste a live WHOIS response or personal registration data.',
52
+ '// Preserve the registry dialect while replacing domains, contacts, handles, addresses, phones,',
53
+ '// nameservers, dates, identifiers, and free text with reserved or fictional values.',
54
+ '{',
55
+ ` name: ${JSON.stringify(`.${suffix} ${scenario} synthetic response`)},`,
56
+ ` capabilityProfile: ${JSON.stringify(profileId)},`,
57
+ ` scenario: ${JSON.stringify(scenario)},`,
58
+ ' chain: [',
59
+ ` rootHop(${JSON.stringify(suffix.toUpperCase())}, ${JSON.stringify(`whois.${suffix}.invalid`)}),`,
60
+ ` registryHop(${JSON.stringify(`whois.${suffix}.invalid`)}, ${JSON.stringify(response, null, 2).replace(/\n/gu, '\n ')}),`,
61
+ ' ],',
62
+ ` expected: ${JSON.stringify(expected, null, 2).replace(/\n/gu, '\n ')},`,
63
+ '},',
64
+ '',
65
+ '// Review checklist:',
66
+ '// - every domain and hostname uses the requested suffix or .invalid;',
67
+ '// - every email ends in .invalid; IP examples use documentation ranges;',
68
+ '// - no real name, address, phone, handle, identifier, or free-text notice remains;',
69
+ '// - authoritative absence is asserted only for a documented registry response;',
70
+ '// - add or refresh the tracked provenance digest after review.',
71
+ '',
72
+ ].join('\n');
73
+ }