@slicedearth/whoisleuth-cli 1.34.0 → 1.39.1

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 (131) hide show
  1. package/README.md +19 -2
  2. package/bin/whoisleuth.mjs +41 -24
  3. package/cli/arguments.mjs +326 -3
  4. package/cli/artifact-verify.mjs +60 -14
  5. package/cli/bulk.mjs +1 -2
  6. package/cli/compare.mjs +1 -1
  7. package/cli/completion.mjs +36 -2
  8. package/cli/doctor.mjs +11 -5
  9. package/cli/evidence-command-arguments.mjs +1 -1
  10. package/cli/evidence-command-runner.mjs +2 -2
  11. package/cli/evidence-signing.mjs +4 -4
  12. package/cli/export-evidence.mjs +9 -2
  13. package/cli/formatters/html.mjs +1 -1
  14. package/cli/formatters/json.mjs +13 -1
  15. package/cli/formatters/terminal.mjs +14 -4
  16. package/cli/investigation-plan.mjs +112 -0
  17. package/cli/local-mmdb-review.mjs +76 -0
  18. package/cli/lookalike-calibration.mjs +121 -0
  19. package/cli/lookup-command-runner.mjs +4 -1
  20. package/cli/lookup-plan.mjs +3 -3
  21. package/cli/lookup-reconcile.mjs +141 -0
  22. package/cli/lookup-timeline.mjs +1 -1
  23. package/cli/mail-review.mjs +48 -3
  24. package/cli/offline-evidence-review.mjs +159 -0
  25. package/cli/registry-doctor.mjs +125 -0
  26. package/cli/registry-support.mjs +1 -4
  27. package/cli/risk-calibration.mjs +117 -9
  28. package/cli/runner.mjs +342 -15
  29. package/cli/saved-lookup.mjs +2 -2
  30. package/cli/sharing-review.mjs +202 -0
  31. package/cli/source-reliability.mjs +1 -1
  32. package/docs/cli-reference.md +1110 -0
  33. package/docs/cli.md +224 -847
  34. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  35. package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
  36. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  37. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  38. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  39. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  40. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  41. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  42. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  43. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  44. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  45. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  46. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  47. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  48. package/frontend/src/lib/analysis/http-summary.js +2 -1
  49. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  50. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  51. package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
  52. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  53. package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
  54. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  55. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  56. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  57. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  58. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  59. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  60. package/frontend/src/lib/analysis/scoring.js +8 -88
  61. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  62. package/frontend/src/lib/analysis/utils.js +7 -40
  63. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  64. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  65. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  66. package/frontend/src/lib/analysis/visualization-models.js +370 -0
  67. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  68. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  69. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  70. package/frontend/src/lib/analysis/workspace-archive.js +2 -2
  71. package/lib/analyst-taxonomy.mjs +40 -0
  72. package/lib/availability.mjs +30 -10
  73. package/lib/browser-library-profile.mjs +15 -2
  74. package/lib/bulk-limits.mjs +13 -0
  75. package/lib/certificate-extension-profile.mjs +71 -0
  76. package/lib/credential-surface-profile.mjs +1 -1
  77. package/lib/ct-response-bounds.mjs +9 -0
  78. package/lib/ct-search.mjs +66 -12
  79. package/lib/dns-delegation-health.mjs +56 -5
  80. package/lib/dnssec-evidence-validation.mjs +235 -0
  81. package/lib/domain-assurance.mjs +368 -0
  82. package/lib/domain-control-manifest.mjs +382 -0
  83. package/lib/domain-posture.mjs +11 -10
  84. package/lib/encrypted-dns-contract.mjs +81 -0
  85. package/lib/evidence-export.mjs +17 -3
  86. package/lib/evidence-report-markdown.mjs +1 -1
  87. package/lib/evidence-report.mjs +4 -1
  88. package/lib/favicon.mjs +3 -8
  89. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  90. package/lib/html-signals.mjs +4 -0
  91. package/lib/http-evidence-bounds.mjs +4 -0
  92. package/lib/http-intelligence.mjs +2 -1
  93. package/lib/ip-prefix.mjs +87 -0
  94. package/lib/local-geoip-evidence.mjs +89 -0
  95. package/lib/lookup-response-contract.mjs +346 -0
  96. package/lib/observed-network-context.mjs +1 -1
  97. package/lib/opportunity-scoring.mjs +107 -0
  98. package/lib/outbound-identity.mjs +9 -0
  99. package/lib/outbound-request-bounds.mjs +9 -0
  100. package/lib/page-fingerprints.mjs +1 -1
  101. package/lib/perceptual-hash-comparison.mjs +34 -0
  102. package/lib/perceptual-hash.mjs +3 -31
  103. package/lib/project-metadata.mjs +8 -0
  104. package/lib/provider-policy-admission.mjs +49 -0
  105. package/lib/rdap-capabilities.mjs +6 -6
  106. package/lib/rdap-search-workbench.mjs +247 -0
  107. package/lib/rdap.mjs +7 -2
  108. package/lib/registry-access.mjs +1 -1
  109. package/lib/registry-capabilities.mjs +31 -1
  110. package/lib/registry-capability-catalogue.mjs +15 -14
  111. package/lib/registry-insights.mjs +6 -6
  112. package/lib/response-policy.mjs +81 -6
  113. package/lib/risk-scoring.mjs +206 -83
  114. package/lib/rpki-evidence.mjs +105 -0
  115. package/lib/safe-fetch.mjs +2 -1
  116. package/lib/scoring-evidence-quality.mjs +107 -0
  117. package/lib/security-txt.mjs +3 -3
  118. package/lib/semantic-version.mjs +54 -0
  119. package/lib/static-html-analysis.mjs +27 -0
  120. package/lib/threat-intelligence-manifests.mjs +1 -1
  121. package/lib/threatfox-intelligence.mjs +11 -6
  122. package/lib/tls-intelligence.mjs +13 -3
  123. package/lib/tlsa-evidence.mjs +194 -0
  124. package/lib/urlhaus-intelligence.mjs +11 -6
  125. package/lib/urlscan-intelligence.mjs +10 -5
  126. package/lib/website-security-posture.mjs +16 -9
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-chain.mjs +14 -152
  129. package/lib/whois.mjs +2 -2
  130. package/package.json +6 -2
  131. package/third-party-notices.txt +911 -19
