@slicedearth/whoisleuth-cli 1.36.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 (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  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/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -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
+ }
@@ -0,0 +1,220 @@
1
+ import { registryCapabilityFor, REGISTRY_CAPABILITIES_VERSION } from "../lib/registry-capabilities.mjs";
2
+ import { recordOrEmpty } from "../lib/bounded-contract-normalizers.mjs";
3
+ import { CliUsageError } from "./errors.mjs";
4
+ import { parseSavedLookupDocument } from "./saved-lookup.mjs";
5
+ const REGISTRY_DOCTOR_SCHEMA = 'whoisleuth.cli.registry-doctor';
6
+ const REGISTRY_DOCTOR_VERSION = 2;
7
+ function status(value) {
8
+ return typeof value === 'string'
9
+ ? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().toLowerCase().slice(0, 40) || 'unavailable'
10
+ : 'unavailable';
11
+ }
12
+ function expectedWhois(profile) {
13
+ if (profile === 'iana-referral')
14
+ return 'allowed';
15
+ if (profile === 'registry-policy-restricted' || profile === 'source-ip-authorization-required')
16
+ return 'permission_required';
17
+ return 'unsupported';
18
+ }
19
+ function sourceAlignment(expected, observed) {
20
+ const successful = ['complete', 'partial', 'success'].includes(observed);
21
+ const constrained = ['disabled', 'skipped', 'unsupported'].includes(observed);
22
+ if (expected === 'allowed')
23
+ return successful ? 'observed' : 'investigate';
24
+ if (successful)
25
+ return 'unexpected_observation';
26
+ return constrained ? 'expected_constraint' : 'investigate';
27
+ }
28
+ function normalizedObjectIdentifier(parsed) {
29
+ const candidate = parsed.handle ?? parsed.objectIdentifier ?? parsed.registryObjectId ?? parsed.id;
30
+ return typeof candidate === 'string' && candidate.trim() ? 'observed' : 'not_observed';
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
+ }
119
+ function buildRegistryDoctorReport(raw, generatedAt = new Date().toISOString()) {
120
+ const document = parseSavedLookupDocument(raw, { label: 'Registry doctor input' });
121
+ const capability = registryCapabilityFor(document.registrableDomain);
122
+ if (!capability)
123
+ throw new CliUsageError('Registry doctor could not resolve a capability profile for this domain.');
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);
129
+ const sourceInputs = [
130
+ {
131
+ source: 'rdap',
132
+ expected: capability.rdapAccessProfile === 'iana-bootstrap' ? 'allowed' : 'unsupported',
133
+ observed: status(recordOrEmpty(diagnostics.rdap).status),
134
+ parsed: rdapParsed,
135
+ },
136
+ {
137
+ source: 'whois',
138
+ expected: expectedWhois(capability.whoisAccessProfile),
139
+ observed: status(recordOrEmpty(diagnostics.whois).status),
140
+ parsed: whoisParsed,
141
+ },
142
+ ];
143
+ const sources = sourceInputs.map((source) => ({
144
+ source: source.source,
145
+ expected: source.expected,
146
+ observed: source.observed,
147
+ alignment: sourceAlignment(source.expected, source.observed),
148
+ normalizedFields: Object.keys(source.parsed).length,
149
+ objectIdentifier: normalizedObjectIdentifier(source.parsed),
150
+ limitation: capability.limitation.slice(0, 500),
151
+ }));
152
+ const publication = publicationQuality(rdapParsed, sourceInputs[0]?.observed ?? 'unavailable');
153
+ const recommendations = [
154
+ ...sources.filter((source) => source.alignment === 'investigate').map((source) => (source.expected === 'allowed'
155
+ ? `Review ${source.source.toUpperCase()} source health, transport attempts, and fixture coverage; the catalogue expected collection to be available.`
156
+ : `Review the ${source.source.toUpperCase()} result and capability profile; the observed state did not match the declared access constraint.`)),
157
+ ...sources.filter((source) => source.alignment === 'unexpected_observation').map((source) => (`Review why ${source.source.toUpperCase()} produced normalized evidence despite the declared ${source.expected.replaceAll('_', ' ')} access profile.`)),
158
+ ...sources.filter((source) => source.alignment === 'observed' && source.objectIdentifier === 'not_observed').map((source) => (`${source.source.toUpperCase()} succeeded without a retained registry object identifier; this can be a valid publication omission and should remain unknown rather than an error.`)),
159
+ ].slice(0, 12);
160
+ return {
161
+ schema: REGISTRY_DOCTOR_SCHEMA,
162
+ version: REGISTRY_DOCTOR_VERSION,
163
+ generatedAt,
164
+ domain: document.registrableDomain,
165
+ suffix: capability.suffixes[0] || document.registrableDomain.split('.').at(-1) || '',
166
+ catalogueVersion: REGISTRY_CAPABILITIES_VERSION,
167
+ profile: {
168
+ id: capability.id,
169
+ explicitSuffixProfile: capability.explicitSuffixProfile,
170
+ coverageState: capability.coverageState,
171
+ },
172
+ publication,
173
+ sources,
174
+ summary: {
175
+ investigate: sources.filter((source) => source.alignment === 'investigate' || source.alignment === 'unexpected_observation').length,
176
+ expectedConstraints: sources.filter((source) => source.alignment === 'expected_constraint').length,
177
+ observed: sources.filter((source) => source.alignment === 'observed').length,
178
+ },
179
+ recommendations,
180
+ limitations: [
181
+ 'This diagnostic compares one saved observation with the local reviewed capability catalogue and makes no registry, registrar, RDAP, or WHOIS request.',
182
+ 'A catalogue alignment does not prove live reachability, parser completeness, current registry policy, registration, availability, ownership, safety, or maliciousness.',
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.',
185
+ 'Use fixture-based maintenance checks before changing a registry parser or access profile; automated tests must not contact live registries.',
186
+ ],
187
+ };
188
+ }
189
+ function formatRegistryDoctorReport(report) {
190
+ const output = [
191
+ 'Registry compatibility diagnostic',
192
+ `Domain ${report.domain}`,
193
+ `Profile ${report.profile.id}${report.profile.explicitSuffixProfile ? ' (explicit)' : ' (default)'}`,
194
+ `Review items ${report.summary.investigate}`,
195
+ '',
196
+ ];
197
+ for (const source of report.sources) {
198
+ output.push(`${source.source.toUpperCase()} [${source.alignment.replaceAll('_', ' ')}]`);
199
+ output.push(` Expected: ${source.expected.replaceAll('_', ' ')}`);
200
+ output.push(` Observed: ${source.observed.replaceAll('_', ' ')}`);
201
+ output.push(` Object identifier: ${source.objectIdentifier.replaceAll('_', ' ')}`);
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');
210
+ if (report.recommendations.length) {
211
+ output.push('', 'Recommendations:');
212
+ for (const item of report.recommendations)
213
+ output.push(` - ${item}`);
214
+ }
215
+ output.push('', 'Limitations:');
216
+ for (const limitation of report.limitations)
217
+ output.push(` - ${limitation}`);
218
+ return `${output.join('\n')}\n`;
219
+ }
220
+ export { REGISTRY_DOCTOR_SCHEMA, REGISTRY_DOCTOR_VERSION, buildRegistryDoctorReport, formatRegistryDoctorReport, };
@@ -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
+ }
@@ -1,11 +1,13 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { isValidAsciiDomainName } from "../lib/hostname.mjs";
3
+ import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../lib/analyst-taxonomy.mjs";
3
4
  import { CliUsageError } from "./arguments.mjs";
