@slicedearth/whoisleuth-cli 2.1.0 → 2.3.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 (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +24 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +63 -13
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +240 -3
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +183 -53
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +115 -46
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
@@ -0,0 +1,93 @@
1
+ export const CAPABILITY_IDS = Object.freeze({
2
+ LOOKUP: 'lookup',
3
+ RDAP: 'rdap',
4
+ RDAP_NAMESERVER_SEARCH: 'rdap_nameserver_search',
5
+ WHOIS: 'whois',
6
+ AVAILABILITY: 'availability',
7
+ DOMAIN_EVIDENCE: 'domain_evidence',
8
+ DNS_INTELLIGENCE: 'dns_intelligence',
9
+ WEBSITE_PROBE: 'website_probe',
10
+ TLS_INTELLIGENCE: 'tls_intelligence',
11
+ CERTIFICATE_TRANSPARENCY: 'certificate_transparency',
12
+ SECURITY_TXT: 'security_txt',
13
+ EXTERNAL_INTELLIGENCE: 'external_intelligence',
14
+ URLSCAN_SEARCH: 'urlscan_search',
15
+ URLHAUS_HOST: 'urlhaus_host',
16
+ THREATFOX_DOMAIN_IOC: 'threatfox_domain_ioc',
17
+ REGISTRAR_RDAP: 'registrar_rdap',
18
+ NETWORK_CONTEXT: 'network_context',
19
+ REVERSE_DNS: 'reverse_dns',
20
+ DOMAIN_POSTURE: 'domain_posture',
21
+ DNSSEC_VALIDATION: 'dnssec_validation',
22
+ MAIL_TRANSPORT_REVIEW: 'mail_transport_review',
23
+ RENDERED_WEB_CAPTURE: 'rendered_web_capture',
24
+ RENDERED_CAPTURE_COMPARISON: 'rendered_capture_comparison',
25
+ IDN_CONFUSABLES: 'idn_confusables',
26
+ ANALYST_CASES: 'analyst_cases',
27
+ WATCHLISTS: 'watchlists',
28
+ OFFLINE_REVIEW: 'offline_review',
29
+ PORTABLE_EVIDENCE: 'portable_evidence',
30
+ RUNTIME_DIAGNOSTICS: 'runtime_diagnostics',
31
+ WORKFLOW_EXECUTION: 'workflow_execution',
32
+ SCHEDULED_MONITORING: 'scheduled_monitoring',
33
+ DISTRIBUTED_BUDGETS: 'distributed_budgets',
34
+ });
35
+ export const CLI_HELP_GROUP_ORDER = Object.freeze([
36
+ 'investigate',
37
+ 'respond',
38
+ 'assure',
39
+ 'utilities',
40
+ ]);
41
+ function semantic(group, capabilityFamilyId) {
42
+ return Object.freeze({ group, capabilityFamilyId });
43
+ }
44
+ export const CLI_COMMAND_SEMANTICS = Object.freeze({
45
+ completion: semantic('utilities', CAPABILITY_IDS.OFFLINE_REVIEW),
46
+ doctor: semantic('utilities', CAPABILITY_IDS.RUNTIME_DIAGNOSTICS),
47
+ commands: semantic('utilities', CAPABILITY_IDS.OFFLINE_REVIEW),
48
+ manual: semantic('utilities', CAPABILITY_IDS.OFFLINE_REVIEW),
49
+ manifest: semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
50
+ 'map-observations': semantic('respond', CAPABILITY_IDS.OFFLINE_REVIEW),
51
+ 'oam-export': semantic('respond', CAPABILITY_IDS.PORTABLE_EVIDENCE),
52
+ lookup: semantic('investigate', CAPABILITY_IDS.LOOKUP),
53
+ bulk: semantic('investigate', CAPABILITY_IDS.LOOKUP),
54
+ 'ct-search': semantic('investigate', CAPABILITY_IDS.CERTIFICATE_TRANSPARENCY),
55
+ 'ct-intake': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
56
+ discover: semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
57
+ 'discover-scan': semantic('investigate', CAPABILITY_IDS.LOOKUP),
58
+ posture: semantic('investigate', CAPABILITY_IDS.DOMAIN_POSTURE),
59
+ http: semantic('investigate', CAPABILITY_IDS.WEBSITE_PROBE),
60
+ tls: semantic('investigate', CAPABILITY_IDS.TLS_INTELLIGENCE),
61
+ 'dnssec-validate': semantic('assure', CAPABILITY_IDS.DNSSEC_VALIDATION),
62
+ 'mail-transport': semantic('assure', CAPABILITY_IDS.MAIL_TRANSPORT_REVIEW),
63
+ 'registry-support': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
64
+ 'registry-doctor': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
65
+ 'registry-cohort': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
66
+ 'registry-scaffold': semantic('utilities', CAPABILITY_IDS.OFFLINE_REVIEW),
67
+ 'risk-calibrate': semantic('assure', CAPABILITY_IDS.OFFLINE_REVIEW),
68
+ 'lookalike-calibrate': semantic('assure', CAPABILITY_IDS.OFFLINE_REVIEW),
69
+ 'verify-artifact': semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
70
+ 'interchange-report': semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
71
+ 'inspect-archive': semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
72
+ 'sign-artifact': semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
73
+ 'verify-signature': semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
74
+ 'source-report': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
75
+ compare: semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
76
+ 'page-compare': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
77
+ 'mail-review': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
78
+ 'mail-headers': semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
79
+ 'review-evidence': semantic('investigate', CAPABILITY_IDS.PORTABLE_EVIDENCE),
80
+ brief: semantic('investigate', CAPABILITY_IDS.OFFLINE_REVIEW),
81
+ 'case-pack': semantic('respond', CAPABILITY_IDS.PORTABLE_EVIDENCE),
82
+ 'domain-control': semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
83
+ 'monitor-once': semantic('assure', CAPABILITY_IDS.LOOKUP),
84
+ assurance: semantic('assure', CAPABILITY_IDS.PORTABLE_EVIDENCE),
85
+ 'change-packet': semantic('respond', CAPABILITY_IDS.PORTABLE_EVIDENCE),
86
+ 'sharing-review': semantic('respond', CAPABILITY_IDS.PORTABLE_EVIDENCE),
87
+ 'workflow-plan': semantic('assure', CAPABILITY_IDS.OFFLINE_REVIEW),
88
+ 'workflow-run': semantic('assure', CAPABILITY_IDS.WORKFLOW_EXECUTION),
89
+ diff: semantic('assure', CAPABILITY_IDS.OFFLINE_REVIEW),
90
+ reconcile: semantic('assure', CAPABILITY_IDS.OFFLINE_REVIEW),
91
+ timeline: semantic('assure', CAPABILITY_IDS.OFFLINE_REVIEW),
92
+ export: semantic('respond', CAPABILITY_IDS.PORTABLE_EVIDENCE),
93
+ });
@@ -158,6 +158,15 @@ export const RISK_CALIBRATION_REPORT_PRIVACY_KEYS = Object.freeze([
158
158
  export const RISK_CALIBRATION_REPORT_INTERPRETATION_KEYS = Object.freeze([
159
159
  'authority', 'statement', 'automaticTuning', 'networkRequests', 'persisted',
160
160
  ]);
161
+ /** Stable disposition identities admitted by the versioned calibration schema. */
162
+ export const RISK_CALIBRATION_DISPOSITIONS = Object.freeze([
163
+ 'unreviewed',
164
+ 'suspicious',
165
+ 'confirmed_abuse',
166
+ 'false_positive',
167
+ 'expected',
168
+ 'closed_no_action',
169
+ ]);
161
170
  export const RISK_CALIBRATION_DATASET_COMPATIBILITY = defineSchemaCompatibility({
162
171
  id: 'cli.risk-calibration-dataset',
163
172
  kind: 'cli_document',
@@ -337,8 +346,8 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
337
346
  {
338
347
  id: 'risk-calibration-report-v3-detailed',
339
348
  path: 'test/fixtures/risk-calibration-report-v3-detailed.json',
340
- bytes: 7_026,
341
- sha256: '5c0c435e81478e90bc0b64aa7c20fd82d131b5261a57d9550df3f03334bc20e1',
349
+ bytes: 7_020,
350
+ sha256: 'a5cba1681c170c79f8891037f47efc93e0ae6ce5d247418ff3f1c9a81a352922',
342
351
  contentDigestSha256: null,
343
352
  schema: RISK_CALIBRATION_REPORT_SCHEMA,
344
353
  version: RISK_CALIBRATION_REPORT_VERSION,
@@ -351,8 +360,8 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
351
360
  {
352
361
  id: 'risk-calibration-report-v3-summary',
353
362
  path: 'test/fixtures/risk-calibration-report-v3-summary.json',
354
- bytes: 5_486,
355
- sha256: 'b06c312eed14680ce30fd58a84a93908d29ea9e71bfee8174b90df286b730549',
363
+ bytes: 5_480,
364
+ sha256: '71b7fe1ee613b2ab32bd1f5646ee765985022c904224e1a08f4577c1d1d06240',
356
365
  contentDigestSha256: null,
357
366
  schema: RISK_CALIBRATION_REPORT_SCHEMA,
358
367
  version: RISK_CALIBRATION_REPORT_VERSION,
@@ -1110,9 +1119,7 @@ const FACTOR_SPEC = objectOf([
1110
1119
  const REPORT_RECORD_SPEC = objectOf([
1111
1120
  { key: 'id', value: boundedText(MAX_RISK_CALIBRATION_RECORD_ID_LENGTH) },
1112
1121
  { key: 'domain', value: boundedText(MAX_RISK_CALIBRATION_DOMAIN_LENGTH) },
1113
- { key: 'analystDisposition', value: boundedText(MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, [
1114
- 'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action',
1115
- ]) },
1122
+ { key: 'analystDisposition', value: boundedText(MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, RISK_CALIBRATION_DISPOSITIONS) },
1116
1123
  { key: 'reviewReasonCode', value: nullable(boundedText(MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH)) },
1117
1124
  { key: 'interoperabilityTags', value: arrayOf(boundedText(MAX_RISK_CALIBRATION_STRING_LENGTH), 0, 32) },
1118
1125
  { key: 'metricClass', value: boundedText(16, ['positive', 'negative', 'excluded']) },
@@ -1179,9 +1186,7 @@ const DATASET_EVIDENCE_SPEC = objectOf([
1179
1186
  const DATASET_RECORD_SPEC = objectOf([
1180
1187
  { key: 'id', value: boundedText(MAX_RISK_CALIBRATION_RECORD_ID_LENGTH, null, 1, canonicalRiskCalibrationIdentifier) },
1181
1188
  { key: 'domain', value: boundedText(MAX_RISK_CALIBRATION_DOMAIN_LENGTH, null, 1, validRiskCalibrationDomain) },
1182
- { key: 'analystDisposition', value: boundedText(MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, [
1183
- 'unreviewed', 'suspicious', 'confirmed_abuse', 'false_positive', 'expected', 'closed_no_action',
1184
- ]) },
1189
+ { key: 'analystDisposition', value: boundedText(MAX_RISK_CALIBRATION_DISPOSITION_LENGTH, RISK_CALIBRATION_DISPOSITIONS) },
1185
1190
  { key: 'reviewReasonCode', value: boundedText(MAX_RISK_CALIBRATION_REVIEW_REASON_LENGTH, RISK_CALIBRATION_REVIEW_REASON_VALUES), optional: true },
1186
1191
  { key: 'evidence', value: DATASET_EVIDENCE_SPEC },
1187
1192
  ]);
@@ -4,8 +4,13 @@ import { defineSchemaLifecycleFamily, } from "./schema-lifecycle.mjs";
4
4
  export const WORKSPACE_CONTRACT_OWNER = 'packages/contracts/workspace-portability.mts';
5
5
  export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
6
6
  export const PUBLIC_BRAND_PROFILE_SCHEMA_VERSION = 6;
7
- export const BRAND_PROFILE_SCHEMA_VERSION = 7;
8
- export const BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([PUBLIC_BRAND_PROFILE_SCHEMA_VERSION, BRAND_PROFILE_SCHEMA_VERSION]);
7
+ export const LATEST_PUBLIC_BRAND_PROFILE_SCHEMA_VERSION = 7;
8
+ export const BRAND_PROFILE_SCHEMA_VERSION = 8;
9
+ export const BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([
10
+ PUBLIC_BRAND_PROFILE_SCHEMA_VERSION,
11
+ LATEST_PUBLIC_BRAND_PROFILE_SCHEMA_VERSION,
12
+ BRAND_PROFILE_SCHEMA_VERSION,
13
+ ]);
9
14
  export const SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS = BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS;
10
15
  export const MAX_PROFILES = 100;
11
16
  export const MAX_PROFILE_VALUES = 200;
@@ -15,7 +20,10 @@ export const MAX_PROFILE_IMPORT_BYTES = 2 * 1024 * 1024;
15
20
  export const MAX_PROFILE_NAME_LENGTH = 100;
16
21
  export const MAX_PROFILE_TEXT_LENGTH = 200;
17
22
  export const MAX_PROFILE_DOMAIN_LENGTH = 253;
23
+ export const MAX_PROFILE_URL_LENGTH = 2_048;
18
24
  export const MAX_PROFILE_TLD_LENGTH = 63;
25
+ export const MAX_OFFICIAL_CHANNELS = 30;
26
+ export const MAX_RIGHTS_REFERENCES = 30;
19
27
  export const MAX_DKIM_SELECTOR_LENGTH = 253;
20
28
  export const MAX_DKIM_SELECTORS = 10;
21
29
  export const MAX_PROTECTION_ATTESTATIONS = 6;
@@ -151,7 +159,7 @@ export const BULK_SESSION_BROWSER_STORE_LIFECYCLE_SCHEMA = 'whoisleuth.browser.b
151
159
  export const INVESTIGATION_TEMPLATE_BROWSER_STORE_LIFECYCLE_SCHEMA = 'whoisleuth.browser.investigation-template-store';
152
160
  export const BULK_REVIEW_BROWSER_STORE_LIFECYCLE_SCHEMA = 'whoisleuth.browser.bulk-review-store';
153
161
  export const WORKSPACE_PORTABILITY_IDENTITY_CONSTANTS = Object.freeze([
154
- 'BRAND_PROFILE_SCHEMA', 'BRAND_PROFILE_SCHEMA_VERSION', 'BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS', 'SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS',
162
+ 'BRAND_PROFILE_SCHEMA', 'PUBLIC_BRAND_PROFILE_SCHEMA_VERSION', 'LATEST_PUBLIC_BRAND_PROFILE_SCHEMA_VERSION', 'BRAND_PROFILE_SCHEMA_VERSION', 'BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS', 'SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS',
155
163
  'CAMPAIGN_SCHEMA', 'CAMPAIGN_SCHEMA_VERSION', 'CAMPAIGN_BROWSER_SUPPORTED_VERSIONS', 'CAMPAIGN_EXPORT_SUPPORTED_VERSIONS',
156
164
  'WATCHLIST_SCHEMA', 'WATCHLIST_SCHEMA_VERSION', 'WATCHLIST_BROWSER_SUPPORTED_VERSIONS', 'WATCHLIST_EXPORT_SUPPORTED_VERSIONS',
157
165
  'SHORTLIST_SCHEMA', 'SHORTLIST_SCHEMA_VERSION', 'SHORTLIST_BROWSER_SUPPORTED_VERSIONS', 'SUPPORTED_SHORTLIST_SCHEMA_VERSIONS',
@@ -169,7 +177,8 @@ export const WORKSPACE_PORTABILITY_IDENTITY_CONSTANTS = Object.freeze([
169
177
  ]);
170
178
  export const WORKSPACE_PORTABILITY_BOUND_CONSTANTS = Object.freeze([
171
179
  'MAX_PROFILES', 'MAX_PROFILE_VALUES', 'MAX_PROFILE_VALUE_INPUTS', 'MAX_PROFILE_STORE_BYTES', 'MAX_PROFILE_IMPORT_BYTES',
172
- 'MAX_PROFILE_NAME_LENGTH', 'MAX_PROFILE_TEXT_LENGTH', 'MAX_PROFILE_DOMAIN_LENGTH', 'MAX_PROFILE_TLD_LENGTH', 'MAX_DKIM_SELECTOR_LENGTH',
180
+ 'MAX_PROFILE_NAME_LENGTH', 'MAX_PROFILE_TEXT_LENGTH', 'MAX_PROFILE_DOMAIN_LENGTH', 'MAX_PROFILE_URL_LENGTH', 'MAX_PROFILE_TLD_LENGTH',
181
+ 'MAX_OFFICIAL_CHANNELS', 'MAX_RIGHTS_REFERENCES', 'MAX_DKIM_SELECTOR_LENGTH',
173
182
  'MAX_DKIM_SELECTORS', 'MAX_PROTECTION_ATTESTATIONS', 'MAX_DESIRED_POSTURE_BASELINES', 'MAX_DESIRED_POSTURE_RECORDS',
174
183
  'MAX_DESIRED_POSTURE_SUPPRESSIONS', 'MAX_DESIRED_POSTURE_OBSERVATIONS', 'MAX_DESIRED_POSTURE_CHANGE_WINDOWS',
175
184
  'MAX_CAMPAIGNS', 'MAX_CAMPAIGN_DOMAINS', 'MAX_CAMPAIGN_NAME_LENGTH', 'MAX_CAMPAIGN_DESCRIPTION_LENGTH',
@@ -218,7 +227,7 @@ export const BRAND_PROFILE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
218
227
  supportedVersions: BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
219
228
  futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
220
229
  byteBudget: MAX_PROFILE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
221
- note: 'Exact public version 6 profiles migrate directly to version 7, which gives each approved change window a durable opaque identity; other historical stores are outside the compatibility boundary.',
230
+ note: 'Supported version 6 and 7 profiles migrate directly to version 8. Version 7 gives each approved change window a durable opaque identity; version 8 adds bounded official-channel and rights-reference records.',
222
231
  });
223
232
  export const CAMPAIGN_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
224
233
  id: 'browser.campaigns', kind: 'browser_store', schema: null, currentVersion: CAMPAIGN_SCHEMA_VERSION,
@@ -648,7 +657,7 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
648
657
  "version": 6,
649
658
  "role": "historical",
650
659
  "expectation": "normalises_to_current_output",
651
- "expectedOutputFixtureId": "workspace.browser.brand.v7",
660
+ "expectedOutputFixtureId": "workspace.browser.brand.v8",
652
661
  "scope": "repository",
653
662
  "shapeId": "workspace.browser.brand.shape"
654
663
  },
@@ -660,6 +669,20 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
660
669
  "contentDigestSha256": null,
661
670
  "schema": "whoisleuth.browser.brand-profile-store",
662
671
  "version": 7,
672
+ "role": "historical",
673
+ "expectation": "normalises_to_current_output",
674
+ "expectedOutputFixtureId": "workspace.browser.brand.v8",
675
+ "scope": "repository",
676
+ "shapeId": "workspace.browser.brand.shape"
677
+ },
678
+ {
679
+ "id": "workspace.browser.brand.v8",
680
+ "path": "test/fixtures/workspace-lifecycle/browser-brand-v8.json",
681
+ "bytes": 37,
682
+ "sha256": "6c2310d9da626cb8acd88a47ccb60fa46051a63b23675cdc4801794ec5cc52e3",
683
+ "contentDigestSha256": null,
684
+ "schema": "whoisleuth.browser.brand-profile-store",
685
+ "version": 8,
663
686
  "role": "current",
664
687
  "expectation": "accepted_exact",
665
688
  "expectedOutputFixtureId": null,
@@ -830,7 +853,7 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
830
853
  "version": 6,
831
854
  "role": "historical",
832
855
  "expectation": "normalises_to_current_output",
833
- "expectedOutputFixtureId": "workspace.portable.brand.v7",
856
+ "expectedOutputFixtureId": "workspace.portable.brand.v8",
834
857
  "scope": "repository",
835
858
  "shapeId": "workspace.portable.brand.shape"
836
859
  },
@@ -842,6 +865,20 @@ const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
842
865
  "contentDigestSha256": null,
843
866
  "schema": "whoisleuth.brand-profiles",
844
867
  "version": 7,
868
+ "role": "historical",
869
+ "expectation": "normalises_to_current_output",
870
+ "expectedOutputFixtureId": "workspace.portable.brand.v8",
871
+ "scope": "repository",
872
+ "shapeId": "workspace.portable.brand.shape"
873
+ },
874
+ {
875
+ "id": "workspace.portable.brand.v8",
876
+ "path": "test/fixtures/workspace-lifecycle/portable-brand-v8.json",
877
+ "bytes": 122,
878
+ "sha256": "4e4af17a0e19cb841ea50fe150caa3de79b978311b51a334ed7f6f3d2e1c6a1c",
879
+ "contentDigestSha256": null,
880
+ "schema": "whoisleuth.brand-profiles",
881
+ "version": 8,
845
882
  "role": "current",
846
883
  "expectation": "accepted_exact",
847
884
  "expectedOutputFixtureId": null,
@@ -171,15 +171,47 @@ export function canonicalMxRecord(value) {
171
171
  }
172
172
  return exchange(candidate);
173
173
  }
174
- function caaValue(value) {
175
- return recordText(value, MAX_DOMAIN_CONTROL_CAA_VALUE_LENGTH).replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2').trim().toLowerCase();
174
+ function caaValue(value, tag) {
175
+ const candidate = recordText(value, MAX_DOMAIN_CONTROL_CAA_VALUE_LENGTH)
176
+ .replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, '$1$2').trim();
177
+ if (!candidate)
178
+ return '';
179
+ if (tag === 'issue' || tag === 'issuewild') {
180
+ const [issuer = '', ...parameters] = candidate.split(';');
181
+ const normalizedIssuer = issuer.trim().toLowerCase().replace(/\.$/u, '');
182
+ return [normalizedIssuer, ...parameters.map((parameter) => parameter.trim())].join('; ');
183
+ }
184
+ const scheme = /^([a-z][a-z0-9+.-]*):(.*)$/isu.exec(candidate);
185
+ if (!scheme)
186
+ return candidate;
187
+ const normalizedScheme = scheme[1].toLowerCase();
188
+ if (normalizedScheme === 'http' || normalizedScheme === 'https') {
189
+ try {
190
+ const url = new URL(candidate);
191
+ url.protocol = normalizedScheme;
192
+ url.hostname = url.hostname.toLowerCase();
193
+ return url.toString();
194
+ }
195
+ catch {
196
+ return candidate;
197
+ }
198
+ }
199
+ if (normalizedScheme === 'mailto') {
200
+ const [address = '', ...query] = scheme[2].split('?');
201
+ const at = address.lastIndexOf('@');
202
+ const normalizedAddress = at > 0
203
+ ? `${address.slice(0, at)}@${address.slice(at + 1).toLowerCase()}`
204
+ : address;
205
+ return `${normalizedScheme}:${normalizedAddress}${query.length ? `?${query.join('?')}` : ''}`;
206
+ }
207
+ return `${normalizedScheme}:${scheme[2]}`;
176
208
  }
177
209
  export function canonicalCaaRecord(value) {
178
210
  const item = structuredRecord(value, CAA_RECORD_KEYS, 'Domain control CAA record');
179
211
  if (item) {
180
212
  const flags = canonicalIntegerAlias(item, ['critical', 'flags'], MAX_DOMAIN_CONTROL_CAA_FLAGS, 'Domain control CAA flags', 0);
181
213
  const tag = recordText(item.tag, MAX_DOMAIN_CONTROL_CAA_TAG_LENGTH).toLowerCase();
182
- const payload = caaValue(item.value);
214
+ const payload = caaValue(item.value, tag);
183
215
  return flags !== null && /^(?:issue|issuewild|iodef)$/u.test(tag) && payload
184
216
  ? `${flags} ${tag} ${payload}`
185
217
  : '';
@@ -190,7 +222,7 @@ export function canonicalCaaRecord(value) {
190
222
  return '';
191
223
  const flags = recordInteger(match[1], MAX_DOMAIN_CONTROL_CAA_FLAGS);
192
224
  const tag = match[2]?.toLowerCase() ?? '';
193
- const payload = caaValue(match[3]);
225
+ const payload = caaValue(match[3], tag);
194
226
  return flags !== null && payload ? `${flags} ${tag} ${payload}` : '';
195
227
  }
196
228
  export function canonicalDsRecord(value) {
@@ -405,7 +437,9 @@ function hostnames(value) {
405
437
  return [...new Set(names)].sort(compareCodeUnits).slice(0, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS);
406
438
  }
407
439
  function digest(value) {
408
- const candidate = text(value, DOMAIN_CONTROL_SPKI_SHA256_HEX_LENGTH)?.toLowerCase() ?? '';
440
+ if (typeof value !== 'string' || CONTROL_RE.test(value))
441
+ return null;
442
+ const candidate = value.trim().toLowerCase();
409
443
  return SPKI_DIGEST_PATTERN.test(candidate) ? candidate : null;
410
444
  }
411
445
  function normalizeEntry(value) {
@@ -415,6 +449,12 @@ function normalizeEntry(value) {
415
449
  const domain = domainControlName(source.domain);
416
450
  if (!domain)
417
451
  return null;
452
+ const tlsSpkiSha256 = digest(source.tlsSpkiSha256);
453
+ if (source.tlsSpkiSha256 !== undefined && source.tlsSpkiSha256 !== null
454
+ && (typeof source.tlsSpkiSha256 !== 'string' || source.tlsSpkiSha256.trim())
455
+ && !tlsSpkiSha256) {
456
+ throw new TypeError('Domain control TLS public-key fingerprint must be exactly 64 hexadecimal characters.');
457
+ }
418
458
  return Object.freeze({
419
459
  domain,
420
460
  nameservers: Object.freeze(hostnames(source.nameservers)),
@@ -422,7 +462,7 @@ function normalizeEntry(value) {
422
462
  mx: Object.freeze(canonicalDomainControlRecordList(source.mx, 'mx')),
423
463
  caa: Object.freeze(canonicalDomainControlRecordList(source.caa, 'caa')),
424
464
  tlsIssuer: text(source.tlsIssuer, MAX_DOMAIN_CONTROL_TEXT_LENGTH)?.toLowerCase() ?? null,
425
- tlsSpkiSha256: digest(source.tlsSpkiSha256),
465
+ tlsSpkiSha256,
426
466
  registrarLock: source.registrarLock === 'required' || source.registrarLock === 'not_required'
427
467
  ? source.registrarLock
428
468
  : null,
@@ -1,4 +1,5 @@
1
1
  import { normalizeDomain, openOrCreateCase, updateCase, } from "../cases/case-model.mjs";
2
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
2
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
4
  import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
4
5
  import { hasUnsafeRetainedText } from "./retained-text.mjs";
@@ -13,6 +14,7 @@ export const EXTERNAL_FINDING_CATEGORIES = [
13
14
  'registration',
14
15
  'reputation',
15
16
  ];
17
+ export const MAX_EXTERNAL_FINDING_SUMMARY_LENGTH = 900;
16
18
  const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
17
19
  const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
18
20
  const ROOT_KEYS = new Set(['schema', 'schemaVersion', 'source', 'findings']);
@@ -218,7 +220,7 @@ export function parseExternalFindingsDocument(value) {
218
220
  domain,
219
221
  category: item.category,
220
222
  evidenceClass,
221
- summary: requiredText(item.summary, 900, `Finding ${index + 1} summary`),
223
+ summary: requiredText(item.summary, MAX_EXTERNAL_FINDING_SUMMARY_LENGTH, `Finding ${index + 1} summary`),
222
224
  observedAt: iso(item.observedAt, `Finding ${index + 1} observation time`),
223
225
  completeness: item.completeness,
224
226
  limitations: limitations(item.limitations, index),
@@ -311,6 +313,58 @@ function structuredPinFields(finding) {
311
313
  certificateObservation,
312
314
  };
313
315
  }
316
+ function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
317
+ const target = current.find((candidate) => candidate.id === caseId) ?? null;
318
+ if (!target)
319
+ throw new Error('The selected Case is unavailable for this finding.');
320
+ if (existingPinKey(target, finding, source.name)) {
321
+ return { cases: [...current], record: target, added: false };
322
+ }
323
+ const sourceLimitations = [
324
+ finding.evidenceClass === 'deployment_observation'
325
+ ? `Imported as an observation made by ${source.name}; this browser session did not collect or independently verify it.`
326
+ : `Reported by ${source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
327
+ ...(source.reference ? [`Source reference: ${source.reference}`] : []),
328
+ ...finding.limitations,
329
+ ];
330
+ const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
331
+ let updated = updateCase([...current], target.id, {
332
+ evidencePin: {
333
+ ...structuredPinFields(finding),
334
+ label: `External ${finding.category} finding`,
335
+ value: finding.structuredObservation?.value ?? pinValue(finding),
336
+ source: importedSourceLabel(finding, source.name),
337
+ observedAt: finding.observedAt,
338
+ completeness: finding.completeness,
339
+ limitations: sourceLimitations,
340
+ },
341
+ }, now);
342
+ const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
343
+ if (!addedPin)
344
+ throw new Error('The imported finding could not be retained as Case evidence.');
345
+ updated = updateCase(updated.cases, target.id, {
346
+ sighting: {
347
+ state: finding.evidenceClass === 'deployment_observation'
348
+ ? 'observed_by_deployment'
349
+ : 'reported_by_provider',
350
+ category: finding.category === 'dns'
351
+ ? 'delegation'
352
+ : finding.category === 'certificate'
353
+ ? 'certificate'
354
+ : finding.category === 'registration'
355
+ ? 'registration'
356
+ : finding.category === 'http' || finding.category === 'page'
357
+ ? 'website'
358
+ : 'other',
359
+ source: source.name,
360
+ observedAt: finding.observedAt,
361
+ completeness: finding.completeness,
362
+ evidencePinId: addedPin.id,
363
+ limitations: sourceLimitations,
364
+ },
365
+ }, now);
366
+ return { cases: updated.cases, record: updated.record, added: true };
367
+ }
314
368
  export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
315
369
  let cases = [...current];
316
370
  const createdDomains = new Set();
@@ -322,54 +376,12 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
322
376
  const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
323
377
  cases = opened.cases;
324
378
  const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
325
- if (existingPinKey(target, finding, document.source.name)) {
379
+ const merged = mergeExternalFindingIntoCase(cases, target.id, finding, document.source, now);
380
+ cases = merged.cases;
381
+ if (!merged.added) {
326
382
  duplicatesSkipped += 1;
327
383
  continue;
328
384
  }
329
- const sourceLimitations = [
330
- finding.evidenceClass === 'deployment_observation'
331
- ? `Imported as an observation made by ${document.source.name}; this browser session did not collect or independently verify it.`
332
- : `Reported by ${document.source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
333
- ...(document.source.reference ? [`Source reference: ${document.source.reference}`] : []),
334
- ...finding.limitations,
335
- ];
336
- const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
337
- const updated = updateCase(cases, target.id, {
338
- evidencePin: {
339
- ...structuredPinFields(finding),
340
- label: `External ${finding.category} finding`,
341
- value: finding.structuredObservation?.value ?? pinValue(finding),
342
- source: importedSourceLabel(finding, document.source.name),
343
- observedAt: finding.observedAt,
344
- completeness: finding.completeness,
345
- limitations: sourceLimitations,
346
- },
347
- }, now);
348
- cases = updated.cases;
349
- const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
350
- if (addedPin) {
351
- cases = updateCase(cases, updated.record.id, {
352
- sighting: {
353
- state: finding.evidenceClass === 'deployment_observation'
354
- ? 'observed_by_deployment'
355
- : 'reported_by_provider',
356
- category: finding.category === 'dns'
357
- ? 'delegation'
358
- : finding.category === 'certificate'
359
- ? 'certificate'
360
- : finding.category === 'registration'
361
- ? 'registration'
362
- : finding.category === 'http' || finding.category === 'page'
363
- ? 'website'
364
- : 'other',
365
- source: document.source.name,
366
- observedAt: finding.observedAt,
367
- completeness: finding.completeness,
368
- evidencePinId: addedPin.id,
369
- limitations: sourceLimitations,
370
- },
371
- }, now).cases;
372
- }
373
385
  findingsAdded += 1;
374
386
  if (existing)
375
387
  updatedDomains.add(finding.domain);
@@ -384,3 +396,60 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
384
396
  duplicatesSkipped,
385
397
  };
386
398
  }
399
+ function targetedFinding(finding, caseDomain) {
400
+ if (finding.domain === caseDomain)
401
+ return finding;
402
+ const prefix = `Captured hostname ${finding.domain}. `;
403
+ const maximumSummaryLength = MAX_EXTERNAL_FINDING_SUMMARY_LENGTH;
404
+ const remaining = maximumSummaryLength - prefix.length;
405
+ const summary = remaining > 1 && finding.summary.length > remaining
406
+ ? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
407
+ : `${prefix}${finding.summary}`;
408
+ return { ...finding, domain: caseDomain, summary };
409
+ }
410
+ export function externalFindingsCaseTargets(document, caseDomain) {
411
+ if (canonicalRegistrableDomain(caseDomain) !== caseDomain) {
412
+ throw new Error('The selected Case does not have a canonical registrable-domain identity.');
413
+ }
414
+ const targets = new Set();
415
+ for (const finding of document.findings) {
416
+ if (canonicalRegistrableDomain(finding.domain) !== caseDomain) {
417
+ throw new Error(`The imported finding for ${finding.domain} does not belong to the selected Case for ${caseDomain}.`);
418
+ }
419
+ targets.add(finding.domain);
420
+ }
421
+ return Object.freeze([...targets]);
422
+ }
423
+ /**
424
+ * Merges already-validated external findings into one selected Case. A Case is
425
+ * keyed to its registrable domain, while rendered captures may retain an exact
426
+ * hostname. Only same-domain findings are accepted, the exact hostname remains
427
+ * explicit in the retained evidence, and this operation can never open another
428
+ * Case as a side effect.
429
+ */
430
+ export function mergeExternalFindingsIntoCase(current, caseId, document, now = new Date().toISOString()) {
431
+ const selected = current.find((candidate) => candidate.id === caseId) ?? null;
432
+ if (!selected)
433
+ throw new Error('Select an existing Case before importing rendered-capture evidence.');
434
+ externalFindingsCaseTargets(document, selected.domain);
435
+ let cases = [...current];
436
+ let record = selected;
437
+ let findingsAdded = 0;
438
+ let duplicatesSkipped = 0;
439
+ for (const original of document.findings) {
440
+ const finding = targetedFinding(original, selected.domain);
441
+ const merged = mergeExternalFindingIntoCase(cases, selected.id, finding, document.source, now);
442
+ cases = merged.cases;
443
+ record = merged.record;
444
+ if (merged.added)
445
+ findingsAdded += 1;
446
+ else
447
+ duplicatesSkipped += 1;
448
+ }
449
+ return {
450
+ cases,
451
+ record,
452
+ findingsAdded,
453
+ duplicatesSkipped,
454
+ };
455
+ }