@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
@@ -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,
@@ -3,13 +3,13 @@ export const CLI_LOOKUP_PLAN_VERSION = 1;
3
3
  const RDAP = Object.freeze({
4
4
  source: 'rdap',
5
5
  purpose: 'Collect authoritative registration or allocation evidence where supported.',
6
- disclosure: 'The normalized target is sent to the applicable RDAP bootstrap and service endpoints.',
6
+ disclosure: 'The normalised target is sent to the applicable RDAP bootstrap and service endpoints.',
7
7
  conditional: false,
8
8
  });
9
9
  const WHOIS = Object.freeze({
10
10
  source: 'whois',
11
11
  purpose: 'Collect separately attributed registry and referral publications.',
12
- disclosure: 'The normalized target is sent over bounded TCP connections to applicable WHOIS services.',
12
+ disclosure: 'The normalised target is sent over bounded TCP connections to applicable WHOIS services.',
13
13
  conditional: false,
14
14
  });
15
15
  const FAST_DOMAIN_EVIDENCE = Object.freeze({
@@ -39,7 +39,7 @@ const NETWORK_CONTEXT = Object.freeze({
39
39
  const REVERSE_DNS = Object.freeze({
40
40
  source: 'reverse_dns',
41
41
  purpose: 'Collect operator-published reverse-DNS context for a public IP address.',
42
- disclosure: 'A DNS resolver receives the reverse lookup for the normalized IP address.',
42
+ disclosure: 'A DNS resolver receives the reverse lookup for the normalised IP address.',
43
43
  conditional: false,
44
44
  });
45
45
  function plannedSources(classified, deep) {
@@ -0,0 +1,141 @@
1
+ import { CliUsageError } from "./errors.mjs";
2
+ import { buildCliLookupDiff } from "./lookup-diff.mjs";
3
+ import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
+ const CLI_LOOKUP_RECONCILIATION_SCHEMA = 'whoisleuth.cli.lookup-reconciliation';
5
+ const CLI_LOOKUP_RECONCILIATION_VERSION = 1;
6
+ const MAX_LOOKUP_RECONCILIATION_INPUT_BYTES = 32 * 1024 * 1024;
7
+ function object(value) {
8
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
9
+ }
10
+ function label(value) {
11
+ if (typeof value !== 'string')
12
+ return null;
13
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, 80);
14
+ return normalized || null;
15
+ }
16
+ function observationContext(document) {
17
+ const context = object(document.collectionContext);
18
+ return {
19
+ generatedAt: document.generatedAt,
20
+ mode: document.mode,
21
+ observerLabel: label(context.observerLabel),
22
+ vantageLabel: label(context.vantageLabel),
23
+ };
24
+ }
25
+ function comparable(value, sourceState) {
26
+ if (['error', 'not_recorded', 'partial', 'skipped', 'unavailable', 'unsupported'].includes(sourceState))
27
+ return false;
28
+ return value !== 'Not observed' && value !== 'Not recorded in compact Bulk evidence';
29
+ }
30
+ function normalizedValue(value) {
31
+ return value.replace(/\s+/gu, ' ').trim().toLocaleLowerCase('en-US');
32
+ }
33
+ function buildCliLookupReconciliation(inputs, generatedAt = new Date().toISOString()) {
34
+ if (inputs.length < 2 || inputs.length > 5)
35
+ throw new CliUsageError('Lookup reconciliation requires from 2 to 5 observations.');
36
+ const documents = inputs.map((input, index) => parseSavedLookupDocument(input, {
37
+ label: `Lookup reconciliation observation ${index + 1}`,
38
+ }));
39
+ const domain = documents[0]?.registrableDomain;
40
+ if (!domain || documents.some((document) => document.registrableDomain !== domain)) {
41
+ throw new CliUsageError('Lookup reconciliation requires observations for one domain.');
42
+ }
43
+ const comparisons = documents.slice(1).map((document, index) => buildCliLookupDiff(inputs[0], inputs[index + 1], generatedAt, { domainMode: 'same' }));
44
+ const firstRows = comparisons[0]?.comparison.rows || [];
45
+ const fields = firstRows.map((firstRow) => {
46
+ const values = [{
47
+ observation: 0,
48
+ value: firstRow.left,
49
+ sourceState: firstRow.leftSourceState,
50
+ comparable: comparable(firstRow.left, firstRow.leftSourceState),
51
+ }];
52
+ for (let index = 0; index < comparisons.length; index++) {
53
+ const row = comparisons[index].comparison.rows.find((candidate) => candidate.id === firstRow.id);
54
+ if (!row)
55
+ continue;
56
+ values.push({
57
+ observation: index + 1,
58
+ value: row.right,
59
+ sourceState: row.rightSourceState,
60
+ comparable: comparable(row.right, row.rightSourceState),
61
+ });
62
+ }
63
+ const comparableValues = values.filter((value) => value.comparable);
64
+ const distinctValues = new Set(comparableValues.map((value) => normalizedValue(value.value)));
65
+ const state = comparableValues.length < 2
66
+ ? 'non_comparable'
67
+ : distinctValues.size === 1
68
+ ? 'agreement'
69
+ : 'disagreement';
70
+ return {
71
+ id: firstRow.id,
72
+ category: firstRow.category,
73
+ label: firstRow.label,
74
+ source: firstRow.source,
75
+ state,
76
+ method: firstRow.method,
77
+ values,
78
+ limitations: firstRow.limitations,
79
+ };
80
+ });
81
+ const observations = documents.map(observationContext);
82
+ const identities = observations.map((observation) => (observation.observerLabel && observation.vantageLabel
83
+ ? `${observation.observerLabel}\u0000${observation.vantageLabel}`
84
+ : null));
85
+ const labelsComplete = identities.every((identity) => identity !== null);
86
+ const labelsDistinct = labelsComplete && new Set(identities).size === identities.length;
87
+ const independence = labelsDistinct
88
+ ? {
89
+ state: 'verified_distinct_labels',
90
+ reason: 'Every observation carries a different analyst-supplied observer and vantage label.',
91
+ }
92
+ : {
93
+ state: 'unverified',
94
+ reason: 'Observer and vantage labels are missing or repeated; independent collection cannot be inferred.',
95
+ };
96
+ return {
97
+ schema: CLI_LOOKUP_RECONCILIATION_SCHEMA,
98
+ version: CLI_LOOKUP_RECONCILIATION_VERSION,
99
+ generatedAt,
100
+ domain,
101
+ independence,
102
+ observations,
103
+ fields,
104
+ summary: {
105
+ observationCount: observations.length,
106
+ agreement: fields.filter((field) => field.state === 'agreement').length,
107
+ disagreement: fields.filter((field) => field.state === 'disagreement').length,
108
+ nonComparable: fields.filter((field) => field.state === 'non_comparable').length,
109
+ },
110
+ privacy: { filenamesRetained: 0, rawRegistryPayloadsCopied: 0 },
111
+ limitations: [
112
+ 'This report reconciles only bounded normalised values already present in saved Lookup documents and makes no network request.',
113
+ 'Observer and vantage labels are analyst-supplied metadata. Distinct labels do not prove independent resolvers, networks, providers, caches, or collection paths.',
114
+ 'Agreement can reflect shared upstream data or collection conditions. Disagreement can reflect collection timing, propagation, source health, or a real domain change.',
115
+ 'Non-comparable evidence remains separate from agreement and is never interpreted as absence, safety, or a negative finding.',
116
+ ],
117
+ };
118
+ }
119
+ function formatCliLookupReconciliation(document) {
120
+ const output = [
121
+ 'Lookup observation reconciliation',
122
+ `Domain ${document.domain}`,
123
+ `Observations ${document.summary.observationCount}`,
124
+ `Independence ${document.independence.state.replaceAll('_', ' ')}`,
125
+ `Agreement ${document.summary.agreement}`,
126
+ `Disagreement ${document.summary.disagreement}`,
127
+ `Non-comparable ${document.summary.nonComparable}`,
128
+ '',
129
+ ];
130
+ for (const field of document.fields.filter((candidate) => candidate.state !== 'agreement')) {
131
+ output.push(`${field.label} [${field.state.replaceAll('_', ' ')}]`);
132
+ for (const value of field.values) {
133
+ output.push(` Observation ${value.observation + 1}: ${value.value} (${value.sourceState.replaceAll('_', ' ')})`);
134
+ }
135
+ }
136
+ output.push('', 'Limitations:');
137
+ for (const limitation of document.limitations)
138
+ output.push(` - ${limitation}`);
139
+ return `${output.join('\n')}\n`;
140
+ }
141
+ export { CLI_LOOKUP_RECONCILIATION_SCHEMA, CLI_LOOKUP_RECONCILIATION_VERSION, MAX_LOOKUP_RECONCILIATION_INPUT_BYTES, buildCliLookupReconciliation, formatCliLookupReconciliation, };
@@ -85,7 +85,7 @@ function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString())
85
85
  rawRegistryPayloadsCopied: 0,
86
86
  },
87
87
  limitations: [
88
- 'The timeline compares only bounded normalized fields already present in the selected saved Lookup documents and makes no network request.',
88
+ 'The timeline compares only bounded normalised fields already present in the selected saved Lookup documents and makes no network request.',
89
89
  'Input filenames and raw registry payloads are not copied into the timeline output.',
90
90
  'Missing, unavailable, and not-recorded evidence remain separate from an observed difference.',
91
91
  'A difference can reflect a domain change or changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
@@ -213,7 +213,7 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
213
213
  'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
214
214
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
215
215
  'Optional TLSA evidence is compared offline. A DANE match requires separately validated DNSSEC evidence from the same observation.',
216
- 'SMTP delivery, mailbox existence, catch-all behavior, banner collection, and message acceptance were not tested.',
216
+ 'SMTP delivery, mailbox existence, catch-all behaviour, banner collection, and message acceptance were not tested.',
217
217
  ],
218
218
  };
219
219
  }
