@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. package/third-party-notices.txt +29 -29
@@ -7,11 +7,13 @@ export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
7
7
  export const PUBLIC_BRAND_PROFILE_SCHEMA_VERSION = 6;
8
8
  // Historical readers retain a fixed published identity when the writer moves.
9
9
  export const PUBLISHED_V2_2_BRAND_PROFILE_SCHEMA_VERSION = 7;
10
- export const BRAND_PROFILE_SCHEMA_VERSION = 9;
10
+ export const BRAND_PROFILE_SCHEMA_VERSION = 11;
11
11
  export const BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([
12
12
  PUBLIC_BRAND_PROFILE_SCHEMA_VERSION,
13
13
  PUBLISHED_V2_2_BRAND_PROFILE_SCHEMA_VERSION,
14
14
  8,
15
+ 9,
16
+ 10,
15
17
  BRAND_PROFILE_SCHEMA_VERSION,
16
18
  ]);
17
19
  export const SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS = BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS;
@@ -21,6 +23,17 @@ export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
21
23
  export const MAX_PROFILE_STORE_BYTES = 4 * 1024 * 1024;
22
24
  export const MAX_PROFILE_IMPORT_BYTES = MAX_PROFILE_STORE_BYTES * 8;
23
25
  export const MAX_PROFILE_NAME_LENGTH = 100;
26
+ export const MAX_CANDIDATE_OBSERVATIONS = 200;
27
+ export const MAX_CANDIDATE_MATCHES = 20;
28
+ export const MAX_CANDIDATE_SOURCES = 12;
29
+ export const MAX_CANDIDATE_TEXT = 300;
30
+ export const MAX_CANDIDATE_EXCEPTIONS = 200;
31
+ export const MAX_CANDIDATE_EXCEPTION_HISTORY = 8;
32
+ export const MAX_BRAND_KEYWORD_CAMPAIGNS = 20;
33
+ export const MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY = 8;
34
+ export const MAX_BRAND_KEYWORD_TERMS = 20;
35
+ export const MAX_BRAND_KEYWORD_TERM_LENGTH = 80;
36
+ export const MAX_WATCH_CONTEXTS = 20;
24
37
  export const MAX_PROFILE_TEXT_LENGTH = 200;
25
38
  export const MAX_PROFILE_DOMAIN_LENGTH = 253;
26
39
  export const MAX_PROFILE_URL_LENGTH = 2_048;
@@ -47,14 +60,21 @@ export const MAX_CAMPAIGN_IMPORT_BYTES = 2 * 1024 * 1024;
47
60
  export const MAX_CAMPAIGN_INPUT_RECORDS = 500;
48
61
  export const MAX_CAMPAIGN_STORE_BYTES = 512 * 1024;
49
62
  export const WATCHLIST_SCHEMA = 'whoisleuth.watchlists';
50
- export const WATCHLIST_SCHEMA_VERSION = 4;
51
- export const WATCHLIST_BROWSER_SUPPORTED_VERSIONS = Object.freeze([2, 3, WATCHLIST_SCHEMA_VERSION]);
63
+ export const WATCHLIST_SCHEMA_VERSION = 6;
64
+ export const WATCHLIST_BROWSER_SUPPORTED_VERSIONS = Object.freeze([2, 3, 4, 5, WATCHLIST_SCHEMA_VERSION]);
52
65
  export const WATCHLIST_EXPORT_SUPPORTED_VERSIONS = WATCHLIST_BROWSER_SUPPORTED_VERSIONS;
53
66
  export const MAX_WATCHLISTS = 100;
54
67
  export const MAX_WATCHLIST_INPUTS = MAX_WATCHLISTS * 4;
55
68
  export const MAX_WATCHLIST_NAME_LENGTH = 100;
56
69
  export const MAX_WATCHLIST_STORE_BYTES = 2 * 1024 * 1024;
57
70
  export const MAX_WATCHLIST_IMPORT_BYTES = 2 * 1024 * 1024;
71
+ // Recovery metadata is not new analyst data. Reserve only its exact JSON cost;
72
+ // the store writer still enforces the ordinary budget after subtracting it.
73
+ export const WATCHLIST_RECOVERY_METADATA = Object.freeze({ membershipRecovery: 'legacy_overflow' });
74
+ export const WATCHLIST_RECOVERY_METADATA_BYTES = JSON.stringify(WATCHLIST_RECOVERY_METADATA).length - 1;
75
+ export const MAX_WATCHLIST_READ_BYTES = MAX_WATCHLIST_STORE_BYTES + MAX_WATCHLISTS * WATCHLIST_RECOVERY_METADATA_BYTES;
76
+ // Date.toISOString() produces at most 27 ASCII characters, including extended years.
77
+ export const MAX_WATCHLIST_PORTABLE_BYTES = MAX_WATCHLIST_READ_BYTES + JSON.stringify({ exportedAt: '+999999-12-31T23:59:59.999Z' }).length - 1;
58
78
  export const MAX_WATCHLIST_HISTORY_EVENTS = 12;
59
79
  export const MAX_WATCHLIST_CHANGES_PER_EVENT = 500;
60
80
  export const MAX_WATCHLIST_DOMAINS = 2_000;
@@ -82,9 +102,9 @@ export const MAX_CT_HISTORY_EVER_SEEN_DOMAINS = 1_000;
82
102
  export const MAX_CT_HISTORY_QUERY_LENGTH = 200;
83
103
  export const MAX_CT_HISTORY_STORE_BYTES = 1024 * 1024;
84
104
  export const DETECTION_RULE_SCHEMA = 'whoisleuth.detection-rules';