@@ -72,7 +72,7 @@ export async function buildAcquisitionDecisionPacket(input) {
72
72
  },
73
73
  evidenceReview: input.review,
74
74
  limitations: [
75
- 'This local artifact records an analyst review of bounded point-in-time evidence and does not submit, reserve, value, purchase, or transfer a domain.',
75
+ 'This local artefact records an analyst review of bounded point-in-time evidence and does not submit, reserve, value, purchase, or transfer a domain.',
76
76
  'A reviewed state records completion of the displayed manual checklist, not the accuracy of external statements or a legal, financial, eligibility, or ownership determination.',
77
77
  'Refresh authoritative registration and policy evidence immediately before acting.',
78
78
  ...(input.synthetic === true
@@ -102,7 +102,7 @@ function lifecycleItem(availability, lifecycle, publications) {
102
102
  label: 'Lifecycle date observed',
103
103
  state: expiresInDays !== null && expiresInDays <= 30 ? 'review' : 'observed',
104
104
  detail: timing,
105
- provenance: 'Normalized RDAP or WHOIS lifecycle',
105
+ provenance: 'Normalised RDAP or WHOIS lifecycle',
106
106
  };
107
107
  }
108
108
  return {
@@ -110,7 +110,7 @@ function lifecycleItem(availability, lifecycle, publications) {
110
110
  label: completePublication ? 'Expiry date not published' : 'Lifecycle timing unavailable',
111
111
  state: completePublication ? 'observed' : 'unavailable',
112
112
  detail: completePublication
113
- ? 'A usable publication was collected without a normalized expiry date. Registry policy and renewal state still require manual confirmation.'
113
+ ? 'A usable publication was collected without a normalised expiry date. Registry policy and renewal state still require manual confirmation.'
114
114
  : 'Incomplete or unavailable registry publications cannot establish an expiry or release window.',
115
115
  provenance: 'Registry publication quality',
116
116
  };
@@ -135,7 +135,7 @@ function transferItem(lifecycle, publications) {
135
135
  id: 'transfer',
136
136
  label: 'No interpreted lock status observed',
137
137
  state: 'review',
138
- detail: 'The collected statuses did not contain a recognized client or server lock. This does not prove transfer eligibility or that other policy restrictions are absent.',
138
+ detail: 'The collected statuses did not contain a recognised client or server lock. This does not prove transfer eligibility or that other policy restrictions are absent.',
139
139
  provenance: 'EPP status interpretation',
140
140
  };
141
141
  }
@@ -307,7 +307,7 @@ function registryPolicyChecks(availability, lifecycle, publications) {
307
307
  detail: statuses.length
308
308
  ? `The collected publication included ${statuses.slice(0, 6).join(', ')}${statuses.length > 6 ? ', …' : ''}. Confirm current registry timing and restoration or deletion rules; WHOISleuth does not predict release.`
309
309
  : publicationAvailable
310
- ? 'No recognized lifecycle status was retained. Confirm renewal, grace, redemption, restoration, and deletion rules with the current registry or registrar.'
310
+ ? 'No recognised lifecycle status was retained. Confirm renewal, grace, redemption, restoration, and deletion rules with the current registry or registrar.'
311
311
  : 'No usable registry publication was available for a current lifecycle-policy review.',
312
312
  provenance: 'Separately attributed RDAP and WHOIS lifecycle publications',
313
313
  },
@@ -316,9 +316,9 @@ function registryPolicyChecks(availability, lifecycle, publications) {
316
316
  label: locks.client === true || locks.server === true ? 'Confirm observed transfer constraints' : 'Confirm transfer and change-of-control requirements',
317
317
  state: publicationAvailable ? 'review' : 'unavailable',
318
318
  detail: locks.client === true || locks.server === true
319
- ? 'Recognized client or server lock evidence was observed. Confirm authorization codes, lock removal, waiting periods, identity checks, and any registrar change-of-control process.'
319
+ ? 'Recognised client or server lock evidence was observed. Confirm authorisation codes, lock removal, waiting periods, identity checks, and any registrar change-of-control process.'
320
320
  : publicationAvailable
321
- ? 'No recognized lock was retained, but that does not establish transfer eligibility. Confirm authorization, waiting periods, identity checks, and registrar requirements.'
321
+ ? 'No recognised lock was retained, but that does not establish transfer eligibility. Confirm authorisation, waiting periods, identity checks, and registrar requirements.'
322
322
  : 'No usable status publication was available to describe current transfer constraints.',
323
323
  provenance: 'EPP status interpretation and manual policy requirements',
324
324
  },
@@ -168,32 +168,32 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
168
168
  .some((value) => ['conflict', 'conflicting'].includes(value.toLowerCase()));
169
169
  const rows = [
170
170
  row('registration', 'registration', 'Registration', left.availability, right.availability, 'Authority-aware compact availability state', registry, [], { conflicting: registrationConflicting }),
171
- row('registrar', 'registration', 'Registrar', left.registrar, right.registrar, 'Normalized registrar display identity', registry),
171
+ row('registrar', 'registration', 'Registrar', left.registrar, right.registrar, 'Normalised registrar display identity', registry),
172
172
  row('created', 'lifecycle', 'Created', left.createdDate, right.createdDate, 'Source-reported lifecycle date', registry),
173
173
  row('expires', 'lifecycle', 'Expires', left.expiryDate, right.expiryDate, 'Source-reported lifecycle date', registry),
174
- row('nameservers', 'infrastructure', 'Nameservers', left.nameservers, right.nameservers, 'Normalized exact set comparison', registry),
175
- row('dns-a', 'dns', 'A records', dnsValues(left, 'a'), dnsValues(right, 'a'), 'Normalized exact retained DNS answer set', dns),
176
- row('dns-aaaa', 'dns', 'AAAA records', dnsValues(left, 'aaaa'), dnsValues(right, 'aaaa'), 'Normalized exact retained DNS answer set', dns),
177
- row('dns-cname', 'dns', 'CNAME records', dnsValues(left, 'cname'), dnsValues(right, 'cname'), 'Normalized exact retained DNS answer set', dns),
178
- row('dns-caa', 'dns', 'CAA records', dnsValues(left, 'caa'), dnsValues(right, 'caa'), 'Normalized exact retained DNS answer set', dns),
174
+ row('nameservers', 'infrastructure', 'Nameservers', left.nameservers, right.nameservers, 'Normalised exact set comparison', registry),
175
+ row('dns-a', 'dns', 'A records', dnsValues(left, 'a'), dnsValues(right, 'a'), 'Normalised exact retained DNS answer set', dns),
176
+ row('dns-aaaa', 'dns', 'AAAA records', dnsValues(left, 'aaaa'), dnsValues(right, 'aaaa'), 'Normalised exact retained DNS answer set', dns),
177
+ row('dns-cname', 'dns', 'CNAME records', dnsValues(left, 'cname'), dnsValues(right, 'cname'), 'Normalised exact retained DNS answer set', dns),
178
+ row('dns-caa', 'dns', 'CAA records', dnsValues(left, 'caa'), dnsValues(right, 'caa'), 'Normalised exact retained DNS answer set', dns),
179
179
  row('dnssec', 'dns', 'DNSSEC state', left.dnssec, right.dnssec, 'Separately attributed compact DNSSEC observation', dns),
180
- row('ip-addresses', 'infrastructure', 'Observed IP addresses', left.relationship.ipAddresses, right.relationship.ipAddresses, 'Normalized exact set comparison', dns, ['Shared hosting and CDNs are common and do not prove common control.']),
180
+ row('ip-addresses', 'infrastructure', 'Observed IP addresses', left.relationship.ipAddresses, right.relationship.ipAddresses, 'Normalised exact set comparison', dns, ['Shared hosting and CDNs are common and do not prove common control.']),
181
181
  row('tls-source', 'certificate', 'TLS collection', tls.leftSourceState, tls.rightSourceState, 'Retained compact source-state comparison', tls),
182
182
  row('certificate', 'certificate', 'Leaf certificate fingerprint', left.relationship.certificateFingerprint, right.relationship.certificateFingerprint, 'Exact SHA-256 comparison', tls, ['Multi-domain certificates and managed hosting are common.']),
183
- row('tls-issuer', 'certificate', 'TLS issuer label', left.comparisonEvidence?.tls.issuerLabel ?? null, right.comparisonEvidence?.tls.issuerLabel ?? null, 'Normalized retained issuer-label comparison', compactTls, ['Issuer labels are certificate metadata and do not establish common ownership or control.']),
183
+ row('tls-issuer', 'certificate', 'TLS issuer label', left.comparisonEvidence?.tls.issuerLabel ?? null, right.comparisonEvidence?.tls.issuerLabel ?? null, 'Normalised retained issuer-label comparison', compactTls, ['Issuer labels are certificate metadata and do not establish common ownership or control.']),
184
184
  row('tls-spki', 'certificate', 'TLS public-key fingerprint', left.comparisonEvidence?.tls.spkiSha256 ?? null, right.comparisonEvidence?.tls.spkiSha256 ?? null, 'Exact SPKI SHA-256 comparison', compactTls, ['Public-key reuse can be an investigative lead but does not establish ownership, intent, safety, or maliciousness.']),
185
185
  row('mail', 'mail', 'MX observed', left.hasMx, right.hasMx, 'Compact Deep DNS observation', dns),
186
186
  row('null-mx', 'mail', 'Null MX observed', left.hasNullMx, right.hasNullMx, 'Compact Deep DNS observation', dns),
187
187
  row('spf', 'mail', 'SPF observed', left.hasSpf, right.hasSpf, 'Compact Deep DNS observation', dns),
188
188
  row('dmarc', 'mail', 'DMARC observed', left.hasDmarc, right.hasDmarc, 'Compact Deep DNS observation', dns),
189
189
  row('website', 'web', 'Website activity', left.activityStatus, right.activityStatus, 'Bounded homepage observation', http),
190
- row('page-title', 'identity', 'Page title', left.pageTitle, right.pageTitle, 'Normalized bounded title equality', http),
190
+ row('page-title', 'identity', 'Page title', left.pageTitle, right.pageTitle, 'Normalised bounded title equality', http),
191
191
  row('favicon', 'identity', 'Favicon fingerprint', left.faviconHash, right.faviconHash, 'Exact SHA-256 comparison', http),
192
- row('tracking', 'identity', 'Tracking identifiers', left.relationship.trackingIdentifiers, right.relationship.trackingIdentifiers, 'Normalized exact set comparison', http, ['Shared identifiers are an investigative lead, not proof of ownership.']),
192
+ row('tracking', 'identity', 'Tracking identifiers', left.relationship.trackingIdentifiers, right.relationship.trackingIdentifiers, 'Normalised exact set comparison', http, ['Shared identifiers are an investigative lead, not proof of ownership.']),
193
193
  row('password-field', 'identity', 'Password field', left.hasPasswordField, right.hasPasswordField, 'Bounded static form observation', http),
194
194
  row('phishing-language', 'identity', 'Phishing-language indicator', left.phishingLanguageMatch, right.phishingLanguageMatch, 'Bounded explainable page-language signal', http, ['A wording match is a review lead, not proof of malicious intent.']),
195
195
  row('official-assets', 'identity', 'Official asset reuse', left.reusesOfficialAssets, right.reusesOfficialAssets, 'Configured Brand Profile comparison', http),
196
- row('technology', 'technology', 'Technology identifiers', left.comparisonEvidence?.technology.ids ?? [], right.comparisonEvidence?.technology.ids ?? [], 'Normalized exact set comparison of at most 12 curated identifiers', compactTechnology, ['An unmatched identifier is not evidence that a technology is absent. Shared technologies do not establish common ownership or control.']),
196
+ row('technology', 'technology', 'Technology identifiers', left.comparisonEvidence?.technology.ids ?? [], right.comparisonEvidence?.technology.ids ?? [], 'Normalised exact set comparison of at most 12 curated identifiers', compactTechnology, ['An unmatched identifier is not evidence that a technology is absent. Shared technologies do not establish common ownership or control.']),
197
197
  row('source-health', 'source', 'Recorded source coverage', sourceSummary(left), sourceSummary(right), 'Source-by-source compact state comparison', source, ['A source-state difference can reflect collection conditions rather than a domain change.']),
198
198
  ];
199
199
  const counts = {
@@ -216,7 +216,7 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
216
216
  counts,
217
217
  limitations: options.allowSameDomain
218
218
  ? [
219
- 'This compares normalized evidence already present in two saved observations for the same domain and makes no new request.',
219
+ 'This compares normalised evidence already present in two saved observations for the same domain and makes no new request.',
220
220
  'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
221
221
  'An observed difference can reflect changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
222
222
  ]
@@ -169,7 +169,7 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
169
169
  limitations: [
170
170
  'This review uses compact Bulk evidence already collected and makes no additional request.',
171
171
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
172
- 'SMTP delivery, mailbox existence, catch-all behavior, and message acceptance were not tested.',
172
+ 'SMTP delivery, mailbox existence, catch-all behaviour, and message acceptance were not tested.',
173
173
  ],
174
174
  };
175
175
  }