@@ -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,11 +1,18 @@
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";
15
+ import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mjs";
9
16
  const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
10
17
  const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
11
18
  const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
@@ -37,14 +44,18 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
37
44
  kind = 'rdap_search';
38
45
  const request = record(input.request);
39
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
+ });
40
53
  result = Object.freeze({
41
54
  help,
42
- plan: planRdapReverseSearch(help, {
43
- searchableResourceType: String(request.searchableResourceType ?? ''),
44
- relatedResourceType: String(request.relatedResourceType ?? ''),
45
- property: String(request.property ?? ''),
46
- value: request.value,
47
- }),
55
+ plan,
56
+ responseInspection: input.response === undefined
57
+ ? null
58
+ : inspectRdapReverseSearchResponse(input.response, [request.property]),
48
59
  });
49
60
  }
50
61
  else if (input.schema === 'whoisleuth.dnssec-evidence-input') {
@@ -87,8 +98,32 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
87
98
  const query = record(input.query);
88
99
  result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
89
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
+ }
90
125
  else {
91
- throw new CliUsageError('Offline evidence review does not recognize this input schema.');
126
+ throw new CliUsageError('Offline evidence review does not recognise this input schema.');
92
127
  }
93
128
  return Object.freeze({
94
129
  schema: OFFLINE_EVIDENCE_REVIEW_SCHEMA,
@@ -101,9 +136,39 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
101
136
  ]),
