@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 (90) hide show
  1. package/README.md +23 -4
  2. package/bin/whoisleuth.mjs +31 -9
  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 +72 -39
  7. package/cli/bulk-command-runner.mjs +34 -4
  8. package/cli/bulk.mjs +18 -7
  9. package/cli/case-pack.mjs +136 -0
  10. package/cli/ci-report.mjs +73 -0
  11. package/cli/collection-preflight.mjs +59 -0
  12. package/cli/command-reference.mjs +395 -0
  13. package/cli/completion.mjs +67 -13
  14. package/cli/config-profile.mjs +183 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-observation-snapshot.mjs +8 -14
  17. package/cli/discovery-scan-command-runner.mjs +29 -2
  18. package/cli/discovery-snapshot.mjs +8 -15
  19. package/cli/domain-control-monitor.mjs +104 -0
  20. package/cli/domain-control-observations.mjs +235 -0
  21. package/cli/errors.mjs +7 -1
  22. package/cli/evidence-command-arguments.mjs +11 -0
  23. package/cli/evidence-command-runner.mjs +1 -0
  24. package/cli/external-observation-mapping.mjs +162 -0
  25. package/cli/fail-policy.mjs +64 -0
  26. package/cli/formatters/html.mjs +1 -1
  27. package/cli/formatters/json.mjs +12 -4
  28. package/cli/formatters/terminal.mjs +1 -1
  29. package/cli/http.mjs +2 -1
  30. package/cli/investigation-manifest.mjs +113 -0
  31. package/cli/investigation-run.mjs +112 -0
  32. package/cli/lookup-brief.mjs +116 -0
  33. package/cli/lookup-command-runner.mjs +8 -1
  34. package/cli/lookup-diff.mjs +17 -19
  35. package/cli/lookup-reconcile.mjs +1 -1
  36. package/cli/mail-review.mjs +5 -5
  37. package/cli/manual.mjs +1 -1
  38. package/cli/network-command-runner.mjs +4 -1
  39. package/cli/offline-evidence-review.mjs +105 -8
  40. package/cli/open-asset-model-bridge.mjs +169 -0
  41. package/cli/registry-cohort.mjs +121 -0
  42. package/cli/registry-doctor.mjs +106 -11
  43. package/cli/registry-fixture-scaffold.mjs +74 -0
  44. package/cli/runner.mjs +388 -315
  45. package/docs/cli-reference.md +266 -21
  46. package/docs/cli.md +43 -7
  47. package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
  48. package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
  49. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  50. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  51. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  52. package/frontend/src/lib/analysis/case-report.js +573 -0
  53. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  54. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  55. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  56. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  57. package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
  58. package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
  59. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  60. package/frontend/src/lib/analysis/scoring.js +1 -1
  61. package/frontend/src/lib/analysis/utils.js +1 -1
  62. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  63. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  64. package/frontend/src/lib/analysis/workspace-archive.js +5 -7
  65. package/lib/abusech-auth.mjs +1 -1
  66. package/lib/availability.mjs +1 -1
  67. package/lib/bounded-contract-normalizers.mjs +33 -0
  68. package/lib/bounded-file.mjs +69 -0
  69. package/lib/bounded-json-response.mjs +157 -0
  70. package/lib/browser-library-profile.mjs +167 -38
  71. package/lib/dns-convergence-review.mjs +183 -0
  72. package/lib/dns-delegation-health.mjs +196 -3
  73. package/lib/domain-change-packet.mjs +133 -0
  74. package/lib/domain-change-review.mjs +307 -0
  75. package/lib/domain-control-flight-recorder.mjs +268 -0
  76. package/lib/domain-portfolio-review.mjs +151 -0
  77. package/lib/evidence-export.mjs +1 -1
  78. package/lib/html-signals.mjs +5 -11
  79. package/lib/lookup-cache.mjs +5 -1
  80. package/lib/nameserver-preflight-review.mjs +159 -0
  81. package/lib/page-language-signals.mjs +124 -0
  82. package/lib/rdap-transport.mjs +6 -6
  83. package/lib/risk-scoring.mjs +42 -1
  84. package/lib/safe-fetch.mjs +37 -32
  85. package/lib/static-html-analysis.mjs +29 -1
  86. package/lib/threat-intelligence-runtime.mjs +1 -2
  87. package/lib/trust-store-comparison.mjs +146 -0
  88. package/lib/website-technology.mjs +22 -12
  89. package/lib/zone-intent-review.mjs +534 -0
  90. package/package.json +1 -1
