@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. package/third-party-notices.txt +29 -29
package/DISCLOSURE CHANGED
@@ -1,4 +1,4 @@
1
- WHOISleuth is a defensive domain investigation and brand-protection tool. Its
1
+ WHOISleuth is a defensive domain investigation and brand protection tool. Its
2
2
  command-line package can collect bounded WHOIS, RDAP, DNS, HTTP, TLS,
3
3
  certificate-transparency, website, and related infrastructure evidence. It can
4
4
  also generate and review lookalike-domain candidates, compare saved evidence,
@@ -1,6 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertPacketAmendmentSelection } from "../../packages/cases/case-requested-evidence.mjs";
3
+ import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "../../packages/cases/case-packet-correction.mjs";
4
+ import { CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION } from "../../packages/cases/case-evidence-links.mjs";
5
+ import { OBJECT_RESPONSE_CASE_PACKET_VERSION, OBJECT_RESPONSE_CASE_REVIEW_INPUTS_VERSION } from "../../packages/contracts/case-portability.mjs";
3
6
  import { canonicalArtifactJsonV2 } from "../../packages/evidence/artifact-integrity.mjs";
7
+ import { EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION, EVIDENCE_FOLLOW_UP_CASE_RESPONSE_REVIEW_INPUTS_VERSION } from "../../packages/contracts/case-portability.mjs";
8
+ import { readCaseResponseObject, readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome } from "../../packages/cases/case-response-object.mjs";
4
9
  import { CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSIVE_URLS, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILES, RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, RESPONSE_ROUTE_STALE_AFTER_DAYS, } from "../../packages/cases/case-response-packet.mjs";
5
10
  import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PROVIDER_OUTCOMES, isLegalCaseActionTransition, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RECIPIENT_LENGTH, } from "../../packages/cases/case-response-model.mjs";
6
11
  import { HEX_DIGEST_RE, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, optionalText, sameValues, strings, text, } from "./structure-primitives.mjs";
