@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,12 +1,21 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { latestObservationCohort } from "../packages/evidence/latest-observations.mjs";
3
+ import { canonicalCaaRecord, canonicalDsRecord, canonicalMxRecord, canonicalDomainControlRecords, canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
4
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
5
+ import { postureTransferRestriction } from "../packages/evidence/domain-posture-context.mjs";
6
+ import { MAX_DOMAIN_CONTROL_INPUT_RECORDS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION } from "../packages/contracts/domain-control-manifest.mjs";
2
7
  import { scanBoundedJson, } from "../lib/bounded-json.mjs";
3
- import { CLI_DOMAIN_CONTROL_REVIEW_INPUT_KEYS, CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_LIMITATIONS, CLI_DOMAIN_CONTROL_REVIEW_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_VERSION, DOMAIN_CONTROL_REVIEW_VERSION, MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES, MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH, MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS, MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES, MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS, MIN_DOMAIN_CONTROL_REVIEW_LOOKUPS, } from "../packages/contracts/domain-control-review.mjs";
4
- import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, } from "../lib/domain-control-flight-recorder.mjs";
8
+ import { CLI_DOMAIN_CONTROL_REVIEW_INPUT_KEYS, CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_LIMITATIONS, CLI_DOMAIN_CONTROL_REVIEW_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_VERSION, PUBLIC_CLI_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_CLI_DOMAIN_CONTROL_REVIEW_VERSIONS, DOMAIN_CONTROL_REVIEW_VERSION, MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES, MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH, MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS, MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES, MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS, MIN_DOMAIN_CONTROL_REVIEW_LOOKUPS, } from "../packages/contracts/domain-control-review.mjs";
9
+ import { MAX_FLIGHT_RECORDER_VALUES, mergeConcurrentDomainControlFields, } from "../lib/domain-control-flight-recorder.mjs";
5
10
  import { DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, reviewDomainControlManifest, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
6
11
  import { CliUsageError } from "./errors.mjs";
7
12
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
8
13
  export { CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_VERSION, MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES, MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH, MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS, MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES, MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS, };
9
14
  const CLI_REVIEW_INPUT_KEY_SET = new Set(CLI_DOMAIN_CONTROL_REVIEW_INPUT_KEYS);
15
+ export const DOMAIN_CONTROL_REVIEW_SOURCE_FIELDS = Object.freeze({
16
+ registry_nameservers: 'nameservers', delegation_ds: 'ds', mail_exchangers: 'mx', caa_policy: 'caa',
17
+ tls_certificate: 'tlsIssuer', tls_public_key: 'tlsSpkiSha256', registrar_lock: 'registrarLock',
18
+ });
10
19
  function record(value) {
11
20
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
12
21
  }
@@ -21,116 +30,147 @@ function state(value) {
21
30
  return 'unavailable';
22
31
  }
23
32
  function text(value, maximum = 500) {
24
- if (typeof value !== 'string')
33
+ if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
25
34
  return null;
26
- const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum);
35
+ const normalized = value.trim();
27
36
  return normalized || null;
28
37
  }
