@slicedearth/whoisleuth-cli 2.3.0 → 2.4.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/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
package/package.json
CHANGED
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
"bugs": {
|
|
34
34
|
"url": "https://github.com/slicedearth/whoisleuth/issues"
|
|
35
35
|
},
|
|
36
|
-
"version": "2.
|
|
36
|
+
"version": "2.4.0",
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@peculiar/x509": "2.0.0",
|
|
39
|
+
"fflate": "0.8.3",
|
|
39
40
|
"maxmind": "5.0.7",
|
|
40
41
|
"parse5": "8.0.1",
|
|
41
42
|
"reflect-metadata": "0.2.2",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { MAX_SELECTED_FILES, SELECTED_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
|
|
2
|
+
import { array, enumeration, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
|
|
3
|
+
import { readRetainedFileReference } from "../evidence/retained-file.mjs";
|
|
4
|
+
import { readImageDerivation } from "../evidence/image-regions.mjs";
|
|
5
|
+
/** Case provenance is independent of deduplicated immutable content. */
|
|
6
|
+
export function readCaseAttachment(value) {
|
|
7
|
+
const item = exactOptional(value, ['id', 'fileName', 'mediaType', 'source', 'observedAt', 'retainedAt', 'digestSha256', 'byteLength'], ['derivation'], 'Case attachment');
|
|
8
|
+
const id = text(item.id, 'Attachment ID', 128);
|
|
9
|
+
const fileName = text(item.fileName, 'Attachment filename', 240);
|
|
10
|
+
if (/[/\\]/u.test(fileName) || fileName === '.' || fileName === '..')
|
|
11
|
+
throw new TypeError('Attachment filenames cannot contain a path.');
|
|
12
|
+
const mediaType = enumeration(item.mediaType, SELECTED_FILE_MEDIA_TYPES, 'Attachment media type');
|
|
13
|
+
const source = item.source === null ? null : text(item.source, 'Attachment source', 240);
|
|
14
|
+
const derivation = Object.hasOwn(item, 'derivation') ? readImageDerivation(item.derivation) : undefined;
|
|
15
|
+
if (derivation && (mediaType !== 'image/png' || derivation.sourceAttachmentId === id))
|
|
16
|
+
throw new TypeError('A derived PNG must identify a different source attachment.');
|
|
17
|
+
iso(item.observedAt, 'Attachment observation time', true);
|
|
18
|
+
iso(item.retainedAt, 'Attachment retention time');
|
|
19
|
+
return Object.freeze({ id, fileName, mediaType, source, observedAt: item.observedAt, retainedAt: item.retainedAt,
|
|
20
|
+
...readRetainedFileReference({ digestSha256: item.digestSha256, byteLength: item.byteLength }),
|
|
21
|
+
...(derivation === undefined ? {} : { derivation }) });
|
|
22
|
+
}
|
|
23
|
+
export function readCaseAttachments(value) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
const attachments = array(value, 'Case attachments', MAX_SELECTED_FILES).map(readCaseAttachment);
|
|
27
|
+
if (new Set(attachments.map(item => item.id)).size !== attachments.length)
|
|
28
|
+
throw new TypeError('Case attachment reference IDs must be unique.');
|
|
29
|
+
const lengths = new Map();
|
|
30
|
+
for (const attachment of attachments) {
|
|
31
|
+
const previous = lengths.get(attachment.digestSha256);
|
|
32
|
+
if (previous !== undefined && previous !== attachment.byteLength)
|
|
33
|
+
throw new TypeError('One retained file digest has conflicting byte lengths.');
|
|
34
|
+
lengths.set(attachment.digestSha256, attachment.byteLength);
|
|
35
|
+
}
|
|
36
|
+
return attachments;
|
|
37
|
+
}
|
|
38
|
+
export function mergeCaseAttachments(local, incoming) {
|
|
39
|
+
if (local === undefined && incoming === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const result = new Map((readCaseAttachments(local) ?? []).map(item => [item.id, item]));
|
|
42
|
+
for (const item of readCaseAttachments(incoming) ?? []) {
|
|
43
|
+
const existing = result.get(item.id);
|
|
44
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(item))
|
|
45
|
+
throw new TypeError('An attachment reference conflicts with retained provenance. No file or reference was overwritten.');
|
|
46
|
+
result.set(item.id, item);
|
|
47
|
+
}
|
|
48
|
+
return readCaseAttachments([...result.values()]);
|
|
49
|
+
}
|
|
50
|
+
export function caseAttachmentReferences(cases) {
|
|
51
|
+
return cases.flatMap(record => (record.attachments ?? []).map(({ digestSha256, byteLength }) => ({ digestSha256, byteLength })));
|
|
52
|
+
}
|
|
53
|
+
export function addCaseAttachments(record, attachments, updatedAt) {
|
|
54
|
+
iso(updatedAt, 'Case update time');
|
|
55
|
+
return { ...record, attachments: mergeCaseAttachments(record.attachments, attachments) ?? [], updatedAt };
|
|
56
|
+
}
|
|
57
|
+
/** A new local derivative must still refer to the observation the analyst reviewed. */
|
|
58
|
+
export class CaseAttachmentSourceChangedError extends TypeError {
|
|
59
|
+
constructor() {
|
|
60
|
+
super('The source image or its observation changed or was removed. Review the current source before retaining a derivative.');
|
|
61
|
+
this.name = 'CaseAttachmentSourceChangedError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function assertDerivedCaseAttachmentSource(record, input) {
|
|
65
|
+
const attachment = readCaseAttachment(input), derivation = attachment.derivation;
|
|
66
|
+
if (!derivation)
|
|
67
|
+
return;
|
|
68
|
+
const source = record.attachments?.find(candidate => candidate.id === derivation.sourceAttachmentId);
|
|
69
|
+
if (!source || source.mediaType !== 'image/png'
|
|
70
|
+
|| source.digestSha256 !== derivation.source.digestSha256 || source.byteLength !== derivation.source.byteLength
|
|
71
|
+
|| source.source !== attachment.source || source.observedAt !== attachment.observedAt) {
|
|
72
|
+
throw new CaseAttachmentSourceChangedError();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function removeCaseAttachment(record, id, expected, updatedAt) {
|
|
76
|
+
const current = record.attachments?.find(item => item.id === id);
|
|
77
|
+
if (!current)
|
|
78
|
+
return record;
|
|
79
|
+
if (JSON.stringify(readCaseAttachment(current)) !== JSON.stringify(readCaseAttachment(expected)))
|
|
80
|
+
throw new TypeError('The attachment changed in another tab. Refresh before removing it.');
|
|
81
|
+
iso(updatedAt, 'Case update time');
|
|
82
|
+
return { ...record, attachments: record.attachments.filter(item => item.id !== id), updatedAt };
|
|
83
|
+
}
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import { normalizeHttpSummary } from "./http-summary.mjs";
|
|
4
4
|
import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
|
|
5
5
|
import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
|
|
6
|
+
import { latestObservationCohort } from "../evidence/latest-observations.mjs";
|
|
7
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
8
|
+
import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
9
|
+
import { validWebObservationMode } from "../evidence/lookup-target.mjs";
|
|
6
10
|
import { 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, } from "./case-record-contracts.mjs";
|
|
7
11
|
import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
|
|
8
12
|
function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
|
|
@@ -24,7 +28,10 @@ function boolOrNull(value) {
|
|
|
24
28
|
// are deduplicated and the result is sorted deterministically (largest
|
|
25
29
|
// contribution first, then label) so input order alone can never change a
|
|
26
30
|
// snapshot's fingerprint and two equal factor sets in different order collapse.
|
|
27
|
-
function
|
|
31
|
+
function compareFactors(a, b) {
|
|
32
|
+
return b.points - a.points || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
|
|
33
|
+
}
|
|
34
|
+
function normalizeFactors(value, legacyOrder = false) {
|
|
28
35
|
if (!Array.isArray(value))
|
|
29
36
|
return [];
|
|
30
37
|
const seen = new Set();
|
|
@@ -45,7 +52,7 @@ function normalizeFactors(value) {
|
|
|
45
52
|
seen.add(key);
|
|
46
53
|
out.push({ label, points });
|
|
47
54
|
}
|
|
48
|
-
out.sort((a, b) => b.points - a.points || a.label.localeCompare(b.label));
|
|
55
|
+
out.sort(legacyOrder ? (a, b) => b.points - a.points || a.label.localeCompare(b.label) : compareFactors);
|
|
49
56
|
return out.slice(0, MAX_EVIDENCE_FACTORS);
|
|
50
57
|
}
|
|
51
58
|
// Case-insensitive, terminal-dot-stripped, deduplicated and sorted so a
|
|
@@ -112,6 +119,8 @@ const DEEP_SIGNAL_FIELDS = [
|
|
|
112
119
|
// another. Deterministic ordering here is what makes the fingerprint stable.
|
|
113
120
|
const MATERIAL_FIELD_ORDER = [
|
|
114
121
|
'inputHostname',
|
|
122
|
+
'observationHostname',
|
|
123
|
+
'webObservationMode',
|
|
115
124
|
'scanDepth',
|
|
116
125
|
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
|
|
117
126
|
'riskFactors', 'opportunityFactors',
|
|
@@ -128,7 +137,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
128
137
|
// The canonical, comparison-safe value of a material field. Registrar casing,
|
|
129
138
|
// nameserver order, and sub-day timestamps are collapsed so they can never
|
|
130
139
|
// count as a "change"; a non-conclusive availability contributes nothing.
|
|
131
|
-
function materialValue(field, snapshot) {
|
|
140
|
+
function materialValue(field, snapshot, legacyOrder = false) {
|
|
132
141
|
switch (field) {
|
|
133
142
|
case 'availability':
|
|
134
143
|
return typeof snapshot.availability === 'string' && CONCLUSIVE_AVAILABILITY.has(snapshot.availability)
|
|
@@ -147,9 +156,9 @@ function materialValue(field, snapshot) {
|
|
|
147
156
|
case 'mutationTypes':
|
|
148
157
|
return snapshot.mutationTypes;
|
|
149
158
|
case 'riskFactors':
|
|
150
|
-
return snapshot.riskFactors.map((factor) => [factor.label, factor.points]);
|
|
159
|
+
return (legacyOrder ? snapshot.riskFactors : [...snapshot.riskFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
|
|
151
160
|
case 'opportunityFactors':
|
|
152
|
-
return snapshot.opportunityFactors.map((factor) => [factor.label, factor.points]);
|
|
161
|
+
return (legacyOrder ? snapshot.opportunityFactors : [...snapshot.opportunityFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
|
|
153
162
|
default:
|
|
154
163
|
return snapshot[field] ?? null;
|
|
155
164
|
}
|
|
@@ -165,7 +174,7 @@ function isEmptyMaterial(value) {
|
|
|
165
174
|
}
|
|
166
175
|
// Fields that describe the capture rather than assert evidence, so they never
|
|
167
176
|
// on their own keep an otherwise-empty snapshot alive.
|
|
168
|
-
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'scanDepth', 'confidence']);
|
|
177
|
+
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'confidence']);
|
|
169
178
|
// A snapshot with no material evidence (only timestamps/source/depth, or only a
|
|
170
179
|
// bare confidence/unknown-availability) is dropped rather than added to a
|
|
171
180
|
// timeline.
|
|
@@ -179,14 +188,13 @@ function hasMaterialEvidence(snapshot) {
|
|
|
179
188
|
return false;
|
|
180
189
|
}
|
|
181
190
|
// Deterministic string form of the material identity, keys in fixed order.
|
|
182
|
-
function canonicalMaterialString(snapshot) {
|
|
191
|
+
function canonicalMaterialString(snapshot, legacyOrder = false) {
|
|
183
192
|
const canonical = {};
|
|
184
193
|
for (const field of MATERIAL_FIELD_ORDER) {
|
|
185
|
-
const value = materialValue(field, snapshot);
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
if (field === 'inputHostname' && value === null)
|
|
194
|
+
const value = materialValue(field, snapshot, legacyOrder);
|
|
195
|
+
// Optional collection context does not alter historical fingerprints when
|
|
196
|
+
// absent; recorded context separates otherwise-identical captures.
|
|
197
|
+
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode') && value === null)
|
|
190
198
|
continue;
|
|
191
199
|
canonical[field] = value;
|
|
192
200
|
}
|
|
@@ -213,14 +221,32 @@ function buildSnapshot(raw, options) {
|
|
|
213
221
|
if (!raw || typeof raw !== 'object')
|
|
214
222
|
return null;
|
|
215
223
|
const record = objectRecord(raw);
|
|
224
|
+
if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
|
|
225
|
+
return null;
|
|
226
|
+
const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
227
|
+
if (historicalSchema && record.factorOrder !== undefined)
|
|
228
|
+
return null;
|
|
229
|
+
// Fieldless retained snapshots keep their original identity algorithm. New
|
|
230
|
+
// captures declare code-unit ordering; the declaration survives every save.
|
|
231
|
+
const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
|
|
216
232
|
const scanDepth = normalizeScanDepth(record.scanDepth);
|
|
217
233
|
const httpSummary = normalizeHttpSummary(record);
|
|
218
234
|
const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
|
|
219
235
|
const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
|
|
236
|
+
const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
237
|
+
const observationHostname = acceptsObservationHostname
|
|
238
|
+
? normalizeEvidenceHostnameForCase(record.observationHostname, options.caseDomain)
|
|
239
|
+
: null;
|
|
240
|
+
if (acceptsObservationHostname && record.observationHostname != null && !observationHostname)
|
|
241
|
+
return null;
|
|
242
|
+
if (acceptsObservationHostname && !validWebObservationMode(record.webObservationMode))
|
|
243
|
+
return null;
|
|
220
244
|
const fields = {
|
|
221
245
|
inputHostname: acceptsInputHostname
|
|
222
246
|
? normalizeEvidenceHostnameForCase(record.inputHostname, options.caseDomain)
|
|
223
247
|
: null,
|
|
248
|
+
...(observationHostname ? { observationHostname } : {}),
|
|
249
|
+
...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
|
|
224
250
|
scanDepth,
|
|
225
251
|
availability: evidenceString(record.availability),
|
|
226
252
|
confidence: evidenceString(record.confidence),
|
|
@@ -228,8 +254,8 @@ function buildSnapshot(raw, options) {
|
|
|
228
254
|
riskScore: clampScore(record.riskScore),
|
|
229
255
|
opportunityModelVersion: normalizeOpportunityModelVersion(record.opportunityModelVersion),
|
|
230
256
|
opportunityScore: clampScore(record.opportunityScore),
|
|
231
|
-
riskFactors: normalizeFactors(record.riskFactors),
|
|
232
|
-
opportunityFactors: normalizeFactors(record.opportunityFactors),
|
|
257
|
+
riskFactors: normalizeFactors(record.riskFactors, legacyOrder),
|
|
258
|
+
opportunityFactors: normalizeFactors(record.opportunityFactors, legacyOrder),
|
|
233
259
|
registrar: evidenceString(record.registrar),
|
|
234
260
|
createdDate: lifecycleTimestamp(record.createdDate, options.sourceVersion),
|
|
235
261
|
expiryDate: lifecycleTimestamp(record.expiryDate, options.sourceVersion),
|
|
@@ -317,10 +343,11 @@ function buildSnapshot(raw, options) {
|
|
|
317
343
|
? options.source
|
|
318
344
|
: DEFAULT_EVIDENCE_SOURCE;
|
|
319
345
|
const material = canonicalMaterialString(fields);
|
|
320
|
-
const fingerprint = hashString(material);
|
|
346
|
+
const fingerprint = hashString(legacyOrder ? canonicalMaterialString(fields, true) : material);
|
|
321
347
|
const snapshot = {
|
|
322
348
|
id: `ev-${fingerprint}`,
|
|
323
349
|
fingerprint,
|
|
350
|
+
...(!legacyOrder ? { factorOrder: 'code-unit-v1' } : {}),
|
|
324
351
|
firstCapturedAt,
|
|
325
352
|
capturedAt,
|
|
326
353
|
source,
|
|
@@ -356,7 +383,10 @@ function mergeDuplicateSnapshots(kept, incoming) {
|
|
|
356
383
|
? incoming.capturedAt
|
|
357
384
|
: kept.capturedAt;
|
|
358
385
|
const source = chooseSource(kept, incoming);
|
|
359
|
-
|
|
386
|
+
// Preserve an existing historical identifier when a new capture contains
|
|
387
|
+
// the same evidence under the newer ordering declaration.
|
|
388
|
+
const base = kept.factorOrder && !incoming.factorOrder ? incoming : kept;
|
|
389
|
+
return { ...base, firstCapturedAt, capturedAt, source };
|
|
360
390
|
}
|
|
361
391
|
function compareSnapshotChrono(a, b) {
|
|
362
392
|
return (Date.parse(a.capturedAt) - Date.parse(b.capturedAt) ||
|
|
@@ -403,15 +433,65 @@ function assignUniqueSnapshotIds(snapshots) {
|
|
|
403
433
|
return snapshot.id === id ? snapshot : { ...snapshot, id };
|
|
404
434
|
});
|
|
405
435
|
}
|
|
406
|
-
/**
|
|
407
|
-
|
|
408
|
-
* without knowing the history is a bounded, deduplicated timeline.
|
|
409
|
-
* @param {{ evidenceHistory?: CaseEvidenceSnapshot[] } | null | undefined} record
|
|
410
|
-
* @returns {CaseEvidenceSnapshot | null}
|
|
411
|
-
*/
|
|
412
|
-
export function latestCaseEvidence(record) {
|
|
436
|
+
/** Select from an admitted history without assigning order to equal or unknown capture times. */
|
|
437
|
+
export function currentCaseEvidence(record) {
|
|
413
438
|
const history = record && Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
|
|
414
|
-
|
|
439
|
+
const cohort = latestObservationCohort(history, (snapshot) => snapshot.capturedAt);
|
|
440
|
+
const snapshot = cohort.undated.length === 0 && cohort.latest.length === 1 ? cohort.latest[0] : null;
|
|
441
|
+
return {
|
|
442
|
+
snapshot,
|
|
443
|
+
candidates: [...cohort.latest, ...cohort.undated],
|
|
444
|
+
capturedAt: cohort.observedAt,
|
|
445
|
+
limitation: cohort.undated.length
|
|
446
|
+
? 'Retained snapshots include an unknown capture time. No single latest assessment is selected.'
|
|
447
|
+
: cohort.latest.length > 1
|
|
448
|
+
? 'Distinct snapshots share the latest capture time. No single latest assessment is selected.'
|
|
449
|
+
: null,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/** The unique latest retained snapshot, or null for empty or temporally ambiguous evidence. */
|
|
453
|
+
export function latestCaseEvidence(record) {
|
|
454
|
+
return currentCaseEvidence(record).snapshot;
|
|
455
|
+
}
|
|
456
|
+
/** Chronological presentation and comparison admission shared by the browser and Case report. */
|
|
457
|
+
export function caseEvidenceTimeline(history) {
|
|
458
|
+
const dated = (history ?? []).map((snapshot) => ({
|
|
459
|
+
snapshot,
|
|
460
|
+
at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
|
|
461
|
+
}));
|
|
462
|
+
const counts = new Map();
|
|
463
|
+
for (const { at } of dated)
|
|
464
|
+
if (at)
|
|
465
|
+
counts.set(at, (counts.get(at) ?? 0) + 1);
|
|
466
|
+
const hasUndated = dated.some(({ at }) => at === null);
|
|
467
|
+
dated.sort((a, b) => (a.at === b.at ? 0 : a.at === null ? 1 : b.at === null ? -1 : Date.parse(a.at) - Date.parse(b.at))
|
|
468
|
+
|| (a.snapshot.id < b.snapshot.id ? -1 : a.snapshot.id > b.snapshot.id ? 1 : 0));
|
|
469
|
+
return dated.map(({ snapshot, at }, index) => {
|
|
470
|
+
const previous = dated[index - 1];
|
|
471
|
+
const isBaseline = index === 0 && !hasUndated && at !== null && counts.get(at) === 1;
|
|
472
|
+
const orderingLimitation = !at || (previous && !previous.at)
|
|
473
|
+
? 'Capture order is unknown; no temporal change is inferred.'
|
|
474
|
+
: counts.get(at) !== 1 || (previous && counts.get(previous.at) !== 1)
|
|
475
|
+
? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
|
|
476
|
+
: null;
|
|
477
|
+
const comparable = previous && !orderingLimitation;
|
|
478
|
+
const changes = comparable ? compareCaseEvidence(previous.snapshot, snapshot) : [];
|
|
479
|
+
const incomparableReasons = orderingLimitation
|
|
480
|
+
? ['other']
|
|
481
|
+
: comparable ? caseEvidenceIncomparableReasons(previous.snapshot, snapshot) : [];
|
|
482
|
+
if (comparable && !changes.length && !incomparableReasons.length
|
|
483
|
+
&& canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
|
|
484
|
+
incomparableReasons.push('other');
|
|
485
|
+
return {
|
|
486
|
+
snapshot,
|
|
487
|
+
isBaseline,
|
|
488
|
+
hasRepeatedObservation: snapshot.firstCapturedAt !== snapshot.capturedAt,
|
|
489
|
+
changes: changes.length ? changes : null,
|
|
490
|
+
hasIncomparableChange: incomparableReasons.length > 0,
|
|
491
|
+
incomparableReasons,
|
|
492
|
+
orderingLimitation,
|
|
493
|
+
};
|
|
494
|
+
});
|
|
415
495
|
}
|
|
416
496
|
/**
|
|
417
497
|
* Returns the exact hostname from the latest retained observation when it is
|
|
@@ -433,38 +513,38 @@ export function caseLookupTarget(record) {
|
|
|
433
513
|
// a mode change is never reported.
|
|
434
514
|
// (absent) - always comparable (data available in every capture).
|
|
435
515
|
const COMPARE_FIELDS = [
|
|
436
|
-
{ field: 'availability', label: 'Availability', type: 'availability' },
|
|
437
|
-
{ field: 'confidence', label: 'Confidence', type: 'token' },
|
|
438
|
-
{ field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
|
|
439
|
-
{ field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
|
|
440
|
-
{ field: 'opportunityScore', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
|
|
441
|
-
{ field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
|
|
442
|
-
{ field: 'registrar', label: 'Registrar', type: 'registrar' },
|
|
443
|
-
{ field: 'createdDate', label: 'Creation date', type: 'date' },
|
|
444
|
-
{ field: 'expiryDate', label: 'Expiry date', type: 'date' },
|
|
445
|
-
{ field: 'nameservers', label: 'Nameservers', type: 'set', emptyGuard: true },
|
|
446
|
-
{ field: 'hasMx', label: 'MX', type: 'bool', depthGate: 'both-deep' },
|
|
447
|
-
{ field: 'hasSpf', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
|
|
448
|
-
{ field: 'hasDmarc', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
|
|
449
|
-
{ field: 'activityStatus', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
|
|
450
|
-
{ field: 'websiteProbeDetail', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
|
|
451
|
-
{ field: 'pageTitle', label: 'Page title', type: 'text', depthGate: 'both-deep' },
|
|
452
|
-
{ field: 'httpEvidenceStatus', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
|
|
453
|
-
{ field: 'httpFinalOrigin', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
|
|
454
|
-
{ field: 'httpResponseStatus', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
|
|
455
|
-
{ field: 'httpTransportSecurity', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
|
|
456
|
-
{ field: 'httpRedirectCount', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
|
|
457
|
-
{ field: 'httpCrossOriginRedirect', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
|
|
458
|
-
{ field: 'httpHttpsDowngrade', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
|
|
459
|
-
{ field: 'httpContentType', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
|
|
460
|
-
{ field: 'httpSecurityHeaders', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
|
|
461
|
-
{ field: 'faviconMatch', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
|
|
462
|
-
{ field: 'faviconNearMatch', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
|
|
463
|
-
{ field: 'reusesOfficialAssets', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
|
|
464
|
-
{ field: 'hasPasswordField', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
|
|
465
|
-
{ field: 'hasExternalFormAction', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
|
|
466
|
-
{ field: 'phishingLanguageMatch', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
|
|
467
|
-
{ field: 'mutationTypes', label: 'Mutation types', type: 'set' },
|
|
516
|
+
{ field: 'availability', scope: 'registration', label: 'Availability', type: 'availability' },
|
|
517
|
+
{ field: 'confidence', scope: 'registration', label: 'Confidence', type: 'token' },
|
|
518
|
+
{ field: 'riskScore', scope: 'web', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
|
|
519
|
+
{ field: 'riskFactors', scope: 'web', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
|
|
520
|
+
{ field: 'opportunityScore', scope: 'web', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
|
|
521
|
+
{ field: 'opportunityFactors', scope: 'web', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
|
|
522
|
+
{ field: 'registrar', scope: 'registration', label: 'Registrar', type: 'registrar' },
|
|
523
|
+
{ field: 'createdDate', scope: 'registration', label: 'Creation date', type: 'date' },
|
|
524
|
+
{ field: 'expiryDate', scope: 'registration', label: 'Expiry date', type: 'date' },
|
|
525
|
+
{ field: 'nameservers', scope: 'registration', label: 'Nameservers', type: 'set', emptyGuard: true },
|
|
526
|
+
{ field: 'hasMx', scope: 'hostname', label: 'MX', type: 'bool', depthGate: 'both-deep' },
|
|
527
|
+
{ field: 'hasSpf', scope: 'hostname', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
|
|
528
|
+
{ field: 'hasDmarc', scope: 'hostname', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
|
|
529
|
+
{ field: 'activityStatus', scope: 'web', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
|
|
530
|
+
{ field: 'websiteProbeDetail', scope: 'web', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
|
|
531
|
+
{ field: 'pageTitle', scope: 'web', label: 'Page title', type: 'text', depthGate: 'both-deep' },
|
|
532
|
+
{ field: 'httpEvidenceStatus', scope: 'web', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
|
|
533
|
+
{ field: 'httpFinalOrigin', scope: 'web', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
|
|
534
|
+
{ field: 'httpResponseStatus', scope: 'web', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
|
|
535
|
+
{ field: 'httpTransportSecurity', scope: 'web', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
|
|
536
|
+
{ field: 'httpRedirectCount', scope: 'web', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
|
|
537
|
+
{ field: 'httpCrossOriginRedirect', scope: 'web', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
|
|
538
|
+
{ field: 'httpHttpsDowngrade', scope: 'web', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
|
|
539
|
+
{ field: 'httpContentType', scope: 'web', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
|
|
540
|
+
{ field: 'httpSecurityHeaders', scope: 'web', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
|
|
541
|
+
{ field: 'faviconMatch', scope: 'web', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
|
|
542
|
+
{ field: 'faviconNearMatch', scope: 'web', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
|
|
543
|
+
{ field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
|
|
544
|
+
{ field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
|
|
545
|
+
{ field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
|
|
546
|
+
{ field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
|
|
547
|
+
{ field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
|
|
468
548
|
];
|
|
469
549
|
function depthComparable(a, b) {
|
|
470
550
|
return a === b && (a === 'fast' || a === 'deep');
|
|
@@ -495,7 +575,7 @@ export function caseEvidenceIncomparableReasons(previous, current) {
|
|
|
495
575
|
if (!previous || !current || previous.fingerprint === current.fingerprint)
|
|
496
576
|
return [];
|
|
497
577
|
const reasons = [];
|
|
498
|
-
if (previous
|
|
578
|
+
if (!sameObservationContext(previous, current))
|
|
499
579
|
reasons.push('observation-context');
|
|
500
580
|
const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
|
|
501
581
|
|| previous.riskFactors.length > 0 || current.riskFactors.length > 0;
|
|
@@ -523,6 +603,16 @@ function isPresent(value) {
|
|
|
523
603
|
return value.length > 0;
|
|
524
604
|
return true;
|
|
525
605
|
}
|
|
606
|
+
function sameObservationHostname(previous, current) {
|
|
607
|
+
return previous.inputHostname === current.inputHostname
|
|
608
|
+
&& (previous.observationHostname ?? null) === (current.observationHostname ?? null);
|
|
609
|
+
}
|
|
610
|
+
function sameObservationContext(previous, current) {
|
|
611
|
+
// Compact Case snapshots deliberately omit URL paths and queries. A selected
|
|
612
|
+
// page cannot therefore establish a comparable website target here.
|
|
613
|
+
return sameObservationHostname(previous, current)
|
|
614
|
+
&& previous.webObservationMode !== 'selected_url' && current.webObservationMode !== 'selected_url';
|
|
615
|
+
}
|
|
526
616
|
function setsEqual(a, b) {
|
|
527
617
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
528
618
|
}
|
|
@@ -648,11 +738,9 @@ function compareField(spec, before, after) {
|
|
|
648
738
|
return { before: b, after: a, tone: 'neutral' };
|
|
649
739
|
}
|
|
650
740
|
case 'factors': {
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const b = Array.isArray(before) ? before : [];
|
|
655
|
-
const a = Array.isArray(after) ? after : [];
|
|
741
|
+
// Historical identity ordering is not a change in the factor set.
|
|
742
|
+
const b = normalizeFactors(before);
|
|
743
|
+
const a = normalizeFactors(after);
|
|
656
744
|
if (setsEqual(b, a))
|
|
657
745
|
return null;
|
|
658
746
|
return { before: b, after: a, tone: 'neutral' };
|
|
@@ -681,8 +769,14 @@ export function compareCaseEvidence(previous, current) {
|
|
|
681
769
|
const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
|
|
682
770
|
const comparableRiskModel = riskModelComparable(previous, current);
|
|
683
771
|
const comparableOpportunityModel = opportunityModelComparable(previous, current);
|
|
772
|
+
const sameHostname = sameObservationHostname(previous, current);
|
|
773
|
+
const sameWebContext = sameObservationContext(previous, current);
|
|
684
774
|
const changes = [];
|
|
685
775
|
for (const spec of COMPARE_FIELDS) {
|
|
776
|
+
if (spec.scope === 'hostname' && !sameHostname)
|
|
777
|
+
continue;
|
|
778
|
+
if (spec.scope === 'web' && !sameWebContext)
|
|
779
|
+
continue;
|
|
686
780
|
if (spec.depthGate === 'both-deep' && !bothDeep)
|
|
687
781
|
continue;
|
|
688
782
|
if (spec.depthGate === 'comparable' && !comparableDepth)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../../lib/bounded-json.mjs";
|
|
2
|
+
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
3
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
+
import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
5
|
+
import { normalizeCaseStore } from "./case-migration-model.mjs";
|
|
6
|
+
import { serializeCaseStore } from "./case-storage-model.mjs";
|
|
7
|
+
/** Historical wire shape remains independent of current in-memory defaults. */
|
|
8
|
+
export function canonicalCaseExportProjection(cases, caseVersion) {
|
|
9
|
+
if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
|
|
10
|
+
return [...cases];
|
|
11
|
+
return cases.map((value) => {
|
|
12
|
+
if ([...value.evidencePins, ...value.sightings].some((item) => item.observedAt === null)) {
|
|
13
|
+
throw new TypeError('The published Case 15 format requires a known observation time on each retained pin and sighting.');
|
|
14
|
+
}
|
|
15
|
+
const { title: _title, ...published } = value;
|
|
16
|
+
return {
|
|
17
|
+
...published,
|
|
18
|
+
sightings: [...value.sightings].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
|
|
19
|
+
|| (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Editing never silently repairs, drops or truncates an imported record. */
|
|
24
|
+
export function readEditableCaseExport(input) {
|
|
25
|
+
const root = parseBoundedJsonObject(input, {
|
|
26
|
+
label: 'Case file', maximumBytes: MAX_EDITABLE_CASE_INPUT_BYTES,
|
|
27
|
+
limits: boundedJsonLimitsForBytes(MAX_CASE_STORE_BYTES),
|
|
28
|
+
});
|
|
29
|
+
if (Object.keys(root).some(key => !['version', 'exportedAt', 'cases'].includes(key))) {
|
|
30
|
+
throw new TypeError('Case editing requires an ordinary Case export, not a response packet or an envelope with additional fields.');
|
|
31
|
+
}
|
|
32
|
+
if (!CLI_CASE_PACK_INPUT_CASE_VERSIONS.some(version => version === root.version) || !Array.isArray(root.cases)) {
|
|
33
|
+
throw new TypeError(`Case editing reads exact schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(', ')} and writes schema ${CASE_SCHEMA_VERSION}. Re-export other supported historical formats through the ordinary import workflow first.`);
|
|
34
|
+
}
|
|
35
|
+
if (Object.hasOwn(root, 'exportedAt') && !normalizeExplicitIsoTimestamp(root.exportedAt)) {
|
|
36
|
+
throw new TypeError('The Case export time is invalid.');
|
|
37
|
+
}
|
|
38
|
+
const cases = normalizeCaseStore(root).cases;
|
|
39
|
+
const projected = canonicalCaseExportProjection(cases, root.version);
|
|
40
|
+
const byId = new Map(projected.map(record => [record.id, record]));
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
if (cases.length !== root.cases.length || root.cases.some(value => {
|
|
43
|
+
if (!value || typeof value !== 'object' || typeof value.id !== 'string' || seen.has(value.id))
|
|
44
|
+
return true;
|
|
45
|
+
seen.add(value.id);
|
|
46
|
+
return canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(byId.get(value.id));
|
|
47
|
+
}))
|
|
48
|
+
throw new TypeError('The Case file would lose, repair or truncate retained data during normalisation. Nothing was changed.');
|
|
49
|
+
if (new TextEncoder().encode(serializeCaseStore(cases)).byteLength > MAX_CASE_STORE_BYTES) {
|
|
50
|
+
throw new TypeError('The Case file exceeds the canonical store byte budget. Select fewer Cases; no retained evidence was pruned.');
|
|
51
|
+
}
|
|
52
|
+
return cases;
|
|
53
|
+
}
|