@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -10,11 +10,11 @@ const CLI_HTTP_SCHEMA = 'whoisleuth.cli.http';
10
10
  const CLI_TLS_SCHEMA = 'whoisleuth.cli.tls';
11
11
  const CLI_COMPARE_SCHEMA = 'whoisleuth.cli.compare';
12
12
  const CLI_LOOKUP_SCHEMA_VERSION = 1;
13
- const CLI_BULK_SCHEMA_VERSION = 2;
13
+ const CLI_BULK_SCHEMA_VERSION = 3;
14
14
  const CLI_CT_SEARCH_SCHEMA_VERSION = 1;
15
15
  const CLI_DISCOVER_SCHEMA_VERSION = 2;
16
16
  const CLI_POSTURE_SCHEMA_VERSION = 1;
17
- const CLI_HTTP_SCHEMA_VERSION = 1;
17
+ const CLI_HTTP_SCHEMA_VERSION = 2;
18
18
  const CLI_TLS_SCHEMA_VERSION = 1;
19
19
  const CLI_COMPARE_SCHEMA_VERSION = 3;
20
20
  function buildCliLookupDocument(query, classified, result, generatedAt = new Date().toISOString(), mode = 'fast', collectionContext = {}) {
@@ -25,6 +25,7 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
25
25
  ? collectionContext.vantageLabel
26
26
  : null;
27
27
  return {
28
+ ...result,
28
29
  schema: CLI_LOOKUP_SCHEMA,
29
30
  version: CLI_LOOKUP_SCHEMA_VERSION,
30
31
  generatedAt,
@@ -40,7 +41,6 @@ function buildCliLookupDocument(query, classified, result, generatedAt = new Dat
40
41
  ...(vantageLabel ? { vantageLabel } : {}),
41
42
  },
42
43
  } : {}),
43
- ...result,
44
44
  };
45
45
  }
46
46
  function formatJsonDocument(document) {
@@ -105,15 +105,21 @@ function buildCliCompareDocument(result, generatedAt = new Date().toISOString())
105
105
  return { ...result, schema: CLI_COMPARE_SCHEMA, version: CLI_COMPARE_SCHEMA_VERSION, generatedAt };
106
106
  }
