@slicedearth/whoisleuth-cli 2.6.0 → 2.8.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 (96) hide show
  1. package/DISCLOSURE +8 -5
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -857
  5. package/cli/artifact-validation/lookup-evidence.mjs +15 -2
  6. package/cli/artifact-verify.mjs +32 -1
  7. package/cli/bulk-command-runner.mjs +5 -4
  8. package/cli/case-command.mjs +17 -17
  9. package/cli/case-pack.mjs +7 -1
  10. package/cli/ci-report.mjs +17 -5
  11. package/cli/collection-arguments.mjs +6 -0
  12. package/cli/collection-command-definitions.mjs +13 -3
  13. package/cli/command-definition.mjs +7 -5
  14. package/cli/discovery-scan-command-runner.mjs +13 -7
  15. package/cli/doctor.mjs +37 -3
  16. package/cli/domain-control-monitor.mjs +25 -2
  17. package/cli/domain-control-observations.mjs +52 -15
  18. package/cli/fail-policy.mjs +31 -22
  19. package/cli/formatters/html.mjs +3 -0
  20. package/cli/formatters/json.mjs +8 -1
  21. package/cli/formatters/terminal-lookup.mjs +24 -1
  22. package/cli/history-command-definitions.mjs +1 -1
  23. package/cli/investigation-plan.mjs +15 -0
  24. package/cli/lookup-brief.mjs +11 -5
  25. package/cli/lookup-command-runner.mjs +26 -18
  26. package/cli/lookup-diff.mjs +1 -0
  27. package/cli/review-command-definitions.mjs +11 -11
  28. package/cli/risk-policy.mjs +83 -0
  29. package/cli/saved-lookup.mjs +22 -7
  30. package/cli/source-reliability.mjs +12 -10
  31. package/cli/support-arguments.mjs +1 -1
  32. package/cli/support-command-definitions.mjs +5 -4
  33. package/cli/support-command-runner.mjs +6 -4
  34. package/cli/workflow-command-definitions.mjs +2 -1
  35. package/cli/workflow-command-runner.mjs +5 -1
  36. package/docs/cli-reference.md +56 -11
  37. package/docs/cli.md +162 -33
  38. package/docs/contextual-reviews.md +10 -0
  39. package/docs/infrastructure-observations.md +9 -0
  40. package/lib/dmarc-discovery.mjs +4 -2
  41. package/lib/dns-inheritance-review.mjs +14 -1
  42. package/lib/evidence-export.mjs +10 -4
  43. package/lib/evidence-report.mjs +2 -1
  44. package/lib/generated/cisa-kev-catalog.mjs +19 -4
  45. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  46. package/lib/generated/retire-browser-catalog.mjs +178 -5
  47. package/lib/idn-confusables.mjs +5 -1
  48. package/lib/lookup-collection-receipt.mjs +52 -0
  49. package/lib/lookup-diagnostics.mjs +2 -12
  50. package/lib/lookup-progress.mjs +29 -31
  51. package/lib/lookup-response-contract.mjs +9 -0
  52. package/lib/lookup-response.mjs +15 -14
  53. package/lib/lookup-source-progress.mjs +4 -2
  54. package/lib/lookup.mjs +33 -2
  55. package/lib/rdap-capabilities.mjs +43 -9
  56. package/lib/registry-insights.mjs +17 -6
  57. package/lib/sslbl-certificates.generated.mjs +143 -142
  58. package/lib/whois-authority.mjs +56 -1
  59. package/lib/whois-chain.mjs +2 -1
  60. package/lib/whois-contracts.mjs +2 -0
  61. package/lib/whois-normalization.mjs +4 -4
  62. package/package.json +6 -6
  63. package/packages/analysis/bounded-json.mjs +12 -2
  64. package/packages/analysis/brand-profile-signals.mjs +42 -0
  65. package/packages/cases/case-attachment-model.mjs +36 -1
  66. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  67. package/packages/cases/case-evidence-links.mjs +28 -0
  68. package/packages/cases/case-evidence-model.mjs +39 -6
  69. package/packages/cases/case-migration-model.mjs +9 -1
  70. package/packages/cases/case-object-coverage.mjs +106 -20
  71. package/packages/cases/case-record-operations.mjs +13 -0
  72. package/packages/cases/case-record-projection.mjs +28 -2
  73. package/packages/cases/case-report.mjs +5 -4
  74. package/packages/cases/case-response-packet-validation.mjs +880 -0
  75. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  76. package/packages/cases/case-response-packet.mjs +2 -15
  77. package/packages/cases/case-response-review-inputs.mjs +18 -1
  78. package/packages/cases/case-type-presentation.mjs +10 -0
  79. package/packages/cases/case-workflow-metadata.mjs +0 -8
  80. package/packages/comparison/capture-context.mjs +3 -2
  81. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  82. package/packages/contracts/case-portability.mjs +59 -28
  83. package/packages/contracts/cli-doctor.mjs +4 -0
  84. package/packages/contracts/cli-lookup.mjs +57 -46
  85. package/packages/contracts/lookup-evidence.mjs +12 -2
  86. package/packages/contracts/registry-insights.mjs +6 -0
  87. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  88. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  89. package/packages/evidence/lookup-collection-review.mjs +74 -0
  90. package/packages/interchange/external-findings-import.mjs +8 -4
  91. package/packages/investigation/bulk-domain-comparison.mjs +17 -8
  92. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  93. package/packages/investigation/domain-history-review.mjs +7 -1
  94. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  95. package/packages/workspace/bulk-session-model.mjs +8 -0
  96. package/third-party-notices.txt +30 -30
