@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
@@ -9,6 +9,8 @@ import { requireDomainName, requireIsoTimestamp } from "../lib/bounded-contract-
9
9
  import { buildCliLookupDocument } from "./formatters/json.mjs";
10
10
  import { buildCliDomainControlReview, DOMAIN_CONTROL_REVIEW_SOURCE_FIELDS, } from "./domain-control-observations.mjs";
11
11
  import { CliUsageError } from "./errors.mjs";
12
+ import { lookupStrictExitFindings } from "./strict-exit.mjs";
13
+ import { lookupObservationHostname } from "../packages/evidence/lookup-target.mjs";
12
14
  export { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA, CLI_DOMAIN_CONTROL_MONITOR_VERSION };
13
15
  export const MAX_DOMAIN_CONTROL_MONITOR_DOMAINS = CONTRACT_MAX_DOMAIN_CONTROL_MONITOR_DOMAINS;
14
16
  function parseMonitorJson(input, label) {
@@ -232,6 +234,19 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
232
234
  }
233
235
  const previous = previousSnapshot(previousInput, startedAt);
234
236
  const entries = manifest.entries.slice(0, Math.min(options.limit, MAX_DOMAIN_CONTROL_MONITOR_DOMAINS));
237
+ const targets = entries.map(entry => {
238
+ let classified;
239
+ try {
240
+ classified = classifyQuery(entry.domain);
241
+ }
242
+ catch {
243
+ throw new CliUsageError('monitor-once requires registrable-domain targets.');
244
+ }
245
+ if (classified.type !== 'domain' || classified.isSubdomain) {
246
+ throw new CliUsageError('monitor-once supports registrable-domain targets only; exact-host monitoring is not supported.');
247
+ }
248
+ return classified;
249
+ });
235
250
  const lookups = new Array(entries.length);
236
251
  const failuresByIndex = new Array(entries.length);
237
252
  let cursor = 0;
@@ -244,13 +259,18 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
244
259
  if (!entry)
245
260
  break;
246
261
  try {
247
- const classified = classifyQuery(entry.domain);
262
+ const classified = targets[index];
248
263
  const result = await options.executeLookup(classified, { fast: false, compact: false, ...(options.signal ? { signal: options.signal } : {}) });
249
264
  options.signal?.throwIfAborted();
250
265
  const capturedAt = requireIsoTimestamp(options.now(), 'Domain-control capture time');
251
266
  if (Date.parse(capturedAt) < checkedAt)
252
267
  throw new CliUsageError('Domain-control capture time must not precede the run.');
253
- lookups[index] = buildCliLookupDocument(entry.domain, classified, result, capturedAt, 'deep');
268
+ const lookup = buildCliLookupDocument(entry.domain, classified, result, capturedAt, 'deep');
269
+ const observedHostname = lookupObservationHostname({ domain: entry.domain, ...lookup.availability });
270
+ if (observedHostname !== null && observedHostname !== entry.domain) {
271
+ throw new TypeError('Lookup observation does not match the monitor target.');
272
+ }
273
+ lookups[index] = lookup;
254
274
  }
