@slicedearth/whoisleuth-cli 1.43.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 +67 -10
- package/bin/whoisleuth.mjs +22 -9
- 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 +67 -31
- package/cli/bulk-command-runner.mjs +16 -6
- package/cli/bulk.mjs +18 -7
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +50 -11
- package/cli/config-profile.mjs +15 -12
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +18 -6
- package/cli/discovery-snapshot.mjs +8 -15
- 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/errors.mjs +7 -1
- 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/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +4 -1
- package/cli/http.mjs +2 -1
- 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/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- 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-fixture-scaffold.mjs +4 -3
- 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 +150 -177
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +467 -74
- package/docs/cli.md +25 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
- 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 +9 -1
- 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-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +43 -13
- 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-asset-graph.js +8 -8
- 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 +63 -8
- package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +27 -4
- 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-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +105 -19
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +82 -0
- package/lib/bounded-json-response.mjs +170 -0
- package/lib/bounded-json.mjs +249 -0
- package/lib/browser-library-profile.mjs +167 -38
- 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-cache.mjs +5 -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/rdap-transport.mjs +6 -6
- 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 +96 -42
- 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 +4 -13
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/lib/website-technology.mjs +2 -6
- 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
|
|
|
@@ -168,6 +173,7 @@ commands do not publish partial files. Output is capped at 32 MiB.
|
|
|
168
173
|
| 4 | A bounded operation completed partially. |
|
|
169
174
|
| 70 | Unexpected CLI bootstrap failure. |
|
|
170
175
|
| 130 | The analyst cancelled the command; no partial final result was emitted. |
|
|
176
|
+
| 143 | The process received SIGTERM; no partial final result was emitted. |
|
|
171
177
|
|
|
172
178
|
## Detailed command reference
|
|
173
179
|
|
|
@@ -188,7 +194,7 @@ full contracts into the packaged reference.
|
|
|
188
194
|
|
|
189
195
|
### Offline artefact verification
|
|
190
196
|
|
|
191
|
-
[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)
|
|
192
198
|
|
|
193
199
|
### Privacy-safe source reliability report
|
|
194
200
|
|
|
@@ -243,6 +249,18 @@ search contract.](cli-reference.md#workspace-archive-inspection)
|
|
|
243
249
|
|
|
244
250
|
[Read the bounded TLS collection contract.](cli-reference.md#tls-intelligence)
|
|
245
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
|
+
|
|
246
264
|
### Registry-source comparison
|
|
247
265
|
|
|
248
266
|
[Read the normalised RDAP and WHOIS comparison contract.](cli-reference.md#registry-source-comparison)
|
|
@@ -257,7 +275,7 @@ search contract.](cli-reference.md#workspace-archive-inspection)
|
|
|
257
275
|
|
|
258
276
|
### Offline supplied-evidence review
|
|
259
277
|
|
|
260
|
-
[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)
|
|
261
279
|
|
|
262
280
|
### Domain control manifests
|
|
263
281
|
|
|
@@ -290,6 +308,8 @@ search contract.](cli-reference.md#workspace-archive-inspection)
|
|
|
290
308
|
### Lookup evidence export
|
|
291
309
|
|
|
292
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.
|
|
293
313
|
|
|
294
314
|
## Safety and limitations
|
|
295
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,
|
|
@@ -205,7 +205,7 @@ function contactsItem(insights) {
|
|
|
205
205
|
function sourceComplete(evidence) {
|
|
206
206
|
return evidence.source === 'dns' && evidence.complete === true;
|
|
207
207
|
}
|
|
208
|
-
function transitionDependencies(
|
|
208
|
+
function transitionDependencies(_availability, activation, dnsEvidence, dnsRecords, tlsEvidence) {
|
|
209
209
|
const dnsComplete = sourceComplete(dnsEvidence);
|
|
210
210
|
const nameservers = strings(dnsRecords.ns);
|
|
211
211
|
const addresses = [...strings(dnsRecords.a), ...strings(dnsRecords.aaaa)];
|
|
@@ -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])?$/;
|
|
@@ -67,8 +68,8 @@ function boundedText(value, maximum = MAX_PROFILE_TEXT_LENGTH) {
|
|
|
67
68
|
return '';
|
|
68
69
|
return value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
|
|
69
70
|
}
|
|
70
|
-
function
|
|
71
|
-
return
|
|
71
|
+
export function normalizeBrandProfileId(value) {
|
|
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)) {
|
|
@@ -337,7 +338,9 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
337
338
|
const pageBaseline = candidateBaseline && officialDomains.includes(candidateBaseline.domain)
|
|
338
339
|
? candidateBaseline
|
|
339
340
|
: null;
|
|
340
|
-
const profileId =
|
|
341
|
+
const profileId = normalizeBrandProfileId(existing?.id)
|
|
342
|
+
|| normalizeBrandProfileId(value.id)
|
|
343
|
+
|| (typeof options.makeId === 'function' ? normalizeBrandProfileId(options.makeId()) : null);
|
|
341
344
|
const name = boundedText(value.name, MAX_PROFILE_NAME_LENGTH);
|
|
342
345
|
if (!profileId || !name)
|
|
343
346
|
return null;
|
|
@@ -418,17 +421,39 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
|
|
|
418
421
|
if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
|
|
419
422
|
throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
|
|
420
423
|
}
|
|
421
|
-
if (!
|
|
422
|
-
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);
|
|
423
442
|
}
|
|
424
443
|
const local = normalizeBrandProfileStore(localRaw).profiles;
|
|
444
|
+
for (const profile of local)
|
|
445
|
+
retainIdName(profile.id, profile.name);
|
|
425
446
|
const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
|
|
426
447
|
const input = profileList(importedRaw);
|
|
427
448
|
let added = 0;
|
|
428
449
|
let updated = 0;
|
|
429
450
|
let skipped = Math.max(0, input.length - MAX_PROFILES * 4);
|
|
430
451
|
for (const item of input.slice(0, MAX_PROFILES * 4)) {
|
|
431
|
-
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);
|
|
432
457
|
const existing = rawName ? byName.get(rawName.toLowerCase()) : null;
|
|
433
458
|
const profile = normalizeBrandProfile(item, {
|
|
434
459
|
existing,
|
|
@@ -440,6 +465,7 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
|
|
|
440
465
|
skipped++;
|
|
441
466
|
continue;
|
|
442
467
|
}
|
|
468
|
+
retainIdName(profile.id, profile.name);
|
|
443
469
|
if (existing) {
|
|
444
470
|
byName.set(profile.name.toLowerCase(), profile);
|
|
445
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'),
|