@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
|
@@ -89,6 +89,7 @@ const ASSERTION_EXTERNAL_ENTITY_TYPES = new Set(CASE_ASSERTION_EXTERNAL_ENTITY_T
|
|
|
89
89
|
const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
|
|
90
90
|
const SIGHTING_STATES = new Set(CASE_SIGHTING_STATES);
|
|
91
91
|
const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
|
|
92
|
+
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
92
93
|
function record(value) {
|
|
93
94
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
94
95
|
? value
|
|
@@ -159,6 +160,54 @@ function sourceSchema(value) {
|
|
|
159
160
|
: null;
|
|
160
161
|
return collection && schema && version !== null ? { collection, schema, version } : null;
|
|
161
162
|
}
|
|
163
|
+
function certificateObservation(value, pin) {
|
|
164
|
+
const item = record(value);
|
|
165
|
+
if (!Object.keys(item).length)
|
|
166
|
+
return null;
|
|
167
|
+
const keys = new Set([
|
|
168
|
+
'eventId',
|
|
169
|
+
'logId',
|
|
170
|
+
'certificateSha256',
|
|
171
|
+
'issuer',
|
|
172
|
+
'notAfter',
|
|
173
|
+
'dnsNameCount',
|
|
174
|
+
'namesComplete',
|
|
175
|
+
]);
|
|
176
|
+
if (Object.keys(item).some((key) => !keys.has(key)))
|
|
177
|
+
return null;
|
|
178
|
+
if (pin.sourceSchema?.collection !== 'external_observations'
|
|
179
|
+
|| pin.sourceSchema.schema !== 'whoisleuth.certificate-observation-rows'
|
|
180
|
+
|| (pin.field !== 'certificateSha256' && pin.field !== 'fingerprintSha256'))
|
|
181
|
+
return null;
|
|
182
|
+
const eventId = text(item.eventId, 64);
|
|
183
|
+
const logId = text(item.logId, 200);
|
|
184
|
+
const certificateSha256 = text(item.certificateSha256, 64).toLowerCase();
|
|
185
|
+
const issuer = item.issuer === null ? null : text(item.issuer, 160) || null;
|
|
186
|
+
const notAfter = item.notAfter === null ? null : optionalIso(item.notAfter);
|
|
187
|
+
const dnsNameCount = typeof item.dnsNameCount === 'number'
|
|
188
|
+
&& Number.isSafeInteger(item.dnsNameCount)
|
|
189
|
+
&& item.dnsNameCount >= 1
|
|
190
|
+
&& item.dnsNameCount <= 100
|
|
191
|
+
? item.dnsNameCount
|
|
192
|
+
: null;
|
|
193
|
+
if (!SAFE_ID_RE.test(eventId)
|
|
194
|
+
|| !logId
|
|
195
|
+
|| !SHA256_RE.test(certificateSha256)
|
|
196
|
+
|| certificateSha256 !== pin.value.toLowerCase()
|
|
197
|
+
|| dnsNameCount === null
|
|
198
|
+
|| typeof item.namesComplete !== 'boolean'
|
|
199
|
+
|| (item.notAfter !== null && notAfter === null))
|
|
200
|
+
return null;
|
|
201
|
+
return {
|
|
202
|
+
eventId,
|
|
203
|
+
logId,
|
|
204
|
+
certificateSha256,
|
|
205
|
+
issuer,
|
|
206
|
+
notAfter,
|
|
207
|
+
dnsNameCount,
|
|
208
|
+
namesComplete: item.namesComplete,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
162
211
|
function uniqueIds(value, validIds) {
|
|
163
212
|
if (!Array.isArray(value))
|
|
164
213
|
return [];
|
|
@@ -196,25 +245,27 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
|
|
|
196
245
|
}
|
|
197
246
|
return [...output].map(([evidencePinId, stance]) => ({ evidencePinId, stance }));
|
|
198
247
|
}
|
|
199
|
-
function normalizePin(raw, fallback) {
|
|
248
|
+
function normalizePin(raw, fallback, options = {}) {
|
|
200
249
|
const item = record(raw);
|
|
201
250
|
const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
|
|
202
251
|
const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
|
|
203
252
|
if (!label || !value)
|
|
204
253
|
return null;
|
|
205
254
|
const createdAt = iso(item.createdAt, fallback);
|
|
206
|
-
|
|
255
|
+
const normalizedSourceSchema = sourceSchema(item.sourceSchema);
|
|
256
|
+
const normalizedField = text(item.field, 120) || null;
|
|
257
|
+
const normalized = {
|
|
207
258
|
id: safeId(item.id, 'pin', { label, value, createdAt }),
|
|
208
259
|
checkpointId: typeof item.checkpointId === 'string' && SAFE_ID_RE.test(item.checkpointId)
|
|
209
260
|
? item.checkpointId
|
|
210
261
|
: null,
|
|
211
|
-
field:
|
|
262
|
+
field: normalizedField,
|
|
212
263
|
category: text(item.category, 80) || null,
|
|
213
264
|
label,
|
|
214
265
|
value,
|
|
215
266
|
source: text(item.source, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_selected',
|
|
216
267
|
sourceState: text(item.sourceState, 40) || null,
|
|
217
|
-
sourceSchema:
|
|
268
|
+
sourceSchema: normalizedSourceSchema,
|
|
218
269
|
observedAt: iso(item.observedAt, createdAt),
|
|
219
270
|
collectionDepth: item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
|
|
220
271
|
? item.collectionDepth
|
|
@@ -230,13 +281,21 @@ function normalizePin(raw, fallback) {
|
|
|
230
281
|
limitations: limitations(item.limitations),
|
|
231
282
|
createdAt,
|
|
232
283
|
};
|
|
233
|
-
|
|
234
|
-
|
|
284
|
+
normalized.certificateObservation = options.allowCertificateObservation === false
|
|
285
|
+
? null
|
|
286
|
+
: certificateObservation(item.certificateObservation, {
|
|
287
|
+
field: normalizedField,
|
|
288
|
+
value,
|
|
289
|
+
sourceSchema: normalizedSourceSchema,
|
|
290
|
+
});
|
|
291
|
+
return normalized;
|
|
292
|
+
}
|
|
293
|
+
export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
|
|
235
294
|
if (!Array.isArray(raw))
|
|
236
295
|
return [];
|
|
237
296
|
const byId = new Map();
|
|
238
297
|
for (const item of raw.slice(0, MAX_CASE_EVIDENCE_PINS * 2)) {
|
|
239
|
-
const normalized = normalizePin(item, fallback);
|
|
298
|
+
const normalized = normalizePin(item, fallback, options);
|
|
240
299
|
if (normalized && !byId.has(normalized.id))
|
|
241
300
|
byId.set(normalized.id, normalized);
|
|
242
301
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Pure abuse-evidence packet builder. It creates local review artifacts only:
|
|
2
2
|
// no network requests, mailto links, submissions, or provider side effects.
|
|
3
3
|
import { buildCaseActionOutcomeSummary } from "./case-response-model.js";
|
|
4
|
+
import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "./artifact-integrity.js";
|
|
4
5
|
export const CASE_RESPONSE_PACKET_SCHEMA = 'whoisleuth.case-response-packet';
|
|
5
|
-
export const CASE_RESPONSE_PACKET_VERSION =
|
|
6
|
+
export const CASE_RESPONSE_PACKET_VERSION = 6;
|
|
7
|
+
export const LEGACY_CASE_RESPONSE_PACKET_VERSION = 5;
|
|
6
8
|
export const MAX_ABUSIVE_URLS = 20;
|
|
7
9
|
export const MAX_RESPONSE_CONTACTS = 12;
|
|
8
10
|
export const MAX_RESPONSE_ACTION_HISTORY = 20;
|
|
@@ -387,13 +389,21 @@ async function sha256(value) {
|
|
|
387
389
|
}
|
|
388
390
|
export async function verifyCaseResponsePacketIntegrity(packet) {
|
|
389
391
|
const { integrity, ...unsigned } = packet;
|
|
392
|
+
const canonicalization = packet.schemaVersion === LEGACY_CASE_RESPONSE_PACKET_VERSION
|
|
393
|
+
&& integrity.canonicalization === 'sorted-json-v1'
|
|
394
|
+
? canonicalJson
|
|
395
|
+
: packet.schemaVersion === CASE_RESPONSE_PACKET_VERSION
|
|
396
|
+
&& integrity.canonicalization === SORTED_JSON_V2
|
|
397
|
+
? canonicalArtifactJsonV2
|
|
398
|
+
: null;
|
|
390
399
|
if (integrity.algorithm !== 'SHA-256'
|
|
391
|
-
||
|
|
400
|
+
|| !canonicalization
|
|
392
401
|
|| integrity.scope !== 'packet excluding integrity'
|
|
402
|
+
|| typeof integrity.digestSha256 !== 'string'
|
|
393
403
|
|| !/^[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
394
404
|
return false;
|
|
395
405
|
}
|
|
396
|
-
return integrity.digestSha256 === await sha256(
|
|
406
|
+
return integrity.digestSha256 === await sha256(canonicalization(unsigned));
|
|
397
407
|
}
|
|
398
408
|
function escapeMarkdown(value) {
|
|
399
409
|
return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
|
|
@@ -472,12 +482,12 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
472
482
|
limitations,
|
|
473
483
|
},
|
|
474
484
|
};
|
|
475
|
-
const digestSha256 = await sha256(
|
|
485
|
+
const digestSha256 = await sha256(canonicalArtifactJsonV2(unsigned));
|
|
476
486
|
const json = {
|
|
477
487
|
...unsigned,
|
|
478
488
|
integrity: {
|
|
479
489
|
algorithm: 'SHA-256',
|
|
480
|
-
canonicalization:
|
|
490
|
+
canonicalization: SORTED_JSON_V2,
|
|
481
491
|
scope: 'packet excluding integrity',
|
|
482
492
|
digestSha256,
|
|
483
493
|
},
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { MAX_BULK_SESSIONS, compareBulkSessions, normalizeBulkSession, } from "./bulk-session-model.js";
|
|
2
|
+
import { MAX_COMPARISON_LEDGER_BULK_PAIRS, boundedComparisonLedgerId, comparisonLedgerCollector, comparisonLedgerInputArray, comparisonLedgerRecord, comparisonLedgerValuePresent, comparisonLedgerValueState, makeComparisonLedgerCandidate, stableComparisonLedgerId, } from "./comparison-ledger-contract.js";
|
|
3
|
+
function bulkCompleteness(session) {
|
|
4
|
+
return session.state === 'complete' ? 'complete' : session.state === 'partial' ? 'partial' : 'unavailable';
|
|
5
|
+
}
|
|
6
|
+
function sourceMap(result) {
|
|
7
|
+
return new Map(result.sourceCoverage.map((item) => [item.source, item.state]));
|
|
8
|
+
}
|
|
9
|
+
function namedBulkSourceState(result, source) {
|
|
10
|
+
return result.sourceCoverage.find((item) => item.source === source)?.state ?? 'not_reported';
|
|
11
|
+
}
|
|
12
|
+
function bulkFamilyState(result, family) {
|
|
13
|
+
if (family === 'model')
|
|
14
|
+
return 'derived';
|
|
15
|
+
if (family === 'collection')
|
|
16
|
+
return 'retained';
|
|
17
|
+
return result.status;
|
|
18
|
+
}
|
|
19
|
+
function bulkSide(session, result, family, value, sourceState) {
|
|
20
|
+
return {
|
|
21
|
+
source: `Saved Bulk session · ${session.name}`,
|
|
22
|
+
sourceState: sourceState ?? (result ? bulkFamilyState(result, family) : session.state),
|
|
23
|
+
value,
|
|
24
|
+
observedAt: session.state === 'complete' ? session.completedAt : null,
|
|
25
|
+
retainedAt: session.updatedAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const BULK_FIELDS = Object.freeze([
|
|
29
|
+
{ field: 'Availability', family: 'registration', kind: 'source', read: (item) => item.availability, sourceState: (item) => namedBulkSourceState(item, 'availability') },
|
|
30
|
+
{ field: 'Registrar', family: 'registration', kind: 'registrar', read: (item) => item.registrar, sourceState: () => 'not_reported' },
|
|
31
|
+
{ field: 'Website activity', family: 'website', kind: 'source', read: (item) => item.activityStatus, sourceState: (item) => namedBulkSourceState(item, 'http') },
|
|
32
|
+
{ field: 'Risk score', family: 'model', kind: 'model', read: (item) => item.risk, sourceState: () => 'derived' },
|
|
33
|
+
{ field: 'Opportunity score', family: 'model', kind: 'model', read: (item) => item.opportunity, sourceState: () => 'derived' },
|
|
34
|
+
{ field: 'Technology IDs', family: 'technology', kind: 'source', read: (item) => item.comparisonEvidence?.technology.ids ?? null, sourceState: (item) => item.comparisonEvidence?.technology.state ?? 'not_reported' },
|
|
35
|
+
{ field: 'TLS issuer', family: 'certificate', kind: 'source', read: (item) => item.comparisonEvidence?.tls.issuerLabel ?? null, sourceState: (item) => item.comparisonEvidence?.tls.state ?? 'not_reported' },
|
|
36
|
+
{ field: 'TLS public key', family: 'certificate', kind: 'source', read: (item) => item.comparisonEvidence?.tls.spkiSha256 ?? null, sourceState: (item) => item.comparisonEvidence?.tls.state ?? 'not_reported' },
|
|
37
|
+
]);
|
|
38
|
+
function completeBulkSourceState(value) { return value === 'complete' || value === 'success'; }
|
|
39
|
+
function settledBulkCoverageState(value) {
|
|
40
|
+
return value === 'complete' || value === 'skipped' || value === 'unsupported';
|
|
41
|
+
}
|
|
42
|
+
function bulkFieldDecision(field, earlier, later, before, after) {
|
|
43
|
+
const earlierState = field.sourceState(earlier);
|
|
44
|
+
const laterState = field.sourceState(later);
|
|
45
|
+
if (earlier.scanDepth !== later.scanDepth)
|
|
46
|
+
return { state: 'not_compared', completeness: 'partial', earlierState, laterState, limitation: 'Unlike Fast and Deep result rows are not compared as an observed field change.' };
|
|
47
|
+
if (field.kind === 'registrar')
|
|
48
|
+
return { state: 'not_compared', completeness: 'not_reported', earlierState, laterState, limitation: 'The exact retained registrar texts are shown, but saved Bulk rows do not retain field-level registrar provenance; these values are not compared as a publication or target change.' };
|
|
49
|
+
if (field.kind === 'model') {
|
|
50
|
+
const comparable = comparisonLedgerValuePresent(before) && comparisonLedgerValuePresent(after);
|
|
51
|
+
return { state: comparable ? 'different' : 'not_compared', completeness: comparable ? 'complete' : 'partial', earlierState, laterState, limitation: comparable ? null : 'A score absent from either retained row is not represented as an observed score change.' };
|
|
52
|
+
}
|
|
53
|
+
if (earlierState !== laterState)
|
|
54
|
+
return { state: 'collection_changed', completeness: 'partial', earlierState, laterState, limitation: 'The exact retained family source state changed, so the values are not represented as an observed field change.' };
|
|
55
|
+
if (!completeBulkSourceState(earlierState) || !completeBulkSourceState(laterState)) {
|
|
56
|
+
const state = laterState === 'unsupported' ? 'unsupported' : ['error', 'not_found', 'unavailable'].includes(laterState) ? 'unavailable' : 'not_compared';
|
|
57
|
+
return { state, completeness: state === 'not_compared' ? 'partial' : 'unavailable', earlierState, laterState, limitation: 'Both retained family source states must be complete before values are compared.' };
|
|
58
|
+
}
|
|
59
|
+
return { state: comparisonLedgerValueState(before, after, true), completeness: 'complete', earlierState, laterState, limitation: null };
|
|
60
|
+
}
|
|
61
|
+
function bulkPairCompleteness(earlier, later) {
|
|
62
|
+
if (bulkCompleteness(earlier) !== 'complete' || bulkCompleteness(later) !== 'complete' || earlier.mode !== later.mode)
|
|
63
|
+
return 'partial';
|
|
64
|
+
const before = new Map(earlier.results.map((item) => [item.domain, item]));
|
|
65
|
+
const after = new Map(later.results.map((item) => [item.domain, item]));
|
|
66
|
+
if (before.size !== after.size)
|
|
67
|
+
return 'partial';
|
|
68
|
+
for (const [domain, left] of before) {
|
|
69
|
+
const right = after.get(domain);
|
|
70
|
+
if (!right || left.scanDepth !== right.scanDepth)
|
|
71
|
+
return 'partial';
|
|
72
|
+
if (left.riskModelVersion === null || right.riskModelVersion === null || left.riskModelVersion !== right.riskModelVersion
|
|
73
|
+
|| left.opportunityModelVersion === null || right.opportunityModelVersion === null || left.opportunityModelVersion !== right.opportunityModelVersion)
|
|
74
|
+
return 'partial';
|
|
75
|
+
for (const field of BULK_FIELDS) {
|
|
76
|
+
if (field.kind === 'registrar' && exactEqual(field.read(left), field.read(right)))
|
|
77
|
+
continue;
|
|
78
|
+
const decision = bulkFieldDecision(field, left, right, field.read(left), field.read(right));
|
|
79
|
+
if (decision.completeness !== 'complete')
|
|
80
|
+
return 'partial';
|
|
81
|
+
}
|
|
82
|
+
const leftSources = sourceMap(left);
|
|
83
|
+
const rightSources = sourceMap(right);
|
|
84
|
+
if ([...new Set([...leftSources.keys(), ...rightSources.keys()])].some((source) => leftSources.get(source) !== rightSources.get(source)
|
|
85
|
+
|| !settledBulkCoverageState(leftSources.get(source)) || !settledBulkCoverageState(rightSources.get(source))))
|
|
86
|
+
return 'partial';
|
|
87
|
+
}
|
|
88
|
+
return 'complete';
|
|
89
|
+
}
|
|
90
|
+
function exactEqual(left, right) {
|
|
91
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
92
|
+
return Array.isArray(left) && Array.isArray(right)
|
|
93
|
+
&& left.length === right.length
|
|
94
|
+
&& left.every((value, index) => value === right[index]);
|
|
95
|
+
}
|
|
96
|
+
return Object.is(left, right);
|
|
97
|
+
}
|
|
98
|
+
function buildBulkRows(ownerId, earlier, later, pairCompleteness) {
|
|
99
|
+
const output = comparisonLedgerCollector();
|
|
100
|
+
const before = new Map(earlier.results.map((item) => [item.domain, item]));
|
|
101
|
+
const after = new Map(later.results.map((item) => [item.domain, item]));
|
|
102
|
+
for (const domain of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
103
|
+
const left = before.get(domain) ?? null;
|
|
104
|
+
const right = after.get(domain) ?? null;
|
|
105
|
+
if (!left && right) {
|
|
106
|
+
output.add({
|
|
107
|
+
comparisonId: ownerId,
|
|
108
|
+
ownerId,
|
|
109
|
+
entityId: domain,
|
|
110
|
+
mode: 'membership',
|
|
111
|
+
state: 'added',
|
|
112
|
+
field: 'Settled result-set membership',
|
|
113
|
+
family: 'membership',
|
|
114
|
+
earlier: bulkSide(earlier, null, 'membership', null),
|
|
115
|
+
later: bulkSide(later, right, 'membership', 'Included in the later settled result set'),
|
|
116
|
+
completeness: bulkCompleteness(later),
|
|
117
|
+
limitations: ['This is membership in two explicitly selected saved sessions, not evidence that the domain itself was created or became active.'],
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (left && !right) {
|
|
122
|
+
output.add({
|
|
123
|
+
comparisonId: ownerId,
|
|
124
|
+
ownerId,
|
|
125
|
+
entityId: domain,
|
|
126
|
+
mode: 'membership',
|
|
127
|
+
state: 'not_compared',
|
|
128
|
+
field: 'Settled result-set membership',
|
|
129
|
+
family: 'membership',
|
|
130
|
+
earlier: bulkSide(earlier, left, 'membership', 'Included in the earlier settled result set'),
|
|
131
|
+
later: bulkSide(later, null, 'membership', null),
|
|
132
|
+
completeness: 'partial',
|
|
133
|
+
limitations: ['No settled row is retained in the later saved session. This is not domain removal, release, resolution, or an availability finding.'],
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!left || !right)
|
|
138
|
+
continue;
|
|
139
|
+
const riskModelsComparable = left.riskModelVersion !== null
|
|
140
|
+
&& right.riskModelVersion !== null
|
|
141
|
+
&& left.riskModelVersion === right.riskModelVersion;
|
|
142
|
+
const riskModelChanged = left.riskModelVersion !== null
|
|
143
|
+
&& right.riskModelVersion !== null
|
|
144
|
+
&& left.riskModelVersion !== right.riskModelVersion;
|
|
145
|
+
const leftOpportunityVersion = left.opportunityModelVersion ?? null;
|
|
146
|
+
const rightOpportunityVersion = right.opportunityModelVersion ?? null;
|
|
147
|
+
const opportunityModelsComparable = leftOpportunityVersion !== null
|
|
148
|
+
&& rightOpportunityVersion !== null
|
|
149
|
+
&& leftOpportunityVersion === rightOpportunityVersion;
|
|
150
|
+
const opportunityModelChanged = leftOpportunityVersion !== null
|
|
151
|
+
&& rightOpportunityVersion !== null
|
|
152
|
+
&& leftOpportunityVersion !== rightOpportunityVersion;
|
|
153
|
+
for (const field of BULK_FIELDS) {
|
|
154
|
+
const beforeValue = field.read(left);
|
|
155
|
+
const afterValue = field.read(right);
|
|
156
|
+
if ((field.field === 'Risk score' && !riskModelsComparable)
|
|
157
|
+
|| (field.field === 'Opportunity score' && !opportunityModelsComparable))
|
|
158
|
+
continue;
|
|
159
|
+
const decision = bulkFieldDecision(field, left, right, beforeValue, afterValue);
|
|
160
|
+
if (exactEqual(beforeValue, afterValue) && decision.state !== 'collection_changed')
|
|
161
|
+
continue;
|
|
162
|
+
output.add({
|
|
163
|
+
comparisonId: ownerId,
|
|
164
|
+
ownerId,
|
|
165
|
+
entityId: domain,
|
|
166
|
+
mode: 'temporal',
|
|
167
|
+
state: decision.state,
|
|
168
|
+
field: field.field,
|
|
169
|
+
family: field.family,
|
|
170
|
+
earlier: bulkSide(earlier, left, field.family, beforeValue, decision.earlierState),
|
|
171
|
+
later: bulkSide(later, right, field.family, afterValue, decision.laterState),
|
|
172
|
+
completeness: decision.completeness,
|
|
173
|
+
limitations: [
|
|
174
|
+
'Saved Bulk rows are compact derived observations, not raw source publications or current provider state.',
|
|
175
|
+
...(decision.limitation ? [decision.limitation] : []),
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (riskModelChanged) {
|
|
180
|
+
output.add({
|
|
181
|
+
comparisonId: ownerId,
|
|
182
|
+
ownerId,
|
|
183
|
+
entityId: domain,
|
|
184
|
+
mode: 'temporal',
|
|
185
|
+
state: 'model_changed',
|
|
186
|
+
field: 'Risk model version',
|
|
187
|
+
family: 'model',
|
|
188
|
+
earlier: bulkSide(earlier, left, 'model', left.riskModelVersion),
|
|
189
|
+
later: bulkSide(later, right, 'model', right.riskModelVersion),
|
|
190
|
+
completeness: 'partial',
|
|
191
|
+
limitations: ['Risk scores from unlike model versions are not represented as an observed domain change.'],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else if (!riskModelsComparable && (left.risk !== null || right.risk !== null)) {
|
|
195
|
+
output.add({
|
|
196
|
+
comparisonId: ownerId,
|
|
197
|
+
ownerId,
|
|
198
|
+
entityId: domain,
|
|
199
|
+
mode: 'temporal',
|
|
200
|
+
state: 'not_compared',
|
|
201
|
+
field: 'Risk model version',
|
|
202
|
+
family: 'model',
|
|
203
|
+
earlier: bulkSide(earlier, left, 'model', left.riskModelVersion),
|
|
204
|
+
later: bulkSide(later, right, 'model', right.riskModelVersion),
|
|
205
|
+
completeness: 'partial',
|
|
206
|
+
limitations: ['At least one saved Risk score lacks a retained model version, so score values are not compared.'],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (opportunityModelChanged) {
|
|
210
|
+
output.add({
|
|
211
|
+
comparisonId: ownerId,
|
|
212
|
+
ownerId,
|
|
213
|
+
entityId: domain,
|
|
214
|
+
mode: 'temporal',
|
|
215
|
+
state: 'model_changed',
|
|
216
|
+
field: 'Opportunity model version',
|
|
217
|
+
family: 'model',
|
|
218
|
+
earlier: bulkSide(earlier, left, 'model', leftOpportunityVersion),
|
|
219
|
+
later: bulkSide(later, right, 'model', rightOpportunityVersion),
|
|
220
|
+
completeness: 'partial',
|
|
221
|
+
limitations: ['Opportunity scores from unlike model versions are not represented as an observed domain change.'],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
else if (!opportunityModelsComparable && (left.opportunity !== null || right.opportunity !== null)) {
|
|
225
|
+
output.add({
|
|
226
|
+
comparisonId: ownerId,
|
|
227
|
+
ownerId,
|
|
228
|
+
entityId: domain,
|
|
229
|
+
mode: 'temporal',
|
|
230
|
+
state: 'not_compared',
|
|
231
|
+
field: 'Opportunity model version',
|
|
232
|
+
family: 'model',
|
|
233
|
+
earlier: bulkSide(earlier, left, 'model', leftOpportunityVersion),
|
|
234
|
+
later: bulkSide(later, right, 'model', rightOpportunityVersion),
|
|
235
|
+
completeness: 'partial',
|
|
236
|
+
limitations: ['At least one saved Opportunity score lacks a retained model version, so score values are not compared.'],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const beforeSources = sourceMap(left);
|
|
240
|
+
const afterSources = sourceMap(right);
|
|
241
|
+
for (const source of [...new Set([...beforeSources.keys(), ...afterSources.keys()])].sort()) {
|
|
242
|
+
const beforeState = beforeSources.get(source) ?? 'not_reported';
|
|
243
|
+
const afterState = afterSources.get(source) ?? 'not_reported';
|
|
244
|
+
if (beforeState === afterState)
|
|
245
|
+
continue;
|
|
246
|
+
output.add({
|
|
247
|
+
comparisonId: ownerId,
|
|
248
|
+
ownerId,
|
|
249
|
+
entityId: domain,
|
|
250
|
+
mode: 'temporal',
|
|
251
|
+
state: 'collection_changed',
|
|
252
|
+
field: `${source} source state`,
|
|
253
|
+
family: 'collection',
|
|
254
|
+
earlier: bulkSide(earlier, left, 'collection', beforeState),
|
|
255
|
+
later: bulkSide(later, right, 'collection', afterState),
|
|
256
|
+
completeness: 'partial',
|
|
257
|
+
limitations: ['A source-state difference can reflect collection conditions rather than a target change.'],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (earlier.mode !== later.mode) {
|
|
262
|
+
output.add({
|
|
263
|
+
comparisonId: ownerId,
|
|
264
|
+
ownerId,
|
|
265
|
+
entityId: `${earlier.id}:${later.id}`,
|
|
266
|
+
mode: 'temporal',
|
|
267
|
+
state: 'collection_changed',
|
|
268
|
+
field: 'Bulk collection mode',
|
|
269
|
+
family: 'collection',
|
|
270
|
+
earlier: bulkSide(earlier, null, 'collection', earlier.mode),
|
|
271
|
+
later: bulkSide(later, null, 'collection', later.mode),
|
|
272
|
+
completeness: 'partial',
|
|
273
|
+
limitations: ['Fast and Deep saved sessions have different collection contracts; unlike fields remain not compared.'],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (!output.totalRows) {
|
|
277
|
+
output.add({
|
|
278
|
+
comparisonId: ownerId,
|
|
279
|
+
ownerId,
|
|
280
|
+
entityId: `${earlier.id}:${later.id}`,
|
|
281
|
+
mode: 'temporal',
|
|
282
|
+
state: pairCompleteness === 'complete' ? 'equivalent' : 'incomplete',
|
|
283
|
+
field: 'Compact comparable saved rows',
|
|
284
|
+
family: 'summary',
|
|
285
|
+
earlier: bulkSide(earlier, null, 'summary', 'No bounded material difference'),
|
|
286
|
+
later: bulkSide(later, null, 'summary', 'No bounded material difference'),
|
|
287
|
+
completeness: pairCompleteness,
|
|
288
|
+
limitations: [pairCompleteness === 'complete'
|
|
289
|
+
? 'Equivalence applies only to the compact fields retained in both explicitly selected saved sessions.'
|
|
290
|
+
: 'At least one compact field or source state is not completely comparable across both saved sessions, so the absence of a retained row difference is not represented as equivalence or resolution.'],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return { rows: output.rows, totalRows: output.totalRows, sourceOmittedRows: 0 };
|
|
294
|
+
}
|
|
295
|
+
export function comparisonLedgerBulkPairIndexId(earlierSessionId, laterSessionId) {
|
|
296
|
+
return stableComparisonLedgerId('ledger-bulk_session_pair', [String(earlierSessionId ?? ''), String(laterSessionId ?? '')]);
|
|
297
|
+
}
|
|
298
|
+
export function buildBulkComparisonCandidates(sessionsRaw, pairsRaw, counters) {
|
|
299
|
+
const input = comparisonLedgerInputArray(sessionsRaw);
|
|
300
|
+
counters.inputRecords += Math.max(0, input.length - MAX_BULK_SESSIONS);
|
|
301
|
+
const sessions = new Map();
|
|
302
|
+
for (const value of input.slice(0, MAX_BULK_SESSIONS)) {
|
|
303
|
+
const item = normalizeBulkSession(value);
|
|
304
|
+
if (!item) {
|
|
305
|
+
counters.invalidRecords += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (sessions.has(item.id)) {
|
|
309
|
+
counters.duplicateRecords += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
sessions.set(item.id, item);
|
|
313
|
+
}
|
|
314
|
+
const pairs = comparisonLedgerInputArray(pairsRaw);
|
|
315
|
+
counters.inputRecords += Math.max(0, pairs.length - MAX_COMPARISON_LEDGER_BULK_PAIRS);
|
|
316
|
+
const candidates = [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
for (const rawPair of pairs.slice(0, MAX_COMPARISON_LEDGER_BULK_PAIRS)) {
|
|
319
|
+
const pair = comparisonLedgerRecord(rawPair);
|
|
320
|
+
const earlierId = boundedComparisonLedgerId(pair?.earlierSessionId, counters);
|
|
321
|
+
const laterId = boundedComparisonLedgerId(pair?.laterSessionId, counters);
|
|
322
|
+
const earlier = sessions.get(earlierId);
|
|
323
|
+
const later = sessions.get(laterId);
|
|
324
|
+
if (!pair || !earlierId || !laterId || earlierId === laterId || !earlier || !later
|
|
325
|
+
|| earlier.updatedAt > later.updatedAt) {
|
|
326
|
+
counters.invalidRecords += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const id = comparisonLedgerBulkPairIndexId(earlierId, laterId);
|
|
330
|
+
if (seen.has(id)) {
|
|
331
|
+
counters.duplicateRecords += 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
seen.add(id);
|
|
335
|
+
const comparison = compareBulkSessions(earlier, later);
|
|
336
|
+
if (!comparison) {
|
|
337
|
+
counters.invalidRecords += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const ownerId = stableComparisonLedgerId('bulk-pair', [earlierId, laterId]);
|
|
341
|
+
const pairCompleteness = bulkPairCompleteness(earlier, later);
|
|
342
|
+
const candidate = makeComparisonLedgerCandidate({
|
|
343
|
+
idParts: [earlierId, laterId],
|
|
344
|
+
ownerType: 'bulk_session_pair',
|
|
345
|
+
ownerId,
|
|
346
|
+
entityId: `${earlierId}:${laterId}`,
|
|
347
|
+
label: `${earlier.name} → ${later.name} · explicit saved-session pair`,
|
|
348
|
+
mode: 'temporal',
|
|
349
|
+
earlier: { source: `Saved Bulk session · ${earlier.name}`, sourceState: earlier.state, observedAt: earlier.completedAt, retainedAt: earlier.updatedAt },
|
|
350
|
+
later: { source: `Saved Bulk session · ${later.name}`, sourceState: later.state, observedAt: later.completedAt, retainedAt: later.updatedAt },
|
|
351
|
+
completeness: pairCompleteness,
|
|
352
|
+
truncated: false,
|
|
353
|
+
limitations: [
|
|
354
|
+
...comparison.limitations,
|
|
355
|
+
...(pairCompleteness === 'complete' ? [] : ['At least one compact field or retained source state is not completely comparable across both selected sessions.']),
|
|
356
|
+
],
|
|
357
|
+
ownerHref: '/bulk#bulk-sessions-title',
|
|
358
|
+
buildDetails: () => buildBulkRows(ownerId, earlier, later, pairCompleteness),
|
|
359
|
+
}, counters);
|
|
360
|
+
if (candidate)
|
|
361
|
+
candidates.push(candidate);
|
|
362
|
+
else
|
|
363
|
+
counters.invalidRecords += 1;
|
|
364
|
+
}
|
|
365
|
+
return candidates;
|
|
366
|
+
}
|