85
- export const DETECTION_RULE_SCHEMA_VERSION = 1;
86
- export const DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([DETECTION_RULE_SCHEMA_VERSION]);
87
- export const DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS = Object.freeze([DETECTION_RULE_SCHEMA_VERSION]);
105
+ export const DETECTION_RULE_SCHEMA_VERSION = 2;
106
+ export const DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([1, DETECTION_RULE_SCHEMA_VERSION]);
107
+ export const DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS = DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS;
88
108
  export const MAX_DETECTION_RULES = 50;
89
109
  export const MAX_RULE_CONDITIONS = 8;
90
110
  export const MAX_RULE_INPUT_RECORDS = 250;
@@ -114,8 +134,8 @@ export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
114
134
  export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
115
135
  export const MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES = 768 * 1024;
116
136
  export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
117
- export const BULK_SESSION_SCHEMA_VERSION = 6;
118
- export const BULK_SESSION_BROWSER_SUPPORTED_VERSIONS = Object.freeze([4, 5, BULK_SESSION_SCHEMA_VERSION]);
137
+ export const BULK_SESSION_SCHEMA_VERSION = 7;
138
+ export const BULK_SESSION_BROWSER_SUPPORTED_VERSIONS = Object.freeze([4, 5, 6, BULK_SESSION_SCHEMA_VERSION]);
119
139
  export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = BULK_SESSION_BROWSER_SUPPORTED_VERSIONS;
120
140
  export const MAX_BULK_SESSIONS = 10;
121
141
  export const MAX_BULK_SESSION_ROWS = 2_000;
@@ -180,12 +200,14 @@ export const WORKSPACE_PORTABILITY_IDENTITY_CONSTANTS = Object.freeze([
180
200
  export const WORKSPACE_PORTABILITY_BOUND_CONSTANTS = Object.freeze([
181
201
  'MAX_PROFILES', 'MAX_PROFILE_VALUES', 'MAX_PROFILE_VALUE_INPUTS', 'MAX_PROFILE_STORE_BYTES', 'MAX_PROFILE_IMPORT_BYTES',
182
202
  'MAX_PROFILE_NAME_LENGTH', 'MAX_PROFILE_TEXT_LENGTH', 'MAX_PROFILE_DOMAIN_LENGTH', 'MAX_PROFILE_URL_LENGTH', 'MAX_PROFILE_TLD_LENGTH',
203
+ 'MAX_BRAND_KEYWORD_CAMPAIGNS', 'MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY', 'MAX_BRAND_KEYWORD_TERMS', 'MAX_BRAND_KEYWORD_TERM_LENGTH',
183
204
  'MAX_OFFICIAL_CHANNELS', 'MAX_RIGHTS_REFERENCES', 'MAX_DKIM_SELECTOR_LENGTH',
184
205
  'MAX_DKIM_SELECTORS', 'MAX_PROTECTION_ATTESTATIONS', 'MAX_DESIRED_POSTURE_BASELINES', 'MAX_DESIRED_POSTURE_RECORDS',
185
206
  'MAX_DESIRED_POSTURE_SUPPRESSIONS', 'MAX_DESIRED_POSTURE_OBSERVATIONS', 'MAX_DESIRED_POSTURE_CHANGE_WINDOWS',
186
207
  'MAX_CAMPAIGNS', 'MAX_CAMPAIGN_DOMAINS', 'MAX_CAMPAIGN_NAME_LENGTH', 'MAX_CAMPAIGN_DESCRIPTION_LENGTH',
187
208
  'MAX_CAMPAIGN_IMPORT_BYTES', 'MAX_CAMPAIGN_INPUT_RECORDS', 'MAX_CAMPAIGN_STORE_BYTES',
188
209
  'MAX_WATCHLISTS', 'MAX_WATCHLIST_INPUTS', 'MAX_WATCHLIST_NAME_LENGTH', 'MAX_WATCHLIST_STORE_BYTES', 'MAX_WATCHLIST_IMPORT_BYTES',
210
+ 'MAX_WATCHLIST_READ_BYTES', 'MAX_WATCHLIST_PORTABLE_BYTES',
189
211
  'MAX_WATCHLIST_HISTORY_EVENTS', 'MAX_WATCHLIST_CHANGES_PER_EVENT', 'MAX_WATCHLIST_DOMAINS', 'MAX_WATCHLIST_INPUT_RECORDS',
190
212
  'MAX_WATCHLIST_NAMESERVERS', 'MAX_WATCHLIST_MUTATION_TYPES', 'MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS',
191
213
  'MAX_SHORTLIST_ENTRIES', 'MAX_SHORTLIST_INPUTS', 'MAX_SHORTLIST_STORE_BYTES', 'MAX_SHORTLIST_IMPORT_BYTES', 'MAX_SHORTLIST_FACTORS',
@@ -229,7 +251,7 @@ export const BRAND_PROFILE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
229
251
  supportedVersions: BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
230
252
  futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
231
253
  byteBudget: MAX_PROFILE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
232
- note: 'Published versions 6, 7 and 8 migrate directly to the current profile envelope. Version 9 preserves earlier page baselines while admitting independently versioned native-tree fingerprints; incompatible fingerprint algorithms are not compared.',
254
+ note: 'Versions 6–10 remain readable without invented keyword campaigns. Version 11 adds bounded, revisioned, time-limited Brand keyword intent without collection authority; candidate provenance and independent page-fingerprint algorithms remain intact.',
233
255
  });
234
256
  export const CAMPAIGN_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
235
257
  id: 'browser.campaigns', kind: 'browser_store', schema: null, currentVersion: CAMPAIGN_SCHEMA_VERSION,
@@ -242,7 +264,7 @@ export const WATCHLIST_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
242
264
  id: 'browser.watchlists', kind: 'browser_store', schema: null, currentVersion: WATCHLIST_SCHEMA_VERSION,
243
265
  supportedVersions: WATCHLIST_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
244
266
  futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
245
- byteBudget: MAX_WATCHLIST_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
267
+ byteBudget: MAX_WATCHLIST_READ_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
246
268
  note: 'Public version 2 remains readable without inferred collection outcomes. Version 3 retains explicit page and favicon collection quality; unversioned maps are outside the compatibility boundary.',
247
269
  });
