@slicedearth/whoisleuth-cli 2.2.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 +12 -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 +60 -11
  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 +6 -4
  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 +171 -51
  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 +3 -2
  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,
@@ -14,6 +14,7 @@ export const EXTERNAL_FINDING_CATEGORIES = [
14
14
  'registration',
15
15
  'reputation',
16
16
  ];
17
+ export const MAX_EXTERNAL_FINDING_SUMMARY_LENGTH = 900;
17
18
  const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
18
19
  const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
19
20
  const ROOT_KEYS = new Set(['schema', 'schemaVersion', 'source', 'findings']);
@@ -219,7 +220,7 @@ export function parseExternalFindingsDocument(value) {
219
220
  domain,
220
221
  category: item.category,
221
222
  evidenceClass,
222
- summary: requiredText(item.summary, 900, `Finding ${index + 1} summary`),
223
+ summary: requiredText(item.summary, MAX_EXTERNAL_FINDING_SUMMARY_LENGTH, `Finding ${index + 1} summary`),
223
224
  observedAt: iso(item.observedAt, `Finding ${index + 1} observation time`),
224
225
  completeness: item.completeness,
225
226
  limitations: limitations(item.limitations, index),
@@ -399,7 +400,7 @@ function targetedFinding(finding, caseDomain) {
399
400
  if (finding.domain === caseDomain)
400
401
  return finding;
401
402
  const prefix = `Captured hostname ${finding.domain}. `;
402
- const maximumSummaryLength = 900;
403
+ const maximumSummaryLength = MAX_EXTERNAL_FINDING_SUMMARY_LENGTH;
403
404
  const remaining = maximumSummaryLength - prefix.length;
404
405
  const summary = remaining > 1 && finding.summary.length > remaining
405
406
  ? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
@@ -130,13 +130,16 @@ function hostname(value) {
130
130
  return null;
131
131
  return candidate;
132
132
  }
133
- function urlHost(value) {
133
+ function webOrigin(value) {
134
134
  const candidate = text(value, 2_048);
135
135
  if (!candidate)
136
136
  return null;
137
137
  try {
138
- return new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`)
139
- .hostname.toLowerCase().replace(/\.$/u, '');
138
+ const parsed = new URL(/^[a-z][a-z\d+.-]*:\/\//iu.test(candidate) ? candidate : `https://${candidate}`);
139
+ const parsedHostname = hostname(parsed.hostname);
140
+ return parsedHostname && parsed.origin !== 'null'
141
+ ? Object.freeze({ origin: parsed.origin, hostname: parsedHostname })
142
+ : null;
140
143
  }
141
144
  catch {
142
145
  return null;
@@ -233,12 +236,12 @@ export function buildLookupAssetGraph(input) {
233
236
  ...textList(input.profileDomains?.partner, 32),
234
237
  ...textList(input.profileDomains?.allowlisted, 32),
235
238
  ].map((value) => value.toLowerCase().replace(/\.$/u, '')));
236
- const trustBoundary = (candidate, sourceHost = target) => {
239
+ const trustBoundary = (candidate, sourceHost = target, candidateOrigin = null, sourceOrigin = null) => {
237
240
  if (!candidate)
238
241
  return 'unresolved';
239
- if (candidate === sourceHost)
242
+ if (candidateOrigin && sourceOrigin && candidateOrigin === sourceOrigin)
240
243
  return 'same_origin';
241
- if (candidate === target || candidate.endsWith(`.${target}`))
244
+ if (candidate === sourceHost || candidate === target || candidate.endsWith(`.${target}`))
242
245
  return 'same_registrable_domain';
243
246
  if (reviewedProfileDomains.has(candidate))
244
247
  return 'reviewed_profile';
@@ -414,7 +417,8 @@ export function buildLookupAssetGraph(input) {
414
417
  }
415
418
  }
416
419
  const httpEvidence = record(input.httpEvidence);
417
- const finalHost = urlHost(httpEvidence.finalUrl);
420
+ const finalOrigin = webOrigin(httpEvidence.finalUrl);
421
+ const finalHost = finalOrigin?.hostname ?? null;
418
422
  const finalHostId = finalHost
419
423
  ? connect('hostname', finalHost, {
420
424
  kind: 'redirects-to',
@@ -429,9 +433,9 @@ export function buildLookupAssetGraph(input) {
429
433
  }, 'Observed final website origin')
430
434
  : null;
431
435
  const identitySource = finalHostId || targetId;
432
- const canonicalHost = urlHost(record(input.pageCanonical).url);
433
- if (canonicalHost) {
434
- connect('origin', canonicalHost, {
436
+ const canonicalOrigin = webOrigin(record(input.pageCanonical).url);
437
+ if (canonicalOrigin) {
438
+ connect('origin', canonicalOrigin.origin, {
435
439
  kind: 'declares-canonical',
436
440
  label: 'declares canonical origin',
437
441
  sourceLabel: 'HTML canonical metadata',
@@ -440,12 +444,12 @@ export function buildLookupAssetGraph(input) {
440
444
  limitations: limitations(record(input.pageIdentity).limitations),
441
445
  lenses: ['identity'],
442
446
  href: '#evidence-page',
443
- boundary: trustBoundary(canonicalHost, finalHost || target),
447
+ boundary: trustBoundary(canonicalOrigin.hostname, finalHost || target, canonicalOrigin.origin, finalOrigin?.origin ?? null),
444
448
  }, 'Publisher-declared canonical origin', identitySource);
445
449
  }
446
- const openGraphHost = urlHost(record(input.pageOpenGraphUrl).url);
447
- if (openGraphHost) {
448
- connect('origin', openGraphHost, {
450
+ const openGraphOrigin = webOrigin(record(input.pageOpenGraphUrl).url);
451
+ if (openGraphOrigin) {
452
+ connect('origin', openGraphOrigin.origin, {
449
453
  kind: 'declares-open-graph',
450
454
  label: 'declares Open Graph origin',
451
455
  sourceLabel: 'Open Graph metadata',
@@ -454,13 +458,13 @@ export function buildLookupAssetGraph(input) {
454
458
  limitations: limitations(record(input.pageIdentity).limitations),
455
459
  lenses: ['identity'],
456
460
  href: '#evidence-page',
457
- boundary: trustBoundary(openGraphHost, finalHost || target),
461
+ boundary: trustBoundary(openGraphOrigin.hostname, finalHost || target, openGraphOrigin.origin, finalOrigin?.origin ?? null),
458
462
  }, 'Publisher-declared Open Graph origin', identitySource);
459
463
  }
460
464
  for (const origin of textList(record(input.pageForms).externalActionOrigins)) {
461
- const host = urlHost(origin);
462
- if (host) {
463
- connect('origin', host, {
465
+ const destination = webOrigin(origin);
466
+ if (destination) {
467
+ connect('origin', destination.origin, {
464
468
  kind: 'form-destination',
465
469
  label: 'form may submit to',
466
470
  sourceLabel: 'Static HTML form',
@@ -469,14 +473,14 @@ export function buildLookupAssetGraph(input) {
469
473
  limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
470
474
  lenses: ['identity'],
471
475
  href: '#evidence-page',
472
- boundary: trustBoundary(host, finalHost || target),
476
+ boundary: trustBoundary(destination.hostname, finalHost || target, destination.origin, finalOrigin?.origin ?? null),
473
477
  }, 'External form-action origin', identitySource);
474
478
  }
475
479
  }
476
480
  for (const origin of textList(record(input.pageResources).externalOrigins)) {
477
- const host = urlHost(origin);
478
- if (host) {
479
- connect('hostname', host, {
481
+ const resource = webOrigin(origin);
482
+ if (resource) {
483
+ connect('origin', resource.origin, {
480
484
  kind: 'loads-from',
481
485
  label: 'references resources from',
482
486
  sourceLabel: 'Static HTML resources',
@@ -485,7 +489,7 @@ export function buildLookupAssetGraph(input) {
485
489
  limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
486
490
  lenses: ['identity'],
487
491
  href: '#evidence-page',
488
- boundary: trustBoundary(host, finalHost || target),
492
+ boundary: trustBoundary(resource.hostname, finalHost || target, resource.origin, finalOrigin?.origin ?? null),
489
493
  }, 'External resource origin', identitySource);
490
494
  }
491
495
  }
@@ -507,7 +511,7 @@ export function buildLookupAssetGraph(input) {
507
511
  const structuredIdentity = record(input.structuredDataIdentity);
508
512
  for (const entity of records(structuredIdentity.entities, 12)) {
509
513
  const entityLabel = text(entity.name, 180);
510
- const declaredOrigin = urlHost(entity.declaredOrigin);
514
+ const declaredOrigin = webOrigin(entity.declaredOrigin);
511
515
  const entityId = entityLabel
512
516
  ? connect('identity', entityLabel, {
513
517
  kind: 'declares-publisher',
@@ -524,7 +528,7 @@ export function buildLookupAssetGraph(input) {
524
528
  }, textList(entity.types, 5).join(', ') || 'Publisher-declared entity', identitySource)
525
529
  : identitySource;
526
530
  if (declaredOrigin) {
527
- connect('origin', declaredOrigin, {
531
+ connect('origin', declaredOrigin.origin, {
528
532
  kind: 'declares-origin',
529
533
  label: 'declares origin',
530
534
  sourceLabel: 'Structured identity metadata',
@@ -533,7 +537,7 @@ export function buildLookupAssetGraph(input) {
533
537
  limitations: limitations(structuredIdentity.limitations),
534
538
  lenses: ['identity'],
535
539
  href: '#evidence-structured-identity',
536
- boundary: trustBoundary(declaredOrigin, finalHost || target),
540
+ boundary: trustBoundary(declaredOrigin.hostname, finalHost || target, declaredOrigin.origin, finalOrigin?.origin ?? null),
537
541
  }, 'Publisher-declared structured-data origin', entityId || identitySource);
538
542
  }
539
543
  for (const sameAsHost of textList(entity.sameAsHosts, 12)) {
@@ -666,7 +670,11 @@ export function buildLookupAssetGraph(input) {
666
670
  }
667
671
  }
668
672
  const issuer = record(input.tlsIssuer);
669
- const issuerLabel = text(issuer.organization || issuer.commonName || issuer.CN, 180);
673
+ const issuerOrganizations = textList(issuer.organizations, 8);
674
+ const issuerCommonNames = textList(issuer.commonNames, 8);
675
+ const issuerLabel = issuerOrganizations[0]
676
+ || issuerCommonNames[0]
677
+ || text(issuer.organization || issuer.commonName || issuer.CN, 180);
670
678
  if (issuerLabel) {
671
679
  const issuerId = addNode('issuer', issuerLabel, 'Certificate issuer');
672
680
  if (issuerId) {