@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
@@ -2,6 +2,8 @@ import { readBoundedRegularFile } from "../lib/bounded-file.mjs";
2
2
  import { MAX_MESSAGE_INTAKE_BYTES, MESSAGE_INTAKE_INPUTS } from "../packages/contracts/message-intake.mjs";
3
3
  import { reviewSelectedInputInWorker } from "./selected-input-worker.mjs";
4
4
  import { reviewIdentityIncident } from "../packages/investigation/identity-incident-review.mjs";
5
+ import { intakeIndicators, intakeIndicatorSource } from "../packages/investigation/intake-indicators.mjs";
6
+ import { MAX_INTAKE_CONTEXT_BYTES, applyIntakeContextInput } from "../packages/investigation/intake-context.mjs";
5
7
  import { selectReceiverTrust, authenticationHeaderLabel } from "../packages/investigation/mail-authentication-review.mjs";
6
8
  import { CliUsageError } from "./errors.mjs";
7
9
  import { formatJsonDocument } from "./formatters/json.mjs";
@@ -33,6 +35,28 @@ export function formatMessageIntake(report) {
33
35
  lines.push(` Ambiguous parameters: ${link.authorisation.duplicateParameters.join(', ')}`);
34
36
  }
35
37
  }
38
+ for (const indicator of intakeIndicators(report))
39
+ lines.push(`${indicator.id}: ${indicator.kind.toUpperCase()} ${indicator.value} · literal selected text · ${indicator.location.partId}${indicator.location.page ? ` page ${indicator.location.page}` : ''} · source ${intakeIndicatorSource(report, indicator)}`);
40
+ if (report.schemaVersion === 2) {
41
+ lines.push(`Indicator coverage: ${report.indicatorCoverage.state.replaceAll('_', ' ')}. Hashes require explicit algorithm labels; no indicator was resolved or reputation-checked.`);
42
+ const supplied = report.distributionContext;
43
+ if (supplied)
44
+ lines.push(`Analyst-declared distribution: ${supplied.channel} · ${supplied.sourceLabel} · observed ${supplied.observedAt ?? 'unknown'}`, ` Reference ${supplied.reference ?? 'not supplied'} · observer ${supplied.observerLabel ?? 'unknown'} · vantage ${supplied.vantageLabel ?? 'unknown'}`, 'Distribution, observation time and vantage are supplied claims, not verified capture conditions or independent collection.');
45
+ const selected = report.selectedEvidence;
46
+ if (selected) {
47
+ lines.push(`Deliberately selected phone groups: ${selected.phones.length}. Original spans use zero-based, end-exclusive UTF-16 code units.`);
48
+ lines.push(`Phone extraction coverage: ${selected.phoneCoverage.state.replaceAll('_', ' ')}; ${selected.phoneCoverage.candidatesShown} transient candidates shown, ${selected.phoneCoverage.candidatesReviewed} candidate runs reviewed.`);
49
+ for (const phone of selected.phones) {
50
+ lines.push(` ${phone.canonical ?? 'No canonical number'} · ${phone.state.replaceAll('_', ' ')} · declared ${phone.declaration.role.replaceAll('_', ' ')} · ${phone.declaration.basis.replaceAll('_', ' ')} · country context ${phone.declaration.countryCallingCode ?? 'unknown'}`, ` Source ${phone.declaration.sourceLabel}; declared observation ${phone.declaration.observedAt ?? 'unknown'}; input ${selected.sourceDigestSha256}`);
51
+ for (const occurrence of phone.occurrences)
52
+ lines.push(` ${JSON.stringify(occurrence.original)} · [${occurrence.start}, ${occurrence.end})${phone.extension ? ` · separate extension ${phone.extension}` : ''}`);
53
+ }
54
+ const pair = selected.destinationPair;
55
+ if (pair)
56
+ lines.push(`Supplied destination pair: ${pair.state.replaceAll('_', ' ')} · displayed ${pair.displayed.hostname ?? pair.displayed.state} · destination ${pair.destination.hostname ?? pair.destination.state}`, ` Displayed source ${pair.displayedDeclaration.sourceLabel}; declared ${pair.displayedDeclaration.observedAt ?? 'unknown'}; ${pair.displayedDeclaration.basis}`, ` Destination role ${pair.destinationDeclaration.role}; source ${pair.destinationDeclaration.sourceLabel}; declared ${pair.destinationDeclaration.observedAt ?? 'unknown'}; ${pair.destinationDeclaration.basis}`);
57
+ lines.push(...selected.limitations);
58
+ }
59
+ }
36
60
  if (report.actionHints.length)
37
61
  lines.push(`Review wording: ${report.actionHints.map(value => value.replaceAll('_', ' ')).join(', ')}`);