248
270
  export const SHORTLIST_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
@@ -262,7 +284,7 @@ export const CT_HISTORY_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
262
284
  export const DETECTION_RULE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
263
285
  id: 'browser.detection-rules', kind: 'browser_store', schema: null, currentVersion: DETECTION_RULE_SCHEMA_VERSION,
264
286
  supportedVersions: DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
265
- futureVersionBehavior: 'preserve_without_write', migration: 'exact_current_only', writeSemantics: 'normalized_rewrite',
287
+ futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
266
288
  byteBudget: MAX_RULE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
267
289
  note: 'Only allowlisted structured rule fields and operators survive normalisation; the schema string belongs to portable exports.',
268
290
  });
@@ -306,7 +328,7 @@ export const BRAND_PROFILE_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
306
328
  supportedVersions: SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, acceptsUnversionedLegacy: false,
307
329
  futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
308
330
  byteBudget: MAX_PROFILE_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
309
- note: 'Supported exports merge non-destructively by bounded normalised profile identity.',
331
+ note: 'Supported exports merge non-destructively by bounded profile identity. Candidate decisions cannot be reassigned to a different Brand identifier with the same name.',
310
332
  });
311
333
  export const CAMPAIGN_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
312
334
  id: 'export.campaigns', kind: 'export', schema: CAMPAIGN_SCHEMA, currentVersion: CAMPAIGN_SCHEMA_VERSION,
@@ -319,8 +341,8 @@ export const WATCHLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
319
341
  id: 'export.watchlists', kind: 'export', schema: WATCHLIST_SCHEMA, currentVersion: WATCHLIST_SCHEMA_VERSION,
320
342
  supportedVersions: WATCHLIST_EXPORT_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
321
343
  futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
322
- byteBudget: MAX_WATCHLIST_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
323
- note: 'Public version 2 and current version 3 merge non-destructively. Historical records have unknown collection quality; incomplete current checks cannot erase a usable baseline.',
344
+ byteBudget: MAX_WATCHLIST_PORTABLE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
345
+ note: 'Versions 2–5 remain readable. Version 6 preserves older membership overflow as paused, exportable records without activating or pruning domains. New active membership remains bounded. Brand contexts and incomplete-check baselines remain preserved.',
324
346
  });
325
347
  export const SHORTLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
326
348
  id: 'export.shortlist', kind: 'export', schema: SHORTLIST_SCHEMA, currentVersion: SHORTLIST_SCHEMA_VERSION,
@@ -332,7 +354,7 @@ export const SHORTLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
332
354
  export const DETECTION_RULE_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
333
355
  id: 'export.detection-rules', kind: 'export', schema: DETECTION_RULE_SCHEMA, currentVersion: DETECTION_RULE_SCHEMA_VERSION,
334
356
  supportedVersions: DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
335
- futureVersionBehavior: 'reject', migration: 'exact_current_only', writeSemantics: 'non_destructive_merge',
357
+ futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
336
358
  byteBudget: MAX_RULE_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
337
359
  note: 'Non-destructive rule merge; imported conditions remain allowlisted and non-executable.',
338
360
  });
@@ -421,6 +443,15 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
421
443
  itemBounds: [
422
444
  { id: 'profiles', path: 'profiles', maximum: MAX_PROFILES, handling: 'truncate' },
423
445
  { id: 'profile-values', path: 'profiles[].values', maximum: MAX_PROFILE_VALUES, handling: 'truncate' },
446
+ { id: 'candidate-observations', path: 'profiles[].candidateObservations', maximum: MAX_CANDIDATE_OBSERVATIONS, handling: 'truncate' },
447
+ { id: 'candidate-matches', path: 'profiles[].candidateObservations[].matches', maximum: MAX_CANDIDATE_MATCHES, handling: 'reject' },
448
+ { id: 'candidate-sources', path: 'profiles[].candidateObservations[].sources', maximum: MAX_CANDIDATE_SOURCES, handling: 'reject' },
449
+ { id: 'candidate-exceptions', path: 'profiles[].candidateExceptions', maximum: MAX_CANDIDATE_EXCEPTIONS, handling: 'truncate' },
450
+ { id: 'exception-history', path: 'profiles[].candidateExceptions[].history', maximum: MAX_CANDIDATE_EXCEPTION_HISTORY, handling: 'truncate' },
451
+ { id: 'keyword-campaigns', path: 'profiles[].keywordCampaigns', maximum: MAX_BRAND_KEYWORD_CAMPAIGNS, handling: 'reject' },
452
+ { id: 'keyword-campaign-history', path: 'profiles[].keywordCampaigns[].history', maximum: MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY, handling: 'reject' },
453
+ { id: 'keyword-positive-terms', path: 'profiles[].keywordCampaigns[].positiveTerms', maximum: MAX_BRAND_KEYWORD_TERMS, handling: 'reject' },
454
+ { id: 'keyword-negative-terms', path: 'profiles[].keywordCampaigns[].negativeTerms', maximum: MAX_BRAND_KEYWORD_TERMS, handling: 'reject' },
424
455
  ],
