@slicedearth/whoisleuth-cli 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/cli/arguments.mjs +389 -1745
- package/cli/artifact-validation/case-response.mjs +55 -30
- package/cli/artifact-validation/signed-review.mjs +30 -12
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/case-pack.mjs +39 -92
- package/cli/command-argument-grammar.mjs +211 -0
- package/cli/command-reference.mjs +200 -213
- package/cli/completion.mjs +5 -1
- package/cli/discovery-observation-snapshot.mjs +16 -3
- package/cli/discovery-scan-command-runner.mjs +19 -0
- package/cli/discovery-scan.mjs +6 -0
- package/cli/domain-control-observations.mjs +1 -1
- package/cli/inline-command-families.mjs +1 -0
- package/cli/input.mjs +116 -0
- package/cli/interchange-report.mjs +14 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/mail-header-review.mjs +467 -0
- package/cli/review-command-runner.mjs +25 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +7 -1
- package/docs/cli-reference.md +8 -1
- package/docs/cli.md +12 -1
- package/lib/bounded-json.mjs +9 -6
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-intelligence.mjs +37 -3
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/domain-change-packet.mjs +42 -14
- package/lib/domain-control-flight-recorder.mjs +4 -1
- package/lib/domain-control-manifest.mjs +16 -16
- package/lib/domain-posture-parsers.mjs +114 -3
- package/lib/domain-posture.mjs +34 -3
- package/lib/evidence-export.mjs +60 -11
- package/lib/evidence-report.mjs +2 -2
- package/lib/html-signals.mjs +39 -33
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-response.mjs +5 -4
- package/lib/lookup-source-progress.mjs +27 -4
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/registry-insights.mjs +35 -22
- package/lib/risk-calibration-summary.mjs +1 -3
- package/lib/security-txt.mjs +33 -1
- package/lib/static-html-analysis.mjs +69 -13
- package/lib/website-technology.mjs +79 -11
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +1 -1
- package/packages/cases/case-migration-model.mjs +71 -22
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +10 -21
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +6 -4
- package/packages/cases/case-record-projection.mjs +218 -0
- package/packages/cases/case-report.mjs +11 -6
- package/packages/cases/case-response-model.mjs +43 -6
- package/packages/cases/case-response-packet.mjs +78 -27
- package/packages/cases/case-storage-model.mjs +6 -2
- package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
- package/packages/comparison/page-similarity.mjs +3 -1
- package/packages/contracts/case-portability.mjs +171 -51
- package/packages/contracts/cli-command-semantics.mjs +93 -0
- package/packages/contracts/risk-calibration.mjs +15 -10
- package/packages/contracts/workspace-portability.mjs +44 -7
- package/packages/evidence/domain-control-runtime.mjs +46 -6
- package/packages/interchange/external-findings-import.mjs +3 -2
- package/packages/investigation/lookup-asset-graph.mjs +35 -27
- package/packages/workspace/brand-profile-model.mjs +118 -2
- package/packages/workspace/bulk-review-model.mjs +13 -7
- package/packages/workspace/bulk-session-model.mjs +5 -4
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/relationship-observation-model.mjs +4 -1
- package/packages/workspace/website-snapshot-model.mjs +106 -8
- package/packages/workspace/workspace-archive.mjs +2 -0
- package/cli/evidence-command-arguments.mjs +0 -156
|
@@ -7,8 +7,8 @@ import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
|
|
|
7
7
|
import { normalizePageBaseline } from "./page-baseline.mjs";
|
|
8
8
|
import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
|
|
9
9
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
10
|
-
import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
11
|
-
export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
10
|
+
import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
11
|
+
export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
12
12
|
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
13
13
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
14
14
|
const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
@@ -42,6 +42,17 @@ export const DESIRED_POSTURE_SUPPRESSION_FIELDS = Object.freeze([
|
|
|
42
42
|
]);
|
|
43
43
|
const DESIRED_POSTURE_FIELDS = new Set(DESIRED_POSTURE_SUPPRESSION_FIELDS);
|
|
44
44
|
const POSTURE_CHECK_STATUSES = new Set(['danger', 'info', 'pass', 'warning']);
|
|
45
|
+
export const OFFICIAL_CHANNEL_PLATFORMS = [
|
|
46
|
+
'facebook',
|
|
47
|
+
'instagram',
|
|
48
|
+
'linkedin',
|
|
49
|
+
'telegram',
|
|
50
|
+
'tiktok',
|
|
51
|
+
'x',
|
|
52
|
+
'youtube',
|
|
53
|
+
'other',
|
|
54
|
+
];
|
|
55
|
+
export const RIGHTS_REFERENCE_KINDS = ['trademark', 'copyright', 'design', 'other'];
|
|
45
56
|
function record(value) {
|
|
46
57
|
return ordinaryWorkspaceRecord(value, 'Brand Profile input') ?? {};
|
|
47
58
|
}
|
|
@@ -333,6 +344,82 @@ function normalizeFaviconHash(value) {
|
|
|
333
344
|
function normalizeFaviconPHash(value) {
|
|
334
345
|
return typeof value === 'string' && isInformativeFaviconHash(value) ? value.toLowerCase() : '';
|
|
335
346
|
}
|
|
347
|
+
function normalizePublicUrl(value) {
|
|
348
|
+
if (typeof value !== 'string' || CONTROL_RE.test(value) || value.length > MAX_PROFILE_URL_LENGTH * 2)
|
|
349
|
+
return '';
|
|
350
|
+
try {
|
|
351
|
+
const parsed = new URL(value.trim());
|
|
352
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
353
|
+
return '';
|
|
354
|
+
const normalized = parsed.toString();
|
|
355
|
+
return normalized.length <= MAX_PROFILE_URL_LENGTH ? normalized : '';
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return '';
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function normalizeOfficialChannels(value) {
|
|
362
|
+
if (!Array.isArray(value))
|
|
363
|
+
return [];
|
|
364
|
+
const output = new Map();
|
|
365
|
+
for (const candidate of value.slice(0, MAX_OFFICIAL_CHANNELS * 4)) {
|
|
366
|
+
const item = record(candidate);
|
|
367
|
+
const platform = typeof item.platform === 'string'
|
|
368
|
+
&& OFFICIAL_CHANNEL_PLATFORMS.includes(item.platform)
|
|
369
|
+
? item.platform
|
|
370
|
+
: null;
|
|
371
|
+
const url = normalizePublicUrl(item.url);
|
|
372
|
+
if (!platform || !url)
|
|
373
|
+
continue;
|
|
374
|
+
const key = url.toLowerCase();
|
|
375
|
+
if (output.has(key))
|
|
376
|
+
continue;
|
|
377
|
+
output.set(key, {
|
|
378
|
+
platform,
|
|
379
|
+
url,
|
|
380
|
+
handle: boundedText(item.handle),
|
|
381
|
+
role: boundedText(item.role),
|
|
382
|
+
reviewedAt: timestamp(item.reviewedAt, null),
|
|
383
|
+
});
|
|
384
|
+
if (output.size >= MAX_OFFICIAL_CHANNELS)
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
return [...output.values()];
|
|
388
|
+
}
|
|
389
|
+
function normalizeRightsReferences(value) {
|
|
390
|
+
if (!Array.isArray(value))
|
|
391
|
+
return [];
|
|
392
|
+
const output = new Map();
|
|
393
|
+
for (const candidate of value.slice(0, MAX_RIGHTS_REFERENCES * 4)) {
|
|
394
|
+
const item = record(candidate);
|
|
395
|
+
const kind = typeof item.kind === 'string' && RIGHTS_REFERENCE_KINDS.includes(item.kind)
|
|
396
|
+
? item.kind
|
|
397
|
+
: null;
|
|
398
|
+
const owner = boundedText(item.owner);
|
|
399
|
+
const identifier = boundedText(item.identifier);
|
|
400
|
+
if (!kind || (!owner && !identifier))
|
|
401
|
+
continue;
|
|
402
|
+
const jurisdiction = boundedText(item.jurisdiction);
|
|
403
|
+
const sourceUrl = item.sourceUrl == null || item.sourceUrl === '' ? '' : normalizePublicUrl(item.sourceUrl);
|
|
404
|
+
if (item.sourceUrl && !sourceUrl)
|
|
405
|
+
continue;
|
|
406
|
+
const key = `${kind}\u0000${owner.toLowerCase()}\u0000${identifier.toLowerCase()}\u0000${jurisdiction.toLowerCase()}`;
|
|
407
|
+
if (output.has(key))
|
|
408
|
+
continue;
|
|
409
|
+
output.set(key, {
|
|
410
|
+
kind,
|
|
411
|
+
owner,
|
|
412
|
+
identifier,
|
|
413
|
+
jurisdiction,
|
|
414
|
+
sourceUrl,
|
|
415
|
+
reviewedAt: timestamp(item.reviewedAt, null),
|
|
416
|
+
note: boundedText(item.note),
|
|
417
|
+
});
|
|
418
|
+
if (output.size >= MAX_RIGHTS_REFERENCES)
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
return [...output.values()];
|
|
422
|
+
}
|
|
336
423
|
/** Normalize one profile while retaining only known, bounded fields. */
|
|
337
424
|
export function normalizeBrandProfile(raw, options = {}) {
|
|
338
425
|
const value = record(raw);
|
|
@@ -360,6 +447,7 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
360
447
|
id: profileId,
|
|
361
448
|
name,
|
|
362
449
|
officialDomains,
|
|
450
|
+
officialChannels: normalizeOfficialChannels(value.officialChannels),
|
|
363
451
|
productNames: normalizeProfileTextValues(value.productNames),
|
|
364
452
|
tlds: normalizeProfileTlds(value.tlds),
|
|
365
453
|
approvedPartnerDomains: normalizeProfileDomains(value.approvedPartnerDomains),
|
|
@@ -373,6 +461,7 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
373
461
|
desiredPostureBaselines: normalizeDesiredPostureBaselines(value.desiredPostureBaselines, officialDomains, now, profileId),
|
|
374
462
|
trademarkOwner: boundedText(value.trademarkOwner),
|
|
375
463
|
trademarkRegistration: boundedText(value.trademarkRegistration),
|
|
464
|
+
rightsReferences: normalizeRightsReferences(value.rightsReferences),
|
|
376
465
|
officialFaviconHash: baselineDomainMismatch ? '' : normalizeFaviconHash(value.officialFaviconHash),
|
|
377
466
|
officialFaviconPHash: baselineDomainMismatch ? '' : normalizeFaviconPHash(value.officialFaviconPHash),
|
|
378
467
|
pageBaseline,
|
|
@@ -380,6 +469,33 @@ export function normalizeBrandProfile(raw, options = {}) {
|
|
|
380
469
|
updatedAt: options.touch === true ? now : timestamp(value.updatedAt, createdAt),
|
|
381
470
|
};
|
|
382
471
|
}
|
|
472
|
+
const BRAND_PROFILE_FIELD_PATCH_KEYS = Object.freeze([
|
|
473
|
+
'allowlistedDomains',
|
|
474
|
+
'allowlistedRegistrars',
|
|
475
|
+
'desiredPostureBaselines',
|
|
476
|
+
'protectionAttestations',
|
|
477
|
+
]);
|
|
478
|
+
/**
|
|
479
|
+
* Applies one field-owned edit to the transaction-current profile. Identity,
|
|
480
|
+
* timestamps, and every field outside the explicit patch remain owned by that
|
|
481
|
+
* current record rather than by the caller's potentially stale snapshot.
|
|
482
|
+
*/
|
|
483
|
+
export function applyBrandProfileFieldPatch(existing, patch, options = {}) {
|
|
484
|
+
const merged = { ...existing };
|
|
485
|
+
const supplied = patch;
|
|
486
|
+
for (const key of BRAND_PROFILE_FIELD_PATCH_KEYS) {
|
|
487
|
+
if (Object.hasOwn(supplied, key))
|
|
488
|
+
merged[key] = supplied[key];
|
|
489
|
+
}
|
|
490
|
+
const normalized = normalizeBrandProfile(merged, {
|
|
491
|
+
existing,
|
|
492
|
+
nowIso: options.nowIso,
|
|
493
|
+
touch: true,
|
|
494
|
+
});
|
|
495
|
+
if (!normalized)
|
|
496
|
+
throw new Error('The Brand Profile field update is invalid.');
|
|
497
|
+
return normalized;
|
|
498
|
+
}
|
|
383
499
|
function profileList(raw) {
|
|
384
500
|
if (Array.isArray(raw))
|
|
385
501
|
return raw;
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
2
|
+
import { CASE_DISPOSITIONS as CASE_DISPOSITION_OPTIONS } from "../cases/case-record-contracts.mjs";
|
|
2
3
|
import { normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
|
|
3
4
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
5
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
5
6
|
import { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
|
|
6
7
|
export { BULK_REVIEW_SCHEMA, BULK_REVIEW_SCHEMA_VERSION, MAX_BULK_REVIEW_NAME_LENGTH, MAX_BULK_REVIEW_PRESETS, MAX_BULK_REVIEW_ROWS, MAX_BULK_REVIEW_STORE_BYTES, } from "../contracts/workspace-portability.mjs";
|
|
7
8
|
export const BULK_REVIEW_STATES = ['unreviewed', 'reviewing', 'reviewed', 'deferred'];
|
|
9
|
+
export const BULK_SOURCE_FILTERS = ['', 'complete', 'limited', 'unrecorded'];
|
|
10
|
+
export const BULK_LIFECYCLE_FILTERS = ['', 'registered', 'for_sale', 'expiring', 'available', 'unknown', 'error'];
|
|
11
|
+
export const BULK_AGE_FILTERS = ['', 'new_30', 'new_365', 'older_365', 'unknown'];
|
|
12
|
+
export const BULK_MAIL_FILTERS = ['', 'mail', 'no_mail', 'authenticated', 'auth_gap', 'unknown'];
|
|
13
|
+
export const BULK_GROUP_OPTIONS = ['', 'mutation', 'tld', 'registrar', 'nameserver'];
|
|
8
14
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
9
15
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u;
|
|
10
16
|
const PRIMARY_FILTERS = new Set(['all', 'available', 'registered', 'high_risk', 'trusted', 'profile_unevaluated', 'errors']);
|
|
11
|
-
const SOURCE_FILTERS = new Set(
|
|
12
|
-
const LIFECYCLE_FILTERS = new Set(
|
|
13
|
-
const AGE_FILTERS = new Set(
|
|
14
|
-
const MAIL_FILTERS = new Set(
|
|
15
|
-
const GROUPS = new Set(
|
|
16
|
-
const SORT_KEYS = new Set(['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
|
|
17
|
+
const SOURCE_FILTERS = new Set(BULK_SOURCE_FILTERS);
|
|
18
|
+
const LIFECYCLE_FILTERS = new Set(BULK_LIFECYCLE_FILTERS);
|
|
19
|
+
const AGE_FILTERS = new Set(BULK_AGE_FILTERS);
|
|
20
|
+
const MAIL_FILTERS = new Set(BULK_MAIL_FILTERS);
|
|
21
|
+
const GROUPS = new Set(BULK_GROUP_OPTIONS);
|
|
22
|
+
const SORT_KEYS = new Set(['domain', 'availability', 'confidence', 'risk', 'opportunity', 'activity', 'registrar', 'mutation']);
|
|
17
23
|
const REVIEW_STATES = new Set(BULK_REVIEW_STATES);
|
|
18
|
-
const CASE_DISPOSITIONS = new Set(['', 'untracked',
|
|
24
|
+
const CASE_DISPOSITIONS = new Set(['', 'untracked', ...CASE_DISPOSITION_OPTIONS.map((item) => item.value)]);
|
|
19
25
|
const SIGNAL_FILTERS = new Set(['favicon', 'password', 'phishing', 'asset_reuse', 'idn']);
|
|
20
26
|
function record(value) {
|
|
21
27
|
return ordinaryWorkspaceRecord(value, 'Bulk-review input') ?? {};
|
|
@@ -488,10 +488,11 @@ export function deleteBulkSession(raw, idRaw) {
|
|
|
488
488
|
function sourceStateMap(value) {
|
|
489
489
|
return new Map(value.sourceCoverage.map((item) => [item.source, item.state]));
|
|
490
490
|
}
|
|
491
|
-
function
|
|
491
|
+
export function areBulkRiskScoresComparable(previous, current) {
|
|
492
492
|
return previous.profileContext.sourceState === 'ready'
|
|
493
493
|
&& current.profileContext.sourceState === 'ready'
|
|
494
494
|
&& sameProfileContext(previous.profileContext, current.profileContext)
|
|
495
|
+
&& previous.scanDepth === current.scanDepth
|
|
495
496
|
&& previous.risk !== null
|
|
496
497
|
&& current.risk !== null
|
|
497
498
|
&& previous.riskModelVersion !== null
|
|
@@ -501,7 +502,7 @@ function resultChanges(previous, current) {
|
|
|
501
502
|
const changes = [];
|
|
502
503
|
if (previous.availability !== current.availability)
|
|
503
504
|
changes.push(`Registration: ${previous.availability} → ${current.availability}`);
|
|
504
|
-
if (
|
|
505
|
+
if (areBulkRiskScoresComparable(previous, current) && previous.risk !== current.risk) {
|
|
505
506
|
changes.push(`Risk: ${previous.risk ?? 'unavailable'} → ${current.risk ?? 'unavailable'}`);
|
|
506
507
|
}
|
|
507
508
|
if (previous.registrar !== current.registrar)
|
|
@@ -554,7 +555,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
554
555
|
rows.push({ domain, changes: ['Absent from the later settled result set.'] });
|
|
555
556
|
}
|
|
556
557
|
else {
|
|
557
|
-
if (!
|
|
558
|
+
if (!areBulkRiskScoresComparable(previous, next))
|
|
558
559
|
riskIncomparable = true;
|
|
559
560
|
const changes = resultChanges(previous, next);
|
|
560
561
|
if (changes.length)
|
|
@@ -576,7 +577,7 @@ export function compareBulkSessions(baselineRaw, currentRaw) {
|
|
|
576
577
|
'A source-state change may reflect collection availability rather than a change to the domain.',
|
|
577
578
|
'A missing row means it was not completed in that saved session; it does not establish domain removal.',
|
|
578
579
|
...(riskIncomparable
|
|
579
|
-
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance
|
|
580
|
+
? ['Risk deltas are omitted unless both rows retain the same ready Brand Profile provenance, scan depth, and versioned Risk model.']
|
|
580
581
|
: []),
|
|
581
582
|
],
|
|
582
583
|
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// small structured language: field names and operators come from allowlists,
|
|
3
3
|
// so imported rules cannot execute code or reach outside bounded case evidence.
|
|
4
4
|
import { latestCaseEvidence } from "../cases/case-model.mjs";
|
|
5
|
+
import { CASE_DISPOSITIONS, CASE_STATUSES } from "../cases/case-record-contracts.mjs";
|
|
5
6
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
6
7
|
import { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
7
8
|
export { DETECTION_RULE_SCHEMA, DETECTION_RULE_SCHEMA_VERSION, MAX_CONDITION_VALUE_LENGTH, MAX_CUSTOM_RISK_TOTAL, MAX_DETECTION_RULES, MAX_RULE_CONDITIONS, MAX_RULE_IMPORT_BYTES, MAX_RULE_INPUT_RECORDS, MAX_RULE_NAME_LENGTH, MAX_RULE_RISK_DELTA, MAX_RULE_STORE_BYTES, MAX_RULE_TAG_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
@@ -26,8 +27,8 @@ export const RULE_FIELD_DEFINITIONS = Object.freeze([
|
|
|
26
27
|
{ value: 'mutationTypes', label: 'Mutation type', kind: 'list' },
|
|
27
28
|
{ value: 'nameservers', label: 'Nameserver', kind: 'list' },
|
|
28
29
|
{ value: 'httpSecurityHeaders', label: 'HTTP security header', kind: 'list' },
|
|
29
|
-
{ value: 'status', label: 'Case status', kind: 'enum', values:
|
|
30
|
-
{ value: 'disposition', label: 'Case disposition', kind: 'enum', values:
|
|
30
|
+
{ value: 'status', label: 'Case status', kind: 'enum', values: CASE_STATUSES.map((item) => item.value) },
|
|
31
|
+
{ value: 'disposition', label: 'Case disposition', kind: 'enum', values: CASE_DISPOSITIONS.map((item) => item.value) },
|
|
31
32
|
{ value: 'tags', label: 'Case tag', kind: 'list' },
|
|
32
33
|
]);
|
|
33
34
|
const FIELD_BY_VALUE = new Map(RULE_FIELD_DEFINITIONS.map((field) => [field.value, field]));
|
|
@@ -103,13 +104,14 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
|
|
|
103
104
|
return null;
|
|
104
105
|
const conditions = [];
|
|
105
106
|
const rawConditions = Array.isArray(item.conditions) ? item.conditions : [];
|
|
106
|
-
|
|
107
|
-
const condition = normalizeRuleCondition(item);
|
|
108
|
-
if (condition)
|
|
109
|
-
conditions.push(condition);
|
|
110
|
-
}
|
|
111
|
-
if (!conditions.length)
|
|
107
|
+
if (!rawConditions.length || rawConditions.length > MAX_RULE_CONDITIONS)
|
|
112
108
|
return null;
|
|
109
|
+
for (const rawCondition of rawConditions) {
|
|
110
|
+
const condition = normalizeRuleCondition(rawCondition);
|
|
111
|
+
if (!condition)
|
|
112
|
+
return null;
|
|
113
|
+
conditions.push(condition);
|
|
114
|
+
}
|
|
113
115
|
const riskDelta = normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA);
|
|
114
116
|
return {
|
|
115
117
|
id: safeId(item.id) || (generateId ? makeId() : null),
|
|
@@ -121,6 +123,30 @@ export function normalizeDetectionRule(raw, { generateId = false } = {}) {
|
|
|
121
123
|
tag: normalizedText(item.tag, MAX_RULE_TAG_LENGTH).toLowerCase(),
|
|
122
124
|
};
|
|
123
125
|
}
|
|
126
|
+
function validateDetectionRule(raw, { generateId = false } = {}) {
|
|
127
|
+
const item = record(raw);
|
|
128
|
+
if (!Array.isArray(item.conditions) || !item.conditions.length) {
|
|
129
|
+
throw new Error('A custom rule needs at least one condition.');
|
|
130
|
+
}
|
|
131
|
+
if (item.conditions.length > MAX_RULE_CONDITIONS) {
|
|
132
|
+
throw new Error(`Custom rules are limited to ${MAX_RULE_CONDITIONS} conditions.`);
|
|
133
|
+
}
|
|
134
|
+
for (const [index, condition] of item.conditions.entries()) {
|
|
135
|
+
if (!normalizeRuleCondition(condition)) {
|
|
136
|
+
throw new Error(`Condition ${index + 1} is invalid or outside the allowed bounds.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (item.match !== undefined && item.match !== 'all' && item.match !== 'any') {
|
|
140
|
+
throw new Error('Choose whether all or any conditions must match.');
|
|
141
|
+
}
|
|
142
|
+
if (item.riskDelta !== undefined && normalizeInteger(item.riskDelta, 0, MAX_RULE_RISK_DELTA) === null) {
|
|
143
|
+
throw new Error(`Custom contribution must be a whole number from 0 to ${MAX_RULE_RISK_DELTA}.`);
|
|
144
|
+
}
|
|
145
|
+
const normalized = normalizeDetectionRule(item, { generateId });
|
|
146
|
+
if (!normalized)
|
|
147
|
+
throw new Error('Enter a rule name and valid conditions.');
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
124
150
|
function ruleList(raw) {
|
|
125
151
|
if (Array.isArray(raw))
|
|
126
152
|
return raw;
|
|
@@ -133,23 +159,32 @@ export function detectionRuleStoreVersion(raw) {
|
|
|
133
159
|
const item = record(raw);
|
|
134
160
|
return typeof item.version === 'number' && Number.isFinite(item.version) ? item.version : null;
|
|
135
161
|
}
|
|
136
|
-
export function
|
|
162
|
+
export function recoverDetectionRuleStore(raw) {
|
|
137
163
|
assertWorkspaceInputGraph(raw, 'Detection-rule store');
|
|
138
164
|
assertWorkspaceDeclaredVersion(raw, 'Detection-rule store');
|
|
139
165
|
const byId = new Map();
|
|
140
|
-
|
|
166
|
+
const source = ruleList(raw);
|
|
167
|
+
let rejected = Math.max(0, source.length - MAX_RULE_INPUT_RECORDS);
|
|
168
|
+
for (const item of source.slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
141
169
|
const rule = normalizeDetectionRule(item);
|
|
142
|
-
if (!rule?.id || byId.has(rule.id))
|
|
170
|
+
if (!rule?.id || byId.has(rule.id)) {
|
|
171
|
+
rejected += 1;
|
|
143
172
|
continue;
|
|
173
|
+
}
|
|
174
|
+
if (byId.size >= MAX_DETECTION_RULES) {
|
|
175
|
+
rejected += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
144
178
|
byId.set(rule.id, { ...rule, id: rule.id });
|
|
145
|
-
if (byId.size >= MAX_DETECTION_RULES)
|
|
146
|
-
break;
|
|
147
179
|
}
|
|
148
|
-
return { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] };
|
|
180
|
+
return { store: { version: DETECTION_RULE_SCHEMA_VERSION, rules: [...byId.values()] }, rejected };
|
|
181
|
+
}
|
|
182
|
+
export function normalizeDetectionRuleStore(raw) {
|
|
183
|
+
return recoverDetectionRuleStore(raw).store;
|
|
149
184
|
}
|
|
150
185
|
export function createDetectionRule(rules, input) {
|
|
151
|
-
const normalized =
|
|
152
|
-
if (!normalized
|
|
186
|
+
const normalized = validateDetectionRule(input, { generateId: true });
|
|
187
|
+
if (!normalized.id)
|
|
153
188
|
throw new Error('Enter a rule name and one valid condition.');
|
|
154
189
|
const existing = normalizeDetectionRuleStore(rules).rules;
|
|
155
190
|
if (existing.length >= MAX_DETECTION_RULES)
|
|
@@ -162,8 +197,8 @@ export function updateDetectionRule(rules, id, patch) {
|
|
|
162
197
|
const current = normalizedRules.find((rule) => rule.id === id);
|
|
163
198
|
if (!current)
|
|
164
199
|
throw new Error('That custom rule no longer exists.');
|
|
165
|
-
const updated =
|
|
166
|
-
if (!updated
|
|
200
|
+
const updated = validateDetectionRule({ ...current, ...record(patch), id });
|
|
201
|
+
if (!updated.id)
|
|
167
202
|
throw new Error('A custom rule needs a name and at least one valid condition.');
|
|
168
203
|
return normalizeDetectionRuleStore(normalizedRules.map((rule) => rule.id === id ? updated : rule)).rules;
|
|
169
204
|
}
|
|
@@ -245,8 +280,14 @@ function ruleSignature(rule) {
|
|
|
245
280
|
});
|
|
246
281
|
}
|
|
247
282
|
export function previewDetectionRule(records, existingRules, rawCandidate) {
|
|
248
|
-
|
|
249
|
-
|
|
283
|
+
let normalized;
|
|
284
|
+
try {
|
|
285
|
+
normalized = validateDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
if (!normalized.id)
|
|
250
291
|
return null;
|
|
251
292
|
const candidate = { ...normalized, id: normalized.id };
|
|
252
293
|
const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
|
|
@@ -295,8 +336,15 @@ export function mergeDetectionRules(localRaw, importedRaw) {
|
|
|
295
336
|
let updated = 0;
|
|
296
337
|
let skipped = Math.max(0, importedList.length - MAX_RULE_INPUT_RECORDS);
|
|
297
338
|
for (const item of importedList.slice(0, MAX_RULE_INPUT_RECORDS)) {
|
|
298
|
-
|
|
299
|
-
|
|
339
|
+
let rule;
|
|
340
|
+
try {
|
|
341
|
+
rule = validateDetectionRule(item);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
skipped++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (!rule.id) {
|
|
300
348
|
skipped++;
|
|
301
349
|
continue;
|
|
302
350
|
}
|
|
@@ -283,8 +283,11 @@ export function createRelationshipObservation(raw, options = {}) {
|
|
|
283
283
|
throw new Error('That relationship does not contain a supported bounded value and at least one valid domain.');
|
|
284
284
|
}
|
|
285
285
|
const now = new Date().toISOString();
|
|
286
|
-
const observedAt = timestamp(options.observedAt)
|
|
286
|
+
const observedAt = Object.hasOwn(options, 'observedAt') ? timestamp(options.observedAt) : now;
|
|
287
287
|
const retainedAt = timestamp(options.retainedAt) || now;
|
|
288
|
+
if (!observedAt) {
|
|
289
|
+
throw new Error('A relationship observation needs the contributing scan observation time; an unknown time is not replaced with the retention time.');
|
|
290
|
+
}
|
|
288
291
|
const canonical = canonicalIdentity(type, normalizedValue, domains);
|
|
289
292
|
const metadata = TYPE_METADATA[type];
|
|
290
293
|
return {
|
|
@@ -296,14 +296,89 @@ export function mergeWebsiteSnapshots(localRaw, incomingRaw) {
|
|
|
296
296
|
const snapshots = normalizeWebsiteSnapshotStore([...byId.values()]).snapshots;
|
|
297
297
|
return { snapshots, added, updated, skipped: Math.max(0, incoming.length - added - updated) };
|
|
298
298
|
}
|
|
299
|
-
|
|
299
|
+
const MAX_RECONCILED_WEBSITE_SOURCES = 16;
|
|
300
|
+
const UNAVAILABLE_WEBSITE_SOURCE_STATES = new Set([
|
|
301
|
+
'blocked', 'error', 'not_found', 'rate_limited', 'unavailable', 'unsupported',
|
|
302
|
+
]);
|
|
303
|
+
export function canonicalWebsiteSourceState(value) {
|
|
304
|
+
return value.trim().toLowerCase().replace(/[\s-]+/gu, '_');
|
|
305
|
+
}
|
|
306
|
+
function websiteSourceStateSeverity(value) {
|
|
307
|
+
if (UNAVAILABLE_WEBSITE_SOURCE_STATES.has(value))
|
|
308
|
+
return 2;
|
|
309
|
+
return ['complete', 'success'].includes(value) ? 0 : 1;
|
|
310
|
+
}
|
|
311
|
+
export function reconciledWebsiteSources(snapshot) {
|
|
312
|
+
const selected = new Map();
|
|
313
|
+
for (const sourceEntry of snapshot.sources.slice(0, MAX_RECONCILED_WEBSITE_SOURCES)) {
|
|
314
|
+
const sourceId = sourceEntry.source.trim().toLowerCase();
|
|
315
|
+
if (!sourceId)
|
|
316
|
+
continue;
|
|
317
|
+
const state = canonicalWebsiteSourceState(sourceEntry.state);
|
|
318
|
+
const severity = websiteSourceStateSeverity(state);
|
|
319
|
+
const current = selected.get(sourceId);
|
|
320
|
+
if (!current || severity > current.severity || (severity === current.severity && state.localeCompare(current.state) < 0)) {
|
|
321
|
+
selected.set(sourceId, { state, severity });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return [...selected.entries()]
|
|
325
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
326
|
+
.map(([sourceName, value]) => ({ source: sourceName, state: value.state }));
|
|
327
|
+
}
|
|
328
|
+
export function websiteWithReconciledSources(snapshot) {
|
|
329
|
+
return { ...snapshot, sources: reconciledWebsiteSources(snapshot) };
|
|
330
|
+
}
|
|
331
|
+
export function reconciledWebsiteSourceState(snapshot, sourceId) {
|
|
332
|
+
const canonicalId = sourceId.trim().toLowerCase();
|
|
333
|
+
return reconciledWebsiteSources(snapshot)
|
|
334
|
+
.find((sourceEntry) => sourceEntry.source === canonicalId)?.state ?? null;
|
|
335
|
+
}
|
|
336
|
+
function completeWebsiteSourceState(value) {
|
|
337
|
+
const state = canonicalWebsiteSourceState(value);
|
|
338
|
+
return state === 'complete' || state === 'success';
|
|
339
|
+
}
|
|
340
|
+
export function websiteSnapshotFieldSource(field) {
|
|
341
|
+
if (field.startsWith('certificate.'))
|
|
342
|
+
return 'tls';
|
|
343
|
+
if (field.startsWith('source.'))
|
|
344
|
+
return field.slice('source.'.length);
|
|
345
|
+
if (field.startsWith('dependency.')) {
|
|
346
|
+
const recordType = field.slice('dependency.'.length).split(':')[0];
|
|
347
|
+
return recordType === 'HTTP' ? 'http' : 'dns';
|
|
348
|
+
}
|
|
349
|
+
if (['technology', 'posture', 'identity'].some((family) => field.startsWith(family)))
|
|
350
|
+
return 'http';
|
|
351
|
+
return '';
|
|
352
|
+
}
|
|
353
|
+
export function websiteSnapshotFieldComplete(snapshot, field) {
|
|
354
|
+
const sourceName = websiteSnapshotFieldSource(field);
|
|
355
|
+
const retainedSource = sourceName ? reconciledWebsiteSourceState(snapshot, sourceName) : null;
|
|
356
|
+
const retainedSourceComplete = !retainedSource || completeWebsiteSourceState(retainedSource);
|
|
357
|
+
if (field.startsWith('certificate.')) {
|
|
358
|
+
return Boolean(snapshot.certificate?.complete && !snapshot.certificate.truncated && retainedSourceComplete);
|
|
359
|
+
}
|
|
360
|
+
return snapshot.complete && !snapshot.truncated && retainedSourceComplete;
|
|
361
|
+
}
|
|
362
|
+
export function websiteSnapshotComparisonEvidenceComplete(snapshot) {
|
|
363
|
+
return websiteSnapshotFieldComplete(snapshot, 'technology')
|
|
364
|
+
&& websiteSnapshotFieldComplete(snapshot, 'posture')
|
|
365
|
+
&& websiteSnapshotFieldComplete(snapshot, 'identity')
|
|
366
|
+
&& websiteSnapshotFieldComplete(snapshot, 'dependency.CNAME')
|
|
367
|
+
&& (!snapshot.certificate || websiteSnapshotFieldComplete(snapshot, 'certificate.observation'));
|
|
368
|
+
}
|
|
369
|
+
function compareMap(field, before, after, options = {}) {
|
|
300
370
|
const changes = [];
|
|
301
371
|
for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
302
372
|
const left = before.get(key) ?? null;
|
|
303
373
|
const right = after.get(key) ?? null;
|
|
304
374
|
if (left === right)
|
|
305
375
|
continue;
|
|
306
|
-
|
|
376
|
+
const state = left === null
|
|
377
|
+
? options.beforeComplete?.(key) === false ? 'incomparable' : 'added'
|
|
378
|
+
: right === null
|
|
379
|
+
? options.afterComplete?.(key) === false ? 'incomparable' : 'removed'
|
|
380
|
+
: 'changed';
|
|
381
|
+
changes.push({ field: `${field}.${key}`, state, before: left, after: right });
|
|
307
382
|
}
|
|
308
383
|
return changes;
|
|
309
384
|
}
|
|
@@ -313,6 +388,7 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
313
388
|
if (!before || !after || before.domain !== after.domain) {
|
|
314
389
|
return {
|
|
315
390
|
compatible: false,
|
|
391
|
+
complete: false,
|
|
316
392
|
changes: [{ field: 'snapshot', state: 'incomparable', before: before?.domain ?? null, after: after?.domain ?? null }],
|
|
317
393
|
dependencyTransitions: [],
|
|
318
394
|
};
|
|
@@ -328,20 +404,29 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
328
404
|
? 'legacy_unknown'
|
|
329
405
|
: postureVersionBefore === postureVersionAfter ? 'comparable' : 'detector_changed';
|
|
330
406
|
const changes = [
|
|
331
|
-
...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`]))
|
|
407
|
+
...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), {
|
|
408
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'technology'),
|
|
409
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'technology'),
|
|
410
|
+
}) : [{
|
|
332
411
|
field: 'technology.profileVersion',
|
|
333
412
|
state: technologyComparability === 'detector_changed' ? 'changed' : 'incomparable',
|
|
334
413
|
before: technologyVersionBefore === null ? null : String(technologyVersionBefore),
|
|
335
414
|
after: technologyVersionAfter === null ? null : String(technologyVersionAfter),
|
|
336
415
|
}]),
|
|
337
|
-
...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))
|
|
416
|
+
...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state])), {
|
|
417
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'posture'),
|
|
418
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'posture'),
|
|
419
|
+
}) : [{
|
|
338
420
|
field: 'posture.profileVersion',
|
|
339
421
|
state: postureComparability === 'detector_changed' ? 'changed' : 'incomparable',
|
|
340
422
|
before: postureVersionBefore === null ? null : String(postureVersionBefore),
|
|
341
423
|
after: postureVersionAfter === null ? null : String(postureVersionAfter),
|
|
342
424
|
}]),
|
|
343
425
|
...compareMap('source', new Map(before.sources.map((item) => [item.source, item.state])), new Map(after.sources.map((item) => [item.source, item.state]))),
|
|
344
|
-
...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`]))
|
|
426
|
+
...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), {
|
|
427
|
+
beforeComplete: (key) => websiteSnapshotFieldComplete(before, `dependency.${key}`),
|
|
428
|
+
afterComplete: (key) => websiteSnapshotFieldComplete(after, `dependency.${key}`),
|
|
429
|
+
}),
|
|
345
430
|
];
|
|
346
431
|
const dependencyTransitions = [];
|
|
347
432
|
const beforeDependencies = new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, item]));
|
|
@@ -365,10 +450,10 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
365
450
|
detail: 'An earlier active dependency is unresolved in the later retained observation. Resolver or collection failure remains possible.',
|
|
366
451
|
});
|
|
367
452
|
}
|
|
368
|
-
else if (!left && right) {
|
|
453
|
+
else if (!left && right && websiteSnapshotFieldComplete(before, `dependency.${key}`)) {
|
|
369
454
|
dependencyTransitions.push({ target: right.target, recordType: right.recordType, state: 'added', detail: 'This dependency first appears in the later retained snapshot.' });
|
|
370
455
|
}
|
|
371
|
-
else if (left && !right && after
|
|
456
|
+
else if (left && !right && websiteSnapshotFieldComplete(after, `dependency.${key}`)) {
|
|
372
457
|
dependencyTransitions.push({ target: left.target, recordType: left.recordType, state: 'removed', detail: 'This dependency is not represented in the later complete snapshot. It may have been intentionally removed or replaced.' });
|
|
373
458
|
}
|
|
374
459
|
if (dependencyTransitions.length >= 20)
|
|
@@ -381,7 +466,16 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
381
466
|
continue;
|
|
382
467
|
changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
|
|
383
468
|
}
|
|
384
|
-
changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item]))
|
|
469
|
+
changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item])), {
|
|
470
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.resourceHosts'),
|
|
471
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.resourceHosts'),
|
|
472
|
+
}), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), {
|
|
473
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.trackingIdentifiers'),
|
|
474
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.trackingIdentifiers'),
|
|
475
|
+
}), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item])), {
|
|
476
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.formActionOrigins'),
|
|
477
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.formActionOrigins'),
|
|
478
|
+
}));
|
|
385
479
|
if (before.certificate || after.certificate) {
|
|
386
480
|
if (!before.certificate || !after.certificate) {
|
|
387
481
|
changes.push({
|
|
@@ -425,6 +519,10 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
425
519
|
}
|
|
426
520
|
return {
|
|
427
521
|
compatible: true,
|
|
522
|
+
complete: websiteSnapshotComparisonEvidenceComplete(before)
|
|
523
|
+
&& websiteSnapshotComparisonEvidenceComplete(after)
|
|
524
|
+
&& technologyComparability === 'comparable'
|
|
525
|
+
&& postureComparability === 'comparable',
|
|
428
526
|
profileComparability: {
|
|
429
527
|
technology: technologyComparability,
|
|
430
528
|
securityPosture: postureComparability,
|
|
@@ -528,6 +528,7 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
|
|
|
528
528
|
skipped: result.skipped ?? 0,
|
|
529
529
|
pruned: result.pruned ?? 0,
|
|
530
530
|
brandProfileReferencesOmitted: result.brandProfileReferencesOmitted ?? 0,
|
|
531
|
+
authoredHistoryOmitted: result.authoredHistoryOmitted ?? 0,
|
|
531
532
|
selected,
|
|
532
533
|
normalizedSettings: result.settings || null,
|
|
533
534
|
});
|
|
@@ -567,6 +568,7 @@ export async function previewWorkspaceArchive(raw, localInput, options = {}) {
|
|
|
567
568
|
skipped: dependentSection.recordCount,
|
|
568
569
|
pruned: 0,
|
|
569
570
|
brandProfileReferencesOmitted: 0,
|
|
571
|
+
authoredHistoryOmitted: 0,
|
|
570
572
|
selected: false,
|
|
571
573
|
normalizedSettings: dependentId === 'settings' ? null : dependentSection.normalizedSettings ?? null,
|
|
572
574
|
};
|