@slicedearth/whoisleuth-cli 2.3.0 → 2.5.1
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 +8 -2
- 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/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -408
- package/cli/artifact-validation/case-response.mjs +65 -44
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +15 -9
- package/cli/artifact-validation/signed-review.mjs +136 -68
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +92 -24
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +40 -5
- 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 +1 -1
- package/cli/bulk-command-runner.mjs +4 -2
- package/cli/bulk-output.mjs +29 -3
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +212 -0
- package/cli/case-pack.mjs +50 -32
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -206
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +94 -1357
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +8 -6
- package/cli/ct-event-intake.mjs +16 -16
- package/cli/discover.mjs +5 -4
- package/cli/discovery-observation-snapshot.mjs +46 -18
- package/cli/discovery-scan-command-runner.mjs +5 -3
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +5 -5
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +151 -103
- package/cli/errors.mjs +37 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +18 -8
- package/cli/evidence-signing.mjs +5 -4
- package/cli/external-observation-mapping.mjs +9 -10
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +24 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -46
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +8 -10
- 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 +38 -147
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +256 -35
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +33 -39
- package/cli/lookup-diff.mjs +47 -12
- package/cli/lookup-plan.mjs +16 -14
- package/cli/lookup-reconcile.mjs +15 -12
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +75 -21
- package/cli/manual.mjs +1 -0
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +45 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/output-file.mjs +31 -5
- package/cli/posture.mjs +1 -1
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +10 -9
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +104 -149
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +74 -37
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +8 -80
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +75 -84
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +75 -13
- package/docs/cli-reference.md +103 -30
- package/docs/cli.md +510 -25
- package/docs/contextual-reviews.md +155 -0
- package/lib/abort.mjs +4 -1
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +27 -754
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +2 -290
- package/lib/browser-library-profile.mjs +99 -130
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +89 -46
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +36 -22
- package/lib/dns-inheritance-review.mjs +207 -0
- package/lib/dns-intelligence.mjs +44 -29
- package/lib/dns-mx.mjs +1 -1
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +18 -14
- package/lib/domain-change-review.mjs +40 -16
- package/lib/domain-control-flight-recorder.mjs +301 -124
- package/lib/domain-control-manifest.mjs +126 -58
- package/lib/domain-posture-analysis.mjs +41 -11
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +120 -32
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +568 -0
- package/lib/evidence-export.mjs +12 -593
- package/lib/evidence-report.mjs +7 -2
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +50 -4
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +2742 -1034
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +87 -85
- package/lib/http-evidence-bounds.mjs +2 -7
- package/lib/http-intelligence.mjs +52 -27
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +79 -13
- package/lib/lookup-response-contract.mjs +1077 -0
- package/lib/lookup-response.mjs +11 -24
- package/lib/lookup-source-progress.mjs +24 -35
- package/lib/lookup.mjs +49 -28
- package/lib/observed-network-context.mjs +43 -27
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +87 -134
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +79 -24
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +26 -14
- 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 +2 -36
- package/lib/registrar-standing-contract.mjs +2 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +7 -3
- package/lib/registry-capability-catalogue.mjs +29 -20
- 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 +2 -338
- package/lib/risk-scoring.mjs +2 -345
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/security-txt.mjs +10 -7
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +35 -0
- package/lib/smtp-transport-review.mjs +75 -34
- package/lib/sslbl-certificates.generated.mjs +141 -138
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +2 -1
- 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 +2 -36
- package/lib/typosquat-generator.mjs +29 -16
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-enrichment.mjs +388 -0
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +55 -5
- 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-normalization.mjs +3 -1
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-parser.mjs +5 -3
- package/lib/whois-transport.mjs +23 -7
- package/package.json +14 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +229 -69
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +55 -0
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +98 -48
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +149 -0
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +4 -3
- package/packages/cases/case-record-operations.mjs +123 -133
- package/packages/cases/case-record-projection.mjs +30 -20
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +114 -67
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +756 -0
- package/packages/cases/case-response-model.mjs +49 -1129
- package/packages/cases/case-response-outcomes.mjs +338 -0
- package/packages/cases/case-response-packet-render.mjs +157 -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 +58 -593
- package/packages/cases/case-response-records.mjs +93 -0
- package/packages/cases/case-response-review-inputs.mjs +382 -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 +26 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- 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/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +3 -2
- package/packages/comparison/relationship-evidence.mjs +103 -14
- package/packages/comparison/relationship-provenance.mjs +99 -0
- package/packages/contracts/analyst-interchange.mjs +234 -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 +370 -114
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
- package/packages/contracts/domain-control-manifest.mjs +128 -74
- package/packages/contracts/domain-control-monitor.mjs +59 -45
- package/packages/contracts/domain-control-review.mjs +200 -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/har-review.mjs +2 -0
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +18 -0
- package/packages/contracts/ip-address.mjs +44 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/passive-mail.mjs +10 -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 +219 -238
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +108 -0
- package/packages/evidence/observation.mjs +18 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -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/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +93 -61
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +389 -0
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +9 -14
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- 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/link-intake.mjs +179 -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/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +160 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +39 -31
- package/packages/workspace/bulk-session-model.mjs +112 -27
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +45 -6
- package/packages/workspace/page-baseline.mjs +20 -17
- package/packages/workspace/relationship-observation-model.mjs +54 -35
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +30 -14
- package/packages/workspace/watchlist-store.mjs +24 -6
- package/packages/workspace/website-snapshot-model.mjs +98 -33
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +90 -25
- package/third-party-notices.txt +577 -17
- package/lib/hostname.mjs +0 -38
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// the watchlist entry for rescanning, while history stores compact changes
|
|
3
3
|
// against a last-known baseline so browser-local storage does not grow by one complete
|
|
4
4
|
// result set on every check.
|
|
5
|
-
import { explainRiskScore, normalizeRiskModelVersion } from "
|
|
5
|
+
import { explainRiskScore, normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
|
|
6
6
|
import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "../cases/http-summary.mjs";
|
|
7
|
-
import { normalizeDomain } from "../
|
|
7
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
8
8
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
9
|
+
import { registryDateIso } from "../evidence/registry-dates.mjs";
|
|
10
|
+
import { mergeWebCollectionQuality, normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
|
|
9
11
|
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
12
|
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
13
|
const MAX_TEXT_LENGTH = 300;
|
|
@@ -144,9 +146,6 @@ function boundedText(value, maximum = MAX_TEXT_LENGTH, allowNull = true) {
|
|
|
144
146
|
const normalized = value.slice(0, maximum * 4).replace(/\s+/g, ' ').trim().slice(0, maximum).trim();
|
|
145
147
|
return normalized || (allowNull ? null : '');
|
|
146
148
|
}
|
|
147
|
-
function isoTimestamp(value, fallback = new Date(0).toISOString()) {
|
|
148
|
-
return normalizeExplicitIsoTimestamp(value) ?? fallback;
|
|
149
|
-
}
|
|
150
149
|
function boundedInteger(value, maximum, fallback = 0) {
|
|
151
150
|
return Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= maximum
|
|
152
151
|
? Number(value)
|
|
@@ -155,8 +154,7 @@ function boundedInteger(value, maximum, fallback = 0) {
|
|
|
155
154
|
function normalizeDateValue(value) {
|
|
156
155
|
if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
|
|
157
156
|
return null;
|
|
158
|
-
|
|
159
|
-
return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
|
|
157
|
+
return registryDateIso(value)?.slice(0, 10) ?? null;
|
|
160
158
|
}
|
|
161
159
|
function inferredScanDepth(record) {
|
|
162
160
|
if (record.scanDepth === 'deep' || record.scanDepth === 'fast')
|
|
@@ -205,11 +203,14 @@ function compactRecord(value) {
|
|
|
205
203
|
if (!domain)
|
|
206
204
|
return null;
|
|
207
205
|
const scanDepth = inferredScanDepth(record);
|
|
206
|
+
const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
|
|
208
207
|
const httpSummary = normalizeHttpSummary(record) || {};
|
|
209
208
|
const providedRiskScore = typeof record.riskScore === 'number' && Number.isFinite(record.riskScore)
|
|
210
209
|
? Math.max(0, Math.min(100, Math.round(record.riskScore)))
|
|
211
210
|
: null;
|
|
212
|
-
const computedRisk = scanDepth === 'deep' && providedRiskScore === null
|
|
211
|
+
const computedRisk = scanDepth === 'deep' && providedRiskScore === null
|
|
212
|
+
&& (!webCollectionQuality || (record.riskScore === undefined
|
|
213
|
+
&& webCollectionAllowsComparison('riskScore', webCollectionQuality, scanDepth))) ? explainRiskScore(record) : null;
|
|
213
214
|
const riskScore = scanDepth === 'deep' ? (providedRiskScore ?? computedRisk?.score ?? null) : null;
|
|
214
215
|
const riskModelVersion = riskScore === null
|
|
215
216
|
? null
|
|
@@ -219,6 +220,7 @@ function compactRecord(value) {
|
|
|
219
220
|
return {
|
|
220
221
|
domain,
|
|
221
222
|
scanDepth,
|
|
223
|
+
...(webCollectionQuality ? { webCollectionQuality } : {}),
|
|
222
224
|
availability: hasString(AVAILABILITY_VALUES, record.availability) ? record.availability : null,
|
|
223
225
|
registrarName: boundedText(record.registrarName),
|
|
224
226
|
nameservers: normalizeNameservers(record.nameservers),
|
|
@@ -260,6 +262,8 @@ export function compactWatchlistResults(results) {
|
|
|
260
262
|
function isComparable(field, value, record) {
|
|
261
263
|
if (DEEP_FIELDS.has(field) && record.scanDepth !== 'deep')
|
|
262
264
|
return false;
|
|
265
|
+
if (!webCollectionAllowsComparison(field, record.webCollectionQuality, record.scanDepth))
|
|
266
|
+
return false;
|
|
263
267
|
if (field === 'availability')
|
|
264
268
|
return hasString(CONCLUSIVE_AVAILABILITY, value);
|
|
265
269
|
if (field === 'nameservers')
|
|
@@ -358,7 +362,7 @@ export function mergeWatchlistBaseline(baseline, current) {
|
|
|
358
362
|
return current.map((next) => {
|
|
359
363
|
const previous = previousByDomain.get(next.domain);
|
|
360
364
|
const updated = {
|
|
361
|
-
...(previous ||
|
|
365
|
+
...(previous || next),
|
|
362
366
|
domain: next.domain,
|
|
363
367
|
scanDepth: previous?.scanDepth === 'deep' || next.scanDepth === 'deep' ? 'deep' : 'fast',
|
|
364
368
|
};
|
|
@@ -370,9 +374,12 @@ export function mergeWatchlistBaseline(baseline, current) {
|
|
|
370
374
|
// mean "unversioned" to the comparator, but retaining the key makes the
|
|
371
375
|
// stored/exported contract unambiguous and prevents consumers from having
|
|
372
376
|
// to infer why a score was deliberately excluded from comparison.
|
|
373
|
-
if (next.scanDepth === 'deep' && typeof next.riskScore === 'number') {
|
|
377
|
+
if (next.scanDepth === 'deep' && typeof next.riskScore === 'number' && isComparable('riskScore', next.riskScore, next)) {
|
|
374
378
|
updated.riskModelVersion = normalizeRiskModelVersion(next.riskModelVersion);
|
|
375
379
|
}
|
|
380
|
+
const quality = mergeWebCollectionQuality(previous?.webCollectionQuality, next.webCollectionQuality);
|
|
381
|
+
if (quality)
|
|
382
|
+
updated.webCollectionQuality = quality;
|
|
376
383
|
return updated;
|
|
377
384
|
});
|
|
378
385
|
}
|
|
@@ -452,12 +459,13 @@ function normalizeStoredFieldValue(field, value) {
|
|
|
452
459
|
}
|
|
453
460
|
function initialHistoryEvent(entry, baseline) {
|
|
454
461
|
return {
|
|
455
|
-
checkedAt: entry.updatedAt
|
|
462
|
+
checkedAt: entry.updatedAt,
|
|
456
463
|
mode: 'saved',
|
|
457
464
|
resultCount: Array.isArray(entry.results) ? entry.results.length : baseline.length,
|
|
458
465
|
conclusiveCount: baseline.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
|
|
459
466
|
changeCount: 0,
|
|
460
467
|
omittedChanges: 0,
|
|
468
|
+
webComparisonLimitedCount: limitedWebComparisonCount(baseline),
|
|
461
469
|
changes: [],
|
|
462
470
|
};
|
|
463
471
|
}
|
|
@@ -480,7 +488,7 @@ export function normalizeWatchlistEntry(entry) {
|
|
|
480
488
|
: [];
|
|
481
489
|
const rawMode = event.mode;
|
|
482
490
|
return {
|
|
483
|
-
checkedAt:
|
|
491
|
+
checkedAt: normalizeExplicitIsoTimestamp(event.checkedAt),
|
|
484
492
|
mode: typeof rawMode === 'string' && ['fast', 'deep', 'saved'].includes(rawMode)
|
|
485
493
|
? rawMode
|
|
486
494
|
: 'saved',
|
|
@@ -488,12 +496,14 @@ export function normalizeWatchlistEntry(entry) {
|
|
|
488
496
|
conclusiveCount: boundedInteger(event.conclusiveCount, MAX_WATCHLIST_DOMAINS),
|
|
489
497
|
changeCount: boundedInteger(event.changeCount, MAX_CHANGE_COUNT, changes.length),
|
|
490
498
|
omittedChanges: boundedInteger(event.omittedChanges, MAX_CHANGE_COUNT),
|
|
499
|
+
...(event.webComparisonLimitedCount !== undefined
|
|
500
|
+
? { webComparisonLimitedCount: boundedInteger(event.webComparisonLimitedCount, MAX_WATCHLIST_DOMAINS) } : {}),
|
|
491
501
|
changes,
|
|
492
502
|
};
|
|
493
503
|
}).slice(-MAX_WATCHLIST_HISTORY_EVENTS)
|
|
494
504
|
: [];
|
|
495
505
|
const normalized = {
|
|
496
|
-
updatedAt:
|
|
506
|
+
updatedAt: normalizeExplicitIsoTimestamp(input.updatedAt),
|
|
497
507
|
results,
|
|
498
508
|
baseline,
|
|
499
509
|
history,
|
|
@@ -502,13 +512,18 @@ export function normalizeWatchlistEntry(entry) {
|
|
|
502
512
|
normalized.history.push(initialHistoryEvent(normalized, baseline));
|
|
503
513
|
return normalized;
|
|
504
514
|
}
|
|
515
|
+
function limitedWebComparisonCount(current, baseline = []) {
|
|
516
|
+
const previous = new Map(baseline.map(record => [record.domain, record]));
|
|
517
|
+
return current.filter(record => record.scanDepth === 'deep' && (!webCollectionAllowsComparison('riskScore', record.webCollectionQuality, record.scanDepth)
|
|
518
|
+
|| (previous.has(record.domain) && !webCollectionAllowsComparison('riskScore', previous.get(record.domain).webCollectionQuality, previous.get(record.domain).scanDepth)))).length;
|
|
519
|
+
}
|
|
505
520
|
/**
|
|
506
521
|
* @param {object | null} existingEntry
|
|
507
522
|
* @param {object[]} results
|
|
508
523
|
* @param {{ checkedAt?: string, mode?: string, ignoredDomains?: Set<string> }} [options]
|
|
509
524
|
*/
|
|
510
525
|
export function appendWatchlistScan(existingEntry, results, options = {}) {
|
|
511
|
-
const checkedAt =
|
|
526
|
+
const checkedAt = options.checkedAt === undefined ? new Date().toISOString() : normalizeExplicitIsoTimestamp(options.checkedAt);
|
|
512
527
|
const mode = typeof options.mode === 'string' && ['fast', 'deep', 'saved'].includes(options.mode)
|
|
513
528
|
? options.mode
|
|
514
529
|
: 'saved';
|
|
@@ -525,6 +540,7 @@ export function appendWatchlistScan(existingEntry, results, options = {}) {
|
|
|
525
540
|
conclusiveCount: current.filter((record) => hasString(CONCLUSIVE_AVAILABILITY, record.availability)).length,
|
|
526
541
|
changeCount: changes.length,
|
|
527
542
|
omittedChanges: Math.max(0, changes.length - storedChanges.length),
|
|
543
|
+
webComparisonLimitedCount: limitedWebComparisonCount(current, previous?.baseline),
|
|
528
544
|
changes: storedChanges,
|
|
529
545
|
};
|
|
530
546
|
const history = [...(previous?.history || []), event].slice(-MAX_WATCHLIST_HISTORY_EVENTS);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { MAX_WATCHLIST_DOMAINS, normalizeWatchlistEntry } from "./watchlist-history.mjs";
|
|
5
5
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
6
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
7
|
-
import { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
7
|
+
import { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, WATCHLIST_BROWSER_SUPPORTED_VERSIONS, WATCHLIST_EXPORT_SUPPORTED_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
8
8
|
export { MAX_WATCHLIST_INPUTS, MAX_WATCHLIST_NAME_LENGTH, MAX_WATCHLISTS, MAX_WATCHLIST_STORE_BYTES, WATCHLIST_SCHEMA, WATCHLIST_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
|
|
9
9
|
const BLOCKED_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
|
|
10
10
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
@@ -39,12 +39,21 @@ export function watchlistStoreVersion(raw) {
|
|
|
39
39
|
function defineEntry(target, name, entry) {
|
|
40
40
|
Object.defineProperty(target, name, { value: entry, writable: true, enumerable: true, configurable: true });
|
|
41
41
|
}
|
|
42
|
+
function assertHistoricalQuality(entry, version) {
|
|
43
|
+
if (version !== 2)
|
|
44
|
+
return;
|
|
45
|
+
for (const values of [entry.results, entry.baseline]) {
|
|
46
|
+
if (Array.isArray(values) && values.some(value => plainRecord(value)?.webCollectionQuality !== undefined)) {
|
|
47
|
+
throw new TypeError('Web collection quality requires Watchlist schema 3 or later; historical evidence was not reinterpreted.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
42
51
|
export function normalizeWatchlistStore(raw) {
|
|
43
52
|
assertWorkspaceInputGraph(raw, 'Watchlist store');
|
|
44
53
|
const root = plainRecord(raw);
|
|
45
54
|
if (root?.schema === WATCHLIST_SCHEMA)
|
|
46
55
|
assertWorkspaceDeclaredVersion(raw, 'Watchlist store');
|
|
47
|
-
if (root?.schema === WATCHLIST_SCHEMA && watchlistStoreVersion(root)
|
|
56
|
+
if (root?.schema === WATCHLIST_SCHEMA && !WATCHLIST_BROWSER_SUPPORTED_VERSIONS.includes(Number(watchlistStoreVersion(root)))) {
|
|
48
57
|
throw new Error(`Watchlist schema ${String(root.version)} is unsupported; no data was changed.`);
|
|
49
58
|
}
|
|
50
59
|
const source = watchlistMap(raw);
|
|
@@ -56,6 +65,7 @@ export function normalizeWatchlistStore(raw) {
|
|
|
56
65
|
const entry = plainRecord(rawEntry);
|
|
57
66
|
if (!name || !entry || !Array.isArray(entry.results) || entry.results.length > MAX_WATCHLIST_DOMAINS)
|
|
58
67
|
continue;
|
|
68
|
+
assertHistoricalQuality(entry, root?.schema === WATCHLIST_SCHEMA ? watchlistStoreVersion(root) : null);
|
|
59
69
|
defineEntry(watchlists, name, normalizeWatchlistEntry(entry));
|
|
60
70
|
if (Object.keys(watchlists).length >= MAX_WATCHLISTS)
|
|
61
71
|
break;
|
|
@@ -93,8 +103,8 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
|
|
|
93
103
|
if (importedVersion !== null && importedVersion > WATCHLIST_SCHEMA_VERSION) {
|
|
94
104
|
throw new Error(`This watchlist file uses newer schema ${importedVersion}. Update the app before importing it.`);
|
|
95
105
|
}
|
|
96
|
-
if (importedVersion
|
|
97
|
-
throw new Error(
|
|
106
|
+
if (!WATCHLIST_EXPORT_SUPPORTED_VERSIONS.includes(Number(importedVersion))) {
|
|
107
|
+
throw new Error('Expected a supported WHOISleuth watchlist export.');
|
|
98
108
|
}
|
|
99
109
|
const local = normalizeWatchlistStore(localRaw).watchlists;
|
|
100
110
|
const source = watchlistMap(importedRaw) || {};
|
|
@@ -109,15 +119,23 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
|
|
|
109
119
|
skipped++;
|
|
110
120
|
continue;
|
|
111
121
|
}
|
|
112
|
-
|
|
122
|
+
assertHistoricalQuality(entry, importedVersion);
|
|
123
|
+
const normalized = normalizeWatchlistEntry(entry);
|
|
124
|
+
if (Object.prototype.hasOwnProperty.call(local, name)) {
|
|
125
|
+
const localTime = local[name].updatedAt;
|
|
126
|
+
if (!normalized.updatedAt || !localTime || normalized.updatedAt <= localTime) {
|
|
127
|
+
skipped++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
113
130
|
updated++;
|
|
131
|
+
}
|
|
114
132
|
else if (Object.keys(local).length >= MAX_WATCHLISTS) {
|
|
115
133
|
skipped++;
|
|
116
134
|
continue;
|
|
117
135
|
}
|
|
118
136
|
else
|
|
119
137
|
added++;
|
|
120
|
-
defineEntry(local, name,
|
|
138
|
+
defineEntry(local, name, normalized);
|
|
121
139
|
}
|
|
122
140
|
return { watchlists: local, added, updated, skipped };
|
|
123
141
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { normalizeDomain } from "../
|
|
1
|
+
import { normalizeDomain } from "../evidence/domain-name.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
|
}
|
|
@@ -284,17 +302,42 @@ export function mergeWebsiteSnapshots(localRaw, incomingRaw) {
|
|
|
284
302
|
const local = normalizeWebsiteSnapshotStore(localRaw).snapshots;
|
|
285
303
|
const incoming = normalizeWebsiteSnapshotStore(incomingRaw).snapshots;
|
|
286
304
|
const byId = new Map(local.map((item) => [item.id, item]));
|
|
305
|
+
const perDomain = new Map();
|
|
306
|
+
for (const item of local)
|
|
307
|
+
perDomain.set(item.domain, (perDomain.get(item.domain) ?? 0) + 1);
|
|
308
|
+
const byteLength = (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
309
|
+
let bytes = byteLength({ schema: WEBSITE_SNAPSHOT_SCHEMA, version: WEBSITE_SNAPSHOT_SCHEMA_VERSION, snapshots: local });
|
|
310
|
+
if (bytes > MAX_WEBSITE_SNAPSHOT_STORE_BYTES)
|
|
311
|
+
throw new Error('Existing website snapshots exceed the storage limit; no imported changes were applied.');
|
|
287
312
|
let added = 0;
|
|
288
313
|
let updated = 0;
|
|
314
|
+
const supplied = record(incomingRaw)?.snapshots;
|
|
315
|
+
let skipped = Array.isArray(supplied) ? supplied.length - incoming.length : 0;
|
|
316
|
+
let capacitySkipped = 0;
|
|
289
317
|
for (const item of incoming) {
|
|
290
|
-
|
|
318
|
+
const existing = byId.get(item.id);
|
|
319
|
+
const targetCount = (perDomain.get(item.domain) ?? 0) - Number(existing?.domain === item.domain);
|
|
320
|
+
const delta = byteLength(item) - (existing ? byteLength(existing) : 0) + (!existing && byId.size ? 1 : 0);
|
|
321
|
+
if ((!existing && byId.size >= MAX_WEBSITE_SNAPSHOTS)
|
|
322
|
+
|| targetCount >= MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN
|
|
323
|
+
|| bytes + delta > MAX_WEBSITE_SNAPSHOT_STORE_BYTES) {
|
|
324
|
+
skipped += 1;
|
|
325
|
+
capacitySkipped += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (existing) {
|
|
291
329
|
updated += 1;
|
|
330
|
+
perDomain.set(existing.domain, (perDomain.get(existing.domain) ?? 1) - 1);
|
|
331
|
+
}
|
|
292
332
|
else
|
|
293
333
|
added += 1;
|
|
294
334
|
byId.set(item.id, item);
|
|
335
|
+
perDomain.set(item.domain, (perDomain.get(item.domain) ?? 0) + 1);
|
|
336
|
+
bytes += delta;
|
|
295
337
|
}
|
|
296
338
|
const snapshots = normalizeWebsiteSnapshotStore([...byId.values()]).snapshots;
|
|
297
|
-
return { snapshots, added, updated, skipped
|
|
339
|
+
return { snapshots, added, updated, skipped,
|
|
340
|
+
reason: capacitySkipped ? `${capacitySkipped} imported website snapshots did not fit the remaining per-domain or total storage capacity. Existing local snapshots are preserved.` : '' };
|
|
298
341
|
}
|
|
299
342
|
const MAX_RECONCILED_WEBSITE_SOURCES = 16;
|
|
300
343
|
const UNAVAILABLE_WEBSITE_SOURCE_STATES = new Set([
|
|
@@ -366,6 +409,13 @@ export function websiteSnapshotComparisonEvidenceComplete(snapshot) {
|
|
|
366
409
|
&& websiteSnapshotFieldComplete(snapshot, 'dependency.CNAME')
|
|
367
410
|
&& (!snapshot.certificate || websiteSnapshotFieldComplete(snapshot, 'certificate.observation'));
|
|
368
411
|
}
|
|
412
|
+
export function websiteSnapshotProfileComparability(before, after, profile) {
|
|
413
|
+
const left = before.profileProvenance[profile].version;
|
|
414
|
+
const right = after.profileProvenance[profile].version;
|
|
415
|
+
if (left === null || right === null)
|
|
416
|
+
return 'legacy_unknown';
|
|
417
|
+
return left === right ? 'comparable' : 'detector_changed';
|
|
418
|
+
}
|
|
369
419
|
function compareMap(field, before, after, options = {}) {
|
|
370
420
|
const changes = [];
|
|
371
421
|
for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
@@ -394,15 +444,13 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
394
444
|
};
|
|
395
445
|
}
|
|
396
446
|
const technologyVersionBefore = before.profileProvenance.technology.version;
|
|
447
|
+
const selectedPage = before.webObservationMode === 'selected_url' || after.webObservationMode === 'selected_url';
|
|
397
448
|
const technologyVersionAfter = after.profileProvenance.technology.version;
|
|
398
449
|
const postureVersionBefore = before.profileProvenance.securityPosture.version;
|
|
399
450
|
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';
|
|
451
|
+
const technologyComparability = websiteSnapshotProfileComparability(before, after, 'technology');
|
|
452
|
+
const postureComparability = websiteSnapshotProfileComparability(before, after, 'securityPosture');
|
|
453
|
+
const fingerprintComparability = websiteSnapshotProfileComparability(before, after, 'pageFingerprint');
|
|
406
454
|
const changes = [
|
|
407
455
|
...(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
456
|
beforeComplete: () => websiteSnapshotFieldComplete(before, 'technology'),
|
|
@@ -462,20 +510,30 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
462
510
|
for (const key of Object.keys(before.identity)) {
|
|
463
511
|
const left = before.identity[key];
|
|
464
512
|
const right = after.identity[key];
|
|
513
|
+
if (key !== 'faviconHash' && fingerprintComparability !== 'comparable')
|
|
514
|
+
continue;
|
|
465
515
|
if (left === right)
|
|
466
516
|
continue;
|
|
467
517
|
changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
|
|
468
518
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
519
|
+
if (fingerprintComparability !== 'comparable') {
|
|
520
|
+
changes.push({
|
|
521
|
+
field: 'identity.profileVersion', state: 'incomparable',
|
|
522
|
+
before: before.profileProvenance.pageFingerprint.version?.toString() ?? null,
|
|
523
|
+
after: after.profileProvenance.pageFingerprint.version?.toString() ?? null,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
else
|
|
527
|
+
changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item])), {
|
|
528
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.resourceHosts'),
|
|
529
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.resourceHosts'),
|
|
530
|
+
}), ...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])), {
|
|
531
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.trackingIdentifiers'),
|
|
532
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.trackingIdentifiers'),
|
|
533
|
+
}), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item])), {
|
|
534
|
+
beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.formActionOrigins'),
|
|
535
|
+
afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.formActionOrigins'),
|
|
536
|
+
}));
|
|
479
537
|
if (before.certificate || after.certificate) {
|
|
480
538
|
if (!before.certificate || !after.certificate) {
|
|
481
539
|
changes.push({
|
|
@@ -519,15 +577,22 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
|
|
|
519
577
|
}
|
|
520
578
|
return {
|
|
521
579
|
compatible: true,
|
|
522
|
-
complete: websiteSnapshotComparisonEvidenceComplete(before)
|
|
580
|
+
complete: !selectedPage && websiteSnapshotComparisonEvidenceComplete(before)
|
|
523
581
|
&& websiteSnapshotComparisonEvidenceComplete(after)
|
|
524
582
|
&& technologyComparability === 'comparable'
|
|
525
|
-
&& postureComparability === 'comparable'
|
|
583
|
+
&& postureComparability === 'comparable'
|
|
584
|
+
&& fingerprintComparability === 'comparable',
|
|
526
585
|
profileComparability: {
|
|
527
586
|
technology: technologyComparability,
|
|
528
587
|
securityPosture: postureComparability,
|
|
588
|
+
pageFingerprint: fingerprintComparability,
|
|
529
589
|
},
|
|
530
|
-
changes
|
|
531
|
-
|
|
590
|
+
changes: selectedPage ? [
|
|
591
|
+
{ field: 'web.collectionTarget', state: 'incomparable', before: before.webObservationMode ?? 'homepage', after: after.webObservationMode ?? 'homepage' },
|
|
592
|
+
...changes.map((change) => change.field.startsWith('certificate.') || change.field.startsWith('source.') || change.field === 'completeness'
|
|
593
|
+
|| /^dependency\.(?:CNAME|HTTPS|NS|MX)(?::|$)/u.test(change.field)
|
|
594
|
+
? change : { ...change, state: 'incomparable' }),
|
|
595
|
+
] : changes,
|
|
596
|
+
dependencyTransitions: selectedPage ? dependencyTransitions.filter((transition) => transition.recordType !== 'HTTP') : dependencyTransitions,
|
|
532
597
|
};
|
|
533
598
|
}
|
|
@@ -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 "
|
|
5
|
+
import { boundedJsonLimitsForBytes, parseBoundedJson } from "../analysis/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);
|