425
456
  portable: {
426
457
  descriptor: BRAND_PROFILE_EXPORT_COMPATIBILITY,
@@ -466,14 +497,17 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
466
497
  { id: 'watchlists', path: 'watchlists', maximum: MAX_WATCHLISTS, handling: 'truncate' },
467
498
  { id: 'domains', path: 'watchlists[].results', maximum: MAX_WATCHLIST_DOMAINS, handling: 'drop_value' },
468
499
  { id: 'history', path: 'watchlists[].history', maximum: MAX_WATCHLIST_HISTORY_EVENTS, handling: 'truncate' },
500
+ { id: 'domain-contexts', path: 'watchlists[].domainMetadata', maximum: MAX_WATCHLIST_DOMAINS, handling: 'reject' },
501
+ { id: 'brand-contexts', path: 'watchlists[].domainMetadata[].contexts', maximum: MAX_WATCH_CONTEXTS, handling: 'truncate' },
469
502
  ],
470
503
  portable: {
471
504
  descriptor: WATCHLIST_EXPORT_COMPATIBILITY,
472
505
  schema: WATCHLIST_SCHEMA,
473
506
  builder: 'buildWatchlistExport',
474
507
  merger: 'mergeWatchlistStores',
475
- serializerHookId: 'workspace.portable.json',
508
+ serializerHookId: 'workspace.portable.watchlist-json',
476
509
  indentSpaces: 2,
510
+ compactFallback: true,
477
511
  terminalLf: false,
478
512
  },
479
513
  },
@@ -649,6 +683,34 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
649
683
  },
650
684
  ]);
651
685
  const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
