@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.
- package/DISCLOSURE +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- 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 =
|
|
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 =
|
|
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 =
|
|
86
|
-
export const DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([DETECTION_RULE_SCHEMA_VERSION]);
|
|
87
|
-
export const DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS =
|
|
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 =
|
|
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: '
|
|
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:
|
|
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: '
|
|
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
|
|
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:
|
|
323
|
-
note: '
|
|
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: '
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 !==
|
|
195
|
-
throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema 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
|
-
|
|
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:
|
|
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
|
|
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
|
+
}
|