@slicedearth/whoisleuth-cli 2.2.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 +20 -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 +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- 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 +112 -72
- 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-checkpoint.mjs +34 -3
- 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 +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- 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 +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -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 +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -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/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- 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-header-review.mjs +467 -0
- 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 +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- 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 +77 -24
- package/docs/cli.md +300 -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 +51 -8
- 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-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- 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 +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- 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 +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- 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-capabilities.mjs +1 -1
- 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 +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -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 +178 -151
- 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 +153 -45
- 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/lib/zone-intent-review.mjs +16 -6
- 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 +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- 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 +74 -572
- 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 +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- 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 +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -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 +15 -11
- 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 +183 -211
- 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 +201 -33
- 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 +92 -51
- 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 +193 -165
- 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 +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- 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 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
package/lib/whois-transport.mjs
CHANGED
|
@@ -16,38 +16,45 @@ function errorMessage(value, fallback) {
|
|
|
16
16
|
// A referral hostname is upstream-controlled, so resolve and validate its
|
|
17
17
|
// addresses before connecting. Connect to the selected address directly to
|
|
18
18
|
// prevent a second DNS lookup from bypassing the public-address decision.
|
|
19
|
-
function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, } = {}) {
|
|
19
|
+
function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, signal, } = {}) {
|
|
20
20
|
return new Promise((resolve, reject) => {
|
|
21
|
+
signal?.throwIfAborted();
|
|
21
22
|
const socket = createConnection({ host: address, port }, () => {
|
|
22
|
-
|
|
23
|
+
if (!signal?.aborted)
|
|
24
|
+
socket.write(query + '\r\n');
|
|
23
25
|
});
|
|
24
26
|
const chunks = [];
|
|
25
27
|
let totalBytes = 0;
|
|
26
28
|
let settled = false;
|
|
27
29
|
const deadline = setTimeout(() => {
|
|
28
|
-
|
|
30
|
+
settle(reject, new Error(`WHOIS request to ${server} exceeded the total time limit`));
|
|
29
31
|
socket.destroy();
|
|
30
|
-
reject(new Error(`WHOIS request to ${server} exceeded the total time limit`));
|
|
31
32
|
}, totalDeadlineMs);
|
|
32
33
|
function settle(fn, value) {
|
|
33
34
|
if (settled)
|
|
34
35
|
return;
|
|
35
36
|
settled = true;
|
|
36
37
|
clearTimeout(deadline);
|
|
38
|
+
signal?.removeEventListener('abort', abort);
|
|
37
39
|
fn(value);
|
|
38
40
|
}
|
|
41
|
+
const abort = () => { settle(reject, signal.reason); socket.destroy(); };
|
|
39
42
|
socket.setTimeout(Math.min(timeoutMs, totalDeadlineMs));
|
|
40
43
|
socket.on('data', (chunk) => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
41
46
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
42
47
|
totalBytes += buffer.length;
|
|
43
48
|
if (totalBytes > MAX_WHOIS_BYTES) {
|
|
44
|
-
socket.destroy();
|
|
45
49
|
settle(reject, new Error(`WHOIS response from ${server} exceeded ${MAX_WHOIS_BYTES} bytes`));
|
|
50
|
+
socket.destroy();
|
|
46
51
|
return;
|
|
47
52
|
}
|
|
48
53
|
chunks.push(buffer);
|
|
49
54
|
});
|
|
50
55
|
const settleResponse = () => {
|
|
56
|
+
if (settled)
|
|
57
|
+
return;
|
|
51
58
|
try {
|
|
52
59
|
const responseText = new TextDecoder('utf-8', { fatal: true })
|
|
53
60
|
.decode(Buffer.concat(chunks, totalBytes));
|
|
@@ -60,13 +67,17 @@ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 1000
|
|
|
60
67
|
socket.on('end', settleResponse);
|
|
61
68
|
socket.on('close', settleResponse);
|
|
62
69
|
socket.on('timeout', () => {
|
|
63
|
-
socket.destroy();
|
|
64
70
|
settle(reject, new Error(`WHOIS request to ${server} timed out`));
|
|
71
|
+
socket.destroy();
|
|
65
72
|
});
|
|
66
73
|
socket.on('error', (error) => settle(reject, error));
|
|
74
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
abort();
|
|
67
77
|
});
|
|
68
78
|
}
|
|
69
|
-
async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, } = {}) {
|
|
79
|
+
async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, signal, } = {}) {
|
|
80
|
+
signal?.throwIfAborted();
|
|
70
81
|
const startedAt = now();
|
|
71
82
|
let resolutionTimer;
|
|
72
83
|
const records = await Promise.race([
|
|
@@ -75,10 +86,12 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
|
|
|
75
86
|
resolutionTimer = setTimeout(() => reject(new Error(`WHOIS request to ${server} timed out during DNS resolution`)), totalDeadlineMs);
|
|
76
87
|
}),
|
|
77
88
|
]).finally(() => clearTimeout(resolutionTimer));
|
|
89
|
+
signal?.throwIfAborted();
|
|
78
90
|
const candidates = records.slice(0, MAX_WHOIS_ADDRESSES);
|
|
79
91
|
const failures = [];
|
|
80
92
|
let attempts = 0;
|
|
81
93
|
for (const { address } of candidates) {
|
|
94
|
+
signal?.throwIfAborted();
|
|
82
95
|
const remainingMs = totalDeadlineMs - (now() - startedAt);
|
|
83
96
|
if (remainingMs <= 0)
|
|
84
97
|
break;
|
|
@@ -88,7 +101,9 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
|
|
|
88
101
|
port,
|
|
89
102
|
timeoutMs: Math.min(timeoutMs, remainingMs),
|
|
90
103
|
totalDeadlineMs: remainingMs,
|
|
104
|
+
...(signal ? { signal } : {}),
|
|
91
105
|
});
|
|
106
|
+
signal?.throwIfAborted();
|
|
92
107
|
try {
|
|
93
108
|
onAddressSelected(address);
|
|
94
109
|
}
|
|
@@ -98,6 +113,7 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
|
|
|
98
113
|
return response;
|
|
99
114
|
}
|
|
100
115
|
catch (error) {
|
|
116
|
+
signal?.throwIfAborted();
|
|
101
117
|
failures.push(errorMessage(error, 'connection failed').slice(0, 200));
|
|
102
118
|
}
|
|
103
119
|
}
|
|
@@ -142,7 +142,11 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
|
|
|
142
142
|
const family = type === 'A' ? 4 : 6;
|
|
143
143
|
if (tokens.length !== 1 || isIP(tokens[0] ?? '') !== family)
|
|
144
144
|
throw new TypeError(`${type} data must contain one valid address.`);
|
|
145
|
-
|
|
145
|
+
const supplied = tokens[0];
|
|
146
|
+
const normalised = family === 4
|
|
147
|
+
? supplied.split('.').map((part) => String(Number(part))).join('.')
|
|
148
|
+
: new URL(`http://[${supplied}]/`).hostname.slice(1, -1).toLowerCase();
|
|
149
|
+
return { value: normalised, valueTreatment: 'normalised' };
|
|
146
150
|
}
|
|
147
151
|
if (type === 'CNAME' || type === 'NS') {
|
|
148
152
|
if (tokens.length !== 1)
|
|
@@ -152,7 +156,12 @@ export function normaliseRdata(type, rawValue, origin, nameMode = 'absolute') {
|
|
|
152
156
|
if (type === 'MX') {
|
|
153
157
|
if (tokens.length !== 2)
|
|
154
158
|
throw new TypeError('MX data must contain preference and exchange.');
|
|
155
|
-
|
|
159
|
+
const preference = integerToken(tokens[0], 0, 65_535, 'MX preference');
|
|
160
|
+
const exchangeToken = tokens[1];
|
|
161
|
+
if (exchangeToken === '.' && preference !== 0)
|
|
162
|
+
throw new TypeError('A Null MX exchange must use preference 0.');
|
|
163
|
+
const exchange = exchangeToken === '.' ? '.' : host(exchangeToken, 'MX exchange');
|
|
164
|
+
return { value: `${preference} ${exchange}`, valueTreatment: 'normalised' };
|
|
156
165
|
}
|
|
157
166
|
if (type === 'CAA') {
|
|
158
167
|
if (tokens.length < 3)
|
|
@@ -380,7 +389,6 @@ function parseBindZone(zoneText, initialOrigin) {
|
|
|
380
389
|
if (tokens.length !== 2)
|
|
381
390
|
throw new TypeError('$ORIGIN requires one domain name.');
|
|
382
391
|
origin = domainName(tokens[1], origin, '$ORIGIN', 'master-file');
|
|
383
|
-
lastOwner = origin;
|
|
384
392
|
continue;
|
|
385
393
|
}
|
|
386
394
|
if (directive === '$TTL')
|
|
@@ -388,10 +396,12 @@ function parseBindZone(zoneText, initialOrigin) {
|
|
|
388
396
|
if (directive?.startsWith('$'))
|
|
389
397
|
throw new TypeError(`${directive} is deliberately unsupported.`);
|
|
390
398
|
let offset = 0;
|
|
391
|
-
const ownerToken = entry.ownerOmitted ?
|
|
392
|
-
if (!ownerToken)
|
|
399
|
+
const ownerToken = entry.ownerOmitted ? null : tokens[offset++];
|
|
400
|
+
if (!entry.ownerOmitted && !ownerToken)
|
|
393
401
|
throw new TypeError('Record owner is missing.');
|
|
394
|
-
const owner =
|
|
402
|
+
const owner = entry.ownerOmitted
|
|
403
|
+
? lastOwner
|
|
404
|
+
: ownerName(ownerToken, origin, 'record owner', 'master-file');
|
|
395
405
|
lastOwner = owner;
|
|
396
406
|
let ttl = null;
|
|
397
407
|
if (/^\d+$/u.test(tokens[offset] ?? ''))
|
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)
|