@@ -282,7 +287,8 @@ function validateVersionedCaseResponsePacket(value, version) {
282
287
  fail('Case-response abusive URLs');
283
288
  text(incident.observedHarm, 'Case-response observed harm', 2_000);
284
289
  iso(incident.observedAt, 'Case-response observedAt');
285
- if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`)
290
+ if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
291
+ && (version < 13 || profile.subject !== `Correction or retraction request: ${caseRecord.domain} (${incident.category})`))
286
292
  fail('Case-response profile subject');
287
293
  const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
288
294
  const contactKeys = new Set();
@@ -379,7 +385,10 @@ function validateVersionedCaseResponsePacket(value, version) {
379
385
  const evidence = array(root.selectedEvidence, 'Case-response selected evidence', MAX_RESPONSE_SELECTED_EVIDENCE);
380
386
  const evidenceIds = new Set();
381
387
  for (const candidate of evidence) {
382
- const item = exactOptional(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION ? ['observationHostname'] : [], 'Case-response selected evidence');
388
+ const item = exactOptional(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], [...(version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION ? ['observationHostname', 'webObservationMode'] : []), ...(version >= 12 ? ['responseObject'] : [])], 'Case-response selected evidence');
389
+ readCaseResponseObject(item.responseObject);
390
+ if (item.webObservationMode !== undefined && item.webObservationMode !== 'selected_url')
391
+ fail('Case-response web observation mode');
383
392
  const id = text(item.id, 'Case-response evidence id', 64);
384
393
  if (evidenceIds.has(id))
385
394
  fail('Case-response selected evidence');
@@ -550,8 +559,11 @@ function validateVersionedCaseResponsePacket(value, version) {
550
559
  'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
551
560
  ...(hasActionBinding ? ['routeObservedAt'] : []),
552
561
  ...(hasPlatformRoutes ? ['routeReviewAfter'] : []),
553
- ], Number(root.schemaVersion) >= 11 ? ['amendment'] : [], 'Case-response escalation action');
562
+ ], [...(Number(root.schemaVersion) >= 11 ? ['amendment'] : []), ...(version >= 12 ? ['responseObjects'] : []), ...(version >= 13 ? ['correction'] : [])], 'Case-response escalation action');
563
+ readCaseResponseObjects(action.responseObjects);
554
564
  readCasePacketAmendment(action.amendment);
565
+ if (readCasePacketCorrection(action.correction) && action.amendment)
566
+ throw new TypeError('Correction and provider amendment must remain separate.');
555
567
  const actionId = text(action.actionId, 'Case-response action id', 64);
556
568
  if (actionIds.has(actionId))
557
569
  fail('Case-response action identity');
@@ -583,7 +595,19 @@ function validateVersionedCaseResponsePacket(value, version) {
583
595
  let previousTime = Number.NEGATIVE_INFINITY;
584
596
  let previousEventId = '';
585
597
  for (const eventCandidate of transitions) {
586
- const event = exactOptional(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], Number(root.schemaVersion) >= 11 ? ['evidenceRequest'] : [], 'Case-response action transition');
598
+ const event = exactOptional(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], [...(Number(root.schemaVersion) >= 11 ? ['evidenceRequest'] : []), ...(version >= 12 ? ['responseObjects', 'objectOutcome'] : []), ...(version >= 13 ? ['packetReceipt'] : [])], 'Case-response action transition');
599
+ const receipt = readCaseDeliveryPacketReceipt(event.packetReceipt);
600
+ assertDeliveryPacketReceipt(receipt, event, actionId);
601
+ if (receipt && (receipt.caseId !== caseRecord.id || receipt.target !== caseRecord.domain || receipt.recipient !== action.recipient))
602
+ fail('Case-response receipt Case, target or recipient');
603
+ const responseObjects = readCaseResponseObjects(event.responseObjects);
604
+ const objectOutcome = readCaseResponseObjectOutcome(event.objectOutcome);
605
+ if (objectOutcome) {
606
+ if (!responseObjects?.length || !['submitted', 'acknowledged', 'terminal'].includes(String(event.nextState))
607
+ || !['analyst', 'provider'].includes(String(event.sourceClass)) || event.previousState === 'authorised')
608
+ fail('Case-response object outcome scope');
609
+ responseObjects.forEach(object => assertCaseObjectOutcome(objectOutcome, object));
610
+ }
587
611
  const evidenceRequest = readCaseEvidenceRequest(event.evidenceRequest);
588
612
  assertEvidenceRequestEvent(evidenceRequest, event);
589
613
  const eventId = text(event.id, 'Case-response action event id', 64);
@@ -679,6 +703,15 @@ function validateVersionedCaseResponsePacket(value, version) {
679
703
  const action = candidate;
680
704
  return { id: action.actionId, history: action.transitions, originActionId: action.originActionId, amendment: action.amendment };
681
705
  }), selectedActionId, evidenceIds);
706
+ if (version >= 13) {
707
+ const actions = history.map(candidate => { const action = candidate; return { ...action, id: action.actionId, history: action.transitions }; });
708
+ const selected = actions.find(action => action.id === selectedActionId);
709
+ if (selected?.correction)
710
+ correctionDelivery(actions, selected, { caseId: caseRecord.id, target: caseRecord.domain, profile: profile.id, selectedPinIds: evidenceIds });
711
+ const expectedPrefix = selected?.correction ? 'Correction or retraction request' : selectedProfile?.subjectPrefix;
712
+ if (profile.subject !== `${expectedPrefix}: ${caseRecord.domain} (${incident.category})`)
713
+ fail('Case-response correction subject');
714
+ }
682
715
  const lifecycle = exact(root.responseLifecycle, ['providerOutcomeState', 'latestProviderOutcome', 'observedChangeState', 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations'], 'Case-response lifecycle');
683
716
  const providerOutcomeState = enumeration(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle provider state');
684
717
  let actualLatestProvider = null;
@@ -721,7 +754,10 @@ function validateVersionedCaseResponsePacket(value, version) {
721
754
  fail('Case-response lifecycle provider state');
722
755
  const observedChangeState = enumeration(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle observed-change state');
723
756
  if (lifecycle.latestObservedEffect !== null) {
724
- const effect = exact(lifecycle.latestObservedEffect, ['reviewId', 'state', 'observedAt', 'sourceClass', 'source'], 'Case-response lifecycle observed effect');
757
+ const effect = exactOptional(lifecycle.latestObservedEffect, ['reviewId', 'state', 'observedAt', 'sourceClass', 'source'], version >= 12 ? ['responseObject', 'objectOutcome'] : [], 'Case-response lifecycle observed effect');
758
+ const responseObject = readCaseResponseObject(effect.responseObject);
759
+ const objectOutcome = readCaseResponseObjectOutcome(effect.objectOutcome);
760
+ assertCaseObjectOutcome(objectOutcome, responseObject);
725
761
  text(effect.reviewId, 'Case-response effect review id', 64);
726
762
  enumeration(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response effect state');
727
763
  iso(effect.observedAt, 'Case-response effect observedAt');
@@ -743,9 +779,11 @@ function validateVersionedCaseResponsePacket(value, version) {
743
779
  contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
744
780
  version: version === CASE_RESPONSE_PACKET_VERSION
745
781
  ? CASE_RESPONSE_REVIEW_INPUTS_VERSION
746
- : version === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION ? LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION
747
- : hasPlatformRoutes ? PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION
748
- : hasActionBinding ? PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION,
782
+ : version === OBJECT_RESPONSE_CASE_PACKET_VERSION ? OBJECT_RESPONSE_CASE_REVIEW_INPUTS_VERSION
783
+ : version === EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION ? EVIDENCE_FOLLOW_UP_CASE_RESPONSE_REVIEW_INPUTS_VERSION
784
+ : version === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION ? LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION
785
+ : hasPlatformRoutes ? PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION
786
+ : hasActionBinding ? PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION,
749
787
  profile: {
750
788
  id: profile.id,
751
789
  label: profile.label,
@@ -768,6 +806,7 @@ function validateVersionedCaseResponsePacket(value, version) {
768
806
  escalationHistoryOmitted: root.escalationHistoryOmitted,
769
807
  escalationHistoryLimitations: root.escalationHistoryLimitations,
770
808
  responseLifecycle: root.responseLifecycle,
809
+ ...(version >= 13 ? { sourceQualifications: strings(root.provenance.limitations, 'Case-response limitations', 8, 600).filter(value => value === CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION) } : {}),
771
810
  };
772
811
  const expectedReviewDigest = createHash('sha256')
773
812
  .update(canonicalArtifactJsonV2(reviewedInputs))
@@ -794,6 +833,10 @@ function validateVersionedCaseResponsePacket(value, version) {
794
833
  fail('Case-response integrity');
795
834
  }
796
835
  export function validateCaseResponsePacket(value) {
836
+ if (value.schemaVersion === OBJECT_RESPONSE_CASE_PACKET_VERSION)
837
+ return validateVersionedCaseResponsePacket(value, OBJECT_RESPONSE_CASE_PACKET_VERSION);
838
+ if (value.schemaVersion === EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION)
839
+ return validateVersionedCaseResponsePacket(value, EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION);
797
840
  if (value.schemaVersion === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION)
798
841
  return validateVersionedCaseResponsePacket(value, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION);
799
842
  if (value.schemaVersion === PUBLIC_CASE_RESPONSE_PACKET_VERSION)
@@ -1,6 +1,8 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
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";
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";
@@ -24,7 +26,7 @@ const PUBLIC_LOOKUP_AVAILABILITY_ANALYSIS_KEYS = [
24
26
  'expiresInDays', 'privacyProtected', 'dnssec', 'activityStatus',
25
27
  'websiteProbeStatus', 'websiteProbeDetail', 'http', 'deepScanComplete',
26
28
  'faviconHash', 'faviconPHash', 'pageTitle', 'hasPasswordField',
27
- 'phishingLanguageMatch', 'hasExternalFormAction', 'externalAssetHosts',
29
+ 'phishingLanguageMatch', 'hasExternalFormAction', 'hasExternalPasswordForm', 'externalAssetHosts',
28
30
  'pageIdentity', 'credentialSurfaceProfile', 'structuredDataIdentity',
29
31
  'technologyProfile', 'pageRoleProfile', 'clientBehaviorProfile',
30
32
  'securityPosture', 'dns', 'tls', 'hasMx', 'hasNullMx', 'mxHosts', 'hasSpf',
@@ -42,6 +44,18 @@ const LOOKUP_REGISTRAR_COMPARISON_STATUSES = [
42
44
  'registrar_unavailable', 'registry_incomplete', 'registrar_incomplete',
43
45
  ];
44
46
  function validateLookupEvidenceHomepageMetadata(availability, version) {
47
+ const credentialProfile = availability.credentialSurfaceProfile === null || availability.credentialSurfaceProfile === undefined
48
+ ? null : record(availability.credentialSurfaceProfile, 'Lookup evidence credential profile');
49
+ if (version < 30 && (Object.hasOwn(availability, 'hasExternalPasswordForm') || credentialProfile?.credentialSurfaceVersion === 2)) {
50
+ fail('Lookup evidence form-attribution epoch');
51
+ }
52
+ if (Object.hasOwn(availability, 'hasExternalPasswordForm') && availability.hasExternalPasswordForm !== null)
53
+ boolean(availability.hasExternalPasswordForm, 'Lookup evidence password-form destination');
54
+ if (credentialProfile?.credentialSurfaceVersion === 2 && credentialSurfaceContractState(credentialProfile) !== 'supported')
55
+ fail('Lookup evidence form-attribution profile');
56
+ if (credentialProfile?.credentialSurfaceVersion === 2 && availability.hasExternalPasswordForm !== undefined
57
+ && availability.hasExternalPasswordForm !== externalPasswordFormObservation(credentialProfile.formAttribution))
58
+ fail('Lookup evidence form-attribution consistency');
45
59
  const pageIdentity = availability.pageIdentity === null || availability.pageIdentity === undefined
46
60
  ? null
47
61
  : record(availability.pageIdentity, 'Lookup evidence page identity');
@@ -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)
@@ -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 {
@@ -590,8 +590,15 @@ export function formatOfflineArtifactVerification(report) {
590
590
  }
591
591
  if (report.package) {
592
592
  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)
593
+ for (const entry of report.package.entries) {
594
594
  lines.push(`${entry.id}: ${entry.state} · ${entry.byteLength} bytes · ${entry.mediaType}${entry.issue ? ` · ${entry.issue}` : ''}`);
595
+ if (entry.imageDerivation) {
596
+ const declaration = entry.imageDerivation;
597
+ lines.push(` Declared image derivation: ${declaration.method}; ${declaration.operations.join(', ')}`, ` Parent: ${declaration.source.digestSha256} · ${declaration.source.byteLength} bytes`);
598
+ }
599
+ else if (entry.mediaType.startsWith('image/'))
600
+ lines.push(' Editing history: not declared');
601
+ }
595
602
  for (const link of report.package.links)
596
603
  lines.push(`Source link ${link.capsuleEntryId}: ${link.state}${link.sourceEntryId ? ` (${link.sourceEntryId})` : ''}`);
597
604
  for (const capture of report.package.captureManifests) {
@@ -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";
@@ -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') {
@@ -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,7 +188,7 @@ 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
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.');
@@ -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)) {
@@ -166,8 +237,13 @@ 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
249
  lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
package/cli/case-pack.mjs CHANGED
@@ -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,13 @@ 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');
81
86
  return true;
82
87
  }
83
88
  /** Rejects hidden audience-sensitive copies with a budget proven by the serialized byte bound. */
@@ -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({
@@ -130,6 +130,7 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
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'),
@@ -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));
@@ -0,0 +1,61 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { DOMAIN_FEED_LIMITS, normalizeDomainFeedSelection } from "../packages/monitoring/domain-feed.mjs";
4
+ import { CliUsageError } from "./errors.mjs";
5
+ export function parseDomainFeedSelectors(values) {
6
+ const hosts = [], terms = [], negativeTerms = [];
7
+ for (const value of values) {
8
+ if (value.startsWith('host:'))
9
+ hosts.push(value.slice(5));
10
+ else if (value.startsWith('term:'))
11
+ terms.push(value.slice(5));
12
+ else if (value.startsWith('exclude:'))
13
+ negativeTerms.push(value.slice(8));
14
+ else
15
+ throw new CliUsageError('--select requires host:<plain-hostname>, term:<literal-text> or exclude:<literal-text>.');
16
+ }
17
+ const selection = normalizeDomainFeedSelection({ hosts, terms, ...(negativeTerms.length ? { negativeTerms } : {}) });
18
+ if (!selection.hosts.length && !selection.terms.length)
19
+ throw new CliUsageError('Select at least one exact host or literal term.');
20
+ return selection;
21
+ }
22
+ /** Stream one descriptor-bound local regular file; URLs, pipes and symlinks are not inputs. */
23
+ export async function* readDomainFeedFile(source, signal) {
24
+ if (!source || /^[a-z][a-z0-9+.-]*:\/\//iu.test(source))
25
+ throw new CliUsageError('Domain feed review requires an explicit local regular file, not a URL.');
26
+ if (signal?.aborted)
27
+ throw new DOMException('Feed reading was cancelled.', 'AbortError');
28
+ const handle = await open(source, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
29
+ let stream;
30
+ const abort = () => stream?.destroy(new DOMException('Feed reading was cancelled.', 'AbortError'));
31
+ try {
32
+ if (signal?.aborted)
33
+ throw new DOMException('Feed reading was cancelled.', 'AbortError');
34
+ const before = await handle.stat();
35
+ if (!before.isFile())
36
+ throw new CliUsageError('The domain feed must be a regular file.');
37
+ if (before.size > DOMAIN_FEED_LIMITS.bytes)
38
+ throw new CliUsageError('The domain feed exceeds its 256 MiB raw-byte ceiling.');
39
+ stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 * 1024 });
40
+ signal?.addEventListener('abort', abort, { once: true });
41
+ if (signal?.aborted)
42
+ abort();
43
+ let bytes = 0;
44
+ for await (const chunk of stream) {
45
+ if (signal?.aborted)
46
+ throw new DOMException('Feed reading was cancelled.', 'AbortError');
47
+ if (!(chunk instanceof Uint8Array) || chunk.byteLength > DOMAIN_FEED_LIMITS.bytes - bytes)
48
+ throw new CliUsageError('The domain feed exceeds its raw-byte ceiling.');
49
+ bytes += chunk.byteLength;
50
+ yield chunk;
51
+ }
52
+ const after = await handle.stat();
53
+ if (bytes !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)
54
+ throw new CliUsageError('The domain feed changed while it was being read; no review was produced.');
55
+ }
56
+ finally {
57
+ signal?.removeEventListener('abort', abort);
58
+ stream?.destroy();
59
+ await handle.close();
60
+ }
61
+ }
@@ -1,6 +1,7 @@
1
1
  import { bulkDnsSummary } from "../bulk-output.mjs";
2
2
  import { SAVED_LOOKUP_SCHEMA_VERSION, buildCliLookupDocument, } from "../saved-lookup.mjs";
3
3
  import { CLI_LOOKUP_SCHEMA } from "../../packages/contracts/cli-lookup.mjs";
4
+ import { terminalSafeJson } from "../../packages/interchange/json-output.mjs";
4
5
  const CLI_BULK_SCHEMA = 'whoisleuth.cli.bulk';
5
6
  const CLI_BULK_ITEM_SCHEMA = 'whoisleuth.cli.bulk.item';
6
7
  const CLI_CT_SEARCH_SCHEMA = 'whoisleuth.cli.ct-search';
@@ -18,19 +19,6 @@ const CLI_POSTURE_SCHEMA_VERSION = 1;
18
19
  const CLI_HTTP_SCHEMA_VERSION = 3;
19
20
  const CLI_TLS_SCHEMA_VERSION = 1;
20
21
  const CLI_COMPARE_SCHEMA_VERSION = 3;
21
- const TERMINAL_UNSAFE_JSON_RE = /[\u007f-\u009f\u2028\u2029]|\p{Default_Ignorable_Code_Point}/gu;
22
- function jsonUnicodeEscape(value) {
23
- const codePoint = value.codePointAt(0);
24
- if (codePoint <= 0xffff)
25
- return `\\u${codePoint.toString(16).padStart(4, '0')}`;
26
- const offset = codePoint - 0x10000;
27
- const high = 0xd800 + (offset >> 10);
28
- const low = 0xdc00 + (offset & 0x3ff);
29
- return `\\u${high.toString(16).padStart(4, '0')}\\u${low.toString(16).padStart(4, '0')}`;
30
- }
31
- function terminalSafeJson(value, space) {
32
- return JSON.stringify(value, null, space).replace(TERMINAL_UNSAFE_JSON_RE, jsonUnicodeEscape);
33
- }
34
22
  function formatJsonDocument(document) {
35
23
  return `${terminalSafeJson(document, 2)}\n`;
36
24
  }
@@ -1,7 +1,6 @@
1
1
  import { buildManagedIndicatorRevision, managedIndicatorState, parseManagedIndicatorJson, readManagedIndicatorSet } from "../packages/interchange/managed-indicator-set.mjs";
2
2
  import { exportManagedIndicators } from "../packages/interchange/managed-indicator-export.mjs";
3
3
  import { MAX_MANAGED_INDICATOR_PLAN_BYTES, MAX_MANAGED_INDICATOR_SET_BYTES } from "../packages/contracts/analyst-interchange.mjs";
4
- import { formatJsonDocument } from "./formatters/json.mjs";
5
4
  import { safeTerminalValue } from "./formatters/terminal.mjs";
6
5
  import EXIT_CODES from "./exit-codes.mjs";
7
6
  export async function runIndicatorSetCommand(args, dependencies, context) {
@@ -18,7 +17,7 @@ export async function runIndicatorSetCommand(args, dependencies, context) {
18
17
  if (args.operation === 'stix' || args.operation === 'misp')
19
18
  context.writeStdout((await exportManagedIndicators(manifest, args.operation)).content);
20
19
  else if (args.output === 'json')
21
- context.writeStdout(formatJsonDocument(manifest));
20
+ context.writeStdout((await exportManagedIndicators(manifest, 'manifest')).content);
22
21
  else {
23
22
  const lines = [`${safeTerminalValue(manifest.name)} · revision ${manifest.revision}`, `Set ${manifest.id}`, `Content digest ${manifest.integrity.digestSha256}`,
24
23
  'Content verified; authorship and earlier revisions are not authenticated.'];