@@ -1,11 +1,12 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
2
+ import { assertLookupEvidencePrivacySafeTree, assertLookupEvidencePortableTree, LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, lookupEvidenceRegistryInsightsVersion, HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION, PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION, REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, projectLookupEvidenceAvailability, projectLookupEvidenceAvailabilityPublic, projectLookupEvidenceQuery, projectLookupEvidenceRdapPublication, projectLookupEvidenceRegistryInsights, projectLookupEvidenceWhoisPublication, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../../lib/evidence-export.mjs";
3
3
  import { validLookupObservationScope } from "../../packages/evidence/lookup-target.mjs";
4
4
  import { externalPasswordFormObservation } from "../../packages/evidence/credential-form-attribution.mjs";
5
5
  import { credentialSurfaceContractState } from "../../lib/lookup-child-profile-contract.mjs";
6
6
  import { registrarStandingObservedBy, resolveRegistrarIanaId, validRegistrarStanding, } from "../../lib/registrar-standing-contract.mjs";
7
7
  import { compareRegistrySources } from "../../lib/registry-comparison.mjs";
8
8
  import { buildRegistryInsights } from "../../lib/registry-insights.mjs";
9
+ import { resolveRdapCapabilityCatalogRevision } from "../../lib/rdap-capabilities.mjs";
9
10
  import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../../packages/analysis/project-metadata.mjs";
10
11
  import { validHttpDeliveryMetadata, validPagePublicationMetadata, } from "../../lib/homepage-metadata-contract.mjs";
11
12
  import { HEX_DIGEST_RE, SEMANTIC_VERSION_RE, absoluteUrl, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, nullableRecord, optionalText, record, strings, text, } from "./structure-primitives.mjs";
@@ -344,7 +345,7 @@ function validateLookupEvidenceWhois(value, version) {
344
345
  optionalText(complete.conflictingHop, 'Lookup evidence WHOIS conflicting hop', 253);
345
346
  nullableRecord(complete.parsed, 'Lookup evidence WHOIS parsed data');
346
347
  if (version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
347
- && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed))) {
348
+ && !isDeepStrictEqual(complete.parsed, projectLookupEvidenceWhoisPublication(complete.parsed, version))) {
348
349
  fail('Lookup evidence WHOIS portable publication');
349
350
  }
