@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
|
@@ -85,6 +85,9 @@ export function saveInvestigationTemplate(localRaw, candidateRaw) {
|
|
|
85
85
|
if (!candidate)
|
|
86
86
|
throw new Error('The investigation template is incomplete or invalid.');
|
|
87
87
|
const local = normalizeInvestigationTemplateStore(localRaw).templates;
|
|
88
|
+
if (!local.some((item) => item.id === candidate.id) && local.length >= MAX_INVESTIGATION_TEMPLATES) {
|
|
89
|
+
throw new Error(`Investigation-template storage is full (${MAX_INVESTIGATION_TEMPLATES} templates). Export and remove a template before saving another; no templates were changed.`);
|
|
90
|
+
}
|
|
88
91
|
return normalizeInvestigationTemplateStore([
|
|
89
92
|
candidate,
|
|
90
93
|
...local.filter((item) => item.id !== candidate.id),
|
|
@@ -126,11 +129,23 @@ export function mergeInvestigationTemplates(localRaw, incomingRaw) {
|
|
|
126
129
|
const byId = new Map(local.map((item) => [item.id, item]));
|
|
127
130
|
let added = 0;
|
|
128
131
|
let updated = 0;
|
|
132
|
+
let skipped = 0;
|
|
129
133
|
for (const item of incoming) {
|
|
130
|
-
|
|
134
|
+
const existing = byId.get(item.id);
|
|
135
|
+
if (existing) {
|
|
136
|
+
if (item.updatedAt <= existing.updatedAt) {
|
|
137
|
+
skipped += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
131
140
|
updated += 1;
|
|
132
|
-
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
if (byId.size >= MAX_INVESTIGATION_TEMPLATES) {
|
|
144
|
+
skipped += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
133
147
|
added += 1;
|
|
148
|
+
}
|
|
134
149
|
byId.set(item.id, item);
|
|
135
150
|
}
|
|
136
151
|
const templates = normalizeInvestigationTemplateStore([...byId.values()]).templates;
|
|
@@ -138,7 +153,7 @@ export function mergeInvestigationTemplates(localRaw, incomingRaw) {
|
|
|
138
153
|
templates,
|
|
139
154
|
added,
|
|
140
155
|
updated,
|
|
141
|
-
skipped
|
|
142
|
-
pruned:
|
|
156
|
+
skipped,
|
|
157
|
+
pruned: 0,
|
|
143
158
|
};
|
|
144
159
|
}
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
6
6
|
import { isInformativePerceptualHash as isInformativeFaviconHash } from "../../lib/perceptual-hash-comparison.mjs";
|
|
7
7
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import { PAGE_FINGERPRINT_TOKEN_LIMITS } from "../contracts/page-fingerprints.mjs";
|
|
9
|
+
import { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_PARSERS, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
10
|
+
export { MAX_BASELINE_IDENTIFIERS, MAX_BASELINE_RESOURCE_HOSTS, MAX_BASELINE_TITLE_LENGTH, PAGE_BASELINE_VERSION, PAGE_FINGERPRINT_VERSION, PAGE_FINGERPRINT_PARSERS, PAGE_IDENTITY_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
10
11
|
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
11
12
|
const SIMHASH_RE = /^[a-f0-9]{16}$/i;
|
|
12
13
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
13
|
-
const MAX_HTML_TOKENS = 4096;
|
|
14
14
|
const MAX_TEXT_TOKENS = 8192;
|
|
15
15
|
const MAX_FORMS = 50;
|
|
16
16
|
const MAX_FORM_CONTROLS = 500;
|
|
@@ -50,12 +50,12 @@ function shaComponent(raw, countKey, maximum) {
|
|
|
50
50
|
truncated: value.truncated === true,
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
-
function visibleTextComponent(raw) {
|
|
53
|
+
function visibleTextComponent(raw, maximum = MAX_TEXT_TOKENS) {
|
|
54
54
|
const value = record(raw);
|
|
55
55
|
if (!value || value.algorithm !== 'simhash64-v1' || typeof value.value !== 'string' || !SIMHASH_RE.test(value.value))
|
|
56
56
|
return null;
|
|
57
|
-
const tokenCount = count(value.tokenCount,
|
|
58
|
-
const featureCount = count(value.featureCount,
|
|
57
|
+
const tokenCount = count(value.tokenCount, maximum);
|
|
58
|
+
const featureCount = count(value.featureCount, maximum);
|
|
59
59
|
if (tokenCount === null || featureCount === null)
|
|
60
60
|
return null;
|
|
61
61
|
return {
|
|
@@ -66,20 +66,21 @@ function visibleTextComponent(raw) {
|
|
|
66
66
|
truncated: value.truncated === true,
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
|
-
function domComponent(raw) {
|
|
69
|
+
function domComponent(raw, fingerprintVersion) {
|
|
70
70
|
const value = record(raw);
|
|
71
|
-
if (!value || value.algorithm !== 'sha256' || value.parser !==
|
|
71
|
+
if (!value || value.algorithm !== 'sha256' || value.parser !== PAGE_FINGERPRINT_PARSERS[fingerprintVersion])
|
|
72
72
|
return null;
|
|
73
73
|
const digest = sha256(value.value);
|
|
74
|
-
const
|
|
74
|
+
const maximum = PAGE_FINGERPRINT_TOKEN_LIMITS[fingerprintVersion];
|
|
75
|
+
const nodeCount = count(value.nodeCount, maximum);
|
|
75
76
|
if (!digest || nodeCount === null)
|
|
76
77
|
return null;
|
|
77
|
-
const similarity = value.similarity == null ? null : visibleTextComponent(value.similarity);
|
|
78
|
+
const similarity = value.similarity == null ? null : visibleTextComponent(value.similarity, maximum);
|
|
78
79
|
return {
|
|
79
80
|
algorithm: 'sha256',
|
|
80
81
|
value: digest,
|
|
81
82
|
nodeCount,
|
|
82
|
-
parser:
|
|
83
|
+
parser: PAGE_FINGERPRINT_PARSERS[fingerprintVersion],
|
|
83
84
|
truncated: value.truncated === true,
|
|
84
85
|
...(value.similarity == null ? {} : { similarity }),
|
|
85
86
|
};
|
|
@@ -175,11 +176,13 @@ export function normalizePageBaseline(raw) {
|
|
|
175
176
|
const lookupDomain = normalizeDomain(value.lookupDomain);
|
|
176
177
|
const observedAt = timestamp(value.observedAt);
|
|
177
178
|
const pageIdentityVersion = count(value.pageIdentityVersion, PAGE_IDENTITY_VERSION);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
179
|
+
if (typeof value.fingerprintVersion !== 'number' || !Object.hasOwn(PAGE_FINGERPRINT_PARSERS, value.fingerprintVersion))
|
|
180
|
+
return null;
|
|
181
|
+
const fingerprintVersion = value.fingerprintVersion;
|
|
182
|
+
const normalizedHtml = shaComponent(value.normalizedHtml, 'tokenCount', PAGE_FINGERPRINT_TOKEN_LIMITS[fingerprintVersion]);
|
|
183
|
+
const domStructure = domComponent(value.domStructure, fingerprintVersion);
|
|
181
184
|
if (!domain || !lookupDomain || !observedAt || pageIdentityVersion !== PAGE_IDENTITY_VERSION
|
|
182
|
-
||
|
|
185
|
+
|| !normalizedHtml || !domStructure)
|
|
183
186
|
return null;
|
|
184
187
|
const visibleText = value.visibleText == null ? null : visibleTextComponent(value.visibleText);
|
|
185
188
|
const formStructure = value.formStructure == null ? null : formComponent(value.formStructure);
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
2
2
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
3
|
+
import { RELATIONSHIP_EVIDENCE_VERSION } from "../contracts/offline-comparison.mjs";
|
|
4
|
+
import { RELATIONSHIP_TYPES, qualifyRelationshipSources } from "../comparison/relationship-provenance.mjs";
|
|
3
5
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
4
6
|
import { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
5
7
|
export { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
6
|
-
export const RELATIONSHIP_OBSERVATION_TYPES =
|
|
7
|
-
'nameserver_set',
|
|
8
|
-
'ip_address',
|
|
9
|
-
'certificate',
|
|
10
|
-
'tracking_identifier',
|
|
11
|
-
'favicon',
|
|
12
|
-
'official_asset',
|
|
13
|
-
]);
|
|
8
|
+
export const RELATIONSHIP_OBSERVATION_TYPES = RELATIONSHIP_TYPES;
|
|
14
9
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
15
10
|
const SAFE_ID_RE = /^relationship-[a-z0-9]{1,16}-[a-z0-9]{1,16}$/u;
|
|
16
11
|
const SHA256_RE = /^[a-f0-9]{64}$/iu;
|
|
@@ -205,7 +200,7 @@ export function relationshipObservationId(raw) {
|
|
|
205
200
|
}
|
|
206
201
|
export function relationshipObservationStoreVersion(raw) {
|
|
207
202
|
const value = record(raw);
|
|
208
|
-
return
|
|
203
|
+
return typeof value?.version === 'number' && Number.isSafeInteger(value.version) && value.version > 0 ? value.version : null;
|
|
209
204
|
}
|
|
210
205
|
function observationList(raw) {
|
|
211
206
|
if (Array.isArray(raw))
|
|
@@ -213,7 +208,7 @@ function observationList(raw) {
|
|
|
213
208
|
const value = record(raw);
|
|
214
209
|
return Array.isArray(value?.observations) ? value.observations : [];
|
|
215
210
|
}
|
|
216
|
-
export function normalizeRelationshipObservation(raw) {
|
|
211
|
+
export function normalizeRelationshipObservation(raw, sourceStoreVersion = RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
|
|
217
212
|
const value = record(raw);
|
|
218
213
|
if (!value || typeof value.type !== 'string' || !TYPES.has(value.type))
|
|
219
214
|
return null;
|
|
@@ -230,8 +225,16 @@ export function normalizeRelationshipObservation(raw) {
|
|
|
230
225
|
const id = observationId(canonical);
|
|
231
226
|
const observedAt = timestamp(value.observedAt);
|
|
232
227
|
const retainedAt = timestamp(value.retainedAt);
|
|
233
|
-
if (!
|
|
228
|
+
if (!retainedAt || (value.observedAt !== null && !observedAt))
|
|
234
229
|
return null;
|
|
230
|
+
const sourceVersion = positiveInteger(value.sourceVersion) || 1;
|
|
231
|
+
if (value.sourceVersion !== undefined && !positiveInteger(value.sourceVersion))
|
|
232
|
+
throw new TypeError('This retained relationship uses an unsupported source-evidence version; no evidence was interpreted.');
|
|
233
|
+
if (sourceVersion > RELATIONSHIP_EVIDENCE_VERSION)
|
|
234
|
+
throw new TypeError('This retained relationship uses a newer source-evidence version; no evidence was interpreted.');
|
|
235
|
+
if (sourceStoreVersion === 1 && sourceVersion > 2)
|
|
236
|
+
throw new TypeError('Relationship schema 1 cannot contain newer source evidence; no evidence was interpreted.');
|
|
237
|
+
const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? value.sourceEvidence : [], domains, { truncated: value.truncated === true, type });
|
|
235
238
|
return {
|
|
236
239
|
id,
|
|
237
240
|
type,
|
|
@@ -243,11 +246,14 @@ export function normalizeRelationshipObservation(raw) {
|
|
|
243
246
|
description: metadata.description,
|
|
244
247
|
classification: 'derived',
|
|
245
248
|
source: 'bulk_relationship_analysis',
|
|
246
|
-
sourceVersion
|
|
247
|
-
|
|
249
|
+
sourceVersion,
|
|
250
|
+
// Legacy scan timestamps remain available, but cannot qualify missing
|
|
251
|
+
// contributing-source timestamps or turn the old complete flag into proof.
|
|
252
|
+
observedAt: qualified.observedAt ?? (observedAt || null),
|
|
253
|
+
sourceEvidence: qualified.sourceEvidence,
|
|
248
254
|
retainedAt,
|
|
249
|
-
complete: value.complete === true,
|
|
250
|
-
truncated:
|
|
255
|
+
complete: value.complete === true && qualified.complete,
|
|
256
|
+
truncated: qualified.truncated
|
|
251
257
|
|| (Array.isArray(value.domains) && value.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
|
|
252
258
|
limitations: observationLimitations(value.limitations),
|
|
253
259
|
};
|
|
@@ -255,9 +261,13 @@ export function normalizeRelationshipObservation(raw) {
|
|
|
255
261
|
export function normalizeRelationshipObservationStore(raw) {
|
|
256
262
|
assertWorkspaceInputGraph(raw, 'Relationship-observation store');
|
|
257
263
|
assertWorkspaceDeclaredVersion(raw, 'Relationship-observation store');
|
|
264
|
+
const sourceStoreVersion = relationshipObservationStoreVersion(raw) ?? RELATIONSHIP_OBSERVATION_SCHEMA_VERSION;
|
|
265
|
+
if (sourceStoreVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
|
|
266
|
+
throw new TypeError(`This relationship-observation store uses newer schema ${sourceStoreVersion}; no evidence was interpreted.`);
|
|
267
|
+
}
|
|
258
268
|
const byId = new Map();
|
|
259
269
|
for (const candidate of observationList(raw).slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
|
|
260
|
-
const observation = normalizeRelationshipObservation(candidate);
|
|
270
|
+
const observation = normalizeRelationshipObservation(candidate, sourceStoreVersion);
|
|
261
271
|
if (!observation)
|
|
262
272
|
continue;
|
|
263
273
|
const existing = byId.get(observation.id);
|
|
@@ -283,7 +293,15 @@ export function createRelationshipObservation(raw, options = {}) {
|
|
|
283
293
|
throw new Error('That relationship does not contain a supported bounded value and at least one valid domain.');
|
|
284
294
|
}
|
|
285
295
|
const now = new Date().toISOString();
|
|
286
|
-
const
|
|
296
|
+
const sourceVersion = Object.hasOwn(options, 'sourceVersion') ? positiveInteger(options.sourceVersion) : RELATIONSHIP_EVIDENCE_VERSION;
|
|
297
|
+
if (!sourceVersion || sourceVersion > RELATIONSHIP_EVIDENCE_VERSION)
|
|
298
|
+
throw new TypeError('This relationship needs a supported source-evidence version.');
|
|
299
|
+
const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? raw.sourceEvidence : [], domains, { truncated: options.truncated === true, type });
|
|
300
|
+
// Older callers can supply their original scan timestamp. It does not
|
|
301
|
+
// qualify the missing source metadata, and an explicit current projection
|
|
302
|
+
// never substitutes that timestamp for unknown contributing-source times.
|
|
303
|
+
const observedAt = qualified.observedAt
|
|
304
|
+
?? (!Object.hasOwn(raw, 'sourceEvidence') ? timestamp(options.observedAt) || null : null);
|
|
287
305
|
const retainedAt = timestamp(options.retainedAt) || now;
|
|
288
306
|
const canonical = canonicalIdentity(type, normalizedValue, domains);
|
|
289
307
|
const metadata = TYPE_METADATA[type];
|
|
@@ -298,11 +316,12 @@ export function createRelationshipObservation(raw, options = {}) {
|
|
|
298
316
|
description: metadata.description,
|
|
299
317
|
classification: 'derived',
|
|
300
318
|
source: 'bulk_relationship_analysis',
|
|
301
|
-
sourceVersion
|
|
319
|
+
sourceVersion,
|
|
302
320
|
observedAt,
|
|
321
|
+
sourceEvidence: qualified.sourceEvidence,
|
|
303
322
|
retainedAt,
|
|
304
|
-
complete: options.complete
|
|
305
|
-
truncated:
|
|
323
|
+
complete: options.complete !== false && qualified.complete,
|
|
324
|
+
truncated: qualified.truncated
|
|
306
325
|
|| (Array.isArray(raw.domains) && raw.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
|
|
307
326
|
limitations: observationLimitations(options.limitations),
|
|
308
327
|
};
|
|
@@ -356,12 +375,11 @@ export function deleteRelationshipObservation(rawStore, id) {
|
|
|
356
375
|
export function mergeRelationshipObservations(localRaw, importedRaw) {
|
|
357
376
|
assertWorkspaceInputGraph(localRaw, 'Local relationship-observation store');
|
|
358
377
|
assertWorkspaceInputGraph(importedRaw, 'Imported relationship-observation document');
|
|
359
|
-
assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
|
|
378
|
+
const importedVersion = assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
|
|
360
379
|
const importedRecord = record(importedRaw);
|
|
361
380
|
if (importedRecord?.schema !== undefined && importedRecord.schema !== RELATIONSHIP_OBSERVATION_SCHEMA) {
|
|
362
381
|
throw new Error('This JSON section is not a WHOISleuth relationship-observation export.');
|
|
363
382
|
}
|
|
364
|
-
const importedVersion = relationshipObservationStoreVersion(importedRaw);
|
|
365
383
|
if (importedVersion !== null && importedVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
|
|
366
384
|
throw new Error(`This relationship-observation section uses newer schema ${importedVersion}. Update the app before importing it.`);
|
|
367
385
|
}
|
|
@@ -372,7 +390,7 @@ export function mergeRelationshipObservations(localRaw, importedRaw) {
|
|
|
372
390
|
let updated = 0;
|
|
373
391
|
let skipped = Math.max(0, importedInput.length - MAX_RELATIONSHIP_OBSERVATION_INPUTS);
|
|
374
392
|
for (const candidate of importedInput.slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
|
|
375
|
-
const observation = normalizeRelationshipObservation(candidate);
|
|
393
|
+
const observation = normalizeRelationshipObservation(candidate, importedVersion ?? RELATIONSHIP_OBSERVATION_SCHEMA_VERSION);
|
|
376
394
|
if (!observation) {
|
|
377
395
|
skipped += 1;
|
|
378
396
|
continue;
|
|
@@ -6,6 +6,7 @@ import { explainRiskScore, normalizeRiskModelVersion } from "../../lib/risk-scor
|
|
|
6
6
|
import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "../cases/http-summary.mjs";
|
|
7
7
|
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
8
8
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
9
|
+
import { registryDateIso } from "../evidence/registry-dates.mjs";
|
|
9
10
|
import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
|
|
10
11
|
export { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
|
|
11
12
|
const MAX_TEXT_LENGTH = 300;
|
|
@@ -155,8 +156,7 @@ function boundedInteger(value, maximum, fallback = 0) {
|
|
|
155
156
|
function normalizeDateValue(value) {
|
|
156
157
|
if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
|
|
157
158
|
return null;
|
|
158
|
-
|
|
159
|
-
return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
|
|
159
|
+
return registryDateIso(value)?.slice(0, 10) ?? null;
|
|
160
160
|
}
|
|
161
161
|
function inferredScanDepth(record) {
|
|
162
162
|
if (record.scanDepth === 'deep' || record.scanDepth === 'fast')
|
|
@@ -109,15 +109,21 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
|
|
|
109
109
|
skipped++;
|
|
110
110
|
continue;
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
const normalized = normalizeWatchlistEntry(entry);
|
|
113
|
+
if (Object.prototype.hasOwnProperty.call(local, name)) {
|
|
114
|
+
if (normalized.updatedAt <= local[name].updatedAt) {
|
|
115
|
+
skipped++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
113
118
|
updated++;
|
|
119
|
+
}
|
|
114
120
|
else if (Object.keys(local).length >= MAX_WATCHLISTS) {
|
|
115
121
|
skipped++;
|
|
116
122
|
continue;
|
|
117
123
|
}
|
|
118
124
|
else
|
|
119
125
|
added++;
|
|
120
|
-
defineEntry(local, name,
|
|
126
|
+
defineEntry(local, name, normalized);
|
|
121
127
|
}
|
|
122
128
|
return { watchlists: local, added, updated, skipped };
|
|
123
129
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
2
2
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
3
|
+
import { validWebObservationMode } from "../evidence/lookup-target.mjs";
|
|
3
4
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
4
|
-
import { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
5
|
+
import { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, PAGE_FINGERPRINT_PARSERS, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
5
6
|
export { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
6
7
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
7
8
|
const DIGEST_RE = /^[a-f0-9]{16,128}$/iu;
|
|
@@ -10,6 +11,10 @@ const SERIAL_RE = /^[a-f0-9]{1,128}$/iu;
|
|
|
10
11
|
const SNAPSHOT_TECHNOLOGY_ROLES = new Set([
|
|
11
12
|
'observed_edge', 'application_platform', 'framework_runtime', 'embedded_dependency',
|
|
12
13
|
]);
|
|
14
|
+
export const WEBSITE_SNAPSHOT_COLLECTION_LIMITS = Object.freeze({
|
|
15
|
+
technologies: 40, posture: 40, sources: 16, dependencies: 20,
|
|
16
|
+
resourceHosts: 30, trackingIdentifiers: 30, formActionOrigins: 20,
|
|
17
|
+
});
|
|
13
18
|
function record(value) {
|
|
14
19
|
return ordinaryWorkspaceRecord(value, 'Website-snapshot input');
|
|
15
20
|
}
|
|
@@ -67,18 +72,26 @@ function profileVersion(value) {
|
|
|
67
72
|
? Number(value)
|
|
68
73
|
: null;
|
|
69
74
|
}
|
|
70
|
-
function profileProvenance(value,
|
|
75
|
+
function profileProvenance(value, sourceVersion) {
|
|
76
|
+
const legacy = sourceVersion === 4;
|
|
71
77
|
if (legacy) {
|
|
72
78
|
return {
|
|
73
79
|
technology: { version: null, state: 'legacy_unknown' },
|
|
74
80
|
securityPosture: { version: null, state: 'legacy_unknown' },
|
|
81
|
+
pageFingerprint: { version: 1, state: 'known' },
|
|
75
82
|
};
|
|
76
83
|
}
|
|
77
84
|
const item = record(value);
|
|
78
85
|
const technologyItem = record(item?.technology);
|
|
79
86
|
const securityPostureItem = record(item?.securityPosture);
|
|
87
|
+
const pageFingerprintItem = record(item?.pageFingerprint);
|
|
80
88
|
const technologyVersion = profileVersion(technologyItem?.version);
|
|
81
89
|
const securityPostureVersion = profileVersion(securityPostureItem?.version);
|
|
90
|
+
// Exact published snapshot writers 4 and 5 used fingerprint algorithm 1.
|
|
91
|
+
// Unversioned records do not receive invented historical provenance.
|
|
92
|
+
const candidateFingerprintVersion = sourceVersion === 5 ? 1 : profileVersion(pageFingerprintItem?.version);
|
|
93
|
+
const pageFingerprintVersion = candidateFingerprintVersion !== null && Object.hasOwn(PAGE_FINGERPRINT_PARSERS, candidateFingerprintVersion)
|
|
94
|
+
? candidateFingerprintVersion : null;
|
|
82
95
|
return {
|
|
83
96
|
technology: {
|
|
84
97
|
version: technologyVersion,
|
|
@@ -88,6 +101,7 @@ function profileProvenance(value, legacy = false) {
|
|
|
88
101
|
version: securityPostureVersion,
|
|
89
102
|
state: securityPostureVersion === null ? 'legacy_unknown' : 'known',
|
|
90
103
|
},
|
|
104
|
+
pageFingerprint: { version: pageFingerprintVersion, state: pageFingerprintVersion === null ? 'legacy_unknown' : 'known' },
|
|
91
105
|
};
|
|
92
106
|
}
|
|
93
107
|
function posture(value) {
|
|
@@ -183,8 +197,8 @@ function origin(value) {
|
|
|
183
197
|
}
|
|
184
198
|
function identityValues(value) {
|
|
185
199
|
const item = record(value);
|
|
186
|
-
const resourceHosts = values(item?.resourceHosts,
|
|
187
|
-
const trackingIdentifiers = values(item?.trackingIdentifiers,
|
|
200
|
+
const resourceHosts = values(item?.resourceHosts, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.resourceHosts, (candidate) => normalizeDomain(candidate));
|
|
201
|
+
const trackingIdentifiers = values(item?.trackingIdentifiers, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.trackingIdentifiers, (candidate) => {
|
|
188
202
|
const tracker = record(candidate);
|
|
189
203
|
const type = text(tracker?.type, 40).toLowerCase();
|
|
190
204
|
const trackerValue = text(tracker?.value, 64).toUpperCase();
|
|
@@ -192,7 +206,7 @@ function identityValues(value) {
|
|
|
192
206
|
? { type, value: trackerValue }
|
|
193
207
|
: null;
|
|
194
208
|
});
|
|
195
|
-
const formActionOrigins = values(item?.formActionOrigins,
|
|
209
|
+
const formActionOrigins = values(item?.formActionOrigins, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.formActionOrigins, origin);
|
|
196
210
|
return { resourceHosts, trackingIdentifiers, formActionOrigins };
|
|
197
211
|
}
|
|
198
212
|
export function normalizeWebsiteProfileSnapshot(raw, sourceVersion) {
|
|
@@ -203,20 +217,24 @@ export function normalizeWebsiteProfileSnapshot(raw, sourceVersion) {
|
|
|
203
217
|
const id = text(value?.id, 128);
|
|
204
218
|
if (!domain || !observedAt || !savedAt || !id)
|
|
205
219
|
return null;
|
|
220
|
+
const acceptsWebScope = sourceVersion === undefined || sourceVersion >= 6;
|
|
221
|
+
if (acceptsWebScope && !validWebObservationMode(value?.webObservationMode))
|
|
222
|
+
return null;
|
|
206
223
|
return {
|
|
207
224
|
id,
|
|
208
225
|
domain,
|
|
226
|
+
...(acceptsWebScope && value?.webObservationMode === 'selected_url' ? { webObservationMode: value.webObservationMode } : {}),
|
|
209
227
|
observedAt,
|
|
210
228
|
savedAt,
|
|
211
229
|
complete: value?.complete === true,
|
|
212
230
|
truncated: value?.truncated === true,
|
|
213
|
-
profileProvenance: profileProvenance(value?.profileProvenance, sourceVersion
|
|
214
|
-
technologies: values(value?.technologies,
|
|
215
|
-
posture: values(value?.posture,
|
|
231
|
+
profileProvenance: profileProvenance(value?.profileProvenance, sourceVersion),
|
|
232
|
+
technologies: values(value?.technologies, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.technologies, technology),
|
|
233
|
+
posture: values(value?.posture, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.posture, posture),
|
|
216
234
|
identity: identity(value?.identity),
|
|
217
235
|
identityValues: identityValues(value?.identityValues),
|
|
218
|
-
sources: values(value?.sources,
|
|
219
|
-
dependencies: values(value?.dependencies,
|
|
236
|
+
sources: values(value?.sources, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.sources, source),
|
|
237
|
+
dependencies: values(value?.dependencies, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.dependencies, dependency),
|
|
220
238
|
certificate: certificate(value?.certificate),
|
|
221
239
|
};
|
|
222
240
|
}
|
|
@@ -296,14 +314,96 @@ export function mergeWebsiteSnapshots(localRaw, incomingRaw) {
|
|
|
296
314
|
const snapshots = normalizeWebsiteSnapshotStore([...byId.values()]).snapshots;
|
|
297
315
|
return { snapshots, added, updated, skipped: Math.max(0, incoming.length - added - updated) };
|
|
298
316
|
}
|
|
299
|
-
|
|
317
|
+
const MAX_RECONCILED_WEBSITE_SOURCES = 16;
|
|
318
|
+
const UNAVAILABLE_WEBSITE_SOURCE_STATES = new Set([
|
|
319
|
+
'blocked', 'error', 'not_found', 'rate_limited', 'unavailable', 'unsupported',
|
|
320
|
+
]);
|
|
321
|
+
export function canonicalWebsiteSourceState(value) {
|
|
322
|
+
return value.trim().toLowerCase().replace(/[\s-]+/gu, '_');
|
|
323
|
+
}
|
|
324
|
+
function websiteSourceStateSeverity(value) {
|
|
325
|
+
if (UNAVAILABLE_WEBSITE_SOURCE_STATES.has(value))
|
|
326
|
+
return 2;
|
|
327
|
+
return ['complete', 'success'].includes(value) ? 0 : 1;
|
|
328
|
+
}
|
|
329
|
+
export function reconciledWebsiteSources(snapshot) {
|
|
330
|
+
const selected = new Map();
|
|
331
|
+
for (const sourceEntry of snapshot.sources.slice(0, MAX_RECONCILED_WEBSITE_SOURCES)) {
|
|
332
|
+
const sourceId = sourceEntry.source.trim().toLowerCase();
|
|
333
|
+
if (!sourceId)
|
|
334
|
+
continue;
|
|
335
|
+
const state = canonicalWebsiteSourceState(sourceEntry.state);
|
|
336
|
+
const severity = websiteSourceStateSeverity(state);
|
|
337
|
+
const current = selected.get(sourceId);
|
|
338
|
+
if (!current || severity > current.severity || (severity === current.severity && state.localeCompare(current.state) < 0)) {
|
|
339
|
+
selected.set(sourceId, { state, severity });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [...selected.entries()]
|
|
343
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
344
|
+
.map(([sourceName, value]) => ({ source: sourceName, state: value.state }));
|
|
345
|
+
}
|
|
346
|
+
export function websiteWithReconciledSources(snapshot) {
|
|
347
|
+
return { ...snapshot, sources: reconciledWebsiteSources(snapshot) };
|
|
348
|
+
}
|
|
349
|
+
export function reconciledWebsiteSourceState(snapshot, sourceId) {
|
|
350
|
+
const canonicalId = sourceId.trim().toLowerCase();
|
|
351
|
+
return reconciledWebsiteSources(snapshot)
|
|
352
|
+
.find((sourceEntry) => sourceEntry.source === canonicalId)?.state ?? null;
|
|
353
|
+
}
|
|
354
|
+
function completeWebsiteSourceState(value) {
|
|
355
|
+
const state = canonicalWebsiteSourceState(value);
|
|
356
|
+
return state === 'complete' || state === 'success';
|
|
357
|
+
}
|
|
358
|
+
export function websiteSnapshotFieldSource(field) {
|
|
359
|
+
if (field.startsWith('certificate.'))
|
|
360
|
+
return 'tls';
|
|
361
|
+
if (field.startsWith('source.'))
|
|
362
|
+
return field.slice('source.'.length);
|
|
363
|
+
if (field.startsWith('dependency.')) {
|
|
364
|
+
const recordType = field.slice('dependency.'.length).split(':')[0];
|
|
365
|
+
return recordType === 'HTTP' ? 'http' : 'dns';
|
|
366
|
+
}
|
|
367
|
+
if (['technology', 'posture', 'identity'].some((family) => field.startsWith(family)))
|
|
368
|
+
return 'http';
|
|
369
|
+
return '';
|
|
370
|
+
}
|
|
371
|
+
export function websiteSnapshotFieldComplete(snapshot, field) {
|
|
372
|
+
const sourceName = websiteSnapshotFieldSource(field);
|
|
373
|
+
const retainedSource = sourceName ? reconciledWebsiteSourceState(snapshot, sourceName) : null;
|
|
374
|
+
const retainedSourceComplete = !retainedSource || completeWebsiteSourceState(retainedSource);
|
|
375
|
+
if (field.startsWith('certificate.')) {
|
|
376
|
+
return Boolean(snapshot.certificate?.complete && !snapshot.certificate.truncated && retainedSourceComplete);
|
|
377
|
+
}
|
|
378
|
+
return snapshot.complete && !snapshot.truncated && retainedSourceComplete;
|
|
379
|
+
}
|
|
380
|
+
export function websiteSnapshotComparisonEvidenceComplete(snapshot) {
|
|
381
|
+
return websiteSnapshotFieldComplete(snapshot, 'technology')
|
|
382
|
+
&& websiteSnapshotFieldComplete(snapshot, 'posture')
|
|
383
|
+
&& websiteSnapshotFieldComplete(snapshot, 'identity')
|
|
384
|
+
&& websiteSnapshotFieldComplete(snapshot, 'dependency.CNAME')
|
|
385
|
+
&& (!snapshot.certificate || websiteSnapshotFieldComplete(snapshot, 'certificate.observation'));
|
|
386
|
+
}
|
|
387
|
+
export function websiteSnapshotProfileComparability(before, after, profile) {
|
|
388
|
+
const left = before.profileProvenance[profile].version;
|
|
389
|
+
const right = after.profileProvenance[profile].version;
|
|
390
|
+
if (left === null || right === null)
|
|
391
|
+
return 'legacy_unknown';
|
|
392
|
+
return left === right ? 'comparable' : 'detector_changed';
|
|
393
|
+
}
|
|
394
|
+
function compareMap(field, before, after, options = {}) {
|
|
300
395
|
const changes = [];
|
|
301
396
|
for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
302
397
|
const left = before.get(key) ?? null;
|
|
303
398
|
const right = after.get(key) ?? null;
|
|
304
399
|
if (left === right)
|
|
305
400
|
continue;
|
|
306
|
-
|
|
401
|
+
const state = left === null
|
|
402
|
+
? options.beforeComplete?.(key) === false ? 'incomparable' : 'added'
|
|
403
|
+
: right === null
|
|
404
|
+
? options.afterComplete?.(key) === false ? 'incomparable' : 'removed'
|
|
405
|
+
: 'changed';
|
|
406
|
+
changes.push({ field: `${field}.${key}`, state, before: left, after: right });
|
|
307
407
|
}
|
|
308
408
|
return changes;
|
|
309
409
|
}
|
|
@@ -313,35 +413,43 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
313
413
|
if (!before || !after || before.domain !== after.domain) {
|
|
314
414
|
return {
|
|
315
415
|
compatible: false,
|
|
416
|
+
complete: false,
|
|
316
417
|
changes: [{ field: 'snapshot', state: 'incomparable', before: before?.domain ?? null, after: after?.domain ?? null }],
|
|
317
418
|
dependencyTransitions: [],
|
|
318
419
|
};
|
|
319
420
|
}
|
|
320
421
|
const technologyVersionBefore = before.profileProvenance.technology.version;
|
|
422
|
+
const selectedPage = before.webObservationMode === 'selected_url' || after.webObservationMode === 'selected_url';
|
|
321
423
|
const technologyVersionAfter = after.profileProvenance.technology.version;
|
|
322
424
|
const postureVersionBefore = before.profileProvenance.securityPosture.version;
|
|
323
425
|
const postureVersionAfter = after.profileProvenance.securityPosture.version;
|
|
324
|
-
const technologyComparability =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const postureComparability = postureVersionBefore === null || postureVersionAfter === null
|
|
328
|
-
? 'legacy_unknown'
|
|
329
|
-
: postureVersionBefore === postureVersionAfter ? 'comparable' : 'detector_changed';
|
|
426
|
+
const technologyComparability = websiteSnapshotProfileComparability(before, after, 'technology');
|
|
427
|
+
const postureComparability = websiteSnapshotProfileComparability(before, after, 'securityPosture');
|
|
428
|
+
const fingerprintComparability = websiteSnapshotProfileComparability(before, after, 'pageFingerprint');
|
|
330
429
|
const changes = [
|
|
331
|
-
...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`]))
|
|
430
|
+
...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), {
|
|
431
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'technology'),
|
|
432
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'technology'),
|
|
433
|
+
}) : [{
|
|
332
434
|
field: 'technology.profileVersion',
|
|
333
435
|
state: technologyComparability === 'detector_changed' ? 'changed' : 'incomparable',
|
|
334
436
|
before: technologyVersionBefore === null ? null : String(technologyVersionBefore),
|
|
335
437
|
after: technologyVersionAfter === null ? null : String(technologyVersionAfter),
|
|
336
438
|
}]),
|
|
337
|
-
...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))
|
|
439
|
+
...(postureComparability === 'comparable' ? compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state])), {
|
|
440
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'posture'),
|
|
441
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'posture'),
|
|
442
|
+
}) : [{
|
|
338
443
|
field: 'posture.profileVersion',
|
|
339
444
|
state: postureComparability === 'detector_changed' ? 'changed' : 'incomparable',
|
|
340
445
|
before: postureVersionBefore === null ? null : String(postureVersionBefore),
|
|
341
446
|
after: postureVersionAfter === null ? null : String(postureVersionAfter),
|
|
342
447
|
}]),
|
|
343
448
|
...compareMap('source', new Map(before.sources.map((item) => [item.source, item.state])), new Map(after.sources.map((item) => [item.source, item.state]))),
|
|
344
|
-
...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`]))
|
|
449
|
+
...compareMap('dependency', new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), new Map(after.dependencies.map((item) => [`${item.recordType}:${item.target}`, `${item.state}|${item.qualification}`])), {
|
|
450
|
+
beforeComplete: (key) => websiteSnapshotFieldComplete(before, `dependency.${key}`),
|
|
451
|
+
afterComplete: (key) => websiteSnapshotFieldComplete(after, `dependency.${key}`),
|
|
452
|
+
}),
|
|
345
453
|
];
|
|
346
454
|
const dependencyTransitions = [];
|
|
347
455
|
const beforeDependencies = new Map(before.dependencies.map((item) => [`${item.recordType}:${item.target}`, item]));
|
|
@@ -365,10 +473,10 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
365
473
|
detail: 'An earlier active dependency is unresolved in the later retained observation. Resolver or collection failure remains possible.',
|
|
366
474
|
});
|
|
367
475
|
}
|
|
368
|
-
else if (!left && right) {
|
|
476
|
+
else if (!left && right && websiteSnapshotFieldComplete(before, `dependency.${key}`)) {
|
|
369
477
|
dependencyTransitions.push({ target: right.target, recordType: right.recordType, state: 'added', detail: 'This dependency first appears in the later retained snapshot.' });
|
|
370
478
|
}
|
|
371
|
-
else if (left && !right && after
|
|
479
|
+
else if (left && !right && websiteSnapshotFieldComplete(after, `dependency.${key}`)) {
|
|
372
480
|
dependencyTransitions.push({ target: left.target, recordType: left.recordType, state: 'removed', detail: 'This dependency is not represented in the later complete snapshot. It may have been intentionally removed or replaced.' });
|
|
373
481
|
}
|
|
374
482
|
if (dependencyTransitions.length >= 20)
|
|
@@ -377,11 +485,30 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
377
485
|
for (const key of Object.keys(before.identity)) {
|
|
378
486
|
const left = before.identity[key];
|
|
379
487
|
const right = after.identity[key];
|
|
488
|
+
if (key !== 'faviconHash' && fingerprintComparability !== 'comparable')
|
|
489
|
+
continue;
|
|
380
490
|
if (left === right)
|
|
381
491
|
continue;
|
|
382
492
|
changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
|
|
383
493
|
}
|
|
384
|
-
|
|
494
|
+
if (fingerprintComparability !== 'comparable') {
|
|
495
|
+
changes.push({
|
|
496
|
+
field: 'identity.profileVersion', state: 'incomparable',
|
|
497
|
+
before: before.profileProvenance.pageFingerprint.version?.toString() ?? null,
|
|
498
|
+
after: after.profileProvenance.pageFingerprint.version?.toString() ?? null,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
else
|
|
502
|
+
changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item])), {
|
|
503
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.resourceHosts'),
|
|
504
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.resourceHosts'),
|
|
505
|
+
}), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), {
|
|
506
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.trackingIdentifiers'),
|
|
507
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.trackingIdentifiers'),
|
|
508
|
+
}), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item])), {
|
|
509
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.formActionOrigins'),
|
|
510
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.formActionOrigins'),
|
|
511
|
+
}));
|
|
385
512
|
if (before.certificate || after.certificate) {
|
|
386
513
|
if (!before.certificate || !after.certificate) {
|
|
387
514
|
changes.push({
|
|
@@ -425,11 +552,22 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
425
552
|
}
|
|
426
553
|
return {
|
|
427
554
|
compatible: true,
|
|
555
|
+
complete: !selectedPage && websiteSnapshotComparisonEvidenceComplete(before)
|
|
556
|
+
&& websiteSnapshotComparisonEvidenceComplete(after)
|
|
557
|
+
&& technologyComparability === 'comparable'
|
|
558
|
+
&& postureComparability === 'comparable'
|
|
559
|
+
&& fingerprintComparability === 'comparable',
|
|
428
560
|
profileComparability: {
|
|
429
561
|
technology: technologyComparability,
|
|
430
562
|
securityPosture: postureComparability,
|
|
563
|
+
pageFingerprint: fingerprintComparability,
|
|
431
564
|
},
|
|
432
|
-
changes
|
|
433
|
-
|
|
565
|
+
changes: selectedPage ? [
|
|
566
|
+
{ field: 'web.collectionTarget', state: 'incomparable', before: before.webObservationMode ?? 'homepage', after: after.webObservationMode ?? 'homepage' },
|
|
567
|
+
...changes.map((change) => change.field.startsWith('certificate.') || change.field.startsWith('source.') || change.field === 'completeness'
|
|
568
|
+
|| /^dependency\.(?:CNAME|HTTPS|NS|MX)(?::|$)/u.test(change.field)
|
|
569
|
+
? change : { ...change, state: 'incomparable' }),
|
|
570
|
+
] : changes,
|
|
571
|
+
dependencyTransitions: selectedPage ? dependencyTransitions.filter((transition) => transition.recordType !== 'HTTP') : dependencyTransitions,
|
|
434
572
|
};
|
|
435
573
|
}
|