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