@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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 (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -1,9 +1,12 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
2
+ import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, lookupEvidenceRegistryInsightsVersion, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
3
3
  import { validLookupObservationScope } from "../../packages/evidence/lookup-target.mjs";
4
+ import { externalPasswordFormObservation } from "../../packages/evidence/credential-form-attribution.mjs";
5
+ import { credentialSurfaceContractState } from "../../lib/lookup-child-profile-contract.mjs";
4
6
  import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../../lib/registrar-standing-contract.mjs";
5
7
  import { compareRegistrySources } from "../../lib/registry-comparison.mjs";
6
8
  import { buildRegistryInsights } from "../../lib/registry-insights.mjs";
9
+ import { resolveRdapCapabilityCatalogRevision } from "../../lib/rdap-capabilities.mjs";
7
10
  import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../../packages/analysis/project-metadata.mjs";
8
11
  import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../../lib/homepage-metadata-contract.mjs";
9
12
  import { HEX_DIGEST_RE, SEMANTIC_VERSION_RE, absoluteUrl, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, nullableRecord, optionalText, record, strings, text, } from "./structure-primitives.mjs";
@@ -24,7 +27,7 @@ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
24
27
  'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
25
28
  'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
26
29
  'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
27
- 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
30
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'hasExternalPasswordForm', 'externalAssetHosts',
28
31
  'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
29
32
  'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
30
33
  'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
@@ -42,6 +45,18 @@ const LOOKUP_REGISTRAR_COMPARISON_STATUSES = [
42
45
  'registrar_unavailable', 'registry_incomplete', 'registrar_incomplete',
43
46
  ];
44
47
  function validateLookupEvidenceHomepageMetadata(availability, version) {
48
+ const credentialProfile = availability.credentialSurfaceProfile === null || availability.credentialSurfaceProfile === undefined
49
+ ? null : record(availability.credentialSurfaceProfile, 'Lookup evidence credential profile');
50
+ if (version < 30 && (Object.hasOwn(availability, 'hasExternalPasswordForm') || credentialProfile?.credentialSurfaceVersion === 2)) {
51
+ fail('Lookup evidence form-attribution epoch');
52
+ }
53
+ if (Object.hasOwn(availability, 'hasExternalPasswordForm') && availability.hasExternalPasswordForm !== null)
54
+ boolean(availability.hasExternalPasswordForm, 'Lookup evidence password-form destination');
55
+ if (credentialProfile?.credentialSurfaceVersion === 2 && credentialSurfaceContractState(credentialProfile) !== 'supported')
56
+ fail('Lookup evidence form-attribution profile');
57
+ if (credentialProfile?.credentialSurfaceVersion === 2 && availability.hasExternalPasswordForm !== undefined
58
+ && availability.hasExternalPasswordForm !== externalPasswordFormObservation(credentialProfile.formAttribution))
59
+ fail('Lookup evidence form-attribution consistency');
45
60
  const pageIdentity = availability.pageIdentity === null || availability.pageIdentity === undefined
46
61
  ? null
47
62
  : record(availability.pageIdentity, 'Lookup evidence page identity');
@@ -330,7 +345,7 @@ function validateLookupEvidenceWhois(value, version) {
330
345
  optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
331
346
  nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
332
347
  if (version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
333
- && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed))) {
348
+ && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed, version))) {
334
349
  fail('Lookup evidence WHOIS portable publication');
335
350
  }
336
351
  array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
@@ -701,6 +716,15 @@ export function validateLookupEvidenceArtifactStructure(value) {
701
716
  });
702
717
  if (!isDeepStrictEqual(registryComparison, expectedComparison))
703
718
  fail('Lookup evidence registry comparison derivation');