29
- function list(value, normalizer = (item) => text(item)) {
30
- if (!Array.isArray(value))
31
- return [];
32
- return [...new Set(value.slice(0, 128).flatMap((item) => {
33
- const normalized = normalizer(item);
34
- return normalized ? [normalized.toLowerCase()] : [];
35
- }))].sort().slice(0, 32);
36
- }
37
- function hostname(value) {
38
- return text(value, 253)?.toLowerCase().replace(/\.$/u, '') ?? null;
39
- }
40
- function mxValue(value) {
41
- const item = record(value);
42
- const exchange = hostname(item.exchange ?? item.host ?? item.value);
43
- if (!exchange)
44
- return text(value);
45
- const priority = Number.isSafeInteger(item.priority) && Number(item.priority) >= 0 ? Number(item.priority) : null;
46
- return priority === null ? exchange : `${priority} ${exchange}`;
47
- }
48
- function caaValue(value) {
49
- const item = record(value);
50
- const tag = text(item.tag, 32)?.toLowerCase();
51
- const recordValue = text(item.value, 500)?.toLowerCase();
52
- if (!tag || !recordValue)
53
- return text(value);
54
- const critical = Number.isSafeInteger(item.critical) ? Number(item.critical) : Number.isSafeInteger(item.flags) ? Number(item.flags) : 0;
55
- return `${critical} ${tag} ${recordValue}`;
56
- }
57
- function dsValue(value) {
58
- const item = record(value);
59
- const keyTag = Number.isSafeInteger(item.keyTag) ? item.keyTag : null;
60
- const algorithm = Number.isSafeInteger(item.algorithm) ? item.algorithm : null;
61
- const digestType = Number.isSafeInteger(item.digestType) ? item.digestType : null;
62
- const digest = text(item.digest, 512)?.toLowerCase();
63
- return keyTag !== null && algorithm !== null && digestType !== null && digest
64
- ? `${keyTag} ${algorithm} ${digestType} ${digest}`
65
- : text(value);
66
- }
67
- function field(id, source, sourceState, values) {
68
- return Object.freeze({ id, source, state: sourceState, values: Object.freeze([...values].sort()) });
38
+ function sourceClock(...values) {
39
+ const declared = values.filter((value) => value !== undefined && value !== null);
40
+ const times = declared.map(normalizeExplicitIsoTimestamp);
41
+ return times.length && times.every((time) => time !== null && time === times[0]) ? times[0] : null;
42
+ }
43
+ function sourceState(value) {
44
+ const result = state(value.status);
45
+ return result === 'observed' && (value.complete === false || value.truncated === true) ? 'partial' : result;
46
+ }
47
+ function dnsRecordState(dns, name) {
48
+ const query = record(record(dns.diagnostics)[name]);
49
+ if (!Object.keys(query).length)
50
+ return sourceState(dns);
51
+ const values = record(dns.records)[name];
52
+ const contradictoryAbsence = query.status === 'not_found' && Array.isArray(values) && values.length > 0;
53
+ if ((query.status === 'success' || query.status === 'not_found') && query.truncated === false && query.discarded === 0
54
+ && !contradictoryAbsence && (query.error === undefined || query.error === null))
55
+ return 'observed';
56
+ return state(query.status) === 'observed' || query.status === 'not_found' ? 'partial' : state(query.status);
57
+ }
58
+ function field(id, source, sourceState, values, observedAt) {
59
+ return Object.freeze({ id, source, state: sourceState, values: Object.freeze([...values].sort()), observedAt });
60
+ }
61
+ function recordField(id, source, sourceState, input, normalize, observedAt) {
62
+ const rows = Array.isArray(input) ? input.slice(0, MAX_DOMAIN_CONTROL_INPUT_RECORDS) : [];
63
+ const values = new Set();
64
+ let invalid = Array.isArray(input) || (input === undefined && sourceState !== 'observed') ? 0 : 1;
65
+ for (const row of rows) {
66
+ let value = '';
67
+ try {
68
+ value = normalize(row);
69
+ }
70
+ catch (error) {
71
+ if (!(error instanceof TypeError))
72
+ throw error;
73
+ }
74
+ if (value)
75
+ values.add(value);
76
+ else
77
+ invalid += 1;
78
+ }
79
+ const sorted = [...values].sort();
80
+ const omitted = Math.max(0, (Array.isArray(input) ? input.length : 0) - rows.length)
81
+ + Math.max(0, sorted.length - MAX_FLIGHT_RECORDER_VALUES);
82
+ return field(id, invalid || omitted ? `${source} (${invalid} invalid; ${omitted} omitted records)` : source, sourceState === 'observed' && (invalid || omitted) ? 'partial' : sourceState, sorted.slice(0, MAX_FLIGHT_RECORDER_VALUES), observedAt);
69
83
  }
70
84
  function diagnostic(document, key) {
71
- return state(record(document.diagnostics)[key] && record(record(document.diagnostics)[key]).status);
85
+ return sourceState(record(record(document.diagnostics)[key]));
86
+ }
87
+ function registryClock(document, key) {
88
+ const detail = record(record(document.diagnostics)[key]);
89
+ const publication = record(document[key]);
90
+ const named = key === 'rdap' ? 'fetchedAt' : 'queriedAt';
91
+ return sourceClock(detail.observedAt, detail[named], publication.observedAt, publication[named]);
72
92
  }
73
93
  function registryParsed(document) {
74
94
  const rdapState = diagnostic(document, 'rdap');
75
95
  const whoisState = diagnostic(document, 'whois');
76
96
  const rdap = record(record(document.rdap).parsed);
77
97
  if (rdapState === 'observed' && Object.keys(rdap).length)
78
- return { parsed: rdap, source: 'Registry RDAP', sourceState: rdapState };
98
+ return { parsed: rdap, source: 'Registry RDAP', sourceState: rdapState, observedAt: registryClock(document, 'rdap') };
79
99
  const whois = record(record(document.whois).parsed);
80
- return { parsed: whois, source: 'WHOIS', sourceState: whoisState };
100
+ return { parsed: whois, source: 'WHOIS', sourceState: whoisState, observedAt: registryClock(document, 'whois') };
81
101
  }
82
102
  function lockValue(parsed) {
83
- const statuses = list(parsed.statuses ?? parsed.status, (item) => text(item, 120)?.replace(/[\s_-]+/gu, '').toLowerCase() ?? null);
84
- return [statuses.some((item) => item.includes('transferprohibited')) ? 'required' : 'not_required'];
103
+ const input = parsed.statuses ?? parsed.status;
104
+ const statuses = Array.isArray(input) ? canonicalPostureRecords('registration_lock', input) : null;
105
+ return statuses ? postureTransferRestriction(statuses) : null;
85
106
  }
86
107
  function pageIdentityValue(availability) {
87
108
  const identity = record(availability.pageIdentity);
88
109
  const components = [
89
- text(identity.bodySha256 ?? availability.pageBodySha256, 64),
90
- text(identity.faviconHash ?? availability.faviconHash, 128),
91
- text(identity.title ?? availability.pageTitle, 240),
110
+ text(identity.bodySha256, 64),
111
+ text(identity.faviconHash, 128),
112
+ text(identity.title, 240),
92
113
  ].filter((item) => Boolean(item));
93
- return components.length ? [components.join(' | ').toLowerCase()] : [];
114
+ return components.length ? [components.join(' | ')] : [];
94
115
  }
95
116
  export function domainControlObservationFromSavedLookup(document) {
96
117
  const availability = record(document.availability);
97
118
  const dns = record(availability.dns);
98
119
  const dnsRecords = record(dns.records);
120
+ const dnsAt = sourceClock(dns.observedAt, dns.checkedAt);
99
121
  const delegation = record(dns.delegation);
100
122
  const delegationRecords = record(delegation.records);
101
123
  const tls = record(availability.tls);
102
- const tlsState = state(tls.status);
124
+ const tlsState = sourceState(tls);
125
+ const tlsAt = sourceClock(tls.observedAt, tls.checkedAt);
103
126
  const certificate = record(tls.certificate);
104
127
  const publicKey = record(certificate.publicKey);
105
128
  const http = record(availability.http);
106
- const httpState = state(http.status);
107
129
  const registry = registryParsed(document);
108
130
  const rdapParsed = record(record(document.rdap).parsed);
109
131
  const rdapState = diagnostic(document, 'rdap');
110
- const dnsState = state(dns.status);
132
+ const rdapAt = registryClock(document, 'rdap');
133
+ const scalar = (id, source, status, value, observedAt, maximum = 500) => recordField(id, source, status, [value], (item) => text(item, maximum) ?? '', observedAt);
134
+ const hostname = (value) => canonicalDomainControlRecords([value], 'nameservers')[0] ?? '';
135
+ const identity = record(availability.pageIdentity);
136
+ const identityValues = pageIdentityValue(availability);
111
137
  const fields = [
112
- field('registrar', registry.source, registry.sourceState, list([record(registry.parsed.registrar).name ?? registry.parsed.registrar], (item) => text(item, 300))),
113
- field('registrar_lock', registry.source, registry.sourceState, registry.sourceState === 'observed' ? lockValue(registry.parsed) : []),
114
- field('registry_dnssec', 'Registry RDAP', rdapState, list([rdapParsed.dnssec], (item) => text(item, 80))),
115
- field('registry_nameservers', 'Registry RDAP', rdapState, list(rdapParsed.nameservers, hostname)),
116
- field('whois_nameservers', 'WHOIS', diagnostic(document, 'whois'), list(record(record(document.whois).parsed).nameservers, hostname)),
117
- field('delegated_nameservers', 'DNS', dnsState, list(dnsRecords.ns, hostname)),
118
- field('delegation_ds', 'DNS delegation', state(delegation.status ?? dns.status), list(delegationRecords.ds ?? rdapParsed.dsData, dsValue)),
119
- field('mail_exchangers', 'DNS', dnsState, list(dnsRecords.mx, mxValue)),
120
- field('caa_policy', 'DNS', dnsState, list(record(dns.caaPolicy).records ?? dnsRecords.caa, caaValue)),
121
- field('tls_certificate', 'TLS', tlsState, list([certificate.fingerprintSha256 ?? tls.fingerprintSha256], (item) => text(item, 128))),
122
- field('tls_public_key', 'TLS', tlsState, list([publicKey.fingerprintSha256 ?? tls.spkiSha256], (item) => text(item, 128))),
123
- field('http_origin', 'HTTP', httpState, list([http.finalOrigin ?? availability.httpFinalOrigin], (item) => text(item, 500))),
124
- field('page_identity', 'Static page identity', state(record(availability.pageIdentity).status ?? availability.pageIdentityStatus ?? http.status), pageIdentityValue(availability)),
138
+ scalar('registrar', registry.source, registry.sourceState, record(registry.parsed.registrar).name ?? registry.parsed.registrar, registry.observedAt, 300),
139
+ scalar('registrar_lock', registry.source, registry.sourceState, lockValue(registry.parsed), registry.observedAt, 20),
140
+ scalar('registry_dnssec', 'Registry RDAP', rdapState, rdapParsed.dnssec, rdapAt, 80),
141
+ recordField('registry_nameservers', 'Registry RDAP', rdapState, rdapParsed.nameservers, hostname, rdapAt),
142
+ recordField('whois_nameservers', 'WHOIS', diagnostic(document, 'whois'), record(record(document.whois).parsed).nameservers, hostname, registryClock(document, 'whois')),
143
+ recordField('delegated_nameservers', 'DNS NS', dnsRecordState(dns, 'ns'), dnsRecords.ns, hostname, dnsAt),
144
+ recordField('delegation_ds', 'DNS delegation', Object.hasOwn(delegationRecords, 'ds') ? sourceState(delegation) : 'unsupported', delegationRecords.ds, canonicalDsRecord, sourceClock(delegation.observedAt)),
145
+ recordField('mail_exchangers', 'DNS MX', dnsRecordState(dns, 'mx'), dnsRecords.mx, canonicalMxRecord, dnsAt),
146
+ recordField('caa_policy', 'DNS CAA', dnsRecordState(dns, 'caa'), dnsRecords.caa, canonicalCaaRecord, dnsAt),
147
+ scalar('tls_certificate', 'TLS', tlsState, certificate.fingerprintSha256 ?? tls.fingerprintSha256, tlsAt, 128),
148
+ scalar('tls_public_key', 'TLS', tlsState, publicKey.fingerprintSha256 ?? tls.spkiSha256, tlsAt, 128),
149
+ scalar('http_origin', 'HTTP', sourceState(http), http.finalOrigin, sourceClock(http.observedAt, http.checkedAt)),
150
+ field('page_identity', 'Static page identity', sourceState(identity) === 'observed' && !identityValues.length ? 'partial' : sourceState(identity), identityValues, sourceClock(identity.observedAt)),
125
151
  ];
126
152
  return Object.freeze({
127
- domain: document.registrableDomain,
128
- observedAt: document.generatedAt,
129
- collectionDepth: document.mode,
130
- fields: Object.freeze(fields.filter((item) => DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS.includes(item.id))),
153
+ domain: document.registrableDomain, capturedAt: document.generatedAt, collectionDepth: document.mode,
154
+ fields: Object.freeze(fields.map((item) => item.state === 'observed' && item.observedAt !== null
155
+ && Date.parse(item.observedAt) > Date.parse(document.generatedAt) ? Object.freeze({ ...item, state: 'partial' }) : item)),
156
+ });
157
+ }
158
+ function reviewFields(document, observation) {
159
+ return observation.fields.map((candidate) => {
160
+ if (candidate.id !== 'tls_certificate')
161
+ return candidate;
162
+ const issuer = record(record(record(document.availability).tls).certificate).issuer;
163
+ const value = record(issuer);
164
+ const commonNames = Array.isArray(value.commonNames) ? value.commonNames : [];
165
+ const organisationNames = Array.isArray(value.organizationNames) ? value.organizationNames : [];
166
+ return recordField(candidate.id, candidate.source, candidate.state, [commonNames[0] ?? organisationNames[0] ?? issuer], (item) => text(item, 300) ?? '', candidate.observedAt);
131
167
  });
132
168
  }
133
169
  export function buildCliDomainControlReview(inputText, generatedAt = new Date().toISOString()) {
170
+ const reviewTime = normalizeExplicitIsoTimestamp(generatedAt);
171
+ if (!reviewTime)
172
+ throw new CliUsageError('Domain-control review time must include a valid explicit timezone.');
173
+ generatedAt = reviewTime;
134
174
  if (Buffer.byteLength(inputText, 'utf8') > MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES) {
135
175
  throw new CliUsageError(`Domain-control review input is limited to ${MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES} bytes.`);
136
176
  }
@@ -161,7 +201,7 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
161
201
  }
162
202
  const input = record(parsed);
163
203
  if (input.schema !== CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
164
- || input.version !== CLI_DOMAIN_CONTROL_REVIEW_VERSION
204
+ || !SUPPORTED_CLI_DOMAIN_CONTROL_REVIEW_VERSIONS.some((version) => version === input.version)
165
205
  || !Array.isArray(input.lookups)) {
166
206
  throw new CliUsageError(`Domain-control review input must use ${CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version ${CLI_DOMAIN_CONTROL_REVIEW_VERSION}.`);
167
207
  }
@@ -173,42 +213,49 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
173
213
  throw new CliUsageError(`Domain-control review requires from ${MIN_DOMAIN_CONTROL_REVIEW_LOOKUPS} to ${MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS} saved Lookup documents.`);
174
214
  }
