@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

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 (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -1,15 +1,8 @@
1
1
  import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../evidence/artifact-integrity.mjs";
2
+ import { PASSIVE_MAIL_INTERPRETATION, PASSIVE_MAIL_LABELS } from "../contracts/passive-mail.mjs";
2
3
  import { BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION, normalizeBulkProfileContext, normalizeBulkSessionResult, } from "../workspace/bulk-session-model.mjs";
3
4
  import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, BULK_MAIL_EXPOSURE_VERSION, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../contracts/investigation-portability.mjs";
4
5
  export { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, BULK_MAIL_EXPOSURE_VERSION, MAX_BULK_MAIL_EXPOSURE_ROWS };
5
- const STATE_LABELS = {
6
- authenticated_mail: 'Receiving mail with SPF and DMARC',
7
- evidence_incomplete: 'Mail evidence incomplete',
8
- mail_auth_gap: 'Receiving mail with an authentication gap',
9
- mail_auth_incomplete: 'Receiving mail with incomplete authentication evidence',
10
- no_explicit_mx: 'No explicit MX observed',
11
- null_mx: 'Null MX observed',
12
- };
13
6
  function timestamp(value) {
14
7
  if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)))
15
8
  return null;
@@ -51,16 +44,16 @@ function exposureState(row) {
51
44
  }