102
137
  });
103
138
  }
139
+ async function buildOfflineEvidenceReviewWithLocalResources(value, generatedAt = new Date().toISOString(), options = {}) {
140
+ const input = parseInput(value);
141
+ if (input.schema !== LOCAL_MMDB_QUERY_SCHEMA)
142
+ return buildOfflineEvidenceReview(value, generatedAt);
143
+ if (!options.mmdbPath)
144
+ throw new CliUsageError('Local MMDB review requires --mmdb <database-file>.');
145
+ return Object.freeze({
146
+ schema: OFFLINE_EVIDENCE_REVIEW_SCHEMA,
147
+ version: OFFLINE_EVIDENCE_REVIEW_VERSION,
148
+ generatedAt,
149
+ kind: 'geoip',
150
+ result: await reviewLocalMmdb(input, options.mmdbPath),
151
+ limitations: Object.freeze([
152
+ 'The review is local and uses only the supplied query metadata and analyst-supplied database. It does not transmit the address or refresh the database.',
153
+ ]),
154
+ });
155
+ }
104
156
  function formatOfflineEvidenceReview(document) {
105
157
  const result = record(document.result);
106
- 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';
107
172
  const lines = [
108
173
  'Offline evidence review',
109
174
  `Kind ${document.kind.replaceAll('_', ' ')}`,
@@ -112,8 +177,48 @@ function formatOfflineEvidenceReview(document) {
112
177
  if (document.kind === 'rdap_search') {
113
178
  const help = record(result.help);
114
179
  const plan = record(result.plan);
180
+ const responseInspection = record(result.responseInspection);
115
181
  lines.push(`Help ${String(help.state ?? 'unavailable').replaceAll('_', ' ')}`);
116
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)}`);
117
222
  }
118
223
  else {
119
224
  for (const [label, key] of [
@@ -128,6 +233,16 @@ function formatOfflineEvidenceReview(document) {
128
233
  lines.push(`${label.padEnd(7)}${candidate}`);
129
234
  }
130
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
+ }
131
246
  lines.push('', 'Limitations:');
132
247
  for (const limitation of document.limitations)
133
248
  lines.push(` - ${limitation}`);
@@ -138,4 +253,4 @@ function formatOfflineEvidenceReview(document) {
138
253
  }
139
254
  return `${lines.join('\n')}\n`;
140
255
  }
141
- export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, formatOfflineEvidenceReview, };
256
+ export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
@@ -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
+ }