350
351
  array(complete.chain, 'Lookup evidence WHOIS chain', 16).forEach((item, index) => {
@@ -715,6 +716,15 @@ export function validateLookupEvidenceArtifactStructure(value) {
715
716
  });
716
717
  if (!isDeepStrictEqual(registryComparison, expectedComparison))
717
718
  fail('Lookup evidence registry comparison derivation');
719
+ const capabilities = record(registryInsights.rdapCapabilities, 'Lookup evidence RDAP capabilities');
720
+ const registryCapabilities = record(capabilities.registry, 'Lookup evidence registry RDAP capabilities');
721
+ const registrarCapabilities = record(capabilities.registrar, 'Lookup evidence registrar RDAP capabilities');
722
+ const catalogReviewedAt = registryCapabilities.catalogReviewedAt;
723
+ if (typeof catalogReviewedAt !== 'string'
724
+ || resolveRdapCapabilityCatalogRevision(catalogReviewedAt) === null
725
+ || registrarCapabilities.catalogReviewedAt !== catalogReviewedAt) {
726
+ fail('Lookup evidence RDAP capability catalogue revision');
727
+ }
718
728
  const rebuiltInsights = buildRegistryInsights({
719
729
  rdapParsed,
720
730
  rdapStatus: rdapDiagnosticStatus,
@@ -722,6 +732,9 @@ export function validateLookupEvidenceArtifactStructure(value) {
722
732
  whoisParsed,
723
733
  whoisStatus: whoisDiagnosticStatus,
724
734
  whoisQueriedAt: whois.queriedAt,
735
+ }, {
736
+ rdapCapabilityCatalogReviewedAt: catalogReviewedAt,
737
+ version: lookupEvidenceRegistryInsightsVersion(version),
725
738
  });
726
739
  const expectedInsights = version >= PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION
727
740
  ? projectLookupEvidenceRegistryInsights(rebuiltInsights)
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION } from "../packages/contracts/analyst-interchange.mjs";
2
+ import { MANAGED_INDICATOR_SET_SCHEMA, MANAGED_INDICATOR_SET_VERSION, MAX_MANAGED_INDICATOR_SET_BYTES } from "../packages/contracts/analyst-interchange.mjs";
3
3
  import { createHash } from 'node:crypto';
4
4
  import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
5
5
  import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "./bounded-json.mjs";
@@ -532,6 +532,34 @@ export async function verifyOfflineArtifact(raw, options = {}) {
532
532
  : null;
533
533
  return Object.freeze({ ...report, manifestIdentity });
534
534
  }
535
+ function offlineArtifactNextSteps(report) {
536
+ switch (report.artifact.kind) {
537
+ case 'saved_lookup':
538
+ return ['Review retained source states without displaying target values:', ' whoisleuth source-report -- ./lookup.json'];
539
+ case 'case_export':
540
+ return ['Read the Case records, including private analyst content:', ' whoisleuth case show -- ./cases.json'];
541
+ case 'workspace_archive':
542
+ return ['Inspect section metadata without restoring the archive:', ' whoisleuth inspect-archive -- ./workspace.json'];
543
+ case 'encrypted_workspace_archive':
544
+ return report.checks.authenticatedEncryption === 'verified'
545
+ ? ['Inspect decrypted section metadata without restoring the archive:', ' whoisleuth inspect-archive --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json']
546
+ : ['Check the encrypted contents using a separate passphrase file:', ' whoisleuth verify-artifact --passphrase-file ./passphrase.txt -- ./workspace.encrypted.json'];
547
+ case 'lookup_evidence':
548
+ return ['Review which fields this portable format preserves and omits:', ' whoisleuth interchange-report -- ./evidence.json'];
549
+ case 'cli_case_pack':
550
+ return ['Review the Case-pack compatibility and field exclusions:', ' whoisleuth interchange-report -- ./case-pack.json'];
551
+ case 'signed_review_artifact':
552
+ if (report.artifact.schema === DOMAIN_CONTROL_MANIFEST_SCHEMA || report.artifact.schema === LOOKUP_CLAIM_PASSPORT_SCHEMA) {
553
+ return ['Review the passport compatibility and retained field groups:', ' whoisleuth interchange-report -- ./passport.json'];
554
+ }
555
+ return report.artifact.schema === MANAGED_INDICATOR_SET_SCHEMA
556
+ && report.summary.inputBytes <= MAX_MANAGED_INDICATOR_SET_BYTES
557
+ ? ['Read the retained indicator identities and review windows:', ' whoisleuth indicator-set inspect -- ./indicators.json']
558
+ : [];
559
+ default:
560
+ return [];
561
+ }
562
+ }
535
563
  export function formatOfflineArtifactVerification(report) {
536
564
  const lines = [
537
565
  'WHOISleuth offline artefact verification',
@@ -611,5 +639,8 @@ export function formatOfflineArtifactVerification(report) {
611
639
  }
612
640
  for (const limitation of report.limitations)
613
641
  lines.push(`Limitation: ${limitation}`);
642
+ const nextSteps = offlineArtifactNextSteps(report);
643
+ if (nextSteps.length)
644
+ lines.push('', 'Offline next steps (examples; replace filenames; nothing runs automatically):', ...nextSteps);
614
645
  return `${lines.join('\n')}\n`;
615
646
  }
@@ -8,7 +8,8 @@ import EXIT_CODES from "./exit-codes.mjs";
8
8
  import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
9
9
  import { formatTerminalBulk } from "./formatters/terminal.mjs";
10
10
  import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
11
- import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
11
+ import { evaluateCliFailPolicies, reportCliFailPolicies } from "./fail-policy.mjs";
12
+ import { readCliRiskProfile } from "./risk-policy.mjs";
12
13
  import { formatCliJunit } from "./ci-report.mjs";
13
14
  import { createCliProgressEvents } from "./progress-events.mjs";
14
15
  async function runBulkCommand(args, dependencies, context) {
@@ -39,6 +40,7 @@ async function runBulkCommand(args, dependencies, context) {
39
40
  context.writeStdout(args.output === 'json' ? formatJsonDocument(document) : context.terminal(formatCollectionPreflight(document), args.color));
40
41
  return EXIT_CODES.SUCCESS;
41
42
  }
43
+ const riskProfile = await readCliRiskProfile(args.brandProfileSource, context.readInput, args.brandProfileId);
42
44
  const classify = dependencies.classifyQuery || classifyQuery;
43
45
  const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
44
46
  const checkpoint = args.checkpoint
@@ -106,6 +108,7 @@ async function runBulkCommand(args, dependencies, context) {
106
108
  collectedTotal: items.length,
107
109
  filter: args.filter,
108
110
  };
111
+ const policyFindings = evaluateCliFailPolicies(buildCliBulkDocument(items, { ...metadata, filter: 'all' }), args.failOn || [], riskProfile);
109
112
  const selectedItems = selectBulkItems(items, args.filter);
110
113
  const selectedDocument = buildCliBulkDocument(selectedItems, metadata);
111
114
  if (!args.quiet) {
@@ -126,9 +129,7 @@ async function runBulkCommand(args, dependencies, context) {
126
129
  else
127
130
  context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
128
131
  }
129
- const policyFindings = evaluateCliFailPolicies(buildCliBulkDocument(items, { ...metadata, filter: 'all' }), args.failOn || []);
130
- if (policyFindings.length && !eventProgress.enabled)
131
- context.writeStderr(formatFailPolicyNotice(policyFindings));
132
+ reportCliFailPolicies(policyFindings, context.writeStderr, eventProgress);
132
133
  const exitCode = checkpointFailure || items.some((item) => !item.ok) || policyFindings.length
133
134
  ? EXIT_CODES.PARTIAL_FAILURE
134
135
  : EXIT_CODES.SUCCESS;
@@ -29,12 +29,12 @@ function prose(input, maximum, label) {
29
29
  function selection(cases, args, required) {
30
30
  const domain = args.domain === null ? null : normalizeDomain(args.domain);
31
31
  if (args.domain !== null && !domain)
32
- throw new CliUsageError('Select a valid Case domain.');
32
+ throw new CliUsageError('Select a valid case domain.');
33
33
  const selected = cases.filter(record => (!args.caseId || record.id === args.caseId) && (!domain || record.domain === domain));
34
34
  if ((args.caseId || domain || required) && selected.length === 0)
35
- throw new CliUsageError('The selected Case is not present in this file.');
35
+ throw new CliUsageError('The selected case is not present in this file.');
36
36
  if (required && selected.length !== 1)
37
- throw new CliUsageError('Select exactly one Case with --case-id; no arbitrary Case was chosen.');
37
+ throw new CliUsageError('Select exactly one case with --case-id; no arbitrary case was chosen.');
38
38
  return selected;
39
39
  }
40
40
  function pin(input, now) {
@@ -42,7 +42,7 @@ function pin(input, now) {
42
42
  throw new CliUsageError('An evidence pin must be a JSON object.');
43
43
  const raw = input;
44
44
  if (Object.hasOwn(raw, 'id') || Object.hasOwn(raw, 'createdAt'))
45
- throw new CliUsageError('New pin identity and creation time are allocated by the Case operation.');
45
+ throw new CliUsageError('New pin identity and creation time are allocated by the case operation.');
46
46
  const normal = appendCaseEvidencePin([], raw, now)[0];
47
47
  // Validate the supplied subset against the domain result, without duplicating
48
48
  // its field inventory or accepting silent truncation/default substitution.
@@ -86,7 +86,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
86
86
  ? createCaseIncident(cases, { domain: args.domain, title }, now)
87
87
  : openOrCreateCase(cases, { domain: args.domain, ...(title ? { title } : {}) }, now, args.caseId ? { caseId: args.caseId } : {});
88
88
  if (!result.created && title && title !== result.record.title)
89
- throw new CliUsageError('The selected Case already exists with another title. Use --new-incident for a separate investigation.');
89
+ throw new CliUsageError('The selected case already exists with another title. Use --new-incident for a separate investigation.');
90
90
  return result.cases;
91
91
  }
92
92
  const current = selection(cases, args, true)[0];
@@ -113,7 +113,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
113
113
  result = updateCase(cases, current.id, { action: input }, now);
114
114
  const action = result.record.actions.find(candidate => !current.actions.some(prior => prior.id === candidate.id));
115
115
  if (!action)
116
- throw new CliUsageError('No new action was retained within the Case bounds; nothing was written.');
116
+ throw new CliUsageError('No new action was retained within the case bounds; nothing was written.');
117
117
  for (const [key, value] of Object.entries(input))
118
118
  if (canonicalArtifactJsonV2(key === 'responseObjects' ? readCaseResponseObjects(value) : value) !== canonicalArtifactJsonV2(action[key])) {
119
119
  throw new CliUsageError(`Action field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
@@ -142,7 +142,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
142
142
  result = updateCase(cases, current.id, { assertion: { ...input, kind: 'next_step' } }, now);
143
143
  const question = result.record.assertions.find(candidate => !current.assertions.some(prior => prior.id === candidate.id));
144
144
  if (!question)
145
- throw new CliUsageError('No new question was retained within the Case bounds; nothing was written.');
145
+ throw new CliUsageError('No new question was retained within the case bounds; nothing was written.');
146
146
  for (const [key, value] of Object.entries(input))
147
147
  if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(question[key])) {
148
148
  throw new CliUsageError(`Question field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
@@ -150,12 +150,12 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
150
150
  }
151
151
  else if (args.operation === 'close-object') {
152
152
  if (!input?.responseObject)
153
- throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole Case.');
153
+ throw new CliUsageError('Object closure requires one explicit responseObject; it cannot close the whole case.');
154
154
  fields(input, ['responseObject', 'reason', 'summary', 'observedEffectReviewId', 'actionId', 'limitations'], 'Object closure');
155
155
  result = updateCase(cases, current.id, { closure: input }, now);
156
156
  const closure = result.record.closures.records.find(candidate => !current.closures.records.some(prior => prior.id === candidate.id));
157
157
  if (!closure)
158
- throw new CliUsageError('No new closure was retained within the Case bounds; nothing was written.');
158
+ throw new CliUsageError('No new closure was retained within the case bounds; nothing was written.');
159
159
  for (const [key, value] of Object.entries(input))
160
160
  if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(closure[key])) {
161
161
  throw new CliUsageError(`Closure field ${safeTerminalValue(key)} is not canonical; nothing was written.`);
@@ -191,7 +191,7 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
191
191
  fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname', 'responseObject', 'objectOutcome'], 'Recheck');
192
192
  const recheck = readCaseRecheckAnswerContext(input.recheck);
193
193
  if (input.state === 'not_reproduced' && !recheck) {
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.');
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.');
195
195
  }
196
196
  const summary = prose(input.comparisonSummary, MAX_RESPONSE_VALUE_LENGTH, 'Comparison summary');
197
197
  result = recordCaseRecheckOutcome(cases, current.id, {
@@ -214,12 +214,12 @@ export function applyCliCaseOperation(cases, args, input, note, now) {
214
214
  }
215
215
  }
216
216
  else
217
- throw new CliUsageError('Select a supported Case mutation.');
217
+ throw new CliUsageError('Select a supported case mutation.');
218
218
  retainExistingEntries(current, result.record);
219
219
  return result.cases;
220
220
  }
221
221
  function formatCases(cases, digest) {
222
- const lines = ['Local Case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
222
+ const lines = ['Local case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
223
223
  for (const record of cases) {
224
224
  lines.push(`Case ${safeTerminalValue(record.id)} · ${safeTerminalValue(record.title || record.domain)}`, `Domain ${safeTerminalValue(record.domain)}`, `Status ${statusLabel(record.status)}`, `Disposition ${dispositionLabel(record.disposition)} (analyst assessment)`, `Updated ${record.updatedAt}`);
225
225
  for (const note of record.notes)
@@ -246,16 +246,16 @@ function formatCases(cases, digest) {
246
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}` : ''}`);
247
247
  lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
248
248
  }
249
- lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
249
+ lines.push('Use --json for the full case export. Attached file bytes remain separate. This command makes no request.', '');
250
250
  return lines.join('\n');
251
251
  }
252
252
  export async function runCaseCommand(args, dependencies, context) {
253
- context.setFailureLabel('Local Case operation');
253
+ context.setFailureLabel('Local case operation');
254
254
  try {
255
255
  const raw = args.source ? dependencies.caseFileInput ?? await context.readInput(args.source, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case file') : null;
256
256
  const digest = raw === null ? null : `sha256:${createHash('sha256').update(raw).digest('hex')}`;
257
257
  if (args.expectedFileDigest && args.expectedFileDigest !== digest)
258
- throw new CliUsageError('The Case file differs from the reviewed file digest. Nothing was written.');
258
+ throw new CliUsageError('The case file differs from the reviewed file digest. Nothing was written.');
259
259
  const cases = raw === null ? [] : readEditableCaseExport(raw);
260
260
  const now = normalizeExplicitIsoTimestamp(context.now());
261
261
  if (!now)
@@ -271,7 +271,7 @@ export async function runCaseCommand(args, dependencies, context) {
271
271
  const note = args.noteSource ? await context.readInput(args.noteSource, MAX_NOTE_LENGTH * 4 + 4, 'Case note') : args.text;
272
272
  const next = applyCliCaseOperation(cases, args, input, note, now);
273
273
  if (Buffer.byteLength(serializeCaseStore(next)) > MAX_CASE_STORE_BYTES)
274
- throw new CliUsageError('The updated Case store exceeds its byte budget. No evidence was pruned and nothing was written.');
274
+ throw new CliUsageError('The updated case store exceeds its byte budget. No evidence was pruned and nothing was written.');
275
275
  const output = `${JSON.stringify(buildCaseExport(next, now))}\n`;
276
276
  // Apply the same strict admission to our output before atomic publication.
277
277
  readEditableCaseExport(output);
@@ -283,6 +283,6 @@ export async function runCaseCommand(args, dependencies, context) {
283
283
  dependencies.signal?.throwIfAborted();
284
284
  if (cause instanceof CliUsageError)
285
285
  throw cause;
286
- throw new CliUsageError(cause instanceof Error ? cause.message : 'The Case operation could not be completed.');
286
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'The case operation could not be completed.');
287
287
  }
288
288
  }
package/cli/case-pack.mjs CHANGED
@@ -5,7 +5,7 @@ import { buildCaseReport, buildCaseReportVerificationProjection } from "../packa
5
5
  import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
6
6
  import { assertBoundedJsonStructure, scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
7
7
  import { assertCaseBrandProfileIds } from "../packages/cases/case-brand-profile-references.mjs";
8
- import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, } from "../packages/cases/case-record-projection.mjs";
8
+ import { CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, projectCaseForAudience, assertCaseCollectionReceiptAudience, } from "../packages/cases/case-record-projection.mjs";
9
9
  import { normalizeDomain } from "../packages/evidence/domain-name.mjs";
10
10
  import { normalizeCaseStore } from "../packages/cases/case-migration-model.mjs";
11
11
  import { safeId } from "../packages/cases/case-record-core.mjs";
@@ -83,6 +83,11 @@ function allowedSensitiveFieldPath(key, path) {
83
83
  return listItemPath(path, 'actions');
84
84
  if (key === 'packetReceipt')
85
85
  return typeof path.at(-1) === 'number' && path.at(-2) === 'history' && listItemPath(path.slice(0, -2), 'actions');
86
+ if (key === 'checkpointCollectionReceipts')
87
+ return topCasePath(path);
88
+ if (key === 'collectionReceipt')
89
+ return path.length === 4 && path[0] === 'cases' && typeof path[1] === 'number'
90
+ && (path[2] === 'evidenceHistory' || path[2] === 'checkpointCollectionReceipts') && typeof path[3] === 'number';
86
91
  return true;
87
92
  }
88
93
  /** Rejects hidden audience-sensitive copies with a budget proven by the serialized byte bound. */
@@ -390,6 +395,7 @@ export function verifyCliCasePack(input) {
390
395
  assertClosedEnvelopes(root, packet, integrity, redactionManifest, packet.reports, root.version);
391
396
  assertSensitiveFieldPlacement(root, serialized.length);
392
397
  const audience = packet.audience;
398
+ assertCaseCollectionReceiptAudience(root, audience);
393
399
  if (!Number.isSafeInteger(redactionManifest.sourceCaseCount)
394
400
  || redactionManifest.sourceCaseCount !== root.cases.length
395
401
  || !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
package/cli/ci-report.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { WHOISLEUTH_REQUEST_POLICY_URL } from "../packages/analysis/project-metadata.mjs";
2
2
  import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA } from "../packages/contracts/domain-control-monitor.mjs";
3
+ import { lookupDiagnosticEntries } from "../lib/lookup-progress.mjs";
4
+ import { lookupStrictExitFindings } from "./strict-exit.mjs";
3
5
  function record(value) {
4
6
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
5
7
  }
@@ -9,13 +11,23 @@ function xml(value) {
9
11
  function junitCases(document) {
10
12
  const schema = String(document.schema || '');
11
13
  if (schema === 'whoisleuth.cli.lookup') {
12
- const diagnostics = record(document.diagnostics);
13
- const cases = Object.entries(diagnostics).slice(0, 64).map(([source, value]) => {
14
- const state = String(record(value).status ?? record(value).state ?? 'unavailable').toLowerCase();
15
- return ['error', 'failed', 'partial', 'rate_limited', 'timeout', 'unavailable'].includes(state)
16
- ? { name: `source ${source}`, failure: `Source state: ${state}` }
14
+ const sources = lookupDiagnosticEntries(document.diagnostics).filter(source => source.present);
15
+ const findings = lookupStrictExitFindings(document);
16
+ const cases = sources.map(({ source, path, state }) => {
17
+ const failure = findings.find(finding => finding.path === path);
18
+ return failure || state === null
19
+ ? { name: `source ${source}`, failure: `Source state: ${failure?.state ?? 'unavailable'}` }
17
20
  : { name: `source ${source}` };
18
21
  });
22
+ // Retain nested and historical failure coverage without echoing imported
23
+ // object keys, endpoints or evidence into CI names or messages.
24
+ const namedPaths = new Set(sources.map(source => source.path));
25
+ const additional = findings.filter(finding => !namedPaths.has(finding.path));
26
+ if (additional.length)
27
+ cases.push({
28
+ name: 'additional source diagnostics',
29
+ failure: `Source states: ${[...new Set(additional.map(finding => finding.state))].sort().join(', ')}`,
30
+ });
19
31
  return cases.length ? cases : [{ name: 'lookup diagnostics', failure: 'No source diagnostics were available.' }];
20
32
  }
21
33
  if (schema === 'whoisleuth.cli.bulk') {
@@ -29,6 +29,8 @@ function parseLookupArguments(parsed) {
29
29
  ? { saveLookup: parsed.optionValue('--save-lookup') }
30
30
  : {}),
31
31
  ...(selectedFailPolicies ? { failOn: selectedFailPolicies } : {}),
32
+ ...(parsed.optionValue('--brand-profile') ? { brandProfileSource: parsed.optionValue('--brand-profile') } : {}),
33
+ ...(parsed.optionValue('--brand-id') ? { brandProfileId: parsed.optionValue('--brand-id') } : {}),
32
34
  };
33
35
  }
34
36
  function parseBulkArguments(parsed) {
@@ -55,6 +57,8 @@ function parseBulkArguments(parsed) {
55
57
  filter,
56
58
  ...terminalOptions(parsed),
57
59
  ...(selectedFailPolicies ? { failOn: selectedFailPolicies } : {}),
60
+ ...(parsed.optionValue('--brand-profile') ? { brandProfileSource: parsed.optionValue('--brand-profile') } : {}),
61
+ ...(parsed.optionValue('--brand-id') ? { brandProfileId: parsed.optionValue('--brand-id') } : {}),
58
62
  };
59
63
  }
60
64
  function parseDiscoverArguments(parsed) {
@@ -113,6 +117,8 @@ function parseDiscoverScanArguments(parsed) {
113
117
  plan: parsed.hasOption('--plan'),
114
118
  ...terminalOptions(parsed),
115
119
  ...(selectedFailPolicies ? { failOn: selectedFailPolicies } : {}),
120
+ ...(parsed.optionValue('--brand-profile') ? { brandProfileSource: parsed.optionValue('--brand-profile') } : {}),
121
+ ...(parsed.optionValue('--brand-id') ? { brandProfileId: parsed.optionValue('--brand-id') } : {}),
116
122
  };
117
123
  }
118
124
  export const COLLECTION_ARGUMENT_PARSERS = Object.freeze({
@@ -2,12 +2,13 @@ import { positional, OPTIONAL_FILE_POSITIONAL, OPTIONAL_TEXT_POSITIONAL, constra
2
2
  import { bulkQueryLimit, bulkConcurrencyLimit } from "../lib/bulk-limits.mjs";
3
3
  const COLLECTION_CEILINGS = `${bulkQueryLimit('fast')} fast or ${bulkQueryLimit('deep')} deep`;
4
4
  const CONCURRENCY_CEILINGS = `${bulkConcurrencyLimit('fast')} fast or ${bulkConcurrencyLimit('deep')} deep`;
5
+ const FAILURE_POLICY_BOUNDARY = 'Failure policies evaluate all collected and restored results before output filtering. For danger review, explicitly select --deep and --brand-profile; use --brand-id to select one profile from a multi-profile export. The current canonical Risk model compares collected evidence with that local reference; Fast, missing or ineligible evidence cannot establish a threshold match. The heuristic review threshold is not a maliciousness or safety verdict. The profile is not sent to collectors, retained in checkpoints or copied into output.';
5
6
  export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
6
7
  lookup: commandSeed({
7
8
  reference: {
8
9
  description: 'Collect registration evidence for one domain, IP, or ASN.',
9
10
  example: 'whoisleuth lookup example.test --deep --browse',
10
- boundary: 'Fast is the default. An ICANN-recognised public domain, reserved documentation domain, IP, or ASN may occupy command position as shorthand; it delegates to this same parser and URL-like input requires the explicit lookup command. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable. --deep --exact-url explicitly sends the input URL path and query to the website, without its fragment; ordinary URL input sends only the hostname. --plan remains offline and omits the selected URL. Retained paths and page-derived text require review before sharing. A full Deep homepage observation can derive fixed publication and delivery/cache summaries from the same response without retaining raw metadata values or making another request. --browse opens before collection, shows aggregate Fast progress or independently settled planned Deep sources, and then navigates allowlisted retained fields in the completed document. Press ? for help and / to search rendered panel text only. Closing during collection cancels without a partial document. --save-lookup writes the exact completed private JSON only after a normal browser close; it can contain normalised evidence omitted from panels and refuses an existing path.',
11
+ boundary: 'Fast is the default. An ICANN-recognised public domain, reserved documentation domain, IP, or ASN may occupy command position as shorthand; it delegates to this same parser and URL-like input requires the explicit lookup command. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable. --deep --exact-url explicitly sends the input URL path and query to the website, without its fragment; ordinary URL input sends only the hostname. --plan remains offline and omits the selected URL. Retained paths and page-derived text require review before sharing. A full Deep homepage observation can derive fixed publication and delivery/cache summaries from the same response without retaining raw metadata values or making another request. --browse opens before collection, shows aggregate Fast progress or independently settled planned Deep sources, and then navigates allowlisted retained fields in the completed document. Press ? for help and / to search rendered panel text only. Closing during collection cancels without a partial document. --save-lookup writes the exact completed private JSON only after a normal browser close; it can contain normalised evidence omitted from panels and refuses an existing path. ' + FAILURE_POLICY_BOUNDARY,
11
12
  },
12
13
  collection: {
13
14
  scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.',
@@ -31,6 +32,8 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
31
32
  '--save-lookup',
32
33
  '--strict-exit',
33
34
  '--fail-on',
35
+ '--brand-profile',
36
+ '--brand-id',
34
37
  '--events',
35
38
  '--quiet',
36
39
  '--no-color',
@@ -39,6 +42,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
39
42
  positional('target', 'text', 0, 1, [], 'argv_or_stdin', ['--browse']),
40
43
  ]),
41
44
  constraints: Object.freeze([
45
+ constraint({ kind: 'requires_all', option: '--brand-id', requiredOptions: ['--brand-profile'] }),
42
46
  constraint({
43
47
  kind: 'mutually_exclusive',
44
48
  options: ['--json', '--junit', '--markdown', '--html'],
@@ -106,7 +110,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
106
110
  reference: {
107
111
  description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
108
112
  example: 'cat domains.txt | whoisleuth bulk --jsonl',
109
- boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns.',
113
+ boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns. ' + FAILURE_POLICY_BOUNDARY,
110
114
  },
111
115
  collection: {
112
116
  scope: `Accepts at most ${COLLECTION_CEILINGS} targets, with concurrency capped at ${CONCURRENCY_CEILINGS}.`,
@@ -131,11 +135,14 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
131
135
  '--events',
132
136
  '--plan',
133
137
  '--fail-on',
138
+ '--brand-profile',
139
+ '--brand-id',
134
140
  '--quiet',
135
141
  '--no-color',
136
142
  ],
137
143
  positionals: OPTIONAL_FILE_POSITIONAL,
138
144
  constraints: Object.freeze([
145
+ constraint({ kind: 'requires_all', option: '--brand-id', requiredOptions: ['--brand-profile'] }),
139
146
  constraint({
140
147
  kind: 'mutually_exclusive',
141
148
  options: [
@@ -236,7 +243,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
236
243
  reference: {
237
244
  description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
238
245
  example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
239
- boundary: `This command performs network collection. Fast compact lookup is the default; deep mode is capped at ${bulkQueryLimit('deep')} candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns.`,
246
+ boundary: `This command performs network collection. Fast compact lookup is the default; deep mode is capped at ${bulkQueryLimit('deep')} candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns. ${FAILURE_POLICY_BOUNDARY}`,
240
247
  },
241
248
  collection: {
242
249
  scope: `Scans at most ${COLLECTION_CEILINGS} candidates, with concurrency capped at ${CONCURRENCY_CEILINGS}.`,
@@ -265,6 +272,8 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
265
272
  '--events',
266
273
  '--plan',
267
274
  '--fail-on',
275
+ '--brand-profile',
276
+ '--brand-id',
268
277
  '--json',
269
278
  '--jsonl',
270
279
  '--csv',
@@ -275,6 +284,7 @@ export const COLLECTION_COMMAND_DEFINITIONS = Object.freeze({
275
284
  ],
276
285
  positionals: OPTIONAL_TEXT_POSITIONAL,
277
286
  constraints: Object.freeze([
287
+ constraint({ kind: 'requires_all', option: '--brand-id', requiredOptions: ['--brand-profile'] }),
278
288
  constraint({
279
289
  kind: 'mutually_exclusive',
280
290
  options: ['--json', '--jsonl', '--csv', '--csv-with-metadata', '--domains'],
@@ -126,7 +126,7 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
126
126
  '--config': file('Load explicit versioned CLI configuration from this file.'),
127
127
  '--profile': text('Select a named profile from the supplied configuration.', true),
128
128
  '--palette': enumeration('Choose the terminal colour palette; redirected output and no-colour settings still take precedence.', ['auto', 'light', 'dark']),
129
- '--network': flag('Include the optional public DNS and port 43 runtime checks.'),
129
+ '--network': flag('Include the optional public DNS, HTTPS and WHOIS (port 43) runtime checks.'),
130
130
  '--json': flag('Write structured JSON to stdout or the selected output file.'),
131
131
  '--reported-action': optionDefinition('enum', 'Record an analyst-reported identity action; repeat for separate actions.', { values: IDENTITY_ACTIONS.map(action => action.id), occurrence: 'repeatable' }),
132
132
  '--trusted-auth-header': optionDefinition('text', 'Select a recognised receiver header by part:header-index. This records analyst trust, not independent authentication; repeat for separate headers.', { occurrence: 'repeatable' }),
@@ -156,6 +156,8 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
156
156
  '--interactive': flag('Prompt for missing supported inputs on an interactive terminal.'),
157
157
  '--save-lookup': file('After a normal evidence-browser close, save the completed private Lookup JSON to a new file.'),
158
158
  '--strict-exit': flag('Use the command’s strict outcome policy when deciding the exit status.'),
159
+ '--brand-profile': file('Read a local Brand Profile export for danger-policy comparisons; no additional requests or profile retention.'),
160
+ '--brand-id': text('Select an exact profile ID when --brand-profile contains multiple profiles.'),
159
161
  '--fail-on': optionDefinition('policy_list', 'Return a failure-policy exit status for the selected comma-separated outcomes.', {
160
162
  values: (command) => CLI_FAIL_POLICIES_BY_COMMAND[command] ?? [],
161
163
  }),
@@ -235,10 +237,10 @@ const CLI_OPTION_DEFINITIONS = Object.freeze({
235
237
  '--left-session': text('Select the left-hand retained capture session.', true),
236
238
  '--right-session': text('Select the right-hand retained capture session.', true),
237
239
  '--compact': flag('Write a compact report presentation.'),
238
- '--case-id': text('Select the retained Case identifier.'),
239
- '--domain': text('Supply the Case domain.'),
240
- '--title': text('Supply the Case title.'),
241
- '--new-incident': flag('Create a separate incident instead of updating a matching Case.'),
240
+ '--case-id': text('Select the retained case identifier.'),
241
+ '--domain': text('Supply the case domain.'),
242
+ '--title': text('Supply the case title.'),
243
+ '--new-incident': flag('Create a separate incident instead of updating a matching case.'),
242
244
  '--text': text('Supply the note text directly.'),
243
245
  '--note-file': file('Read note text from a selected local file.'),
244
246
  '--input': file('Read the selected local input file.'),
@@ -10,7 +10,8 @@ import EXIT_CODES from "./exit-codes.mjs";
10
10
  import { formatJsonDocument } from "./formatters/json.mjs";
11
11
  import { createCliProgressEvents } from "./progress-events.mjs";
12
12
  import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
13
- import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
13
+ import { evaluateCliFailPolicies, reportCliFailPolicies } from "./fail-policy.mjs";
14
+ import { readCliRiskProfile } from "./risk-policy.mjs";
14
15
  async function readAllowlist(source, dependencies, context, classify) {
15
16
  if (!source)
16
17
  return new Set();
@@ -59,6 +60,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
59
60
  context.writeStdout(args.output === 'json' ? formatJsonDocument(document) : context.terminal(formatCollectionPreflight(document), args.color));
60
61
  return EXIT_CODES.SUCCESS;
61
62
  }
63
+ const riskProfile = await readCliRiskProfile(args.brandProfileSource, context.readInput, args.brandProfileId);
62
64
  const classify = dependencies.classifyQuery || classifyQuery;
63
65
  const allowlist = await readAllowlist(args.allowlistSource, dependencies, context, classify);
64
66
  const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
@@ -144,7 +146,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
144
146
  const snapshot = args.observationSnapshot
145
147
  ? await updateDiscoveryObservationSnapshot(args.observationSnapshot, candidates, items, { deep: args.deep, resolverServers }, generationMetadata.generatedAt)
146
148
  : null;
147
- const document = buildDiscoveryScanDocument(candidates, items, {
149
+ const metadata = {
148
150
  generatedAt: generationMetadata.generatedAt,
149
151
  seed: generationMetadata.seed,
150
152
  preset: generationMetadata.preset,
@@ -157,9 +159,15 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
157
159
  chunkSize: args.chunkSize,
158
160
  concurrency: args.concurrency,
159
161
  deep: args.deep,
160
- filter: args.filter,
162
+ filter: 'all',
161
163
  resolverServers,
162
- }, allowlist, snapshot);
164
+ };
165
+ // Policies describe the complete collected run, including restored results,
166
+ // source diagnostics and candidate context, before presentation selection.
167
+ const collectedDocument = buildDiscoveryScanDocument(candidates, items, metadata, allowlist, snapshot);
168
+ const policyFindings = evaluateCliFailPolicies(collectedDocument, args.failOn || [], riskProfile);
169
+ const document = args.filter === 'all' ? collectedDocument
170
+ : buildDiscoveryScanDocument(candidates, items, { ...metadata, filter: args.filter }, allowlist, snapshot);
163
171
  if (!args.quiet) {
164
172
  if (args.output === 'json')
165
173
  context.writeStdout(formatJsonDocument(document));
@@ -174,9 +182,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
174
182
  else
175
183
  context.writeStdout(context.terminal(formatTerminalDiscoveryScan(document), args.color));
176
184
  }
177
- const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
178
- if (policyFindings.length && !eventProgress.enabled)
179
- context.writeStderr(formatFailPolicyNotice(policyFindings));
185
+ reportCliFailPolicies(policyFindings, context.writeStderr, eventProgress);
180
186
  const exitCode = checkpointFailure || items.some((item) => !item.ok) || policyFindings.length
181
187
  ? EXIT_CODES.PARTIAL_FAILURE
182
188
  : EXIT_CODES.SUCCESS;
package/cli/doctor.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  import { withTimeout } from "../lib/abort.mjs";
2
- export const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
3
- export const DOCTOR_VERSION = 1;
2
+ import { CLI_COMMAND_REGISTRY } from "./command-reference.mjs";
3
+ import { DOCTOR_SCHEMA, DOCTOR_VERSION, DOCTOR_SUMMARY_SCHEMA, DOCTOR_SUMMARY_VERSION } from "../packages/contracts/cli-doctor.mjs";
4
+ export { DOCTOR_SCHEMA, DOCTOR_VERSION, DOCTOR_SUMMARY_SCHEMA, DOCTOR_SUMMARY_VERSION };
4
5
  const MINIMUM_NODE_MAJOR = 24;
5
6
  const NETWORK_HOST = 'whois.iana.org';
6
7
  const NETWORK_QUERY = 'example.com';
7
8
  const NETWORK_HTTPS_URL = 'https://data.iana.org/rdap/dns.json';
8
9
  const NETWORK_TIMEOUT_MS = 6_000;
9
10
  const MAX_DOCTOR_DETAIL_LENGTH = 180;
11
+ const DOCTOR_CHECK_IDS = ['runtime', 'platform', 'terminal', 'offline_features', 'network', 'public_dns', 'https_transport', 'whois_transport'];
10
12
  function boundedDetail(value, fallback) {
11
13
  return String(value || fallback)
12
14
  .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
@@ -162,6 +164,38 @@ async function buildDoctorReport(options) {
162
164
  checks: Object.freeze(checks),
163
165
  });
164
166
  }
167
+ function diagnosticVersion(value) {
168
+ return /^(\d{1,6}\.\d{1,6}\.\d{1,6})(?:[-+].*)?$/u.exec(value)?.[1] ?? null;
169
+ }
170
+ function buildDoctorSummary(report, nodeVersion = process.versions.node) {
171
+ const checkCodes = DOCTOR_CHECK_IDS.flatMap(id => {
172
+ const check = report.checks.find(item => item.id === id);
173
+ if (!check)
174
+ return [];
175
+ const state = check.state === 'pass' || check.state === 'partial' || check.state === 'skipped' ? check.state : 'unknown';
176
+ return [`${id}_${state}`];
177
+ });
178
+ const omittedCheckCount = report.checks.length - checkCodes.length;
179
+ return Object.freeze({
180
+ schema: DOCTOR_SUMMARY_SCHEMA,
181
+ version: DOCTOR_SUMMARY_VERSION,
182
+ cliVersion: diagnosticVersion(report.cliVersion),
183
+ nodeVersion: diagnosticVersion(nodeVersion),
184
+ state: report.state === 'pass' && omittedCheckCount === 0 ? 'pass' : 'partial',
185
+ networkRequested: report.networkRequested === true,
186
+ checkCodes: Object.freeze(checkCodes),
187
+ omittedCheckCount,
188
+ declaredFormatIdentifiers: Object.freeze([...new Set(CLI_COMMAND_REGISTRY.flatMap(command => command.documentation.supportedSchemaIdentifiers))].sort()),
189
+ formatScope: 'Public command declarations only; not an installed-package or input-compatibility test.',
190
+ omissions: Object.freeze([
191
+ 'Targets, URLs, queries and fragments',
192
+ 'File paths, folder names and user names',
193
+ 'Launch tokens, cookies, credentials and headers',
194
+ 'Input files, response content and free-form diagnostic details',
195
+ 'Timestamps, platform details and version prerelease/build metadata',
196
+ ]),
197
+ });
198
+ }
165
199
  function formatDoctorReport(report) {
166
200
  const lines = [
167
201
  'Runtime:',
@@ -175,4 +209,4 @@ function formatDoctorReport(report) {
175
209
  }
176
210
  return `${lines.join('\n')}\n`;
177
211
  }
178
- export { buildDoctorReport, formatDoctorReport, };
212
+ export { buildDoctorReport, buildDoctorSummary, formatDoctorReport, };