@@ -1,13 +1,7 @@
1
1
  import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
2
+ import { limitedBulkSources } from "./bulk-source-coverage.js";
2
3
  export const MAX_BULK_RETRY_ROWS = 200;
3
4
  export const BULK_REVIEW_STALE_AFTER_DAYS = 7;
4
- const LIMITED_STATES = new Set([
5
- 'error',
6
- 'partial',
7
- 'skipped',
8
- 'unavailable',
9
- 'unsupported',
10
- ]);
11
5
  function freshness(value, now = Date.now()) {
12
6
  if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
13
7
  return { observedAt: null, ageDays: null, state: 'unknown' };
@@ -21,9 +15,7 @@ function freshness(value, now = Date.now()) {
21
15
  };
22
16
  }
23
17
  function retryRow(value, isStale) {
24
- const limitedSources = value.sourceCoverage
25
- .filter((item) => LIMITED_STATES.has(item.state))
26
- .map((item) => item.source)
18
+ const limitedSources = limitedBulkSources(value.domain, value.sourceCoverage)
27
19
  .sort()
28
20
  .slice(0, 12);
29
21
  const reasons = [];
@@ -56,6 +48,7 @@ export function buildBulkRetryPlan(values, mode, observedAtRaw = null, now = Dat
56
48
  'This repeats the selected lookup profile; the current API does not request only one upstream source.',
57
49
  `Evidence is marked stale after ${BULK_REVIEW_STALE_AFTER_DAYS} days for review purposes; this is a local observation age, not an upstream record age.`,
58
50
  'Optional third-party intelligence providers are not contacted by Bulk.',
51
+ 'Sources deliberately skipped by the selected scan profile or deployment policy do not independently trigger a retry.',
59
52
  'A retry can remain partial or fail and does not convert unavailable evidence into absence.',
60
53
  ],
61
54
  };
