@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { array, enumeration, exact, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
|
|
2
|
+
import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
|
|
3
|
+
import { MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
|
|
4
|
+
import { SAFE_ID_RE, freshId } from "./case-response-values.mjs";
|
|
5
|
+
export const CASE_EVIDENCE_LINK_KINDS = ['derived_from', 'shared_source'];
|
|
6
|
+
/** Enough for both directed relationships between every admitted pin pair. */
|
|
7
|
+
export const MAX_CASE_EVIDENCE_LINKS = MAX_CASE_EVIDENCE_PINS * (MAX_CASE_EVIDENCE_PINS - 1) * CASE_EVIDENCE_LINK_KINDS.length;
|
|
8
|
+
function identifier(value) {
|
|
9
|
+
const id = text(value, 'Evidence link identity', 64);
|
|
10
|
+
if (!SAFE_ID_RE.test(id))
|
|
11
|
+
throw new TypeError('Evidence link identity is invalid.');
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
function explanation(value) {
|
|
15
|
+
const result = text(value, 'Evidence relationship basis', MAX_RESPONSE_RATIONALE_LENGTH).trim();
|
|
16
|
+
if (!result)
|
|
17
|
+
throw new TypeError('Explain the evidence relationship.');
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
export function readCaseEvidenceLinks(value) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
assertWorkspaceInputGraph(value, 'Evidence relationships');
|
|
24
|
+
const links = array(value, 'Evidence relationships', MAX_CASE_EVIDENCE_LINKS).map(raw => {
|
|
25
|
+
const row = exactOptional(raw, ['id', 'fromPinId', 'toPinId', 'kind', 'basis', 'createdAt'], ['withdrawal'], 'Evidence relationship');
|
|
26
|
+
const fromPinId = identifier(row.fromPinId), toPinId = identifier(row.toPinId);
|
|
27
|
+
if (fromPinId === toPinId)
|
|
28
|
+
throw new TypeError('An evidence pin cannot derive from or share a source with itself.');
|
|
29
|
+
iso(row.createdAt, 'Relationship recording time');
|
|
30
|
+
let withdrawal;
|
|
31
|
+
if (row.withdrawal !== undefined) {
|
|
32
|
+
const item = exact(row.withdrawal, ['at', 'reason'], 'Relationship withdrawal');
|
|
33
|
+
iso(item.at, 'Withdrawal time');
|
|
34
|
+
if (Date.parse(item.at) < Date.parse(row.createdAt))
|
|
35
|
+
throw new TypeError('Withdrawal precedes the relationship.');
|
|
36
|
+
withdrawal = { at: item.at, reason: explanation(item.reason) };
|
|
37
|
+
}
|
|
38
|
+
return { id: identifier(row.id), fromPinId, toPinId,
|
|
39
|
+
kind: enumeration(row.kind, CASE_EVIDENCE_LINK_KINDS, 'Evidence relationship kind'),
|
|
40
|
+
basis: explanation(row.basis), createdAt: row.createdAt, ...(withdrawal ? { withdrawal } : {}) };
|
|
41
|
+
});
|
|
42
|
+
if (new Set(links.map(link => link.id)).size !== links.length)
|
|
43
|
+
throw new TypeError('Evidence relationship identities must be unique.');
|
|
44
|
+
return links;
|
|
45
|
+
}
|
|
46
|
+
function derivationGraph(links) {
|
|
47
|
+
const graph = new Map();
|
|
48
|
+
for (const link of links)
|
|
49
|
+
if (!link.withdrawal && link.kind === 'derived_from') {
|
|
50
|
+
const neighbours = graph.get(link.fromPinId) ?? [];
|
|
51
|
+
neighbours.push(link.toPinId);
|
|
52
|
+
graph.set(link.fromPinId, neighbours);
|
|
53
|
+
}
|
|
54
|
+
return graph;
|
|
55
|
+
}
|
|
56
|
+
function reaches(graph, from, to) {
|
|
57
|
+
const pending = [from], visited = new Set();
|
|
58
|
+
while (pending.length) {
|
|
59
|
+
const id = pending.pop();
|
|
60
|
+
if (id === to)
|
|
61
|
+
return true;
|
|
62
|
+
if (visited.has(id))
|
|
63
|
+
continue;
|
|
64
|
+
visited.add(id);
|
|
65
|
+
pending.push(...(graph.get(id) ?? []));
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
export function appendCaseEvidenceLink(current, input, pins, now) {
|
|
70
|
+
const row = exact(input, ['fromPinId', 'toPinId', 'kind', 'basis'], 'New evidence relationship');
|
|
71
|
+
const link = readCaseEvidenceLinks([{ ...row, id: freshId('evidence-link'), createdAt: now }])[0];
|
|
72
|
+
const ids = new Set(pins.map(pin => pin.id));
|
|
73
|
+
if (!ids.has(link.fromPinId) || !ids.has(link.toPinId))
|
|
74
|
+
throw new TypeError('Select two retained evidence pins.');
|
|
75
|
+
if (current.some(item => !item.withdrawal && item.kind === link.kind
|
|
76
|
+
&& ((item.fromPinId === link.fromPinId && item.toPinId === link.toPinId)
|
|
77
|
+
|| (link.kind === 'shared_source' && item.fromPinId === link.toPinId && item.toPinId === link.fromPinId)))) {
|
|
78
|
+
throw new TypeError('This evidence relationship is already recorded. Withdraw it before recording a replacement.');
|
|
79
|
+
}
|
|
80
|
+
if (link.kind === 'derived_from' && reaches(derivationGraph(current), link.toPinId, link.fromPinId))
|
|
81
|
+
throw new TypeError('This derivation would create a cycle. Review the existing links first.');
|
|
82
|
+
return readCaseEvidenceLinks([...current, link]);
|
|
83
|
+
}
|
|
84
|
+
export function withdrawCaseEvidenceLink(current, input, now) {
|
|
85
|
+
const row = exact(input, ['id', 'reason'], 'Evidence relationship withdrawal');
|
|
86
|
+
const id = identifier(row.id), existing = current.find(link => link.id === id);
|
|
87
|
+
if (!existing || existing.withdrawal)
|
|
88
|
+
throw new TypeError('This relationship is missing or already withdrawn. Refresh before reviewing it.');
|
|
89
|
+
return readCaseEvidenceLinks(current.map(link => link.id === id ? { ...link, withdrawal: { at: now, reason: explanation(row.reason) } } : link));
|
|
90
|
+
}
|
|
91
|
+
/** Keep unresolved references; an imported pin can have been omitted by its source. */
|
|
92
|
+
export function mergeCaseEvidenceLinks(local, incoming) {
|
|
93
|
+
if (local === undefined && incoming === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
const merged = new Map((readCaseEvidenceLinks(local) ?? []).map(link => [link.id, link]));
|
|
96
|
+
for (const link of readCaseEvidenceLinks(incoming) ?? []) {
|
|
97
|
+
const existing = merged.get(link.id);
|
|
98
|
+
if (!existing) {
|
|
99
|
+
merged.set(link.id, link);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const { withdrawal: left, ...a } = existing, { withdrawal: right, ...b } = link;
|
|
103
|
+
if (JSON.stringify(a) !== JSON.stringify(b) || (left && right && JSON.stringify(left) !== JSON.stringify(right))) {
|
|
104
|
+
throw new TypeError('An imported evidence relationship conflicts with retained provenance. No relationship was overwritten.');
|
|
105
|
+
}
|
|
106
|
+
merged.set(link.id, { ...a, ...((left ?? right) ? { withdrawal: left ?? right } : {}) });
|
|
107
|
+
}
|
|
108
|
+
return readCaseEvidenceLinks([...merged.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
109
|
+
}
|
|
110
|
+
export function caseEvidenceLinkIssues(links, pins) {
|
|
111
|
+
const ids = new Set(pins.map(pin => pin.id));
|
|
112
|
+
const graph = derivationGraph(links);
|
|
113
|
+
return links.map(link => ({ link,
|
|
114
|
+
missingPinIds: [link.fromPinId, link.toPinId].filter(id => !ids.has(id)),
|
|
115
|
+
cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
/** Matching context is disclosed, never counted as independent corroboration. */
|
|
119
|
+
export function caseEvidenceSharedContext(pins) {
|
|
120
|
+
const groups = new Map();
|
|
121
|
+
function add(kind, identity, label, id) {
|
|
122
|
+
if (!identity?.trim())
|
|
123
|
+
return;
|
|
124
|
+
const key = JSON.stringify([kind, identity]);
|
|
125
|
+
const group = groups.get(key) ?? { kind, label, pinIds: [] };
|
|
126
|
+
group.pinIds.push(id);
|
|
127
|
+
groups.set(key, group);
|
|
128
|
+
}
|
|
129
|
+
for (const pin of pins) {
|
|
130
|
+
add('checkpoint', pin.checkpointId, 'Same collection checkpoint', pin.id);
|
|
131
|
+
add('import', pin.importContentSha256, 'Same imported content', pin.id);
|
|
132
|
+
add('source', pin.source, `Same declared source: ${pin.source}`, pin.id);
|
|
133
|
+
}
|
|
134
|
+
return [...groups.values()].filter(group => group.pinIds.length > 1);
|
|
135
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import { normalizeHttpSummary } from "./http-summary.mjs";
|
|
4
|
-
import { normalizeOpportunityModelVersion } from "
|
|
5
|
-
import { normalizeRiskModelVersion } from "
|
|
3
|
+
import { httpSummaryFieldIsObserved, normalizeHttpSummary } from "./http-summary.mjs";
|
|
4
|
+
import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scoring.mjs";
|
|
5
|
+
import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
|
|
6
|
+
import { latestObservationCohort } from "../evidence/latest-observations.mjs";
|
|
7
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
8
|
+
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
9
|
+
import { validWebObservationMode } from "../evidence/lookup-target.mjs";
|
|
10
|
+
import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
|
|
6
11
|
import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
|
|
7
12
|
import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
|
|
8
13
|
function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
|
|
@@ -24,7 +29,10 @@ function boolOrNull(value) {
|
|
|
24
29
|
// are deduplicated and the result is sorted deterministically (largest
|
|
25
30
|
// contribution first, then label) so input order alone can never change a
|
|
26
31
|
// snapshot's fingerprint and two equal factor sets in different order collapse.
|
|
27
|
-
function
|
|
32
|
+
function compareFactors(a, b) {
|
|
33
|
+
return b.points - a.points || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0);
|
|
34
|
+
}
|
|
35
|
+
function normalizeFactors(value, legacyOrder = false) {
|
|
28
36
|
if (!Array.isArray(value))
|
|
29
37
|
return [];
|
|
30
38
|
const seen = new Set();
|
|
@@ -45,7 +53,7 @@ function normalizeFactors(value) {
|
|
|
45
53
|
seen.add(key);
|
|
46
54
|
out.push({ label, points });
|
|
47
55
|
}
|
|
48
|
-
out.sort((a, b) => b.points - a.points || a.label.localeCompare(b.label));
|
|
56
|
+
out.sort(legacyOrder ? (a, b) => b.points - a.points || a.label.localeCompare(b.label) : compareFactors);
|
|
49
57
|
return out.slice(0, MAX_EVIDENCE_FACTORS);
|
|
50
58
|
}
|
|
51
59
|
// Case-insensitive, terminal-dot-stripped, deduplicated and sorted so a
|
|
@@ -112,7 +120,10 @@ const DEEP_SIGNAL_FIELDS = [
|
|
|
112
120
|
// another. Deterministic ordering here is what makes the fingerprint stable.
|
|
113
121
|
const MATERIAL_FIELD_ORDER = [
|
|
114
122
|
'inputHostname',
|
|
123
|
+
'observationHostname',
|
|
124
|
+
'webObservationMode',
|
|
115
125
|
'scanDepth',
|
|
126
|
+
'webCollectionQuality',
|
|
116
127
|
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
|
|
117
128
|
'riskFactors', 'opportunityFactors',
|
|
118
129
|
'registrar', 'createdDate', 'expiryDate', 'nameservers',
|
|
@@ -128,7 +139,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
128
139
|
// The canonical, comparison-safe value of a material field. Registrar casing,
|
|
129
140
|
// nameserver order, and sub-day timestamps are collapsed so they can never
|
|
130
141
|
// count as a "change"; a non-conclusive availability contributes nothing.
|
|
131
|
-
function materialValue(field, snapshot) {
|
|
142
|
+
function materialValue(field, snapshot, legacyOrder = false) {
|
|
132
143
|
switch (field) {
|
|
133
144
|
case 'availability':
|
|
134
145
|
return typeof snapshot.availability === 'string' && CONCLUSIVE_AVAILABILITY.has(snapshot.availability)
|
|
@@ -147,9 +158,9 @@ function materialValue(field, snapshot) {
|
|
|
147
158
|
case 'mutationTypes':
|
|
148
159
|
return snapshot.mutationTypes;
|
|
149
160
|
case 'riskFactors':
|
|
150
|
-
return snapshot.riskFactors.map((factor) => [factor.label, factor.points]);
|
|
161
|
+
return (legacyOrder ? snapshot.riskFactors : [...snapshot.riskFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
|
|
151
162
|
case 'opportunityFactors':
|
|
152
|
-
return snapshot.opportunityFactors.map((factor) => [factor.label, factor.points]);
|
|
163
|
+
return (legacyOrder ? snapshot.opportunityFactors : [...snapshot.opportunityFactors].sort(compareFactors)).map((factor) => [factor.label, factor.points]);
|
|
153
164
|
default:
|
|
154
165
|
return snapshot[field] ?? null;
|
|
155
166
|
}
|
|
@@ -165,7 +176,7 @@ function isEmptyMaterial(value) {
|
|
|
165
176
|
}
|
|
166
177
|
// Fields that describe the capture rather than assert evidence, so they never
|
|
167
178
|
// on their own keep an otherwise-empty snapshot alive.
|
|
168
|
-
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'scanDepth', 'confidence']);
|
|
179
|
+
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
|
|
169
180
|
// A snapshot with no material evidence (only timestamps/source/depth, or only a
|
|
170
181
|
// bare confidence/unknown-availability) is dropped rather than added to a
|
|
171
182
|
// timeline.
|
|
@@ -179,14 +190,13 @@ function hasMaterialEvidence(snapshot) {
|
|
|
179
190
|
return false;
|
|
180
191
|
}
|
|
181
192
|
// Deterministic string form of the material identity, keys in fixed order.
|
|
182
|
-
function canonicalMaterialString(snapshot) {
|
|
193
|
+
function canonicalMaterialString(snapshot, legacyOrder = false) {
|
|
183
194
|
const canonical = {};
|
|
184
195
|
for (const field of MATERIAL_FIELD_ORDER) {
|
|
185
|
-
const value = materialValue(field, snapshot);
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
if (field === 'inputHostname' && value === null)
|
|
196
|
+
const value = materialValue(field, snapshot, legacyOrder);
|
|
197
|
+
// Optional collection context does not alter historical fingerprints when
|
|
198
|
+
// absent; recorded context separates otherwise-identical captures.
|
|
199
|
+
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality') && value === null)
|
|
190
200
|
continue;
|
|
191
201
|
canonical[field] = value;
|
|
192
202
|
}
|
|
@@ -213,23 +223,46 @@ function buildSnapshot(raw, options) {
|
|
|
213
223
|
if (!raw || typeof raw !== 'object')
|
|
214
224
|
return null;
|
|
215
225
|
const record = objectRecord(raw);
|
|
226
|
+
if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
|
|
227
|
+
return null;
|
|
228
|
+
const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
229
|
+
if (historicalSchema && record.factorOrder !== undefined)
|
|
230
|
+
return null;
|
|
231
|
+
// Fieldless retained snapshots keep their original identity algorithm. New
|
|
232
|
+
// captures declare code-unit ordering; the declaration survives every save.
|
|
233
|
+
const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
|
|
216
234
|
const scanDepth = normalizeScanDepth(record.scanDepth);
|
|
217
235
|
const httpSummary = normalizeHttpSummary(record);
|
|
236
|
+
const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
|
|
237
|
+
if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
|
|
238
|
+
throw new TypeError('Web collection quality requires Case schema 17 or later; historical evidence was not reinterpreted.');
|
|
239
|
+
}
|
|
218
240
|
const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
|
|
219
241
|
const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
|
|
242
|
+
const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
243
|
+
const observationHostname = acceptsObservationHostname
|
|
244
|
+
? normalizeEvidenceHostnameForCase(record.observationHostname, options.caseDomain)
|
|
245
|
+
: null;
|
|
246
|
+
if (acceptsObservationHostname && record.observationHostname != null && !observationHostname)
|
|
247
|
+
return null;
|
|
248
|
+
if (acceptsObservationHostname && !validWebObservationMode(record.webObservationMode))
|
|
249
|
+
return null;
|
|
220
250
|
const fields = {
|
|
221
251
|
inputHostname: acceptsInputHostname
|
|
222
252
|
? normalizeEvidenceHostnameForCase(record.inputHostname, options.caseDomain)
|
|
223
253
|
: null,
|
|
254
|
+
...(observationHostname ? { observationHostname } : {}),
|
|
255
|
+
...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
|
|
224
256
|
scanDepth,
|
|
257
|
+
...(webCollectionQuality ? { webCollectionQuality } : {}),
|
|
225
258
|
availability: evidenceString(record.availability),
|
|
226
259
|
confidence: evidenceString(record.confidence),
|
|
227
260
|
riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
|
|
228
261
|
riskScore: clampScore(record.riskScore),
|
|
229
262
|
opportunityModelVersion: normalizeOpportunityModelVersion(record.opportunityModelVersion),
|
|
230
263
|
opportunityScore: clampScore(record.opportunityScore),
|
|
231
|
-
riskFactors: normalizeFactors(record.riskFactors),
|
|
232
|
-
opportunityFactors: normalizeFactors(record.opportunityFactors),
|
|
264
|
+
riskFactors: normalizeFactors(record.riskFactors, legacyOrder),
|
|
265
|
+
opportunityFactors: normalizeFactors(record.opportunityFactors, legacyOrder),
|
|
233
266
|
registrar: evidenceString(record.registrar),
|
|
234
267
|
createdDate: lifecycleTimestamp(record.createdDate, options.sourceVersion),
|
|
235
268
|
expiryDate: lifecycleTimestamp(record.expiryDate, options.sourceVersion),
|
|
@@ -274,6 +307,12 @@ function buildSnapshot(raw, options) {
|
|
|
274
307
|
fields.riskModelVersion = null;
|
|
275
308
|
if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
|
|
276
309
|
fields.opportunityModelVersion = null;
|
|
310
|
+
if (webCollectionQuality) {
|
|
311
|
+
for (const field of ['hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
|
|
312
|
+
if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
|
|
313
|
+
fields[field] = null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
277
316
|
// A fast capture never evaluates the deep signals, so any value supplied for
|
|
278
317
|
// them (e.g. a profile's default `false`) is discarded as unevaluated.
|
|
279
318
|
if (scanDepth === 'fast') {
|
|
@@ -317,10 +356,11 @@ function buildSnapshot(raw, options) {
|
|
|
317
356
|
? options.source
|
|
318
357
|
: DEFAULT_EVIDENCE_SOURCE;
|
|
319
358
|
const material = canonicalMaterialString(fields);
|
|
320
|
-
const fingerprint = hashString(material);
|
|
359
|
+
const fingerprint = hashString(legacyOrder ? canonicalMaterialString(fields, true) : material);
|
|
321
360
|
const snapshot = {
|
|
322
361
|
id: `ev-${fingerprint}`,
|
|
323
362
|
fingerprint,
|
|
363
|
+
...(!legacyOrder ? { factorOrder: 'code-unit-v1' } : {}),
|
|
324
364
|
firstCapturedAt,
|
|
325
365
|
capturedAt,
|
|
326
366
|
source,
|
|
@@ -356,7 +396,10 @@ function mergeDuplicateSnapshots(kept, incoming) {
|
|
|
356
396
|
? incoming.capturedAt
|
|
357
397
|
: kept.capturedAt;
|
|
358
398
|
const source = chooseSource(kept, incoming);
|
|
359
|
-
|
|
399
|
+
// Preserve an existing historical identifier when a new capture contains
|
|
400
|
+
// the same evidence under the newer ordering declaration.
|
|
401
|
+
const base = kept.factorOrder && !incoming.factorOrder ? incoming : kept;
|
|
402
|
+
return { ...base, firstCapturedAt, capturedAt, source };
|
|
360
403
|
}
|
|
361
404
|
function compareSnapshotChrono(a, b) {
|
|
362
405
|
return (Date.parse(a.capturedAt) - Date.parse(b.capturedAt) ||
|
|
@@ -374,6 +417,11 @@ function compareSnapshotChrono(a, b) {
|
|
|
374
417
|
* @returns {CaseEvidenceSnapshot[]}
|
|
375
418
|
*/
|
|
376
419
|
export function normalizeEvidenceHistory(rawList, options = {}) {
|
|
420
|
+
const ordered = [...evidenceByMaterial(rawList, options).values()].sort(compareSnapshotChrono);
|
|
421
|
+
const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
|
|
422
|
+
return assignUniqueSnapshotIds(kept);
|
|
423
|
+
}
|
|
424
|
+
function evidenceByMaterial(rawList, options) {
|
|
377
425
|
const list = Array.isArray(rawList) ? rawList : [];
|
|
378
426
|
const byMaterial = new Map();
|
|
379
427
|
for (const raw of list) {
|
|
@@ -385,9 +433,24 @@ export function normalizeEvidenceHistory(rawList, options = {}) {
|
|
|
385
433
|
// collision can never merge two genuinely different snapshots.
|
|
386
434
|
byMaterial.set(built.material, existing ? mergeDuplicateSnapshots(existing, built.snapshot) : built.snapshot);
|
|
387
435
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
436
|
+
return byMaterial;
|
|
437
|
+
}
|
|
438
|
+
/** Imports add observations without evicting existing local material. Ordinary
|
|
439
|
+
* collection still uses the newest-history policy in normalizeEvidenceHistory. */
|
|
440
|
+
export function mergeImportedEvidenceHistory(local, imported, caseDomain) {
|
|
441
|
+
const options = { source: 'import', fallback: null, caseDomain };
|
|
442
|
+
const retained = evidenceByMaterial(normalizeEvidenceHistory(local, options), options);
|
|
443
|
+
let omitted = 0;
|
|
444
|
+
for (const [material, incoming] of evidenceByMaterial(imported, options)) {
|
|
445
|
+
const existing = retained.get(material);
|
|
446
|
+
if (existing)
|
|
447
|
+
retained.set(material, mergeDuplicateSnapshots(existing, incoming));
|
|
448
|
+
else if (retained.size < MAX_EVIDENCE_SNAPSHOTS_PER_CASE)
|
|
449
|
+
retained.set(material, incoming);
|
|
450
|
+
else
|
|
451
|
+
omitted += 1;
|
|
452
|
+
}
|
|
453
|
+
return { records: assignUniqueSnapshotIds([...retained.values()].sort(compareSnapshotChrono)), omitted };
|
|
391
454
|
}
|
|
392
455
|
function assignUniqueSnapshotIds(snapshots) {
|
|
393
456
|
const used = new Set();
|
|
@@ -403,15 +466,72 @@ function assignUniqueSnapshotIds(snapshots) {
|
|
|
403
466
|
return snapshot.id === id ? snapshot : { ...snapshot, id };
|
|
404
467
|
});
|
|
405
468
|
}
|
|
406
|
-
/**
|
|
407
|
-
|
|
408
|
-
* without knowing the history is a bounded, deduplicated timeline.
|
|
409
|
-
* @param {{ evidenceHistory?: CaseEvidenceSnapshot[] } | null | undefined} record
|
|
410
|
-
* @returns {CaseEvidenceSnapshot | null}
|
|
411
|
-
*/
|
|
412
|
-
export function latestCaseEvidence(record) {
|
|
469
|
+
/** Select from an admitted history without assigning order to equal or unknown capture times. */
|
|
470
|
+
export function currentCaseEvidence(record) {
|
|
413
471
|
const history = record && Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
|
|
414
|
-
|
|
472
|
+
const cohort = latestObservationCohort(history, (snapshot) => snapshot.capturedAt);
|
|
473
|
+
const snapshot = cohort.undated.length === 0 && cohort.latest.length === 1 ? cohort.latest[0] : null;
|
|
474
|
+
return {
|
|
475
|
+
snapshot,
|
|
476
|
+
candidates: [...cohort.latest, ...cohort.undated],
|
|
477
|
+
capturedAt: cohort.observedAt,
|
|
478
|
+
limitation: cohort.undated.length
|
|
479
|
+
? 'Retained snapshots include an unknown capture time. No single latest assessment is selected.'
|
|
480
|
+
: cohort.latest.length > 1
|
|
481
|
+
? 'Distinct snapshots share the latest capture time. No single latest assessment is selected.'
|
|
482
|
+
: null,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/** The unique latest retained snapshot, or null for empty or temporally ambiguous evidence. */
|
|
486
|
+
export function latestCaseEvidence(record) {
|
|
487
|
+
return currentCaseEvidence(record).snapshot;
|
|
488
|
+
}
|
|
489
|
+
/** Chronological presentation and comparison admission shared by the browser and Case report. */
|
|
490
|
+
export function caseEvidenceTimeline(history) {
|
|
491
|
+
return buildEvidenceTimeline(history, true);
|
|
492
|
+
}
|
|
493
|
+
/** Reproduce published report fields only; not current analyst comparisons. */
|
|
494
|
+
export function publishedCaseEvidenceTimelineForVerification(history) {
|
|
495
|
+
return buildEvidenceTimeline(history, false);
|
|
496
|
+
}
|
|
497
|
+
function buildEvidenceTimeline(history, enforceCollectionQuality) {
|
|
498
|
+
const dated = (history ?? []).map((snapshot) => ({
|
|
499
|
+
snapshot,
|
|
500
|
+
at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
|
|
501
|
+
}));
|
|
502
|
+
const counts = new Map();
|
|
503
|
+
for (const { at } of dated)
|
|
504
|
+
if (at)
|
|
505
|
+
counts.set(at, (counts.get(at) ?? 0) + 1);
|
|
506
|
+
const hasUndated = dated.some(({ at }) => at === null);
|
|
507
|
+
dated.sort((a, b) => (a.at === b.at ? 0 : a.at === null ? 1 : b.at === null ? -1 : Date.parse(a.at) - Date.parse(b.at))
|
|
508
|
+
|| (a.snapshot.id < b.snapshot.id ? -1 : a.snapshot.id > b.snapshot.id ? 1 : 0));
|
|
509
|
+
return dated.map(({ snapshot, at }, index) => {
|
|
510
|
+
const previous = dated[index - 1];
|
|
511
|
+
const isBaseline = index === 0 && !hasUndated && at !== null && counts.get(at) === 1;
|
|
512
|
+
const orderingLimitation = !at || (previous && !previous.at)
|
|
513
|
+
? 'Capture order is unknown; no temporal change is inferred.'
|
|
514
|
+
: counts.get(at) !== 1 || (previous && counts.get(previous.at) !== 1)
|
|
515
|
+
? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
|
|
516
|
+
: null;
|
|
517
|
+
const comparable = previous && !orderingLimitation;
|
|
518
|
+
const changes = comparable ? compareEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
|
|
519
|
+
const incomparableReasons = orderingLimitation
|
|
520
|
+
? ['other']
|
|
521
|
+
: comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
|
|
522
|
+
if (comparable && !changes.length && !incomparableReasons.length
|
|
523
|
+
&& canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
|
|
524
|
+
incomparableReasons.push('other');
|
|
525
|
+
return {
|
|
526
|
+
snapshot,
|
|
527
|
+
isBaseline,
|
|
528
|
+
hasRepeatedObservation: snapshot.firstCapturedAt !== snapshot.capturedAt,
|
|
529
|
+
changes: changes.length ? changes : null,
|
|
530
|
+
hasIncomparableChange: incomparableReasons.length > 0,
|
|
531
|
+
incomparableReasons,
|
|
532
|
+
orderingLimitation,
|
|
533
|
+
};
|
|
534
|
+
});
|
|
415
535
|
}
|
|
416
536
|
/**
|
|
417
537
|
* Returns the exact hostname from the latest retained observation when it is
|
|
@@ -433,38 +553,38 @@ export function caseLookupTarget(record) {
|
|
|
433
553
|
// a mode change is never reported.
|
|
434
554
|
// (absent) - always comparable (data available in every capture).
|
|
435
555
|
const COMPARE_FIELDS = [
|
|
436
|
-
{ field: 'availability', label: 'Availability', type: 'availability' },
|
|
437
|
-
{ field: 'confidence', label: 'Confidence', type: 'token' },
|
|
438
|
-
{ field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
|
|
439
|
-
{ field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
|
|
440
|
-
{ field: 'opportunityScore', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
|
|
441
|
-
{ field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
|
|
442
|
-
{ field: 'registrar', label: 'Registrar', type: 'registrar' },
|
|
443
|
-
{ field: 'createdDate', label: 'Creation date', type: 'date' },
|
|
444
|
-
{ field: 'expiryDate', label: 'Expiry date', type: 'date' },
|
|
445
|
-
{ field: 'nameservers', label: 'Nameservers', type: 'set', emptyGuard: true },
|
|
446
|
-
{ field: 'hasMx', label: 'MX', type: 'bool', depthGate: 'both-deep' },
|
|
447
|
-
{ field: 'hasSpf', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
|
|
448
|
-
{ field: 'hasDmarc', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
|
|
449
|
-
{ field: 'activityStatus', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
|
|
450
|
-
{ field: 'websiteProbeDetail', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
|
|
451
|
-
{ field: 'pageTitle', label: 'Page title', type: 'text', depthGate: 'both-deep' },
|
|
452
|
-
{ field: 'httpEvidenceStatus', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
|
|
453
|
-
{ field: 'httpFinalOrigin', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
|
|
454
|
-
{ field: 'httpResponseStatus', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
|
|
455
|
-
{ field: 'httpTransportSecurity', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
|
|
456
|
-
{ field: 'httpRedirectCount', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
|
|
457
|
-
{ field: 'httpCrossOriginRedirect', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
|
|
458
|
-
{ field: 'httpHttpsDowngrade', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
|
|
459
|
-
{ field: 'httpContentType', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
|
|
460
|
-
{ field: 'httpSecurityHeaders', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
|
|
461
|
-
{ field: 'faviconMatch', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
|
|
462
|
-
{ field: 'faviconNearMatch', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
|
|
463
|
-
{ field: 'reusesOfficialAssets', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
|
|
464
|
-
{ field: 'hasPasswordField', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
|
|
465
|
-
{ field: 'hasExternalFormAction', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
|
|
466
|
-
{ field: 'phishingLanguageMatch', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
|
|
467
|
-
{ field: 'mutationTypes', label: 'Mutation types', type: 'set' },
|
|
556
|
+
{ field: 'availability', scope: 'registration', label: 'Availability', type: 'availability' },
|
|
557
|
+
{ field: 'confidence', scope: 'registration', label: 'Confidence', type: 'token' },
|
|
558
|
+
{ field: 'riskScore', scope: 'web', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
|
|
559
|
+
{ field: 'riskFactors', scope: 'web', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
|
|
560
|
+
{ field: 'opportunityScore', scope: 'web', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
|
|
561
|
+
{ field: 'opportunityFactors', scope: 'web', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
|
|
562
|
+
{ field: 'registrar', scope: 'registration', label: 'Registrar', type: 'registrar' },
|
|
563
|
+
{ field: 'createdDate', scope: 'registration', label: 'Creation date', type: 'date' },
|
|
564
|
+
{ field: 'expiryDate', scope: 'registration', label: 'Expiry date', type: 'date' },
|
|
565
|
+
{ field: 'nameservers', scope: 'registration', label: 'Nameservers', type: 'set', emptyGuard: true },
|
|
566
|
+
{ field: 'hasMx', scope: 'hostname', label: 'MX', type: 'bool', depthGate: 'both-deep' },
|
|
567
|
+
{ field: 'hasSpf', scope: 'hostname', label: 'SPF', type: 'bool', depthGate: 'both-deep' },
|
|
568
|
+
{ field: 'hasDmarc', scope: 'hostname', label: 'DMARC', type: 'bool', depthGate: 'both-deep' },
|
|
569
|
+
{ field: 'activityStatus', scope: 'web', label: 'Website activity', type: 'token', depthGate: 'both-deep' },
|
|
570
|
+
{ field: 'websiteProbeDetail', scope: 'web', label: 'Website check detail', type: 'text', depthGate: 'both-deep' },
|
|
571
|
+
{ field: 'pageTitle', scope: 'web', label: 'Page title', type: 'text', depthGate: 'both-deep' },
|
|
572
|
+
{ field: 'httpEvidenceStatus', scope: 'web', label: 'HTTP evidence status', type: 'token', depthGate: 'both-deep' },
|
|
573
|
+
{ field: 'httpFinalOrigin', scope: 'web', label: 'Final website origin', type: 'text', depthGate: 'both-deep' },
|
|
574
|
+
{ field: 'httpResponseStatus', scope: 'web', label: 'HTTP response status', type: 'number', depthGate: 'both-deep' },
|
|
575
|
+
{ field: 'httpTransportSecurity', scope: 'web', label: 'Website transport', type: 'http-transport', depthGate: 'both-deep' },
|
|
576
|
+
{ field: 'httpRedirectCount', scope: 'web', label: 'HTTP redirect count', type: 'number', depthGate: 'both-deep' },
|
|
577
|
+
{ field: 'httpCrossOriginRedirect', scope: 'web', label: 'Cross-origin redirect', type: 'http-signal', depthGate: 'both-deep' },
|
|
578
|
+
{ field: 'httpHttpsDowngrade', scope: 'web', label: 'HTTPS downgrade', type: 'signal', depthGate: 'both-deep' },
|
|
579
|
+
{ field: 'httpContentType', scope: 'web', label: 'Website content type', type: 'token', depthGate: 'both-deep' },
|
|
580
|
+
{ field: 'httpSecurityHeaders', scope: 'web', label: 'Observed security headers', type: 'set', depthGate: 'both-deep' },
|
|
581
|
+
{ field: 'faviconMatch', scope: 'web', label: 'Official favicon match', type: 'signal', depthGate: 'both-deep' },
|
|
582
|
+
{ field: 'faviconNearMatch', scope: 'web', label: 'Official favicon near-match', type: 'signal', depthGate: 'both-deep' },
|
|
583
|
+
{ field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
|
|
584
|
+
{ field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
|
|
585
|
+
{ field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
|
|
586
|
+
{ field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
|
|
587
|
+
{ field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
|
|
468
588
|
];
|
|
469
589
|
function depthComparable(a, b) {
|
|
470
590
|
return a === b && (a === 'fast' || a === 'deep');
|
|
@@ -482,6 +602,13 @@ function opportunityModelComparable(previous, current) {
|
|
|
482
602
|
function valuesMateriallyEqual(field, previous, current) {
|
|
483
603
|
return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
|
|
484
604
|
}
|
|
605
|
+
function collectionFieldComparable(field, snapshot) {
|
|
606
|
+
if (field === 'registrar' || field === 'createdDate' || field === 'expiryDate')
|
|
607
|
+
return isPresent(snapshot[field]);
|
|
608
|
+
return field.startsWith('http')
|
|
609
|
+
? httpSummaryFieldIsObserved(field, snapshot)
|
|
610
|
+
: webCollectionAllowsComparison(field, snapshot.webCollectionQuality, snapshot.scanDepth);
|
|
611
|
+
}
|
|
485
612
|
/**
|
|
486
613
|
* Explains material fields that comparison gates deliberately suppress. The
|
|
487
614
|
* reasons are stable machine values for UI/report wording; they are not risk
|
|
@@ -489,14 +616,27 @@ function valuesMateriallyEqual(field, previous, current) {
|
|
|
489
616
|
* in the same observation produced an ordinary material change.
|
|
490
617
|
* @param {CaseEvidenceSnapshot | null | undefined} previous
|
|
491
618
|
* @param {CaseEvidenceSnapshot | null | undefined} current
|
|
492
|
-
* @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
|
|
619
|
+
* @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model' | 'collection-quality'>}
|
|
493
620
|
*/
|
|
494
621
|
export function caseEvidenceIncomparableReasons(previous, current) {
|
|
622
|
+
return incomparableReasonsForEvidence(previous, current, true);
|
|
623
|
+
}
|
|
624
|
+
function incomparableReasonsForEvidence(previous, current, enforceCollectionQuality) {
|
|
495
625
|
if (!previous || !current || previous.fingerprint === current.fingerprint)
|
|
496
626
|
return [];
|
|
497
627
|
const reasons = [];
|
|
498
|
-
if (previous
|
|
628
|
+
if (!sameObservationContext(previous, current))
|
|
499
629
|
reasons.push('observation-context');
|
|
630
|
+
const missingRegistrationValue = ['registrar', 'createdDate', 'expiryDate'].some(field => !valuesMateriallyEqual(field, previous, current) && (!isPresent(previous[field]) || !isPresent(current[field])));
|
|
631
|
+
const incompleteWebFields = previous.scanDepth === 'deep' && current.scanDepth === 'deep'
|
|
632
|
+
&& (JSON.stringify(previous.webCollectionQuality) !== JSON.stringify(current.webCollectionQuality)
|
|
633
|
+
|| COMPARE_FIELDS.some(spec => (spec.modelGate !== 'risk' || riskModelComparable(previous, current))
|
|
634
|
+
&& (spec.modelGate !== 'opportunity' || opportunityModelComparable(previous, current))
|
|
635
|
+
&& !valuesMateriallyEqual(spec.field, previous, current)
|
|
636
|
+
&& (!collectionFieldComparable(spec.field, previous)
|
|
637
|
+
|| !collectionFieldComparable(spec.field, current))));
|
|
638
|
+
if (enforceCollectionQuality && (missingRegistrationValue || incompleteWebFields))
|
|
639
|
+
reasons.push('collection-quality');
|
|
500
640
|
const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
|
|
501
641
|
|| previous.riskFactors.length > 0 || current.riskFactors.length > 0;
|
|
502
642
|
if (hasRiskEvidence && !riskModelComparable(previous, current))
|
|
@@ -523,6 +663,16 @@ function isPresent(value) {
|
|
|
523
663
|
return value.length > 0;
|
|
524
664
|
return true;
|
|
525
665
|
}
|
|
666
|
+
function sameObservationHostname(previous, current) {
|
|
667
|
+
return previous.inputHostname === current.inputHostname
|
|
668
|
+
&& (previous.observationHostname ?? null) === (current.observationHostname ?? null);
|
|
669
|
+
}
|
|
670
|
+
function sameObservationContext(previous, current) {
|
|
671
|
+
// Compact Case snapshots deliberately omit URL paths and queries. A selected
|
|
672
|
+
// page cannot therefore establish a comparable website target here.
|
|
673
|
+
return sameObservationHostname(previous, current)
|
|
674
|
+
&& previous.webObservationMode !== 'selected_url' && current.webObservationMode !== 'selected_url';
|
|
675
|
+
}
|
|
526
676
|
function setsEqual(a, b) {
|
|
527
677
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
528
678
|
}
|
|
@@ -648,11 +798,9 @@ function compareField(spec, before, after) {
|
|
|
648
798
|
return { before: b, after: a, tone: 'neutral' };
|
|
649
799
|
}
|
|
650
800
|
case 'factors': {
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const b = Array.isArray(before) ? before : [];
|
|
655
|
-
const a = Array.isArray(after) ? after : [];
|
|
801
|
+
// Historical identity ordering is not a change in the factor set.
|
|
802
|
+
const b = normalizeFactors(before);
|
|
803
|
+
const a = normalizeFactors(after);
|
|
656
804
|
if (setsEqual(b, a))
|
|
657
805
|
return null;
|
|
658
806
|
return { before: b, after: a, tone: 'neutral' };
|
|
@@ -675,14 +823,23 @@ function compareField(spec, before, after) {
|
|
|
675
823
|
* @returns {Array<{ field: string, label: string, before: unknown, after: unknown, tone: string }>}
|
|
676
824
|
*/
|
|
677
825
|
export function compareCaseEvidence(previous, current) {
|
|
826
|
+
return compareEvidence(previous, current, true);
|
|
827
|
+
}
|
|
828
|
+
function compareEvidence(previous, current, enforceCollectionQuality) {
|
|
678
829
|
if (!previous || !current)
|
|
679
830
|
return [];
|
|
680
831
|
const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
|
|
681
832
|
const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
|
|
682
833
|
const comparableRiskModel = riskModelComparable(previous, current);
|
|
683
834
|
const comparableOpportunityModel = opportunityModelComparable(previous, current);
|
|
835
|
+
const sameHostname = sameObservationHostname(previous, current);
|
|
836
|
+
const sameWebContext = sameObservationContext(previous, current);
|
|
684
837
|
const changes = [];
|
|
685
838
|
for (const spec of COMPARE_FIELDS) {
|
|
839
|
+
if (spec.scope === 'hostname' && !sameHostname)
|
|
840
|
+
continue;
|
|
841
|
+
if (spec.scope === 'web' && !sameWebContext)
|
|
842
|
+
continue;
|
|
686
843
|
if (spec.depthGate === 'both-deep' && !bothDeep)
|
|
687
844
|
continue;
|
|
688
845
|
if (spec.depthGate === 'comparable' && !comparableDepth)
|
|
@@ -691,6 +848,9 @@ export function compareCaseEvidence(previous, current) {
|
|
|
691
848
|
continue;
|
|
692
849
|
if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
|
|
693
850
|
continue;
|
|
851
|
+
if (enforceCollectionQuality && (!collectionFieldComparable(spec.field, previous)
|
|
852
|
+
|| !collectionFieldComparable(spec.field, current)))
|
|
853
|
+
continue;
|
|
694
854
|
const result = compareField(spec, previous[spec.field], current[spec.field]);
|
|
695
855
|
if (result) {
|
|
696
856
|
changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Collection completeness does not establish a usable date, comparison or conclusion. */
|
|
2
|
+
export function casePinHasCompleteObservation(pin) {
|
|
3
|
+
return pin.completeness === 'complete' && !pin.truncated
|
|
4
|
+
&& ['complete', 'success', 'reviewed', 'not_found'].includes(pin.sourceState ?? '');
|
|
5
|
+
}
|