@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -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,11 +293,16 @@ 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
|
-
if (!observedAt) {
|
|
289
|
-
throw new Error('A relationship observation needs the contributing scan observation time; an unknown time is not replaced with the retention time.');
|
|
290
|
-
}
|
|
291
306
|
const canonical = canonicalIdentity(type, normalizedValue, domains);
|
|
292
307
|
const metadata = TYPE_METADATA[type];
|
|
293
308
|
return {
|
|
@@ -301,11 +316,12 @@ export function createRelationshipObservation(raw, options = {}) {
|
|
|
301
316
|
description: metadata.description,
|
|
302
317
|
classification: 'derived',
|
|
303
318
|
source: 'bulk_relationship_analysis',
|
|
304
|
-
sourceVersion
|
|
319
|
+
sourceVersion,
|
|
305
320
|
observedAt,
|
|
321
|
+
sourceEvidence: qualified.sourceEvidence,
|
|
306
322
|
retainedAt,
|
|
307
|
-
complete: options.complete
|
|
308
|
-
truncated:
|
|
323
|
+
complete: options.complete !== false && qualified.complete,
|
|
324
|
+
truncated: qualified.truncated
|
|
309
325
|
|| (Array.isArray(raw.domains) && raw.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
|
|
310
326
|
limitations: observationLimitations(options.limitations),
|
|
311
327
|
};
|
|
@@ -359,12 +375,11 @@ export function deleteRelationshipObservation(rawStore, id) {
|
|
|
359
375
|
export function mergeRelationshipObservations(localRaw, importedRaw) {
|
|
360
376
|
assertWorkspaceInputGraph(localRaw, 'Local relationship-observation store');
|
|
361
377
|
assertWorkspaceInputGraph(importedRaw, 'Imported relationship-observation document');
|
|
362
|
-
assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
|
|
378
|
+
const importedVersion = assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
|
|
363
379
|
const importedRecord = record(importedRaw);
|
|
364
380
|
if (importedRecord?.schema !== undefined && importedRecord.schema !== RELATIONSHIP_OBSERVATION_SCHEMA) {
|
|
365
381
|
throw new Error('This JSON section is not a WHOISleuth relationship-observation export.');
|
|
366
382
|
}
|
|
367
|
-
const importedVersion = relationshipObservationStoreVersion(importedRaw);
|
|
368
383
|
if (importedVersion !== null && importedVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
|
|
369
384
|
throw new Error(`This relationship-observation section uses newer schema ${importedVersion}. Update the app before importing it.`);
|
|
370
385
|
}
|
|
@@ -375,7 +390,7 @@ export function mergeRelationshipObservations(localRaw, importedRaw) {
|
|
|
375
390
|
let updated = 0;
|
|
376
391
|
let skipped = Math.max(0, importedInput.length - MAX_RELATIONSHIP_OBSERVATION_INPUTS);
|
|
377
392
|
for (const candidate of importedInput.slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
|
|
378
|
-
const observation = normalizeRelationshipObservation(candidate);
|
|
393
|
+
const observation = normalizeRelationshipObservation(candidate, importedVersion ?? RELATIONSHIP_OBSERVATION_SCHEMA_VERSION);
|
|
379
394
|
if (!observation) {
|
|
380
395
|
skipped += 1;
|
|
381
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
|
}
|
|
@@ -366,6 +384,13 @@ export function websiteSnapshotComparisonEvidenceComplete(snapshot) {
|
|
|
366
384
|
&& websiteSnapshotFieldComplete(snapshot, 'dependency.CNAME')
|
|
367
385
|
&& (!snapshot.certificate || websiteSnapshotFieldComplete(snapshot, 'certificate.observation'));
|
|
368
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
|
+
}
|
|
369
394
|
function compareMap(field, before, after, options = {}) {
|
|
370
395
|
const changes = [];
|
|
371
396
|
for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
@@ -394,15 +419,13 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
394
419
|
};
|
|
395
420
|
}
|
|
396
421
|
const technologyVersionBefore = before.profileProvenance.technology.version;
|
|
422
|
+
const selectedPage = before.webObservationMode === 'selected_url' || after.webObservationMode === 'selected_url';
|
|
397
423
|
const technologyVersionAfter = after.profileProvenance.technology.version;
|
|
398
424
|
const postureVersionBefore = before.profileProvenance.securityPosture.version;
|
|
399
425
|
const postureVersionAfter = after.profileProvenance.securityPosture.version;
|
|
400
|
-
const technologyComparability =
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const postureComparability = postureVersionBefore === null || postureVersionAfter === null
|
|
404
|
-
? 'legacy_unknown'
|
|
405
|
-
: 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');
|
|
406
429
|
const changes = [
|
|
407
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(',')}`])), {
|
|
408
431
|
beforeComplete: () => websiteSnapshotFieldComplete(before, 'technology'),
|
|
@@ -462,20 +485,30 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
462
485
|
for (const key of Object.keys(before.identity)) {
|
|
463
486
|
const left = before.identity[key];
|
|
464
487
|
const right = after.identity[key];
|
|
488
|
+
if (key !== 'faviconHash' && fingerprintComparability !== 'comparable')
|
|
489
|
+
continue;
|
|
465
490
|
if (left === right)
|
|
466
491
|
continue;
|
|
467
492
|
changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
|
|
468
493
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
+
}));
|
|
479
512
|
if (before.certificate || after.certificate) {
|
|
480
513
|
if (!before.certificate || !after.certificate) {
|
|
481
514
|
changes.push({
|
|
@@ -519,15 +552,22 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
519
552
|
}
|
|
520
553
|
return {
|
|
521
554
|
compatible: true,
|
|
522
|
-
complete: websiteSnapshotComparisonEvidenceComplete(before)
|
|
555
|
+
complete: !selectedPage && websiteSnapshotComparisonEvidenceComplete(before)
|
|
523
556
|
&& websiteSnapshotComparisonEvidenceComplete(after)
|
|
524
557
|
&& technologyComparability === 'comparable'
|
|
525
|
-
&& postureComparability === 'comparable'
|
|
558
|
+
&& postureComparability === 'comparable'
|
|
559
|
+
&& fingerprintComparability === 'comparable',
|
|
526
560
|
profileComparability: {
|
|
527
561
|
technology: technologyComparability,
|
|
528
562
|
securityPosture: postureComparability,
|
|
563
|
+
pageFingerprint: fingerprintComparability,
|
|
529
564
|
},
|
|
530
|
-
changes
|
|
531
|
-
|
|
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,
|
|
532
572
|
};
|
|
533
573
|
}
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// This protects a downloaded file while it is locked. It does not encrypt the
|
|
3
3
|
// active IndexedDB workspace or defend an unlocked page from same-origin code.
|
|
4
4
|
import { WORKSPACE_ARCHIVE_SCHEMA, WORKSPACE_ARCHIVE_VERSION, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.mjs";
|
|
5
|
-
import { parseBoundedJson } from "../../lib/bounded-json.mjs";
|
|
5
|
+
import { boundedJsonLimitsForBytes, parseBoundedJson } from "../../lib/bounded-json.mjs";
|
|
6
6
|
import { ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA, ENCRYPTED_WORKSPACE_ARCHIVE_VERSION, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES, MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS, } from "../contracts/case-portability.mjs";
|
|
7
7
|
import { assertWorkspaceInputGraph } from "./hostile-input.mjs";
|
|
8
|
+
import { AES_KEY_BITS, arrayBuffer, assertPassphrase, cryptoProvider, deriveArchiveKey } from "../evidence/passphrase-encryption.mjs";
|
|
8
9
|
export { ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA, ENCRYPTED_WORKSPACE_ARCHIVE_VERSION, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES, MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS, };
|
|
9
10
|
const SALT_BYTES = 16;
|
|
10
11
|
const IV_BYTES = 12;
|
|
11
|
-
const AES_KEY_BITS = 256;
|
|
12
12
|
const AES_GCM_TAG_BITS = 128;
|
|
13
13
|
const MAX_CIPHERTEXT_BASE64URL_CHARACTERS = Math.ceil((MAX_WORKSPACE_ARCHIVE_BYTES + AES_GCM_TAG_BITS / 8) * 4 / 3);
|
|
14
14
|
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
@@ -25,25 +25,6 @@ function hasExactKeys(value, expected) {
|
|
|
25
25
|
const keys = Object.keys(value);
|
|
26
26
|
return keys.length === expected.length && keys.every((key) => expected.includes(key));
|
|
27
27
|
}
|
|
28
|
-
function arrayBuffer(bytes) {
|
|
29
|
-
return bytes.slice().buffer;
|
|
30
|
-
}
|
|
31
|
-
function assertPassphrase(passphrase) {
|
|
32
|
-
if (typeof passphrase !== 'string') {
|
|
33
|
-
throw new Error(`Use a backup passphrase with at least ${MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS} characters.`);
|
|
34
|
-
}
|
|
35
|
-
if (passphrase.length > MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES) {
|
|
36
|
-
throw new Error('Backup passphrases are limited to 1024 UTF-8 bytes.');
|
|
37
|
-
}
|
|
38
|
-
if (Array.from(passphrase).length < MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS) {
|
|
39
|
-
throw new Error(`Use a backup passphrase with at least ${MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS} characters.`);
|
|
40
|
-
}
|
|
41
|
-
const bytes = encoder.encode(passphrase);
|
|
42
|
-
if (bytes.byteLength > MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES) {
|
|
43
|
-
throw new Error('Backup passphrases are limited to 1024 UTF-8 bytes.');
|
|
44
|
-
}
|
|
45
|
-
return bytes;
|
|
46
|
-
}
|
|
47
28
|
function base64url(bytes) {
|
|
48
29
|
let encoded = '';
|
|
49
30
|
for (let index = 0; index < bytes.length; index += 3) {
|
|
@@ -112,7 +93,7 @@ function validTimestamp(value) {
|
|
|
112
93
|
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
|
|
113
94
|
}
|
|
114
95
|
function validateEnvelope(raw) {
|
|
115
|
-
assertWorkspaceInputGraph(raw, 'Encrypted workspace archive');
|
|
96
|
+
assertWorkspaceInputGraph(raw, 'Encrypted workspace archive', { maximumBytes: MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES });
|
|
116
97
|
const value = record(raw);
|
|
117
98
|
if (!value || value.schema !== ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA) {
|
|
118
99
|
throw new Error('This file is not an encrypted WHOISleuth workspace archive.');
|
|
@@ -197,26 +178,6 @@ function validateEnvelope(raw) {
|
|
|
197
178
|
ciphertext,
|
|
198
179
|
};
|
|
199
180
|
}
|
|
200
|
-
function cryptoProvider(provider = globalThis.crypto) {
|
|
201
|
-
if (!provider
|
|
202
|
-
|| typeof provider.getRandomValues !== 'function'
|
|
203
|
-
|| typeof provider.subtle?.importKey !== 'function'
|
|
204
|
-
|| typeof provider.subtle?.deriveKey !== 'function'
|
|
205
|
-
|| typeof provider.subtle?.encrypt !== 'function'
|
|
206
|
-
|| typeof provider.subtle?.decrypt !== 'function') {
|
|
207
|
-
throw new Error('Encrypted workspace archives are unavailable in this browser.');
|
|
208
|
-
}
|
|
209
|
-
return provider;
|
|
210
|
-
}
|
|
211
|
-
async function deriveArchiveKey(provider, passphraseBytes, salt, usages) {
|
|
212
|
-
const material = await provider.subtle.importKey('raw', arrayBuffer(passphraseBytes), 'PBKDF2', false, ['deriveKey']);
|
|
213
|
-
return provider.subtle.deriveKey({
|
|
214
|
-
name: 'PBKDF2',
|
|
215
|
-
hash: 'SHA-256',
|
|
216
|
-
salt: arrayBuffer(salt),
|
|
217
|
-
iterations: WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS,
|
|
218
|
-
}, material, { name: 'AES-GCM', length: AES_KEY_BITS }, false, usages);
|
|
219
|
-
}
|
|
220
181
|
export function isEncryptedWorkspaceArchive(raw) {
|
|
221
182
|
return record(raw)?.schema === ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA;
|
|
222
183
|
}
|
|
@@ -227,15 +188,19 @@ export function inspectEncryptedWorkspaceArchive(raw) {
|
|
|
227
188
|
}
|
|
228
189
|
/** Encrypt one already-built workspace archive using browser-native Web Crypto. */
|
|
229
190
|
export async function encryptWorkspaceArchive(archive, passphrase, provider) {
|
|
230
|
-
|
|
231
|
-
const archiveVersion = record(archive)?.version;
|
|
232
|
-
if (!isSupportedWorkspaceArchiveVersion(archiveVersion)) {
|
|
233
|
-
throw new Error('The workspace archive declares an unsupported content contract.');
|
|
234
|
-
}
|
|
191
|
+
assertWorkspaceInputGraph(archive, 'Workspace archive', { maximumBytes: MAX_WORKSPACE_ARCHIVE_BYTES });
|
|
235
192
|
const plaintext = JSON.stringify(archive);
|
|
236
193
|
const plaintextBytes = encoder.encode(plaintext);
|
|
237
194
|
if (plaintextBytes.byteLength > MAX_WORKSPACE_ARCHIVE_BYTES) {
|
|
238
|
-
throw new Error(
|
|
195
|
+
throw new Error(`Workspace archives are limited to ${MAX_WORKSPACE_ARCHIVE_BYTES / 1024 / 1024} MiB. Export smaller collections separately before trying again.`);
|
|
196
|
+
}
|
|
197
|
+
// Validation and encryption consume the same snapshot, including when the
|
|
198
|
+
// caller changes the original object while checksum verification is pending.
|
|
199
|
+
const snapshot = JSON.parse(plaintext);
|
|
200
|
+
await readWorkspaceArchive(snapshot);
|
|
201
|
+
const archiveVersion = record(snapshot)?.version;
|
|
202
|
+
if (!isSupportedWorkspaceArchiveVersion(archiveVersion)) {
|
|
203
|
+
throw new Error('The workspace archive declares an unsupported content contract.');
|
|
239
204
|
}
|
|
240
205
|
const crypto = cryptoProvider(provider);
|
|
241
206
|
const passphraseBytes = assertPassphrase(passphrase);
|
|
@@ -278,30 +243,45 @@ export async function encryptWorkspaceArchive(archive, passphrase, provider) {
|
|
|
278
243
|
}
|
|
279
244
|
/** Decrypt and parse one encrypted archive. The ordinary archive reader remains authoritative. */
|
|
280
245
|
export async function decryptWorkspaceArchive(raw, passphrase, provider) {
|
|
246
|
+
return (await decryptWorkspaceArchiveWithMetadata(raw, passphrase, provider)).archive;
|
|
247
|
+
}
|
|
248
|
+
/** Authenticate one envelope and return its inner JSON with verified byte metadata. */
|
|
249
|
+
export async function decryptWorkspaceArchiveWithMetadata(raw, passphrase, provider) {
|
|
281
250
|
const { envelope, salt, iv, ciphertext } = validateEnvelope(raw);
|
|
282
251
|
const crypto = cryptoProvider(provider);
|
|
283
252
|
const passphraseBytes = assertPassphrase(passphrase);
|
|
284
253
|
const { ciphertext: _ciphertext, ...metadata } = envelope;
|
|
285
254
|
try {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
255
|
+
let plaintext;
|
|
256
|
+
try {
|
|
257
|
+
const key = await deriveArchiveKey(crypto, passphraseBytes, salt, ['decrypt']);
|
|
258
|
+
plaintext = await crypto.subtle.decrypt({
|
|
259
|
+
name: 'AES-GCM',
|
|
260
|
+
iv: arrayBuffer(iv),
|
|
261
|
+
additionalData: arrayBuffer(encoder.encode(canonicalMetadata(metadata))),
|
|
262
|
+
tagLength: AES_GCM_TAG_BITS,
|
|
263
|
+
}, key, arrayBuffer(ciphertext));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Authentication failure cannot distinguish a wrong key from tampering.
|
|
267
|
+
throw new Error('The backup passphrase is incorrect or the encrypted file is corrupted.');
|
|
268
|
+
}
|
|
293
269
|
if (plaintext.byteLength > MAX_WORKSPACE_ARCHIVE_BYTES) {
|
|
294
270
|
throw new Error('The decrypted workspace archive exceeds its byte limit.');
|
|
295
271
|
}
|
|
296
|
-
|
|
272
|
+
let json;
|
|
273
|
+
try {
|
|
274
|
+
json = decoder.decode(plaintext);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
throw new Error('The backup decrypted, but its contents are not valid UTF-8.');
|
|
278
|
+
}
|
|
279
|
+
const archive = parseBoundedJson(json, {
|
|
297
280
|
label: 'Decrypted workspace archive',
|
|
298
281
|
maximumBytes: MAX_WORKSPACE_ARCHIVE_BYTES,
|
|
282
|
+
limits: boundedJsonLimitsForBytes(MAX_WORKSPACE_ARCHIVE_BYTES),
|
|
299
283
|
});
|
|
300
|
-
|
|
301
|
-
catch (cause) {
|
|
302
|
-
if (cause instanceof Error && cause.message === 'The decrypted workspace archive exceeds its byte limit.')
|
|
303
|
-
throw cause;
|
|
304
|
-
throw new Error('The backup passphrase is incorrect or the encrypted file is corrupted.');
|
|
284
|
+
return { archive, ciphertextBytes: ciphertext.byteLength };
|
|
305
285
|
}
|
|
306
286
|
finally {
|
|
307
287
|
passphraseBytes.fill(0);
|