107
107
  function bulkJsonItem(item, metadata) {
108
+ const provenance = {
109
+ observedAt: item.observedAt ?? null,
110
+ collectionOrigin: item.collectionOrigin ?? 'current_run',
111
+ };
108
112
  if (!item.ok) {
109
113
  return {
110
114
  schema: CLI_BULK_ITEM_SCHEMA, version: CLI_BULK_SCHEMA_VERSION, generatedAt: metadata.generatedAt,
115
+ ...provenance,
111
116
  index: item.index, query: item.query, ok: false, error: item.error,
112
117
  };
113
118
  }
114
119
  const result = item.result;
115
120
  return {
116
121
  schema: CLI_BULK_ITEM_SCHEMA, version: CLI_BULK_SCHEMA_VERSION, generatedAt: metadata.generatedAt,
122
+ ...provenance,
117
123
  index: item.index, query: item.query, ok: true,
118
124
  type: item.classified.type,
119
125
  inputHostname: item.classified.inputHostname,
@@ -142,6 +148,8 @@ function buildCliBulkDocument(items, metadata) {
142
148
  };
143
149
  }
144
150
  function formatJsonLines(items, metadata) {
145
- return `${items.map((item) => JSON.stringify(bulkJsonItem(item, metadata))).join('\n')}\n`;
151
+ return items.length
152
+ ? `${items.map((item) => JSON.stringify(bulkJsonItem(item, metadata))).join('\n')}\n`
153
+ : '';
146
154
  }
147
155
  export { CLI_BULK_ITEM_SCHEMA, CLI_BULK_SCHEMA, CLI_BULK_SCHEMA_VERSION, CLI_COMPARE_SCHEMA, CLI_COMPARE_SCHEMA_VERSION, CLI_CT_SEARCH_SCHEMA, CLI_CT_SEARCH_SCHEMA_VERSION, CLI_DISCOVER_ITEM_SCHEMA, CLI_DISCOVER_SCHEMA, CLI_DISCOVER_SCHEMA_VERSION, CLI_HTTP_SCHEMA, CLI_HTTP_SCHEMA_VERSION, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, CLI_TLS_SCHEMA, CLI_TLS_SCHEMA_VERSION, CLI_LOOKUP_SCHEMA, CLI_LOOKUP_SCHEMA_VERSION, buildCliBulkDocument, buildCliCompareDocument, buildCliCtSearchDocument, buildCliDiscoverDocument, buildCliHttpDocument, buildCliLookupDocument, buildCliPostureDocument, buildCliTlsDocument, bulkJsonItem, discoverJsonItem, formatDiscoverDomainList, formatDiscoverJsonLines, formatJsonDocument, formatJsonLines, };
@@ -271,6 +271,9 @@ function formatTerminalRegistrySupport(document) {
271
271
  `WHOIS scope ${supportLabel(whois.queryScope)}`,
272
272
  `WHOIS encoding ${supportLabel(whois.encodingProfile)}`,
273
273
  `WHOIS parser ${supportLabel(whois.parserProfile)}`,
274
+ ...(typeof profile.officialLookupUrl === 'string' && profile.officialLookupUrl
275
+ ? [`Official lookup ${safeTerminalValue(profile.officialLookupUrl)}`]
276
+ : []),
274
277
  `Fixture states ${fixtures.length ? fixtures.map((value) => supportLabel(value)).join(', ') : 'None documented'}`,
275
278
  ];
276
279
  for (const file of files)
@@ -431,7 +434,7 @@ function formatTerminalHttp(document) {
431
434
  const lines = [
432
435
  `Domain ${safeTerminalValue(document.domain)}`,
433
436
  `Probe ${titleCase(document.probeStatus)}`,
434
- `Activity ${titleCase(document.activityStatus)}`,
437
+ `Assessment ${titleCase(document.assessment ?? document.activityStatus)}`,
435
438
  `Evidence ${titleCase(http.status)}`,
436
439
  `Final URL ${safeTerminalValue(http.finalUrl)}`,
437
440
  `HTTP status ${safeTerminalValue(response.status)}`,
package/cli/http.mjs CHANGED
@@ -17,7 +17,8 @@ function buildHttpProbeResult(domain, probe) {
17
17
  return {
18
18
  domain,
19
19
  probeStatus: status,
20
- activityStatus: status === 'fetched' || status === 'responded' ? 'active' : 'unreachable',
20
+ assessment: status === 'fetched' || status === 'responded' ? 'active' : 'inconclusive',
21
+ activityStatus: status === 'fetched' || status === 'responded' ? 'active' : null,
21
22
  detail: boundedDetail(input.detail),
22
23
  http: input.http && typeof input.http === 'object' && !Array.isArray(input.http)
23
24
  ? input.http
package/cli/input.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
3
+ import { CliUsageError } from "./errors.mjs";
4
+ function abortError(signal) {
5
+ return signal.reason instanceof Error
6
+ ? signal.reason
7
+ : new DOMException('The operation was aborted.', 'AbortError');
8
+ }
9
+ export async function readCliTextInput(source, stdin, options) {
10
+ if (options.signal?.aborted)
11
+ throw abortError(options.signal);
12
+ if (source) {
13
+ try {
14
+ return await readBoundedRegularTextFile(source, {
15
+ maximumBytes: options.maximumBytes,
16
+ label: options.label,
17
+ allowSymbolicLink: true,
18
+ ...(options.signal ? { signal: options.signal } : {}),
19
+ });
20
+ }
21
+ catch (error) {
22
+ if (options.signal?.aborted)
23
+ throw abortError(options.signal);
24
+ if (error instanceof CliUsageError)
25
+ throw error;
26
+ const message = error instanceof Error ? error.message : `${options.label} could not be read.`;
27
+ throw new CliUsageError(message);
28
+ }
29
+ }
30
+ if (!stdin || stdin.isTTY)
31
+ return '';
32
+ let bytes = 0;
33
+ const chunks = [];
34
+ const onAbort = () => {
35
+ if ('destroy' in stdin && typeof stdin.destroy === 'function')
36
+ stdin.destroy(abortError(options.signal));
37
+ };
38
+ options.signal?.addEventListener('abort', onAbort, { once: true });
39
+ try {
40
+ for await (const chunk of stdin) {
41
+ if (options.signal?.aborted)
42
+ throw abortError(options.signal);
43
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
44
+ bytes += buffer.length;
45
+ if (bytes > options.maximumBytes) {
46
+ throw new CliUsageError(`${options.label} is limited to ${options.maximumBytes} bytes.`);
47
+ }
48
+ chunks.push(buffer);
49
+ }
50
+ if (options.signal?.aborted)
51
+ throw abortError(options.signal);
52
+ return Buffer.concat(chunks, bytes).toString('utf8');
53
+ }
54
+ finally {
55
+ options.signal?.removeEventListener('abort', onAbort);
56
+ }
57
+ }
@@ -0,0 +1,226 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { INTERCHANGE_ARTIFACT_CONTRACTS, } from "../lib/interchange-fidelity-registry.mjs";
3
+ import { offlineArtifactSatisfiesAssurance, verifyOfflineArtifact, } from "./artifact-verify.mjs";
4
+ import { mergeBrandProfiles } from "../frontend/src/lib/analysis/brand-profile-model.js";
5
+ import { parseBoundedJsonObject } from "./bounded-json.mjs";
6
+ export const INTERCHANGE_FIDELITY_REPORT_SCHEMA = 'whoisleuth.interchange-fidelity-report';
7
+ export const INTERCHANGE_FIDELITY_REPORT_VERSION = 2;
8
+ export const MAX_INTERCHANGE_REPORT_BYTES = 15 * 1024 * 1024;
9
+ function record(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value)
11
+ ? value
12
+ : null;
13
+ }
14
+ function parseInput(raw) {
15
+ if (typeof raw !== 'string')
16
+ throw new TypeError('Interchange input must be UTF-8 JSON text.');
17
+ const bytes = Buffer.byteLength(raw, 'utf8');
18
+ if (bytes < 1 || bytes > MAX_INTERCHANGE_REPORT_BYTES) {
19
+ throw new TypeError(`Interchange input must be between 1 byte and ${MAX_INTERCHANGE_REPORT_BYTES} bytes.`);
20
+ }
21
+ return parseBoundedJsonObject(raw.replace(/^\uFEFF/u, ''), {
22
+ label: 'Interchange input',
23
+ maximumBytes: MAX_INTERCHANGE_REPORT_BYTES,
24
+ });
25
+ }
26
+ function nestedRecord(value, path) {
27
+ let current = value;
28
+ for (const part of path)
29
+ current = record(current?.[part]);
30
+ return current;
31
+ }
32
+ function identify(value) {
33
+ for (const contract of INTERCHANGE_ARTIFACT_CONTRACTS) {
34
+ const container = nestedRecord(value, contract.nestedSchemaPath);
35
+ if (container?.schema !== contract.schema)
36
+ continue;
37
+ const declared = container[contract.versionField];
38
+ return {
39
+ contract,
40
+ version: Number.isSafeInteger(declared) && Number(declared) > 0 && Number(declared) <= 1000
41
+ ? Number(declared)
42
+ : null,
43
+ };
44
+ }
45
+ return null;
46
+ }
47
+ function timestamp(value) {
48
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
49
+ throw new TypeError('Interchange report time is invalid.');
50
+ }
51
+ const parsed = Date.parse(value);
52
+ if (!Number.isFinite(parsed))
53
+ throw new TypeError('Interchange report time is invalid.');
54
+ return new Date(parsed).toISOString();
55
+ }
56
+ function validateBrandProfileExport(value) {
57
+ const profiles = value.profiles;
58
+ if (!Array.isArray(profiles) || profiles.length > 100) {
59
+ throw new TypeError('Brand Profile export does not contain a bounded profiles collection.');
60
+ }
61
+ if (typeof value.exportedAt !== 'string' || value.exportedAt.length > 64 || !Number.isFinite(Date.parse(value.exportedAt))) {
62
+ throw new TypeError('Brand Profile export time is invalid.');
63
+ }
64
+ let nextId = 0;
65
+ const result = mergeBrandProfiles([], value, {
66
+ nowIso: new Date(Date.parse(value.exportedAt)).toISOString(),
67
+ makeId: () => `interchange-profile-${++nextId}`,
68
+ });
69
+ return Object.freeze({ accepted: result.profiles.length, skipped: result.skipped });
70
+ }
71
+ export async function buildInterchangeFidelityReport(raw, options = {}) {
72
+ const inputBytes = Buffer.byteLength(raw, 'utf8');
73
+ const value = parseInput(raw);
74
+ const identified = identify(value);
75
+ const generatedAt = timestamp(options.generatedAt ?? new Date().toISOString());
76
+ if (!identified)
77
+ return Object.freeze({
78
+ schema: INTERCHANGE_FIDELITY_REPORT_SCHEMA,
79
+ version: INTERCHANGE_FIDELITY_REPORT_VERSION,
80
+ generatedAt,
81
+ recognised: false,
82
+ artifact: Object.freeze({ id: null, schema: null, version: null, versionSupported: false }),
83
+ verification: Object.freeze({ state: 'not_verified', requiredAssurance: null, assuranceSatisfied: false }),
84
+ compatibility: Object.freeze({
85
+ browser: null,
86
+ cli: null,
87
+ fidelity: 'not_verified',
88
+ preservedFieldGroups: Object.freeze([]),
89
+ excludedFieldGroups: Object.freeze([]),
90
+ futureVersionBehaviour: null,
91
+ fullyImportable: null,
92
+ }),
93
+ summary: Object.freeze({
94
+ inputBytes, recordCount: null, sectionCount: null, encryptedContentVerified: null,
95
+ acceptedRecordCount: null, skippedRecordCount: null, unsupportedSectionCount: null,
96
+ blockedSectionCount: null, prunedRecordCount: null,
97
+ }),
98
+ limitations: Object.freeze([
99
+ 'The file did not match a registered interchange contract. Its supplied schema text and contents are not echoed.',
100
+ 'This report makes no request and does not establish that evidence is accurate, current, complete, or safe to share.',
101
+ ]),
102
+ });
103
+ const { contract, version } = identified;
104
+ const supported = version !== null && contract.versions.includes(version);
105
+ let verificationState = supported ? 'not_verified' : 'unsupported_version';
106
+ let assuranceSatisfied = false;
107
+ let recordCount = null;
108
+ let sectionCount = null;
109
+ let encryptedContentVerified = contract.id === 'encrypted_workspace' ? false : null;
110
+ let acceptedRecordCount = null;
111
+ let skippedRecordCount = null;
112
+ let unsupportedSectionCount = null;
113
+ let blockedSectionCount = null;
114
+ let prunedRecordCount = null;
115
+ let fullyImportable = null;
116
+ if (supported && contract.fidelity !== 'unsupported') {
117
+ try {
118
+ if (contract.id === 'brand_profiles') {
119
+ const validation = validateBrandProfileExport(value);
120
+ recordCount = validation.accepted;
121
+ acceptedRecordCount = validation.accepted;
122
+ skippedRecordCount = validation.skipped;
123
+ verificationState = validation.skipped === 0 ? 'structure_valid' : 'not_verified';
124
+ assuranceSatisfied = validation.skipped === 0 && contract.requiredAssurance === 'structure';
125
+ }
126
+ else {
127
+ const verification = await verifyOfflineArtifact(raw, { passphrase: options.passphrase ?? null });
128
+ verificationState = verification.state;
129
+ assuranceSatisfied = contract.requiredAssurance === 'whole_integrity'
130
+ ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
131
+ : contract.requiredAssurance === 'structure'
132
+ ? offlineArtifactSatisfiesAssurance(verification, 'structure')
133
+ : contract.requiredAssurance === 'authenticated_whole_integrity'
134
+ ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
135
+ && verification.checks.authenticatedEncryption === 'verified'
136
+ : false;
137
+ recordCount = verification.summary.recordCount;
138
+ sectionCount = verification.summary.sectionCount;
139
+ const unsupportedSections = verification.summary.unsupportedSectionCount ?? 0;
140
+ unsupportedSectionCount = unsupportedSections;
141
+ blockedSectionCount = verification.summary.blockedSectionCount ?? null;
142
+ skippedRecordCount = verification.summary.skippedRecordCount ?? skippedRecordCount;
143
+ prunedRecordCount = verification.summary.prunedRecordCount ?? null;
144
+ if (verification.summary.fullyImportable !== undefined
145
+ && verification.summary.fullyImportable !== null) {
146
+ fullyImportable = assuranceSatisfied && verification.summary.fullyImportable;
147
+ }
148
+ if (contract.id === 'encrypted_workspace')
149
+ encryptedContentVerified = verification.state === 'verified'
150
+ && verification.checks.authenticatedEncryption === 'verified';
151
+ }
152
+ }
153
+ catch {
154
+ verificationState = 'not_verified';
155
+ assuranceSatisfied = false;
156
+ }
157
+ }
158
+ const fidelityEligible = assuranceSatisfied && fullyImportable !== false;
159
+ const fidelity = !supported
160
+ ? 'unsupported'
161
+ : fidelityEligible
162
+ ? contract.fidelity
163
+ : contract.fidelity === 'unsupported'
164
+ ? 'unsupported'
165
+ : 'not_verified';
166
+ return Object.freeze({
167
+ schema: INTERCHANGE_FIDELITY_REPORT_SCHEMA,
168
+ version: INTERCHANGE_FIDELITY_REPORT_VERSION,
169
+ generatedAt,
170
+ recognised: true,
171
+ artifact: Object.freeze({ id: contract.id, schema: contract.schema, version, versionSupported: supported }),
172
+ verification: Object.freeze({
173
+ state: verificationState,
174
+ requiredAssurance: contract.requiredAssurance,
175
+ assuranceSatisfied,
176
+ }),
177
+ compatibility: Object.freeze({
178
+ browser: contract.browser,
179
+ cli: contract.cli,
180
+ fidelity,
181
+ preservedFieldGroups: contract.preservedFieldGroups,
182
+ excludedFieldGroups: contract.excludedFieldGroups,
183
+ futureVersionBehaviour: contract.futureVersionBehaviour,
184
+ fullyImportable,
185
+ }),
186
+ summary: Object.freeze({
187
+ inputBytes, recordCount, sectionCount, encryptedContentVerified,
188
+ acceptedRecordCount, skippedRecordCount, unsupportedSectionCount,
189
+ blockedSectionCount, prunedRecordCount,
190
+ }),
191
+ limitations: Object.freeze([
192
+ 'Compatibility describes declared field groups and supported operations, not byte-for-byte equality or evidence truth.',
193
+ 'The report emits only registered metadata, counts, and fixed field-group identifiers. Targets, notes, contacts, passphrases, and evidence values are not included.',
194
+ 'A verified integrity envelope detects file changes but does not establish author identity, source accuracy, currency, completeness, or sharing authority.',
195
+ ]),
196
+ });
197
+ }
198
+ export function formatInterchangeFidelityReport(report) {
199
+ const browser = report.compatibility.browser;
200
+ const cli = report.compatibility.cli;
201
+ return [
202
+ 'Interchange fidelity report',
203
+ `Recognised ${report.recognised ? 'yes' : 'no'}`,
204
+ `Artifact ${report.artifact.id ?? 'unrecognised'}`,
205
+ `Version ${report.artifact.version ?? 'unavailable'}${report.artifact.versionSupported ? '' : ' (unsupported)'}`,
206
+ `Verification ${report.verification.state}`,
207
+ `Assurance ${report.verification.requiredAssurance ?? 'unregistered'} · ${report.verification.assuranceSatisfied ? 'satisfied' : 'withheld'}`,
208
+ `Fidelity ${report.compatibility.fidelity}`,
209
+ ...(report.compatibility.fullyImportable === null ? [] : [`Importability ${report.compatibility.fullyImportable ? 'complete' : 'partial'}`]),
210
+ `Browser import ${browser?.import ?? 'unknown'} · export ${browser?.export ?? 'unknown'}`,
211
+ `CLI read ${cli?.read ?? 'unknown'} · write ${cli?.write ?? 'unknown'} · verify ${cli?.verify ?? 'unknown'}`,
212
+ `Records ${report.summary.recordCount ?? 'not disclosed'}`,
213
+ `Sections ${report.summary.sectionCount ?? 'not disclosed'}`,
214
+ ...(report.summary.acceptedRecordCount === null ? [] : [`Accepted ${report.summary.acceptedRecordCount}`]),
215
+ ...(report.summary.skippedRecordCount === null ? [] : [`Skipped ${report.summary.skippedRecordCount}`]),
216
+ ...(report.summary.unsupportedSectionCount === null ? [] : [`Unsupported ${report.summary.unsupportedSectionCount}`]),
217
+ ...(report.summary.blockedSectionCount === null ? [] : [`Blocked ${report.summary.blockedSectionCount}`]),
218
+ ...(report.summary.prunedRecordCount === null ? [] : [`Pruned ${report.summary.prunedRecordCount}`]),
219
+ '',
220
+ `Preserved ${report.compatibility.preservedFieldGroups.join(', ') || 'none declared'}`,
221
+ `Excluded ${report.compatibility.excludedFieldGroups.join(', ') || 'none declared'}`,
222
+ '',
223
+ ...report.limitations.map((item) => `Limitation: ${item}`),
224
+ '',
225
+ ].join('\n');
226
+ }
@@ -1,10 +1,11 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
- import { canonicalArtifactJson, sha256ArtifactDigest, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
- import { recordOrNull, requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
3
+ import { canonicalArtifactJsonV2, SORTED_JSON_V2, sha256ArtifactDigestV2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
5
5
  import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
6
+ import { parseBoundedJsonObject } from "./bounded-json.mjs";
6
7
  export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
7
- export const INVESTIGATION_MANIFEST_VERSION = 1;
8
+ export const INVESTIGATION_MANIFEST_VERSION = 2;
8
9
  export const MAX_INVESTIGATION_MANIFEST_ARTIFACTS = 16;
9
10
  export const MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES = 15 * 1024 * 1024;
10
11
  export const MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES = 32 * 1024 * 1024;
@@ -40,20 +41,17 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
40
41
  if (byteLength < 1 || byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
41
42
  throw new TypeError(`Artifact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
42
43
  }
43
- let parsed;
44
- try {
45
- parsed = JSON.parse(artifact.content);
44
+ if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
45
+ throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
46
46
  }
47
- catch {
48
- throw new TypeError(`Artifact ${index + 1} must be valid JSON.`);
49
- }
50
- const value = recordOrNull(parsed);
51
- if (!value)
52
- throw new TypeError(`Artifact ${index + 1} must contain one JSON object.`);
47
+ const value = parseBoundedJsonObject(artifact.content, {
48
+ label: `Artifact ${index + 1}`,
49
+ maximumBytes: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES,
50
+ });
53
51
  const schema = value.schema === undefined
54
52
  ? null
55
53
  : requireBoundedString(value.schema, `Artifact ${index + 1} schema`, 160);
56
- const canonical = canonicalArtifactJson(value);
54
+ const canonical = canonicalArtifactJsonV2(value);
57
55
  const contentDigestSha256 = digest(artifact.content);
58
56
  return Object.freeze({
59
57
  sequence: index + 1,
@@ -65,9 +63,6 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
65
63
  canonicalDigestSha256: digest(canonical),
66
64
  });
67
65
  });
68
- if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
69
- throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
70
- }
71
66
  const unsigned = Object.freeze({
72
67
  schema: INVESTIGATION_MANIFEST_SCHEMA,
73
68
  version: INVESTIGATION_MANIFEST_VERSION,
@@ -90,7 +85,11 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
90
85
  });
91
86
  return Object.freeze({
92
87
  ...unsigned,
93
- integrity: Object.freeze({ algorithm: 'SHA-256', digestSha256: await sha256ArtifactDigest(unsigned) }),
88
+ integrity: Object.freeze({
89
+ algorithm: 'SHA-256',
90
+ canonicalization: SORTED_JSON_V2,
91
+ digestSha256: await sha256ArtifactDigestV2(unsigned),
92
+ }),
94
93
  });
95
94
  }
96
95
  export function formatInvestigationManifest(manifest) {
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { buildInvestigationPlan, } from "./investigation-plan.mjs";
3
3
  import { CliUsageError } from "./errors.mjs";
4
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
4
5
  export const CLI_INVESTIGATION_RUN_SCHEMA = 'whoisleuth.cli.investigation-run';
5
6
  export const CLI_INVESTIGATION_RUN_VERSION = 1;
6
7
  export const MAX_INVESTIGATION_RUN_BYTES = 24 * 1024 * 1024;
@@ -9,50 +10,93 @@ function boundedResult(value) {
9
10
  throw new CliUsageError(`Investigation step output is limited to ${MAX_INVESTIGATION_RUN_BYTES} bytes.`);
10
11
  }
11
12
  try {
13
+ scanBoundedJson(value);
12
14
  return JSON.parse(value);
13
15
  }
14
- catch {
16
+ catch (cause) {
17
+ if (cause instanceof TypeError && cause.message !== 'Artefact input is not valid JSON.') {
18
+ throw new CliUsageError(`Investigation step output ${cause.message.replace(/^Artefact JSON /u, '')}`);
19
+ }
15
20
  return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').slice(0, MAX_INVESTIGATION_RUN_BYTES);
16
21
  }
17
22
  }
18
- function parseResumeState(input, recipe, subject) {
23
+ function resultSchema(value) {
24
+ return value && typeof value === 'object' && !Array.isArray(value) && typeof value.schema === 'string'
25
+ ? String(value.schema)
26
+ : null;
27
+ }
28
+ function sameArguments(left, right) {
29
+ return Array.isArray(left)
30
+ && left.length === right.length
31
+ && left.every((value, index) => typeof value === 'string' && value === right[index]);
32
+ }
33
+ function parseResumeState(input, plan) {
19
34
  if (!input)
20
35
  return [];
21
36
  if (Buffer.byteLength(input, 'utf8') > MAX_INVESTIGATION_RUN_BYTES)
22
37
  throw new CliUsageError('Investigation resume state exceeds the 24 MiB limit.');
38
+ const normalizedInput = input.replace(/^\uFEFF/u, '');
39
+ try {
40
+ scanBoundedJson(normalizedInput);
41
+ }
42
+ catch (cause) {
43
+ const detail = cause instanceof Error ? cause.message : '';
44
+ if (detail === 'Artefact input is not valid JSON.')
45
+ throw new CliUsageError('Investigation resume state must be valid JSON.');
46
+ throw new CliUsageError(detail ? `Investigation resume state ${detail.replace(/^Artefact JSON /u, '')}` : 'Investigation resume state must be valid JSON.');
47
+ }
23
48
  let parsed;
24
49
  try {
25
- parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
50
+ parsed = JSON.parse(normalizedInput);
26
51
  }
27
52
  catch {
28
53
  throw new CliUsageError('Investigation resume state must be valid JSON.');
29
54
  }
30
55
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
31
- if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== recipe || root.subject !== subject || !Array.isArray(root.completedSteps)) {
56
+ if (root.schema !== CLI_INVESTIGATION_RUN_SCHEMA || root.version !== CLI_INVESTIGATION_RUN_VERSION || root.recipe !== plan.recipe.id || root.subject !== plan.subject || !Array.isArray(root.completedSteps)) {
32
57
  throw new CliUsageError('Investigation resume state must match this versioned recipe and subject.');
33
58
  }
34
- return root.completedSteps.slice(0, 16).flatMap((value) => {
35
- if (!value || typeof value !== 'object' || Array.isArray(value))
36
- return [];
59
+ if (root.completedSteps.length > plan.steps.length) {
60
+ throw new CliUsageError('Investigation resume state contains more steps than the installed fixed recipe.');
61
+ }
62
+ const completed = [];
63
+ for (const [index, value] of root.completedSteps.entries()) {
64
+ const planned = plan.steps[index];
65
+ if (!planned || !value || typeof value !== 'object' || Array.isArray(value)) {
66
+ throw new CliUsageError('Investigation resume state contains a malformed or out-of-order step.');
67
+ }
37
68
  const item = value;
38
- if (typeof item.id !== 'string' || typeof item.command !== 'string' || !Array.isArray(item.arguments) || !['offline', 'network'].includes(String(item.mode)) || !Number.isSafeInteger(item.exitCode))
39
- return [];
40
- return [Object.freeze({
41
- id: item.id,
42
- command: item.command,
43
- arguments: Object.freeze(item.arguments.filter((entry) => typeof entry === 'string').slice(0, 32)),
44
- mode: item.mode,
45
- exitCode: Number(item.exitCode),
46
- result: item.result,
47
- })];
48
- });
69
+ if (item.id !== planned.id
70
+ || item.command !== planned.command
71
+ || item.mode !== planned.mode
72
+ || !sameArguments(item.arguments, planned.arguments)
73
+ || !Number.isSafeInteger(item.exitCode)) {
74
+ throw new CliUsageError('Investigation resume state does not match the installed fixed recipe.');
75
+ }
76
+ const exitCode = Number(item.exitCode);
77
+ if (exitCode !== 0 && exitCode !== 2) {
78
+ if (index !== root.completedSteps.length - 1) {
79
+ throw new CliUsageError('A failed investigation step must be the final retained step.');
80
+ }
81
+ break;
82
+ }
83
+ if (resultSchema(item.result) !== planned.produces) {
84
+ throw new CliUsageError('Investigation resume state contains output from an unexpected command contract.');
85
+ }
86
+ completed.push(Object.freeze({
87
+ id: planned.id,
88
+ command: planned.command,
89
+ arguments: planned.arguments,
90
+ mode: planned.mode,
91
+ exitCode,
92
+ result: item.result,
93
+ }));
94
+ }
95
+ return completed;
49
96
  }
50
97
  export async function runInvestigationRecipe(recipe, subjectValue, options) {
51
98
  const plan = buildInvestigationPlan(recipe, subjectValue, options.generatedAt);
52
- const prior = parseResumeState(options.resumeInput, recipe, plan.subject);
53
- const allowedStepIds = new Set(plan.steps.map((step) => step.id));
54
- if (prior.some((step) => !allowedStepIds.has(step.id)))
55
- throw new CliUsageError('Investigation resume state contains a step outside the fixed recipe.');
99
+ const prior = parseResumeState(options.resumeInput, plan);
56
100
  const completed = [...prior];
57
101
  let state = 'complete';
58
102
  let currentStep = null;
@@ -69,13 +113,17 @@ export async function runInvestigationRecipe(recipe, subjectValue, options) {
69
113
  break;
70
114
  }
71
115
  const result = await options.execute(step.command, step.arguments);
116
+ const parsedResult = boundedResult(result.stdout);
117
+ if ((result.exitCode === 0 || result.exitCode === 2) && resultSchema(parsedResult) !== step.produces) {
118
+ throw new CliUsageError(`Investigation step ${step.id} returned an unexpected command contract.`);
119
+ }
72
120
  completed.push(Object.freeze({
73
121
  id: step.id,
74
122
  command: step.command,
75
123
  arguments: step.arguments,
76
124
  mode: step.mode,
77
125
  exitCode: result.exitCode,
78
- result: boundedResult(result.stdout),
126
+ result: parsedResult,
79
127
  }));
80
128
  if (result.exitCode !== 0 && result.exitCode !== 2) {
81
129
  state = 'step_failed';