255
275
  catch {
256
276
  if (options.signal?.aborted) {
@@ -270,6 +290,9 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
270
290
  const successful = lookups.filter(Boolean);
271
291
  if (!successful.length)
272
292
  throw new Error('No domain-control monitor lookup completed successfully.');
293
+ // Only current Lookup diagnostics contribute; checkpoints retain observations,
294
+ // not policy metadata or raw diagnostics.
295
+ options.onSourceFailures?.(successful.reduce((count, lookup) => count + lookupStrictExitFindings(lookup).length, 0));
273
296
  const generatedAt = requireIsoTimestamp(options.now(), 'Domain-control completion time');
274
297
  if (Date.parse(generatedAt) < checkedAt || successful.some((lookup) => Date.parse(String(lookup.generatedAt)) > Date.parse(generatedAt))) {
275
298
  throw new CliUsageError('Domain-control completion time must not precede its captures.');
@@ -3,10 +3,11 @@ import { latestObservationCohort } from "../packages/evidence/latest-observation
3
3
  import { canonicalCaaRecord, canonicalDsRecord, canonicalMxRecord, canonicalDomainControlRecords, canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
4
4
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
5
5
  import { postureTransferRestriction } from "../packages/evidence/domain-posture-context.mjs";
6
+ import { lookupObservationHostname } from "../packages/evidence/lookup-target.mjs";
6
7
  import { MAX_DOMAIN_CONTROL_INPUT_RECORDS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION } from "../packages/contracts/domain-control-manifest.mjs";
7
8
  import { scanBoundedJson, } from "../packages/analysis/bounded-json.mjs";
8
9
  import { CLI_DOMAIN_CONTROL_REVIEW_INPUT_KEYS, CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_LIMITATIONS, CLI_DOMAIN_CONTROL_REVIEW_SCHEMA, CLI_DOMAIN_CONTROL_REVIEW_VERSION, PUBLIC_CLI_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_CLI_DOMAIN_CONTROL_REVIEW_VERSIONS, DOMAIN_CONTROL_REVIEW_VERSION, MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES, MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH, MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS, MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES, MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS, MIN_DOMAIN_CONTROL_REVIEW_LOOKUPS, } from "../packages/contracts/domain-control-review.mjs";
9
- import { MAX_FLIGHT_RECORDER_VALUES, mergeConcurrentDomainControlFields, } from "../lib/domain-control-flight-recorder.mjs";
10
+ import { MAX_FLIGHT_RECORDER_VALUES, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, mergeConcurrentDomainControlFields, } from "../lib/domain-control-flight-recorder.mjs";
10
11
  import { DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, reviewDomainControlManifest, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
11
12
  import { CliUsageError } from "./errors.mjs";
12
13
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
@@ -113,12 +114,21 @@ function pageIdentityValue(availability) {
113
114
  ].filter((item) => Boolean(item));
114
115
  return components.length ? [components.join(' | ')] : [];
115
116
  }
116
- export function domainControlObservationFromSavedLookup(document) {
117
+ function serviceHostname(document) {
118
+ // Historical saved Lookups collected supporting evidence at the registration
119
+ // domain unless availability retained a more specific scope. Never use query.
120
+ return lookupObservationHostname({ domain: document.registrableDomain, ...record(document.availability) });
121
+ }
122
+ export function domainControlObservationFromSavedLookup(document, domain = document.registrableDomain) {
117
123
  const availability = record(document.availability);
124
+ const observedHostname = serviceHostname(document);
118
125
  const dns = record(availability.dns);
119
126
  const dnsRecords = record(dns.records);
120
127
  const dnsAt = sourceClock(dns.observedAt, dns.checkedAt);
121
128
  const delegation = record(dns.delegation);
129
+ const delegationHostname = delegation.domain === undefined
130
+ ? observedHostname === document.registrableDomain ? observedHostname : null
131
+ : lookupObservationHostname({ domain: delegation.domain });
122
132
  const delegationRecords = record(delegation.records);
123
133
  const tls = record(availability.tls);
124
134
  const tlsState = sourceState(tls);
@@ -134,14 +144,15 @@ export function domainControlObservationFromSavedLookup(document) {
134
144
  const hostname = (value) => canonicalDomainControlRecords([value], 'nameservers')[0] ?? '';
135
145
  const identity = record(availability.pageIdentity);
136
146
  const identityValues = pageIdentityValue(availability);
137
- const fields = [
147
+ const registrationFields = [
138
148
  scalar('registrar', registry.source, registry.sourceState, record(registry.parsed.registrar).name ?? registry.parsed.registrar, registry.observedAt, 300),
139
149
  scalar('registrar_lock', registry.source, registry.sourceState, lockValue(registry.parsed), registry.observedAt, 20),
140
150
  scalar('registry_dnssec', 'Registry RDAP', rdapState, rdapParsed.dnssec, rdapAt, 80),
141
151
  recordField('registry_nameservers', 'Registry RDAP', rdapState, rdapParsed.nameservers, hostname, rdapAt),
142
152
  recordField('whois_nameservers', 'WHOIS', diagnostic(document, 'whois'), record(record(document.whois).parsed).nameservers, hostname, registryClock(document, 'whois')),
153
+ ];
154
+ const serviceFields = [
143
155
  recordField('delegated_nameservers', 'DNS NS', dnsRecordState(dns, 'ns'), dnsRecords.ns, hostname, dnsAt),
144
- recordField('delegation_ds', 'DNS delegation', Object.hasOwn(delegationRecords, 'ds') ? sourceState(delegation) : 'unsupported', delegationRecords.ds, canonicalDsRecord, sourceClock(delegation.observedAt)),
145
156
  recordField('mail_exchangers', 'DNS MX', dnsRecordState(dns, 'mx'), dnsRecords.mx, canonicalMxRecord, dnsAt),
146
157
  recordField('caa_policy', 'DNS CAA', dnsRecordState(dns, 'caa'), dnsRecords.caa, canonicalCaaRecord, dnsAt),
147
158
  scalar('tls_certificate', 'TLS', tlsState, certificate.fingerprintSha256 ?? tls.fingerprintSha256, tlsAt, 128),
@@ -149,8 +160,13 @@ export function domainControlObservationFromSavedLookup(document) {
149
160
  scalar('http_origin', 'HTTP', sourceState(http), http.finalOrigin, sourceClock(http.observedAt, http.checkedAt)),
150
161
  field('page_identity', 'Static page identity', sourceState(identity) === 'observed' && !identityValues.length ? 'partial' : sourceState(identity), identityValues, sourceClock(identity.observedAt)),
151
162
  ];
163
+ const fields = mergeConcurrentDomainControlFields([
164
+ domain === document.registrableDomain ? registrationFields : [],
165
+ domain === observedHostname ? serviceFields : [],
166
+ domain === delegationHostname ? [recordField('delegation_ds', 'DNS delegation', Object.hasOwn(delegationRecords, 'ds') ? sourceState(delegation) : 'unsupported', delegationRecords.ds, canonicalDsRecord, sourceClock(delegation.observedAt))] : [],
167
+ ]);
152
168
  return Object.freeze({
153
- domain: document.registrableDomain, capturedAt: document.generatedAt, collectionDepth: document.mode,
169
+ domain, capturedAt: document.generatedAt, collectionDepth: document.mode,
154
170
  fields: Object.freeze(fields.map((item) => item.state === 'observed' && item.observedAt !== null
155
171
  && Date.parse(item.observedAt) > Date.parse(document.generatedAt) ? Object.freeze({ ...item, state: 'partial' }) : item)),
156
172
  });
@@ -219,19 +235,40 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
219
235
  const lookups = input.lookups.map((item, index) => parseSavedLookupDocument(JSON.stringify(item), { label: `Lookup ${index + 1}` }));
220
236
  const byDomain = new Map();
221
237
  for (const lookup of lookups) {
222
- const group = byDomain.get(lookup.registrableDomain) ?? [];
223
- group.push(lookup);
224
- byDomain.set(lookup.registrableDomain, group);
238
+ const domains = new Set([lookup.registrableDomain, serviceHostname(lookup)]);
239
+ for (const domain of domains) {
240
+ if (domain === null)
241
+ continue;
242
+ const group = byDomain.get(domain) ?? [];
243
+ group.push(lookup);
244
+ byDomain.set(domain, group);
245
+ }
225
246
  }
226
- let ignoredHistoricalLookups = 0;
247
+ const retainedLookups = new Set();
227
248
  const selected = [...byDomain.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([domain, documents]) => {
228
- const cohort = latestObservationCohort(documents, (document) => document.generatedAt);
229
- ignoredHistoricalLookups += cohort.superseded;
230
- const captures = cohort.latest.map((document) => ({ document, observation: domainControlObservationFromSavedLookup(document) }));
249
+ const candidates = documents.map((document) => {
250
+ const observation = domainControlObservationFromSavedLookup(document, domain);
251
+ return { document, observation, reviewFields: reviewFields(document, observation) };
252
+ });
253
+ const currentCaptures = new Set();
254
+ const currentFields = [];
255
+ const currentReviewFields = [];
256
+ for (const id of DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS) {
257
+ const eligible = candidates.filter(candidate => candidate.observation.fields.some(item => item.id === id));
258
+ const latest = latestObservationCohort(eligible, candidate => candidate.document.generatedAt);
259
+ for (const capture of latest.latest) {
260
+ currentCaptures.add(capture);
261
+ retainedLookups.add(capture.document);
262
+ currentFields.push(capture.observation.fields.filter(item => item.id === id));
263
+ currentReviewFields.push(capture.reviewFields.filter(item => item.id === id));
264
+ }
265
+ }
266
+ const captures = [...currentCaptures];
267
+ const cohort = latestObservationCohort(captures, capture => capture.document.generatedAt);
231
268
  const first = captures[0];
232
269
  if (!first || !cohort.observedAt || cohort.undated.length)
233
270
  throw new CliUsageError('Saved Lookup observation times could not be ordered.');
234
- const mergedFields = mergeConcurrentDomainControlFields(captures.map((capture) => capture.observation.fields));
271
+ const mergedFields = mergeConcurrentDomainControlFields(currentFields);
235
272
  const futureCapture = Date.parse(cohort.observedAt) > Date.parse(generatedAt);
236
273
  const observation = Object.freeze({
237
274
  domain,
@@ -240,7 +277,7 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
240
277
  ? first.observation.collectionDepth : 'unknown',
241
278
  fields: futureCapture ? mergedFields.map((field) => ({ ...field, state: 'partial' })) : mergedFields,
242
279
  });
243
- const fields = mergeConcurrentDomainControlFields(captures.map((capture) => reviewFields(capture.document, capture.observation)));
280
+ const fields = mergeConcurrentDomainControlFields(currentReviewFields);
244
281
  return { observation, reviewFields: futureCapture || observation.collectionDepth === 'unknown'
245
282
  ? fields.map((field) => ({ ...field, state: 'partial' })) : fields };
246
283
  });
@@ -268,7 +305,7 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
268
305
  input: Object.freeze({
269
306
  lookupsReceived: lookups.length,
270
307
  latestDomainObservations: observations.length,
271
- ignoredHistoricalLookups,
308
+ ignoredHistoricalLookups: lookups.length - retainedLookups.size,
272
309
  }),
273
310
  limitations: CLI_DOMAIN_CONTROL_REVIEW_LIMITATIONS,
274
311
  });
@@ -1,5 +1,8 @@
1
1
  import { lookupStrictExitFindings } from "./strict-exit.mjs";
2
2
  import { CliUsageError } from "./errors.mjs";
3
+ import { RISK_REVIEW_THRESHOLD } from "../packages/analysis/risk-scoring.mjs";
4
+ import { explainCliLookupRisk } from "./risk-policy.mjs";
5
+ import { DOMAIN_CONTROL_REVIEW_SCHEMA } from "../packages/contracts/domain-control-review.mjs";
3
6
  export const CLI_FAIL_POLICIES = ['source-failure', 'inconclusive', 'danger', 'material-drift'];
4
7
  export const CLI_FAIL_POLICIES_BY_COMMAND = Object.freeze({
5
8
  lookup: Object.freeze(['source-failure', 'inconclusive', 'danger']),
@@ -31,14 +34,7 @@ function availabilityStates(document) {
31
34
  });
32
35
  return [direct, review, ...resultStates].filter(Boolean);
33
36
  }
34
- function riskScore(value) {
35
- const raw = record(value.risk).score ?? value.riskScore;
36
- if (raw === null || raw === undefined || raw === '')
37
- return null;
38
- const parsed = Number(raw);
39
- return Number.isFinite(parsed) ? parsed : null;
40
- }
41
- export function evaluateCliFailPolicies(document, policies) {
37
+ export function evaluateCliFailPolicies(document, policies, profile = null, currentSourceFailureCount = 0) {
42
38
  const root = record(document);
43
39
  const findings = [];
44
40
  for (const policy of policies) {
@@ -50,28 +46,31 @@ export function evaluateCliFailPolicies(document, policies) {
50
46
  const collection = record(root.collection);
51
47
  const summary = record(root.summary);
52
48
  const failed = Number(collection.failed ?? summary.failed ?? 0);
53
- if (sourceFindings.length || failed > 0)
54
- findings.push({ policy, reason: `${sourceFindings.length + failed} failed or incomplete collection state(s)` });
49
+ const failures = sourceFindings.length + failed + currentSourceFailureCount;
50
+ if (failures > 0)
51
+ findings.push({ policy, reason: `${failures} failed or incomplete collection state(s)` });
55
52
  }
56
53
  else if (policy === 'inconclusive') {
57
- const count = availabilityStates(root).filter((state) => ['unknown', 'inconclusive', 'partial', 'unavailable'].includes(state)).length;
54
+ const review = record(root.review);
55
+ const comparisons = review.schema === DOMAIN_CONTROL_REVIEW_SCHEMA;
56
+ const counts = record(review.counts);
57
+ const count = comparisons
58
+ ? Number(counts.partial ?? 0) + Number(counts.unavailable ?? 0) + Number(counts.unsupported ?? 0)
59
+ : availabilityStates(root).filter((state) => ['unknown', 'inconclusive', 'partial', 'unavailable'].includes(state)).length;
58
60
  if (count)
59
- findings.push({ policy, reason: `${count} authority or availability state(s) remained inconclusive` });
61
+ findings.push({ policy, reason: `${count} ${comparisons ? 'requested comparison(s)' : 'authority or availability state(s)'} remained inconclusive` });
60
62
  }
61
63
  else if (policy === 'danger') {
62
64
  const documents = [root, ...(Array.isArray(root.results) ? root.results.map(record) : [])];
63
- const risks = documents.map(riskScore).filter((score) => score !== null);
64
- const severities = documents
65
- .flatMap((item) => [item.severity, record(item.review).severity])
66
- .map((severity) => String(severity || '').toLowerCase())
67
- .filter((severity) => severity === 'danger' || severity === 'critical');
68
- const highestRisk = risks.length ? Math.max(...risks) : null;
69
- if ((highestRisk !== null && highestRisk >= 70) || severities.length) {
65
+ const risks = documents.flatMap(item => {
66
+ const risk = explainCliLookupRisk(item, profile, item.mode ?? root.mode);
67
+ return risk ? [risk] : [];
68
+ });
69
+ const highestRisk = risks.reduce((highest, risk) => (!highest || risk.score > highest.score ? risk : highest), null);
70
+ if (highestRisk && highestRisk.score >= RISK_REVIEW_THRESHOLD) {
70
71
  findings.push({
71
72
  policy,
72
- reason: highestRisk !== null && highestRisk >= 70
73
- ? `risk score ${highestRisk} met the 70 review threshold`
74
- : `explicit ${severities[0]} severity was present`,
73
+ reason: `risk score ${highestRisk.score} met the ${RISK_REVIEW_THRESHOLD} review threshold; model v${highestRisk.modelVersion}; evidence ${highestRisk.evidenceQuality.state} (heuristic, not a verdict)`,
75
74
  });
76
75
  }
77
76
  }
@@ -87,3 +86,13 @@ export function evaluateCliFailPolicies(document, policies) {
87
86
  export function formatFailPolicyNotice(findings) {
88
87
  return `Failure policy matched: ${findings.map((item) => `${item.policy} (${item.reason})`).join('; ')}.\n`;
89
88
  }
89
+ export function reportCliFailPolicies(findings, writeStderr, progress) {
90
+ if (!findings.length)
91
+ return;
92
+ if (!progress.enabled)
93
+ writeStderr(formatFailPolicyNotice(findings));
94
+ else
95
+ for (const finding of findings) {
96
+ progress.emit({ event: 'warning', state: 'failure_policy_matched', source: finding.policy, reason: finding.reason });
97
+ }
98
+ }
@@ -40,6 +40,7 @@ function formatLookupEvidenceHtml(document, options = {}) {
40
40
  <style>
41
41
  :root{color-scheme:dark;--bg:#07110d;--panel:#0d1b15;--panel2:#10241b;--line:#254837;--text:#d8f5e4;--muted:#91b7a0;--accent:#6dff9d;--shadow:rgba(0,0,0,.28)}
42
42
  *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:min(1120px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{border:1px solid var(--line);background:linear-gradient(145deg,var(--panel2),var(--panel));padding:28px;box-shadow:0 18px 50px var(--shadow)}h1,h2,h3{line-height:1.2;margin:0;color:var(--accent)}h1{font-size:clamp(1.5rem,4vw,2.35rem);overflow-wrap:anywhere}h2{font-size:1.22rem;margin-bottom:16px}h3{font-size:1rem;margin-bottom:12px}.eyebrow{margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}.notice{margin:18px 0 0;color:var(--muted);max-width:85ch}.block{margin-top:24px;border:1px solid var(--line);background:var(--panel);padding:22px}.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px;margin:0}.fields div{display:grid;grid-template-columns:minmax(130px,.8fr) minmax(0,1.6fr);gap:12px;border-top:1px solid var(--line);padding:9px 0}.fields div:first-child,.fields div:nth-child(2){border-top:0}.fields dt{color:var(--muted)}.fields dd{margin:0;overflow-wrap:anywhere}.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:16px}.card{border:1px solid var(--line);background:var(--panel2);padding:18px}.card .fields{display:block}.card .fields div{grid-template-columns:minmax(120px,.8fr) minmax(0,1.4fr)}.card .fields div:nth-child(2){border-top:1px solid var(--line)}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;min-width:720px}caption{text-align:left;color:var(--muted);padding:0 0 10px}th,td{border:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:var(--panel2);color:var(--accent)}tbody th{color:var(--text)}.status{white-space:nowrap;color:var(--accent)}.empty,.omission{color:var(--muted)}ul{margin:0;padding-left:22px}li+li{margin-top:8px}footer{margin-top:24px;color:var(--muted);font-size:.88rem;text-align:center}
43
+ footer{overflow-wrap:anywhere}
43
44
  @media(max-width:700px){main{width:min(100% - 20px,1120px);padding-top:16px}header,.block{padding:17px}.fields{display:block}.fields div,.card .fields div{grid-template-columns:1fr;gap:2px}.fields div:nth-child(2){border-top:1px solid var(--line)}}
44
45
  @media print{:root{color-scheme:light;--bg:#fff;--panel:#fff;--panel2:#f5f7f5;--line:#b9c5bd;--text:#17231b;--muted:#46564b;--accent:#155c2f;--shadow:transparent}body{font:10pt/1.42 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}main{width:100%;padding:0}header,.block,.card{box-shadow:none;break-inside:avoid}.block{margin-top:12px}}
45
46
  </style>
@@ -55,6 +56,8 @@ function formatLookupEvidenceHtml(document, options = {}) {
55
56
  <section class="block"><h2>Query</h2>${renderFields(report.query)}</section>
56
57
  <section class="block"><h2>Assessment</h2>${renderFields(report.assessment)}</section>
57
58
  <section class="block"><h2>Registry sources</h2>${renderGroups(report.registryGroups)}</section>
59
+ ${report.registrarStanding.length ? `<section class="block"><h2>Registrar standing</h2>${renderFields(report.registrarStanding)}</section>` : ''}
60
+ ${report.registryInterpretation.length ? `<section class="block"><h2>Registry interpretation</h2>${renderFields(report.registryInterpretation)}</section>` : ''}
58
61
  <section class="block"><h2>Registry-source comparison</h2>${renderFields(report.comparison.health)}${renderComparison(report.comparison)}</section>
59
62
  ${report.registrarComparison.fields.length ? `<section class="block"><h2>Registry / registrar RDAP comparison</h2>${renderFields(report.registrarComparison.health)}${renderRegistrarComparison(report.registrarComparison)}</section>` : ''}
60
63
  <section class="block"><h2>Network evidence</h2>${renderGroups(report.networkGroups)}</section>
@@ -69,7 +69,14 @@ function versionedResult(result, schema, version, generatedAt, requestedField, r
69
69
  return { ...result, schema, version, generatedAt, [requestedField]: requestedValue };
70
70
  }
71
71
  function buildCliPostureDocument(requestedDomain, report, generatedAt = new Date().toISOString()) {
72
- return versionedResult(report, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
72
+ const checks = report.checks;
73
+ const retained = Array.isArray(checks) ? { ...report, checks: checks.map(check => {
74
+ if (!check || typeof check !== 'object' || Array.isArray(check))
75
+ return check;
76
+ const { dmarcPolicyContext: _transient, ...fields } = check;
77
+ return fields;
78
+ }) } : report;
79
+ return versionedResult(retained, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
73
80
  }
74
81
  function buildCliHttpDocument(requestedDomain, result, generatedAt = new Date().toISOString()) {
75
82
  return versionedResult(result, CLI_HTTP_SCHEMA, CLI_HTTP_SCHEMA_VERSION, generatedAt, 'requestedDomain', requestedDomain);
@@ -1,5 +1,7 @@
1
1
  import { registryAccessProfileLabel } from "../registry-access.mjs";
2
+ import { isSupportedRegistryInsightsVersion } from "../../packages/contracts/registry-insights.mjs";
2
3
  import { dnsAddressFamilyEvidence } from "../../packages/evidence/dns-query-outcome.mjs";
4
+ import { lookupCollectionReceiptReview } from "../../packages/evidence/lookup-collection-review.mjs";
3
5
  import { appendTechnologyLines } from "./terminal-technology.mjs";
4
6
  import { appendDeliveryMetadataLines, appendPublicationMetadataLines } from "./terminal-metadata.mjs";
5
7
  import { securityPostureReview, summarizeSecurityPostureReview } from "../../lib/website-security-posture.mjs";
@@ -252,6 +254,14 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
252
254
  `RDAP ${titleCase(rdapDiagnostics.status)}`,
253
255
  `WHOIS ${titleCase(whoisDiagnostics.status)}`,
254
256
  ];
257
+ if (detail !== 'summary') {
258
+ for (const [source, status] of [['rdap', rdapDiagnostics.status], ['whois', whoisDiagnostics.status]]) {
259
+ const sourceDetail = terminalRecord(document[source]).detail;
260
+ if (['unsupported', 'skipped', 'disabled'].includes(String(status)) && typeof sourceDetail === 'string' && sourceDetail) {
261
+ registrationLines.push(`${source.toUpperCase()} detail ${boundedTerminalComponent(sourceDetail, 300)}`);
262
+ }
263
+ }
264
+ }
255
265
  if (detail !== 'summary' && rdapDiagnostics.endpoint) {
256
266
  registrationLines.splice(1, 0, `RDAP source ${safeTerminalValue(rdapDiagnostics.endpoint)}`);
257
267
  }
@@ -267,7 +277,7 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
267
277
  appendNetworkRegistrationLines(registrationLines, terminalRecord(rdap.parsed), document.type, detail);
268
278
  }
269
279
  const registryInsights = terminalRecord(document.registryInsights);
270
- if (detail !== 'summary' && registryInsights.version === 1) {
280
+ if (detail !== 'summary' && isSupportedRegistryInsightsVersion(registryInsights.version)) {
271
281
  const lifecycle = terminalRecord(registryInsights.lifecycle);
272
282
  const disclosure = terminalRecord(registryInsights.contactDisclosure);
273
283
  const registryDisclosure = terminalRecord(disclosure.registryRdap);
@@ -712,6 +722,19 @@ function formatTerminalLookup(document, { detail = 'standard' } = {}) {
712
722
  const collectionLines = [];
713
723
  if (detail === 'verbose') {
714
724
  collectionLines.push(`Generated ${safeTerminalValue(document.generatedAt, 'unknown')}`);
725
+ const receipt = lookupCollectionReceiptReview(document.collectionReceipt);
726
+ if (receipt) {
727
+ collectionLines.push(`Original plan ${receipt.selection}`);
728
+ for (const choice of receipt.choices)
729
+ collectionLines.push(`${choice.label}: ${choice.selected ? 'selected' : 'not selected'}`);
730
+ for (const source of receipt.sources) {
731
+ collectionLines.push(`${source.label}: ${source.summary}; ${source.outcome}`);
732
+ collectionLines.push(` ${source.explanation}`);
733
+ }
734
+ collectionLines.push('Collector start is local execution provenance, not proof of network delivery or complete evidence.');
735
+ }
736
+ else
737
+ collectionLines.push('Original plan Not retained; original optional choices and collector starts are unknown.');
715
738
  const timing = terminalRecord(document.timing);
716
739
  if (Number(timing.totalMs) >= 0)
717
740
  collectionLines.push(`Total time ${safeTerminalValue(timing.totalMs)} ms`);
@@ -65,7 +65,7 @@ export const HISTORY_COMMAND_DEFINITIONS = Object.freeze({
65
65
  reference: {
66
66
  description: 'Convert one saved lookup into a versioned evidence report.',
67
67
  example: 'whoisleuth export lookup.json --markdown',
68
- boundary: `Saved Lookup versions 1 and 2 are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schemas ${PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
68
+ boundary: `Supported saved Lookups are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schemas ${PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
69
69
  },
70
70
  collection: { scope: 'Reads one saved Lookup and writes one bounded report.' },
71
71
  summary: 'Convert a lookup to an evidence report',
@@ -75,6 +75,7 @@ export function formatWorkflowRecipeCatalogue(catalogue) {
75
75
  `${recipe.id} — ${recipe.label}`,
76
76
  ` Subject: ${recipe.subjectRequirement.replaceAll('_', ' ')}`,
77
77
  ` Workflow run: ${recipe.runnableByWorkflowRun ? 'supported' : 'plan and explanation only'}`,
78
+ ...formatWorkflowEffort(recipe.id, recipe.steps).map((line) => ` ${line}`),
78
79
  ` ${recipe.objective}`,
79
80
  ...recipe.steps.map((item, index) => ` ${index + 1}. ${item.label} [${item.mode}; ${item.approval.replaceAll('_', ' ')}]`),
80
81
  ...recipe.limitations.map((item) => ` Limitation: ${item}`),
@@ -84,6 +85,19 @@ export function formatWorkflowRecipeCatalogue(catalogue) {
84
85
  '',
85
86
  ].join('\n');
86
87
  }
88
+ function formatWorkflowEffort(recipeId, steps) {
89
+ const counts = { network: 0, offline: 0 };
90
+ for (const step of steps)
91
+ counts[step.mode] += 1;
92
+ const reuse = workflowStandardInputs(recipeId).length;
93
+ return [
94
+ `Work planned: ${counts.network} networked step${counts.network === 1 ? '' : 's'}; ${counts.offline} offline step${counts.offline === 1 ? '' : 's'}.`,
95
+ reuse
96
+ ? `Evidence reuse: ${reuse} link${reuse === 1 ? '' : 's'} from earlier outputs to later steps.`
97
+ : 'Evidence reuse: no earlier-output links are declared.',
98
+ 'Counts describe steps and reuse links, not requests. Saved files and cache state are not checked.',
99
+ ];
100
+ }
87
101
  function validatedSteps(steps) {
88
102
  for (const step of steps) {
89
103
  const invocationEffect = cliInvocationNetworkEffect(step.command, step.arguments);
@@ -147,6 +161,7 @@ export function formatInvestigationPlan(plan) {
147
161
  `Investigation plan: ${plan.recipe.label}`,
148
162
  `Subject ${plan.subject}`,
149
163
  `Mode ${plan.execution}`,
164
+ ...formatWorkflowEffort(plan.recipe.id, plan.steps),
150
165
  '',
151
166
  ...plan.steps.flatMap((item, index) => [
152
167
  `${index + 1}. ${item.label}`,
@@ -214,23 +214,29 @@ export function buildCliLookupBrief(input, generatedAt = new Date().toISOString(
214
214
  });
215
215
  }
216
216
  export function formatCliLookupBrief(document) {
217
+ const sourceFacts = document.sourceHealth.flatMap((source) => {
218
+ const facts = document.facts.filter((item) => item.source.id === source.id);
219
+ return [
220
+ ` ${safeTerminalValue(source.label)} [${safeTerminalValue(source.state)}; ${source.observedAt ? `observed ${safeTerminalValue(source.observedAt)}` : 'source time not recorded'}]`,
221
+ ...(facts.length
222
+ ? facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)}`)
223
+ : [' No fact selected for this brief.']),
224
+ ];
225
+ });
217
226
  return [
218
227
  `Lookup brief: ${safeTerminalValue(document.target)}`,
219
228
  `Observed ${safeTerminalValue(document.observedAt)}`,
220
229
  `Mode ${safeTerminalValue(document.mode)}`,
221
230
  '',
222
231
  'Source-attributed facts',
223
- ...(document.facts.length ? document.facts.map((item) => ` ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.value)} (${safeTerminalValue(item.source.label)}; ${safeTerminalValue(item.source.state)}; ${item.source.observedAt ? `observed ${safeTerminalValue(item.source.observedAt)}` : 'source time not recorded'})`) : [' No bounded fact was available.']),
232
+ ...sourceFacts,
224
233
  '',
225
234
  'Contradictions',
226
235
  ...(document.contradictions.length ? document.contradictions.flatMap((item) => [
227
236
  ` ${safeTerminalValue(item.field)}: ${safeTerminalValue(item.detail)}`,
228
- ...item.observations.map((observation) => ` ${safeTerminalValue(observation.source.label)}: ${safeTerminalValue(observation.value)} (${safeTerminalValue(observation.state)}; ${observation.source.observedAt ? `observed ${safeTerminalValue(observation.source.observedAt)}` : 'source time not recorded'})`),
237
+ ...item.observations.map((observation) => ` ${safeTerminalValue(observation.source.label)}: ${safeTerminalValue(observation.value)} (value state: ${safeTerminalValue(observation.state)}; source state: ${safeTerminalValue(observation.source.state)}; ${observation.source.observedAt ? `observed ${safeTerminalValue(observation.source.observedAt)}` : 'source time not recorded'})`),
229
238
  ]) : [' No conflicting publication field was retained.']),
230
239
  '',
231
- 'Unknown or incomplete',
232
- ...(document.unknowns.length ? document.unknowns.map((item) => ` ${safeTerminalValue(item)}`) : [' No incomplete source state was identified.']),
233
- '',
234
240
  'Recommended manual actions',
235
241
  ...document.actionPlan.flatMap((item, index) => [
236
242
  ` ${index + 1}. ${safeTerminalValue(item.action)}`,
@@ -13,7 +13,8 @@ import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
13
13
  import { createCliProgressEvents } from "./progress-events.mjs";
14
14
  import { serializeCliLookupDocument } from "./saved-lookup.mjs";
15
15
  import { lookupStrictExitFindings } from "./strict-exit.mjs";
16
- import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
16
+ import { evaluateCliFailPolicies, reportCliFailPolicies } from "./fail-policy.mjs";
17
+ import { readCliRiskProfile } from "./risk-policy.mjs";
17
18
  import { formatCliJunit } from "./ci-report.mjs";
18
19
  import { browseLookupOperation, canBrowseLookup } from "./lookup-browser.mjs";
19
20
  import { writePrivateFile } from "./output-file.mjs";
@@ -66,26 +67,34 @@ async function runLookupCommand(args, dependencies, context) {
66
67
  : context.terminal(formatCliLookupPlan(plan), args.color));
67
68
  return EXIT_CODES.SUCCESS;
68
69
  }
70
+ const riskProfile = await readCliRiskProfile(args.brandProfileSource, context.readInput, args.brandProfileId);
69
71
  let documentGeneratedAt = '';
70
- const buildDocument = (result) => {
72
+ const buildDocument = ({ result, collectionReceipt }) => {
71
73
  documentGeneratedAt = context.now();
72
74
  return buildCliLookupDocument(query, classified, result, documentGeneratedAt, args.deep ? 'deep' : 'fast', {
75
+ collectionReceipt,
73
76
  ...(args.observerLabel ? { observerLabel: args.observerLabel } : {}),
74
77
  ...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
75
78
  });
76
79
  };
77
- const collect = (signal, onSourceSettled) => abortable(() => executeLookup(classified, {
78
- fast: !args.deep,
79
- compact: false,
80
- ...(signal ? { signal } : {}),
81
- ...(args.deep ? {
82
- ...(selectedUrl ? { selectedUrl } : {}),
83
- onSourceSettled: settlement => {
84
- onSourceSettled(settlement);
85
- eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
86
- },
87
- } : {}),
88
- }), signal);
80
+ const collect = (signal, onSourceSettled) => abortable(async () => {
81
+ let collectionReceipt;
82
+ const result = await executeLookup(classified, {
83
+ fast: !args.deep,
84
+ compact: false,
85
+ onCollectionReceipt: value => { collectionReceipt = value; },
86
+ ...(signal ? { signal } : {}),
87
+ ...(args.deep ? {
88
+ ...(selectedUrl ? { selectedUrl } : {}),
89
+ onSourceSettled: settlement => {
90
+ onSourceSettled(settlement);
91
+ eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
92
+ },
93
+ } : {}),
94
+ });
95
+ signal?.throwIfAborted();
96
+ return { result, collectionReceipt };
97
+ }, signal);
89
98
  let document;
90
99
  if (args.browse === true) {
91
100
  const browse = dependencies.browseLookupOperation || browseLookupOperation;
@@ -132,6 +141,8 @@ async function runLookupCommand(args, dependencies, context) {
132
141
  }
133
142
  document = buildDocument(result);
134
143
  }
144
+ const strictFindings = args.strictExit ? lookupStrictExitFindings(document) : [];
145
+ const policyFindings = evaluateCliFailPolicies(document, args.failOn || [], riskProfile);
135
146
  if (!args.quiet) {
136
147
  if (args.output === 'json')
137
148
  context.writeStdout(serializeCliLookupDocument(document));
@@ -149,14 +160,11 @@ async function runLookupCommand(args, dependencies, context) {
149
160
  context.writeStdout(context.terminal(formatTerminalLookup(document, { detail: args.detail }), args.color));
150
161
  }
151
162
  }
152
- const strictFindings = args.strictExit ? lookupStrictExitFindings(document) : [];
153
- const policyFindings = evaluateCliFailPolicies(document, args.failOn || []);
154
163
  const exitCode = strictFindings.length || policyFindings.length ? EXIT_CODES.PARTIAL_FAILURE : EXIT_CODES.SUCCESS;
155
164
  if (strictFindings.length && !args.events) {
156
165
  context.writeStderr(`Strict exit: ${strictFindings.length} requested source state${strictFindings.length === 1 ? '' : 's'} were incomplete.\n`);
157
166
  }
158
- if (policyFindings.length && !args.events)
159
- context.writeStderr(formatFailPolicyNotice(policyFindings));
167
+ reportCliFailPolicies(policyFindings, context.writeStderr, eventProgress);
160
168
  eventProgress.emit({ event: 'completed', exitCode });
161
169
  return exitCode;
162
170
  }
@@ -83,6 +83,7 @@ function lookupComparisonInput(document) {
83
83
  trusted: null,
84
84
  error: '',
85
85
  scanDepth: document.mode,
86
+ webCollectionQuality: availability.webCollectionQuality,
86
87
  createdDate: lifecycleValue(preferredRegistry, 'createdDate'),
87
88
  expiryDate: lifecycleValue(preferredRegistry, 'expiryDate'),
88
89
  nameservers: relationship.nameservers.length ? relationship.nameservers : stringList(preferredRegistry.nameservers, 20),