52
45
  function stateDetail(state) {
53
46
  if (state === 'authenticated_mail') {
54
- return 'An explicit receiving MX plus SPF and DMARC publications were observed.';
47
+ return 'Explicit MX, SPF and DMARC publications were observed.';
55
48
  }
56
49
  if (state === 'null_mx') {
57
50
  return 'A null MX explicitly declaring no inbound mail was observed.';
58
51
  }
59
52
  if (state === 'mail_auth_gap') {
60
- return 'A receiving MX was observed while SPF or DMARC was observed absent.';
53
+ return 'An MX publication was observed while SPF or DMARC was observed absent.';
61
54
  }
62
55
  if (state === 'mail_auth_incomplete') {
63
- return 'A receiving MX was observed, but SPF or DMARC collection was incomplete.';
56
+ return 'An MX publication was observed, but SPF or DMARC collection was incomplete.';
64
57
  }
65
58
  if (state === 'no_explicit_mx') {
66
59
  return 'DNS collection completed without an explicit MX. This is not proof that SMTP delivery is impossible.';
@@ -173,7 +166,7 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
173
166
  return {
174
167
  domain: item.domain,
175
168
  state,
176
- label: STATE_LABELS[state],
169
+ label: PASSIVE_MAIL_LABELS[state],
177
170
  detail: stateDetail(state),
178
171
  ...relation(state, profile, profileSourceState, profileContextComparable, profileContextLimitation),
179
172
  mutationTypes: item.mutationTypes.slice(0, 40),
@@ -217,8 +210,9 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
217
210
  counts,
218
211
  profileContextUnevaluatedCount,
219
212
  limitations: [
213
+ PASSIVE_MAIL_INTERPRETATION,
220
214
  'This review uses compact Bulk evidence already collected and makes no additional request.',
221
- 'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
215
+ 'Null MX, no explicit MX, published mail policies, policy gaps, and incomplete evidence remain separate states.',
222
216
  'SMTP delivery, mailbox existence, catch-all behaviour, and message acceptance were not tested.',
223
217
  ],
224
218
  };
@@ -0,0 +1,57 @@
1
+ import { array, boolean, enumeration, exact, integer, text } from "../evidence/artifact-structure.mjs";
2
+ export const MAX_CAPTURE_CHANNEL_OBSERVATIONS = 500;
3
+ export const CAPTURE_REQUEST_CHANNELS = ['navigation', 'document', 'frame', 'script', 'stylesheet', 'image', 'font', 'media', 'xhr', 'fetch', 'beacon', 'event_source', 'other'];
4
+ export const CAPTURE_DISABLED_SURFACES = ['service_workers', 'dedicated_workers', 'shared_workers', 'websockets', 'webrtc', 'webtransport', 'downloads'];
5
+ export function emptyCaptureCoverage() {
6
+ return { attempts: [], omittedAttempts: 0, disabledSurfaces: [...CAPTURE_DISABLED_SURFACES], interactions: 'not_exercised', websocketRefusals: 0, directConnectionRefusals: 0 };
7
+ }
8
+ export function captureCoverageIsPartial(value) {
9
+ return Boolean(value.omittedAttempts || value.websocketRefusals || value.directConnectionRefusals || value.attempts.some(row => row.state !== 'observed'));
10
+ }
11
+ export function readCaptureCoverage(raw) {
12
+ const value = exact(raw, ['attempts', 'omittedAttempts', 'disabledSurfaces', 'interactions', 'websocketRefusals', 'directConnectionRefusals'], 'Capture coverage');
13
+ const disabledSurfaces = array(value.disabledSurfaces, 'Disabled capture surfaces', CAPTURE_DISABLED_SURFACES.length).map(value => enumeration(value, CAPTURE_DISABLED_SURFACES, 'Disabled capture surface'));
14
+ if (new Set(disabledSurfaces).size !== CAPTURE_DISABLED_SURFACES.length)
15
+ throw new TypeError('Capture coverage must identify every required disabled surface.');
16
+ const attempts = array(value.attempts, 'Capture request attempts', MAX_CAPTURE_CHANNEL_OBSERVATIONS).map(raw => {
17
+ const row = exact(raw, ['position', 'channel', 'method', 'origin', 'state', 'reason', 'collectionStarted'], 'Capture request attempt');
18
+ const state = enumeration(row.state, ['observed', 'refused', 'unavailable'], 'Capture request state');
19
+ const method = enumeration(row.method, ['read', 'non_read', 'unknown'], 'Capture method category');
20
+ const reason = row.reason === null ? null : enumeration(row.reason, ['method', 'request_bound', 'host_bound', 'response_bound', 'shutdown', 'address_or_transport'], 'Capture request qualification');
21
+ boolean(row.collectionStarted, 'Collector-started flag');
22
+ let origin = null;
23
+ if (row.origin !== null) {
24
+ const candidate = text(row.origin, 'Capture request origin', 500), parsed = new URL(candidate);
25
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== candidate || parsed.username || parsed.password)
26
+ throw new TypeError('Capture request coverage retains origins only.');
27
+ origin = candidate;
28
+ }
29
+ if (state === 'observed' && (reason !== null || method !== 'read' || !origin || row.collectionStarted !== true)
30
+ || state !== 'observed' && reason === null
31
+ || reason === 'method' && (method === 'read' || row.collectionStarted)
32
+ || ['request_bound', 'host_bound', 'shutdown'].includes(reason ?? '') && row.collectionStarted
33
+ || state === 'unavailable' && reason !== 'address_or_transport')
34
+ throw new TypeError('Capture request coverage has contradictory collection states.');
35
+ return { position: integer(row.position, 'Capture request position', 1, 1_000_000), channel: enumeration(row.channel, CAPTURE_REQUEST_CHANNELS, 'Capture request channel'),
36
+ method, origin, state, reason, collectionStarted: row.collectionStarted };
37
+ });
38
+ if (attempts.some((row, index) => index > 0 && row.position <= attempts[index - 1].position))
39
+ throw new TypeError('Capture request positions must be unique and ordered.');
40
+ return { attempts, omittedAttempts: integer(value.omittedAttempts, 'Omitted capture attempts', 0, 1_000_000), disabledSurfaces: [...CAPTURE_DISABLED_SURFACES],
41
+ interactions: enumeration(value.interactions, ['not_exercised'], 'Capture interaction coverage'),
42
+ websocketRefusals: integer(value.websocketRefusals, 'WebSocket refusals', 0, 1_000_000), directConnectionRefusals: integer(value.directConnectionRefusals, 'Direct-connection refusals', 0, 1_000_000) };
43
+ }
44
+ export function captureAttemptDescription(value) {
45
+ const status = value.state === 'observed' ? 'Response supplied' : value.state === 'refused' ? 'Response refused' : 'Response unavailable';
46
+ const reason = { method: 'non-read method', request_bound: 'request limit', host_bound: 'host limit', response_bound: 'response byte limit', shutdown: 'capture closing', address_or_transport: 'address or transport check' };
47
+ return `${status}${value.reason ? ` · ${reason[value.reason]}` : ''} · collector ${value.collectionStarted ? 'started' : 'not started'}`;
48
+ }
49
+ /** Unseen in a bounded capture is not the same as disabled or absent. */
50
+ export function captureChannelSummary(value) {
51
+ return CAPTURE_REQUEST_CHANNELS.map(channel => {
52
+ const rows = value.attempts.filter(row => row.channel === channel);
53
+ return { channel, observed: rows.filter(row => row.state === 'observed').length, refused: rows.filter(row => row.state === 'refused').length,
54
+ unavailable: rows.filter(row => row.state === 'unavailable').length,
55
+ coverage: rows.length ? 'recorded' : value.omittedAttempts ? 'unknown' : 'not_observed' };
56
+ });
57
+ }
@@ -0,0 +1,100 @@
1
+ import { array, exact, iso, record, text } from "../evidence/artifact-structure.mjs";
2
+ import { pageObservationOrigin } from "./page-behaviour.mjs";
3
+ import { CONTEXT_REVIEW_SCHEMA, CONTEXT_REVIEW_VERSION, MAX_CONTEXT_RECORDS, MAX_CONTEXT_INPUT_BYTES } from "../contracts/context-review.mjs";
4
+ import { parseBoundedJson } from "../analysis/bounded-json.mjs";
5
+ export { CONNECTOR_INPUT_SCHEMA, CONNECTOR_INPUT_VERSION } from "../contracts/context-review.mjs";
6
+ const KNOWN_FIELDS = new Set(['url', 'serverUrl', 'command', 'args', 'env', 'headers', 'type', 'transport', 'disabled', 'enabled', 'capabilities', 'package', 'oauth', 'auth']);
7
+ const CAPABILITIES = ['tools', 'resources', 'prompts', 'logging', 'completions'];
8
+ const PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9.+_-]*)?$/u;
9
+ function countObject(value, label) {
10
+ if (value === undefined)
11
+ return 0;
12
+ const keys = Object.keys(record(value, label));
13
+ if (keys.length > MAX_CONTEXT_RECORDS)
14
+ throw new TypeError(`${label} exceeds the metadata bound.`);
15
+ return keys.length;
16
+ }
17
+ /** Configuration is data only. Values of arguments, environment, headers and auth are never returned. */
18
+ export function readConnectorConfiguration(raw) {
19
+ const root = record(raw, 'Connector configuration');
20
+ const roots = ['mcpServers', 'servers'].filter(key => Object.hasOwn(root, key));
21
+ if (roots.length !== 1)
22
+ throw new TypeError('Select a configuration with exactly one mcpServers or servers object.');
23
+ if (Object.keys(root).some(key => !roots.includes(key)))
24
+ throw new TypeError('Select only the connector configuration section; unrelated settings are not reviewed.');
25
+ const entries = Object.entries(record(root[roots[0]], 'Connector entries'));
26
+ if (entries.length > MAX_CONTEXT_RECORDS)
27
+ throw new TypeError('Connector count exceeds the review bound.');
28
+ return entries.map(([name, value]) => {
29
+ text(name, 'Connector name', 200);
30
+ const item = record(value, 'Connector definition');
31
+ if (Object.keys(item).length > MAX_CONTEXT_RECORDS)
32
+ throw new TypeError('Connector definition exceeds the field bound.');
33
+ if (item.enabled !== undefined && typeof item.enabled !== 'boolean' || item.disabled !== undefined && typeof item.disabled !== 'boolean'
34
+ || item.enabled !== undefined && item.disabled !== undefined && item.enabled === item.disabled)
35
+ throw new TypeError('Connector enablement declarations are invalid or conflicting.');
36
+ const suppliedUrl = item.url ?? item.serverUrl;
37
+ if (item.url !== undefined && item.serverUrl !== undefined && item.url !== item.serverUrl)
38
+ throw new TypeError('Conflicting connector URL declarations require a deliberate selection.');
39
+ const origin = suppliedUrl === undefined ? null : pageObservationOrigin(suppliedUrl);
40
+ if (suppliedUrl !== undefined && !origin)
41
+ throw new TypeError('Remote connector URL must be bounded HTTP(S) without credentials.');
42
+ const command = item.command === undefined ? null : text(item.command, 'Local connector command', 2_000);
43
+ const basename = command?.split(/[\\/]/u).at(-1) ?? null;
44
+ const executable = basename && /^[A-Za-z0-9._-]{1,120}$/u.test(basename) ? basename : null;
45
+ const args = item.args === undefined ? [] : array(item.args, 'Connector arguments', MAX_CONTEXT_RECORDS).map(value => text(value, 'Connector argument', 8_192, true));
46
+ let packageIdentity = item.package === undefined ? null : text(item.package, 'Declared connector package', 240);
47
+ if (packageIdentity !== null && !PACKAGE.test(packageIdentity))
48
+ throw new TypeError('Declared package must be a package identity, not an executable expression or URL.');
49
+ if (packageIdentity === null && ['npx', 'npx.cmd', 'uvx'].includes(executable ?? '')) {
50
+ // Only a simple first package operand is interpreted. Complex invocations remain unknown.
51
+ const operands = args.filter(value => !['--yes', '-y', '--no-install'].includes(value));
52
+ if (operands[0] && PACKAGE.test(operands[0]))
53
+ packageIdentity = operands[0];
54
+ }
55
+ const capabilityObject = item.capabilities === undefined ? {} : record(item.capabilities, 'Declared capabilities');
56
+ if (Object.keys(capabilityObject).length > MAX_CONTEXT_RECORDS)
57
+ throw new TypeError('Declared capabilities exceed the bound.');
58
+ return { name, transport: origin && command ? 'ambiguous' : origin ? 'remote' : command ? 'local' : 'unknown', enabled: item.enabled === false || item.disabled === true ? 'disabled' : item.enabled === true || item.disabled === false ? 'enabled' : 'unknown', origin, executable, package: packageIdentity,
59
+ argumentCount: args.length, environmentCount: countObject(item.env, 'Environment metadata'), headerCount: countObject(item.headers, 'Header metadata'),
60
+ authenticationDeclared: item.auth !== undefined || item.oauth !== undefined,
61
+ declaredCapabilities: CAPABILITIES.filter(key => Object.hasOwn(capabilityObject, key) && capabilityObject[key] !== false && capabilityObject[key] !== null),
62
+ unsupportedFields: Object.keys(item).filter(key => !KNOWN_FIELDS.has(key)).length + Object.keys(capabilityObject).filter(key => !CAPABILITIES.includes(key)).length,
63
+ privateLocationOmitted: suppliedUrl !== undefined && new URL(suppliedUrl).href !== `${origin}/` || command !== null && command !== executable,
64
+ };
65
+ });
66
+ }
67
+ export function connectorProvenancePresentation(raw, reviewedAt) {
68
+ iso(reviewedAt, 'Review time');
69
+ const input = exact(raw, ['current', 'previous'], 'Connector review');
70
+ const current = readConnectorConfiguration(input.current), previous = input.previous === null ? null : readConnectorConfiguration(input.previous);
71
+ const signature = (row) => JSON.stringify([row.transport, row.enabled, row.origin, row.executable, row.package, row.argumentCount, row.environmentCount, row.headerCount, row.authenticationDeclared, row.declaredCapabilities]);
72
+ const observations = current.map(row => {
73
+ const old = previous?.find(value => value.name === row.name);
74
+ const changed = old ? signature(old) !== signature(row) : false;
75
+ return { label: `${row.name} · ${row.transport} connector`, state: row.transport === 'unknown' || row.transport === 'ambiguous' || row.unsupportedFields ? 'partial' : changed ? 'changed' : 'reported',
76
+ detail: `Declared enablement: ${row.enabled}. Endpoint: ${row.origin ?? 'not supplied'}; executable: ${row.executable ?? 'not retained or unknown'}; package: ${row.package ?? 'not established'}. Arguments: ${row.argumentCount}; environment entries: ${row.environmentCount}; headers: ${row.headerCount}; auth declaration ${row.authenticationDeclared ? 'present' : 'not supplied'}. Capabilities: ${row.declaredCapabilities.join(', ') || 'not declared'}. ${previous ? old ? changed ? 'Retained metadata changed.' : 'Retained metadata matches; excluded values were not compared.' : 'Newly listed connector.' : 'No earlier configuration selected.'}${row.unsupportedFields ? ` Uninterpreted fields: ${row.unsupportedFields}.` : ''}${row.privateLocationOmitted ? ' Private location detail omitted.' : ''}`,
77
+ source: 'Analyst-selected local configuration; declarations were not negotiated with a server', observedAt: null, hostname: row.origin ? new URL(row.origin).hostname : null };
78
+ });
79
+ for (const row of previous ?? [])
80
+ if (!current.some(value => value.name === row.name))
81
+ observations.push({ label: `${row.name} · no longer listed`, state: 'changed', detail: 'Absent from the selected current configuration; this does not establish that a process or endpoint has stopped.', source: 'Selected configuration comparison', observedAt: null, hostname: row.origin ? new URL(row.origin).hostname : null });
82
+ const report = { schema: CONTEXT_REVIEW_SCHEMA, version: CONTEXT_REVIEW_VERSION, kind: 'connector', reviewedAt, title: 'Connector provenance', state: !current.length || observations.some(row => row.state === 'partial') ? 'partial' : 'reviewed',
83
+ summary: `Configured connectors: ${current.length}${previous ? `; earlier configuration entries: ${previous.length}` : ''}. Commands were not run and endpoints were not contacted.`, observations,
84
+ nextSteps: ['Verify package publisher, exact version, installation source and requested privileges through a trusted source before enabling a local connector.', 'For remote connectors, investigate the endpoint domain separately and review authentication and data-sharing scope before granting access.', 'Review tool, resource and prompt capabilities against the task. Configuration declarations are not evidence of negotiated capabilities or trustworthy behaviour.'],
85
+ limitations: ['Arguments, local paths, environment values, header values, auth material and URL paths or queries are excluded from this report. Names and package identities remain visible; review them before sharing.', 'Matching retained metadata does not mean excluded values are unchanged. This is not code execution, a server connection, a protocol conformance test or safety certification.'] };
86
+ return { report, connectors: current };
87
+ }
88
+ export function reviewConnectorProvenance(raw, reviewedAt) {
89
+ return connectorProvenancePresentation(raw, reviewedAt).report;
90
+ }
91
+ export function reviewConnectorConfigurationText(current, previous, reviewedAt) {
92
+ return connectorConfigurationPresentation(current, previous, reviewedAt).report;
93
+ }
94
+ /** Browser presentation receives only the same minimised metadata as the report. */
95
+ export function connectorConfigurationPresentation(current, previous, reviewedAt) {
96
+ if (typeof current !== 'string' || typeof previous !== 'string')
97
+ throw new TypeError('Select connector configuration text.');
98
+ const parse = (value) => parseBoundedJson(value, { label: 'Connector configuration', maximumBytes: MAX_CONTEXT_INPUT_BYTES });
99
+ return connectorProvenancePresentation({ current: parse(current), previous: previous.trim() ? parse(previous) : null }, reviewedAt);
100
+ }
@@ -0,0 +1,35 @@
1
+ import { parseBoundedJson } from "../analysis/bounded-json.mjs";
2
+ import { exact, text } from "../evidence/artifact-structure.mjs";
3
+ import { readEditableCaseExport } from "../cases/case-export-input.mjs";
4
+ import { MAX_CONTEXT_INPUT_BYTES } from "../contracts/context-review.mjs";
5
+ import { DOMAIN_HISTORY_INPUT_SCHEMA, DOMAIN_HISTORY_INPUT_VERSION, reviewDomainHistory } from "./domain-history-review.mjs";
6
+ import { PLATFORM_CONTINUITY_INPUT_SCHEMA, PLATFORM_CONTINUITY_INPUT_VERSION, reviewPlatformContinuity } from "./platform-continuity-review.mjs";
7
+ import { STOREFRONT_INPUT_SCHEMA, STOREFRONT_INPUT_VERSION, reviewStorefront } from "./storefront-review.mjs";
8
+ import { CONNECTOR_INPUT_SCHEMA, CONNECTOR_INPUT_VERSION, reviewConnectorProvenance } from "./connector-provenance-review.mjs";
9
+ import { INCIDENT_SEQUENCE_INPUT_SCHEMA, INCIDENT_SEQUENCE_INPUT_VERSION, reviewIncidentSequence } from "./incident-sequence-review.mjs";
10
+ export { CONTEXT_INPUT_SCHEMAS } from "../contracts/context-review.mjs";
11
+ /** Adapters share the same explicit dispatch; each domain owns its validation. */
12
+ export function reviewContextInput(raw, reviewedAt) {
13
+ const input = exact(raw, ['schema', 'version', 'evidence'], 'Context review input');
14
+ if (input.schema === DOMAIN_HISTORY_INPUT_SCHEMA && input.version === DOMAIN_HISTORY_INPUT_VERSION) {
15
+ const evidence = exact(input.evidence, ['caseExport', 'caseId', 'declarations'], 'Domain history input');
16
+ const caseId = text(evidence.caseId, 'Selected Case ID', 240);
17
+ const cases = readEditableCaseExport(JSON.stringify(evidence.caseExport));
18
+ const selected = cases.find(record => record.id === caseId);
19
+ if (!selected)
20
+ throw new TypeError('The selected Case is not present in this export.');
21
+ return reviewDomainHistory(selected, evidence.declarations, reviewedAt);
22
+ }
23
+ if (input.schema === PLATFORM_CONTINUITY_INPUT_SCHEMA && input.version === PLATFORM_CONTINUITY_INPUT_VERSION)
24
+ return reviewPlatformContinuity(input.evidence, reviewedAt);
25
+ if (input.schema === STOREFRONT_INPUT_SCHEMA && input.version === STOREFRONT_INPUT_VERSION)
26
+ return reviewStorefront(input.evidence, reviewedAt);
27
+ if (input.schema === CONNECTOR_INPUT_SCHEMA && input.version === CONNECTOR_INPUT_VERSION)
28
+ return reviewConnectorProvenance(input.evidence, reviewedAt);
29
+ if (input.schema === INCIDENT_SEQUENCE_INPUT_SCHEMA && input.version === INCIDENT_SEQUENCE_INPUT_VERSION)
30
+ return reviewIncidentSequence(input.evidence, reviewedAt);
31
+ throw new TypeError('Unsupported context review schema or version.');
32
+ }
33
+ export function parseContextInput(input) {
34
+ return parseBoundedJson(input, { label: 'Context review input', maximumBytes: MAX_CONTEXT_INPUT_BYTES });
35
+ }
@@ -0,0 +1,81 @@
1
+ import { createIntakeReport } from "./intake-report.mjs";
2
+ import { createLinkIntake } from "./link-intake.mjs";
3
+ import { decodeQrPixels } from "./qr-intake.mjs";
4
+ import { sha256ArtifactBytes } from "../evidence/artifact-integrity.mjs";
5
+ import { MAX_DOCUMENT_PARTS, MAX_DOCUMENT_TEXT_BYTES, MAX_DOCUMENT_TOTAL_IMAGE_PIXELS } from "../contracts/document-intake.mjs";
6
+ /** The format adapters provide inert text/pixels; this owner retains provenance and links. */
7
+ export async function createDocumentIntake(bytes, kind, reviewedAt) {
8
+ const base = await createIntakeReport(bytes, kind, reviewedAt), links = createLinkIntake();
9
+ const parts = [], notes = new Set();
10
+ let textBytes = 0, imagePixels = 0;
11
+ let state = 'reviewed', pageCount = null, reviewedPages = 0;
12
+ const encoder = new TextEncoder();
13
+ function partial(note) { if (state === 'reviewed')
14
+ state = 'partial'; notes.add(note); }
15
+ async function part(input, original) {
16
+ if (parts.length >= MAX_DOCUMENT_PARTS) {
17
+ partial('Further document parts exceed the review bound.');
18
+ return null;
19
+ }
20
+ const value = { ...input, id: `part-${parts.length + 1}`, parentDigestSha256: base.source.digestSha256,
21
+ digestSha256: await sha256ArtifactBytes(original), byteLength: original.byteLength };
22
+ parts.push(value);
23
+ return { partId: value.id, page: value.page };
24
+ }
25
+ return {
26
+ partial,
27
+ unavailable(reason, note) { state = reason; notes.add(note); },
28
+ pages(total, reviewed) { pageCount = total; reviewedPages = reviewed; },
29
+ async text(value, page, original) {
30
+ if (value.length > MAX_DOCUMENT_TEXT_BYTES - textBytes) {
31
+ partial('Further extracted text exceeds the review bound.');
32
+ return;
33
+ }
34
+ const encoded = encoder.encode(value);
35
+ textBytes += encoded.byteLength;
36
+ if (textBytes > MAX_DOCUMENT_TEXT_BYTES) {
37
+ partial('Further extracted text exceeds the review bound.');
38
+ return;
39
+ }
40
+ const location = await part({ kind: 'text', page, identity: original ? 'original_part_bytes' : 'extracted_text', state: 'reviewed' }, original ?? encoded);
41
+ if (location)
42
+ links.addText(value, 'document_text', location);
43
+ },
44
+ async destinations(values, page, original) {
45
+ const encoded = encoder.encode(JSON.stringify(values));
46
+ if (encoded.byteLength > MAX_DOCUMENT_TEXT_BYTES - textBytes) {
47
+ partial('Further document links exceed the review bound.');
48
+ return;
49
+ }
50
+ textBytes += encoded.byteLength;
51
+ const location = await part({ kind: 'links', page, identity: original ? 'original_part_bytes' : 'extracted_links', state: 'reviewed' }, original ?? encoded);
52
+ if (location)
53
+ for (const value of values)
54
+ links.add(value, 'document_link', '', null, 0, location);
55
+ },
56
+ async image(image, page, original) {
57
+ imagePixels += image.width * image.height;
58
+ if (imagePixels > MAX_DOCUMENT_TOTAL_IMAGE_PIXELS) {
59
+ partial('Further image pixels exceed the aggregate QR review bound.');
60
+ return;
61
+ }
62
+ const decoded = decodeQrPixels(image);
63
+ const location = await part({ kind: 'image', page, identity: original ? 'original_part_bytes' : 'decoded_rgba_pixels', state: decoded.bounded || decoded.structured ? 'partial' : 'reviewed' }, original ?? new Uint8Array(image.pixels.buffer, image.pixels.byteOffset, image.pixels.byteLength));
64
+ if (location)
65
+ for (const value of decoded.texts)
66
+ links.addQr(value, 'document_qr', location);
67
+ if (decoded.bounded || decoded.structured)
68
+ partial('Some QR candidates were bounded or require multi-symbol reassembly.');
69
+ },
70
+ finish() {
71
+ const result = links.result();
72
+ for (const limitation of result.limitations)
73
+ partial(limitation);
74
+ if (result.bounded)
75
+ partial('Further extracted links exceed the review bound.');
76
+ return { targets: result.targets, report: { ...base, links: result.links,
77
+ coverage: { ...base.coverage, state: state === 'reviewed' ? 'reviewed' : 'partial', reviewedParts: parts.length, rejectedLinks: result.rejected },
78
+ documentReview: { state, pageCount, reviewedPages, parts, notes: [...notes] } } };
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,133 @@
1
+ import { SaxesParser } from 'saxes';
2
+ import { extractBoundedZipEntries } from "../interchange/bounded-zip-extraction.mjs";
3
+ import { decodeEvidencePng } from "../evidence/png-pixels.mjs";
4
+ import { createDocumentIntake } from "./document-intake.mjs";
5
+ import { MAX_DOCUMENT_PARTS, MAX_DOCUMENT_DECODED_BYTES, MAX_DOCUMENT_TEXT_BYTES, MAX_DOCUMENT_XML_DEPTH, MAX_DOCUMENT_XML_NODES } from "../contracts/document-intake.mjs";
6
+ import { MAX_MESSAGE_INTAKE_BYTES, MAX_INTAKE_LINKS, MAX_INTAKE_URL_LENGTH } from "../contracts/message-intake.mjs";
7
+ const WORD_NAMESPACES = new Set(['http://schemas.openxmlformats.org/wordprocessingml/2006/main', 'http://purl.oclc.org/ooxml/wordprocessingml/main']);
8
+ const RELATIONSHIP_NAMESPACES = new Set(['http://schemas.openxmlformats.org/package/2006/relationships', 'http://purl.oclc.org/ooxml/package/relationships']);
9
+ const WORD_PART = /^word\/(?:document|header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/u;
10
+ const RELATIONSHIP_PART = /^word\/(?:[^/]+\/)*_rels\/[^/]+\.rels$/u;
11
+ const MEDIA_PART = /^word\/media\/[^/]+$/u;
12
+ function archiveKey(name) {
13
+ const parts = name.endsWith('/') ? name.slice(0, -1).split('/') : name.split('/');
14
+ if (!name || name.length > 512 || /[\\\x00-\x1f\x7f]/u.test(name) || parts.some(part => !part || part === '.' || part === '..'))
15
+ throw new TypeError('The document contains an unsafe archive path.');
16
+ return name.normalize('NFC').toLowerCase();
17
+ }
18
+ function xmlText(bytes) {
19
+ const encoding = bytes[0] === 0xff && bytes[1] === 0xfe || bytes[0] === 0x3c && bytes[1] === 0 ? 'utf-16le'
20
+ : bytes[0] === 0xfe && bytes[1] === 0xff || bytes[0] === 0 && bytes[1] === 0x3c ? 'utf-16be' : 'utf-8';
21
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes);
22
+ }
23
+ export async function reviewDocxInput(bytes, reviewedAt) {
24
+ const review = await createDocumentIntake(bytes, 'docx', reviewedAt);
25
+ if (bytes[0] === 0xd0 && bytes[1] === 0xcf) {
26
+ review.unavailable('unsupported', 'This is a compound document, which may be encrypted or a legacy format. Export an unencrypted DOCX copy for review.');
27
+ return review.finish();
28
+ }
29
+ let unsupportedParts = 0;
30
+ const { files } = extractBoundedZipEntries(bytes, {
31
+ maximumEntries: MAX_DOCUMENT_PARTS, maximumSelectedBytes: MAX_DOCUMENT_DECODED_BYTES,
32
+ selectedBytesExceededMessage: 'The document exceeds the decoded-byte review bound.', metadataMismatchMessage: 'The DOCX archive is encrypted, malformed or outside supported ZIP bounds.',
33
+ keyForName: archiveKey,
34
+ inspect(entry, metadata) {
35
+ const key = archiveKey(entry.name);
36
+ if (metadata.kind === 'special')
37
+ throw new TypeError('Document archives cannot contain special filesystem entries.');
38
+ const xml = entry.name === '[Content_Types].xml' || WORD_PART.test(entry.name) || RELATIONSHIP_PART.test(entry.name);
39
+ const png = MEDIA_PART.test(entry.name) && /\.png$/iu.test(entry.name);
40
+ if (entry.name.startsWith('word/embeddings/') || /(?:vbaProject\.bin|\.jpe?g|\.webp|\.svg|\.wmf|\.emf|\.gif)$/iu.test(entry.name))
41
+ unsupportedParts++;
42
+ return { key, selected: xml || png, maximumBytes: png ? MAX_MESSAGE_INTAKE_BYTES : MAX_DOCUMENT_TEXT_BYTES, exceededMessage: 'A document part exceeds the local review bound.' };
43
+ },
44
+ });
45
+ if (!files.has('[Content_Types].xml') || !files.has('word/document.xml'))
46
+ throw new TypeError('The selected archive is not a supported DOCX document.');
47
+ let nodes = 0, declaredMain = false, externalResources = 0;
48
+ function parseXml(input, callbacks) {
49
+ const parser = new SaxesParser({ xmlns: true });
50
+ let depth = 0;
51
+ parser.on('doctype', () => { throw new TypeError('Document XML declarations cannot define entities.'); });
52
+ parser.on('error', () => { throw new TypeError('A document XML part is malformed.'); });
53
+ parser.on('opentag', tag => {
54
+ if (++nodes > MAX_DOCUMENT_XML_NODES || ++depth > MAX_DOCUMENT_XML_DEPTH)
55
+ throw new TypeError('Document XML exceeds the structural review bound.');
56
+ callbacks.open?.(tag);
57
+ });
58
+ parser.on('closetag', tag => { callbacks.close?.(tag); depth--; });
59
+ parser.on('text', value => callbacks.text?.(value));
60
+ parser.on('cdata', value => callbacks.text?.(value));
61
+ const inputText = xmlText(input);
62
+ for (let offset = 0; offset < inputText.length; offset += 4096)
63
+ parser.write(inputText.slice(offset, offset + 4096));
64
+ parser.close();
65
+ }
66
+ const attr = (tag, name) => Object.values(tag.attributes).find(value => value.local === name && value.uri === '')?.value;
67
+ parseXml(files.get('[Content_Types].xml'), { open(tag) {
68
+ if (tag.local === 'Override' && tag.uri === 'http://schemas.openxmlformats.org/package/2006/content-types'
69
+ && attr(tag, 'PartName') === '/word/document.xml' && attr(tag, 'ContentType') === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml')
70
+ declaredMain = true;
71
+ } });
72
+ if (!declaredMain)
73
+ throw new TypeError('The selected archive does not declare an ordinary DOCX document. Macro-enabled and other document types are not interpreted as DOCX.');
74
+ for (const [name, body] of files) {
75
+ if (WORD_PART.test(name)) {
76
+ try {
77
+ let text = '', textDepth = 0;
78
+ parseXml(body, { open(tag) { if (WORD_NAMESPACES.has(tag.uri) && ['t', 'instrText'].includes(tag.local))
79
+ textDepth++; },
80
+ close(tag) { if (WORD_NAMESPACES.has(tag.uri) && ['t', 'instrText'].includes(tag.local))
81
+ textDepth--; if (WORD_NAMESPACES.has(tag.uri) && ['p', 'tab', 'br'].includes(tag.local))
82
+ text += '\n'; },
83
+ text(value) { if (textDepth) {
84
+ if (text.length + value.length > MAX_DOCUMENT_TEXT_BYTES)
85
+ throw new TypeError('Document text exceeds its review bound.');
86
+ text += value;
87
+ } } });
88
+ await review.text(text, null, body);
89
+ }
90
+ catch {
91
+ review.partial('A document text part could not be fully decoded within the XML review bounds.');
92
+ }
93
+ }
94
+ else if (RELATIONSHIP_PART.test(name)) {
95
+ try {
96
+ const destinations = [];
97
+ parseXml(body, { open(tag) {
98
+ if (tag.local !== 'Relationship' || !RELATIONSHIP_NAMESPACES.has(tag.uri) || attr(tag, 'TargetMode') !== 'External')
99
+ return;
100
+ const type = attr(tag, 'Type'), target = attr(tag, 'Target');
101
+ if (type !== 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink' && type !== 'http://purl.oclc.org/ooxml/officeDocument/relationships/hyperlink') {
102
+ externalResources++;
103
+ return;
104
+ }
105
+ if (!target || target.length > MAX_INTAKE_URL_LENGTH || destinations.length >= MAX_INTAKE_LINKS) {
106
+ review.partial('Some relationship targets exceed the link review bound.');
107
+ return;
108
+ }
109
+ destinations.push(target);
110
+ } });
111
+ await review.destinations(destinations, null, body);
112
+ }
113
+ catch {
114
+ review.partial('A relationship part could not be fully decoded within the XML review bounds.');
115
+ }
116
+ }
117
+ else if (MEDIA_PART.test(name)) {
118
+ try {
119
+ await review.image(decodeEvidencePng(body), null, body);
120
+ }
121
+ catch {
122
+ review.partial('An embedded image was unsupported, malformed or outside the pixel review bounds.');
123
+ }
124
+ }
125
+ }
126
+ if (externalResources)
127
+ review.partial(`${externalResources} external non-link resources were declared but not requested.`);
128
+ if (unsupportedParts)
129
+ review.partial(`${unsupportedParts} embedded objects or non-PNG images were not decoded.`);
130
+ // Text/relationships are declarations in the selected package, not a rendered page.
131
+ review.partial('DOCX page layout, vector artwork, embedded objects and non-PNG images are not rendered. Link relationships can include unused declarations.');
132
+ return review.finish();
133
+ }
@@ -0,0 +1,100 @@
1
+ import { caseEvidenceIncomparableReasons, compareCaseEvidence } from "../cases/case-evidence-model.mjs";
2
+ import { domainTransitionReview } from "./domain-transition-review.mjs";
3
+ import { MAX_CASE_EVIDENCE_PINS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE } from "../contracts/case-portability.mjs";
4
+ import { array, domain, enumeration, exact, iso, text } from "../evidence/artifact-structure.mjs";
5
+ import { CONTEXT_REVIEW_SCHEMA, CONTEXT_REVIEW_VERSION, MAX_CONTEXT_RECORDS } from "../contracts/context-review.mjs";
6
+ export { DOMAIN_HISTORY_INPUT_SCHEMA, DOMAIN_HISTORY_INPUT_VERSION } from "../contracts/context-review.mjs";
7
+ export const DOMAIN_CHANGE_FAMILIES = ['registration', 'dns', 'mail', 'certificate', 'web'];
8
+ export function readDomainHistoryDeclarations(raw) {
9
+ const input = exact(raw, ['expectedChanges', 'retiredDependencies'], 'Domain history declarations');
10
+ const expectedChanges = array(input.expectedChanges, 'Expected changes', MAX_CONTEXT_RECORDS).map(value => {
11
+ const item = exact(value, ['family', 'start', 'end', 'reason'], 'Expected change');
12
+ iso(item.start, 'Expected window start');
13
+ iso(item.end, 'Expected window end');
14
+ if (Date.parse(item.start) > Date.parse(item.end))
15
+ throw new TypeError('Expected change window ends before it starts.');
16
+ return { family: enumeration(item.family, DOMAIN_CHANGE_FAMILIES, 'Change family'), start: item.start, end: item.end, reason: text(item.reason, 'Change reason', 500) };
17
+ });
18
+ const retiredDependencies = array(input.retiredDependencies, 'Retired dependencies', MAX_CONTEXT_RECORDS).map(value => {
19
+ const item = exact(value, ['asset', 'dependency', 'family', 'retiredAt', 'source'], 'Retired dependency');
20
+ domain(item.asset, 'Owned asset');
21
+ domain(item.dependency, 'Dependency hostname');
22
+ iso(item.retiredAt, 'Retirement time');
23
+ return { asset: item.asset, dependency: item.dependency, family: enumeration(item.family, DOMAIN_CHANGE_FAMILIES, 'Dependency family'), retiredAt: item.retiredAt, source: text(item.source, 'Retirement source', 500) };
24
+ });
25
+ return { expectedChanges, retiredDependencies };
26
+ }
27
+ function family(field) {
28
+ if (['registrar', 'createdDate', 'expiryDate', 'availability'].includes(field))
29
+ return 'registration';
30
+ if (field === 'nameservers')
31
+ return 'dns';
32
+ if (['hasMx', 'hasSpf', 'hasDmarc'].includes(field))
33
+ return 'mail';
34
+ if (field.startsWith('http') || ['activityStatus', 'pageTitle', 'websiteProbeDetail', 'faviconMatch', 'faviconNearMatch',
35
+ 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch'].includes(field))
36
+ return 'web';
37
+ return null;
38
+ }
39
+ /** Input is a canonical Case from storage or the existing exact Case-export reader. */
40
+ export function reviewDomainHistory(record, declarations, reviewedAt) {
41
+ iso(reviewedAt, 'Review time');
42
+ const declared = readDomainHistoryDeclarations(declarations);
43
+ const related = (hostname) => hostname === record.domain || hostname.endsWith(`.${record.domain}`);
44
+ if (declared.retiredDependencies.some(row => !related(row.asset) && !related(row.dependency)))
45
+ throw new TypeError('A retired dependency must involve this Case domain or one of its hostnames.');
46
+ array(record.evidenceHistory, 'Case evidence history', MAX_EVIDENCE_SNAPSHOTS_PER_CASE);
47
+ array(record.evidencePins, 'Case evidence pins', MAX_CASE_EVIDENCE_PINS);
48
+ const snapshots = [...record.evidenceHistory].sort((a, b) => Date.parse(a.capturedAt) - Date.parse(b.capturedAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
49
+ const observations = [];
50
+ let orderedPairs = 0;
51
+ let limitedPairs = 0;
52
+ const prompts = new Set();
53
+ for (let index = 1; index < snapshots.length; index++) {
54
+ const before = snapshots[index - 1], after = snapshots[index];
55
+ if (before.capturedAt === after.capturedAt) {
56
+ observations.push({ label: 'Concurrent snapshot observations', state: 'unknown', detail: 'These records have the same observation time; their ordering is not a change sequence.', source: `${before.id} / ${after.id}`, observedAt: after.capturedAt, hostname: record.domain });
57
+ continue;
58
+ }
59
+ orderedPairs++;
60
+ const reasons = caseEvidenceIncomparableReasons(before, after);
61
+ if (reasons.length) {
62
+ limitedPairs++;
63
+ observations.push({ label: 'Comparison coverage', state: 'partial',
64
+ detail: `Some fields are not comparable: ${reasons.map(reason => ({
65
+ 'observation-context': 'different hostname or selected-page context',
66
+ 'scan-depth': 'different collection depth',
67
+ 'collection-quality': 'missing registration values or incomplete page/favicon collection',
68
+ 'risk-model': 'different or unknown risk model',
69
+ 'opportunity-model': 'different or unknown opportunity model',
70
+ })[reason]).join('; ')}. These gaps do not establish activation, disappearance or resolution.`,
71
+ source: `${before.source} ${before.id} → ${after.source} ${after.id}`, observedAt: after.capturedAt,
72
+ hostname: after.observationHostname ?? record.domain });
73
+ }
74
+ for (const change of compareCaseEvidence(before, after)) {
75
+ const category = family(change.field);
76
+ if (!category)
77
+ continue;
78
+ const prompt = domainTransitionReview(change.field, change.before, change.after);
79
+ if (prompt)
80
+ prompts.add(prompt);
81
+ const windows = declared.expectedChanges.filter(row => row.family === category && Date.parse(row.start) <= Date.parse(after.capturedAt) && Date.parse(row.end) >= Date.parse(before.capturedAt));
82
+ observations.push({ label: `${category} · ${change.label}`, state: 'changed',
83
+ detail: `${JSON.stringify(change.before)} → ${JSON.stringify(change.after)}${windows.length ? ` · Overlaps ${windows.length} analyst-declared expected-change window(s); not automatically approved.` : ''}`,
84
+ source: `${before.source} ${before.id} → ${after.source} ${after.id}`, observedAt: after.capturedAt, hostname: after.observationHostname ?? record.domain });
85
+ }
86
+ }
87
+ for (const pin of record.evidencePins.filter(pin => pin.certificateObservation)) {
88
+ observations.push({ label: `Retained certificate · ${pin.label}`, state: pin.completeness === 'complete' && pin.truncated !== true ? 'observed' : 'partial', detail: pin.value,
89
+ source: `${pin.source} · pin ${pin.id}`, observedAt: pin.observedAt, hostname: record.domain });
90
+ }
91
+ for (const row of declared.expectedChanges)
92
+ observations.push({ label: `Expected ${row.family} change`, state: 'reported', detail: `${row.start} – ${row.end} · ${row.reason}`, source: 'Analyst declaration', observedAt: null, hostname: record.domain });
93
+ for (const row of declared.retiredDependencies)
94
+ observations.push({ label: `Declared retired ${row.family} dependency`, state: 'reported', detail: `${row.asset} depended on ${row.dependency}. Check whether owned configuration still references it.`, source: row.source, observedAt: row.retiredAt, hostname: row.dependency });
95
+ const certificateCount = observations.filter(row => row.label.startsWith('Retained certificate')).length;
96
+ return { schema: CONTEXT_REVIEW_SCHEMA, version: CONTEXT_REVIEW_VERSION, kind: 'domain_history', reviewedAt, title: 'Domain history and retired dependencies', state: orderedPairs < 1 || limitedPairs > 0 ? 'partial' : 'reviewed',
97
+ summary: `Retained snapshots: ${snapshots.length}; time-ordered pairs: ${orderedPairs}; limited pairs: ${limitedPairs}; certificate pins: ${certificateCount}; declared retired dependencies: ${declared.retiredDependencies.length}.`, observations,
98
+ nextSteps: [...prompts, 'Review changed registration, DNS, mail and web evidence against declared maintenance windows.', 'Recheck owned references to retired dependencies before changing configuration; preserve evidence of legitimate transfers or rebrands.'],
99
+ limitations: ['This review uses retained Case evidence, not fresh collection. Missing or incompatible fields are not compared.', 'Certificate pins retain their own source and completeness; they are not equivalent to live TLS observations.', 'Changes and retired dependencies do not establish ownership transfer, malicious repurposing or takeover availability.'] };
100
+ }
@@ -0,0 +1,47 @@
1
+ /** Review prompts for changes already admitted by a source-aware comparator. */
2
+ export function domainTransitionReview(field, before, after) {
3
+ if (before == null || after == null)
4
+ return null;
5
+ switch (field) {
6
+ case 'createdDate':
7
+ return 'Reassess earlier relevance, exceptions and response baselines against the changed registration date. Keep the prior history; the date alone does not establish a new owner or confirmed re-registration.';
8
+ case 'availability':
9
+ return before === 'available' || after === 'available'
10
+ ? 'Review the dated registration observations before carrying earlier decisions into this context. An availability change does not by itself establish who controls the domain.'
11
+ : 'Review the registration state in its source context; sale and expiry labels do not establish deletion or a change of owner.';
12
+ case 'expiryDate':
13
+ return 'Check the reported renewal timeline. Expiry, deletion and re-registration are different events; this change does not reset earlier decisions.';
14
+ case 'registrar':
15
+ case 'registrarName':
16
+ return 'Review the registration source and any expected transfer. A registrar change is not evidence of new ownership.';
17
+ case 'nameservers':
18
+ return 'Compare the delegation change with expected hosting or DNS maintenance before revising relationships or response routes.';
19
+ case 'hasMx':
20
+ case 'hasNullMx':
21
+ case 'hasSpf':
22
+ case 'hasDmarc':
23
+ return 'Review the changed mail configuration against the watch reason. DNS configuration does not show whether messages were sent or an account was compromised.';
24
+ case 'hasPasswordField':
25
+ return after === true
26
+ ? 'Review the newly observed password form and its destination against the recorded concern. A login form alone is not credential theft.'
27
+ : 'Review the current page evidence before changing the assessment; removal of one form does not establish that the incident is resolved.';
28
+ case 'hasExternalFormAction':
29
+ return 'Check the observed form destination and legitimate service use before updating the assessment. No form was submitted by this review.';
30
+ case 'faviconMatch':
31
+ case 'faviconNearMatch':
32
+ case 'reusesOfficialAssets':
33
+ case 'pageBaselineMatch':
34
+ return 'Review the retained Brand comparison and legitimate shared assets. An identity match or its disappearance is not an infringement or resolution verdict.';
35
+ case 'activityStatus':
36
+ return after === 'unreachable' || after === 'no_site'
37
+ ? 'The website observation is inconclusive; do not treat it as disappearance or resolution. Recheck deliberately within the existing collection scope.'
38
+ : 'Review the changed site activity against the watch reason. A legitimate launch can change the same fields as an incident.';
39
+ case 'pageTitle':
40
+ case 'httpFinalOrigin':
41
+ case 'httpCrossOriginRedirect':
42
+ case 'faviconHash':
43
+ return 'Review the changed page or destination against the retained concern and expected site changes. Preserve the exact observation scope before making a Case or priority decision.';
44
+ default:
45
+ return null;
46
+ }
47
+ }