@@ -4,8 +4,8 @@
4
4
  import { normalizeDomain } from "./case-model.js";
5
5
  import { normalizeCaaCritical } from "./dns-record-normalization.js";
6
6
  export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
7
- export const BULK_SESSION_SCHEMA_VERSION = 2;
8
- export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, BULK_SESSION_SCHEMA_VERSION]);
7
+ export const BULK_SESSION_SCHEMA_VERSION = 3;
8
+ export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, 2, BULK_SESSION_SCHEMA_VERSION]);
9
9
  export const MAX_BULK_SESSIONS = 10;
10
10
  export const MAX_BULK_SESSION_ROWS = 2_000;
11
11
  export const MAX_BULK_SESSION_STORE_BYTES = 4 * 1024 * 1024;
@@ -231,6 +231,7 @@ export function normalizeBulkSessionResult(value) {
231
231
  scanDepth: scanDepth,
232
232
  createdDate: boundedText(item.createdDate, 64) || null,
233
233
  expiryDate: boundedText(item.expiryDate, 64) || null,
234
+ privacyProtected: nullableBoolean(item.privacyProtected),
234
235
  nameservers: boundedStrings(item.nameservers, 20),
235
236
  hasMx: nullableBoolean(item.hasMx),
236
237
  hasNullMx: nullableBoolean(item.hasNullMx),
@@ -246,7 +247,11 @@ export function normalizeBulkSessionResult(value) {
246
247
  hasPasswordField: item.hasPasswordField === true,
247
248
  hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
248
249
  phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
250
+ idnReferenceMatch: nullableBoolean(item.idnReferenceMatch),
251
+ pageBaselineMatch: nullableBoolean(item.pageBaselineMatch),
252
+ hasActiveBrandProfile: nullableBoolean(item.hasActiveBrandProfile),
249
253
  riskModelVersion: nullableVersion(item.riskModelVersion),
254
+ opportunityModelVersion: nullableVersion(item.opportunityModelVersion),
250
255
  riskFactors: normalizeRiskFactors(item.riskFactors),
251
256
  dns: normalizeDns(item.dns),
252
257
  dnssec: boundedText(item.dnssec, 40) || null,
@@ -0,0 +1,76 @@
1
+ // Authority-aware interpretation for compact Bulk source observations. Raw
2
+ // source states remain unchanged; this layer only decides whether an observed
3
+ // limitation was unexpected for the registry's published service paths.
4
+ import { registryCapabilityFor } from "../../../../lib/registry-capabilities.mjs";
5
+ const MAX_SOURCE_OBSERVATIONS = 32;
6
+ const MAX_SOURCE_TEXT_LENGTH = 40;
7
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
8
+ const LIMITED_SOURCE_STATES = new Set([
9
+ 'error',
10
+ 'failed',
11
+ 'inconclusive',
12
+ 'partial',
13
+ 'rate_limited',
14
+ 'unavailable',
15
+ 'unsupported',
16
+ ]);
17
+ function text(value) {
18
+ return typeof value === 'string'
19
+ && value.length <= MAX_SOURCE_TEXT_LENGTH
20
+ && !CONTROL_RE.test(value)
21
+ ? value.trim().toLowerCase()
22
+ : '';
23
+ }
24
+ function record(value) {
25
+ return value && typeof value === 'object' && !Array.isArray(value)
26
+ ? value
27
+ : null;
28
+ }
29
+ function observations(value) {
30
+ return Array.isArray(value)
31
+ ? value.slice(0, MAX_SOURCE_OBSERVATIONS).flatMap((entry) => {
32
+ const item = record(entry);
33
+ const source = text(item?.source);
34
+ const state = text(item?.state);
35
+ return source && state ? [{ source, state }] : [];
36
+ })
37
+ : [];
38
+ }
39
+ function expectedUnsupportedSources(domain) {
40
+ const capability = registryCapabilityFor(domain);
41
+ if (!capability)
42
+ return new Set();
43
+ const expected = new Set();
44
+ if (capability.rdapAccessProfile === 'no-iana-service')
45
+ expected.add('rdap');
46
+ if (capability.whoisAccessProfile === 'no-iana-service')
47
+ expected.add('whois');
48
+ return expected;
49
+ }
50
+ export function isExpectedUnsupportedBulkSource(domain, observation) {
51
+ const source = text(observation.source);
52
+ const state = text(observation.state);
53
+ return state === 'unsupported' && expectedUnsupportedSources(domain).has(source);
54
+ }
55
+ export function limitedBulkSources(domain, coverage) {
56
+ const expectedUnsupported = expectedUnsupportedSources(domain);
57
+ return observations(coverage)
58
+ .filter(({ source, state }) => (LIMITED_SOURCE_STATES.has(state)
59
+ && !(state === 'unsupported' && expectedUnsupported.has(source))))
60
+ .map(({ source }) => source);
61
+ }
62
+ export function classifyBulkSourceCoverage(domain, coverage) {
63
+ const retained = observations(coverage);
64
+ if (!retained.length)
65
+ return 'unrecorded';
66
+ return limitedBulkSources(domain, retained).length ? 'limited' : 'complete';
67
+ }
68
+ export function describeBulkSourceCoverage(domain, observation) {
69
+ const source = text(observation.source);
70
+ const state = text(observation.state);
71
+ if (!source || !state)
72
+ return 'unrecorded source';
73
+ return isExpectedUnsupportedBulkSource(domain, observation)
74
+ ? `${source}: unsupported (no IANA-published service)`
75
+ : `${source}: ${state}`;
76
+ }
@@ -1,7 +1,7 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
3
  import { normalizeHttpSummary } from "./http-summary.js";
4
- import { normalizeRiskModelVersion } from "./scoring.js";
4
+ import { normalizeOpportunityModelVersion, normalizeRiskModelVersion } from "./scoring.js";
5
5
  import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
6
6
  import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, hashString, isoOrNull, objectRecord, safeId, } from "./case-record-core.js";
7
7
  function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
@@ -97,6 +97,7 @@ const DEEP_SIGNAL_FIELDS = [
97
97
  'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
98
98
  'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
99
99
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
100
+ 'pageBaselineMatch',
100
101
  ];
101
102
  // Ordered list of the fields that make up a snapshot's *material* identity -
102
103
  // everything except capture timestamps, snapshot id and source. `scanDepth` is
@@ -104,7 +105,7 @@ const DEEP_SIGNAL_FIELDS = [
104
105
  // another. Deterministic ordering here is what makes the fingerprint stable.
105
106
  const MATERIAL_FIELD_ORDER = [
106
107
  'scanDepth',
107
- 'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityScore',
108
+ 'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
108
109
  'riskFactors', 'opportunityFactors',
109
110
  'registrar', 'createdDate', 'expiryDate', 'nameservers',
110
111
  'hasMx', 'hasSpf', 'hasDmarc',
@@ -112,6 +113,7 @@ const MATERIAL_FIELD_ORDER = [
112
113
  'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
113
114
  'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
114
115
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
116
+ 'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
115
117
  'mutationTypes',
116
118
  ];
117
119
  // The canonical, comparison-safe value of a material field. Registrar casing,
@@ -203,6 +205,7 @@ function buildSnapshot(raw, options) {
203
205
  confidence: evidenceString(record.confidence),
204
206
  riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
205
207
  riskScore: clampScore(record.riskScore),
208
+ opportunityModelVersion: normalizeOpportunityModelVersion(record.opportunityModelVersion),
206
209
  opportunityScore: clampScore(record.opportunityScore),
207
210
  riskFactors: normalizeFactors(record.riskFactors),
208
211
  opportunityFactors: normalizeFactors(record.opportunityFactors),
@@ -232,12 +235,18 @@ function buildSnapshot(raw, options) {
232
235
  hasPasswordField: boolOrNull(record.hasPasswordField),
233
236
  hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
234
237
  phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
238
+ privacyProtected: boolOrNull(record.privacyProtected),
239
+ idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
240
+ pageBaselineMatch: boolOrNull(record.pageBaselineMatch),
241
+ hasActiveBrandProfile: boolOrNull(record.hasActiveBrandProfile),
235
242
  mutationTypes: normalizeMutationList(record.mutationTypes),
236
243
  };
237
244
  // A version without an actual risk assessment is orphaned metadata. Drop it
238
245
  // so it cannot make otherwise-identical evidence look materially different.
239
246
  if (fields.riskScore === null && fields.riskFactors.length === 0)
240
247
  fields.riskModelVersion = null;
248
+ if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
249
+ fields.opportunityModelVersion = null;
241
250
  // A fast capture never evaluates the deep signals, so any value supplied for
242
251
  // them (e.g. a profile's default `false`) is discarded as unevaluated.
243
252
  if (scanDepth === 'fast') {
@@ -264,6 +273,7 @@ function buildSnapshot(raw, options) {
264
273
  hasPasswordField: null,
265
274
  hasExternalFormAction: null,
266
275
  phishingLanguageMatch: null,
276
+ pageBaselineMatch: null,
267
277
  });
268
278
  }
269
279
  if (!hasMaterialEvidence(fields))
@@ -391,8 +401,8 @@ const COMPARE_FIELDS = [
391
401
  { field: 'confidence', label: 'Confidence', type: 'token' },
392
402
  { field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
393
403
  { field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
394
- { field: 'opportunityScore', label: 'Opportunity score', type: 'score' },
395
- { field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors' },
404
+ { field: 'opportunityScore', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
405
+ { field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
396
406
  { field: 'registrar', label: 'Registrar', type: 'registrar' },
397
407
  { field: 'createdDate', label: 'Creation date', type: 'date' },
398
408
  { field: 'expiryDate', label: 'Expiry date', type: 'date' },
@@ -428,6 +438,11 @@ function riskModelComparable(previous, current) {
428
438
  const after = normalizeRiskModelVersion(current?.riskModelVersion);
429
439
  return before !== null && before === after;
430
440
  }
441
+ function opportunityModelComparable(previous, current) {
442
+ const before = normalizeOpportunityModelVersion(previous?.opportunityModelVersion);
443
+ const after = normalizeOpportunityModelVersion(current?.opportunityModelVersion);
444
+ return before !== null && before === after;
445
+ }
431
446
  function valuesMateriallyEqual(field, previous, current) {
432
447
  return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
433
448
  }
@@ -448,6 +463,10 @@ export function caseEvidenceIncomparableReasons(previous, current) {
448
463
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
449
464
  if (hasRiskEvidence && !riskModelComparable(previous, current))
450
465
  reasons.push('risk-model');
466
+ const hasOpportunityEvidence = previous.opportunityScore !== null || current.opportunityScore !== null
467
+ || previous.opportunityFactors.length > 0 || current.opportunityFactors.length > 0;
468
+ if (hasOpportunityEvidence && !opportunityModelComparable(previous, current))
469
+ reasons.push('opportunity-model');
451
470
  if (!depthComparable(previous.scanDepth, current.scanDepth)) {
452
471
  const deepOnlyChanged = DEEP_SIGNAL_FIELDS.some((field) => !valuesMateriallyEqual(field, previous, current));
453
472
  const comparableRiskChanged = riskModelComparable(previous, current)
@@ -623,6 +642,7 @@ export function compareCaseEvidence(previous, current) {
623
642
  const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
624
643
  const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
625
644
  const comparableRiskModel = riskModelComparable(previous, current);
645
+ const comparableOpportunityModel = opportunityModelComparable(previous, current);
626
646
  const changes = [];
627
647
  for (const spec of COMPARE_FIELDS) {
628
648
  if (spec.depthGate === 'both-deep' && !bothDeep)
@@ -631,6 +651,8 @@ export function compareCaseEvidence(previous, current) {
631
651
  continue;
632
652
  if (spec.modelGate === 'risk' && !comparableRiskModel)
633
653
  continue;
654
+ if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
655
+ continue;
634
656
  const result = compareField(spec, previous[spec.field], current[spec.field]);
635
657
  if (result) {
636
658
  changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
@@ -1,4 +1,4 @@
1
- import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
1
+ import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
2
2
  import { mergeCaseActions, mergeCaseAssertions, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, } from "./case-response-model.js";
3
3
  const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
4
4
  const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
@@ -97,6 +97,9 @@ function extractImportPatch(raw) {
97
97
  rawId: typeof record.id === 'string' ? record.id : null,
98
98
  status: importScalar(record.status, STATUS_VALUES),
99
99
  disposition: importScalar(record.disposition, DISPOSITION_VALUES),
100
+ reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
101
+ ? normalizeReviewReasonCode(record.reviewReasonCode)
102
+ : undefined,
100
103
  source: importScalar(record.source, SOURCE_VALUES),
101
104
  evidenceHistory: normalizeEvidenceHistory(rawEvidence, { source: 'import', fallback: importFallback }),
102
105
  evidencePins,
@@ -139,6 +142,7 @@ function caseFromPatch(patch, now) {
139
142
  domain: patch.domain,
140
143
  status: patch.status ?? DEFAULT_STATUS,
141
144
  disposition: patch.disposition ?? DEFAULT_DISPOSITION,
145
+ reviewReasonCode: patch.reviewReasonCode ?? null,
142
146
  tags: patch.tags,
143
147
  notes: patch.notes,
144
148
  source: patch.source ?? DEFAULT_SOURCE,
@@ -171,6 +175,7 @@ function applyImportPatch(local, patch) {
171
175
  ...local,
172
176
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
173
177
  disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
178
+ reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
174
179
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
175
180
  evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
176
181
  evidencePins,
@@ -1,5 +1,6 @@
1
- export const CASE_SCHEMA_VERSION = 9;
2
- export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, CASE_SCHEMA_VERSION];
1
+ import { ANALYST_REVIEW_REASONS } from "../../../../lib/analyst-taxonomy.mjs";
2
+ export const CASE_SCHEMA_VERSION = 10;
3
+ export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, 9, CASE_SCHEMA_VERSION];
3
4
  export const MAX_CASES = 500;
4
5
  export const MAX_NOTES_PER_CASE = 50;
5
6
  export const MAX_NOTE_LENGTH = 2000;
@@ -31,6 +32,7 @@ export const CASE_DISPOSITIONS = [
31
32
  { value: 'expected', label: 'Expected' },
32
33
  { value: 'closed_no_action', label: 'Closed without action' },
33
34
  ];
35
+ export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
34
36
  export const CASE_SOURCES = [
35
37
  { value: 'lookup', label: 'Lookup' },
36
38
  { value: 'bulk', label: 'Bulk' },
@@ -1,6 +1,6 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
3
+ import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
4
4
  // Forward-version policy (two distinct guarantees):
5
5
  // - A locally-stored envelope that declares a version greater than this is
6
6
  // still read best-effort on load (known fields kept, unknown dropped), but
@@ -26,6 +26,7 @@ export const EVIDENCE_SOURCE_RANK = {
26
26
  };
27
27
  const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
28
28
  const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
29
+ const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
29
30
  const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
30
31
  const STATUS_LABELS = Object.fromEntries(CASE_STATUSES.map((item) => [item.value, item.label]));
31
32
  const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
@@ -135,6 +136,9 @@ export function normalizeStatus(value) {
135
136
  export function normalizeDisposition(value) {
136
137
  return typeof value === 'string' && DISPOSITION_VALUES.has(value) ? value : DEFAULT_DISPOSITION;
137
138
  }
139
+ export function normalizeReviewReasonCode(value) {
140
+ return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
141
+ }
138
142
  export function normalizeSource(value) {
139
143
  return typeof value === 'string' && SOURCE_VALUES.has(value) ? value : DEFAULT_SOURCE;
140
144
  }
@@ -2,7 +2,7 @@
2
2
  // record normalization, and analyst updates.
3
3
  import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
4
4
  import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
5
- import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
5
+ import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
6
6
  import { normalizeEvidenceHistory, } from "./case-evidence-model.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Case normalization
@@ -49,6 +49,7 @@ export function normalizeCase(raw, existing, nowIso) {
49
49
  domain,
50
50
  status: normalizeStatus(record.status),
51
51
  disposition: normalizeDisposition(record.disposition),
52
+ reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
52
53
  tags: normalizeTags(record.tags),
53
54
  notes: normalizeNotes(record.notes, now),
54
55
  source: normalizeSource(record.source),
@@ -86,6 +87,7 @@ export function createCase(input, nowIso) {
86
87
  domain,
87
88
  status: normalizeStatus(input.status),
88
89
  disposition: normalizeDisposition(input.disposition),
90
+ reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
89
91
  tags: normalizeTags(input.tags),
90
92
  notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
91
93
  source,
@@ -206,6 +208,9 @@ export function updateCase(cases, id, patch, nowIso) {
206
208
  ...current,
207
209
  status: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
208
210
  disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
211
+ reviewReasonCode: patch.reviewReasonCode !== undefined
212
+ ? normalizeReviewReasonCode(patch.reviewReasonCode)
213
+ : current.reviewReasonCode ?? null,
209
214
  tags: patch.tags !== undefined ? normalizeTags(patch.tags) : current.tags,
210
215
  source,
211
216
  evidenceHistory,
@@ -36,7 +36,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
36
36
  includedEvidence: ['Exact URLs', 'Observation time', 'Case disposition', 'Selected evidence pins', 'Registrar contact provenance'],
37
37
  excludedEvidence: ['Raw WHOIS or RDAP payloads', 'Unselected contacts', 'Provider secrets', 'Unrelated analyst notes'],
38
38
  redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
39
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
39
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
40
40
  followUpFields: ['Registrar reference', 'Acknowledgement time', 'Resolution or transfer outcome'],
41
41
  },
42
42
  {
@@ -50,7 +50,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
50
50
  includedEvidence: ['Exact URLs', 'Observation time', 'Registry contact provenance', 'Selected evidence pins', 'Prior action references'],
51
51
  excludedEvidence: ['Raw registry payloads', 'Unselected contacts', 'Secrets', 'Unsupported ownership claims'],
52
52
  redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
53
- attachments: ['Reviewed response packet', 'Optional normalized case report', 'Optional registrar reference'],
53
+ attachments: ['Reviewed response packet', 'Optional normalised case report', 'Optional registrar reference'],
54
54
  followUpFields: ['Registry reference', 'Registrar referral', 'Delegation or status outcome'],
55
55
  },
56
56
  {
@@ -64,7 +64,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
64
64
  includedEvidence: ['Exact URLs', 'Observation time', 'Selected HTTP, TLS, DNS, or page-identity pins', 'Contact provenance'],
65
65
  excludedEvidence: ['Raw response bodies', 'Cookies', 'Secrets', 'Unproven origin-host claims'],
66
66
  redactions: ['Remove credentials, query secrets, fragments, cookies, and unrelated contacts'],
67
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
67
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
68
68
  followUpFields: ['Provider ticket', 'Content status', 'Infrastructure change outcome'],
69
69
  },
70
70
  {
@@ -78,7 +78,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
78
78
  includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Contact source and limitations'],
79
79
  excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected case notes', 'Legal conclusions'],
80
80
  redactions: ['Remove credentials, tokens, personal data, and unrelated identifiers'],
81
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
81
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
82
82
  followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
83
83
  },
84
84
  {
@@ -88,11 +88,11 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
88
88
  subjectPrefix: 'Reviewed unsafe-site report',
89
89
  requiredContactKind: null,
90
90
  checklist: ['Use the recipient submission form or documented route manually', 'Include exact URLs and current observation time', 'Disclose source gaps and false-positive context'],
91
- evidenceOrder: ['Exact URLs', 'Observed behavior', 'Selected corroborating evidence', 'Limitations'],
91
+ evidenceOrder: ['Exact URLs', 'Observed behaviour', 'Selected corroborating evidence', 'Limitations'],
92
92
  includedEvidence: ['Exact URLs', 'Observation time', 'Observed harm', 'Selected evidence pins', 'Contradictions and cautions'],
93
93
  excludedEvidence: ['Raw provider payloads', 'Secrets', 'Unselected personal data', 'Automated maliciousness claims'],
94
94
  redactions: ['Remove credentials, tokens, URL fragments, and unrelated contacts'],
95
- attachments: ['Reviewed response packet', 'Optional normalized case report'],
95
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
96
96
  followUpFields: ['Submission reference', 'Review state', 'Listing or delisting outcome'],
97
97
  },
98
98
  {
@@ -106,7 +106,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
106
106
  includedEvidence: ['Case disposition', 'Selected evidence pins', 'Analyst decisions and assertions', 'Action history', 'Exact URLs when relevant'],
107
107
  excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported attribution'],
108
108
  redactions: ['Remove credentials, tokens, and personal data not required for the internal decision'],
109
- attachments: ['Reviewed response packet', 'Normalized case report when required by internal policy'],
109
+ attachments: ['Reviewed response packet', 'Normalised case report when required by internal policy'],
110
110
  followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
111
111
  },
112
112
  ]);