@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -72,7 +72,7 @@ export async function buildAcquisitionDecisionPacket(input) {
|
|
|
72
72
|
},
|
|
73
73
|
evidenceReview: input.review,
|
|
74
74
|
limitations: [
|
|
75
|
-
'This local
|
|
75
|
+
'This local artefact records an analyst review of bounded point-in-time evidence and does not submit, reserve, value, purchase, or transfer a domain.',
|
|
76
76
|
'A reviewed state records completion of the displayed manual checklist, not the accuracy of external statements or a legal, financial, eligibility, or ownership determination.',
|
|
77
77
|
'Refresh authoritative registration and policy evidence immediately before acting.',
|
|
78
78
|
...(input.synthetic === true
|
|
@@ -102,7 +102,7 @@ function lifecycleItem(availability, lifecycle, publications) {
|
|
|
102
102
|
label: 'Lifecycle date observed',
|
|
103
103
|
state: expiresInDays !== null && expiresInDays <= 30 ? 'review' : 'observed',
|
|
104
104
|
detail: timing,
|
|
105
|
-
provenance: '
|
|
105
|
+
provenance: 'Normalised RDAP or WHOIS lifecycle',
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
return {
|
|
@@ -110,7 +110,7 @@ function lifecycleItem(availability, lifecycle, publications) {
|
|
|
110
110
|
label: completePublication ? 'Expiry date not published' : 'Lifecycle timing unavailable',
|
|
111
111
|
state: completePublication ? 'observed' : 'unavailable',
|
|
112
112
|
detail: completePublication
|
|
113
|
-
? 'A usable publication was collected without a
|
|
113
|
+
? 'A usable publication was collected without a normalised expiry date. Registry policy and renewal state still require manual confirmation.'
|
|
114
114
|
: 'Incomplete or unavailable registry publications cannot establish an expiry or release window.',
|
|
115
115
|
provenance: 'Registry publication quality',
|
|
116
116
|
};
|
|
@@ -135,7 +135,7 @@ function transferItem(lifecycle, publications) {
|
|
|
135
135
|
id: 'transfer',
|
|
136
136
|
label: 'No interpreted lock status observed',
|
|
137
137
|
state: 'review',
|
|
138
|
-
detail: 'The collected statuses did not contain a
|
|
138
|
+
detail: 'The collected statuses did not contain a recognised client or server lock. This does not prove transfer eligibility or that other policy restrictions are absent.',
|
|
139
139
|
provenance: 'EPP status interpretation',
|
|
140
140
|
};
|
|
141
141
|
}
|
|
@@ -307,7 +307,7 @@ function registryPolicyChecks(availability, lifecycle, publications) {
|
|
|
307
307
|
detail: statuses.length
|
|
308
308
|
? `The collected publication included ${statuses.slice(0, 6).join(', ')}${statuses.length > 6 ? ', …' : ''}. Confirm current registry timing and restoration or deletion rules; WHOISleuth does not predict release.`
|
|
309
309
|
: publicationAvailable
|
|
310
|
-
? 'No
|
|
310
|
+
? 'No recognised lifecycle status was retained. Confirm renewal, grace, redemption, restoration, and deletion rules with the current registry or registrar.'
|
|
311
311
|
: 'No usable registry publication was available for a current lifecycle-policy review.',
|
|
312
312
|
provenance: 'Separately attributed RDAP and WHOIS lifecycle publications',
|
|
313
313
|
},
|
|
@@ -316,9 +316,9 @@ function registryPolicyChecks(availability, lifecycle, publications) {
|
|
|
316
316
|
label: locks.client === true || locks.server === true ? 'Confirm observed transfer constraints' : 'Confirm transfer and change-of-control requirements',
|
|
317
317
|
state: publicationAvailable ? 'review' : 'unavailable',
|
|
318
318
|
detail: locks.client === true || locks.server === true
|
|
319
|
-
? '
|
|
319
|
+
? 'Recognised client or server lock evidence was observed. Confirm authorisation codes, lock removal, waiting periods, identity checks, and any registrar change-of-control process.'
|
|
320
320
|
: publicationAvailable
|
|
321
|
-
? 'No
|
|
321
|
+
? 'No recognised lock was retained, but that does not establish transfer eligibility. Confirm authorisation, waiting periods, identity checks, and registrar requirements.'
|
|
322
322
|
: 'No usable status publication was available to describe current transfer constraints.',
|
|
323
323
|
provenance: 'EPP status interpretation and manual policy requirements',
|
|
324
324
|
},
|
|
@@ -383,7 +383,7 @@ export function buildAcquisitionDueDiligence(input) {
|
|
|
383
383
|
policyChecks,
|
|
384
384
|
nextSteps,
|
|
385
385
|
limitations: [
|
|
386
|
-
'This workspace
|
|
386
|
+
'This workspace organises observed evidence; it does not value a domain or establish that it can be acquired.',
|
|
387
387
|
'Expiry dates, EPP statuses, sale signals, and contact routes can change after collection.',
|
|
388
388
|
'WHOISleuth does not assess legal rights, registry eligibility, registrar terms, escrow, tax, or payment risk.',
|
|
389
389
|
],
|
|
@@ -5,7 +5,7 @@ import { normalizeDomain } from "./case-model.js";
|
|
|
5
5
|
import { normalizePageBaseline } from "./page-baseline.js";
|
|
6
6
|
import { isInformativeFaviconHash } from "./utils.js";
|
|
7
7
|
export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
|
|
8
|
-
export const BRAND_PROFILE_SCHEMA_VERSION =
|
|
8
|
+
export const BRAND_PROFILE_SCHEMA_VERSION = 6;
|
|
9
9
|
export const MAX_PROFILES = 100;
|
|
10
10
|
export const MAX_PROFILE_VALUES = 200;
|
|
11
11
|
export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
|
|
@@ -22,6 +22,8 @@ export const MAX_PROTECTION_ATTESTATIONS = 6;
|
|
|
22
22
|
export const MAX_DESIRED_POSTURE_BASELINES = 20;
|
|
23
23
|
export const MAX_DESIRED_POSTURE_RECORDS = 32;
|
|
24
24
|
export const MAX_DESIRED_POSTURE_SUPPRESSIONS = 12;
|
|
25
|
+
export const MAX_DESIRED_POSTURE_OBSERVATIONS = 12;
|
|
26
|
+
export const MAX_DESIRED_POSTURE_CHANGE_WINDOWS = 8;
|
|
25
27
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
26
28
|
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
27
29
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
@@ -203,6 +205,35 @@ function normalizeDesiredPostureObservation(value) {
|
|
|
203
205
|
}
|
|
204
206
|
return checks.length ? { observedAt, checks } : null;
|
|
205
207
|
}
|
|
208
|
+
function normalizeDesiredPostureObservationHistory(value, previous) {
|
|
209
|
+
const candidates = Array.isArray(value) ? value : previous ? [previous] : [];
|
|
210
|
+
const byTime = new Map();
|
|
211
|
+
for (const item of candidates.slice(0, MAX_DESIRED_POSTURE_OBSERVATIONS * 4)) {
|
|
212
|
+
const normalized = normalizeDesiredPostureObservation(item);
|
|
213
|
+
if (normalized)
|
|
214
|
+
byTime.set(normalized.observedAt, normalized);
|
|
215
|
+
}
|
|
216
|
+
return [...byTime.values()]
|
|
217
|
+
.sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt))
|
|
218
|
+
.slice(-MAX_DESIRED_POSTURE_OBSERVATIONS);
|
|
219
|
+
}
|
|
220
|
+
function normalizeDesiredPostureChangeWindows(value) {
|
|
221
|
+
if (!Array.isArray(value))
|
|
222
|
+
return [];
|
|
223
|
+
const output = [];
|
|
224
|
+
for (const item of value.slice(0, MAX_DESIRED_POSTURE_CHANGE_WINDOWS * 4)) {
|
|
225
|
+
const candidate = record(item);
|
|
226
|
+
const startsAt = timestamp(candidate.startsAt, null);
|
|
227
|
+
const endsAt = timestamp(candidate.endsAt, null);
|
|
228
|
+
const summary = boundedText(candidate.summary, 300);
|
|
229
|
+
if (!startsAt || !endsAt || Date.parse(endsAt) <= Date.parse(startsAt) || !summary)
|
|
230
|
+
continue;
|
|
231
|
+
output.push({ startsAt, endsAt, summary });
|
|
232
|
+
if (output.length >= MAX_DESIRED_POSTURE_CHANGE_WINDOWS)
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
return output.sort((left, right) => Date.parse(left.startsAt) - Date.parse(right.startsAt));
|
|
236
|
+
}
|
|
206
237
|
function normalizeTlsSanPatterns(value) {
|
|
207
238
|
if (!Array.isArray(value))
|
|
208
239
|
return [];
|
|
@@ -250,6 +281,8 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
|
|
|
250
281
|
break;
|
|
251
282
|
}
|
|
252
283
|
seen.add(domain);
|
|
284
|
+
const previousObservation = normalizeDesiredPostureObservation(candidate.previousObservation);
|
|
285
|
+
const observationHistory = normalizeDesiredPostureObservationHistory(candidate.observationHistory, previousObservation);
|
|
253
286
|
output.push({
|
|
254
287
|
version: 1,
|
|
255
288
|
domain,
|
|
@@ -266,9 +299,18 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
|
|
|
266
299
|
? candidate.registrarLock
|
|
267
300
|
: 'unconfigured',
|
|
268
301
|
renewalReviewAt: timestamp(candidate.renewalReviewAt, null),
|
|
302
|
+
zoneIntent: ['active_service', 'defensive_registration', 'no_service', 'parked', 'redirect_only'].includes(String(candidate.zoneIntent))
|
|
303
|
+
? candidate.zoneIntent
|
|
304
|
+
: 'unconfigured',
|
|
305
|
+
lifecycle: ['active', 'change_planned', 'retiring', 'retired'].includes(String(candidate.lifecycle))
|
|
306
|
+
? candidate.lifecycle
|
|
307
|
+
: 'active',
|
|
308
|
+
recoveryDependency: boundedText(candidate.recoveryDependency, 200),
|
|
309
|
+
approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows),
|
|
269
310
|
suppressions,
|
|
270
311
|
note: boundedText(candidate.note, MAX_PROFILE_TEXT_LENGTH),
|
|
271
|
-
previousObservation:
|
|
312
|
+
previousObservation: observationHistory.at(-1) ?? previousObservation,
|
|
313
|
+
observationHistory,
|
|
272
314
|
updatedAt: timestamp(candidate.updatedAt, fallback),
|
|
273
315
|
});
|
|
274
316
|
if (output.length >= MAX_DESIRED_POSTURE_BASELINES)
|
|
@@ -376,8 +418,8 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
|
|
|
376
418
|
if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
|
|
377
419
|
throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
|
|
378
420
|
}
|
|
379
|
-
if (![2, 3, 4, BRAND_PROFILE_SCHEMA_VERSION].includes(importedVersion ?? 0)) {
|
|
380
|
-
throw new Error(`Expected a WHOISleuth Brand Profile export using schema 2, 3, 4, or ${BRAND_PROFILE_SCHEMA_VERSION}.`);
|
|
421
|
+
if (![2, 3, 4, 5, BRAND_PROFILE_SCHEMA_VERSION].includes(importedVersion ?? 0)) {
|
|
422
|
+
throw new Error(`Expected a WHOISleuth Brand Profile export using schema 2, 3, 4, 5, or ${BRAND_PROFILE_SCHEMA_VERSION}.`);
|
|
381
423
|
}
|
|
382
424
|
const local = normalizeBrandProfileStore(localRaw).profiles;
|
|
383
425
|
const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
|
|
@@ -168,32 +168,32 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
|
|
|
168
168
|
.some((value) => ['conflict', 'conflicting'].includes(value.toLowerCase()));
|
|
169
169
|
const rows = [
|
|
170
170
|
row('registration', 'registration', 'Registration', left.availability, right.availability, 'Authority-aware compact availability state', registry, [], { conflicting: registrationConflicting }),
|
|
171
|
-
row('registrar', 'registration', 'Registrar', left.registrar, right.registrar, '
|
|
171
|
+
row('registrar', 'registration', 'Registrar', left.registrar, right.registrar, 'Normalised registrar display identity', registry),
|
|
172
172
|
row('created', 'lifecycle', 'Created', left.createdDate, right.createdDate, 'Source-reported lifecycle date', registry),
|
|
173
173
|
row('expires', 'lifecycle', 'Expires', left.expiryDate, right.expiryDate, 'Source-reported lifecycle date', registry),
|
|
174
|
-
row('nameservers', 'infrastructure', 'Nameservers', left.nameservers, right.nameservers, '
|
|
175
|
-
row('dns-a', 'dns', 'A records', dnsValues(left, 'a'), dnsValues(right, 'a'), '
|
|
176
|
-
row('dns-aaaa', 'dns', 'AAAA records', dnsValues(left, 'aaaa'), dnsValues(right, 'aaaa'), '
|
|
177
|
-
row('dns-cname', 'dns', 'CNAME records', dnsValues(left, 'cname'), dnsValues(right, 'cname'), '
|
|
178
|
-
row('dns-caa', 'dns', 'CAA records', dnsValues(left, 'caa'), dnsValues(right, 'caa'), '
|
|
174
|
+
row('nameservers', 'infrastructure', 'Nameservers', left.nameservers, right.nameservers, 'Normalised exact set comparison', registry),
|
|
175
|
+
row('dns-a', 'dns', 'A records', dnsValues(left, 'a'), dnsValues(right, 'a'), 'Normalised exact retained DNS answer set', dns),
|
|
176
|
+
row('dns-aaaa', 'dns', 'AAAA records', dnsValues(left, 'aaaa'), dnsValues(right, 'aaaa'), 'Normalised exact retained DNS answer set', dns),
|
|
177
|
+
row('dns-cname', 'dns', 'CNAME records', dnsValues(left, 'cname'), dnsValues(right, 'cname'), 'Normalised exact retained DNS answer set', dns),
|
|
178
|
+
row('dns-caa', 'dns', 'CAA records', dnsValues(left, 'caa'), dnsValues(right, 'caa'), 'Normalised exact retained DNS answer set', dns),
|
|
179
179
|
row('dnssec', 'dns', 'DNSSEC state', left.dnssec, right.dnssec, 'Separately attributed compact DNSSEC observation', dns),
|
|
180
|
-
row('ip-addresses', 'infrastructure', 'Observed IP addresses', left.relationship.ipAddresses, right.relationship.ipAddresses, '
|
|
180
|
+
row('ip-addresses', 'infrastructure', 'Observed IP addresses', left.relationship.ipAddresses, right.relationship.ipAddresses, 'Normalised exact set comparison', dns, ['Shared hosting and CDNs are common and do not prove common control.']),
|
|
181
181
|
row('tls-source', 'certificate', 'TLS collection', tls.leftSourceState, tls.rightSourceState, 'Retained compact source-state comparison', tls),
|
|
182
182
|
row('certificate', 'certificate', 'Leaf certificate fingerprint', left.relationship.certificateFingerprint, right.relationship.certificateFingerprint, 'Exact SHA-256 comparison', tls, ['Multi-domain certificates and managed hosting are common.']),
|
|
183
|
-
row('tls-issuer', 'certificate', 'TLS issuer label', left.comparisonEvidence?.tls.issuerLabel ?? null, right.comparisonEvidence?.tls.issuerLabel ?? null, '
|
|
183
|
+
row('tls-issuer', 'certificate', 'TLS issuer label', left.comparisonEvidence?.tls.issuerLabel ?? null, right.comparisonEvidence?.tls.issuerLabel ?? null, 'Normalised retained issuer-label comparison', compactTls, ['Issuer labels are certificate metadata and do not establish common ownership or control.']),
|
|
184
184
|
row('tls-spki', 'certificate', 'TLS public-key fingerprint', left.comparisonEvidence?.tls.spkiSha256 ?? null, right.comparisonEvidence?.tls.spkiSha256 ?? null, 'Exact SPKI SHA-256 comparison', compactTls, ['Public-key reuse can be an investigative lead but does not establish ownership, intent, safety, or maliciousness.']),
|
|
185
185
|
row('mail', 'mail', 'MX observed', left.hasMx, right.hasMx, 'Compact Deep DNS observation', dns),
|
|
186
186
|
row('null-mx', 'mail', 'Null MX observed', left.hasNullMx, right.hasNullMx, 'Compact Deep DNS observation', dns),
|
|
187
187
|
row('spf', 'mail', 'SPF observed', left.hasSpf, right.hasSpf, 'Compact Deep DNS observation', dns),
|
|
188
188
|
row('dmarc', 'mail', 'DMARC observed', left.hasDmarc, right.hasDmarc, 'Compact Deep DNS observation', dns),
|
|
189
189
|
row('website', 'web', 'Website activity', left.activityStatus, right.activityStatus, 'Bounded homepage observation', http),
|
|
190
|
-
row('page-title', 'identity', 'Page title', left.pageTitle, right.pageTitle, '
|
|
190
|
+
row('page-title', 'identity', 'Page title', left.pageTitle, right.pageTitle, 'Normalised bounded title equality', http),
|
|
191
191
|
row('favicon', 'identity', 'Favicon fingerprint', left.faviconHash, right.faviconHash, 'Exact SHA-256 comparison', http),
|
|
192
|
-
row('tracking', 'identity', 'Tracking identifiers', left.relationship.trackingIdentifiers, right.relationship.trackingIdentifiers, '
|
|
192
|
+
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
193
|
row('password-field', 'identity', 'Password field', left.hasPasswordField, right.hasPasswordField, 'Bounded static form observation', http),
|
|
194
194
|
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
195
|
row('official-assets', 'identity', 'Official asset reuse', left.reusesOfficialAssets, right.reusesOfficialAssets, 'Configured Brand Profile comparison', http),
|
|
196
|
-
row('technology', 'technology', 'Technology identifiers', left.comparisonEvidence?.technology.ids ?? [], right.comparisonEvidence?.technology.ids ?? [], '
|
|
196
|
+
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
197
|
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
198
|
];
|
|
199
199
|
const counts = {
|
|
@@ -216,7 +216,7 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
|
|
|
216
216
|
counts,
|
|
217
217
|
limitations: options.allowSameDomain
|
|
218
218
|
? [
|
|
219
|
-
'This compares
|
|
219
|
+
'This compares normalised evidence already present in two saved observations for the same domain and makes no new request.',
|
|
220
220
|
'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
|
|
221
221
|
'An observed difference can reflect changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
|
|
222
222
|
]
|
|
@@ -169,7 +169,7 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
|
|
|
169
169
|
limitations: [
|
|
170
170
|
'This review uses compact Bulk evidence already collected and makes no additional request.',
|
|
171
171
|
'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
|
|
172
|
-
'SMTP delivery, mailbox existence, catch-all
|
|
172
|
+
'SMTP delivery, mailbox existence, catch-all behaviour, and message acceptance were not tested.',
|
|
173
173
|
],
|
|
174
174
|
};
|
|
175
175
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { normalizeDomain } from "./case-model.js";
|
|
5
5
|
import { normalizeCaaCritical } from "./dns-record-normalization.js";
|
|
6
6
|
export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
|
|
7
|
-
export const BULK_SESSION_SCHEMA_VERSION =
|
|
8
|
-
export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, BULK_SESSION_SCHEMA_VERSION]);
|
|
7
|
+
export const BULK_SESSION_SCHEMA_VERSION = 3;
|
|
8
|
+
export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, 2, BULK_SESSION_SCHEMA_VERSION]);
|
|
9
9
|
export const MAX_BULK_SESSIONS = 10;
|
|
10
10
|
export const MAX_BULK_SESSION_ROWS = 2_000;
|
|
11
11
|
export const MAX_BULK_SESSION_STORE_BYTES = 4 * 1024 * 1024;
|
|
@@ -231,6 +231,7 @@ export function normalizeBulkSessionResult(value) {
|
|
|
231
231
|
scanDepth: scanDepth,
|
|
232
232
|
createdDate: boundedText(item.createdDate, 64) || null,
|
|
233
233
|
expiryDate: boundedText(item.expiryDate, 64) || null,
|
|
234
|
+
privacyProtected: nullableBoolean(item.privacyProtected),
|
|
234
235
|
nameservers: boundedStrings(item.nameservers, 20),
|
|
235
236
|
hasMx: nullableBoolean(item.hasMx),
|
|
236
237
|
hasNullMx: nullableBoolean(item.hasNullMx),
|
|
@@ -246,7 +247,11 @@ export function normalizeBulkSessionResult(value) {
|
|
|
246
247
|
hasPasswordField: item.hasPasswordField === true,
|
|
247
248
|
hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
|
|
248
249
|
phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
|
|
250
|
+
idnReferenceMatch: nullableBoolean(item.idnReferenceMatch),
|
|
251
|
+
pageBaselineMatch: nullableBoolean(item.pageBaselineMatch),
|
|
252
|
+
hasActiveBrandProfile: nullableBoolean(item.hasActiveBrandProfile),
|
|
249
253
|
riskModelVersion: nullableVersion(item.riskModelVersion),
|
|
254
|
+
opportunityModelVersion: nullableVersion(item.opportunityModelVersion),
|
|
250
255
|
riskFactors: normalizeRiskFactors(item.riskFactors),
|
|
251
256
|
dns: normalizeDns(item.dns),
|
|
252
257
|
dnssec: boundedText(item.dnssec, 40) || null,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
3
|
import { normalizeHttpSummary } from "./http-summary.js";
|
|
4
|
-
import { normalizeRiskModelVersion } from "./scoring.js";
|
|
4
|
+
import { normalizeOpportunityModelVersion, normalizeRiskModelVersion } from "./scoring.js";
|
|
5
5
|
import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
|
|
6
6
|
import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, hashString, isoOrNull, objectRecord, safeId, } from "./case-record-core.js";
|
|
7
7
|
function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
|
|
@@ -97,6 +97,7 @@ const DEEP_SIGNAL_FIELDS = [
|
|
|
97
97
|
'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
|
|
98
98
|
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
99
99
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
|
|
100
|
+
'pageBaselineMatch',
|
|
100
101
|
];
|
|
101
102
|
// Ordered list of the fields that make up a snapshot's *material* identity -
|
|
102
103
|
// everything except capture timestamps, snapshot id and source. `scanDepth` is
|
|
@@ -104,7 +105,7 @@ const DEEP_SIGNAL_FIELDS = [
|
|
|
104
105
|
// another. Deterministic ordering here is what makes the fingerprint stable.
|
|
105
106
|
const MATERIAL_FIELD_ORDER = [
|
|
106
107
|
'scanDepth',
|
|
107
|
-
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityScore',
|
|
108
|
+
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
|
|
108
109
|
'riskFactors', 'opportunityFactors',
|
|
109
110
|
'registrar', 'createdDate', 'expiryDate', 'nameservers',
|
|
110
111
|
'hasMx', 'hasSpf', 'hasDmarc',
|
|
@@ -112,6 +113,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
112
113
|
'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
|
|
113
114
|
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
114
115
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
|
|
116
|
+
'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
115
117
|
'mutationTypes',
|
|
116
118
|
];
|
|
117
119
|
// The canonical, comparison-safe value of a material field. Registrar casing,
|
|
@@ -203,6 +205,7 @@ function buildSnapshot(raw, options) {
|
|
|
203
205
|
confidence: evidenceString(record.confidence),
|
|
204
206
|
riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
|
|
205
207
|
riskScore: clampScore(record.riskScore),
|
|
208
|
+
opportunityModelVersion: normalizeOpportunityModelVersion(record.opportunityModelVersion),
|
|
206
209
|
opportunityScore: clampScore(record.opportunityScore),
|
|
207
210
|
riskFactors: normalizeFactors(record.riskFactors),
|
|
208
211
|
opportunityFactors: normalizeFactors(record.opportunityFactors),
|
|
@@ -232,12 +235,18 @@ function buildSnapshot(raw, options) {
|
|
|
232
235
|
hasPasswordField: boolOrNull(record.hasPasswordField),
|
|
233
236
|
hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
|
|
234
237
|
phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
|
|
238
|
+
privacyProtected: boolOrNull(record.privacyProtected),
|
|
239
|
+
idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
|
|
240
|
+
pageBaselineMatch: boolOrNull(record.pageBaselineMatch),
|
|
241
|
+
hasActiveBrandProfile: boolOrNull(record.hasActiveBrandProfile),
|
|
235
242
|
mutationTypes: normalizeMutationList(record.mutationTypes),
|
|
236
243
|
};
|
|
237
244
|
// A version without an actual risk assessment is orphaned metadata. Drop it
|
|
238
245
|
// so it cannot make otherwise-identical evidence look materially different.
|
|
239
246
|
if (fields.riskScore === null && fields.riskFactors.length === 0)
|
|
240
247
|
fields.riskModelVersion = null;
|
|
248
|
+
if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
|
|
249
|
+
fields.opportunityModelVersion = null;
|
|
241
250
|
// A fast capture never evaluates the deep signals, so any value supplied for
|
|
242
251
|
// them (e.g. a profile's default `false`) is discarded as unevaluated.
|
|
243
252
|
if (scanDepth === 'fast') {
|
|
@@ -264,6 +273,7 @@ function buildSnapshot(raw, options) {
|
|
|
264
273
|
hasPasswordField: null,
|
|
265
274
|
hasExternalFormAction: null,
|
|
266
275
|
phishingLanguageMatch: null,
|
|
276
|
+
pageBaselineMatch: null,
|
|
267
277
|
});
|
|
268
278
|
}
|
|
269
279
|
if (!hasMaterialEvidence(fields))
|
|
@@ -391,8 +401,8 @@ const COMPARE_FIELDS = [
|
|
|
391
401
|
{ field: 'confidence', label: 'Confidence', type: 'token' },
|
|
392
402
|
{ field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
|
|
393
403
|
{ field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
|
|
394
|
-
{ field: 'opportunityScore', label: 'Opportunity score', type: 'score' },
|
|
395
|
-
{ field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors' },
|
|
404
|
+
{ field: 'opportunityScore', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
|
|
405
|
+
{ field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
|
|
396
406
|
{ field: 'registrar', label: 'Registrar', type: 'registrar' },
|
|
397
407
|
{ field: 'createdDate', label: 'Creation date', type: 'date' },
|
|
398
408
|
{ field: 'expiryDate', label: 'Expiry date', type: 'date' },
|
|
@@ -428,6 +438,11 @@ function riskModelComparable(previous, current) {
|
|
|
428
438
|
const after = normalizeRiskModelVersion(current?.riskModelVersion);
|
|
429
439
|
return before !== null && before === after;
|
|
430
440
|
}
|
|
441
|
+
function opportunityModelComparable(previous, current) {
|
|
442
|
+
const before = normalizeOpportunityModelVersion(previous?.opportunityModelVersion);
|
|
443
|
+
const after = normalizeOpportunityModelVersion(current?.opportunityModelVersion);
|
|
444
|
+
return before !== null && before === after;
|
|
445
|
+
}
|
|
431
446
|
function valuesMateriallyEqual(field, previous, current) {
|
|
432
447
|
return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
|
|
433
448
|
}
|
|
@@ -448,6 +463,10 @@ export function caseEvidenceIncomparableReasons(previous, current) {
|
|
|
448
463
|
|| previous.riskFactors.length > 0 || current.riskFactors.length > 0;
|
|
449
464
|
if (hasRiskEvidence && !riskModelComparable(previous, current))
|
|
450
465
|
reasons.push('risk-model');
|
|
466
|
+
const hasOpportunityEvidence = previous.opportunityScore !== null || current.opportunityScore !== null
|
|
467
|
+
|| previous.opportunityFactors.length > 0 || current.opportunityFactors.length > 0;
|
|
468
|
+
if (hasOpportunityEvidence && !opportunityModelComparable(previous, current))
|
|
469
|
+
reasons.push('opportunity-model');
|
|
451
470
|
if (!depthComparable(previous.scanDepth, current.scanDepth)) {
|
|
452
471
|
const deepOnlyChanged = DEEP_SIGNAL_FIELDS.some((field) => !valuesMateriallyEqual(field, previous, current));
|
|
453
472
|
const comparableRiskChanged = riskModelComparable(previous, current)
|
|
@@ -623,6 +642,7 @@ export function compareCaseEvidence(previous, current) {
|
|
|
623
642
|
const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
|
|
624
643
|
const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
|
|
625
644
|
const comparableRiskModel = riskModelComparable(previous, current);
|
|
645
|
+
const comparableOpportunityModel = opportunityModelComparable(previous, current);
|
|
626
646
|
const changes = [];
|
|
627
647
|
for (const spec of COMPARE_FIELDS) {
|
|
628
648
|
if (spec.depthGate === 'both-deep' && !bothDeep)
|
|
@@ -631,6 +651,8 @@ export function compareCaseEvidence(previous, current) {
|
|
|
631
651
|
continue;
|
|
632
652
|
if (spec.modelGate === 'risk' && !comparableRiskModel)
|
|
633
653
|
continue;
|
|
654
|
+
if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
|
|
655
|
+
continue;
|
|
634
656
|
const result = compareField(spec, previous[spec.field], current[spec.field]);
|
|
635
657
|
if (result) {
|
|
636
658
|
changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
|
|
1
|
+
import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
|
|
2
2
|
import { mergeCaseActions, mergeCaseAssertions, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, } from "./case-response-model.js";
|
|
3
3
|
const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
|
|
4
4
|
const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
|
|
@@ -97,6 +97,9 @@ function extractImportPatch(raw) {
|
|
|
97
97
|
rawId: typeof record.id === 'string' ? record.id : null,
|
|
98
98
|
status: importScalar(record.status, STATUS_VALUES),
|
|
99
99
|
disposition: importScalar(record.disposition, DISPOSITION_VALUES),
|
|
100
|
+
reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
|
|
101
|
+
? normalizeReviewReasonCode(record.reviewReasonCode)
|
|
102
|
+
: undefined,
|
|
100
103
|
source: importScalar(record.source, SOURCE_VALUES),
|
|
101
104
|
evidenceHistory: normalizeEvidenceHistory(rawEvidence, { source: 'import', fallback: importFallback }),
|
|
102
105
|
evidencePins,
|
|
@@ -139,6 +142,7 @@ function caseFromPatch(patch, now) {
|
|
|
139
142
|
domain: patch.domain,
|
|
140
143
|
status: patch.status ?? DEFAULT_STATUS,
|
|
141
144
|
disposition: patch.disposition ?? DEFAULT_DISPOSITION,
|
|
145
|
+
reviewReasonCode: patch.reviewReasonCode ?? null,
|
|
142
146
|
tags: patch.tags,
|
|
143
147
|
notes: patch.notes,
|
|
144
148
|
source: patch.source ?? DEFAULT_SOURCE,
|
|
@@ -171,6 +175,7 @@ function applyImportPatch(local, patch) {
|
|
|
171
175
|
...local,
|
|
172
176
|
status: patch.status !== undefined && importNewer ? patch.status : local.status,
|
|
173
177
|
disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
|
|
178
|
+
reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
|
|
174
179
|
source: patch.source !== undefined && importNewer ? patch.source : local.source,
|
|
175
180
|
evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
|
|
176
181
|
evidencePins,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
export const
|
|
1
|
+
import { ANALYST_REVIEW_REASONS } from "../../../../lib/analyst-taxonomy.mjs";
|
|
2
|
+
export const CASE_SCHEMA_VERSION = 10;
|
|
3
|
+
export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, 9, CASE_SCHEMA_VERSION];
|
|
3
4
|
export const MAX_CASES = 500;
|
|
4
5
|
export const MAX_NOTES_PER_CASE = 50;
|
|
5
6
|
export const MAX_NOTE_LENGTH = 2000;
|
|
@@ -31,6 +32,7 @@ export const CASE_DISPOSITIONS = [
|
|
|
31
32
|
{ value: 'expected', label: 'Expected' },
|
|
32
33
|
{ value: 'closed_no_action', label: 'Closed without action' },
|
|
33
34
|
];
|
|
35
|
+
export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
|
|
34
36
|
export const CASE_SOURCES = [
|
|
35
37
|
{ value: 'lookup', label: 'Lookup' },
|
|
36
38
|
{ value: 'bulk', label: 'Bulk' },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
|
|
3
|
+
import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
|
|
4
4
|
// Forward-version policy (two distinct guarantees):
|
|
5
5
|
// - A locally-stored envelope that declares a version greater than this is
|
|
6
6
|
// still read best-effort on load (known fields kept, unknown dropped), but
|
|
@@ -26,6 +26,7 @@ export const EVIDENCE_SOURCE_RANK = {
|
|
|
26
26
|
};
|
|
27
27
|
const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
|
|
28
28
|
const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
|
|
29
|
+
const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
|
|
29
30
|
const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
|
|
30
31
|
const STATUS_LABELS = Object.fromEntries(CASE_STATUSES.map((item) => [item.value, item.label]));
|
|
31
32
|
const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
|
|
@@ -135,6 +136,9 @@ export function normalizeStatus(value) {
|
|
|
135
136
|
export function normalizeDisposition(value) {
|
|
136
137
|
return typeof value === 'string' && DISPOSITION_VALUES.has(value) ? value : DEFAULT_DISPOSITION;
|
|
137
138
|
}
|
|
139
|
+
export function normalizeReviewReasonCode(value) {
|
|
140
|
+
return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
|
|
141
|
+
}
|
|
138
142
|
export function normalizeSource(value) {
|
|
139
143
|
return typeof value === 'string' && SOURCE_VALUES.has(value) ? value : DEFAULT_SOURCE;
|
|
140
144
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
3
|
import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
|
|
4
4
|
import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
|
|
5
|
-
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
|
|
5
|
+
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
|
|
6
6
|
import { normalizeEvidenceHistory, } from "./case-evidence-model.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Case normalization
|
|
@@ -49,6 +49,7 @@ export function normalizeCase(raw, existing, nowIso) {
|
|
|
49
49
|
domain,
|
|
50
50
|
status: normalizeStatus(record.status),
|
|
51
51
|
disposition: normalizeDisposition(record.disposition),
|
|
52
|
+
reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
|
|
52
53
|
tags: normalizeTags(record.tags),
|
|
53
54
|
notes: normalizeNotes(record.notes, now),
|
|
54
55
|
source: normalizeSource(record.source),
|
|
@@ -86,6 +87,7 @@ export function createCase(input, nowIso) {
|
|
|
86
87
|
domain,
|
|
87
88
|
status: normalizeStatus(input.status),
|
|
88
89
|
disposition: normalizeDisposition(input.disposition),
|
|
90
|
+
reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
|
|
89
91
|
tags: normalizeTags(input.tags),
|
|
90
92
|
notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
|
|
91
93
|
source,
|
|
@@ -206,6 +208,9 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
206
208
|
...current,
|
|
207
209
|
status: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
|
|
208
210
|
disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
|
|
211
|
+
reviewReasonCode: patch.reviewReasonCode !== undefined
|
|
212
|
+
? normalizeReviewReasonCode(patch.reviewReasonCode)
|
|
213
|
+
: current.reviewReasonCode ?? null,
|
|
209
214
|
tags: patch.tags !== undefined ? normalizeTags(patch.tags) : current.tags,
|
|
210
215
|
source,
|
|
211
216
|
evidenceHistory,
|