686
+ { id: 'workspace.browser.brand.v11', path: 'test/fixtures/workspace-lifecycle/browser-brand-v11.json', bytes: 38, sha256: 'c727cd501774f0f89a39f399a01216065b6ed7048bfed97bbcdaf10e651a7133', schema: 'whoisleuth.browser.brand-profile-store', version: 11 },
687
+ { id: 'workspace.portable.brand.v11', path: 'test/fixtures/workspace-lifecycle/portable-brand-v11.json', bytes: 123, sha256: 'c5023e1eebe187cf355c4fb53b7d8a755dc0ffb01a3deeb9b9dd7032007933d9', schema: 'whoisleuth.brand-profiles', version: 11 },
688
+ { id: 'workspace.browser.watchlist.v6', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v6.json', bytes: 76, sha256: '16df0f813b17f1765862f5c2ff41c2db9c7d6d76e75f24746aa6d75b59d3f02e', schema: 'whoisleuth.browser.watchlist-store', version: 6 },
689
+ { id: 'workspace.portable.watchlist.v6', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v6.json', bytes: 120, sha256: 'ced22d0d16e839e0b4625f8799eef30a776908abf3af998de6fad5b549898987', schema: 'whoisleuth.watchlists', version: 6 },
690
+ { id: 'workspace.browser.brand.v10', path: 'test/fixtures/workspace-lifecycle/browser-brand-v10.json', bytes: 38, sha256: 'bebf3a72ba57c06246e6169ebd9a597eb5b867508db85e1db0e3d763bc0839bc', schema: 'whoisleuth.browser.brand-profile-store', version: 10 },
691
+ { id: 'workspace.portable.brand.v10', path: 'test/fixtures/workspace-lifecycle/portable-brand-v10.json', bytes: 123, sha256: '85cd7c58696f8219484ac88210ad7ab4a0ad1c9798e8cb02ff6ebb3fd2c82d8a', schema: 'whoisleuth.brand-profiles', version: 10 },
692
+ { id: 'workspace.browser.watchlist.v5', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v5.json', bytes: 76, sha256: '11eabd20f952611307a5c84291c7892c15d3748f7ada5549813e1d705967c2c0', schema: 'whoisleuth.browser.watchlist-store', version: 5 },
693
+ { id: 'workspace.portable.watchlist.v5', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v5.json', bytes: 120, sha256: '732d4bda46e907bdd4d343ce36999a2787a0057029ee4dba0c4ef462f2822fed', schema: 'whoisleuth.watchlists', version: 5 },
694
+ {
695
+ id: 'workspace.browser.detection.v2', path: 'test/fixtures/workspace-lifecycle/browser-detection-v2.json',
696
+ bytes: 34, sha256: '2db6ae8eb9d24cb86924a7020c6f3eebfa41b5fc5848a53e239465db622c2312',
697
+ schema: 'whoisleuth.browser.detection-rule-store', version: 2,
698
+ },
699
+ {
700
+ id: 'workspace.portable.detection.v2', path: 'test/fixtures/workspace-lifecycle/portable-detection-v2.json',
701
+ bytes: 293, sha256: 'bf4409e18ef0b721f5a7e2f14a06f2fada88287114ec17775fef42e7ed38072a',
702
+ schema: 'whoisleuth.detection-rules', version: 2,
703
+ },
704
+ {
705
+ id: 'workspace.browser.bulk.v7', path: 'test/fixtures/workspace-lifecycle/browser-bulk-v7.json',
706
+ bytes: 77, sha256: '6b02cbd48a939a34d8f292da2b6bc8e8b59f3ed1b9c106a8e9e271ec0e949459',
707
+ schema: 'whoisleuth.browser.bulk-session-store', version: 7,
708
+ },
709
+ {
710
+ id: 'workspace.portable.bulk.v7', path: 'test/fixtures/workspace-lifecycle/portable-bulk-v7.json',
711
+ bytes: 338, sha256: 'f7e14d866eeb4e94b5390086e278348bfa0610ace72776fee4fbf24871ab3393',
712
+ schema: 'whoisleuth.bulk-sessions', version: 7,
713
+ },
652
714
  {
653
715
  id: 'workspace.browser.watchlist.v4', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v4.json',
654
716
  bytes: 76, sha256: '059930ee54d1bc65ac68ef6d3a8ac8cd8e53f682fe9a857512e2b6c57e93d5fe',
@@ -1146,6 +1208,13 @@ const WORKSPACE_LIFECYCLE_HOOKS = Object.freeze([
1146
1208
  module: WORKSPACE_CONTRACT_OWNER,
1147
1209
  exportName: 'serialiseWorkspacePortableJson',
1148
1210
  },
1211
+ {
1212
+ id: 'workspace.portable.watchlist-json',
1213
+ role: 'serialiser',
1214
+ runtime: 'shared',
1215
+ module: 'packages/workspace/watchlist-store.mts',
1216
+ exportName: 'serializeWatchlistExport',
1217
+ },
1149
1218
  {
1150
1219
  id: 'workspace.portable.json-line',
1151
1220
  role: 'serialiser',
@@ -1254,6 +1323,7 @@ function workspacePortableSerialisation(definition) {
1254
1323
  encoding: 'utf-8',
1255
1324
  bom: false,
1256
1325
  indentSpaces: definition.portable.indentSpaces,
1326
+ ...(definition.portable.compactFallback ? { compactFallback: true } : {}),
1257
1327
  terminalLf: definition.portable.terminalLf,
1258
1328
  propertyOrder: 'normalised_fixed',
1259
1329
  canonicalisation: null,
@@ -35,7 +35,7 @@ export function webCollectionQualityForCapture(value, depth) {
35
35
  return capturedWebCollectionQuality('not_collected', 'not_collected');
36
36
  return normalizeWebCollectionQuality(value) ?? capturedWebCollectionQuality('unknown', 'unknown');
37
37
  }
38
- const PAGE_FIELDS = new Set(['activityStatus', 'pageTitle', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch', 'pageBaselineMatch']);
38
+ const PAGE_FIELDS = new Set(['activityStatus', 'pageTitle', 'hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'phishingLanguageMatch', 'pageBaselineMatch']);
39
39
  const FAVICON_FIELDS = new Set(['faviconHash', 'faviconPHash', 'faviconMatch', 'faviconNearMatch']);
40
40
  const COMBINED_FIELDS = new Set(['riskScore', 'riskFactors', 'riskModelVersion', 'opportunityScore', 'opportunityFactors', 'reusesOfficialAssets']);
41
41
  /** Missing historical quality never proves that a false/null value was observed. */
@@ -0,0 +1,60 @@
1
+ export const CREDENTIAL_CATEGORY_NAMES = ['password', 'email', 'username', 'one_time_code', 'payment'];
2
+ export const MAX_CREDENTIAL_FORMS = 50;
3
+ export const MAX_CREDENTIAL_INPUTS = 500;
4
+ export const MAX_FORM_DESTINATIONS = 10;
5
+ function record(value) {
6
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
7
+ }
8
+ function keys(value, expected) {
9
+ return Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
10
+ }
11
+ const count = (value, max) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max;
12
+ export function validCredentialFormAttribution(value) {
13
+ if (!record(value) || !keys(value, ['complete', 'unassociatedInputs', 'forms']) || typeof value.complete !== 'boolean'
14
+ || !count(value.unassociatedInputs, MAX_CREDENTIAL_INPUTS) || !Array.isArray(value.forms) || value.forms.length > MAX_CREDENTIAL_FORMS)
15
+ return false;
16
+ let classified = 0;
17
+ for (const [index, form] of value.forms.entries()) {
18
+ if (!record(form) || !keys(form, ['index', 'categories', 'destinations']) || form.index !== index + 1
19
+ || !record(form.categories) || !keys(form.categories, CREDENTIAL_CATEGORY_NAMES)
20
+ || !CREDENTIAL_CATEGORY_NAMES.every(key => count(form.categories[key], MAX_CREDENTIAL_INPUTS))
21
+ || !Array.isArray(form.destinations) || !form.destinations.length || form.destinations.length > MAX_FORM_DESTINATIONS)
22
+ return false;
23
+ classified += Math.max(...CREDENTIAL_CATEGORY_NAMES.map(key => Number(form.categories[key])));
24
+ const seen = new Set();
25
+ for (const destination of form.destinations) {
26
+ if (!record(destination) || !keys(destination, ['relationship', 'origin']))
27
+ return false;
28
+ if (destination.relationship === 'unknown' || destination.relationship === 'no_submission') {
29
+ if (destination.origin !== null || (destination.relationship === 'unknown' && value.complete))
30
+ return false;
31
+ }
32
+ else if (destination.relationship === 'external' || destination.relationship === 'same_origin') {
33
+ if (typeof destination.origin !== 'string' || destination.origin.length > 320)
34
+ return false;
35
+ try {
36
+ const url = new URL(destination.origin);
37
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== destination.origin || url.username || url.password)
38
+ return false;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ else
45
+ return false;
46
+ const identity = `${destination.relationship}:${destination.origin}`;
47
+ if (seen.has(identity))
48
+ return false;
49
+ seen.add(identity);
50
+ }
51
+ }
52
+ return classified + value.unassociatedInputs <= MAX_CREDENTIAL_INPUTS && (!value.complete || value.unassociatedInputs === 0);
53
+ }
54
+ export function externalPasswordFormObservation(value) {
55
+ if (!validCredentialFormAttribution(value))
56
+ return null;
57
+ if (value.forms.some(form => form.categories.password > 0 && form.destinations.some(destination => destination.relationship === 'external')))
58
+ return true;
59
+ return value.complete ? false : null;
60
+ }
@@ -5,6 +5,21 @@ export const MAX_EVIDENCE_IMAGE_PIXELS = 16 * 1024 * 1024;
5
5
  export const MAX_EVIDENCE_IMAGE_DIMENSION = 10_000;
6
6
  // Each selected region emits at most four solid rectangles; no nested effects.
7
7
  export const MAX_EVIDENCE_IMAGE_REGIONS = 64;
8
+ export function readImageDerivationDeclaration(raw) {
9
+ const value = exact(raw, ['method', 'source', 'operations'], 'Image derivation declaration');
10
+ if (value.method !== 'png-regions-v1')
11
+ throw new TypeError('This image derivation method is unsupported.');
12
+ const operations = Array.from(array(value.operations, 'Image operations', 2, 1), operation => enumeration(operation, ['redact', 'outline'], 'Image operation'));
13
+ if (new Set(operations).size !== operations.length)
14
+ throw new TypeError('Image operations must be unique.');
15
+ return Object.freeze({ method: value.method, source: readRetainedFileReference(value.source), operations: Object.freeze(operations) });
16
+ }
17
+ /** Retain the immediate parent's fingerprint, without identifiers or region coordinates. */
18
+ export function declareImageDerivation(raw) {
19
+ const derivation = readImageDerivation(raw);
20
+ const operations = ['redact', 'outline'].filter(kind => derivation.plan.regions.some(region => region.kind === kind));
21
+ return readImageDerivationDeclaration({ method: derivation.method, source: derivation.source, operations });
22
+ }
8
23
  export function readEvidenceImageDimensions(width, height) {
9
24
  const result = {
10
25
  width: integer(width, 'Image width', 1, MAX_EVIDENCE_IMAGE_DIMENSION),
@@ -1,6 +1,7 @@
1
1
  // Shared collision-resistant identities for bounded, already normalised records.
2
2
  // Platform cryptography remains the owner of signatures and authentication.
3
3
  export const MAX_IDENTITY_DIGEST_BYTES = 1024 * 1024;
4
+ export const MAX_INCREMENTAL_DIGEST_BYTES = 256 * 1024 * 1024;
4
5
  const SHA256_CONSTANTS = Object.freeze([
5
6
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
6
7
  0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
@@ -14,27 +15,21 @@ const SHA256_CONSTANTS = Object.freeze([
14
15
  function rotateRight(value, bits) {
15
16
  return (value >>> bits) | (value << (32 - bits));
16
17
  }
17
- export function sha256IdentityHex(bytes) {
18
- if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
19
- throw new RangeError('Record identity exceeds its byte limit.');
20
- // This digest is a collision-resistant record identity, not a password,
21
- // signature, MAC, or substitute for the platform cryptography used there.
22
- const paddedLength = Math.ceil((bytes.byteLength + 9) / 64) * 64;
23
- const padded = new Uint8Array(paddedLength);
24
- padded.set(bytes);
25
- padded[bytes.byteLength] = 0x80;
26
- const view = new DataView(padded.buffer);
27
- const bitLength = bytes.byteLength * 8;
28
- view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x1_0000_0000), false);
29
- view.setUint32(paddedLength - 4, bitLength >>> 0, false);
18
+ /** Incremental content identity only; never authentication or publisher proof. */
19
+ export function createIncrementalSha256(maximumBytes = MAX_IDENTITY_DIGEST_BYTES) {
20
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0 || maximumBytes > MAX_INCREMENTAL_DIGEST_BYTES)
21
+ throw new RangeError('Content identity has an invalid byte limit.');
30
22
  const state = new Uint32Array([
31
23
  0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
32
24
  0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
33
25
  ]);
34
26
  const words = new Uint32Array(64);
35
- for (let offset = 0; offset < paddedLength; offset += 64) {
27
+ const pending = new Uint8Array(64);
28
+ let pendingBytes = 0, totalBytes = 0, finished = false;
29
+ function block(bytes, offset) {
30
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 64);
36
31
  for (let index = 0; index < 16; index += 1)
37
- words[index] = view.getUint32(offset + index * 4, false);
32
+ words[index] = view.getUint32(index * 4, false);
38
33
  for (let index = 16; index < 64; index += 1) {
39
34
  const left = words[index - 15];
40
35
  const right = words[index - 2];
@@ -68,5 +63,54 @@ export function sha256IdentityHex(bytes) {
68
63
  state[6] = (state[6] + g) >>> 0;
69
64
  state[7] = (state[7] + h) >>> 0;
70
65
  }
71
- return [...state].map((value) => value.toString(16).padStart(8, '0')).join('');
66
+ return Object.freeze({
67
+ update(bytes) {
68
+ if (finished)
69
+ throw new Error('Content identity is already finalised.');
70
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength > maximumBytes - totalBytes)
71
+ throw new RangeError('Content identity exceeds its byte limit.');
72
+ totalBytes += bytes.byteLength;
73
+ let offset = 0;
74
+ if (pendingBytes) {
75
+ const length = Math.min(64 - pendingBytes, bytes.byteLength);
76
+ pending.set(bytes.subarray(0, length), pendingBytes);
77
+ pendingBytes += length;
78
+ offset += length;
79
+ if (pendingBytes === 64) {
80
+ block(pending, 0);
81
+ pendingBytes = 0;
82
+ }
83
+ }
84
+ while (offset + 64 <= bytes.byteLength) {
85
+ block(bytes, offset);
86
+ offset += 64;
87
+ }
88
+ if (offset < bytes.byteLength) {
89
+ pending.set(bytes.subarray(offset), 0);
90
+ pendingBytes = bytes.byteLength - offset;
91
+ }
92
+ },
93
+ digestHex() {
94
+ if (finished)
95
+ throw new Error('Content identity is already finalised.');
96
+ finished = true;
97
+ const padding = new Uint8Array(pendingBytes < 56 ? 64 : 128);
98
+ padding.set(pending.subarray(0, pendingBytes));
99
+ padding[pendingBytes] = 0x80;
100
+ const view = new DataView(padding.buffer), bits = totalBytes * 8;
101
+ view.setUint32(padding.length - 8, Math.floor(bits / 0x1_0000_0000), false);
102
+ view.setUint32(padding.length - 4, bits >>> 0, false);
103
+ block(padding, 0);
104
+ if (padding.length === 128)
105
+ block(padding, 64);
106
+ return [...state].map(value => value.toString(16).padStart(8, '0')).join('');
107
+ },
108
+ });
109
+ }
110
+ export function sha256IdentityHex(bytes) {
111
+ if (bytes.byteLength > MAX_IDENTITY_DIGEST_BYTES)
112
+ throw new RangeError('Record identity exceeds its byte limit.');
113
+ const digest = createIncrementalSha256();
114
+ digest.update(bytes);
115
+ return digest.digestHex();
72
116
  }
@@ -0,0 +1,51 @@
1
+ const TECHNOLOGY_EVIDENCE_ROLE_ORDER = Object.freeze([
2
+ 'observed_edge',
3
+ 'application_platform',
4
+ 'framework_runtime',
5
+ 'embedded_dependency',
6
+ ]);
7
+ const TECHNOLOGY_EVIDENCE_ROLE_SET = new Set(TECHNOLOGY_EVIDENCE_ROLE_ORDER);
8
+ function record(value) {
9
+ return value && typeof value === 'object' && !Array.isArray(value)
10
+ ? value
11
+ : {};
12
+ }
13
+ function technologyEvidenceRoles(value) {
14
+ const finding = record(value);
15
+ const roles = new Set();
16
+ let hasExplicitRole = false;
17
+ for (const role of Array.isArray(finding.roles) ? finding.roles.slice(0, 4) : []) {
18
+ if (typeof role === 'string' && TECHNOLOGY_EVIDENCE_ROLE_SET.has(role)) {
19
+ roles.add(role);
20
+ hasExplicitRole = true;
21
+ }
22
+ }
23
+ const evidenceValues = Array.isArray(finding.evidence) ? finding.evidence.slice(0, 4) : [];
24
+ for (const evidenceValue of evidenceValues) {
25
+ const evidence = record(evidenceValue);
26
+ if (typeof evidence.role === 'string'
27
+ && TECHNOLOGY_EVIDENCE_ROLE_SET.has(evidence.role)) {
28
+ roles.add(evidence.role);
29
+ hasExplicitRole = true;
30
+ }
31
+ }
32
+ if (!hasExplicitRole) {
33
+ const resourceOriginOnly = evidenceValues.length > 0
34
+ && evidenceValues.every((evidence) => (String(record(evidence).source).toLowerCase() === 'resource origin'));
35
+ if (evidenceValues.some((evidence) => String(record(evidence).source).toLowerCase() === 'resource origin')) {
36
+ roles.add('embedded_dependency');
37
+ }
38
+ if (!resourceOriginOnly) {
39
+ const category = String(finding.category).toLowerCase();
40
+ if (category === 'delivery platform')
41
+ roles.add('observed_edge');
42
+ else if (['application runtime', 'web framework', 'static site generator', 'web server'].includes(category)) {
43
+ roles.add('framework_runtime');
44
+ }
45
+ else
46
+ roles.add('application_platform');
47
+ }
48
+ }
49
+ return TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => roles.has(role));
50
+ }
51
+ export { TECHNOLOGY_EVIDENCE_ROLE_ORDER, technologyEvidenceRoles, };
@@ -7,6 +7,7 @@ import { sha256IdentityHex } from "../evidence/record-identity.mjs";
7
7
  import { MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, normalizeCaseEvidencePins, normalizeCaseSightings, } from "../cases/case-response-model.mjs";
8
8
  import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
9
9
  import { hasUnsafeRetainedText } from "./retained-text.mjs";
10
+ import { readInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
10
11
  export { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, };
11
12
  export const EXTERNAL_FINDING_CATEGORIES = [
12
13
  'certificate',
@@ -33,6 +34,7 @@ const FINDING_KEYS = new Set([
33
34
  'limitations',
34
35
  'reference',
35
36
  'structuredObservation',
37
+ 'infrastructureObservation',
36
38
  ]);
37
39
  const STRUCTURED_OBSERVATION_KEYS = new Set([
38
40
  'sourceSchema',
@@ -191,8 +193,8 @@ export function parseExternalFindingsDocument(value) {
191
193
  throw new Error('External findings must use the documented object shape without additional top-level fields.');
192
194
  }
193
195
  if (root.schema !== EXTERNAL_FINDINGS_SCHEMA
194
- || root.schemaVersion !== EXTERNAL_FINDINGS_VERSION) {
195
- throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version ${EXTERNAL_FINDINGS_VERSION}.`);
196
+ || (root.schemaVersion !== 4 && root.schemaVersion !== 5)) {
197
+ throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 4 or 5.`);
196
198
  }
197
199
  const sourceValue = record(root.source);
198
200
  if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
@@ -207,6 +209,7 @@ export function parseExternalFindingsDocument(value) {
207
209
  throw new Error(`External findings must contain between 1 and ${MAX_EXTERNAL_FINDINGS} findings.`);
208
210
  }
209
211
  const normalized = [];
212
+ let retainedBytes = new TextEncoder().encode(JSON.stringify({ schema: root.schema, schemaVersion: root.schemaVersion, source, findings: [] })).byteLength + 1;
210
213
  const seen = new Set();
211
214
  const domainCounts = new Map();
212
215
  for (const [index, raw] of root.findings.entries()) {
@@ -228,7 +231,7 @@ export function parseExternalFindingsDocument(value) {
228
231
  if (evidenceClass !== 'deployment_observation' && evidenceClass !== 'provider_report') {
229
232
  throw new Error(`Finding ${index + 1} evidence class is unsupported.`);
230
233
  }
231
- const finding = {
234
+ let finding = {
232
235
  domain,
233
236
  category: item.category,
234
237
  evidenceClass,
@@ -239,6 +242,21 @@ export function parseExternalFindingsDocument(value) {
239
242
  reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
240
243
  structuredObservation: structuredObservation(item.structuredObservation, index),
241
244
  };
245
+ if (Object.hasOwn(item, 'infrastructureObservation')) {
246
+ if (root.schemaVersion !== 5)
247
+ throw new TypeError('Infrastructure observations require external-findings version 5.');
248
+ const infrastructureObservation = readInfrastructureObservation(item.infrastructureObservation);
249
+ if (infrastructureObservation.target !== domain || infrastructureObservation.observedAt !== finding.observedAt)
250
+ throw new TypeError('Infrastructure snapshot must match its finding target and observation time.');
251
+ if (finding.structuredObservation)
252
+ throw new TypeError('Infrastructure snapshots cannot also declare a single structured observation.');
253
+ finding = { ...finding, infrastructureObservation };
254
+ }
255
+ if (root.schemaVersion === 5) {
256
+ retainedBytes += new TextEncoder().encode(JSON.stringify(finding)).byteLength + 1;
257
+ if (retainedBytes > MAX_EXTERNAL_FINDINGS_IMPORT_BYTES)
258
+ throw new TypeError('External findings exceed the retained aggregate byte bound.');
259
+ }
242
260
  const key = findingKey(finding, source.name);
243
261
  if (seen.has(key))
244
262
  continue;
@@ -257,12 +275,14 @@ export function parseExternalFindingsDocument(value) {
257
275
  throw new Error('External findings did not contain a unique valid finding.');
258
276
  return {
259
277
  schema: EXTERNAL_FINDINGS_SCHEMA,
260
- schemaVersion: EXTERNAL_FINDINGS_VERSION,
278
+ schemaVersion: root.schemaVersion,
261
279
  source,
262
280
  findings: normalized,
263
281
  };
264
282
  }
265
283
  export function serializeExternalFindingsDocument(value) {
284
+ if (record(value)?.schemaVersion === 5)
285
+ return `${JSON.stringify(parseExternalFindingsDocument(value))}\n`;
266
286
  const serialised = JSON.stringify(value, null, 2);
267
287
  if (typeof serialised !== 'string') {
268
288
  throw new TypeError('External findings must be JSON-serialisable.');
@@ -294,6 +314,12 @@ function alreadyRetained(recordValue, projection) {
294
314
  });
295
315
  }
296
316
  function structuredPinFields(finding) {
317
+ if (finding.infrastructureObservation)
318
+ return {
319
+ field: 'infrastructure.snapshot', category: 'infrastructure',
320
+ sourceSchema: { collection: 'external_observations', schema: finding.infrastructureObservation.schema, version: finding.infrastructureObservation.version },
321
+ infrastructureObservation: finding.infrastructureObservation,
322
+ };
297
323
  const observation = finding.structuredObservation;
298
324
  if (!observation)
299
325
  return {};
@@ -348,6 +374,7 @@ export function externalFindingCaseProjection(finding, source) {
348
374
  const omittedLimitations = finding.limitations.filter((item) => !sourceLimitations.includes(item)).length;
349
375
  const pin = normalizeCaseEvidencePins([{
350
376
  ...structuredPinFields(finding),
377
+ observationHostname: finding.domain,
351
378
  importContentSha256: identitySha256,
352
379
  label: `External ${finding.category} finding`,
353
380
  value,
@@ -428,17 +455,6 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
428
455
  duplicatesSkipped,
429
456
  };
430
457
  }
431
- function targetedFinding(finding, caseDomain) {
432
- if (finding.domain === caseDomain)
433
- return finding;
434
- const prefix = `Captured hostname ${finding.domain}. `;
435
- const maximumSummaryLength = MAX_EXTERNAL_FINDING_SUMMARY_LENGTH;
436
- const remaining = maximumSummaryLength - prefix.length;
437
- const summary = remaining > 1 && finding.summary.length > remaining
438
- ? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
439
- : `${prefix}${finding.summary}`;
440
- return { ...finding, domain: caseDomain, summary };
441
- }
442
458
  export function externalFindingsCaseTargets(document, caseDomain) {
443
459
  if (canonicalRegistrableDomain(caseDomain) !== caseDomain) {
444
460
  throw new Error('The selected Case does not have a canonical registrable-domain identity.');
@@ -468,8 +484,7 @@ export function mergeExternalFindingsIntoCase(current, caseId, document, now = n
468
484
  let record = selected;
469
485
  let findingsAdded = 0;
470
486
  let duplicatesSkipped = 0;
471
- for (const original of document.findings) {
472
- const finding = targetedFinding(original, selected.domain);
487
+ for (const finding of document.findings) {
473
488
  const merged = mergeExternalFindingIntoCase(cases, selected.id, finding, document.source, now);
474
489
  cases = merged.cases;
475
490
  record = merged.record;
@@ -0,0 +1,14 @@
1
+ const TERMINAL_UNSAFE_JSON_RE = /[\u007f-\u009f\u2028\u2029]|\p{Default_Ignorable_Code_Point}/gu;
2
+ function jsonUnicodeEscape(value) {
3
+ const codePoint = value.codePointAt(0);
4
+ if (codePoint <= 0xffff)
5
+ return `\\u${codePoint.toString(16).padStart(4, '0')}`;
6
+ const offset = codePoint - 0x10000;
7
+ const high = 0xd800 + (offset >> 10);
8
+ const low = 0xdc00 + (offset & 0x3ff);
9
+ return `\\u${high.toString(16).padStart(4, '0')}\\u${low.toString(16).padStart(4, '0')}`;
10
+ }
11
+ /** Escape display controls without changing the parsed JSON values. */
12
+ export function terminalSafeJson(value, space) {
13
+ return JSON.stringify(value, null, space).replace(TERMINAL_UNSAFE_JSON_RE, jsonUnicodeEscape);
14
+ }