38
62
  if (report.documentReview) {
@@ -70,7 +94,7 @@ export function formatMessageIntake(report) {
70
94
  lines.push(`Partial analysis: ${report.coverage.boundsReached.join('; ')}`);
71
95
  if (report.source.kind === 'qr' && !report.coverage.reviewedParts)
72
96
  lines.push('No QR text was decoded. This does not establish that the image contains no QR symbol.');
73
- lines.push('Exact paths, queries, fragments and original message content are not included. Link relationships and authorisation parameters are supplied claims, not proof of a completed redirect or account compromise.');
97
+ lines.push('Exact paths, queries, fragments and surrounding message content are not included. Only deliberately selected phone substrings can be retained. Link relationships and authorisation parameters are supplied claims, not proof of a completed redirect or account compromise.');
74
98
  return `${lines.join('\n')}\n`;
75
99
  }
76
100
  export async function runIntakeCommand(args, dependencies, context) {
@@ -95,11 +119,27 @@ export async function runIntakeCommand(args, dependencies, context) {
95
119
  throw new CliUsageError(cause.message);
96
120
  throw cause;
97
121
  }
98
- const report = { ...result.report, authenticationReview, identityRecovery: reviewIdentityIncident({ reportedActions: args.reportedActions }) };
122
+ let report = { ...result.report, authenticationReview, identityRecovery: reviewIdentityIncident({ reportedActions: args.reportedActions }) };
123
+ if (args.intakeContextSource) {
124
+ if (args.intakeContextSource === '-')
125
+ throw new CliUsageError('Distribution context requires a separate selected file, not stdin.');
126
+ report = applyIntakeContextInput({ ...result, report }, await context.readInput(args.intakeContextSource, MAX_INTAKE_CONTEXT_BYTES, 'Intake context'));
127
+ }
99
128
  dependencies.signal?.throwIfAborted();
129
+ if (!args.quiet && result.phoneReview && (result.phoneReview.candidates.length || result.phoneReview.state === 'partial')) {
130
+ const phones = result.phoneReview;
131
+ context.writeStderr([
132
+ `Transient phone candidates: ${phones.candidates.length}; coverage ${phones.state}. Values are omitted until explicitly selected.`,
133
+ `Full source digest: ${phones.sourceDigestSha256}`,
134
+ ...phones.candidates.map(candidate => `${candidate.id}: UTF-16 span [${candidate.start}, ${candidate.end}) · ${candidate.state.replaceAll('_', ' ')}`),
135
+ 'Review these spans in the original plaintext. Use --intake-context with version 2, review.sourceDigestSha256, and review.phones entries containing start, end and an explicit declaration. Offsets are zero-based UTF-16 code units, end exclusive; retain BOMs and supplementary characters when counting.',
136
+ 'Only selected values enter the private report. Public/trusted Case summaries and STIX/MISP mappings omit phone values.',
137
+ '',
138
+ ].join('\n'));
139
+ }
100
140
  if (!args.quiet)
101
141
  context.writeStdout(args.output === 'json' ? formatJsonDocument(report) : context.terminal(formatMessageIntake(report), args.color));
102
- return args.strictExit && report.coverage.state === 'partial' ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
142
+ return args.strictExit && (report.coverage.state === 'partial' || result.phoneReview?.state === 'partial') ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
103
143
  }
104
144
  finally {
105
145
  bytes.fill(0);
@@ -10,7 +10,7 @@ import { validateLookupEvidenceArtifactStructure } from "./artifact-validation/l
10
10
  import { CLI_DISCOVERY_SCAN_SCHEMA, CLI_DISCOVERY_SCAN_VERSION } from "./discovery-scan.mjs";
11
11
  import { CLI_DISCOVER_SCHEMA, CLI_DISCOVER_SCHEMA_VERSION, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, CLI_CT_SEARCH_SCHEMA, CLI_CT_SEARCH_SCHEMA_VERSION, CLI_COMPARE_SCHEMA, CLI_COMPARE_SCHEMA_VERSION, } from "./formatters/json.mjs";
12
12
  import { CLI_LOOKUP_BRIEF_SCHEMA, CLI_LOOKUP_BRIEF_VERSION } from "../packages/contracts/cli-lookup-brief.mjs";
13
- import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, parseExternalFindingsDocument } from "../packages/interchange/external-findings-import.mjs";
13
+ import { EXTERNAL_FINDINGS_SCHEMA, parseExternalFindingsDocument } from "../packages/interchange/external-findings-import.mjs";
14
14
  import { SOURCE_RELIABILITY_REPORT_SCHEMA, SOURCE_RELIABILITY_REPORT_VERSION } from "./source-reliability.mjs";
15
15
  import { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, verifyCliCasePack } from "./case-pack.mjs";
16
16
  import { SHARING_REVIEW_SCHEMA, SHARING_REVIEW_VERSION } from "./sharing-review.mjs";
@@ -59,8 +59,7 @@ export function validateWorkflowResult(step, result, retained) {
59
59
  break;
60
60
  case 'ct-intake':
61
61
  schema = EXTERNAL_FINDINGS_SCHEMA;
62
- version = EXTERNAL_FINDINGS_VERSION;
63
- parseExternalFindingsDocument(value);
62
+ version = parseExternalFindingsDocument(value).schemaVersion;
64
63
  break;
65
64
  case 'brief':
66
65
  schema = CLI_LOOKUP_BRIEF_SCHEMA;
@@ -74,6 +74,7 @@ export async function verifyOfflineInvestigationPackage(input, passphrase) {
74
74
  }
75
75
  entries.push({ id: reviewed.entry.id, byteLength: reviewed.entry.byteLength,
76
76
  mediaType: 'mediaType' in reviewed.entry ? reviewed.entry.mediaType : 'application/json',
77
+ imageDerivation: 'imageDerivation' in reviewed.entry ? reviewed.entry.imageDerivation : null,
77
78
  state, identity: reviewed.state === 'identity_verified' ? 'verified' : 'failed', verification, issue });
78
79
  }
79
80
  const complete = entries.every((entry) => entry.state === 'admitted' || entry.state === 'opaque')
@@ -97,6 +98,7 @@ export async function verifyOfflineInvestigationPackage(input, passphrase) {
97
98
  'Capture attachment checks bind selected bytes to the capture manifest declaration, not to a trusted publisher or real website observation.',
98
99
  'Case-file checks match original references to other verified package bytes by length and digest, not by filename or claimed source. Missing originals make the package partial; no file is fetched or imported.',
99
100
  'Source declarations and packaging times are not authenticated. Signatures and timestamp tokens require their own verification and trust decisions.',
101
+ 'Image derivation declares the immediate parent fingerprint and editing operations, not proof of transformation or complete redaction. Parent bytes are optional; missing declarations leave editing history unknown.',
100
102
  'This command does not import, overwrite or collect evidence. Unchanged bytes and valid source structure do not establish factual accuracy, currentness or safe sharing.',
101
103
  ]),
102
104
  });
@@ -31,26 +31,33 @@ function names(value) {
31
31
  const source = record(value);
32
32
  return boundedText(source.en, 120);
33
33
  }
