@slicedearth/whoisleuth-cli 1.45.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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  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 +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -1,11 +1,10 @@
1
- import { createReadStream } from 'node:fs';
2
1
  import { MAX_OFFLINE_ARTIFACT_BYTES, MAX_OFFLINE_PASSPHRASE_FILE_BYTES, } from "./artifact-verify.mjs";
3
2
  import { formatArchiveInspection, inspectWorkspaceArchive, } from "./archive-inspect.mjs";
4
3
  import { MAX_SIGNING_KEY_FILE_BYTES, formatEvidenceSignatureVerification, signEvidencePackage, verifyEvidencePackageSignature, } from "./evidence-signing.mjs";
5
4
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
6
5
  import EXIT_CODES from "./exit-codes.mjs";
7
6
  import { formatJsonDocument } from "./formatters/json.mjs";
8
- import { readSavedLookupInputBounded, } from "./saved-lookup.mjs";
7
+ import { readCliTextInput } from "./input.mjs";
9
8
  export function isEvidenceCommand(args) {
10
9
  return args.action === 'inspect-archive'
11
10
  || args.action === 'sign-artifact'
@@ -22,11 +21,10 @@ async function readArtifact(source, label, dependencies) {
22
21
  try {
23
22
  return dependencies.readArtifactInput
24
23
  ? await dependencies.readArtifactInput(source)
25
- : await readSavedLookupInputBounded(source
26
- ? createReadStream(source, { highWaterMark: 64 * 1024 })
27
- : dependencies.stdin, {
28
- limit: MAX_OFFLINE_ARTIFACT_BYTES,
24
+ : await readCliTextInput(source, dependencies.stdin, {
25
+ maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES,
29
26
  label,
27
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
30
28
  });
31
29
  }
32
30
  catch (error) {
@@ -35,11 +33,15 @@ async function readArtifact(source, label, dependencies) {
35
33
  throw new CliUsageError(`Could not read ${label.toLowerCase()}: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
36
34
  }
37
35
  }
38
- async function readKey(source, label, injected) {
36
+ async function readKey(source, label, injected, dependencies) {
39
37
  try {
40
38
  return injected
41
39
  ? await injected(source)
42
- : await readSavedLookupInputBounded(createReadStream(source, { highWaterMark: MAX_SIGNING_KEY_FILE_BYTES }), { limit: MAX_SIGNING_KEY_FILE_BYTES, label });
40
+ : await readCliTextInput(source, dependencies.stdin, {
41
+ maximumBytes: MAX_SIGNING_KEY_FILE_BYTES,
42
+ label,
43
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
44
+ });
43
45
  }
44
46
  catch (error) {
45
47
  if (error instanceof CliUsageError)
@@ -50,7 +52,11 @@ async function readKey(source, label, injected) {
50
52
  async function readPassphrase(source, dependencies) {
51
53
  const passphraseText = dependencies.readPassphraseFile
52
54
  ? await dependencies.readPassphraseFile(source)
53
- : await readSavedLookupInputBounded(createReadStream(source, { highWaterMark: MAX_OFFLINE_PASSPHRASE_FILE_BYTES }), { limit: MAX_OFFLINE_PASSPHRASE_FILE_BYTES, label: 'Passphrase file' });
55
+ : await readCliTextInput(source, dependencies.stdin, {
56
+ maximumBytes: MAX_OFFLINE_PASSPHRASE_FILE_BYTES,
57
+ label: 'Passphrase file',
58
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
59
+ });
54
60
  const passphrase = passphraseText.replace(/\r?\n$/u, '');
55
61
  if (!passphrase || /[\r\n\u0000]/u.test(passphrase)) {
56
62
  throw new CliUsageError('Passphrase file must contain exactly one non-empty UTF-8 line.');
@@ -85,7 +91,7 @@ export async function runEvidenceCommand(args, dependencies) {
85
91
  if (!input.trim()) {
86
92
  throw new CliUsageError('sign-artifact requires one reviewed artefact file or an artefact on stdin.');
87
93
  }
88
- const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile);
94
+ const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile, dependencies);
89
95
  const signed = await signEvidencePackage(input, privateKey, dependencies.now ? dependencies.now() : new Date().toISOString());
90
96
  dependencies.stdout.write(formatJsonDocument(signed));
91
97
  return EXIT_CODES.SUCCESS;
@@ -95,7 +101,7 @@ export async function runEvidenceCommand(args, dependencies) {
95
101
  throw new CliUsageError('verify-signature requires one signed package file or a package on stdin.');
96
102
  }
97
103
  const publicKey = args.publicKeySource
98
- ? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile)
104
+ ? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile, dependencies)
99
105
  : null;
100
106
  const report = await verifyEvidencePackageSignature(input, publicKey);
101
107
  if (!args.quiet) {
@@ -1,11 +1,15 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
3
- import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
- import { MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
3
+ import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, SORTED_JSON_V1, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
+ import { hasVerifiedApplicableIntegrity, hasVerifiedArtifactStructure, MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
5
+ import { parseBoundedJsonObject } from "./bounded-json.mjs";
5
6
  export const SIGNED_EVIDENCE_PACKAGE_SCHEMA = 'whoisleuth.signed-evidence-package';
6
- export const SIGNED_EVIDENCE_PACKAGE_VERSION = 1;
7
+ export const SIGNED_EVIDENCE_PACKAGE_VERSION = 2;
8
+ export const LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION = 1;
9
+ export const EVIDENCE_SIGNATURE_VERIFICATION_SCHEMA = 'whoisleuth.evidence-signature-verification';
10
+ export const EVIDENCE_SIGNATURE_VERIFICATION_VERSION = 2;
7
11
  export const EVIDENCE_SIGNATURE_ALGORITHM = 'Ed25519';
8
- export const EVIDENCE_SIGNATURE_CANONICALIZATION = 'sorted-json-v1';
12
+ export const EVIDENCE_SIGNATURE_CANONICALIZATION = SORTED_JSON_V2;
9
13
  export const MAX_SIGNING_KEY_FILE_BYTES = 16 * 1024;
10
14
  const SHA256_RE = /^[a-f0-9]{64}$/u;
11
15
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/u;
@@ -23,23 +27,7 @@ function record(value) {
23
27
  : null;
24
28
  }
25
29
  function parseObject(raw, label) {
26
- if (typeof raw !== 'string')
27
- throw new TypeError(`${label} must be UTF-8 JSON text.`);
28
- const bytes = Buffer.byteLength(raw, 'utf8');
29
- if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
30
- throw new TypeError(`${label} must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
31
- }
32
- let parsed;
33
- try {
34
- parsed = JSON.parse(raw);
35
- }
36
- catch {
37
- throw new TypeError(`${label} must be valid JSON.`);
38
- }
39
- const value = record(parsed);
40
- if (!value)
41
- throw new TypeError(`${label} must contain one JSON object.`);
42
- return value;
30
+ return parseBoundedJsonObject(raw, { label, maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES });
43
31
  }
44
32
  function hasExactKeys(value, expected) {
45
33
  const keys = Object.keys(value);
@@ -54,6 +42,12 @@ function timestamp(value) {
54
42
  throw new TypeError('Signature time must be a valid timestamp.');
55
43
  return new Date(parsed).toISOString();
56
44
  }
45
+ function canonicalTimestamp(value) {
46
+ const normalized = timestamp(value);
47
+ if (value !== normalized)
48
+ throw new TypeError('Signature time must use canonical UTC timestamp text.');
49
+ return normalized;
50
+ }
57
51
  function importPrivateKey(pem) {
58
52
  if (Buffer.byteLength(pem, 'utf8') > MAX_SIGNING_KEY_FILE_BYTES) {
59
53
  throw new TypeError(`Private key file exceeds ${MAX_SIGNING_KEY_FILE_BYTES} bytes.`);
@@ -96,8 +90,8 @@ function spkiDer(key) {
96
90
  function keyId(publicDer) {
97
91
  return createHash('sha256').update(publicDer).digest('hex');
98
92
  }
99
- function payloadBytes(payload) {
100
- return Buffer.from(canonicalArtifactJson(payload), 'utf8');
93
+ function payloadBytes(payload, canonicalization) {
94
+ return Buffer.from(canonicalArtifactJsonFor(payload, canonicalization), 'utf8');
101
95
  }
102
96
  async function signableArtifact(raw) {
103
97
  const artifact = parseObject(raw, 'Evidence artefact');
@@ -105,6 +99,9 @@ async function signableArtifact(raw) {
105
99
  if (!['case_response_packet', 'investigation_capsule', 'signed_review_artifact'].includes(verification.artifact.kind)) {
106
100
  throw new TypeError('Only reviewed response packets, investigation capsules, and supported review manifests can be signed.');
107
101
  }
102
+ if (!hasVerifiedArtifactStructure(verification) || !hasVerifiedApplicableIntegrity(verification)) {
103
+ throw new TypeError('Evidence signing requires verified structure and a verified applicable integrity contract.');
104
+ }
108
105
  return { artifact, verification };
109
106
  }
110
107
  export async function signEvidencePackage(raw, privateKeyPem, signedAt = new Date().toISOString()) {
@@ -124,7 +121,7 @@ export async function signEvidencePackage(raw, privateKeyPem, signedAt = new Dat
124
121
  canonicalization: EVIDENCE_SIGNATURE_CANONICALIZATION,
125
122
  publicKeySpkiDerBase64: publicDer.toString('base64'),
126
123
  keyIdSha256: keyId(publicDer),
127
- valueBase64: sign(null, payloadBytes(payload), privateKey).toString('base64'),
124
+ valueBase64: sign(null, payloadBytes(payload, SORTED_JSON_V2), privateKey).toString('base64'),
128
125
  }),
129
126
  });
130
127
  }
@@ -134,12 +131,11 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
134
131
  const artifact = record(value.artifact);
135
132
  if (!hasExactKeys(value, PACKAGE_KEYS)
136
133
  || value.schema !== SIGNED_EVIDENCE_PACKAGE_SCHEMA
137
- || value.version !== SIGNED_EVIDENCE_PACKAGE_VERSION
134
+ || (value.version !== LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION && value.version !== SIGNED_EVIDENCE_PACKAGE_VERSION)
138
135
  || !artifact
139
136
  || !signature
140
137
  || !hasExactKeys(signature, SIGNATURE_KEYS)
141
138
  || signature.algorithm !== EVIDENCE_SIGNATURE_ALGORITHM
142
- || signature.canonicalization !== EVIDENCE_SIGNATURE_CANONICALIZATION
143
139
  || typeof signature.publicKeySpkiDerBase64 !== 'string'
144
140
  || signature.publicKeySpkiDerBase64.length > 4096
145
141
  || !BASE64_RE.test(signature.publicKeySpkiDerBase64)
@@ -150,7 +146,17 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
150
146
  || !SHA256_RE.test(signature.keyIdSha256)) {
151
147
  throw new TypeError('Signed evidence package has an unsupported or malformed envelope.');
152
148
  }
153
- const signedAt = timestamp(value.signedAt);
149
+ let canonicalization;
150
+ try {
151
+ canonicalization = resolveArtifactCanonicalization(value.version, signature.canonicalization, [
152
+ { version: LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION, canonicalization: SORTED_JSON_V1, explicit: true },
153
+ { version: SIGNED_EVIDENCE_PACKAGE_VERSION, canonicalization: SORTED_JSON_V2, explicit: true },
154
+ ], 'Signed evidence package');
155
+ }
156
+ catch {
157
+ throw new TypeError('Signed evidence package has an unsupported or malformed envelope.');
158
+ }
159
+ const signedAt = canonicalTimestamp(value.signedAt);
154
160
  const publicDer = Buffer.from(signature.publicKeySpkiDerBase64, 'base64');
155
161
  if (keyId(publicDer) !== signature.keyIdSha256) {
156
162
  throw new TypeError('Signed evidence package public-key identifier does not match its embedded key.');
@@ -158,16 +164,20 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
158
164
  const embeddedPublicKey = importPublicKey(publicDer, 'Embedded evidence-signing public key');
159
165
  const payload = {
160
166
  schema: SIGNED_EVIDENCE_PACKAGE_SCHEMA,
161
- version: SIGNED_EVIDENCE_PACKAGE_VERSION,
167
+ version: value.version,
162
168
  signedAt,
163
169
  artifact,
164
170
  };
165
171
  const signatureBytes = Buffer.from(signature.valueBase64, 'base64');
166
172
  if (signatureBytes.length !== 64
167
- || !verify(null, payloadBytes(payload), embeddedPublicKey, signatureBytes)) {
173
+ || !verify(null, payloadBytes(payload, canonicalization), embeddedPublicKey, signatureBytes)) {
168
174
  throw new TypeError('Signed evidence package failed Ed25519 verification.');
169
175
  }
170
- const artifactVerification = await verifyOfflineArtifact(JSON.stringify(artifact));
176
+ let artifactVerification = null;
177
+ try {
178
+ artifactVerification = await verifyOfflineArtifact(JSON.stringify(artifact));
179
+ }
180
+ catch { /* Signature validity remains independently reportable. */ }
171
181
  let publicKeyMatched = null;
172
182
  if (trustedPublicKeyPem) {
173
183
  const trustedDer = spkiDer(importPublicKey(trustedPublicKeyPem, 'Trusted public key file'));
@@ -176,24 +186,41 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
176
186
  throw new TypeError('Signed evidence package does not match the trusted public key.');
177
187
  }
178
188
  return Object.freeze({
179
- schema: 'whoisleuth.evidence-signature-verification',
180
- version: 1,
181
- valid: true,
189
+ schema: EVIDENCE_SIGNATURE_VERIFICATION_SCHEMA,
190
+ version: EVIDENCE_SIGNATURE_VERIFICATION_VERSION,
182
191
  state: 'signature_valid',
183
- signerTrust: trustedPublicKeyPem ? 'trusted_key' : 'embedded_key_only',
184
- signedAt,
185
- keyIdSha256: signature.keyIdSha256,
186
- publicKeyMatched,
192
+ signature: Object.freeze({
193
+ state: 'valid',
194
+ signerTrust: trustedPublicKeyPem ? 'trusted_key' : 'embedded_key_only',
195
+ signedAt,
196
+ keyIdSha256: signature.keyIdSha256,
197
+ publicKeyMatched,
198
+ }),
187
199
  artifact: Object.freeze({
188
- schema: artifactVerification.artifact.schema,
189
- version: artifactVerification.artifact.version,
190
- kind: artifactVerification.artifact.kind,
200
+ schema: artifactVerification?.artifact.schema
201
+ ?? (typeof artifact.schema === 'string' && /^[a-z0-9.-]{1,160}$/u.test(artifact.schema) ? artifact.schema : null),
202
+ version: artifactVerification?.artifact.version
203
+ ?? (Number.isSafeInteger(artifact.version ?? artifact.schemaVersion)
204
+ && Number(artifact.version ?? artifact.schemaVersion) >= 1
205
+ && Number(artifact.version ?? artifact.schemaVersion) <= 1_000
206
+ ? Number(artifact.version ?? artifact.schemaVersion)
207
+ : null),
208
+ kind: artifactVerification?.artifact.kind ?? 'unverified_artifact',
209
+ assurance: Object.freeze({
210
+ state: artifactVerification?.state ?? 'not_verified',
211
+ structure: artifactVerification?.checks.structure ?? 'not_checked',
212
+ contentIntegrity: artifactVerification?.checks.contentIntegrity ?? 'not_checked',
213
+ contentIntegrityScope: artifactVerification?.checks.contentIntegrityScope ?? 'not_checked',
214
+ }),
191
215
  }),
192
216
  limitations: Object.freeze([
193
217
  trustedPublicKeyPem
194
218
  ? 'The signature and supplied public key match; recipient trust still depends on obtaining that public key through an authenticated channel.'
195
219
  : 'The signature is internally valid for the embedded public key, but signer identity was not authenticated because no trusted public key was supplied.',
196
220
  'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialised file.',
221
+ ...(artifactVerification
222
+ ? [`Embedded-artifact assurance is reported separately as ${artifactVerification.state}; it does not change the cryptographic signature result.`]
223
+ : ['The embedded artefact did not pass a supported local assurance contract. The cryptographic signature remains valid for its bytes, but no structure or inner-integrity claim is made.']),
197
224
  'A valid signature proves the canonical package content has not changed since signing; it does not establish that the retained observations or analyst statements are accurate.',
198
225
  'WHOISleuth does not generate, store, recover, rotate, or publish signing keys.',
199
226
  ]),
@@ -203,11 +230,12 @@ export function formatEvidenceSignatureVerification(report) {
203
230
  const lines = [
204
231
  'WHOISleuth evidence signature verification',
205
232
  `State: ${report.state}`,
206
- `Signer trust: ${report.signerTrust}`,
207
- `Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
208
- `Signed: ${report.signedAt}`,
209
- `Key ID: sha256:${report.keyIdSha256}`,
210
- `Trusted public key: ${report.publicKeyMatched === null ? 'not supplied' : 'matched'}`,
233
+ `Signer trust: ${report.signature.signerTrust}`,
234
+ `Artifact: ${report.artifact.kind} · ${report.artifact.schema ?? 'unrecognised'} v${report.artifact.version ?? 'unrecognised'}`,
235
+ `Artifact assurance: ${report.artifact.assurance.state}`,
236
+ `Signed: ${report.signature.signedAt}`,
237
+ `Key ID: sha256:${report.signature.keyIdSha256}`,
238
+ `Trusted public key: ${report.signature.publicKeyMatched === null ? 'not supplied' : 'matched'}`,
211
239
  ];
212
240
  for (const limitation of report.limitations)
213
241
  lines.push(`Limitation: ${limitation}`);
@@ -1,8 +1,9 @@
1
- import { createRequire } from 'node:module';
2
1
  import { projectCliLookupComparisonInput } from "./compare.mjs";
3
2
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
- const require = createRequire(import.meta.url);
5
- const { version: APPLICATION_VERSION } = require('../package.json');
3
+ import { validateLookupEvidenceArtifactStructure } from "./artifact-structure.mjs";
4
+ import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
5
+ import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, serializeLookupEvidence, } from "../lib/evidence-export.mjs";
6
+ const APPLICATION_VERSION = WHOISLEUTH_APPLICATION_VERSION;
6
7
  function objectOrNull(value) {
7
8
  return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
8
9
  }
@@ -27,9 +28,15 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
27
28
  || result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
28
29
  throw new TypeError('Lookup evidence builder returned an unsupported report contract.');
29
30
  }
31
+ validateLookupEvidenceArtifactStructure(result);
32
+ serializeLookupEvidence(result);
30
33
  return result;
31
34
  }
32
35
  function formatCliEvidenceExport(document, compact = false) {
33
- return `${JSON.stringify(document, null, compact ? 0 : 2)}\n`;
36
+ const output = `${serializeLookupEvidence(document, !compact)}\n`;
37
+ if (new TextEncoder().encode(output).byteLength > LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES) {
38
+ throw new TypeError('Lookup evidence exceeds the 5 MiB portable file limit.');
39
+ }
40
+ return output;
34
41
  }
35
42
  export { APPLICATION_VERSION, buildCliEvidenceExport, formatCliEvidenceExport };
@@ -26,8 +26,8 @@ function renderRegistrarComparison(comparison) {
26
26
  : '';
27
27
  return `<div class="table-scroll"><table><caption>Normalized registry and registrar RDAP publication comparison</caption><thead><tr><th scope="col">Field</th><th scope="col">Result</th><th scope="col">Registry RDAP</th><th scope="col">Registrar RDAP</th></tr></thead><tbody>${rows}</tbody></table></div>${omitted}`;
28
28
  }
29
- function formatLookupEvidenceHtml(document) {
30
- const report = buildLookupEvidenceReport(document);
29
+ function formatLookupEvidenceHtml(document, options = {}) {
30
+ const report = buildLookupEvidenceReport(document, options);
31
31
  const title = `Lookup evidence report: ${report.title}`;
32
32
  return `<!doctype html>
33
33
  <html lang="en">
@@ -60,7 +60,7 @@ function formatLookupEvidenceHtml(document) {
60
60
  <section class="block"><h2>Network evidence</h2>${renderGroups(report.networkGroups)}</section>
61
61
  <section class="block"><h2>Collection diagnostics</h2>${renderFields(report.diagnostics)}</section>
62
62
  <section class="block"><h2>Limitations</h2><ul>${report.limitations.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></section>
63
- <footer>Offline, self-contained report. No scripts, forms, external resources, or active links are included.</footer>
63
+ <footer>Offline, self-contained report. No scripts, forms, external resources, or active links are included.${report.attribution ? `<br>${escapeHtml(report.attribution)}` : ''}</footer>
64
64
  </main>
65
65
  </body>
66
66
  </html>
@@ -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)
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
+ }