175
215
  const manifest = verifyDomainControlManifest(input.manifest);
216
+ if (input.version === PUBLIC_CLI_DOMAIN_CONTROL_REVIEW_VERSION && manifest.version !== PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION) {
217
+ throw new CliUsageError(`Public domain-control review input version ${PUBLIC_CLI_DOMAIN_CONTROL_REVIEW_VERSION} requires manifest version ${PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION}.`);
218
+ }
176
219
  const lookups = input.lookups.map((item, index) => parseSavedLookupDocument(JSON.stringify(item), { label: `Lookup ${index + 1}` }));
177
- const latest = new Map();
220
+ const byDomain = new Map();
178
221
  for (const lookup of lookups) {
179
- const current = latest.get(lookup.registrableDomain);
180
- if (!current || Date.parse(lookup.generatedAt) > Date.parse(current.generatedAt))
181
- latest.set(lookup.registrableDomain, lookup);
222
+ const group = byDomain.get(lookup.registrableDomain) ?? [];
223
+ group.push(lookup);
224
+ byDomain.set(lookup.registrableDomain, group);
182
225
  }
183
- const observations = [...latest.values()].map(domainControlObservationFromSavedLookup);
226
+ let ignoredHistoricalLookups = 0;
227
+ const selected = [...byDomain.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([domain, documents]) => {
228
+ const cohort = latestObservationCohort(documents, (document) => document.generatedAt);
229
+ ignoredHistoricalLookups += cohort.superseded;
230
+ const captures = cohort.latest.map((document) => ({ document, observation: domainControlObservationFromSavedLookup(document) }));
231
+ const first = captures[0];
232
+ if (!first || !cohort.observedAt || cohort.undated.length)
233
+ throw new CliUsageError('Saved Lookup observation times could not be ordered.');
234
+ const mergedFields = mergeConcurrentDomainControlFields(captures.map((capture) => capture.observation.fields));
235
+ const futureCapture = Date.parse(cohort.observedAt) > Date.parse(generatedAt);
236
+ const observation = Object.freeze({
237
+ domain,
238
+ capturedAt: cohort.observedAt,
239
+ collectionDepth: captures.every((capture) => capture.observation.collectionDepth === first.observation.collectionDepth)
240
+ ? first.observation.collectionDepth : 'unknown',
241
+ fields: futureCapture ? mergedFields.map((field) => ({ ...field, state: 'partial' })) : mergedFields,
242
+ });
243
+ const fields = mergeConcurrentDomainControlFields(captures.map((capture) => reviewFields(capture.document, capture.observation)));
244
+ return { observation, reviewFields: futureCapture || observation.collectionDepth === 'unknown'
245
+ ? fields.map((field) => ({ ...field, state: 'partial' })) : fields };
246
+ });
247
+ const observations = selected.map((item) => item.observation);
184
248
  const review = reviewDomainControlManifest({
185
249
  schema: DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA,
186
250
  version: DOMAIN_CONTROL_REVIEW_VERSION,
187
251
  manifest,
188
- observations: observations.map((item) => ({
189
- domain: item.domain,
190
- fields: Object.fromEntries(item.fields.flatMap((candidate) => {
191
- const mapping = {
192
- registry_nameservers: 'nameservers',
193
- delegation_ds: 'ds',
194
- mail_exchangers: 'mx',
195
- caa_policy: 'caa',
196
- tls_certificate: 'tlsIssuer',
197
- tls_public_key: 'tlsSpkiSha256',
198
- registrar_lock: 'registrarLock',
199
- };
200
- const target = mapping[candidate.id];
252
+ observations: selected.map(({ observation, reviewFields: fields }) => ({
253
+ domain: observation.domain,
254
+ fields: Object.fromEntries(fields.flatMap((candidate) => {
255
+ const target = DOMAIN_CONTROL_REVIEW_SOURCE_FIELDS[candidate.id];
201
256
  if (!target)
202
257
  return [];
203
- const candidateValues = candidate.id === 'tls_certificate'
204
- ? list([record(record(record(latest.get(item.domain)?.availability).tls).certificate).issuer], (value) => {
205
- const issuer = record(value);
206
- const commonNames = Array.isArray(issuer.commonNames) ? issuer.commonNames : [];
207
- const organisationNames = Array.isArray(issuer.organizationNames) ? issuer.organizationNames : [];
208
- return text(commonNames[0] ?? organisationNames[0] ?? value, 300);
209
- })
210
- : candidate.values;
211
- return [[target, { state: candidate.state, values: candidateValues, source: candidate.source, observedAt: item.observedAt }]];
258
+ return [[target, { state: candidate.state, values: candidate.values, source: candidate.source, observedAt: candidate.observedAt }]];
212
259
  })),
213
260
  })),
214
261
  }, generatedAt);
@@ -221,7 +268,7 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
221
268
  input: Object.freeze({
222
269
  lookupsReceived: lookups.length,
223
270
  latestDomainObservations: observations.length,
224
- ignoredHistoricalLookups: lookups.length - observations.length,
271
+ ignoredHistoricalLookups,
225
272
  }),
226
273
  limitations: CLI_DOMAIN_CONTROL_REVIEW_LIMITATIONS,
227
274
  });
package/cli/errors.mjs CHANGED
@@ -1,4 +1,6 @@
1
+ import { stripVTControlCharacters } from 'node:util';
1
2
  const MAX_CLI_ERROR_MESSAGE_LENGTH = 300;
3
+ const MAX_CLI_DIAGNOSTIC_BYTES = 4_096;
2
4
  const CLI_DEFAULT_IGNORABLE_RE = /\p{Default_Ignorable_Code_Point}/u;
3
5
  const CLI_DEFAULT_IGNORABLE_GLOBAL_RE = /\p{Default_Ignorable_Code_Point}/gu;
4
6
  class CliUsageError extends Error {
@@ -23,10 +25,40 @@ function boundedCliErrorMessage(error, fallback = 'Unexpected command failure')
23
25
  function hasUnsafeCliText(value) {
24
26
  return /[\x00-\x1f\x7f-\x9f]/u.test(value) || CLI_DEFAULT_IGNORABLE_RE.test(value);
25
27
  }
28
+ // A transient stderr preview, never a checkpoint field. Bound before collecting
29
+ // and redact request URLs and credential-shaped fields before shortening text.
30
+ function createCliDiagnosticOutput() {
31
+ const chunks = [];
32
+ let remaining = MAX_CLI_DIAGNOSTIC_BYTES;
33
+ return Object.freeze({
34
+ stream: {
35
+ isTTY: false,
36
+ columns: 80,
37
+ write(chunk) {
38
+ if (remaining > 0 && chunk.length > 0) {
39
+ const bytes = Buffer.from(chunk.slice(0, remaining), 'utf8').subarray(0, remaining);
40
+ chunks.push(bytes.toString('utf8'));
41
+ remaining -= bytes.byteLength;
42
+ }
43
+ return true;
44
+ },
45
+ },
46
+ value() {
47
+ const source = stripVTControlCharacters(chunks.join('')).replace(CLI_DEFAULT_IGNORABLE_GLOBAL_RE, '');
48
+ if (!source.trim())
49
+ return '';
50
+ const redacted = source
51
+ .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"']*/giu, '[URL omitted]')
52
+ .replace(/\b(authorization|cookie|password|passphrase|token|secret|api[-_ ]?key)\s*[:=]\s*(?:"[^"]*(?:"|$)|'[^']*(?:'|$)|[^\r\n,;]*)/giu, '$1=[omitted]')
53
+ .replace(/\b(?:bearer|basic)\s+[^\s,;]+/giu, '[credential omitted]');
54
+ return boundedCliErrorMessage(redacted);
55
+ },
56
+ });
57
+ }
26
58
  function boundedCliInputError(error, label) {
27
59
  if (error instanceof CliUsageError)
28
60
  return error;
29
61
  const safeLabel = label.replace(/[^A-Za-z0-9 ()_-]+/gu, '').trim().slice(0, 80) || 'Input';
30
62
  return new CliUsageError(`${safeLabel} could not be read as a bounded regular file.`);
31
63
  }
32
- export { CliUsageError, MAX_CLI_ERROR_MESSAGE_LENGTH, boundedCliErrorMessage, boundedCliInputError, hasUnsafeCliText, };
64
+ export { CliUsageError, MAX_CLI_ERROR_MESSAGE_LENGTH, boundedCliErrorMessage, boundedCliInputError, createCliDiagnosticOutput, hasUnsafeCliText, };
@@ -5,6 +5,7 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
5
5
  import EXIT_CODES from "./exit-codes.mjs";
6
6
  import { formatJsonDocument } from "./formatters/json.mjs";
7
7
  import { readCliTextInput } from "./input.mjs";
8
+ import { assessEvidenceSignerTrust, formatSignerTrustReport, MAX_SIGNER_TRUST_STORE_BYTES } from "./signer-trust.mjs";
8
9
  import { runDiscriminatedCommandHandler } from "./discriminated-command-handlers.mjs";
9
10
  export function isEvidenceCommand(args) {
10
11
  return args.action === 'inspect-archive'
@@ -18,12 +19,12 @@ export function evidenceCommandFailureLabel(action) {
18
19
  return 'Evidence signing';
19
20
  return 'Evidence signature verification';
20
21
  }
21
- async function readArtifact(source, label, dependencies) {
22
+ async function readArtifact(source, label, dependencies, maximumBytes = MAX_OFFLINE_ARTIFACT_BYTES) {
22
23
  try {
23
24
  return dependencies.readArtifactInput
24
25
  ? await dependencies.readArtifactInput(source)
25
26
  : await readCliTextInput(source, dependencies.stdin, {
26
- maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES,
27
+ maximumBytes,
27
28
  label,
28
29
  ...(dependencies.signal ? { signal: dependencies.signal } : {}),
29
30
  });
@@ -105,12 +106,22 @@ async function runVerifySignatureCommand(args, dependencies) {
105
106
  ? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile, dependencies)
106
107
  : null;
107
108
  const report = await verifyEvidencePackageSignature(input, publicKey);
109
+ let trustReport = null;
110
+ if (args.trustStoreSource) {
111
+ const trustRaw = await readArtifact(args.trustStoreSource, 'Signer trust store', dependencies, MAX_SIGNER_TRUST_STORE_BYTES);
112
+ try {
113
+ trustReport = assessEvidenceSignerTrust(report, trustRaw, dependencies.now ? dependencies.now() : new Date().toISOString());
114
+ }
115
+ catch (error) {
116
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid signer trust store.'));
117
+ }
118
+ }
108
119
  if (!args.quiet) {
109
120
  dependencies.stdout.write(args.output === 'json'
110
- ? formatJsonDocument(report)
111
- : formatEvidenceSignatureVerification(report));
121
+ ? formatJsonDocument(trustReport ?? report)
122
+ : trustReport ? formatSignerTrustReport(trustReport) : formatEvidenceSignatureVerification(report));
112
123
  }
113
- return EXIT_CODES.SUCCESS;
124
+ return trustReport && trustReport.state !== 'trusted' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
114
125
  }
115
126
  const EVIDENCE_COMMAND_HANDLERS = Object.freeze({
116
127
  'inspect-archive': runInspectArchiveCommand,
@@ -216,11 +216,11 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
216
216
  ]),
217
217
  });
218
218
  }
219
- export function formatEvidenceSignatureVerification(report) {
219
+ export function formatEvidenceSignatureVerification(report, includeStandaloneTrust = true) {
220
220
  const lines = [
221
221
  'WHOISleuth evidence signature verification',
222
222
  `State: ${report.state}`,
223
- `Signer trust: ${report.signature.signerTrust}`,
223
+ ...(includeStandaloneTrust ? [`Signer trust: ${report.signature.signerTrust}`] : []),
224
224
  `Artifact: ${report.artifact.kind} · ${report.artifact.schema ?? 'unrecognised'} v${report.artifact.version ?? 'unrecognised'}`,
225
225
  `Artifact assurance: ${report.artifact.assurance.state}`,
226
226
  `Signed: ${report.signature.signedAt}`,
@@ -1,6 +1,6 @@
1
1
  import { domainToASCII } from 'node:url';
2
2
  import { exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "../lib/bounded-contract-normalizers.mjs";
3
- import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, EXTERNAL_FINDING_CATEGORIES, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_DOMAINS, parseExternalFindingsDocument, } from "../packages/interchange/external-findings-import.mjs";
3
+ import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, EXTERNAL_FINDING_CATEGORIES, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, parseExternalFindingsDocument, retainExternalFindingLimitations, } from "../packages/interchange/external-findings-import.mjs";
4
4
  export const EXTERNAL_OBSERVATION_MAPPING_SCHEMA = 'whoisleuth.external-observation-mapping';
5
5
  export const EXTERNAL_OBSERVATION_MAPPING_VERSION = 1;
6
6
  export const MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES = 4 * 1024 * 1024;
@@ -49,9 +49,9 @@ function domain(value, label) {
49
49
  function limitations(value) {
50
50
  if (value === undefined)
51
51
  return Object.freeze([]);
52
- if (!Array.isArray(value) || value.length > 8)
53
- throw new TypeError('profile.limitations must contain no more than 8 entries.');
54
- return Object.freeze([...new Set(value.map((entry, index) => requireBoundedString(entry, `profile.limitations[${index}]`, 240)))]);
52
+ if (!Array.isArray(value) || value.length > MAX_EXTERNAL_FINDING_LIMITATIONS)
53
+ throw new TypeError(`profile.limitations must contain no more than ${MAX_EXTERNAL_FINDING_LIMITATIONS} entries.`);
54
+ return Object.freeze([...new Set(value.map((entry, index) => requireBoundedString(entry, `profile.limitations[${index}]`, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH)))]);
55
55
  }
56
56
  export function mapExternalObservations(inputRaw) {
57
57
  const input = requireRecord(inputRaw, 'External observation mapping');
@@ -140,11 +140,10 @@ export function mapExternalObservations(inputRaw) {
140
140
  source,
141
141
  findings: selected.map((finding) => ({
142
142
  ...finding,
143
- limitations: [
144
- ...finding.limitations,
143
+ limitations: retainExternalFindingLimitations(finding.limitations, [
145
144
  `Mapped locally with declarative profile ${profile.id} version ${profile.version}; the profile does not verify the upstream field semantics.`,
146
- ...(truncated ? ['The supplied records exceeded the browser import boundary; only the first deterministic bounded findings were retained.'] : []),
147
- ].slice(0, 8),
145
+ ...(truncated ? [`The supplied records exceeded the browser import boundary; ${selected.length} of ${seen.size} unique findings were retained in deterministic order.`] : []),
146
+ ]),
148
147
  })),
149
148
  };
150
149
  return parseExternalFindingsDocument(document);
@@ -1,6 +1,7 @@
1
1
  import { registryAccessProfileLabel } from "../registry-access.mjs";
2
- import { technologyEvidenceRoles, } from "../../lib/technology-evidence-role.mjs";
2
+ import { appendTechnologyLines } from "./terminal-technology.mjs";
3
3
  import { appendDeliveryMetadataLines, appendPublicationMetadataLines } from "./terminal-metadata.mjs";
4
+ import { securityPostureReview, summarizeSecurityPostureReview } from "../../lib/website-security-posture.mjs";
4
5
  import { appendSection, boundedTerminalComponent, boundedTerminalList, boundedTerminalWithSuffix, MAX_TLS_TERMINAL_ALT_NAMES, MAX_TLS_TERMINAL_PURPOSES, safeTerminalValue, terminalCount, terminalCountSummary, terminalDisplayCount, terminalRecord, titleCase, } from "./terminal-shared.mjs";
5
6
  const MAX_LOOKUP_TERMINAL_RECORDS = 5;
6
7
  const MAX_LOOKUP_TERMINAL_NAMES = 5;
@@ -10,13 +11,6 @@ const MAX_LOOKUP_TERMINAL_LIMITATIONS = 3;
10
11
  const MAX_LOOKUP_TERMINAL_CIDRS = 5;
11
12
  const MAX_LOOKUP_TERMINAL_FINDINGS = 5;
12
13
  const MAX_LOOKUP_TERMINAL_ABUSE_ROUTES = 6;
13
- function terminalTechnologyRoleNames(findings, role) {
14
- const names = findings
15
- .filter((finding) => technologyEvidenceRoles(finding).includes(role))
16
- .slice(0, 6)
17
- .map((finding) => safeTerminalValue(terminalRecord(finding).name, 'Unnamed indicator'));
18
- return names.length ? boundedTerminalList(names, Math.max(0, findings.filter((finding) => technologyEvidenceRoles(finding).includes(role)).length - names.length)) : 'None retained';
19
- }
20
14
  function formatLookupDnsRecord(value) {
21
15
  if (typeof value === 'string' || typeof value === 'number')
22
16
  return safeTerminalValue(value);
@@ -244,6 +238,11 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
244
238
  targetLines.push(`Input host ${safeTerminalValue(document.inputHostname)}`);
245
239
  targetLines.push(`Registry query ${safeTerminalValue(document.registrableDomain)}`);
246
240
  }
241
+ if (availability.observationHostname) {
242
+ targetLines.push(`DNS/TLS/web ${safeTerminalValue(availability.observationHostname)}`);
243
+ }
244
+ if (availability.webObservationMode === 'selected_url')
245
+ targetLines.push('Website scope Explicitly selected URL; query omitted from provenance');
247
246
  if (availability.applicable) {
248
247
  targetLines.push(`Availability ${titleCase(availability.state)}`);
249
248
  targetLines.push(`Confidence ${titleCase(availability.confidence)}`);
@@ -486,7 +485,7 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
486
485
  const technology = terminalRecord(availability.technologyProfile);
487
486
  const browserLibraries = terminalRecord(technology.browserLibraryProfile);
488
487
  const posture = terminalRecord(availability.securityPosture);
489
- const postureSummary = terminalRecord(posture.summary);
488
+ const postureReview = summarizeSecurityPostureReview(posture.findings);
490
489
  const pageIdentity = terminalRecord(availability.pageIdentity);
491
490
  const pageRole = terminalRecord(availability.pageRoleProfile);
492
491
  const clientBehavior = terminalRecord(availability.clientBehaviorProfile);
@@ -578,47 +577,20 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
578
577
  if (detail !== 'summary' && visible.length)
579
578
  websiteLines.push(`Declarations ${safeTerminalValue(visible.join('; '))}`);
580
579
  }
581
- if (technology.status || technology.source === 'derived') {
582
- const findings = Array.isArray(technology.findings) ? technology.findings : [];
583
- websiteLines.push(`Technology ${titleCase(technology.status)} · ${findings.length} indicator${findings.length === 1 ? '' : 's'}`);
584
- const visible = findings.slice(0, 6).map((finding) => {
585
- const item = terminalRecord(finding);
586
- const qualifiers = [item.category, item.confidence ? `${item.confidence} signature strength` : null].filter(Boolean).map((value) => safeTerminalValue(value));
587
- return `${safeTerminalValue(item.name, 'Unnamed indicator')}${qualifiers.length ? ` (${qualifiers.join(', ')})` : ''}`;
588
- });
589
- if (detail !== 'summary' && visible.length) {
590
- const omitted = findings.length - visible.length;
591
- websiteLines.push(`Indicators ${safeTerminalValue(`${visible.join('; ')}${omitted > 0 ? `; +${omitted} more` : ''}`)}`);
592
- }
593
- if (detail !== 'summary') {
594
- const nameservers = Array.isArray(availability.nameservers)
595
- ? availability.nameservers.slice(0, MAX_LOOKUP_TERMINAL_NAMES).map((value) => safeTerminalValue(value))
596
- : [];
597
- websiteLines.push(`Nameservers ${nameservers.length ? boundedTerminalList(nameservers, Math.max(0, availability.nameservers.length - nameservers.length)) : 'Unavailable'} · identity does not establish operator or web-host ownership`);
598
- websiteLines.push(`Observed edge ${terminalTechnologyRoleNames(findings, 'observed_edge')}`);
599
- websiteLines.push(`App platform ${terminalTechnologyRoleNames(findings, 'application_platform')}`);
600
- websiteLines.push(`Framework/run ${terminalTechnologyRoleNames(findings, 'framework_runtime')}`);
601
- websiteLines.push(`Embedded deps ${terminalTechnologyRoleNames(findings, 'embedded_dependency')}`);
602
- websiteLines.push('Origin host Not established from retained evidence');
603
- }
604
- if (detail !== 'summary' && (browserLibraries.profileVersion === 1 || browserLibraries.source === 'derived')) {
605
- const libraries = Array.isArray(browserLibraries.findings) ? browserLibraries.findings : [];
606
- const advisoryMatches = libraries.filter((finding) => terminalCount(terminalRecord(finding).advisoryCount) > 0).length;
607
- websiteLines.push(`JS libraries ${titleCase(browserLibraries.status)} · ${libraries.length} apparent · `
608
- + `${advisoryMatches} with catalogue advisory match${advisoryMatches === 1 ? '' : 'es'}`);
609
- }
610
- }
580
+ appendTechnologyLines(websiteLines, {
581
+ technology, libraries: browserLibraries, nameservers: availability.nameservers,
582
+ generatedAt: document.generatedAt, detail,
583
+ });
611
584
  if (posture.status || posture.source === 'derived') {
612
585
  websiteLines.push(`Posture ${titleCase(posture.status)}`);
613
- if (detail !== 'summary' && Object.keys(postureSummary).length)
614
- websiteLines.push(`Posture counts ${terminalCount(postureSummary.observed)} observed · `
615
- + `${terminalCount(postureSummary.potentialExposure)} potential exposure · `
616
- + `${terminalCount(postureSummary.observedAbsence)} observed absence · `
617
- + `${terminalCount(postureSummary.unavailable)} unavailable`);
586
+ if (detail !== 'summary' && Array.isArray(posture.findings))
587
+ websiteLines.push(`Posture checks Needs review ${postureReview.needsReview} · `
588
+ + `Other findings ${postureReview.otherFindings} · `
589
+ + `Could not assess ${postureReview.unavailable}`);
618
590
  if (detail === 'verbose' && positiveSourceStatus(posture.status)) {
619
591
  const findings = boundedFindingLabels(posture.findings, (finding) => {
620
- const state = finding.state ? ` (${titleCase(finding.state)})` : '';
621
- return `${safeTerminalValue(finding.label, 'Unlabelled finding')}${state}`;
592
+ const review = securityPostureReview(finding);
593
+ return `${safeTerminalValue(finding.label, 'Unlabelled finding')}${review.label ? ` (${review.label})` : ''}`;
622
594
  });
623
595
  if (findings)
624
596
  websiteLines.push(`Posture labels ${findings}`);
@@ -6,7 +6,9 @@ const MAX_TLS_TERMINAL_PURPOSES = 8;
6
6
  // through soft hyphens, combining grapheme joiners, variation selectors, tag
7
7
  // characters, bidi controls, or other zero-width formatting code points.
8
8
  const TERMINAL_DEFAULT_IGNORABLE_RE = /\p{Default_Ignorable_Code_Point}/gu;
9
- function safeTerminalValue(value, fallback = '—') {
9
+ function safeTerminalValue(value, fallback = '—', maximum = MAX_TERMINAL_VALUE_LENGTH) {
10
+ if (!Number.isSafeInteger(maximum) || maximum < 1)
11
+ throw new TypeError('Terminal text requires a positive finite character bound.');
10
12
  if (value === null || value === undefined || value === '')
11
13
  return fallback;
12
14
  const normalized = String(value)
@@ -16,8 +18,8 @@ function safeTerminalValue(value, fallback = '—') {
16
18
  .trim();
17
19
  if (!normalized)
18
20
  return fallback;
19
- return normalized.length > MAX_TERMINAL_VALUE_LENGTH
20
- ? `${normalized.slice(0, MAX_TERMINAL_VALUE_LENGTH - 1)}…`
21
+ return normalized.length > maximum
22
+ ? `${normalized.slice(0, maximum - 1)}…`
21
23
  : normalized;
22
24
  }
23
25
  function boundedTerminalList(values, omitted) {