4
5
  import { RISK_MUTATION_TYPES, RISK_REVIEW_THRESHOLD } from "../lib/risk-scoring.mjs";
5
6
  export const RISK_CALIBRATION_DATASET_SCHEMA = 'whoisleuth.risk-calibration-dataset';
6
- export const RISK_CALIBRATION_DATASET_VERSION = 1;
7
+ export const RISK_CALIBRATION_DATASET_VERSION = 2;
8
+ export const SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS = Object.freeze([1, RISK_CALIBRATION_DATASET_VERSION]);
7
9
  export const RISK_CALIBRATION_REPORT_SCHEMA = 'whoisleuth.cli.risk-calibration';
8
- export const RISK_CALIBRATION_REPORT_VERSION = 1;
10
+ export const RISK_CALIBRATION_REPORT_VERSION = 2;
9
11
  export const MAX_RISK_CALIBRATION_INPUT_BYTES = 2 * 1024 * 1024;
10
12
  export const MAX_RISK_CALIBRATION_RECORDS = 500;
11
13
  export const MAX_RISK_CALIBRATION_STRING_LENGTH = 256;
@@ -24,8 +26,10 @@ const AVAILABILITY_STATES = new Set(['registered', 'for_sale', 'expiring', 'avai
24
26
  const ACTIVITY_STATES = new Set(['active', 'parked', 'unreachable', 'no_site']);
25
27
  const BOOLEAN_FIELDS = [
26
28
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField',
27
- 'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected',
29
+ 'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected', 'hasExternalFormAction',
30
+ 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
28
31
  ];
32
+ const REVIEW_REASON_CODES = ANALYST_REVIEW_REASON_VALUES;
29
33
  const MUTATION_TYPES = new Set(RISK_MUTATION_TYPES);
30
34
  function object(value, field) {
31
35
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -109,6 +113,17 @@ function projectEvidence(value, field) {
109
113
  throw new CliUsageError(`${field}.activityStatus is unsupported.`);
110
114
  result.activityStatus = activity;
111
115
  }
116
+ if (source.scanDepth !== null && source.scanDepth !== undefined) {
117
+ const depth = boundedString(source.scanDepth, `${field}.scanDepth`, 16);
118
+ if (depth !== 'fast' && depth !== 'deep')
119
+ throw new CliUsageError(`${field}.scanDepth must be fast or deep.`);
120
+ Object.assign(result, { scanDepth: depth });
121
+ }
122
+ if (source.observedAt !== null && source.observedAt !== undefined) {
123
+ const observedAt = optionalTimestamp(source.observedAt, `${field}.observedAt`);
124
+ if (observedAt)
125
+ Object.assign(result, { observedAt });
126
+ }
112
127
  if (source.phishingLanguageMatch !== null && source.phishingLanguageMatch !== undefined) {
113
128
  result.phishingLanguageMatch = boundedString(source.phishingLanguageMatch, `${field}.phishingLanguageMatch`);
114
129
  }
@@ -164,8 +179,13 @@ export function parseRiskCalibrationDataset(text) {
164
179
  throw new CliUsageError('Risk calibration input must be valid JSON.');
165
180
  }
166
181
  const document = object(parsed, 'Risk calibration input');
167
- if (document.schema !== RISK_CALIBRATION_DATASET_SCHEMA || document.version !== RISK_CALIBRATION_DATASET_VERSION) {
168
- throw new CliUsageError(`Risk calibration input must use ${RISK_CALIBRATION_DATASET_SCHEMA} version ${RISK_CALIBRATION_DATASET_VERSION}.`);
182
+ const documentVersion = typeof document.version === 'number' && Number.isInteger(document.version)
183
+ ? document.version
184
+ : null;
185
+ if (document.schema !== RISK_CALIBRATION_DATASET_SCHEMA
186
+ || documentVersion === null
187
+ || !SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.includes(documentVersion)) {
188
+ throw new CliUsageError(`Risk calibration input must use ${RISK_CALIBRATION_DATASET_SCHEMA} version ${SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.join(' or ')}.`);
169
189
  }
170
190
  if (!Array.isArray(document.records) || !document.records.length) {
171
191
  throw new CliUsageError('Risk calibration input must contain a non-empty records array.');
@@ -187,22 +207,39 @@ export function parseRiskCalibrationDataset(text) {
187
207
  const analystDisposition = boundedString(record.analystDisposition, `${prefix}.analystDisposition`, 32);
188
208
  if (!DISPOSITIONS.has(analystDisposition))
189
209
  throw new CliUsageError(`${prefix}.analystDisposition is unsupported.`);
210
+ let reviewReasonCode;
211
+ if (record.reviewReasonCode !== null && record.reviewReasonCode !== undefined) {
212
+ reviewReasonCode = boundedString(record.reviewReasonCode, `${prefix}.reviewReasonCode`, 64);
213
+ if (!REVIEW_REASON_CODES.has(reviewReasonCode))
214
+ throw new CliUsageError(`${prefix}.reviewReasonCode is unsupported.`);
215
+ }
190
216
  return {
191
217
  id,
192
218
  domain,
193
219
  analystDisposition: analystDisposition,
220
+ ...(reviewReasonCode ? { reviewReasonCode } : {}),
194
221
  evidence: projectEvidence(record.evidence, `${prefix}.evidence`),
195
222
  };
196
223
  });
197
224
  return {
198
225
  schema: RISK_CALIBRATION_DATASET_SCHEMA,
199
- version: RISK_CALIBRATION_DATASET_VERSION,
226
+ version: documentVersion,
200
227
  records,
201
228
  };
202
229
  }
203
230
  function ratio(numerator, denominator) {
204
231
  return denominator ? Number((numerator / denominator).toFixed(4)) : null;
205
232
  }
233
+ function wilson95(successes, total) {
234
+ if (!total)
235
+ return null;
236
+ const z = 1.96;
237
+ const proportion = successes / total;
238
+ const denominator = 1 + (z * z) / total;
239
+ const center = (proportion + (z * z) / (2 * total)) / denominator;
240
+ const margin = (z * Math.sqrt((proportion * (1 - proportion) + (z * z) / (4 * total)) / total)) / denominator;
241
+ return { lower: Number(Math.max(0, center - margin).toFixed(4)), upper: Number(Math.min(1, center + margin).toFixed(4)) };
242
+ }
206
243
  function metricsForThreshold(records, threshold) {
207
244
  let truePositive = 0;
208
245
  let falsePositive = 0;
@@ -217,16 +254,30 @@ function metricsForThreshold(records, threshold) {
217
254
  else
218
255
  flagged ? falsePositive += 1 : trueNegative += 1;
219
256
  }
257
+ const precision = ratio(truePositive, truePositive + falsePositive);
258
+ const recall = ratio(truePositive, truePositive + falseNegative);
259
+ const specificity = ratio(trueNegative, trueNegative + falsePositive);
220
260
  return {
221
261
  threshold,
222
262
  truePositive,
223
263
  falsePositive,
224
264
  trueNegative,
225
265
  falseNegative,
226
- precision: ratio(truePositive, truePositive + falsePositive),
227
- recall: ratio(truePositive, truePositive + falseNegative),
228
- specificity: ratio(trueNegative, trueNegative + falsePositive),
266
+ precision,
267
+ recall,
268
+ specificity,
229
269
  falsePositiveRate: ratio(falsePositive, falsePositive + trueNegative),
270
+ f1: precision === null || recall === null || precision + recall === 0
271
+ ? null
272
+ : Number(((2 * precision * recall) / (precision + recall)).toFixed(4)),
273
+ balancedAccuracy: recall === null || specificity === null
274
+ ? null
275
+ : Number(((recall + specificity) / 2).toFixed(4)),
276
+ confidence95: {
277
+ precision: wilson95(truePositive, truePositive + falsePositive),
278
+ recall: wilson95(truePositive, truePositive + falseNegative),
279
+ specificity: wilson95(trueNegative, trueNegative + falsePositive),
280
+ },
230
281
  };
231
282
  }
232
283
  function metricClass(disposition) {
@@ -254,6 +305,8 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
254
305
  id: record.id,
255
306
  domain: record.domain,
256
307
  analystDisposition: record.analystDisposition,
308
+ reviewReasonCode: record.reviewReasonCode ?? null,
309
+ interoperabilityTags: analystInteroperabilityTags(record.analystDisposition, record.reviewReasonCode),
257
310
  metricClass: classification,
258
311
  includedInMetrics,
259
312
  exclusionReason: includedInMetrics
@@ -273,6 +326,52 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
273
326
  const positive = records.filter((record) => record.metricClass === 'positive' && record.score !== null).length;
274
327
  const negative = records.filter((record) => record.metricClass === 'negative' && record.score !== null).length;
275
328
  const excluded = records.length - positive - negative;
329
+ const strata = [];
330
+ const appendStrata = (dimension, values) => {
331
+ for (const [value, members] of [...values].sort(([left], [right]) => left.localeCompare(right))) {
332
+ const sampleCount = members.filter((member) => member.includedInMetrics).length;
333
+ strata.push({
334
+ dimension,
335
+ value,
336
+ sampleCount,
337
+ insufficientSample: sampleCount < 20,
338
+ metrics: metricsForThreshold(members, options.reviewThreshold),
339
+ });
340
+ }
341
+ };
342
+ const byDepth = new Map();
343
+ const byReason = new Map();
344
+ for (let index = 0; index < dataset.records.length; index += 1) {
345
+ const source = dataset.records[index];
346
+ const scored = records[index];
347
+ if (!source || !scored)
348
+ continue;
349
+ const depth = source.evidence.scanDepth === 'deep' || source.evidence.scanDepth === 'fast' ? source.evidence.scanDepth : 'unknown';
350
+ byDepth.set(depth, [...(byDepth.get(depth) ?? []), scored]);
351
+ const reason = source.reviewReasonCode ?? 'not_recorded';
352
+ byReason.set(reason, [...(byReason.get(reason) ?? []), scored]);
353
+ }
354
+ appendStrata('scan_depth', byDepth);
355
+ appendStrata('review_reason', byReason);
356
+ let scoresChanged = 0;
357
+ let bandsChanged = 0;
358
+ let thresholdClassificationsChanged = 0;
359
+ if (options.explainPreviousRiskScore) {
360
+ for (let index = 0; index < dataset.records.length; index += 1) {
361
+ const source = dataset.records[index];
362
+ const current = records[index];
363
+ if (!source || !current)
364
+ continue;
365
+ const previous = options.explainPreviousRiskScore(source.evidence);
366
+ if (previous?.score !== current.score)
367
+ scoresChanged += 1;
368
+ if (scoreBand(previous?.score ?? null) !== current.band)
369
+ bandsChanged += 1;
370
+ if (((previous?.score ?? -1) >= options.reviewThreshold) !== ((current.score ?? -1) >= options.reviewThreshold)) {
371
+ thresholdClassificationsChanged += 1;
372
+ }
373
+ }
374
+ }
276
375
  return {
277
376
  schema: RISK_CALIBRATION_REPORT_SCHEMA,
278
377
  version: RISK_CALIBRATION_REPORT_VERSION,
@@ -282,6 +381,15 @@ export function buildRiskCalibrationReport(dataset, explainRiskScore, options) {
282
381
  currentReviewThreshold: options.reviewThreshold,
283
382
  summary: { total: records.length, positive, negative, excluded, scoreBands: bands },
284
383
  thresholds: RISK_CALIBRATION_THRESHOLDS.map((threshold) => metricsForThreshold(records, threshold)),
384
+ strata,
385
+ modelComparison: {
386
+ available: Boolean(options.explainPreviousRiskScore),
387
+ previousModelVersion: options.explainPreviousRiskScore ? options.previousModelVersion ?? null : null,
388
+ currentModelVersion: options.modelVersion,
389
+ scoresChanged,
390
+ bandsChanged,
391
+ thresholdClassificationsChanged,
392
+ },
285
393
  records,
286
394
  interpretation: {
287
395
  authority: 'analyst_context_only',