@@ -11,6 +11,8 @@ import { formatTerminalLookup } from "./formatters/terminal.mjs";
11
11
  import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
12
12
  import { createCliProgressEvents } from "./progress-events.mjs";
13
13
  import { lookupStrictExitFindings } from "./strict-exit.mjs";
14
+ import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
15
+ import { formatCliJunit } from "./ci-report.mjs";
14
16
  async function runLookupCommand(args, dependencies, context) {
15
17
  const eventProgress = createCliProgressEvents(context.stderr, {
16
18
  command: 'lookup',
@@ -75,6 +77,8 @@ async function runLookupCommand(args, dependencies, context) {
75
77
  if (!args.quiet) {
76
78
  if (args.output === 'json')
77
79
  context.writeStdout(formatJsonDocument(document));
80
+ else if (args.output === 'junit')
81
+ context.writeStdout(formatCliJunit(document));
78
82
  else if (args.output === 'markdown' || args.output === 'html') {
79
83
  const loadEvidence = dependencies.loadEvidenceExport || (() => import("../lib/evidence-export.mjs"));
80
84
  const evidenceModule = await loadEvidence();
@@ -88,10 +92,13 @@ async function runLookupCommand(args, dependencies, context) {
88
92
  }
89
93
  }
90
94
  const strictFindings = args.strictExit ? lookupStrictExitFindings(document) : [];
91
- const exitCode = strictFindings.length ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
95
+ const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
96
+ const exitCode = strictFindings.length || policyFindings.length ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
92
97
  if (strictFindings.length && !args.events) {
93
98
  context.writeStderr(`Strict exit: ${strictFindings.length} requested source state${strictFindings.length === 1 ? '' : 's'} were incomplete.\n`);
94
99
  }
100
+ if (policyFindings.length && !args.events)
101
+ context.writeStderr(formatFailPolicyNotice(policyFindings));
95
102
  eventProgress.emit({ event: 'completed', exitCode });
96
103
  return exitCode;
97
104
  }
@@ -1,13 +1,11 @@
1
1
  import { buildBulkComparisonEvidence } from "../lib/bulk-comparison-evidence.mjs";
2
+ import { recordOrEmpty } from "../lib/bounded-contract-normalizers.mjs";
2
3
  import { buildBulkDomainComparison, } from "../frontend/src/lib/analysis/bulk-domain-comparison.js";
3
4
  import { relationshipObservation } from "../frontend/src/lib/analysis/relationship-evidence.js";
4
5
  import { CliUsageError } from "./errors.mjs";
5
6
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
6
7
  export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
7
8
  export const CLI_LOOKUP_DIFF_VERSION = 1;
8
- function record(value) {
9
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
- }
11
9
  function text(value, maximum = 300) {
12
10
  if (typeof value !== 'string')
13
11
  return null;
@@ -23,14 +21,14 @@ function stringList(value, maximum = 100) {
23
21
  }))].slice(0, maximum);
24
22
  }
25
23
  function lifecycleValue(parsed, field) {
26
- const lifecycle = record(parsed.lifecycle);
24
+ const lifecycle = recordOrEmpty(parsed.lifecycle);
27
25
  return text(lifecycle[`${field}Iso`] ?? lifecycle[field] ?? parsed[`${field}Iso`] ?? parsed[field], 64);
28
26
  }
29
27
  function registrarLabel(parsed) {
30
28
  const registrar = parsed.registrar;
31
29
  if (typeof registrar === 'string')
32
30
  return text(registrar);
33
- const source = record(registrar);
31
+ const source = recordOrEmpty(registrar);
34
32
  return text(source.name ?? source.org ?? source.handle);
35
33
  }
36
34
  function sourceState(value) {
@@ -50,25 +48,25 @@ function sourceState(value) {
50
48
  return 'unavailable';
51
49
  }
52
50
  function sourceCoverage(document, availability) {
53
- const diagnostics = record(document.diagnostics);
51
+ const diagnostics = recordOrEmpty(document.diagnostics);
54
52
  const entries = [
55
- ['rdap', record(diagnostics.rdap).status],
56
- ['whois', record(diagnostics.whois).status],
57
- ['availability', record(diagnostics.availability).status],
58
- ['dns', record(availability.dns).status],
59
- ['http', record(availability.http).status],
60
- ['tls', record(availability.tls).status],
53
+ ['rdap', recordOrEmpty(diagnostics.rdap).status],
54
+ ['whois', recordOrEmpty(diagnostics.whois).status],
55
+ ['availability', recordOrEmpty(diagnostics.availability).status],
56
+ ['dns', recordOrEmpty(availability.dns).status],
57
+ ['http', recordOrEmpty(availability.http).status],
58
+ ['tls', recordOrEmpty(availability.tls).status],
61
59
  ];
62
60
  return entries.map(([source, status]) => ({ source, state: sourceState(status) }));
63
61
  }
64
62
  function lookupComparisonInput(document) {
65
- const availability = record(document.availability);
66
- const rdapParsed = record(record(document.rdap).parsed);
67
- const whoisParsed = record(record(document.whois).parsed);
63
+ const availability = recordOrEmpty(document.availability);
64
+ const rdapParsed = recordOrEmpty(recordOrEmpty(document.rdap).parsed);
65
+ const whoisParsed = recordOrEmpty(recordOrEmpty(document.whois).parsed);
68
66
  const preferredRegistry = Object.keys(rdapParsed).length ? rdapParsed : whoisParsed;
69
67
  const relationship = relationshipObservation(availability);
70
- const dns = record(availability.dns);
71
- const dnsRecords = record(dns.records);
68
+ const dns = recordOrEmpty(availability.dns);
69
+ const dnsRecords = recordOrEmpty(dns.records);
72
70
  const comparisonEvidence = buildBulkComparisonEvidence(availability);
73
71
  return {
74
72
  domain: document.registrableDomain,
@@ -97,8 +95,8 @@ function lookupComparisonInput(document) {
97
95
  faviconMatch: false,
98
96
  faviconNearMatch: false,
99
97
  reusesOfficialAssets: false,
100
- hasPasswordField: record(record(availability.credentialSurfaceProfile).inputs).categories
101
- ? Number(record(record(record(availability.credentialSurfaceProfile).inputs).categories).password) > 0
98
+ hasPasswordField: recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories
99
+ ? Number(recordOrEmpty(recordOrEmpty(recordOrEmpty(availability.credentialSurfaceProfile).inputs).categories).password) > 0
102
100
  : false,
103
101
  hasExternalFormAction: null,
104
102
  phishingLanguageMatch: null,
@@ -37,7 +37,7 @@ function buildCliLookupReconciliation(inputs, generatedAt = new Date().toISOStri
37
37
  label: `Lookup reconciliation observation ${index + 1}`,
38
38
  }));
39
39
  const domain = documents[0]?.registrableDomain;
40
- if (!domain || documents.some((document) => document.registrableDomain !== domain)) {
40
+ if (!domain || documents.some((candidate) => candidate.registrableDomain !== domain)) {
41
41
  throw new CliUsageError('Lookup reconciliation requires observations for one domain.');
42
42
  }
43
43
  const comparisons = documents.slice(1).map((document, index) => buildCliLookupDiff(inputs[0], inputs[index + 1], generatedAt, { domainMode: 'same' }));
@@ -128,14 +128,14 @@ function parseInput(textValue) {
128
128
  try {
129
129
  const parsed = JSON.parse(textInput);
130
130
  const document = record(parsed);
131
- if (document.schema === 'whoisleuth.cli.bulk' && document.version === 2 && Array.isArray(document.results)) {
131
+ if (document.schema === 'whoisleuth.cli.bulk' && [2, 3].includes(Number(document.version)) && Array.isArray(document.results)) {
132
132
  values = document.results;
133
133
  }
134
- else if (document.schema === 'whoisleuth.cli.bulk.item' && document.version === 2) {
134
+ else if (document.schema === 'whoisleuth.cli.bulk.item' && [2, 3].includes(Number(document.version))) {
135
135
  values = [document];
136
136
  }
137
137
  else {
138
- throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2.');
138
+ throw new CliUsageError('Mail review requires WHOISleuth Bulk JSON schema version 2 or 3.');
139
139
  }
140
140
  }
141
141
  catch (error) {
@@ -152,8 +152,8 @@ function parseInput(textValue) {
152
152
  throw new CliUsageError(`Mail review supports between 1 and ${MAX_MAIL_REVIEW_ROWS} Bulk rows.`);
153
153
  }
154
154
  const rows = values.map((value) => record(value));
155
- if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || value.version !== 2)) {
156
- throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2.');
155
+ if (rows.some((value) => value.schema !== 'whoisleuth.cli.bulk.item' || ![2, 3].includes(Number(value.version)))) {
156
+ throw new CliUsageError('Every Mail review row must use WHOISleuth Bulk item schema version 2 or 3.');
157
157
  }
158
158
  return rows;
159
159
  }
package/cli/manual.mjs CHANGED
@@ -23,7 +23,7 @@ ${commands}
23
23
  .SH OUTPUT
24
24
  Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events are written to standard error. Use --output with an optional --force flag for atomic private file output.
25
25
  .SH EXIT STATUS
26
- 0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, and 130 analyst cancellation.
26
+ 0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
27
27
  .SH PRIVACY
28
28
  Network commands disclose the target to the directly queried upstream services. Offline commands do not make network requests. Output files are created with private permissions and are never uploaded by the CLI.
29
29
  .SH LICENSE
@@ -8,6 +8,7 @@ import { formatTerminalCtSearch, formatTerminalHttp, formatTerminalPosture, form
8
8
  import { buildHttpProbeResult } from "./http.mjs";
9
9
  import { normalizePostureSelectors } from "./posture.mjs";
10
10
  import EXIT_CODES from "./exit-codes.mjs";
11
+ import { buildPostureSarif } from "./ci-report.mjs";
11
12
  async function runNetworkCommand(args, dependencies, context) {
12
13
  if (args.action === 'ct-search') {
13
14
  const keyword = args.keyword || await context.readSingleInput();
@@ -46,7 +47,9 @@ async function runNetworkCommand(args, dependencies, context) {
46
47
  if (!args.quiet) {
47
48
  context.writeStdout(args.output === 'json'
48
49
  ? formatJsonDocument(document)
49
- : context.terminal(formatTerminalPosture(document), args.color));
50
+ : args.output === 'sarif'
51
+ ? formatJsonDocument(buildPostureSarif(document))
52
+ : context.terminal(formatTerminalPosture(document), args.color));
50
53
  }
51
54
  return EXIT_CODES.SUCCESS;
52
55
  }
@@ -1,10 +1,16 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
3
3
  import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
4
+ import { reviewDomainChange } from "../lib/domain-change-review.mjs";
5
+ import { reviewDomainPortfolio } from "../lib/domain-portfolio-review.mjs";
6
+ import { reviewNameserverPreflight } from "../lib/nameserver-preflight-review.mjs";
4
7
  import { buildLocalGeoIpDatabase, lookupLocalGeoIp, } from "../lib/local-geoip-evidence.mjs";
5
- import { normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
8
+ import { inspectRdapReverseSearchResponse, normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
6
9
  import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
7
10
  import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
11
+ import { reviewZoneIntent } from "../lib/zone-intent-review.mjs";
12
+ import { reviewDnsConvergence } from "../lib/dns-convergence-review.mjs";
13
+ import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
8
14
  import { CliUsageError } from "./errors.mjs";
9
15
  import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mjs";
10
16
  const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
@@ -38,14 +44,18 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
38
44
  kind = 'rdap_search';
39
45
  const request = record(input.request);
40
46
  const help = normalizeRdapSearchHelp(input.help);
47
+ const plan = planRdapReverseSearch(help, {
48
+ searchableResourceType: String(request.searchableResourceType ?? ''),
49
+ relatedResourceType: String(request.relatedResourceType ?? ''),
50
+ property: String(request.property ?? ''),
51
+ value: request.value,
52
+ });
41
53
  result = Object.freeze({
42
54
  help,
43
- plan: planRdapReverseSearch(help, {
44
- searchableResourceType: String(request.searchableResourceType ?? ''),
45
- relatedResourceType: String(request.relatedResourceType ?? ''),
46
- property: String(request.property ?? ''),
47
- value: request.value,
48
- }),
55
+ plan,
56
+ responseInspection: input.response === undefined
57
+ ? null
58
+ : inspectRdapReverseSearchResponse(input.response, [request.property]),
49
59
  });
50
60
  }
51
61
  else if (input.schema === 'whoisleuth.dnssec-evidence-input') {
@@ -88,6 +98,30 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
88
98
  const query = record(input.query);
89
99
  result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
90
100
  }
101
+ else if (input.schema === 'whoisleuth.zone-intent.input') {
102
+ kind = 'zone_intent';
103
+ result = reviewZoneIntent(input, generatedAt);
104
+ }
105
+ else if (input.schema === 'whoisleuth.domain-portfolio.input') {
106
+ kind = 'domain_portfolio';
107
+ result = reviewDomainPortfolio(input, generatedAt);
108
+ }
109
+ else if (input.schema === 'whoisleuth.domain-change.input') {
110
+ kind = 'domain_change';
111
+ result = reviewDomainChange(input, generatedAt);
112
+ }
113
+ else if (input.schema === 'whoisleuth.dns-convergence.input') {
114
+ kind = 'dns_convergence';
115
+ result = reviewDnsConvergence(input, generatedAt);
116
+ }
117
+ else if (input.schema === 'whoisleuth.nameserver-preflight.input') {
118
+ kind = 'nameserver_preflight';
119
+ result = reviewNameserverPreflight(input, generatedAt);
120
+ }
121
+ else if (input.schema === 'whoisleuth.trust-store-comparison.input') {
122
+ kind = 'trust_store';
123
+ result = compareTrustStoreEvidence(input, generatedAt);
124
+ }
91
125
  else {
92
126
  throw new CliUsageError('Offline evidence review does not recognise this input schema.');
93
127
  }
@@ -121,7 +155,20 @@ async function buildOfflineEvidenceReviewWithLocalResources(value, generatedAt =
121
155
  }
122
156
  function formatOfflineEvidenceReview(document) {
123
157
  const result = record(document.result);
124
- const state = typeof result.state === 'string' ? result.state : 'reviewed';
158
+ const gate = record(result.gate);
159
+ const counts = record(result.counts);
160
+ const count = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
161
+ const listLength = (value) => Array.isArray(value) ? value.length : 0;
162
+ const state = typeof result.state === 'string'
163
+ ? result.state
164
+ : document.kind === 'zone_intent'
165
+ ? result.complete === true
166
+ && ['different', 'missing', 'unexpected', 'incomplete'].every((key) => count(counts[key]) === 0)
167
+ ? 'aligned'
168
+ : 'review'
169
+ : typeof gate.pass === 'boolean'
170
+ ? gate.pass ? 'ready' : 'review'
171
+ : document.kind === 'domain_portfolio' ? 'inventory' : 'reviewed';
125
172
  const lines = [
126
173
  'Offline evidence review',
127
174
  `Kind ${document.kind.replaceAll('_', ' ')}`,
@@ -130,8 +177,48 @@ function formatOfflineEvidenceReview(document) {
130
177
  if (document.kind === 'rdap_search') {
131
178
  const help = record(result.help);
132
179
  const plan = record(result.plan);
180
+ const responseInspection = record(result.responseInspection);
133
181
  lines.push(`Help ${String(help.state ?? 'unavailable').replaceAll('_', ' ')}`);
134
182
  lines.push(`Plan ${String(plan.state ?? 'unavailable').replaceAll('_', ' ')}`);
183
+ if (result.responseInspection !== null)
184
+ lines.push(`Result ${String(responseInspection.state ?? 'invalid').replaceAll('_', ' ')}`);
185
+ }
186
+ else if (document.kind === 'zone_intent') {
187
+ const desired = record(result.desired);
188
+ lines.push(`Desired ${listLength(desired.records)}`);
189
+ lines.push(`Compared ${listLength(result.comparisons)}`);
190
+ lines.push(`Aligned ${count(counts.aligned)}`);
191
+ lines.push(`Changed ${count(counts.different) + count(counts.missing) + count(counts.unexpected)}`);
192
+ lines.push(`Partial ${count(counts.incomplete)}`);
193
+ lines.push(`Rejected ${listLength(desired.rejected)}`);
194
+ }
195
+ else if (document.kind === 'domain_change') {
196
+ lines.push(`Authority ${listLength(result.authoritativeRecordMatrix)} rows`);
197
+ lines.push(`Resolvers ${listLength(result.resolverDivergenceMatrix)} rows`);
198
+ }
199
+ else if (document.kind === 'dns_convergence') {
200
+ lines.push(`Observers ${listLength(result.observers)}`);
201
+ lines.push(`Snapshots ${listLength(result.snapshots)}`);
202
+ lines.push(`Record sets ${listLength(result.rows)}`);
203
+ }
204
+ else if (document.kind === 'nameserver_preflight') {
205
+ const rows = Array.isArray(result.rows) ? result.rows : [];
206
+ lines.push(`Servers ${rows.length}`);
207
+ lines.push(`Ready ${rows.filter((item) => record(item).ready === true).length}`);
208
+ }
209
+ else if (document.kind === 'trust_store') {
210
+ lines.push(`Stores ${count(counts.stores)}`);
211
+ lines.push(`Matched ${count(counts.anchorObserved)}`);
212
+ lines.push(`Not observed ${count(counts.notObserved)}`);
213
+ lines.push(`Inconclusive ${count(counts.inconclusive)}`);
214
+ }
215
+ else if (document.kind === 'domain_portfolio') {
216
+ const unknownCounts = record(result.unknownCounts);
217
+ lines.push(`Assets ${listLength(result.assets)}`);
218
+ lines.push(`Dependencies ${listLength(result.simulations)}`);
219
+ lines.push(`Renewals ${listLength(result.renewalQueue)}`);
220
+ lines.push(`Recovery ${listLength(result.recoveryCycles)}`);
221
+ lines.push(`Unknown ${Object.values(unknownCounts).reduce((total, value) => total + count(value), 0)}`);
135
222
  }
136
223
  else {
137
224
  for (const [label, key] of [
@@ -146,6 +233,16 @@ function formatOfflineEvidenceReview(document) {
146
233
  lines.push(`${label.padEnd(7)}${candidate}`);
147
234
  }
148
235
  }
236
+ const reasons = Array.isArray(gate.reasons) ? gate.reasons : [];
237
+ if (reasons.length) {
238
+ lines.push('', 'Review reasons:');
239
+ for (const reason of reasons.slice(0, 8)) {
240
+ if (typeof reason === 'string')
241
+ lines.push(` - ${reason.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, 240)}`);
242
+ }
243
+ if (reasons.length > 8)
244
+ lines.push(` - ${reasons.length - 8} more reason(s) omitted.`);
245
+ }
149
246
  lines.push('', 'Limitations:');
150
247
  for (const limitation of document.limitations)
151
248
  lines.push(` - ${limitation}`);
@@ -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
+ }