@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
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 +7 -3
- package/NOTICE +5 -4
- package/README.md +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
package/docs/cli.md
CHANGED
|
@@ -65,10 +65,14 @@ saved result without sending it anywhere:
|
|
|
65
65
|
```bash
|
|
66
66
|
whoisleuth bulk domains.txt --csv
|
|
67
67
|
whoisleuth discover example.com --preset common --jsonl
|
|
68
|
-
whoisleuth verify-artifact lookup.json --json
|
|
68
|
+
whoisleuth verify-artifact lookup.json --json --strict-exit
|
|
69
|
+
whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit
|
|
70
|
+
whoisleuth interchange-report workspace.json --json
|
|
69
71
|
whoisleuth compare lookup.json --json
|
|
72
|
+
whoisleuth diff earlier-bulk.json later-bulk.json --left-session session-1 --right-session session-2 --json
|
|
70
73
|
whoisleuth brief lookup.json
|
|
71
74
|
whoisleuth registry-doctor lookup.json --json
|
|
75
|
+
whoisleuth registry-cohort retained-cohorts.jsonl --json
|
|
72
76
|
```
|
|
73
77
|
|
|
74
78
|
Only one query is accepted by `lookup`; multi-input processing belongs to
|
|
@@ -86,6 +90,7 @@ generated manual, and `commands --json`.
|
|
|
86
90
|
| Deep Lookup | Explicitly requested bounded RDAP, WHOIS, DNS, website, TLS, registrar-RDAP, and observed-network work where applicable. |
|
|
87
91
|
| Compact deep Bulk | Uses the shared Lookup orchestration but omits full-only response fields and collectors. |
|
|
88
92
|
| Offline review | Reads only the named bounded artefact or analyst-authored input and makes no network request. |
|
|
93
|
+
| Isolated authorised action | Runs only when its dedicated command and per-run acknowledgement flags are supplied; it is never added to Lookup, Bulk, monitoring, or recipes. |
|
|
89
94
|
|
|
90
95
|
Networked commands run from the local machine and contact the sources declared
|
|
91
96
|
by their profile. They do not use the hosted login or hosted usage controls;
|
|
@@ -106,12 +111,12 @@ HTTP status, and a bounded explanation.
|
|
|
106
111
|
|
|
107
112
|
| Goal | Commands |
|
|
108
113
|
| --- | --- |
|
|
109
|
-
| Investigate a target | `lookup`, `http`, `tls`, `posture`, `registry-support` |
|
|
114
|
+
| Investigate a target | `lookup`, `http`, `tls`, `dnssec-validate`, `mail-transport`, `posture`, `registry-support` |
|
|
110
115
|
| Review many targets | `bulk`, `discover`, `discover-scan`, `ct-search`, `ct-intake` |
|
|
111
116
|
| Compare saved evidence | `compare`, `page-compare`, `diff`, `reconcile`, `timeline`, `mail-review` |
|
|
112
117
|
| Review supplied evidence | `brief`, `review-evidence`, `registry-doctor`, `registry-cohort`, `source-report`, `sharing-review`, `map-observations` |
|
|
113
118
|
| Plan and assure changes | `domain-control`, `monitor-once`, `assurance`, `change-packet`, `workflow-plan`, `workflow-run` |
|
|
114
|
-
| Verify and package evidence | `case-pack`, `manifest`, `oam-export`, `verify-artifact`, `inspect-archive`, `sign-artifact`, `verify-signature`, `export` |
|
|
119
|
+
| Verify and package evidence | `case-pack`, `manifest`, `oam-export`, `verify-artifact`, `interchange-report`, `inspect-archive`, `sign-artifact`, `verify-signature`, `export` |
|
|
115
120
|
| Calibrate offline | `risk-calibrate`, `lookalike-calibrate` |
|
|
116
121
|
| Operate the CLI | `doctor`, `registry-scaffold`, `commands`, `completion`, `manual` |
|
|
117
122
|
|
|
@@ -189,7 +194,7 @@ full contracts into the packaged reference.
|
|
|
189
194
|
|
|
190
195
|
### Offline artefact verification
|
|
191
196
|
|
|
192
|
-
[Read the archive, saved Lookup, and signed-artefact verification contract.](cli-reference.md#offline-artefact-verification)
|
|
197
|
+
[Read the archive, saved Lookup, Lookup-evidence, and signed-artefact verification contract.](cli-reference.md#offline-artefact-verification)
|
|
193
198
|
|
|
194
199
|
### Privacy-safe source reliability report
|
|
195
200
|
|
|
@@ -244,6 +249,18 @@ search contract.](cli-reference.md#workspace-archive-inspection)
|
|
|
244
249
|
|
|
245
250
|
[Read the bounded TLS collection contract.](cli-reference.md#tls-intelligence)
|
|
246
251
|
|
|
252
|
+
### Isolated cryptographic and mail transport review
|
|
253
|
+
|
|
254
|
+
[Read the explicitly authorised DNSSEC, TLSA/DANE, STARTTLS, and SMTP transport contract.](cli-reference.md#isolated-cryptographic-and-mail-transport-review)
|
|
255
|
+
The mail report keeps address selection, public revalidation, confirmed
|
|
256
|
+
connection, and cryptographic address authentication as separate provenance;
|
|
257
|
+
address authentication is unavailable when no address candidate was retained.
|
|
258
|
+
If a DANE-TA TLSA usage 2 association is published, active collection retains
|
|
259
|
+
only the observed leaf certificate and leaves that comparison partial without
|
|
260
|
+
certificate-path construction and trust-anchor path validation. SMTP relay
|
|
261
|
+
PKIX-TA usage 0 and PKIX-EE usage 1 records are retained as unsupported and
|
|
262
|
+
cannot complete SMTP DANE assurance; a separate usage 3 match remains eligible.
|
|
263
|
+
|
|
247
264
|
### Registry-source comparison
|
|
248
265
|
|
|
249
266
|
[Read the normalised RDAP and WHOIS comparison contract.](cli-reference.md#registry-source-comparison)
|
|
@@ -258,7 +275,7 @@ search contract.](cli-reference.md#workspace-archive-inspection)
|
|
|
258
275
|
|
|
259
276
|
### Offline supplied-evidence review
|
|
260
277
|
|
|
261
|
-
[Read the DNSSEC, DANE, zone-intent, portfolio, domain-change, and supplied-observation review contract.](cli-reference.md#offline-supplied-evidence-review)
|
|
278
|
+
[Read the separate DNSSEC, route-origin, DANE/TLSA, zone-intent, portfolio, domain-change, and supplied-observation review contract.](cli-reference.md#offline-supplied-evidence-review)
|
|
262
279
|
|
|
263
280
|
### Domain control manifests
|
|
264
281
|
|
|
@@ -291,6 +308,8 @@ search contract.](cli-reference.md#workspace-archive-inspection)
|
|
|
291
308
|
### Lookup evidence export
|
|
292
309
|
|
|
293
310
|
[Read normalised JSON, Markdown, and HTML export behaviour.](cli-reference.md#lookup-evidence-export)
|
|
311
|
+
Saved Lookup JSON is byte-, nesting-, entry-, and per-container-bounded before parsing, and the
|
|
312
|
+
completed evidence package must satisfy the stricter portable-tree contract.
|
|
294
313
|
|
|
295
314
|
## Safety and limitations
|
|
296
315
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
|
|
2
2
|
export const ACQUISITION_DECISION_PACKET_SCHEMA = 'whoisleuth.acquisition-decision';
|
|
3
|
-
export const ACQUISITION_DECISION_PACKET_VERSION =
|
|
3
|
+
export const ACQUISITION_DECISION_PACKET_VERSION = 2;
|
|
4
4
|
export const ACQUISITION_DECISIONS = [
|
|
5
5
|
'unresolved',
|
|
6
6
|
'continue_manual_review',
|
|
@@ -80,10 +80,10 @@ export async function buildAcquisitionDecisionPacket(input) {
|
|
|
80
80
|
: []),
|
|
81
81
|
],
|
|
82
82
|
};
|
|
83
|
-
const digestSha256 = await
|
|
83
|
+
const digestSha256 = await sha256ArtifactDigestV2(unsigned);
|
|
84
84
|
const document = {
|
|
85
85
|
...unsigned,
|
|
86
|
-
integrity: { algorithm: 'SHA-256', digestSha256 },
|
|
86
|
+
integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
|
|
87
87
|
};
|
|
88
88
|
return {
|
|
89
89
|
document,
|
|
@@ -19,3 +19,45 @@ export async function sha256ArtifactDigest(value) {
|
|
|
19
19
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
20
20
|
.join('')}`;
|
|
21
21
|
}
|
|
22
|
+
export const SORTED_JSON_V1 = 'sorted-json-v1';
|
|
23
|
+
export const SORTED_JSON_V2 = 'sorted-json-v2';
|
|
24
|
+
function compareCodeUnits(left, right) {
|
|
25
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
26
|
+
}
|
|
27
|
+
export function canonicalArtifactJsonV2(value) {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return 'null';
|
|
30
|
+
if (value === null || typeof value !== 'object')
|
|
31
|
+
return JSON.stringify(value);
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return `[${value.map(canonicalArtifactJsonV2).join(',')}]`;
|
|
34
|
+
const entries = Object.entries(value)
|
|
35
|
+
.filter(([, entry]) => entry !== undefined)
|
|
36
|
+
.sort(([left], [right]) => compareCodeUnits(left, right));
|
|
37
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalArtifactJsonV2(entry)}`).join(',')}}`;
|
|
38
|
+
}
|
|
39
|
+
export async function sha256ArtifactDigestV2(value) {
|
|
40
|
+
const bytes = new TextEncoder().encode(canonicalArtifactJsonV2(value));
|
|
41
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
42
|
+
return `sha256:${[...new Uint8Array(digest)]
|
|
43
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
44
|
+
.join('')}`;
|
|
45
|
+
}
|
|
46
|
+
export function resolveArtifactCanonicalization(version, declaredCanonicalization, routes, label = 'Artifact') {
|
|
47
|
+
const route = routes.find((candidate) => candidate.version === version);
|
|
48
|
+
const expectedDeclaration = route?.explicit ? route.canonicalization : undefined;
|
|
49
|
+
if (!route || declaredCanonicalization !== expectedDeclaration) {
|
|
50
|
+
throw new TypeError(`${label} has an unsupported version or canonicalization.`);
|
|
51
|
+
}
|
|
52
|
+
return route.canonicalization;
|
|
53
|
+
}
|
|
54
|
+
export function canonicalArtifactJsonFor(value, canonicalization) {
|
|
55
|
+
return canonicalization === SORTED_JSON_V1
|
|
56
|
+
? canonicalArtifactJson(value)
|
|
57
|
+
: canonicalArtifactJsonV2(value);
|
|
58
|
+
}
|
|
59
|
+
export function sha256ArtifactDigestFor(value, canonicalization) {
|
|
60
|
+
return canonicalization === SORTED_JSON_V1
|
|
61
|
+
? sha256ArtifactDigest(value)
|
|
62
|
+
: sha256ArtifactDigestV2(value);
|
|
63
|
+
}
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// persistence and downloads; this module owns schema migration, semantic field
|
|
3
3
|
// bounds, import merging, and exact serialized-byte accounting.
|
|
4
4
|
import { normalizeDomain } from "./case-model.js";
|
|
5
|
+
import { normalizeOpaqueReferenceId } from "./opaque-reference-id.js";
|
|
5
6
|
import { normalizePageBaseline } from "./page-baseline.js";
|
|
6
7
|
import { isInformativeFaviconHash } from "./utils.js";
|
|
7
8
|
export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
|
|
8
9
|
export const BRAND_PROFILE_SCHEMA_VERSION = 6;
|
|
10
|
+
export const SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS = Object.freeze([2, 3, 4, 5, BRAND_PROFILE_SCHEMA_VERSION]);
|
|
9
11
|
export const MAX_PROFILES = 100;
|
|
10
12
|
export const MAX_PROFILE_VALUES = 200;
|
|
11
13
|
export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
|
|
@@ -24,7 +26,6 @@ export const MAX_DESIRED_POSTURE_RECORDS = 32;
|
|
|
24
26
|
export const MAX_DESIRED_POSTURE_SUPPRESSIONS = 12;
|
|
25
27
|
export const MAX_DESIRED_POSTURE_OBSERVATIONS = 12;
|
|
26
28
|
export const MAX_DESIRED_POSTURE_CHANGE_WINDOWS = 8;
|
|
27
|
-
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
28
29
|
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
29
30
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
30
31
|
const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
@@ -68,7 +69,7 @@ function boundedText(value, maximum = MAX_PROFILE_TEXT_LENGTH) {
|
|
|
68
69
|
return value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
|
|
69
70
|
}
|
|
70
71
|
export function normalizeBrandProfileId(value) {
|
|
71
|
-
return
|
|
72
|
+
return normalizeOpaqueReferenceId(value);
|
|
72
73
|
}
|
|
73
74
|
function timestamp(value, fallback) {
|
|
74
75
|
if (typeof value === 'string' && value.length <= 64 && !CONTROL_RE.test(value)) {
|
|
@@ -420,17 +421,39 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
|
|
|
420
421
|
if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
|
|
421
422
|
throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
|
|
422
423
|
}
|
|
423
|
-
if (!
|
|
424
|
-
throw new Error(`Expected a WHOISleuth Brand Profile export using schema
|
|
424
|
+
if (!SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.includes(importedVersion ?? 0)) {
|
|
425
|
+
throw new Error(`Expected a WHOISleuth Brand Profile export using schema ${SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS.join(', ').replace(/, ([^,]+)$/u, ', or $1')}.`);
|
|
426
|
+
}
|
|
427
|
+
const idNames = new Map();
|
|
428
|
+
const retainIdName = (id, name) => {
|
|
429
|
+
const nameKey = name.toLowerCase();
|
|
430
|
+
const previous = idNames.get(id);
|
|
431
|
+
if (previous !== undefined && previous !== nameKey) {
|
|
432
|
+
throw new Error('Brand Profile import reuses one exact identifier for different normalised profile names. No profiles were imported.');
|
|
433
|
+
}
|
|
434
|
+
idNames.set(id, nameKey);
|
|
435
|
+
};
|
|
436
|
+
for (const item of profileList(localRaw).slice(0, MAX_PROFILES * 4)) {
|
|
437
|
+
const value = record(item);
|
|
438
|
+
const id = normalizeBrandProfileId(value.id);
|
|
439
|
+
const name = boundedText(value.name, MAX_PROFILE_NAME_LENGTH);
|
|
440
|
+
if (id && name)
|
|
441
|
+
retainIdName(id, name);
|
|
425
442
|
}
|
|
426
443
|
const local = normalizeBrandProfileStore(localRaw).profiles;
|
|
444
|
+
for (const profile of local)
|
|
445
|
+
retainIdName(profile.id, profile.name);
|
|
427
446
|
const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
|
|
428
447
|
const input = profileList(importedRaw);
|
|
429
448
|
let added = 0;
|
|
430
449
|
let updated = 0;
|
|
431
450
|
let skipped = Math.max(0, input.length - MAX_PROFILES * 4);
|
|
432
451
|
for (const item of input.slice(0, MAX_PROFILES * 4)) {
|
|
433
|
-
const
|
|
452
|
+
const value = record(item);
|
|
453
|
+
const rawName = boundedText(value.name, MAX_PROFILE_NAME_LENGTH);
|
|
454
|
+
const rawId = normalizeBrandProfileId(value.id);
|
|
455
|
+
if (rawId && rawName)
|
|
456
|
+
retainIdName(rawId, rawName);
|
|
434
457
|
const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
|
|
435
458
|
const profile = normalizeBrandProfile(item, {
|
|
436
459
|
existing,
|
|
@@ -442,6 +465,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
|
|
|
442
465
|
skipped++;
|
|
443
466
|
continue;
|
|
444
467
|
}
|
|
468
|
+
retainIdName(profile.id, profile.name);
|
|
445
469
|
if (existing) {
|
|
446
470
|
byName.set(profile.name.toLowerCase(), profile);
|
|
447
471
|
updated++;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
|
|
2
|
-
import {
|
|
2
|
+
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
|
|
3
3
|
import { BULK_REVIEW_STALE_AFTER_DAYS } from "./bulk-retry-plan.js";
|
|
4
4
|
export const BULK_DOMAIN_COMPARISON_SCHEMA = 'whoisleuth.domain-comparison';
|
|
5
5
|
export const BULK_DOMAIN_COMPARISON_VERSION = 3;
|
|
6
|
+
export const BULK_DOMAIN_COMPARISON_EXPORT_VERSION = 4;
|
|
6
7
|
function text(value, maximum = 500) {
|
|
7
8
|
return typeof value === 'string'
|
|
8
9
|
? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
@@ -164,6 +165,23 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
|
|
|
164
165
|
});
|
|
165
166
|
const compactTls = comparisonEvidenceContext(left, right, 'tls', tls);
|
|
166
167
|
const source = context(left, right, 'Recorded source coverage', [], observedAt, options);
|
|
168
|
+
const profileContextsComparable = left.profileContext.sourceState === 'ready'
|
|
169
|
+
&& right.profileContext.sourceState === 'ready'
|
|
170
|
+
&& left.profileContext.activeProfileId === right.profileContext.activeProfileId
|
|
171
|
+
&& left.profileContext.profileUpdatedAt === right.profileContext.profileUpdatedAt;
|
|
172
|
+
const profileContext = {
|
|
173
|
+
...http,
|
|
174
|
+
source: 'Brand Profile comparison context',
|
|
175
|
+
leftSourceState: profileContextsComparable ? 'complete' : 'unavailable',
|
|
176
|
+
rightSourceState: profileContextsComparable ? 'complete' : 'unavailable',
|
|
177
|
+
};
|
|
178
|
+
const profileLimitations = profileContextsComparable
|
|
179
|
+
? []
|
|
180
|
+
: [
|
|
181
|
+
left.profileContext.limitation,
|
|
182
|
+
right.profileContext.limitation,
|
|
183
|
+
'Official-asset comparison is withheld unless both rows retain the same ready Brand Profile provenance.',
|
|
184
|
+
].filter(Boolean);
|
|
167
185
|
const registrationConflicting = [left.availability, right.availability]
|
|
168
186
|
.some((value) => ['conflict', 'conflicting'].includes(value.toLowerCase()));
|
|
169
187
|
const rows = [
|
|
@@ -192,7 +210,7 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
|
|
|
192
210
|
row('tracking', 'identity', 'Tracking identifiers', left.relationship.trackingIdentifiers, right.relationship.trackingIdentifiers, 'Normalised exact set comparison', http, ['Shared identifiers are an investigative lead, not proof of ownership.']),
|
|
193
211
|
row('password-field', 'identity', 'Password field', left.hasPasswordField, right.hasPasswordField, 'Bounded static form observation', http),
|
|
194
212
|
row('phishing-language', 'identity', 'Phishing-language indicator', left.phishingLanguageMatch, right.phishingLanguageMatch, 'Bounded explainable page-language signal', http, ['A wording match is a review lead, not proof of malicious intent.']),
|
|
195
|
-
row('official-assets', 'identity', 'Official asset reuse', left.reusesOfficialAssets, right.reusesOfficialAssets, 'Configured Brand Profile comparison',
|
|
213
|
+
row('official-assets', 'identity', 'Official asset reuse', profileContextsComparable ? left.reusesOfficialAssets : null, profileContextsComparable ? right.reusesOfficialAssets : null, 'Configured Brand Profile comparison', profileContext, profileLimitations),
|
|
196
214
|
row('technology', 'technology', 'Technology identifiers', left.comparisonEvidence?.technology.ids ?? [], right.comparisonEvidence?.technology.ids ?? [], 'Normalised exact set comparison of at most 12 curated identifiers', compactTechnology, ['An unmatched identifier is not evidence that a technology is absent. Shared technologies do not establish common ownership or control.']),
|
|
197
215
|
row('source-health', 'source', 'Recorded source coverage', sourceSummary(left), sourceSummary(right), 'Source-by-source compact state comparison', source, ['A source-state difference can reflect collection conditions rather than a domain change.']),
|
|
198
216
|
];
|
|
@@ -219,11 +237,13 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
|
|
|
219
237
|
'This compares normalised evidence already present in two saved observations for the same domain and makes no new request.',
|
|
220
238
|
'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
|
|
221
239
|
'An observed difference can reflect changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
|
|
240
|
+
...(!profileContextsComparable ? ['Profile-derived identity comparison is unavailable because the saved Brand Profile contexts are not ready and identical.'] : []),
|
|
222
241
|
]
|
|
223
242
|
: [
|
|
224
243
|
'This compares compact settled evidence already present in Bulk and makes no new request.',
|
|
225
244
|
'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
|
|
226
245
|
'Equality does not establish common ownership, infrastructure control, intent, safety, or maliciousness.',
|
|
246
|
+
...(!profileContextsComparable ? ['Profile-derived identity comparison is unavailable because the saved Brand Profile contexts are not ready and identical.'] : []),
|
|
227
247
|
],
|
|
228
248
|
};
|
|
229
249
|
}
|
|
@@ -235,14 +255,14 @@ export async function buildBulkDomainComparisonExport(comparisonRaw, generatedAt
|
|
|
235
255
|
const generatedAt = timestamp(generatedAtRaw) || new Date().toISOString();
|
|
236
256
|
const unsigned = {
|
|
237
257
|
schema: BULK_DOMAIN_COMPARISON_SCHEMA,
|
|
238
|
-
version:
|
|
258
|
+
version: BULK_DOMAIN_COMPARISON_EXPORT_VERSION,
|
|
239
259
|
generatedAt,
|
|
240
260
|
comparison,
|
|
241
261
|
};
|
|
242
|
-
const digestSha256 = await
|
|
262
|
+
const digestSha256 = await sha256ArtifactDigestV2(unsigned);
|
|
243
263
|
const document = {
|
|
244
264
|
...unsigned,
|
|
245
|
-
integrity: { algorithm: 'SHA-256', digestSha256 },
|
|
265
|
+
integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
|
|
246
266
|
};
|
|
247
267
|
return {
|
|
248
268
|
document,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
|
|
1
|
+
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
|
|
2
|
+
import { BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION, normalizeBulkProfileContext, normalizeBulkSessionResult, } from "./bulk-session-model.js";
|
|
3
3
|
export const BULK_MAIL_EXPOSURE_SCHEMA = 'whoisleuth.bulk-mail-exposure';
|
|
4
4
|
export const BULK_MAIL_EXPOSURE_VERSION = 1;
|
|
5
|
+
export const BULK_MAIL_EXPOSURE_EXPORT_VERSION = 2;
|
|
5
6
|
export const MAX_BULK_MAIL_EXPOSURE_ROWS = 2_000;
|
|
6
7
|
const STATE_LABELS = {
|
|
7
8
|
authenticated_mail: 'Receiving mail with SPF and DMARC',
|
|
@@ -16,7 +17,11 @@ function timestamp(value) {
|
|
|
16
17
|
return null;
|
|
17
18
|
return new Date(value).toISOString();
|
|
18
19
|
}
|
|
19
|
-
function baselineLabel(profile) {
|
|
20
|
+
function baselineLabel(profile, profileSourceState) {
|
|
21
|
+
if (profileSourceState === 'loading')
|
|
22
|
+
return 'Brand Profile context loading';
|
|
23
|
+
if (profileSourceState === 'unavailable')
|
|
24
|
+
return 'Brand Profile context unavailable';
|
|
20
25
|
if (profile === 'defensive_no_mail')
|
|
21
26
|
return 'Configured no-mail baseline';
|
|
22
27
|
if (profile === 'parked')
|
|
@@ -64,7 +69,21 @@ function stateDetail(state) {
|
|
|
64
69
|
}
|
|
65
70
|
return 'The compact scan did not retain enough settled DNS evidence to classify mail exposure.';
|
|
66
71
|
}
|
|
67
|
-
function relation(state, profile) {
|
|
72
|
+
function relation(state, profile, profileSourceState, profileContextComparable, profileContextLimitation) {
|
|
73
|
+
if (profileSourceState !== 'ready') {
|
|
74
|
+
return {
|
|
75
|
+
baselineRelation: 'inconclusive',
|
|
76
|
+
baselineDetail: profileSourceState === 'loading'
|
|
77
|
+
? 'Brand Profile context is still loading, so no profile-derived mail comparison is available.'
|
|
78
|
+
: 'Brand Profile context could not be read, so no profile-derived mail comparison is available.',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (!profileContextComparable) {
|
|
82
|
+
return {
|
|
83
|
+
baselineRelation: 'inconclusive',
|
|
84
|
+
baselineDetail: profileContextLimitation || BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
68
87
|
if (!profile || state === 'evidence_incomplete' || state === 'mail_auth_incomplete') {
|
|
69
88
|
return {
|
|
70
89
|
baselineRelation: 'inconclusive',
|
|
@@ -102,8 +121,16 @@ function relation(state, profile) {
|
|
|
102
121
|
baselineDetail: 'The parked-domain profile is context rather than a claim about the expected candidate mail posture.',
|
|
103
122
|
};
|
|
104
123
|
}
|
|
105
|
-
function rowLimitations(row, state) {
|
|
124
|
+
function rowLimitations(row, state, profileSourceState, profileContextComparable, profileContextLimitation) {
|
|
106
125
|
const output = [];
|
|
126
|
+
if (profileSourceState !== 'ready') {
|
|
127
|
+
output.push(profileSourceState === 'loading'
|
|
128
|
+
? 'Brand Profile context was still loading; official-domain mail posture was not evaluated.'
|
|
129
|
+
: 'Brand Profile context was unavailable; official-domain mail posture was not evaluated.');
|
|
130
|
+
}
|
|
131
|
+
if (profileSourceState === 'ready' && !profileContextComparable) {
|
|
132
|
+
output.push(profileContextLimitation || BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION);
|
|
133
|
+
}
|
|
107
134
|
const coverage = dnsCoverage(row);
|
|
108
135
|
if (!coverage)
|
|
109
136
|
output.push('DNS source coverage was not recorded for this compact result.');
|
|
@@ -115,30 +142,48 @@ function rowLimitations(row, state) {
|
|
|
115
142
|
output.push('No explicit MX is not equivalent to a null MX or proof that the domain cannot receive mail.');
|
|
116
143
|
}
|
|
117
144
|
output.push('Mail configuration alone does not establish use, intent, control, safety, or maliciousness.');
|
|
118
|
-
return output.slice(0,
|
|
145
|
+
return output.slice(0, 6);
|
|
119
146
|
}
|
|
120
147
|
export function buildBulkMailExposureReport(rowsRaw, options = {}) {
|
|
121
148
|
const generatedAt = timestamp(options.generatedAt) ?? new Date().toISOString();
|
|
122
|
-
const
|
|
149
|
+
const profileSourceState = options.profileSourceState ?? 'ready';
|
|
150
|
+
const currentProfileContext = normalizeBulkProfileContext(options.currentProfileContext ?? (profileSourceState === 'ready'
|
|
151
|
+
? { sourceState: 'ready', activeProfileId: null, profileUpdatedAt: null, limitation: '' }
|
|
152
|
+
: { sourceState: 'unavailable' }));
|
|
153
|
+
const profile = profileSourceState === 'ready' ? options.profile ?? null : null;
|
|
154
|
+
const officialDomains = [...new Set((profileSourceState === 'ready' ? options.officialDomains ?? [] : [])
|
|
123
155
|
.map((domain) => domain.trim().toLowerCase().replace(/\.$/u, ''))
|
|
124
156
|
.filter(Boolean))].slice(0, 20);
|
|
157
|
+
let profileContextUnevaluatedCount = 0;
|
|
125
158
|
const rows = rowsRaw
|
|
126
|
-
.map(normalizeBulkSessionResult)
|
|
159
|
+
.map((row) => normalizeBulkSessionResult(row))
|
|
127
160
|
.filter((row) => Boolean(row))
|
|
128
161
|
.filter((row) => row.trusted === null)
|
|
129
162
|
.slice(0, MAX_BULK_MAIL_EXPOSURE_ROWS)
|
|
130
163
|
.map((item) => {
|
|
131
164
|
const state = exposureState(item);
|
|
165
|
+
const profileContextComparable = profileSourceState === 'ready'
|
|
166
|
+
&& currentProfileContext.sourceState === 'ready'
|
|
167
|
+
&& item.profileContext.sourceState === 'ready'
|
|
168
|
+
&& item.profileContext.activeProfileId === currentProfileContext.activeProfileId
|
|
169
|
+
&& item.profileContext.profileUpdatedAt === currentProfileContext.profileUpdatedAt;
|
|
170
|
+
const profileContextLimitation = profileContextComparable
|
|
171
|
+
? item.profileContext.limitation
|
|
172
|
+
: item.profileContext.limitation || BULK_PROFILE_CONTEXT_MISMATCH_LIMITATION;
|
|
173
|
+
if (!profileContextComparable)
|
|
174
|
+
profileContextUnevaluatedCount += 1;
|
|
132
175
|
return {
|
|
133
176
|
domain: item.domain,
|
|
134
177
|
state,
|
|
135
178
|
label: STATE_LABELS[state],
|
|
136
179
|
detail: stateDetail(state),
|
|
137
|
-
...relation(state,
|
|
180
|
+
...relation(state, profile, profileSourceState, profileContextComparable, profileContextLimitation),
|
|
138
181
|
mutationTypes: item.mutationTypes.slice(0, 40),
|
|
139
182
|
registration: item.availability,
|
|
140
183
|
sourceCoverage: item.sourceCoverage.slice(0, 12),
|
|
141
|
-
|
|
184
|
+
profileContextState: item.profileContext.sourceState,
|
|
185
|
+
profileContextLimitation,
|
|
186
|
+
limitations: rowLimitations(item, state, profileSourceState, profileContextComparable, profileContextLimitation),
|
|
142
187
|
};
|
|
143
188
|
});
|
|
144
189
|
const counts = {
|
|
@@ -156,16 +201,22 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
|
|
|
156
201
|
generatedAt,
|
|
157
202
|
observedAt: timestamp(options.observedAt),
|
|
158
203
|
baseline: {
|
|
159
|
-
profile
|
|
204
|
+
profile,
|
|
160
205
|
officialDomains,
|
|
161
|
-
label: baselineLabel(
|
|
206
|
+
label: baselineLabel(profile, profileSourceState),
|
|
162
207
|
limitations: [
|
|
208
|
+
...(profileSourceState === 'loading'
|
|
209
|
+
? ['Brand Profile context was loading, so profile-derived mail posture is inconclusive.']
|
|
210
|
+
: profileSourceState === 'unavailable'
|
|
211
|
+
? ['Brand Profile context was unavailable, so profile-derived mail posture is inconclusive.']
|
|
212
|
+
: []),
|
|
163
213
|
'The Brand Profile describes an analyst-configured official-domain posture; it is not a live observation.',
|
|
164
214
|
'Candidate similarity and mail configuration do not establish use, control, intent, safety, or maliciousness.',
|
|
165
215
|
],
|
|
166
216
|
},
|
|
167
217
|
rows,
|
|
168
218
|
counts,
|
|
219
|
+
profileContextUnevaluatedCount,
|
|
169
220
|
limitations: [
|
|
170
221
|
'This review uses compact Bulk evidence already collected and makes no additional request.',
|
|
171
222
|
'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
|
|
@@ -176,13 +227,13 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
|
|
|
176
227
|
export async function buildBulkMailExposureExport(report) {
|
|
177
228
|
const unsigned = {
|
|
178
229
|
schema: BULK_MAIL_EXPOSURE_SCHEMA,
|
|
179
|
-
version:
|
|
230
|
+
version: BULK_MAIL_EXPOSURE_EXPORT_VERSION,
|
|
180
231
|
report,
|
|
181
232
|
};
|
|
182
|
-
const digestSha256 = await
|
|
233
|
+
const digestSha256 = await sha256ArtifactDigestV2(unsigned);
|
|
183
234
|
const document = {
|
|
184
235
|
...unsigned,
|
|
185
|
-
integrity: { algorithm: 'SHA-256', digestSha256 },
|
|
236
|
+
integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
|
|
186
237
|
};
|
|
187
238
|
return {
|
|
188
239
|
document,
|
|
@@ -30,7 +30,7 @@ function retryRow(value, isStale) {
|
|
|
30
30
|
export function buildBulkRetryPlan(values, mode, observedAtRaw = null, now = Date.now()) {
|
|
31
31
|
const observationFreshness = freshness(observedAtRaw, now);
|
|
32
32
|
const candidates = values
|
|
33
|
-
.map(normalizeBulkSessionResult)
|
|
33
|
+
.map((row) => normalizeBulkSessionResult(row))
|
|
34
34
|
.filter((value) => Boolean(value))
|
|
35
35
|
.map((value) => retryRow(value, observationFreshness.state === 'stale'))
|
|
36
36
|
.filter((value) => Boolean(value));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
|
|
2
2
|
import { BULK_REVIEW_STATES, } from "./bulk-review-model.js";
|
|
3
|
-
import {
|
|
3
|
+
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "./artifact-integrity.js";
|
|
4
4
|
export const BULK_REVIEW_MANIFEST_SCHEMA = 'whoisleuth.bulk-review-manifest';
|
|
5
|
-
export const BULK_REVIEW_MANIFEST_VERSION =
|
|
5
|
+
export const BULK_REVIEW_MANIFEST_VERSION = 2;
|
|
6
6
|
const MAX_MANIFEST_ROWS = 2_000;
|
|
7
7
|
const REVIEW_STATE_SET = new Set(BULK_REVIEW_STATES);
|
|
8
8
|
function timestamp(value, fallback) {
|
|
@@ -21,7 +21,7 @@ export async function buildBulkReviewManifest(input) {
|
|
|
21
21
|
const observedAt = timestamp(input.observedAt, generatedAt);
|
|
22
22
|
const states = reviewStateMap(input.reviewStates);
|
|
23
23
|
const rows = input.rows
|
|
24
|
-
.map(normalizeBulkSessionResult)
|
|
24
|
+
.map((row) => normalizeBulkSessionResult(row))
|
|
25
25
|
.filter((item) => Boolean(item))
|
|
26
26
|
.slice(0, MAX_MANIFEST_ROWS)
|
|
27
27
|
.map((item) => ({
|
|
@@ -30,6 +30,7 @@ export async function buildBulkReviewManifest(input) {
|
|
|
30
30
|
resultState: item.status,
|
|
31
31
|
scanDepth: item.scanDepth,
|
|
32
32
|
sourceCoverage: item.sourceCoverage,
|
|
33
|
+
profileContext: { ...item.profileContext },
|
|
33
34
|
}));
|
|
34
35
|
const unsigned = {
|
|
35
36
|
schema: BULK_REVIEW_MANIFEST_SCHEMA,
|
|
@@ -45,14 +46,14 @@ export async function buildBulkReviewManifest(input) {
|
|
|
45
46
|
rows,
|
|
46
47
|
limitations: [
|
|
47
48
|
'This manifest records the explicit review selection and view context for a separate CSV export.',
|
|
48
|
-
'It contains compact source states only and excludes raw payloads, contact records, notes, and transient request state.',
|
|
49
|
+
'It contains compact source states and bounded row-level Brand Profile provenance only, and excludes raw payloads, Profile contents, contact records, notes, and transient request state.',
|
|
49
50
|
'Reproducing the filters does not reproduce upstream responses or imply that evidence remains current.',
|
|
50
51
|
],
|
|
51
52
|
};
|
|
52
|
-
const digestSha256 = await
|
|
53
|
+
const digestSha256 = await sha256ArtifactDigestV2(unsigned);
|
|
53
54
|
const document = {
|
|
54
55
|
...unsigned,
|
|
55
|
-
integrity: { algorithm: 'SHA-256', digestSha256 },
|
|
56
|
+
integrity: { algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2, digestSha256 },
|
|
56
57
|
};
|
|
57
58
|
return {
|
|
58
59
|
document,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeDomain } from "./case-model.js";
|
|
2
|
+
import { normalizeBulkPresentationSortKey } from "./bulk-sort.js";
|
|
2
3
|
export const BULK_REVIEW_SCHEMA = 'whoisleuth.bulk-review';
|
|
3
4
|
export const BULK_REVIEW_SCHEMA_VERSION = 1;
|
|
4
5
|
export const MAX_BULK_REVIEW_PRESETS = 24;
|
|
@@ -8,7 +9,7 @@ export const MAX_BULK_REVIEW_NAME_LENGTH = 80;
|
|
|
8
9
|
export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
|
|
9
10
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
10
11
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
11
|
-
const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'errors']);
|
|
12
|
+
const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'profile_unevaluated', 'errors']);
|
|
12
13
|
const SOURCE_FILTERS = new Set(['', 'complete', 'limited', 'failed']);
|
|
13
14
|
const LIFECYCLE_FILTERS = new Set(['', 'new', 'expiring', 'aged', 'unknown']);
|
|
14
15
|
const AGE_FILTERS = new Set(['', '7d', '30d', '90d', '365d', 'older', 'unknown']);
|
|
@@ -50,7 +51,7 @@ function normalizeView(raw) {
|
|
|
50
51
|
? [...new Set(value.signalFilters.filter((item) => typeof item === 'string' && SIGNAL_FILTERS.has(item)))].slice(0, SIGNAL_FILTERS.size)
|
|
51
52
|
: [];
|
|
52
53
|
const sortKey = typeof value.sortKey === 'string' && SORT_KEYS.has(value.sortKey)
|
|
53
|
-
? value.sortKey
|
|
54
|
+
? normalizeBulkPresentationSortKey(value.sortKey)
|
|
54
55
|
: 'risk';
|
|
55
56
|
return {
|
|
56
57
|
primaryFilter: setValue(value.primaryFilter, PRIMARY_FILTERS, 'all'),
|