34
- async function readStableMmdb(databasePath, openFile = open) {
34
+ async function readStableMmdb(databasePath, openFile = open, signal) {
35
35
  let handle = null;
36
+ let database = null;
36
37
  try {
38
+ signal?.throwIfAborted();
37
39
  const resolvedPath = await realpath(databasePath);
40
+ signal?.throwIfAborted();
38
41
  handle = await openFile(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
42
+ signal?.throwIfAborted();
39
43
  const before = await handle.stat();
44
+ signal?.throwIfAborted();
40
45
  if (!before.isFile() || before.size <= 0 || before.size > MAX_LOCAL_MMDB_BYTES) {
41
46
  throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
42
47
  }
43
48
  // Dedicated backing storage can be transferred without copying a pooled
44
49
  // buffer or exposing bytes outside the stable file's exact length.
45
- const database = Buffer.allocUnsafeSlow(before.size);
50
+ database = Buffer.allocUnsafeSlow(before.size);
46
51
  let offset = 0;
47
52
  while (offset < database.length) {
48
53
  const { bytesRead } = await handle.read(database, offset, database.length - offset, offset);
54
+ signal?.throwIfAborted();
49
55
  if (bytesRead === 0)
50
56
  break;
51
57
  offset += bytesRead;
52
58
  }
53
59
  const after = await handle.stat();
60
+ signal?.throwIfAborted();
54
61
  if (offset !== database.length
55
62
  || before.dev !== after.dev
56
63
  || before.ino !== after.ino
@@ -62,6 +69,8 @@ async function readStableMmdb(databasePath, openFile = open) {
62
69
  return database;
63
70
  }
64
71
  catch (cause) {
72
+ database?.fill(0);
73
+ signal?.throwIfAborted();
65
74
  if (cause instanceof CliUsageError)
66
75
  throw cause;
67
76
  throw new CliUsageError('The supplied MMDB file could not be read.');
@@ -238,11 +247,17 @@ if (!isMainThread && parentPort && record(workerData).kind === LOCAL_MMDB_WORKER
238
247
  /** One disposable worker pre-empts the existing synchronous reader. It is not
239
248
  * a sandbox and never receives a database path or inherited environment. */
240
249
  export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date().toISOString(), dependencies = {}) {
250
+ const { signal } = dependencies;
251
+ signal?.throwIfAborted();
241
252
  const query = prepareQuery(input, checkedAt);
242
253
  const deadlineMs = dependencies.deadlineMs ?? LOCAL_MMDB_REVIEW_DEADLINE_MS;
243
254
  if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0 || deadlineMs > LOCAL_MMDB_REVIEW_DEADLINE_MS)
244
255
  throw new CliUsageError('Invalid local database processing deadline.');
245
- const database = await readStableMmdb(databasePath, dependencies.openFile);
256
+ const database = await readStableMmdb(databasePath, dependencies.openFile, signal);
257
+ if (signal?.aborted) {
258
+ database.fill(0);
259
+ signal.throwIfAborted();
260
+ }
246
261
  const identity = { sha256: createHash('sha256').update(database).digest('hex'), byteLength: database.length };
247
262
  const options = { workerData: { kind: LOCAL_MMDB_WORKER_KIND, query, bytes: database, identity },
248
263
  transferList: [database.buffer], env: {}, execArgv: [], stdout: true, stderr: true,
@@ -254,6 +269,7 @@ export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date(
254
269
  catch {
255
270
  if (database.byteLength)
256
271
  database.fill(0);
272
+ signal?.throwIfAborted();
257
273
  if (query.version === 2)
258
274
  return freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, 'processing_unavailable'));
259
275
  throw new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.');
@@ -268,17 +284,24 @@ export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date(
268
284
  return;
269
285
  settled = true;
270
286
  clearTimeout(timer);
271
- if (result)
287
+ signal?.removeEventListener('abort', onAbort);
288
+ if (signal?.aborted)
289
+ reject(signal.reason);
290
+ else if (result)
272
291
  resolve(freezeResult(result));
273
292
  else if (query.version === 2)
274
293
  resolve(freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, reason)));
275
294
  else
276
295
  reject(new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.'));
277
296
  };
297
+ const onAbort = () => finish();
278
298
  const timer = setTimeout(() => finish(undefined, 'processing_deadline'), deadlineMs);
279
299
  worker.once('message', (reply) => reply && 'result' in reply ? finish(reply.result) : finish());
280
300
  worker.once('error', () => finish());
281
301
  worker.once('exit', () => finish());
302
+ signal?.addEventListener('abort', onAbort, { once: true });
303
+ if (signal?.aborted)
304
+ onAbort();
282
305
  });
283
306
  }
284
307
  finally {
@@ -110,6 +110,7 @@ async function runLookupCommand(args, dependencies, context) {
110
110
  const save = dependencies.writePrivateFile || writePrivateFile;
111
111
  await save(args.saveLookup, serializeCliLookupDocument(document), {
112
112
  existingFileMessage: 'Saved Lookup file already exists. Choose a new --save-lookup path or remove the existing file explicitly.',
113
+ beforePublish: async () => { dependencies.signal?.throwIfAborted(); },
113
114
  });
114
115
  context.writeStderr('Saved the completed private Lookup JSON. It can contain raw public registry and WHOIS responses plus target and collection context; review it before sharing.\n');
115
116
  }
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
3
3
  import { CONTEXT_INPUT_SCHEMAS, reviewContextInput } from "../packages/investigation/context-review.mjs";
4
- import { CONTEXT_REVIEW_KINDS } from "../packages/contracts/context-review.mjs";
4
+ import { CONTEXT_REVIEW_KINDS, DOMAIN_HISTORY_INPUT_SCHEMA, DOMAIN_HISTORY_INPUT_VERSION } from "../packages/contracts/context-review.mjs";
5
5
  import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
6
6
  import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
7
7
  import { reviewDomainChange } from "../lib/domain-change-review.mjs";
@@ -18,6 +18,10 @@ import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
18
18
  import { CliUsageError } from "./errors.mjs";
19
19
  import { safeTerminalValue } from "./formatters/terminal.mjs";
20
20
  import { LOCAL_MMDB_QUERY_SCHEMA, LOCAL_MMDB_QUERY_VERSION, LOCAL_MMDB_REVIEW_SCHEMA, LOCAL_MMDB_REVIEW_VERSION, reviewLocalMmdb } from "./local-mmdb-review.mjs";
21
+ import { INFRASTRUCTURE_OBSERVATION_SCHEMA, MAX_INFRASTRUCTURE_OBSERVATION_BYTES, infrastructureObservationFacts, compareInfrastructureObservations } from "../packages/investigation/infrastructure-observation.mjs";
22
+ import { exact } from "../packages/evidence/artifact-structure.mjs";
23
+ import { INFRASTRUCTURE_COMPARISON_INPUT_SCHEMA, MAX_INFRASTRUCTURE_COMPARISON_INPUT_BYTES } from "../packages/contracts/external-observation-interchange.mjs";
24
+ import { CASE_CONTAINMENT_INPUT_SCHEMA, readCaseContainmentInput, formatCaseContainmentHandoff } from "../packages/cases/case-containment-handoff.mjs";
21
25
  const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
22
26
  const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
23
27
  const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
@@ -46,7 +50,8 @@ function parseInput(value) {
46
50
  }
47
51
  const document = record(parsed);
48
52
  if (typeof document.schema !== 'string' || document.version !== 1
49
- && !(document.schema === LOCAL_MMDB_QUERY_SCHEMA && document.version === LOCAL_MMDB_QUERY_VERSION)) {
53
+ && !(document.schema === LOCAL_MMDB_QUERY_SCHEMA && document.version === LOCAL_MMDB_QUERY_VERSION)
54
+ && !(document.schema === DOMAIN_HISTORY_INPUT_SCHEMA && document.version === DOMAIN_HISTORY_INPUT_VERSION)) {
50
55
  throw new CliUsageError('Offline evidence review requires a supported versioned input schema.');
51
56
  }
52
57
  return document;
@@ -57,7 +62,27 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
57
62
  throw new CliUsageError('Local MMDB review requires --mmdb <database-file>.');
58
63
  let kind;
59
64
  let result;
60
- if (CONTEXT_INPUT_SCHEMAS.includes(input.schema)) {
65
+ if (input.schema === CASE_CONTAINMENT_INPUT_SCHEMA) {
66
+ kind = 'internal_containment';
67
+ result = readCaseContainmentInput(input, generatedAt);
68
+ }
69
+ else if (input.schema === INFRASTRUCTURE_OBSERVATION_SCHEMA) {
70
+ if (typeof value === 'string' && new TextEncoder().encode(value).byteLength > MAX_INFRASTRUCTURE_OBSERVATION_BYTES)
71
+ throw new TypeError('Infrastructure snapshot input exceeds its byte bound.');
72
+ kind = 'infrastructure';
73
+ const retained = infrastructureObservationFacts(input);
74
+ result = { ...retained, state: retained.snapshot.coverage.state === 'complete' && !retained.snapshot.coverage.truncated ? 'complete' : 'partial' };
75
+ }
76
+ else if (input.schema === INFRASTRUCTURE_COMPARISON_INPUT_SCHEMA) {
77
+ if (typeof value === 'string' && new TextEncoder().encode(value).byteLength > MAX_INFRASTRUCTURE_COMPARISON_INPUT_BYTES)
78
+ throw new TypeError('Infrastructure comparison input exceeds its byte bound.');
79
+ const pair = exact(input, ['schema', 'version', 'earlier', 'later'], 'Infrastructure comparison input');
80
+ if (new TextEncoder().encode(JSON.stringify(pair)).byteLength > MAX_INFRASTRUCTURE_COMPARISON_INPUT_BYTES)
81
+ throw new TypeError('Infrastructure comparison input exceeds its byte bound.');
82
+ kind = 'infrastructure_comparison';
83
+ result = compareInfrastructureObservations(pair.earlier, pair.later);
84
+ }
85
+ else if (CONTEXT_INPUT_SCHEMAS.includes(input.schema)) {
61
86
  const review = reviewContextInput(input, generatedAt);
62
87
  kind = review.kind;
63
88
  result = review;
@@ -173,13 +198,15 @@ async function buildOfflineEvidenceReviewWithLocalResources(value, generatedAt =
173
198
  version: OFFLINE_EVIDENCE_REVIEW_VERSION,
174
199
  generatedAt,
175
200
  kind: 'geoip',
176
- result: await reviewLocalMmdb(input, options.mmdbPath, generatedAt),
201
+ result: await reviewLocalMmdb(input, options.mmdbPath, generatedAt, options.signal ? { signal: options.signal } : {}),
177
202
  limitations: Object.freeze([
178
203
  'The review is local and uses only the supplied query metadata and analyst-supplied database. It does not transmit the address or refresh the database.',
179
204
  ]),
180
205
  });
181
206
  }
182
207
  function formatOfflineEvidenceReview(document) {
208
+ if (document.kind === 'internal_containment')
209
+ return formatCaseContainmentHandoff(document.result);
183
210
  const result = record(document.result);
184
211
  if (document.kind === 'geoip' && result.schema === LOCAL_MMDB_REVIEW_SCHEMA && result.version !== LOCAL_MMDB_REVIEW_VERSION) {
185
212
  throw new CliUsageError('Offline evidence review does not support this MMDB result version.');
@@ -204,7 +231,26 @@ function formatOfflineEvidenceReview(document) {
204
231
  `Kind ${document.kind.replaceAll('_', ' ')}`,
205
232
  `State ${state.replaceAll('_', ' ')}`,
206
233
  ];
207
- if (CONTEXT_REVIEW_KINDS.includes(document.kind)) {
234
+ if (document.kind === 'infrastructure') {
235
+ const snapshot = record(result.snapshot), scope = record(snapshot.scope), coverage = record(snapshot.coverage);
236
+ lines.push(`Snapshot ${safeTerminalValue(snapshot.id, '')} · ${safeTerminalValue(snapshot.target, '')} · ${safeTerminalValue(snapshot.observedAt, '')}`, `Coverage ${safeTerminalValue(coverage.state, 'unknown')} · ${safeTerminalValue(coverage.detail, '')}`, `Selected hosts ${listLength(scope.hostnames)}`);
237
+ for (const item of Array.isArray(result.facts) ? result.facts : []) {
238
+ const row = record(item), source = record(row.source);
239
+ lines.push(`${safeTerminalValue(row.hostname, '')} · ${safeTerminalValue(row.family, '')}: ${safeTerminalValue(row.value, '')} · ${safeTerminalValue(source.name, '')} (${safeTerminalValue(source.evidenceClass, '')}) · ${safeTerminalValue(row.observedAt, '')}`);
240
+ }
241
+ lines.push('Wildcard patterns are not enumerated hosts. DNS failure, missing values and history do not establish current absence. No requests or watchlist changes were made.');
242
+ }
243
+ else if (document.kind === 'infrastructure_comparison') {
244
+ for (const item of Array.isArray(result.rows) ? result.rows : []) {
245
+ const row = record(item), source = record(row.source);
246
+ // Bound individual identities, not the combined label: a valid DNS query
247
+ // and owner may each occupy all 253 hostname characters.
248
+ const cohort = Object.entries(record(row.cohort)).map(([key, value]) => `${safeTerminalValue(key)}=${safeTerminalValue(value, '', 253)}`).join(' · ');
249
+ const times = (value) => Array.isArray(value) ? value.map(time => safeTerminalValue(time, '')).join(', ') : '';
250
+ lines.push(`${safeTerminalValue(row.hostname, '')} · ${safeTerminalValue(row.family, '')} · ${safeTerminalValue(source.name, '')} (${safeTerminalValue(source.evidenceClass, '')}) · ${safeTerminalValue(row.state, '')}`, ` Cohort: ${cohort}`, ` Earlier observations: ${times(row.beforeTimes)}; later observations: ${times(row.afterTimes)}`, ` ${safeTerminalValue(JSON.stringify(row.before), '')} → ${safeTerminalValue(JSON.stringify(row.after), '')}`, ` ${safeTerminalValue(row.detail, '')}`);
251
+ }
252
+ }
253
+ else if (CONTEXT_REVIEW_KINDS.includes(document.kind)) {
208
254
  lines.push(safeTerminalValue(result.summary, ''));
209
255
  for (const item of Array.isArray(result.observations) ? result.observations : []) {
210
256
  const row = record(item);
@@ -45,10 +45,10 @@ function parseCaseArguments(parsed) {
45
45
  if (operation === 'note' ? text === null && !noteSource : text !== null || noteSource !== null) {
46
46
  throw new CliUsageError('Only case note accepts and requires --text or --note-file.');
47
47
  }
48
- if (['pin', 'link', 'withdraw-link', 'assess', 'recheck'].includes(operation)
48
+ if (['pin', 'link', 'withdraw-link', 'assess', 'recheck', 'incident-link', 'action', 'action-event', 'recheck-question', 'close-object'].includes(operation)
49
49
  ? !inputSource
50
50
  : inputSource !== null) {
51
- throw new CliUsageError('Only case pin, link, withdraw-link, assess and recheck accept and require --input.');
51
+ throw new CliUsageError('This Case mutation requires --input; show, open and note do not accept it.');
52
52
  }
53
53
  if (expectedFileDigest !== null && (!source || !/^sha256:[a-f0-9]{64}$/u.test(expectedFileDigest))) {
54
54
  throw new CliUsageError('--expect-file-digest requires a source file and sha256:<64 lowercase hexadecimal characters>.');
@@ -95,6 +95,7 @@ export const REVIEW_ARGUMENT_PARSERS = Object.freeze({
95
95
  action: 'intake',
96
96
  kind: parsed.positionalValue('kind'),
97
97
  reportedActions: parsed.optionValues('--reported-action'),
98
+ ...(parsed.hasOption('--intake-context') ? { intakeContextSource: parsed.optionValue('--intake-context') } : {}),
98
99
  ...(parsed.hasOption('--trusted-auth-header')
99
100
  ? { trustedAuthHeaders: parsed.optionValues('--trusted-auth-header') }
100
101
  : {}),
@@ -2,6 +2,8 @@ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_
2
2
  import { MESSAGE_INTAKE_KINDS, MESSAGE_INTAKE_SCHEMA } from "../packages/contracts/message-intake.mjs";
3
3
  import { IDENTITY_EVENTS_INPUT_SCHEMA } from "../packages/contracts/identity-events.mjs";
4
4
  import { CONTEXT_INPUT_SCHEMAS, CONTEXT_REVIEW_SCHEMA } from "../packages/contracts/context-review.mjs";
5
+ import { CASE_CONTAINMENT_INPUT_SCHEMA, CASE_CONTAINMENT_HANDOFF_SCHEMA } from "../packages/cases/case-containment-handoff.mjs";
6
+ import { INTAKE_CONTEXT_SCHEMA } from "../packages/investigation/intake-context.mjs";
5
7
  import { MANAGED_INDICATOR_SET_SCHEMA, MAX_MANAGED_INDICATOR_SET_BYTES, MAX_MANAGED_INDICATOR_PLAN_BYTES } from "../packages/contracts/analyst-interchange.mjs";
6
8
  import { CLI_CASE_OPERATIONS, CLI_INDICATOR_OPERATIONS, positional, OPTIONAL_FILE_POSITIONAL, constraint, EMPTY_CONSTRAINTS, commandSeed } from "./command-definition.mjs";
7
9
  export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
@@ -154,23 +156,23 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
154
156
  }),
155
157
  intake: commandSeed({
156
158
  reference: {
157
- description: 'Review message identities, document links, QR destinations, HTTP archives or selected identity events locally.',
159
+ description: 'Review selected messages, indicators, document links, QR destinations, HTTP archives or identity events locally. Plain text offers phone candidates; --intake-context adds explicit selections and source declarations.',
158
160
  example: 'whoisleuth intake email message.eml --json',
159
- boundary: 'Offline only: no destination, embedded command or QR payload is opened or executed. Output omits original bodies, subjects, address local parts, URL paths, queries and fragments. Authentication headers remain attributed claims. Selected PDF and DOCX review retains part identities and extraction coverage; embedded raster QR support does not imply complete rendered-page review. HAR retains minimised request order and timings, never replay authority, headers, cookies or bodies. Use --strict-exit to return 4 for partial coverage.',
161
+ boundary: 'Offline only: no destination, embedded command, phone number or QR payload is opened, contacted or executed. Output omits original bodies, subjects, address local parts, URL paths, queries and fragments. Literal IPs and labelled hashes are bounded text observations, not lookup targets or verdicts; headers, URL contents, QR, HAR and identity-event fields are not scanned for them. Plain-text phone discovery writes only counts, source digest, ranges and status to stderr; --quiet suppresses it. Optional --intake-context reads one separate file up to 8 KiB, not stdin. Version 1 carries distribution declarations; version 2 can also bind explicit phone selections and a supplied destination pair to the exact input digest. Selected phone strings and source declarations enter output; unselected candidates and exact private URLs do not. Authentication headers remain attributed claims. Selected PDF and DOCX review retains part identities and extraction coverage; embedded raster QR support does not imply complete rendered-page review. HAR retains minimised request order and timings, never replay authority, headers, cookies or bodies. Use --strict-exit to return 4 for partial coverage.',
160
162
  },
161
- collection: { scope: 'Reads one selected text, MIME email, calendar, PNG, PDF, DOCX, HAR or identity-event JSON file. No collection or automatic Case write.' },
163
+ collection: { scope: 'Reads one selected text, MIME email, calendar, PNG, PDF, DOCX, HAR or identity-event JSON file and, only with --intake-context, one bounded declaration/selection file. No collection or automatic Case write.' },
162
164
  summary: 'Review selected files and identity events offline',
163
- options: ['--json', '--reported-action', '--trusted-auth-header', '--strict-exit', '--quiet', '--no-color'],
165
+ options: ['--json', '--reported-action', '--trusted-auth-header', '--intake-context', '--strict-exit', '--quiet', '--no-color'],
164
166
  positionals: Object.freeze([positional('kind', 'enum', 1, 1, MESSAGE_INTAKE_KINDS), positional('source', 'file', 0, 1, [], 'argv_or_stdin')]),
165
167
  constraints: EMPTY_CONSTRAINTS, handlerOwner: 'review', networkEffect: 'offline', common: true,
166
- schemaIdentifiers: Object.freeze([MESSAGE_INTAKE_SCHEMA, IDENTITY_EVENTS_INPUT_SCHEMA]), primaryArtefacts: Object.freeze([]),
168
+ schemaIdentifiers: Object.freeze([MESSAGE_INTAKE_SCHEMA, INTAKE_CONTEXT_SCHEMA, IDENTITY_EVENTS_INPUT_SCHEMA]), primaryArtefacts: Object.freeze([]),
167
169
  planSupport: false, additionalOutputFormats: Object.freeze([]), bootstrapProfile: 'allowed',
168
170
  }),
169
171
  "review-evidence": commandSeed({
170
172
  reference: {
171
- description: 'Review versioned protocol evidence, incident sequences, domain history, platform objects, storefronts or connector configuration offline.',
173
+ description: 'Review versioned protocol evidence, source-qualified infrastructure snapshots/comparisons, incident sequences, domain history, platform objects, storefronts, connector configuration or an explicitly selected internal containment handoff offline.',
172
174
  example: 'whoisleuth review-evidence domain-change.json --json --strict-exit',
173
- boundary: 'The command reads only the supplied document and, with --mmdb, one explicitly selected local database up to 512 MiB. MMDB parsing and lookup run in one disposable worker with a ten-second processing deadline and an 8 KiB result limit. Version-2 MMDB queries require a justified age policy; unavailable or partial current reviews return 4 under --strict-exit. Historical version-1 MMDB queries preserve their output without freshness admission. No database is bundled, downloaded, updated or transmitted. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
175
+ boundary: 'The command reads only the supplied document and, with --mmdb, one explicitly selected local database up to 512 MiB. MMDB parsing and lookup run in one disposable worker with a ten-second processing deadline and an 8 KiB result limit. Version-2 MMDB queries require a justified age policy; unavailable or partial current reviews return 4 under --strict-exit. Historical version-1 MMDB queries preserve their output without freshness admission. Domain-history v2 accepts explicit source-linked registration boundaries; v1 retains its original semantics. Internal containment selects retained next-step assertions and supporting pins after explicit audience-disclosure review; it does not change their state or execute controls. No database is bundled, downloaded, updated or transmitted. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
174
176
  },
175
177
  collection: { scope: 'Reads one bounded versioned evidence or request-planning document and, only with --mmdb, one selected bounded local database. Performs no collection.' },
176
178
  summary: 'Review supplied evidence offline',
@@ -182,6 +184,8 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
182
184
  common: true,
183
185
  schemaIdentifiers: Object.freeze([
184
186
  CONTEXT_REVIEW_SCHEMA,
187
+ CASE_CONTAINMENT_INPUT_SCHEMA,
188
+ CASE_CONTAINMENT_HANDOFF_SCHEMA,
185
189
  ...CONTEXT_INPUT_SCHEMAS,
186
190
  'whoisleuth.cli.offline-evidence-review',
187
191
  'whoisleuth.rdap-search-input',
@@ -192,6 +196,8 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
192
196
  'whoisleuth.local-mmdb-query',
193
197
  'whoisleuth.local-mmdb-review',
194
198
  'whoisleuth.encrypted-dns-plan-input',
199
+ 'whoisleuth.infrastructure-observation',
200
+ 'whoisleuth.infrastructure-comparison.input',
195
201
  ]),
196
202
  primaryArtefacts: Object.freeze([]),
197
203
  planSupport: false,
@@ -220,7 +226,7 @@ export const REVIEW_COMMAND_DEFINITIONS = Object.freeze({
220
226
  }),
221
227
  case: commandSeed({
222
228
  reference: {
223
- description: 'Show or open a local Case, append a note or evidence pin, link evidence, withdraw a link, record an assessment, or retain an offline recheck. Use --input for pin, link, withdraw-link, assessment and recheck JSON; --text or --note-file for a note. Link input contains fromPinId, toPinId, kind (derived_from or shared_source), and basis. Withdrawal input contains id and reason. Mutations require --output and always write the complete current Case export.',
229
+ description: 'Show or open a local Case, append a note or evidence pin, link evidence, record an assessment or offline recheck, retain an incident-link, create an action, append an action-event, save a recheck-question, or close-object. JSON mutations use --input; notes use --text or --note-file. Link input contains fromPinId, toPinId, kind and basis. Action-event input contains id and transition. Explicit responseObjects bind an action; a typed objectOutcome requires an explicit affected subset on its event. Object closure never closes other objects or the Case. Mutations require --output and write the complete current Case export.',
224
230
  example: 'whoisleuth case open --domain example.test --output cases.json\n whoisleuth case show cases.json\n whoisleuth case note cases.json --text "Review the retained observation" --output cases.json --force',
225
231
  boundary: 'No database, browser launch, request or external report is created. Select --case-id when a file contains multiple Cases. Existing files require --force; --expect-file-digest sha256:<digest> additionally checks the exact file reviewed earlier. Source and output leases reject concurrent changes. Interrupted .workflow.lock files require deliberate inspection. Recheck records supplied observations; it does not collect them. Not reproduced requires an existing saved question, a complete observation and comparable conditions. Working exports include private analyst content and file references, not attached file bytes.',
226
232
  },
@@ -192,8 +192,9 @@ async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
192
192
  ? await dependencies.readArtifactInput(args.source)
193
193
  : await context.readInput(args.source, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, 'Offline evidence input'), 'offline evidence input', 'review-evidence requires one JSON file or a document on stdin.');
194
194
  const document = args.mmdbSource
195
- ? await buildOfflineEvidenceReviewWithLocalResources(input, context.now(), { mmdbPath: args.mmdbSource })
195
+ ? await buildOfflineEvidenceReviewWithLocalResources(input, context.now(), { mmdbPath: args.mmdbSource, ...(dependencies.signal ? { signal: dependencies.signal } : {}) })
196
196
  : buildOfflineEvidenceReview(input, context.now());
197
+ dependencies.signal?.throwIfAborted();
197
198
  writeReviewReport(context, args, document, formatOfflineEvidenceReview);
198
199
  if (args.strictExit) {
199
200
  const result = document.result && typeof document.result === 'object' && !Array.isArray(document.result)
@@ -205,9 +206,11 @@ async function runOfflineEvidenceReviewCommand(args, dependencies, context) {
205
206
  const zoneMismatch = document.kind === 'zone_intent' && (result.complete !== true
206
207
  || (result.counts && typeof result.counts === 'object' && !Array.isArray(result.counts)
207
208
  && ['different', 'missing', 'unexpected', 'incomplete'].some((key) => Number(result.counts[key]) > 0)));
208
- const contextPartial = CONTEXT_REVIEW_KINDS.includes(document.kind) && result.state === 'partial';
209
+ const contextPartial = (CONTEXT_REVIEW_KINDS.includes(document.kind) || document.kind === 'internal_containment') && result.state === 'partial';
209
210
  const mmdbIncomplete = result.schema === LOCAL_MMDB_REVIEW_SCHEMA && result.version === LOCAL_MMDB_REVIEW_VERSION && result.completeness !== 'complete';
210
- if (gate?.pass === false || zoneMismatch || contextPartial || mmdbIncomplete)
211
+ const infrastructureIncomplete = document.kind === 'infrastructure' ? result.state !== 'complete'
212
+ : document.kind === 'infrastructure_comparison' && result.state !== 'compared';
213
+ if (gate?.pass === false || zoneMismatch || contextPartial || mmdbIncomplete || infrastructureIncomplete)
211
214
  return EXIT_CODES.PARTIAL_FAILURE;
212
215
  }
213
216
  return EXIT_CODES.SUCCESS;
@@ -5,7 +5,7 @@ import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
5
5
  import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
6
6
  import { ANALYST_REVIEW_REASON_VALUES, analystInteroperabilityTags } from "../packages/analysis/analyst-taxonomy.mjs";
7
7
  import { buildRiskCalibrationSummaryReport, parseRiskCalibrationSummaryReport, RISK_CALIBRATION_SUMMARY_INTERPRETATION, RISK_CALIBRATION_SUMMARY_THRESHOLDS, } from "../packages/analysis/risk-calibration-summary.mjs";
8
- import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_DATASET_VERSION, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, RISK_CALIBRATION_DISPOSITIONS, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
8
+ import { MAX_RISK_CALIBRATION_ACTIVITY_LENGTH, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH, MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, MAX_RISK_CALIBRATION_DOMAIN_AGE_DAYS, MAX_RISK_CALIBRATION_DOMAIN_LENGTH, MAX_RISK_CALIBRATION_FINDING_CATEGORY_LENGTH, MAX_RISK_CALIBRATION_FINDINGS_PER_PROVIDER, MAX_RISK_CALIBRATION_INPUT_BYTES, MAX_RISK_CALIBRATION_JSON_CONTAINER_ITEMS, MAX_RISK_CALIBRATION_JSON_DEPTH, MAX_RISK_CALIBRATION_JSON_KEYS, MAX_RISK_CALIBRATION_JSON_VALUES, MAX_RISK_CALIBRATION_MUTATIONS, MAX_RISK_CALIBRATION_PROVIDER_ID_LENGTH, MAX_RISK_CALIBRATION_PROVIDER_STATE_LENGTH, MAX_RISK_CALIBRATION_PROVIDERS, MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, MAX_RISK_CALIBRATION_RECORDS, MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, MAX_RISK_CALIBRATION_SCAN_DEPTH_LENGTH, MAX_RISK_CALIBRATION_STRING_LENGTH, MAX_RISK_CALIBRATION_TIMESTAMP_LENGTH, RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA, RISK_CALIBRATION_REPORT_VERSION, RISK_CALIBRATION_DISPOSITIONS, serializeRiskCalibrationSnapshot, snapshotRiskCalibrationReportForSerialization, SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS, } from "../packages/contracts/risk-calibration.mjs";
9
9
  import { CliUsageError } from "./errors.mjs";
10
10
  import { RISK_MUTATION_TYPES } from "../packages/analysis/risk-scoring.mjs";
11
11
  import { THREAT_INTELLIGENCE_CONTRACT_VERSION, THREAT_INTELLIGENCE_ENVELOPE_VERSION, THREAT_INTELLIGENCE_SCHEMA, } from "../packages/analysis/threat-intelligence-types.mjs";
@@ -17,7 +17,7 @@ const AVAILABILITY_STATES = new Set(['registered', 'for_sale', 'expiring', 'avai
17
17
  const ACTIVITY_STATES = new Set(['active', 'parked', 'unreachable', 'no_site']);
18
18
  const BOOLEAN_FIELDS = [
19
19
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField',
20
- 'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected', 'hasExternalFormAction',
20
+ 'hasMx', 'hasSpf', 'hasDmarc', 'privacyProtected', 'hasExternalFormAction', 'hasExternalPasswordForm',
21
21
  'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
22
22
  ];
23
23
  const REVIEW_REASON_CODES = ANALYST_REVIEW_REASON_VALUES;
@@ -95,8 +95,10 @@ function projectThreatIntelligence(value, field) {
95
95
  }),
96
96
  };
97
97
  }
98
- function projectEvidence(value, field) {
98
+ function projectEvidence(value, field, version) {
99
99
  const source = object(value, field);
100
+ if (version < 3 && Object.hasOwn(source, 'hasExternalPasswordForm'))
101
+ throw new CliUsageError(`${field}.hasExternalPasswordForm requires dataset version 3; historical inputs were not reinterpreted.`);
100
102
  const availability = boundedString(source.availability ?? source.state, `${field}.availability`, MAX_RISK_CALIBRATION_AVAILABILITY_LENGTH);
101
103
  if (!AVAILABILITY_STATES.has(availability))
102
104
  throw new CliUsageError(`${field}.availability is unsupported.`);
@@ -234,7 +236,7 @@ export function parseRiskCalibrationDataset(text) {
234
236
  domain,
235
237
  analystDisposition,
236
238
  ...(reviewReasonCode ? { reviewReasonCode } : {}),
237
- evidence: projectEvidence(record.evidence, `${prefix}.evidence`),
239
+ evidence: projectEvidence(record.evidence, `${prefix}.evidence`, documentVersion),
238
240
  };
239
241
  });
240
242
  return {
@@ -3,6 +3,8 @@ import { reviewSelectedInput } from "../packages/investigation/selected-input-re
3
3
  import { assertMessageBytes } from "../packages/investigation/intake-report.mjs";
4
4
  import { DOCUMENT_REVIEW_DEADLINE_MS } from "../packages/contracts/document-intake.mjs";
5
5
  import { MESSAGE_INTAKE_SCHEMA, MESSAGE_INTAKE_VERSION } from "../packages/contracts/message-intake.mjs";
6
+ import { validateIntakeExtensions } from "../packages/investigation/intake-context.mjs";
7
+ import { validatePhoneReview } from "../packages/investigation/intake-phones.mjs";
6
8
  if (!isMainThread && parentPort) {
7
9
  const port = parentPort, request = workerData;
8
10
  try {
@@ -45,8 +47,16 @@ export async function reviewSelectedInputInWorker(bytes, kind, reviewedAt, signa
45
47
  const abort = () => finish(undefined, new Error('Selected input review was cancelled. Nothing was saved.'));
46
48
  const timer = setTimeout(() => finish(undefined, new Error('Selected input review exceeded its processing deadline. Nothing was saved.')), DOCUMENT_REVIEW_DEADLINE_MS);
47
49
  worker.once('message', (reply) => {
48
- if (reply && typeof reply === 'object' && 'result' in reply && reply.result?.report.schema === MESSAGE_INTAKE_SCHEMA && reply.result.report.schemaVersion === MESSAGE_INTAKE_VERSION)
49
- finish(reply.result);
50
+ if (reply && typeof reply === 'object' && 'result' in reply && reply.result?.report.schema === MESSAGE_INTAKE_SCHEMA && reply.result.report.schemaVersion === MESSAGE_INTAKE_VERSION) {
51
+ try {
52
+ validateIntakeExtensions(reply.result.report);
53
+ validatePhoneReview(reply.result);
54
+ finish(reply.result);
55
+ }
56
+ catch {
57
+ finish(undefined, new Error('Selected input returned an unsupported review. Nothing was retained.'));
58
+ }
59
+ }
50
60
  else
51
61
  finish();
52
62
  });
@@ -1,6 +1,6 @@
1
1
  import { REGISTRY_CAPABILITIES_VERSION, registryCapabilityFor } from "../lib/registry-capabilities.mjs";
2
2
  import { buildRiskCalibrationSummaryReport } from "../packages/analysis/risk-calibration-summary.mjs";
3
- import { explainRiskScore, explainRiskScoreV7, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../packages/analysis/risk-scoring.mjs";
3
+ import { explainRiskScore, explainRiskScoreV8, RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD } from "../packages/analysis/risk-scoring.mjs";
4
4
  import { buildCliCommandCatalogue, formatCliCommandCatalogue, selectCliCommands } from "./command-catalogue.mjs";
5
5
  import { CLI_COMMAND_REGISTRY, CLI_COMMANDS, COMMAND_COLLECTION, COMMAND_DETAILS, COMMAND_USAGE, } from "./command-reference.mjs";
6
6
  import { buildShellCompletion } from "./completion.mjs";
@@ -169,8 +169,8 @@ async function runRiskCalibrationCommand(args, dependencies, context) {
169
169
  modelVersion: dependencies.riskModelVersion || RISK_MODEL_VERSION,
170
170
  reviewThreshold: dependencies.riskReviewThreshold || RISK_REVIEW_THRESHOLD,
171
171
  ...(!dependencies.explainRiskScore ? {
172
- previousModelVersion: 7,
173
- explainPreviousRiskScore: explainRiskScoreV7,
172
+ previousModelVersion: 8,
173
+ explainPreviousRiskScore: explainRiskScoreV8,
174
174
  } : {}),
175
175
  });
176
176
  if (!args.quiet) {
@@ -0,0 +1,43 @@
1
+ import { parseBoundedJson } from "../packages/analysis/bounded-json.mjs";
2
+ import { applyCandidateWatchHandoff, planCandidateWatchHandoff, } from "../packages/workspace/candidate-watch-handoff.mjs";
3
+ import { buildWatchlistExport, mergeWatchlistStores, } from "../packages/workspace/watchlist-store.mjs";
4
+ import { MAX_WATCHLIST_IMPORT_BYTES } from "../packages/contracts/workspace-portability.mjs";
5
+ import { ordinaryWorkspaceRecord } from "../packages/workspace/hostile-input.mjs";
6
+ import { CANDIDATE_WATCH_INPUT_SCHEMA, CANDIDATE_WATCH_INPUT_VERSION, } from "../packages/contracts/candidate-watch-review.mjs";
7
+ export function parseCandidateWatchInput(input) {
8
+ const value = typeof input === 'string'
9
+ ? parseBoundedJson(input, {
10
+ maximumBytes: MAX_WATCHLIST_IMPORT_BYTES,
11
+ label: 'Candidate watch selection',
12
+ })
13
+ : input;
14
+ const raw = ordinaryWorkspaceRecord(value, 'Candidate watch selection');
15
+ if (!raw ||
16
+ raw.schema !== CANDIDATE_WATCH_INPUT_SCHEMA ||
17
+ raw.version !== CANDIDATE_WATCH_INPUT_VERSION ||
18
+ Object.keys(raw).some((key) => !['schema', 'version', 'watchlists', 'selection'].includes(key)))
19
+ throw new TypeError('Use candidate-watch-input schema 1 with watchlists and an explicit selection.');
20
+ const selection = ordinaryWorkspaceRecord(raw.selection, 'Candidate watch selection');
21
+ if (!selection ||
22
+ Object.keys(selection).some((key) => ![
23
+ 'name',
24
+ 'candidates',
25
+ 'brandProfileId',
26
+ 'priority',
27
+ 'reason',
28
+ 'reviewDueAt',
29
+ 'replaceExistingContext',
30
+ ].includes(key)))
31
+ throw new TypeError('The candidate selection contains undeclared fields.');
32
+ const local = raw.watchlists === null ? {} : mergeWatchlistStores({}, raw.watchlists).watchlists;
33
+ planCandidateWatchHandoff(local, selection);
34
+ return raw;
35
+ }
36
+ export function reviewCandidateWatchInput(input, operation, now) {
37
+ const raw = parseCandidateWatchInput(input), selection = raw.selection;
38
+ const local = raw.watchlists === null ? {} : mergeWatchlistStores({}, raw.watchlists).watchlists;
39
+ if (operation === 'plan')
40
+ return planCandidateWatchHandoff(local, selection);
41
+ const result = applyCandidateWatchHandoff(local, selection, now);
42
+ return buildWatchlistExport(result.watchlists, now);
43
+ }