719
+ const capabilities = record(registryInsights.rdapCapabilities, 'Lookup evidence RDAP capabilities');
720
+ const registryCapabilities = record(capabilities.registry, 'Lookup evidence registry RDAP capabilities');
721
+ const registrarCapabilities = record(capabilities.registrar, 'Lookup evidence registrar RDAP capabilities');
722
+ const catalogReviewedAt = registryCapabilities.catalogReviewedAt;
723
+ if (typeof catalogReviewedAt !== 'string'
724
+ || resolveRdapCapabilityCatalogRevision(catalogReviewedAt) === null
725
+ || registrarCapabilities.catalogReviewedAt !== catalogReviewedAt) {
726
+ fail('Lookup evidence RDAP capability catalogue revision');
727
+ }
704
728
  const rebuiltInsights = buildRegistryInsights({
705
729
  rdapParsed,
706
730
  rdapStatus: rdapDiagnosticStatus,
@@ -708,6 +732,9 @@ export function validateLookupEvidenceArtifactStructure(value) {
708
732
  whoisParsed,
709
733
  whoisStatus: whoisDiagnosticStatus,
710
734
  whoisQueriedAt: whois.queriedAt,
735
+ }, {
736
+ rdapCapabilityCatalogReviewedAt: catalogReviewedAt,
737
+ version: lookupEvidenceRegistryInsightsVersion(version),
711
738
  });
712
739
  const expectedInsights = version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
713
740
  ? projectLookupEvidenceRegistryInsights(rebuiltInsights)
@@ -1,4 +1,4 @@
1
- import { ACQUISITION_DECISIONS, ACQUISITION_MANUAL_CHECKS, ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../../packages/investigation/acquisition-decision-packet.mjs";
1
+ import { ACQUISITION_DECISION_PACKET_SCHEMA, validateAcquisitionDecisionPacketStructure, } from "../../packages/investigation/acquisition-decision-packet.mjs";
2
2
  import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../../packages/investigation/bulk-domain-comparison.mjs";
3
3
  import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../../packages/investigation/bulk-mail-exposure.mjs";
4
4
  import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS, } from "../../packages/contracts/investigation-portability.mjs";
@@ -26,55 +26,6 @@ const DOMAIN_COMPARISON_ROW_IDS = [
26
26
  ];
27
27
  const MAIL_STATES = ['authenticated_mail', 'evidence_incomplete', 'mail_auth_gap', 'mail_auth_incomplete', 'no_explicit_mx', 'null_mx'];
28
28
  const DNS_TYPES = ['A', 'AAAA', 'CAA', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'HTTPS', 'MX', 'NS', 'SRV', 'SVCB', 'TLSA', 'TXT'];
29
- function validateAcquisitionItem(value, label) {
30
- const item = exact(value, ['id', 'label', 'state', 'detail', 'provenance'], label);
31
- const id = enumeration(item.id, ['availability', 'contacts', 'lifecycle', 'mail', 'nameservers', 'operations', 'policy_eligibility', 'policy_lifecycle', 'policy_transfer', 'tls', 'transfer', 'web'], label);
32
- enumeration(item.state, ['authoritative', 'observed', 'review', 'unavailable'], label);
33
- text(item.label, label, 200);
34
- text(item.detail, label, 2_000);
35
- text(item.provenance, label, 300);
36
- return id;
37
- }
38
- function validateAcquisition(value) {
39
- const root = exact(value, ['schema', 'version', 'generatedAt', 'target', 'synthetic', 'evidenceObservedAt', 'analystReview', 'evidenceReview', 'limitations', 'integrity'], 'Acquisition decision packet');
40
- iso(root.generatedAt, 'Acquisition decision packet generatedAt');
41
- domain(root.target, 'Acquisition decision packet target');
42
- boolean(root.synthetic, 'Acquisition decision packet synthetic');
43
- iso(root.evidenceObservedAt, 'Acquisition decision packet evidenceObservedAt', true);
44
- const review = exact(root.analystReview, ['decision', 'rationale', 'reviewedChecks', 'outstandingChecks', 'state'], 'Acquisition analyst review');
45
- const decision = enumeration(review.decision, ACQUISITION_DECISIONS, 'Acquisition analyst review decision');
46
- text(review.rationale, 'Acquisition analyst review rationale', 2_000, true);
47
- const reviewed = strings(review.reviewedChecks, 'Acquisition reviewed checks', ACQUISITION_MANUAL_CHECKS.length, 40);
48
- const outstanding = strings(review.outstandingChecks, 'Acquisition outstanding checks', ACQUISITION_MANUAL_CHECKS.length, 40);
49
- const expectedReviewed = ACQUISITION_MANUAL_CHECKS.filter((item) => reviewed.includes(item));
50
- const expectedOutstanding = ACQUISITION_MANUAL_CHECKS.filter((item) => !reviewed.includes(item));
51
- if (!sameValues(reviewed, expectedReviewed) || !sameValues(outstanding, expectedOutstanding))
52
- fail('Acquisition analyst review checks');
53
- const expectedState = decision === 'unresolved' || reviewed.length < ACQUISITION_MANUAL_CHECKS.length ? 'draft' : 'reviewed';
54
- if (review.state !== expectedState)
55
- fail('Acquisition analyst review state');
56
- const evidence = exact(root.evidenceReview, ['version', 'label', 'state', 'items', 'transitionDependencies', 'policyChecks', 'nextSteps', 'limitations'], 'Acquisition evidence review');
57
- if (evidence.version !== 2)
58
- fail('Acquisition evidence review');
59
- text(evidence.label, 'Acquisition evidence review label', 200);
60
- enumeration(evidence.state, ['incomplete', 'registered', 'review_transition', 'sale_signal', 'unregistered_observation'], 'Acquisition evidence review state');
61
- const expectedEvidenceIds = {
62
- items: ['availability', 'lifecycle', 'transfer', 'operations', 'contacts'],
63
- transitionDependencies: ['nameservers', 'web', 'mail', 'tls'],
64
- policyChecks: ['policy_eligibility', 'policy_lifecycle', 'policy_transfer'],
65
- };
66
- for (const key of ['items', 'transitionDependencies', 'policyChecks']) {
67
- const expected = expectedEvidenceIds[key];
68
- const values = array(evidence[key], `Acquisition evidence review ${key}`, expected.length, expected.length);
69
- const ids = values.map((item, index) => validateAcquisitionItem(item, `Acquisition evidence review ${key}[${index}]`));
70
- if (!sameValues(ids, expected))
71
- fail(`Acquisition evidence review ${key}`);
72
- }
73
- strings(evidence.nextSteps, 'Acquisition next steps', 6, 600);
74
- strings(evidence.limitations, 'Acquisition evidence limitations', 12, 600);
75
- strings(root.limitations, 'Acquisition limitations', 8, 600);
76
- validateIntegrity(root.integrity, 'Acquisition integrity', root.version, ACQUISITION_DECISION_PACKET_VERSION);
77
- }
78
29
  function validateClaimPassportTarget(type, value) {
79
30
  const candidate = text(value, 'Claim passport target', 253);
80
31
  if (type === 'domain') {
@@ -656,7 +607,7 @@ export function validateSignedDigestArtifactStructure(schema, value) {
656
607
  if (value.schema !== schema)
657
608
  fail('Signed review artefact');
658
609
  if (schema === ACQUISITION_DECISION_PACKET_SCHEMA)
659
- validateAcquisition(value);
610
+ validateAcquisitionDecisionPacketStructure(value);
660
611
  else if (schema === MANAGED_INDICATOR_SET_SCHEMA)
661
612
  validateManagedIndicatorSet(value);
662
613
  else if (schema === LOOKUP_CLAIM_PASSPORT_SCHEMA)
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION } from "../packages/contracts/analyst-interchange.mjs";
2
+ import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION, MAX_MANAGED_INDICATOR_SET_BYTES } from "../packages/contracts/analyst-interchange.mjs";
3
3
  import { createHash } from 'node:crypto';
4
4
  import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
5
5
  import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "./bounded-json.mjs";
@@ -19,7 +19,7 @@ import { INVESTIGATION_MANIFEST_SCHEMA, SUPPORTED_INVESTIGATION_MANIFEST_VERSION
19
19
  import { verifyCliCasePack } from "./case-pack.mjs";
20
20
  import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, LOOKUP_EVIDENCE_SCHEMA, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
21
21
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
22
- export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 4;
22
+ export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 5;
23
23
  export const MAX_OFFLINE_ARTIFACT_BYTES = Math.max(MAX_DOMAIN_CONTROL_MANIFEST_BYTES, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES);
24
24
  export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
25
25
  export class UnsupportedOfflineArtifactError extends TypeError {
@@ -532,6 +532,34 @@ export async function verifyOfflineArtifact(raw, options = {}) {
532
532
  : null;
533
533
  return Object.freeze({ ...report, manifestIdentity });
534
534
  }
535
+ function offlineArtifactNextSteps(report) {
536
+ switch (report.artifact.kind) {
537
+ case 'saved_lookup':
538
+ return ['Review retained source states without displaying target values:', ' whoisleuth source-report -- ./lookup.json'];
539
+ case 'case_export':
540
+ return ['Read the Case records, including private analyst content:', ' whoisleuth case show -- ./cases.json'];
541
+ case 'workspace_archive':
542
+ return ['Inspect section metadata without restoring the archive:', ' whoisleuth inspect-archive -- ./workspace.json'];
543
+ case 'encrypted_workspace_archive':
544
+ return report.checks.authenticatedEncryption === 'verified'
545
+ ? ['Inspect decrypted section metadata without restoring the archive:', ' whoisleuth inspect-archive --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json']
546
+ : ['Check the encrypted contents using a separate passphrase file:', ' whoisleuth verify-artifact --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json'];
547
+ case 'lookup_evidence':
548
+ return ['Review which fields this portable format preserves and omits:', ' whoisleuth interchange-report -- ./evidence.json'];
549
+ case 'cli_case_pack':
550
+ return ['Review the Case-pack compatibility and field exclusions:', ' whoisleuth interchange-report -- ./case-pack.json'];
551
+ case 'signed_review_artifact':
552
+ if (report.artifact.schema === DOMAIN_CONTROL_MANIFEST_SCHEMA || report.artifact.schema === LOOKUP_CLAIM_PASSPORT_SCHEMA) {
553
+ return ['Review the passport compatibility and retained field groups:', ' whoisleuth interchange-report -- ./passport.json'];
554
+ }
555
+ return report.artifact.schema === MANAGED_INDICATOR_SET_SCHEMA
556
+ && report.summary.inputBytes <= MAX_MANAGED_INDICATOR_SET_BYTES
557
+ ? ['Read the retained indicator identities and review windows:', ' whoisleuth indicator-set inspect -- ./indicators.json']
558
+ : [];
559
+ default:
560
+ return [];
561
+ }
562
+ }
535
563
  export function formatOfflineArtifactVerification(report) {
536
564
  const lines = [
537
565
  'WHOISleuth offline artefact verification',
@@ -590,8 +618,15 @@ export function formatOfflineArtifactVerification(report) {
590
618
  }
591
619
  if (report.package) {
592
620
  lines.push(`Package bytes: ${report.package.digestSha256}`, 'Storage: unchanged; inspection only', 'Audience: private; review selected file contents before sharing', 'Signature trust: not checked', 'Trusted timestamp: not checked', 'Factual accuracy: not established');
593
- for (const entry of report.package.entries)
621
+ for (const entry of report.package.entries) {
594
622
  lines.push(`${entry.id}: ${entry.state} · ${entry.byteLength} bytes · ${entry.mediaType}${entry.issue ? ` · ${entry.issue}` : ''}`);
623
+ if (entry.imageDerivation) {
624
+ const declaration = entry.imageDerivation;
625
+ lines.push(` Declared image derivation: ${declaration.method}; ${declaration.operations.join(', ')}`, ` Parent: ${declaration.source.digestSha256} · ${declaration.source.byteLength} bytes`);
626
+ }
627
+ else if (entry.mediaType.startsWith('image/'))
628
+ lines.push(' Editing history: not declared');
629
+ }
595
630
  for (const link of report.package.links)
596
631
  lines.push(`Source link ${link.capsuleEntryId}: ${link.state}${link.sourceEntryId ? ` (${link.sourceEntryId})` : ''}`);
597
632
  for (const capture of report.package.captureManifests) {
@@ -604,5 +639,8 @@ export function formatOfflineArtifactVerification(report) {
604
639
  }
605
640
  for (const limitation of report.limitations)
606
641
  lines.push(`Limitation: ${limitation}`);
642
+ const nextSteps = offlineArtifactNextSteps(report);
643
+ if (nextSteps.length)
644
+ lines.push('', 'Offline next steps (examples; replace filenames; nothing runs automatically):', ...nextSteps);
607
645
  return `${lines.join('\n')}\n`;
608
646
  }
@@ -93,6 +93,10 @@ async function runBulkCommand(args, dependencies, context) {
93
93
  }
94
94
  catch (error) {
95
95
  checkpointFailure = error;
96
+ eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
97
+ if (!eventProgress.enabled) {
98
+ context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(error, 'Checkpoint could not be written')}. The latest progress may not be saved.\n`);
99
+ }
96
100
  }
97
101
  }
98
102
  const metadata = {
@@ -122,12 +126,6 @@ async function runBulkCommand(args, dependencies, context) {
122
126
  else
123
127
  context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
124
128
  }
125
- if (checkpointFailure) {
126
- eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
127
- if (!eventProgress.enabled) {
128
- context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
129
- }
130
- }
131
129
  const policyFindings = evaluateCliFailPolicies(buildCliBulkDocument(items, { ...metadata, filter: 'all' }), args.failOn || []);
132
130
  if (policyFindings.length && !eventProgress.enabled)
133
131
  context.writeStderr(formatFailPolicyNotice(policyFindings));
@@ -8,6 +8,7 @@ import { createCaseIncident, openOrCreateCase, recordCaseConclusion, recordCaseR
8
8
  import { buildCaseExport, serializeCaseStore } from "../packages/cases/case-storage-model.mjs";
9
9
  import { appendCaseEvidencePin } from "../packages/cases/case-response-model.mjs";
10
10
  import { readCaseRecheckAnswerContext } from "../packages/cases/case-recheck-model.mjs";
11
+ import { readCaseResponseObjects } from "../packages/cases/case-response-object.mjs";
11
12
  import { caseEvidenceLinkIssues, mergeCaseEvidenceLinks } from "../packages/cases/case-evidence-links.mjs";
12
13
  import { dispositionLabel, statusLabel } from "../packages/cases/case-record-decisions.mjs";
13
14
  import { normalizeDomain } from "../packages/cases/case-record-core.mjs";
@@ -28,12 +29,12 @@ function prose(input, maximum, label) {
28
29
  function selection(cases, args, required) {
29
30
  const domain = args.domain === null ? null : normalizeDomain(args.domain);
30
31
  if (args.domain !== null && !domain)
31
- throw new CliUsageError('Select a valid Case domain.');
32
+ throw new CliUsageError('Select a valid case domain.');
32
33
  const selected = cases.filter(record => (!args.caseId || record.id === args.caseId) && (!domain || record.domain === domain));
33
34
  if ((args.caseId || domain || required) && selected.length === 0)
34
- throw new CliUsageError('The selected Case is not present in this file.');
35
+ throw new CliUsageError('The selected case is not present in this file.');
35
36
  if (required && selected.length !== 1)
36
- throw new CliUsageError('Select exactly one Case with --case-id; no arbitrary Case was chosen.');
37
+ throw new CliUsageError('Select exactly one case with --case-id; no arbitrary case was chosen.');
37
38
  return selected;
38
39
  }
39
40
  function pin(input, now) {
@@ -41,7 +42,7 @@ function pin(input, now) {
41
42
  throw new CliUsageError('An evidence pin must be a JSON object.');
42
43
  const raw = input;
43
44
  if (Object.hasOwn(raw, 'id') || Object.hasOwn(raw, 'createdAt'))
44
- throw new CliUsageError('New pin identity and creation time are allocated by the Case operation.');
45
+ throw new CliUsageError('New pin identity and creation time are allocated by the case operation.');
45
46
  const normal = appendCaseEvidencePin([], raw, now)[0];
46
47
  // Validate the supplied subset against the domain result, without duplicating
47
48
  // its field inventory or accepting silent truncation/default substitution.
@@ -59,7 +60,7 @@ function retainExistingEntries(before, after) {
59
60
  }
60
61
  const pairs = [
61
62
  [before.notes, after.notes], [before.evidenceHistory, after.evidenceHistory], [before.evidencePins, after.evidencePins],
62
- [before.decisions, after.decisions], [before.actions, after.actions], [before.assertions, after.assertions],
63
+ [before.decisions, after.decisions], [before.assertions, after.assertions],
63
64
  [before.manualTrail, after.manualTrail], [before.sightings, after.sightings],
64
65
  [before.observedEffects.reviews, after.observedEffects.reviews], [before.closures.records, after.closures.records],
65
66
  [before.branches ?? [], after.branches ?? []], [before.attachments ?? [], after.attachments ?? []],
@@ -70,6 +71,13 @@ function retainExistingEntries(before, after) {
70
71
  throw new CliUsageError('The operation would remove or alter earlier retained evidence. Export a smaller working set or review it in the console; nothing was written.');
71
72
  }
72
73
  }
74
+ for (const action of before.actions) {
75
+ const next = after.actions.find(candidate => candidate.id === action.id);
76
+ const events = new Set(next?.history.map(event => canonicalArtifactJsonV2(event)) ?? []);
77
+ if (!next || action.history.some(event => !events.has(canonicalArtifactJsonV2(event)))) {
78
+ throw new CliUsageError('The operation would alter retained response events. Nothing was written.');
79
+ }
80
+ }
73
81
  }
74
82
  export function applyCliCaseOperation(cases, args, input, note, now) {
75
83
  if (args.operation === 'open') {
@@ -78,7 +86,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
78
86
  ? createCaseIncident(cases, { domain: args.domain, title }, now)
79
87
  : openOrCreateCase(cases, { domain: args.domain, ...(title ? { title } : {}) }, now, args.caseId ? { caseId: args.caseId } : {});
80
88
  if (!result.created && title && title !== result.record.title)
81
- throw new CliUsageError('The selected Case already exists with another title. Use --new-incident for a separate investigation.');
89
+ throw new CliUsageError('The selected case already exists with another title. Use --new-incident for a separate investigation.');
82
90
  return result.cases;
83
91
  }
84
92
  const current = selection(cases, args, true)[0];
@@ -92,6 +100,67 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
92
100
  else if (args.operation === 'link' || args.operation === 'withdraw-link') {
93
101
  result = updateCase(cases, current.id, args.operation === 'link' ? { evidenceLink: input } : { evidenceLinkWithdrawal: input }, now);
94
102
  }
103
+ else if (args.operation === 'incident-link') {
104
+ if (!input)
105
+ throw new CliUsageError('An incident link requires JSON input.');
106
+ fields(input, ['url'], 'Incident link');
107
+ result = updateCase(cases, current.id, { incidentTarget: input.url }, now);
108
+ }
109
+ else if (args.operation === 'action') {
110
+ if (!input)
111
+ throw new CliUsageError('A response action requires JSON input.');
112
+ fields(input, ['type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'dueAt', 'followUpAt', 'responseObjects', 'originActionId', 'amendment', 'correction'], 'Response action');
113
+ result = updateCase(cases, current.id, { action: input }, now);
114
+ const action = result.record.actions.find(candidate => !current.actions.some(prior => prior.id === candidate.id));
115
+ if (!action)
116
+ throw new CliUsageError('No new action was retained within the case bounds; nothing was written.');
117
+ for (const [key, value] of Object.entries(input))
118
+ if (canonicalArtifactJsonV2(key === 'responseObjects' ? readCaseResponseObjects(value) : value) !== canonicalArtifactJsonV2(action[key])) {
119
+ throw new CliUsageError(`Action field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
120
+ }
121
+ }
122
+ else if (args.operation === 'action-event') {
123
+ if (!input || !input.transition || typeof input.transition !== 'object' || Array.isArray(input.transition))
124
+ throw new CliUsageError('An action event requires id and transition JSON.');
125
+ fields(input, ['id', 'transition'], 'Response action event');
126
+ const transition = input.transition;
127
+ fields(transition, ['nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'responseObjects', 'objectOutcome', 'evidenceRequest', 'packetReceipt'], 'Response transition');
128
+ result = updateCase(cases, current.id, { actionUpdate: input }, now);
129
+ const previous = current.actions.find(action => action.id === input.id);
130
+ const event = result.record.actions.find(action => action.id === input.id)?.history.find(candidate => !previous?.history.some(prior => prior.id === candidate.id));
131
+ if (!event)
132
+ throw new CliUsageError('The selected action is not retained. Nothing was written.');
133
+ for (const [key, value] of Object.entries(transition))
134
+ if (canonicalArtifactJsonV2(key === 'responseObjects' ? readCaseResponseObjects(value) : value) !== canonicalArtifactJsonV2(event[key])) {
135
+ throw new CliUsageError(`Transition field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
136
+ }
137
+ }
138
+ else if (args.operation === 'recheck-question') {
139
+ if (!input)
140
+ throw new CliUsageError('A recheck question requires JSON input.');
141
+ fields(input, ['statement', 'rationale', 'recheck', 'evidencePinIds'], 'Recheck question');
142
+ result = updateCase(cases, current.id, { assertion: { ...input, kind: 'next_step' } }, now);
143
+ const question = result.record.assertions.find(candidate => !current.assertions.some(prior => prior.id === candidate.id));
144
+ if (!question)
145
+ throw new CliUsageError('No new question was retained within the case bounds; nothing was written.');
146
+ for (const [key, value] of Object.entries(input))
147
+ if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(question[key])) {
148
+ throw new CliUsageError(`Question field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
149
+ }
150
+ }
151
+ else if (args.operation === 'close-object') {
152
+ if (!input?.responseObject)
153
+ throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole case.');
154
+ fields(input, ['responseObject', 'reason', 'summary', 'observedEffectReviewId', 'actionId', 'limitations'], 'Object closure');
155
+ result = updateCase(cases, current.id, { closure: input }, now);
156
+ const closure = result.record.closures.records.find(candidate => !current.closures.records.some(prior => prior.id === candidate.id));
157
+ if (!closure)
158
+ throw new CliUsageError('No new closure was retained within the case bounds; nothing was written.');
159
+ for (const [key, value] of Object.entries(input))
160
+ if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(closure[key])) {
161
+ throw new CliUsageError(`Closure field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
162
+ }
163
+ }
95
164
  else if (args.operation === 'assess') {
96
165
  if (!input)
97
166
  throw new CliUsageError('An assessment requires JSON input.');
@@ -119,10 +188,10 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
119
188
  else if (args.operation === 'recheck') {
120
189
  if (!input)
121
190
  throw new CliUsageError('A recheck requires JSON input.');
122
- fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname'], 'Recheck');
191
+ fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname', 'responseObject', 'objectOutcome'], 'Recheck');
123
192
  const recheck = readCaseRecheckAnswerContext(input.recheck);
124
193
  if (input.state === 'not_reproduced' && !recheck) {
125
- throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned Case or record unavailable for a limited observation.');
194
+ throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned case or record unavailable for a limited observation.');
126
195
  }
127
196
  const summary = prose(input.comparisonSummary, MAX_RESPONSE_VALUE_LENGTH, 'Comparison summary');
128
197
  result = recordCaseRecheckOutcome(cases, current.id, {
@@ -135,6 +204,8 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
135
204
  comparisonSummary: comparison.value, source: review.source, followUpAt: review.followUpAt,
136
205
  limitations: review.limitations, collectionDepth: comparison.collectionDepth,
137
206
  ...(review.recheck ? { recheck: review.recheck } : {}),
207
+ ...(review.responseObject ? { responseObject: review.responseObject } : {}),
208
+ ...(review.objectOutcome ? { objectOutcome: review.objectOutcome } : {}),
138
209
  ...(comparison.observationHostname ? { observationHostname: comparison.observationHostname } : {}),
139
210
  };
140
211
  for (const [key, value] of Object.entries(input)) {
@@ -143,12 +214,12 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
143
214
  }
144
215
  }
145
216
  else
146
- throw new CliUsageError('Select a supported Case mutation.');
217
+ throw new CliUsageError('Select a supported case mutation.');
147
218
  retainExistingEntries(current, result.record);
148
219
  return result.cases;
149
220
  }
150
221
  function formatCases(cases, digest) {
151
- const lines = ['Local Case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
222
+ const lines = ['Local case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
152
223
  for (const record of cases) {
153
224
  lines.push(`Case ${safeTerminalValue(record.id)} · ${safeTerminalValue(record.title || record.domain)}`, `Domain ${safeTerminalValue(record.domain)}`, `Status ${statusLabel(record.status)}`, `Disposition ${dispositionLabel(record.disposition)} (analyst assessment)`, `Updated ${record.updatedAt}`);
154
225
  for (const note of record.notes)
@@ -166,20 +237,25 @@ function formatCases(cases, digest) {
166
237
  if (cyclic)
167
238
  lines.push(' Conflicting imported derivation cycle; no order is inferred.');
168
239
  }
240
+ for (const action of record.actions) {
241
+ lines.push(`Action ${safeTerminalValue(action.id)} · ${action.type} · ${action.state}`, ` Objects: ${action.responseObjects?.map(object => `${object.kind}: ${safeTerminalValue(object.identifier)}`).join('; ') || 'unknown binding'}`);
242
+ for (const event of action.history)
243
+ lines.push(` Event ${event.occurredAt} · ${event.sourceClass} · ${event.nextState}${event.objectOutcome ? ` · reported ${event.objectOutcome}` : ''}`, ` Scope: ${event.responseObjects?.map(object => `${object.kind}: ${safeTerminalValue(object.identifier)}`).join('; ') || 'unknown binding'}`);
244
+ }
169
245
  for (const review of record.observedEffects.reviews)
170
- lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`);
246
+ lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`, ` Object: ${review.responseObject ? `${review.responseObject.kind}: ${safeTerminalValue(review.responseObject.identifier)}` : 'unknown binding'}${review.objectOutcome ? ` · observed ${review.objectOutcome}` : ''}`);
171
247
  lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
172
248
  }
173
- lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
249
+ lines.push('Use --json for the full case export. Attached file bytes remain separate. This command makes no request.', '');
174
250
  return lines.join('\n');
175
251
  }
176
252
  export async function runCaseCommand(args, dependencies, context) {
177
- context.setFailureLabel('Local Case operation');
253
+ context.setFailureLabel('Local case operation');
178
254
  try {
179
255
  const raw = args.source ? dependencies.caseFileInput ?? await context.readInput(args.source, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case file') : null;
180
256
  const digest = raw === null ? null : `sha256:${createHash('sha256').update(raw).digest('hex')}`;
181
257
  if (args.expectedFileDigest && args.expectedFileDigest !== digest)
182
- throw new CliUsageError('The Case file differs from the reviewed file digest. Nothing was written.');
258
+ throw new CliUsageError('The case file differs from the reviewed file digest. Nothing was written.');
183
259
  const cases = raw === null ? [] : readEditableCaseExport(raw);
184
260
  const now = normalizeExplicitIsoTimestamp(context.now());
185
261
  if (!now)
@@ -195,7 +271,7 @@ export async function runCaseCommand(args, dependencies, context) {
195
271
  const note = args.noteSource ? await context.readInput(args.noteSource, MAX_NOTE_LENGTH * 4 + 4, 'Case note') : args.text;
196
272
  const next = applyCliCaseOperation(cases, args, input, note, now);
197
273
  if (Buffer.byteLength(serializeCaseStore(next)) > MAX_CASE_STORE_BYTES)
198
- throw new CliUsageError('The updated Case store exceeds its byte budget. No evidence was pruned and nothing was written.');
274
+ throw new CliUsageError('The updated case store exceeds its byte budget. No evidence was pruned and nothing was written.');
199
275
  const output = `${JSON.stringify(buildCaseExport(next, now))}\n`;
200
276
  // Apply the same strict admission to our output before atomic publication.
201
277
  readEditableCaseExport(output);
@@ -207,6 +283,6 @@ export async function runCaseCommand(args, dependencies, context) {
207
283
  dependencies.signal?.throwIfAborted();
208
284
  if (cause instanceof CliUsageError)
209
285
  throw cause;
210
- throw new CliUsageError(cause instanceof Error ? cause.message : 'The Case operation could not be completed.');
286
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'The case operation could not be completed.');
211
287
  }
212
288
  }
package/cli/case-pack.mjs CHANGED
@@ -5,7 +5,7 @@ import { buildCaseReport, buildCaseReportVerificationProjection } from "../packa
5
5
  import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
6
6
  import { assertBoundedJsonStructure, scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
7
7
  import { assertCaseBrandProfileIds } from "../packages/cases/case-brand-profile-references.mjs";
8
- import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, } from "../packages/cases/case-record-projection.mjs";
8
+ import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, assertCaseCollectionReceiptAudience, } from "../packages/cases/case-record-projection.mjs";
9
9
  import { normalizeDomain } from "../packages/evidence/domain-name.mjs";
10
10
  import { normalizeCaseStore } from "../packages/cases/case-migration-model.mjs";
11
11
  import { safeId } from "../packages/cases/case-record-core.mjs";
@@ -62,6 +62,7 @@ function listItemPath(path, list) {
62
62
  && path[3] === 'analystResponse' && path[4] === list && typeof path[5] === 'number');
63
63
  }
64
64
  function allowedSensitiveFieldPath(key, path) {
65
+ const deliveryReceipt = path.at(-1) === 'packetReceipt' && typeof path.at(-2) === 'number' && path.at(-3) === 'history' && listItemPath(path.slice(0, -3), 'actions');
65
66
  if (key === 'brandProfileIds')
66
67
  return topCasePath(path) || reportCasePath(path);
67
68
  if (key === 'notes')
@@ -75,9 +76,18 @@ function allowedSensitiveFieldPath(key, path) {
75
76
  return topCasePath(path) || analystResponsePath(path);
76
77
  }
77
78
  if (key === 'recipient')
78
- return listItemPath(path, 'actions');
79
+ return listItemPath(path, 'actions') || deliveryReceipt;
79
80
  if (key === 'target')
80
- return listItemPath(path, 'manualTrail');
81
+ return listItemPath(path, 'manualTrail') || deliveryReceipt;
82
+ if (key === 'correction')
83
+ return listItemPath(path, 'actions');
84
+ if (key === 'packetReceipt')
85
+ return typeof path.at(-1) === 'number' && path.at(-2) === 'history' && listItemPath(path.slice(0, -2), 'actions');
86
+ if (key === 'checkpointCollectionReceipts')
87
+ return topCasePath(path);
88
+ if (key === 'collectionReceipt')
89
+ return path.length === 4 && path[0] === 'cases' && typeof path[1] === 'number'
90
+ && (path[2] === 'evidenceHistory' || path[2] === 'checkpointCollectionReceipts') && typeof path[3] === 'number';
81
91
  return true;
82
92
  }
83
93
  /** Rejects hidden audience-sensitive copies with a budget proven by the serialized byte bound. */
@@ -385,6 +395,7 @@ export function verifyCliCasePack(input) {
385
395
  assertClosedEnvelopes(root, packet, integrity, redactionManifest, packet.reports, root.version);
386
396
  assertSensitiveFieldPlacement(root, serialized.length);
387
397
  const audience = packet.audience;
398
+ assertCaseCollectionReceiptAudience(root, audience);
388
399
  if (!Number.isSafeInteger(redactionManifest.sourceCaseCount)
389
400
  || redactionManifest.sourceCaseCount !== root.cases.length
390
401
  || !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
package/cli/ci-report.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { WHOISLEUTH_REQUEST_POLICY_URL } from "../packages/analysis/project-metadata.mjs";
2
2
  import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA } from "../packages/contracts/domain-control-monitor.mjs";
3
+ import { lookupDiagnosticEntries } from "../lib/lookup-progress.mjs";
4
+ import { lookupStrictExitFindings } from "./strict-exit.mjs";
3
5
  function record(value) {
4
6
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
5
7
  }
@@ -9,13 +11,23 @@ function xml(value) {
9
11
  function junitCases(document) {
10
12
  const schema = String(document.schema || '');
11
13
  if (schema === 'whoisleuth.cli.lookup') {
12
- const diagnostics = record(document.diagnostics);
13
- const cases = Object.entries(diagnostics).slice(0, 64).map(([source, value]) => {
14
- const state = String(record(value).status ?? record(value).state ?? 'unavailable').toLowerCase();
15
- return ['error', 'failed', 'partial', 'rate_limited', 'timeout', 'unavailable'].includes(state)
16
- ? { name: `source ${source}`, failure: `Source state: ${state}` }
14
+ const sources = lookupDiagnosticEntries(document.diagnostics).filter(source => source.present);
15
+ const findings = lookupStrictExitFindings(document);
16
+ const cases = sources.map(({ source, path, state }) => {
17
+ const failure = findings.find(finding => finding.path === path);
18
+ return failure || state === null
19
+ ? { name: `source ${source}`, failure: `Source state: ${failure?.state ?? 'unavailable'}` }
17
20
  : { name: `source ${source}` };
18
21
  });
22
+ // Retain nested and historical failure coverage without echoing imported
23
+ // object keys, endpoints or evidence into CI names or messages.
24
+ const namedPaths = new Set(sources.map(source => source.path));
25
+ const additional = findings.filter(finding => !namedPaths.has(finding.path));
26
+ if (additional.length)
27
+ cases.push({
28
+ name: 'additional source diagnostics',
29
+ failure: `Source states: ${[...new Set(additional.map(finding => finding.state))].sort().join(', ')}`,
30
+ });
19
31
  return cases.length ? cases : [{ name: 'lookup diagnostics', failure: 'No source diagnostics were available.' }];
20
32
  }
21
33
  if (schema === 'whoisleuth.cli.bulk') {
@@ -19,7 +19,7 @@ const LEGACY_WORKSPACE_ARCHIVE_SCOPE = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
19
19
  .map((version) => `v${version}`)
20
20
  .join(' and ');
21
21
  const INLINE_COMMAND_FAMILIES = ['support', 'review', 'assurance', 'workflow', 'history'];
22
- const CLI_CASE_OPERATIONS = ['show', 'open', 'note', 'pin', 'link', 'withdraw-link', 'assess', 'recheck'];
22
+ const CLI_CASE_OPERATIONS = ['show', 'open', 'note', 'pin', 'link', 'withdraw-link', 'assess', 'recheck', 'incident-link', 'action', 'action-event', 'recheck-question', 'close-object'];
23
23
  const CLI_INDICATOR_OPERATIONS = ['revise', 'inspect', 'stix', 'misp'];
24
24
  const CLI_META_ACTIONS = Object.freeze([
25
25
  Object.freeze({
@@ -126,10 +126,11 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
126
126
  '--config': file('Load explicit versioned CLI configuration from this file.'),
127
127
  '--profile': text('Select a named profile from the supplied configuration.', true),
128
128
  '--palette': enumeration('Choose the terminal colour palette; redirected output and no-colour settings still take precedence.', ['auto', 'light', 'dark']),
129
- '--network': flag('Include the optional public DNS and port 43 runtime checks.'),
129
+ '--network': flag('Include the optional public DNS, HTTPS and WHOIS (port 43) runtime checks.'),
130
130
  '--json': flag('Write structured JSON to stdout or the selected output file.'),
131
131
  '--reported-action': optionDefinition('enum', 'Record an analyst-reported identity action; repeat for separate actions.', { values: IDENTITY_ACTIONS.map(action => action.id), occurrence: 'repeatable' }),
132
132
  '--trusted-auth-header': optionDefinition('text', 'Select a recognised receiver header by part:header-index. This records analyst trust, not independent authentication; repeat for separate headers.', { occurrence: 'repeatable' }),
133
+ '--intake-context': optionDefinition('file', 'Read one selected version-1 distribution declaration or version-2 digest-bound phone/destination selection file. Declarations remain attributed claims.'),
133
134
  '--package': flag('Verify a portable evidence ZIP or encrypted package rather than a single report.'),
134
135
  '--folder': file('Verify the evidence package within this selected folder.'),
135
136
  '--quiet': flag('Suppress ordinary terminal presentation.', 'idempotent'),
@@ -234,10 +235,10 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
234
235
  '--left-session': text('Select the left-hand retained capture session.', true),
235
236
  '--right-session': text('Select the right-hand retained capture session.', true),
236
237
  '--compact': flag('Write a compact report presentation.'),
237
- '--case-id': text('Select the retained Case identifier.'),
238
- '--domain': text('Supply the Case domain.'),
239
- '--title': text('Supply the Case title.'),
240
- '--new-incident': flag('Create a separate incident instead of updating a matching Case.'),
238
+ '--case-id': text('Select the retained case identifier.'),
239
+ '--domain': text('Supply the case domain.'),
240
+ '--title': text('Supply the case title.'),
241
+ '--new-incident': flag('Create a separate incident instead of updating a matching case.'),
241
242
  '--text': text('Supply the note text directly.'),
242
243
  '--note-file': file('Read note text from a selected local file.'),
243
244
  '--input': file('Read the selected local input file.'),
@@ -135,6 +135,10 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
135
135
  }
136
136
  catch (error) {
137
137
  checkpointFailure = error;
138
+ eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
139
+ if (!eventProgress.enabled) {
140
+ context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(error, 'Checkpoint could not be written')}. The latest progress may not be saved.\n`);
141
+ }
138
142
  }
139
143
  }
140
144
  const snapshot = args.observationSnapshot
@@ -170,12 +174,6 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
170
174
  else
171
175
  context.writeStdout(context.terminal(formatTerminalDiscoveryScan(document), args.color));
172
176
  }
173
- if (checkpointFailure) {
174
- eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
175
- if (!eventProgress.enabled) {
176
- context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
177
- }
178
- }
179
177
  const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
180
178
  if (policyFindings.length && !eventProgress.enabled)
181
179
  context.writeStderr(formatFailPolicyNotice(policyFindings));