@slicedearth/whoisleuth-cli 2.4.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 +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- 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 -210
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +3 -3
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -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 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- 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-command-runner.mjs +18 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- 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/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +7 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- 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-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -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-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-recheck-model.mjs +90 -32
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -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/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- 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 +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- 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-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- 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/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -8,14 +8,18 @@
|
|
|
8
8
|
// contains raw RDAP/WHOIS material. This module deliberately excludes all raw
|
|
9
9
|
// registry/web responses, contacts, cookies, screenshots, and authentication
|
|
10
10
|
// data. Reports contain only the normalized case record.
|
|
11
|
-
import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence } from "./case-model.mjs";
|
|
11
|
+
import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
|
|
12
12
|
import { httpSecurityHeaderLabel } from "./http-summary.mjs";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { webCollectionScoreLimitation } from "../evidence/collection-quality.mjs";
|
|
14
|
+
import { analystInteroperabilityTags } from "../analysis/analyst-taxonomy.mjs";
|
|
15
|
+
import { caseEvidenceLinkIssues } from "./case-evidence-links.mjs";
|
|
16
|
+
import { caseTypeSummary } from "./case-workflow-metadata.mjs";
|
|
17
|
+
import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../analysis/portable-generator.mjs";
|
|
15
18
|
import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
|
|
16
19
|
import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
|
|
17
20
|
import { CASE_RECHECK_CONDITIONS } from "./case-recheck-model.mjs";
|
|
18
|
-
import {
|
|
21
|
+
import { escapeCaseMarkdownInline as escapeMarkdownInline } from "./case-markdown.mjs";
|
|
22
|
+
import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
19
23
|
// ---------------------------------------------------------------------------
|
|
20
24
|
// Constants
|
|
21
25
|
// ---------------------------------------------------------------------------
|
|
@@ -35,16 +39,6 @@ const LIMITATIONS_TEXT = [
|
|
|
35
39
|
'Snapshot hostnames are excluded; supporting DNS, TLS and web observations need not concern the Case registration domain.',
|
|
36
40
|
...PUBLISHED_V2_3_LIMITATIONS.slice(2),
|
|
37
41
|
].join(' ');
|
|
38
|
-
function escapeMarkdownInline(text) {
|
|
39
|
-
return String(text)
|
|
40
|
-
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
|
41
|
-
.replace(/([\\`*_{}\[\]<>()#+!|~])/g, '\\$1')
|
|
42
|
-
// GFM autolinks bare URLs independently of ordinary link syntax. Break
|
|
43
|
-
// the scheme or the first `www.` separator without making normal domains,
|
|
44
|
-
// dates, and prose noisy in the report source.
|
|
45
|
-
.replace(/\b([a-z][a-z0-9+.-]{1,31}):(?=\/\/)/gi, '$1\\:')
|
|
46
|
-
.replace(/\bwww\.(?=\S)/gi, 'www\\.');
|
|
47
|
-
}
|
|
48
42
|
/**
|
|
49
43
|
* Escapes a multiline note body for safe inclusion in a Markdown blockquote.
|
|
50
44
|
* Each line is individually escaped and prefixed with `> `.
|
|
@@ -90,6 +84,12 @@ function formatReportValue(value, field) {
|
|
|
90
84
|
return String(value);
|
|
91
85
|
return String(value);
|
|
92
86
|
}
|
|
87
|
+
function readableRiskScore(snapshot) {
|
|
88
|
+
const value = escapeMarkdownInline(formatReportValue(snapshot.riskScore));
|
|
89
|
+
const limitation = snapshot.riskScore === null ? null
|
|
90
|
+
: webCollectionScoreLimitation(snapshot.webCollectionQuality, snapshot.scanDepth);
|
|
91
|
+
return limitation ? `${value} — ${escapeMarkdownInline(limitation)}` : value;
|
|
92
|
+
}
|
|
93
93
|
/**
|
|
94
94
|
* Returns a shallow clone of a snapshot containing only known schema fields.
|
|
95
95
|
* Unknown/imported keys are never included.
|
|
@@ -111,6 +111,7 @@ function pickKnownSnapshotFields(snapshot) {
|
|
|
111
111
|
capturedAt: snapshot.capturedAt,
|
|
112
112
|
source: snapshot.source,
|
|
113
113
|
scanDepth: snapshot.scanDepth,
|
|
114
|
+
...(snapshot.webCollectionQuality ? { webCollectionQuality: { ...snapshot.webCollectionQuality } } : {}),
|
|
114
115
|
availability: snapshot.availability,
|
|
115
116
|
confidence: snapshot.confidence,
|
|
116
117
|
riskModelVersion: snapshot.riskModelVersion,
|
|
@@ -173,14 +174,7 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
173
174
|
const generator = buildPortableGeneratorMetadata(options.applicationVersion);
|
|
174
175
|
// --- Build JSON report ---
|
|
175
176
|
const timeline = caseEvidenceTimeline(caseRecord.evidenceHistory);
|
|
176
|
-
const timelineEntries = timeline
|
|
177
|
-
snapshot: pickKnownSnapshotFields(entry.snapshot),
|
|
178
|
-
isBaseline: entry.isBaseline,
|
|
179
|
-
hasRepeatedObservation: entry.hasRepeatedObservation,
|
|
180
|
-
changes: entry.changes?.map(({ field, label, before, after, tone }) => ({ field, label, before, after, tone })) ?? null,
|
|
181
|
-
hasIncomparableChange: entry.hasIncomparableChange,
|
|
182
|
-
incomparableReasons: [...entry.incomparableReasons],
|
|
183
|
-
}));
|
|
177
|
+
const timelineEntries = projectReportTimeline(timeline);
|
|
184
178
|
const selection = currentCaseEvidence(caseRecord);
|
|
185
179
|
const latest = selection.snapshot;
|
|
186
180
|
const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
|
|
@@ -221,7 +215,9 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
221
215
|
currentAssessment,
|
|
222
216
|
evidenceTimeline: timelineEntries,
|
|
223
217
|
analystResponse: {
|
|
218
|
+
...(caseRecord.workflowMetadata === undefined ? {} : { workflowMetadata: structuredClone(caseRecord.workflowMetadata) }),
|
|
224
219
|
evidencePins: caseRecord.evidencePins.map((item) => ({ ...item, limitations: [...item.limitations] })),
|
|
220
|
+
...(caseRecord.evidenceLinks === undefined ? {} : { evidenceLinks: structuredClone(caseRecord.evidenceLinks) }),
|
|
225
221
|
decisions: caseRecord.decisions.map((item) => ({ ...item, evidencePinIds: [...item.evidencePinIds] })),
|
|
226
222
|
actions: caseRecord.actions.map((item) => ({
|
|
227
223
|
...item,
|
|
@@ -270,11 +266,25 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
270
266
|
const md = buildMarkdown(json, options.includeAttribution !== false);
|
|
271
267
|
return { json, markdown: md };
|
|
272
268
|
}
|
|
269
|
+
function projectReportTimeline(timeline) {
|
|
270
|
+
return timeline.map(entry => ({
|
|
271
|
+
snapshot: pickKnownSnapshotFields(entry.snapshot), isBaseline: entry.isBaseline,
|
|
272
|
+
hasRepeatedObservation: entry.hasRepeatedObservation,
|
|
273
|
+
changes: entry.changes?.map(({ field, label, before, after, tone }) => ({ field, label, before, after, tone })) ?? null,
|
|
274
|
+
hasIncomparableChange: entry.hasIncomparableChange, incomparableReasons: [...entry.incomparableReasons],
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
273
277
|
/** Expected strict reader projection; historical disclosure wording is immutable. */
|
|
274
278
|
export function buildCaseReportVerificationProjection(caseRecord, options, schemaVersion) {
|
|
275
|
-
const
|
|
279
|
+
const generated = buildCaseReport(caseRecord, options).json;
|
|
276
280
|
if (schemaVersion === CASE_REPORT_SCHEMA_VERSION)
|
|
277
|
-
return
|
|
281
|
+
return generated;
|
|
282
|
+
if (caseRecord.evidenceHistory.some(snapshot => snapshot.webCollectionQuality !== undefined)) {
|
|
283
|
+
throw new TypeError('Published report formats cannot declare newer collection-quality fields.');
|
|
284
|
+
}
|
|
285
|
+
const current = { ...generated, evidenceTimeline: projectReportTimeline(publishedCaseEvidenceTimelineForVerification(caseRecord.evidenceHistory)) };
|
|
286
|
+
if (schemaVersion === LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION)
|
|
287
|
+
return { ...current, schemaVersion };
|
|
278
288
|
if (schemaVersion !== PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION) {
|
|
279
289
|
throw new TypeError('No strict Case report projection is defined for this version.');
|
|
280
290
|
}
|
|
@@ -325,6 +335,17 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
325
335
|
if (report.case.tags.length > 0) {
|
|
326
336
|
lines.push(`| Tags | ${escapeMarkdownInline(report.case.tags.join(', '))} |`);
|
|
327
337
|
}
|
|
338
|
+
const caseTypes = caseTypeSummary(report.analystResponse.workflowMetadata ? { workflowMetadata: report.analystResponse.workflowMetadata } : {});
|
|
339
|
+
if (caseTypes)
|
|
340
|
+
lines.push(`| Case types | ${escapeMarkdownInline(caseTypes)} |`);
|
|
341
|
+
const context = report.analystResponse.workflowMetadata?.investigationContext;
|
|
342
|
+
if (context) {
|
|
343
|
+
lines.push(`| Investigation objective | ${escapeMarkdownInline(context.objective)} |`);
|
|
344
|
+
lines.push(`| Incident URL (${context.urlRetention === 'exact' ? 'exact' : 'origin only'}) | ${escapeMarkdownInline(context.incidentUrl)} |`);
|
|
345
|
+
}
|
|
346
|
+
for (const target of report.analystResponse.workflowMetadata?.incidentTargets ?? []) {
|
|
347
|
+
lines.push(`| Incident link (${target.state}) | ${escapeMarkdownInline(target.url)} |`);
|
|
348
|
+
}
|
|
328
349
|
lines.push(`| Notes included | ${report.case.notesIncluded ? 'Yes' : 'No'} |`);
|
|
329
350
|
lines.push('');
|
|
330
351
|
// Current assessment
|
|
@@ -333,7 +354,7 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
333
354
|
if (report.currentAssessment) {
|
|
334
355
|
const a = report.currentAssessment;
|
|
335
356
|
lines.push(`- **Availability:** ${escapeMarkdownInline(formatReportValue(a.availability))}`);
|
|
336
|
-
lines.push(`- **Risk score:** ${
|
|
357
|
+
lines.push(`- **Risk score:** ${readableRiskScore(a)}`);
|
|
337
358
|
lines.push(`- **Risk model:** ${a.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.riskModelVersion))}`}`);
|
|
338
359
|
lines.push(`- **Brand Profile context:** ${escapeMarkdownInline(formatReportValue(a.profileContextState))}`);
|
|
339
360
|
if (a.profileContextLimitation)
|
|
@@ -388,7 +409,7 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
388
409
|
lines.push('**Key evidence:**');
|
|
389
410
|
lines.push('');
|
|
390
411
|
lines.push(`- Availability: ${escapeMarkdownInline(formatReportValue(snap.availability))}`);
|
|
391
|
-
lines.push(`- Risk score: ${
|
|
412
|
+
lines.push(`- Risk score: ${readableRiskScore(snap)}`);
|
|
392
413
|
lines.push(`- Risk model: ${snap.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(snap.riskModelVersion))}`}`);
|
|
393
414
|
lines.push(`- Brand Profile context: ${escapeMarkdownInline(formatReportValue(snap.profileContextState))}`);
|
|
394
415
|
if (snap.profileContextLimitation)
|
|
@@ -455,6 +476,8 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
455
476
|
lines.push('> Opportunity scores and factors use different or unversioned models, so their numeric difference is not treated as a domain change.');
|
|
456
477
|
if (reasons.includes('scan-depth'))
|
|
457
478
|
lines.push('> Capture depths differ, so unevaluated deep signals are not treated as additions or removals.');
|
|
479
|
+
if (reasons.includes('collection-quality'))
|
|
480
|
+
lines.push('> Missing or incomplete observations prevent comparison of affected registration, page, favicon or score fields.');
|
|
458
481
|
if (reasons.length === 0 || reasons.includes('other'))
|
|
459
482
|
lines.push('> The observations differ materially, but no reliable field-level comparison is available.');
|
|
460
483
|
lines.push('');
|
|
@@ -464,7 +487,7 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
464
487
|
lines.push('## Analyst Decision Packet');
|
|
465
488
|
lines.push('');
|
|
466
489
|
const response = report.analystResponse;
|
|
467
|
-
if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.observedEffects.reviews.length && !response.closures.records.length && !response.branches.length) {
|
|
490
|
+
if (!response.evidencePins.length && !response.evidenceLinks?.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.observedEffects.reviews.length && !response.closures.records.length && !response.branches.length) {
|
|
468
491
|
lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, response actions, independent effect reviews, closures, or manual investigation steps recorded.');
|
|
469
492
|
lines.push('');
|
|
470
493
|
}
|
|
@@ -539,6 +562,12 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
539
562
|
}
|
|
540
563
|
lines.push('');
|
|
541
564
|
}
|
|
565
|
+
if (response.evidenceLinks?.length) {
|
|
566
|
+
lines.push('### Analyst-declared evidence relationships', '');
|
|
567
|
+
for (const { link, missingPinIds, cyclic } of caseEvidenceLinkIssues(response.evidenceLinks, response.evidencePins))
|
|
568
|
+
lines.push(`- ${escapeMarkdownInline(link.fromPinId)} ${link.kind === 'derived_from' ? 'derived from' : 'shares a source with'} ${escapeMarkdownInline(link.toPinId)} · recorded ${link.createdAt}`, ` Basis: ${escapeMarkdownInline(link.basis)}`, ...(link.withdrawal ? [` Withdrawn ${link.withdrawal.at}: ${escapeMarkdownInline(link.withdrawal.reason)}`] : []), ...(missingPinIds.length ? [` Referenced pins not retained: ${missingPinIds.map(escapeMarkdownInline).join(', ')}`] : []), ...(cyclic ? [' Conflicting imported derivation cycle; no order is inferred.'] : []));
|
|
569
|
+
lines.push('These relationships record analyst attribution, not independent corroboration or added confidence.', '');
|
|
570
|
+
}
|
|
542
571
|
lines.push('### Decisions');
|
|
543
572
|
lines.push('');
|
|
544
573
|
if (!response.decisions.length)
|
|
@@ -564,6 +593,13 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
564
593
|
lines.push(` Route review due: ${escapeMarkdownInline(action.routeReviewAfter)}`);
|
|
565
594
|
if (action.originActionId)
|
|
566
595
|
lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
|
|
596
|
+
if (action.amendment)
|
|
597
|
+
lines.push(` Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}; request events: ${action.amendment.requestEventIds.map(escapeMarkdownInline).join(', ')}`);
|
|
598
|
+
for (const event of action.history)
|
|
599
|
+
if (event.evidenceRequest) {
|
|
600
|
+
const request = event.evidenceRequest;
|
|
601
|
+
lines.push(` Requested evidence [${request.state}]: ${escapeMarkdownInline(request.summary)}; deadline: ${request.dueAt ?? 'not stated'}; original packet: ${request.packetDigestSha256}${event.applied ? '' : ' (retained conflict)'}`, ` Prepared pins: ${request.evidencePinIds.map(escapeMarkdownInline).join(', ') || 'none'}${request.rationale ? `; ${escapeMarkdownInline(request.rationale)}` : ''}`);
|
|
602
|
+
}
|
|
567
603
|
if (action.dueAt)
|
|
568
604
|
lines.push(` Due: ${escapeMarkdownInline(action.dueAt)}`);
|
|
569
605
|
if (action.followUpAt)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { array, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
|
|
2
|
+
import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
|
|
3
|
+
import { MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contracts/case-portability.mjs";
|
|
4
|
+
import { SAFE_ID_RE } from "./case-response-values.mjs";
|
|
5
|
+
function identifier(value, label) {
|
|
6
|
+
const result = text(value, label, 64);
|
|
7
|
+
if (!SAFE_ID_RE.test(result))
|
|
8
|
+
throw new TypeError(`${label} is invalid.`);
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
function identifiers(value, label) {
|
|
12
|
+
const result = array(value, label, MAX_DECISION_PIN_REFERENCES).map(item => identifier(item, label));
|
|
13
|
+
if (new Set(result).size !== result.length)
|
|
14
|
+
throw new TypeError(`${label} contains duplicate identities.`);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
function packetDigest(value) {
|
|
18
|
+
if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
|
|
19
|
+
throw new TypeError('Select a recorded submitted-packet digest.');
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function readCaseEvidenceRequest(raw) {
|
|
23
|
+
if (raw === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
assertWorkspaceInputGraph(raw, 'Requested evidence');
|
|
26
|
+
const row = exact(raw, ['id', 'packetDigestSha256', 'summary', 'dueAt', 'state', 'evidencePinIds', 'rationale', 'previousEventIds'], 'Requested evidence');
|
|
27
|
+
iso(row.dueAt, 'Requested evidence deadline', true);
|
|
28
|
+
const summary = text(row.summary, 'Requested evidence summary', MAX_RESPONSE_VALUE_LENGTH).trim();
|
|
29
|
+
const rationale = text(row.rationale, 'Requested evidence rationale', MAX_RESPONSE_RATIONALE_LENGTH, true).trim();
|
|
30
|
+
const state = enumeration(row.state, ['requested', 'prepared', 'unavailable'], 'Requested evidence state');
|
|
31
|
+
const evidencePinIds = identifiers(row.evidencePinIds, 'Requested evidence pins');
|
|
32
|
+
if (!summary || (state === 'prepared' && !evidencePinIds.length) || (state === 'unavailable' && !rationale)) {
|
|
33
|
+
throw new TypeError('Prepared evidence needs retained pins; unavailable evidence needs an explanation.');
|
|
34
|
+
}
|
|
35
|
+
return { id: identifier(row.id, 'Evidence request identity'), packetDigestSha256: packetDigest(row.packetDigestSha256),
|
|
36
|
+
summary, dueAt: row.dueAt, state, evidencePinIds, rationale,
|
|
37
|
+
previousEventIds: identifiers(row.previousEventIds, 'Prior evidence request events') };
|
|
38
|
+
}
|
|
39
|
+
export function readCasePacketAmendment(raw) {
|
|
40
|
+
if (raw === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
assertWorkspaceInputGraph(raw, 'Packet amendment');
|
|
43
|
+
const row = exact(raw, ['packetDigestSha256', 'requestEventIds'], 'Packet amendment');
|
|
44
|
+
const requestEventIds = identifiers(row.requestEventIds, 'Amendment request events');
|
|
45
|
+
if (!requestEventIds.length)
|
|
46
|
+
throw new TypeError('Select at least one prepared evidence request.');
|
|
47
|
+
return { packetDigestSha256: packetDigest(row.packetDigestSha256), requestEventIds };
|
|
48
|
+
}
|
|
49
|
+
export function assertEvidenceRequestEvent(request, event) {
|
|
50
|
+
if (request && (event.nextState !== 'acknowledged' || !['submitted', 'acknowledged'].includes(String(event.previousState))
|
|
51
|
+
|| (request.state === 'requested'
|
|
52
|
+
? event.sourceClass !== 'provider' || event.providerOutcome !== 'more_information_requested' || request.evidencePinIds.length > 0 || Boolean(request.rationale) || request.previousEventIds.length > 0
|
|
53
|
+
: event.sourceClass !== 'analyst' || request.previousEventIds.length === 0)))
|
|
54
|
+
throw new TypeError('Requested evidence has incompatible event provenance.');
|
|
55
|
+
}
|
|
56
|
+
/** A digest belongs to the explicit delivery event, never the latest mutable reference. */
|
|
57
|
+
export function submittedPacketReceipts(action) {
|
|
58
|
+
return action.history.flatMap(event => {
|
|
59
|
+
const match = /^response-packet-sha256:([a-f0-9]{64})$/u.exec(event.reference ?? '');
|
|
60
|
+
return event.applied && event.previousState === 'authorised' && event.nextState === 'submitted'
|
|
61
|
+
&& event.sourceClass === 'analyst' && match
|
|
62
|
+
? [{ eventId: event.id, digestSha256: match[1], occurredAt: event.occurredAt }] : [];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function latestEvidenceRequests(action) {
|
|
66
|
+
const events = action.history.filter((event) => event.applied && Boolean(event.evidenceRequest));
|
|
67
|
+
const replaced = new Set(events.flatMap(event => event.evidenceRequest.previousEventIds
|
|
68
|
+
.filter(id => events.some(parent => parent.id === id && parent.evidenceRequest.id === event.evidenceRequest.id))));
|
|
69
|
+
return events.filter(event => !replaced.has(event.id));
|
|
70
|
+
}
|
|
71
|
+
/** Causal links order same-time reviews and retain concurrent branches for explicit reconciliation. */
|
|
72
|
+
export function assertEvidenceRequestHistory(events, missingHistory) {
|
|
73
|
+
const byId = new Map(events.map(event => [event.id, event]));
|
|
74
|
+
const complete = new Set(), visiting = new Set();
|
|
75
|
+
const visit = (id) => {
|
|
76
|
+
if (complete.has(id))
|
|
77
|
+
return;
|
|
78
|
+
if (visiting.has(id))
|
|
79
|
+
throw new TypeError('Requested evidence history contains a cycle.');
|
|
80
|
+
visiting.add(id);
|
|
81
|
+
const request = byId.get(id)?.evidenceRequest;
|
|
82
|
+
for (const parentId of request?.previousEventIds ?? []) {
|
|
83
|
+
const parent = byId.get(parentId);
|
|
84
|
+
if (!parent && missingHistory)
|
|
85
|
+
continue;
|
|
86
|
+
if (!parent?.evidenceRequest || parent.evidenceRequest.id !== request.id
|
|
87
|
+
|| parent.evidenceRequest.summary !== request.summary || parent.evidenceRequest.dueAt !== request.dueAt
|
|
88
|
+
|| parent.evidenceRequest.packetDigestSha256 !== request.packetDigestSha256)
|
|
89
|
+
throw new TypeError('Requested evidence history does not preserve its original request.');
|
|
90
|
+
visit(parentId);
|
|
91
|
+
}
|
|
92
|
+
visiting.delete(id);
|
|
93
|
+
complete.add(id);
|
|
94
|
+
};
|
|
95
|
+
for (const event of events)
|
|
96
|
+
visit(event.id);
|
|
97
|
+
}
|
|
98
|
+
export function assertEvidenceRequestTransition(action, request, sourceClass, providerOutcome, validPinIds) {
|
|
99
|
+
if (!['submitted', 'acknowledged'].includes(action.state)
|
|
100
|
+
|| !submittedPacketReceipts(action).some(receipt => receipt.digestSha256 === request.packetDigestSha256)) {
|
|
101
|
+
throw new Error('Requested evidence must refer to an explicitly recorded packet delivery on an open action.');
|
|
102
|
+
}
|
|
103
|
+
const previousEvents = latestEvidenceRequests(action).filter(event => event.evidenceRequest.id === request.id);
|
|
104
|
+
if (!previousEvents.length) {
|
|
105
|
+
if (request.state !== 'requested' || sourceClass !== 'provider' || providerOutcome !== 'more_information_requested'
|
|
106
|
+
|| request.evidencePinIds.length || request.rationale || request.previousEventIds.length)
|
|
107
|
+
throw new Error('A new evidence request must record the provider request before preparation.');
|
|
108
|
+
}
|
|
109
|
+
else if (sourceClass !== 'analyst' || request.state === 'requested'
|
|
110
|
+
|| previousEvents.some(({ evidenceRequest: previous }) => request.summary !== previous.summary
|
|
111
|
+
|| request.dueAt !== previous.dueAt || request.packetDigestSha256 !== previous.packetDigestSha256)) {
|
|
112
|
+
throw new Error('Keep the original provider request unchanged; append a separate request for changed requirements.');
|
|
113
|
+
}
|
|
114
|
+
if (JSON.stringify(request.previousEventIds.slice().sort()) !== JSON.stringify(previousEvents.map(event => event.id).sort())) {
|
|
115
|
+
throw new Error('The evidence preparation changed. Review all current request events before saving.');
|
|
116
|
+
}
|
|
117
|
+
if (validPinIds && request.evidencePinIds.some(id => !validPinIds.has(id)))
|
|
118
|
+
throw new Error('Requested evidence refers to a pin no longer retained in this Case.');
|
|
119
|
+
}
|
|
120
|
+
export function assertPacketAmendment(actions, originActionId, amendment) {
|
|
121
|
+
const original = actions.find(action => action.id === originActionId);
|
|
122
|
+
if (!original || !submittedPacketReceipts(original).some(receipt => receipt.digestSha256 === amendment.packetDigestSha256)) {
|
|
123
|
+
throw new Error('An amendment requires the exact recorded packet delivery from its originating action.');
|
|
124
|
+
}
|
|
125
|
+
const latest = latestEvidenceRequests(original);
|
|
126
|
+
if (amendment.requestEventIds.some(id => !latest.some(event => event.id === id
|
|
127
|
+
&& latest.filter(other => other.evidenceRequest.id === event.evidenceRequest.id).length === 1
|
|
128
|
+
&& event.evidenceRequest.state === 'prepared' && event.evidenceRequest.packetDigestSha256 === amendment.packetDigestSha256))) {
|
|
129
|
+
throw new Error('Review the current prepared evidence requests before creating or submitting an amendment.');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function assertPacketAmendmentSelection(actions, selectedActionId, selectedPinIds) {
|
|
133
|
+
const action = actions.find(item => item.id === selectedActionId);
|
|
134
|
+
if (!action?.amendment)
|
|
135
|
+
return;
|
|
136
|
+
assertPacketAmendment(actions, action.originActionId, action.amendment);
|
|
137
|
+
const original = actions.find(item => item.id === action.originActionId);
|
|
138
|
+
const required = latestEvidenceRequests(original).filter(event => action.amendment.requestEventIds.includes(event.id))
|
|
139
|
+
.flatMap(event => event.evidenceRequest.evidencePinIds);
|
|
140
|
+
if (required.some(id => !selectedPinIds.has(id)))
|
|
141
|
+
throw new TypeError('Select every prepared request pin for this amendment; the original packet is not regenerated.');
|
|
142
|
+
}
|
|
143
|
+
export function evidenceRequestDelivery(actions, actionId, eventId) {
|
|
144
|
+
return actions.flatMap(action => action.originActionId === actionId && action.amendment?.requestEventIds.includes(eventId)
|
|
145
|
+
? submittedPacketReceipts(action).map(receipt => ({ actionId: action.id, ...receipt })) : []);
|
|
146
|
+
}
|
|
@@ -1,9 +1,76 @@
|
|
|
1
1
|
// Append-only response actions: transitions, reconciliation and bounded history.
|
|
2
|
+
import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertEvidenceRequestTransition, assertPacketAmendment } from "./case-requested-evidence.mjs";
|
|
3
|
+
import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
|
|
4
|
+
import { emailRecipient } from "../evidence/email-recipient.mjs";
|
|
5
|
+
import { responseRouteFreshness } from "./response-route-freshness.mjs";
|
|
2
6
|
import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
3
7
|
import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
|
|
4
8
|
import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
|
|
5
9
|
import { SAFE_ID_RE, boundedCounter, compareCodeUnits, deterministicId, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
|
|
6
10
|
const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
|
|
11
|
+
/** Advisory page-memory comparison; neither authenticated identity nor a mutation. */
|
|
12
|
+
export function compareCaseIncomingNotice(action, report, input) {
|
|
13
|
+
const bounded = (value, maximum) => {
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return '';
|
|
16
|
+
if (value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
|
|
17
|
+
throw new TypeError('Notice comparison fields must be bounded single-line values.');
|
|
18
|
+
return value.trim();
|
|
19
|
+
};
|
|
20
|
+
const reference = bounded(input.reference, MAX_RESPONSE_REFERENCE_LENGTH);
|
|
21
|
+
const organisation = bounded(input.claimedOrganisation, MAX_RESPONSE_LABEL_LENGTH);
|
|
22
|
+
const recipient = emailRecipient(action?.recipient);
|
|
23
|
+
let recipientHostname = recipient ? recipient.slice(recipient.lastIndexOf('@') + 1).toLowerCase() : null;
|
|
24
|
+
if (!recipientHostname && action) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(action.recipient);
|
|
27
|
+
if (['https:', 'http:'].includes(url.protocol) && !url.username && !url.password)
|
|
28
|
+
recipientHostname = url.hostname;
|
|
29
|
+
}
|
|
30
|
+
catch { /* A free-text recipient does not establish a route hostname. */ }
|
|
31
|
+
}
|
|
32
|
+
const comparisons = [];
|
|
33
|
+
let senderDomains = 0, omittedSenderDomains = 0;
|
|
34
|
+
for (const identity of report.identities) {
|
|
35
|
+
if (identity.role !== 'from' && identity.role !== 'reply_to')
|
|
36
|
+
continue;
|
|
37
|
+
if (senderDomains >= MAX_MESSAGE_PARTS * 5) {
|
|
38
|
+
omittedSenderDomains += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
senderDomains += 1;
|
|
42
|
+
comparisons.push({
|
|
43
|
+
label: `Part ${identity.part} ${identity.role.replaceAll('_', ' ')} domain: ${identity.domain}`,
|
|
44
|
+
state: recipientHostname ? identity.domain.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
|
|
45
|
+
explanation: 'Compared only with the selected action recipient hostname. Domain agreement does not authenticate the sender; different domains may require independent clarification.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (!senderDomains)
|
|
49
|
+
comparisons.push({ label: 'Sender domains', state: 'unknown', explanation: 'No From or Reply-To domain was retained. Exact sender addresses are not retained.' });
|
|
50
|
+
if (omittedSenderDomains)
|
|
51
|
+
comparisons.push({ label: 'Additional sender context', state: 'unknown', explanation: `${omittedSenderDomains} additional From or Reply-To claims exceed the local comparison cap. No overall sender conclusion is available.` });
|
|
52
|
+
if (!report.links.length)
|
|
53
|
+
comparisons.push({ label: 'Supplied destinations', state: 'unknown', explanation: 'No supported destination was retained. Missing or partial extraction is not proof that no destination exists.' });
|
|
54
|
+
for (const link of report.links.slice(0, MAX_INTAKE_LINKS))
|
|
55
|
+
comparisons.push({
|
|
56
|
+
label: `Destination hostname: ${link.hostname}`,
|
|
57
|
+
state: recipientHostname ? link.hostname.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
|
|
58
|
+
explanation: 'Hostname context only, not an exact route match or authority check. Supplied destinations have not been opened.',
|
|
59
|
+
});
|
|
60
|
+
const deliveryReferences = action?.history.filter(event => event.applied && event.previousState === 'authorised'
|
|
61
|
+
&& event.nextState === 'submitted' && event.sourceClass === 'analyst' && event.reference).map(event => event.reference) ?? [];
|
|
62
|
+
comparisons.push({ label: 'Delivery reference', state: !reference || !deliveryReferences.length ? 'unknown'
|
|
63
|
+
: deliveryReferences.includes(reference) ? 'match' : action.historyOmitted > 0 ? 'unknown' : 'mismatch',
|
|
64
|
+
explanation: 'Compared with retained, applied submission events only. Acknowledgements and later updates have separate references; missing submission history remains unknown. Matching text does not authenticate a notice.' });
|
|
65
|
+
comparisons.push({ label: 'Latest action reference', state: reference && action?.reference ? reference === action.reference ? 'match' : 'mismatch' : 'unknown',
|
|
66
|
+
explanation: 'Compared with the latest retained reference from any applied action event. This may be an acknowledgement or later update, not the delivery reference.' });
|
|
67
|
+
comparisons.push({ label: 'Claimed organisation', state: 'unknown', explanation: organisation ? 'The entered organisation is an analyst-supplied claim. Retained contact-source text is not a structured authenticated organisation identity.' : 'No organisation claim was supplied; no authenticated organisation identity is inferred.' });
|
|
68
|
+
return {
|
|
69
|
+
comparisons,
|
|
70
|
+
routeFreshness: action ? responseRouteFreshness(action.routeObservedAt, action.routeReviewAfter, input.now) : 'unknown',
|
|
71
|
+
confirmation: input.confirmedOutOfBand ? 'analyst_reported_out_of_band' : 'not_reported',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
7
74
|
const ACTION_STATES = new Set(CASE_ACTION_STATES);
|
|
8
75
|
const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
|
|
9
76
|
const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
|
|
@@ -29,6 +96,20 @@ const ACTION_TRANSITIONS = Object.freeze({
|
|
|
29
96
|
acknowledged: new Set(['acknowledged', 'terminal']),
|
|
30
97
|
terminal: new Set(),
|
|
31
98
|
});
|
|
99
|
+
// Terminal also includes deliberate withdrawal before review. This is stage
|
|
100
|
+
// completion, not proof of authorisation or submission.
|
|
101
|
+
const RESPONSE_DECISION_COMPLETE = Object.freeze({
|
|
102
|
+
drafting: false,
|
|
103
|
+
ready_for_review: false,
|
|
104
|
+
reviewed: true,
|
|
105
|
+
authorised: true,
|
|
106
|
+
submitted: true,
|
|
107
|
+
acknowledged: true,
|
|
108
|
+
terminal: true,
|
|
109
|
+
});
|
|
110
|
+
export function caseActionCompletesResponseDecision(state) {
|
|
111
|
+
return RESPONSE_DECISION_COMPLETE[state];
|
|
112
|
+
}
|
|
32
113
|
function bytes(value) {
|
|
33
114
|
try {
|
|
34
115
|
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
@@ -76,6 +157,10 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
76
157
|
&& sourceClass !== 'analyst' && !migrationSnapshot)
|
|
77
158
|
return null;
|
|
78
159
|
const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
|
|
160
|
+
if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
161
|
+
throw new Error('Requested evidence requires Case schema 17.');
|
|
162
|
+
const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
|
|
163
|
+
assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
|
|
79
164
|
const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
80
165
|
? item.providerOutcome
|
|
81
166
|
: null;
|
|
@@ -104,6 +189,7 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
104
189
|
: null;
|
|
105
190
|
const eventMaterial = {
|
|
106
191
|
previousState,
|
|
192
|
+
...(evidenceRequest ? { evidenceRequest } : {}),
|
|
107
193
|
nextState,
|
|
108
194
|
occurredAt,
|
|
109
195
|
sourceClass,
|
|
@@ -283,6 +369,10 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
283
369
|
const createdAt = iso(item.createdAt, fallback, options);
|
|
284
370
|
const actionId = safeId(item.id, 'action', { recipient, createdAt });
|
|
285
371
|
const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
|
|
372
|
+
assertEvidenceRequestHistory(history.history, history.omitted > 0);
|
|
373
|
+
if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
374
|
+
throw new Error('Packet amendments require Case schema 17.');
|
|
375
|
+
const amendment = readCasePacketAmendment(item.amendment);
|
|
286
376
|
const applied = history.history.filter((event) => event.applied);
|
|
287
377
|
const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
|
|
288
378
|
const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
|
|
@@ -297,6 +387,7 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
297
387
|
: null;
|
|
298
388
|
return {
|
|
299
389
|
id: actionId,
|
|
390
|
+
...(amendment ? { amendment } : {}),
|
|
300
391
|
type: legacyPlatformReview
|
|
301
392
|
? 'platform_report'
|
|
302
393
|
: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
|
|
@@ -481,6 +572,9 @@ export function appendCaseAction(current, raw, now) {
|
|
|
481
572
|
: null;
|
|
482
573
|
if (item.originActionId != null && !originActionId)
|
|
483
574
|
throw new Error('A follow-on action requires an existing originating action.');
|
|
575
|
+
const amendment = readCasePacketAmendment(item.amendment);
|
|
576
|
+
if (amendment)
|
|
577
|
+
assertPacketAmendment(current, originActionId, amendment);
|
|
484
578
|
const history = [{
|
|
485
579
|
id: freshId('action-event'),
|
|
486
580
|
previousState: null,
|
|
@@ -537,6 +631,12 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
537
631
|
|| !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
|
|
538
632
|
throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
|
|
539
633
|
}
|
|
634
|
+
const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
|
|
635
|
+
if (evidenceRequest)
|
|
636
|
+
assertEvidenceRequestTransition(action, evidenceRequest, sourceClass, item.providerOutcome, validPinIds);
|
|
637
|
+
if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
|
|
638
|
+
assertPacketAmendment(current, action.originActionId, action.amendment);
|
|
639
|
+
}
|
|
540
640
|
const occurredAt = optionalIso(item.occurredAt) ?? now;
|
|
541
641
|
const event = normalizeActionEvent({
|
|
542
642
|
...item,
|
|
@@ -558,7 +658,7 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
558
658
|
return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
|
|
559
659
|
}
|
|
560
660
|
const ACTION_REVIEW_MATERIAL_FIELDS = [
|
|
561
|
-
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId',
|
|
661
|
+
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment',
|
|
562
662
|
];
|
|
563
663
|
export function updateCaseAction(current, raw, now, validPinIds) {
|
|
564
664
|
const patch = record(raw);
|
|
@@ -587,6 +687,8 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
587
687
|
throw new Error('An action requires a recipient or internal owner.');
|
|
588
688
|
const materialChanged = ACTION_REVIEW_MATERIAL_FIELDS
|
|
589
689
|
.some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
|
|
690
|
+
if (materialChanged && updated.amendment)
|
|
691
|
+
assertPacketAmendment(current, updated.originActionId, updated.amendment);
|
|
590
692
|
if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
|
|
591
693
|
throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
|
|
592
694
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
|
|
6
6
|
import { CASE_ASSERTION_EXTERNAL_ENTITY_TYPES, CASE_ASSERTION_EXTERNAL_FORMATS, CASE_ASSERTION_KINDS, CASE_ASSERTION_STATES, CASE_DECISION_CONFIDENCE_LEVELS, CASE_EVIDENCE_RELATION_STANCES, CASE_MANUAL_TRAIL_KINDS, CASE_SIGHTING_CATEGORIES, CASE_SIGHTING_STATES, CASE_TRANSITION_EXPECTATIONS, } from "./case-response-records.mjs";
|
|
7
7
|
import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
|
|
8
|
-
import { isValidAsciiHostname } from "
|
|
8
|
+
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
9
9
|
import { readCaseRecheckContext } from "./case-recheck-model.mjs";
|
|
10
10
|
export * from "./case-response-records.mjs";
|
|
11
11
|
export * from "./case-response-actions.mjs";
|
|
@@ -195,6 +195,8 @@ export function appendCaseEvidencePin(current, raw, now) {
|
|
|
195
195
|
export function appendCaseEvidencePins(current, raw, now) {
|
|
196
196
|
if (!Array.isArray(raw) || !raw.length)
|
|
197
197
|
throw new Error('An evidence checkpoint requires at least one selected fact.');
|
|
198
|
+
if (raw.length > MAX_CASE_CHECKPOINT_FACTS)
|
|
199
|
+
throw new Error(`A checkpoint can retain at most ${MAX_CASE_CHECKPOINT_FACTS} selected facts. No evidence was saved.`);
|
|
198
200
|
let output = [...current];
|
|
199
201
|
let added = 0;
|
|
200
202
|
for (const item of raw.slice(0, MAX_CASE_CHECKPOINT_FACTS)) {
|
|
@@ -240,6 +242,12 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
|
|
|
240
242
|
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
|
|
241
243
|
.slice(-MAX_CASE_DECISIONS);
|
|
242
244
|
}
|
|
245
|
+
/** Counts decisions, not links; references to pins no longer retained do not
|
|
246
|
+
* support a decision. Inputs have already passed their owning normalisers. */
|
|
247
|
+
export function countEvidenceLinkedCaseDecisions(decisions, pins) {
|
|
248
|
+
const retainedPinIds = new Set(pins.map(pin => pin.id));
|
|
249
|
+
return decisions.filter(decision => decision.evidencePinIds.some(id => retainedPinIds.has(id))).length;
|
|
250
|
+
}
|
|
243
251
|
export function appendCaseDecision(current, raw, now, validPinIds) {
|
|
244
252
|
if (current.length >= MAX_CASE_DECISIONS) {
|
|
245
253
|
throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { MAX_CASE_CLOSURES, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
3
3
|
import { latestObservationCohort, } from "../evidence/latest-observations.mjs";
|
|
4
4
|
import { readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
5
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
5
6
|
import { CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, } from "./case-response-records.mjs";
|
|
6
7
|
import { COMPLETENESS, SAFE_ID_RE, boundedCounter, compareCodeUnits, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
|
|
7
8
|
const OBSERVED_EFFECT_STATES = new Set(CASE_OBSERVED_EFFECT_STATES);
|
|
@@ -222,6 +223,31 @@ export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, valid
|
|
|
222
223
|
]),
|
|
223
224
|
};
|
|
224
225
|
}
|
|
226
|
+
/** New-write policy only. Historical normalisation must not re-adjudicate a closure. */
|
|
227
|
+
export function caseClosureReviewBlocker(reason, review, now) {
|
|
228
|
+
if (reason !== 'independently_not_reproduced' && reason !== 'infrastructure_changed')
|
|
229
|
+
return null;
|
|
230
|
+
const expected = reason === 'independently_not_reproduced' ? 'not_reproduced' : 'changed';
|
|
231
|
+
const invalidLink = reason === 'independently_not_reproduced'
|
|
232
|
+
? 'This closure reason requires a linked independent not-reproduced review.'
|
|
233
|
+
: 'This closure reason requires a linked independent changed review.';
|
|
234
|
+
if (!review || review.state !== expected)
|
|
235
|
+
return invalidLink;
|
|
236
|
+
const observedAt = normalizeExplicitIsoTimestamp(review.observedAt);
|
|
237
|
+
const createdAt = normalizeExplicitIsoTimestamp(review.createdAt);
|
|
238
|
+
const closedAt = normalizeExplicitIsoTimestamp(now);
|
|
239
|
+
if (!observedAt || !createdAt || !closedAt
|
|
240
|
+
|| Date.parse(observedAt) > Date.parse(closedAt)
|
|
241
|
+
|| Date.parse(createdAt) > Date.parse(closedAt))
|
|
242
|
+
return invalidLink;
|
|
243
|
+
if (reason === 'independently_not_reproduced') {
|
|
244
|
+
if (review.completeness !== 'complete')
|
|
245
|
+
return 'Independent non-reproduction closure requires a complete observation. Keep this limited review and collect or record a complete recheck.';
|
|
246
|
+
if (review.recheck && review.recheck.conditionsMatch !== 'comparable')
|
|
247
|
+
return 'Independent non-reproduction closure requires comparable recheck conditions.';
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
225
251
|
export function appendCaseClosure(current, raw, now, observedEffects, actions) {
|
|
226
252
|
const item = record(raw);
|
|
227
253
|
const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
|
|
@@ -233,14 +259,9 @@ export function appendCaseClosure(current, raw, now, observedEffects, actions) {
|
|
|
233
259
|
const action = typeof item.actionId === 'string'
|
|
234
260
|
? actions.find((candidate) => candidate.id === item.actionId) ?? null
|
|
235
261
|
: null;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
throw new Error(
|
|
239
|
-
}
|
|
240
|
-
if (reason === 'infrastructure_changed'
|
|
241
|
-
&& (review?.state !== 'changed' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
|
|
242
|
-
throw new Error('This closure reason requires a linked independent changed review.');
|
|
243
|
-
}
|
|
262
|
+
const reviewBlocker = caseClosureReviewBlocker(reason, review, now);
|
|
263
|
+
if (reviewBlocker)
|
|
264
|
+
throw new Error(reviewBlocker);
|
|
244
265
|
if (reason === 'provider_reported_resolution_not_independently_checked'
|
|
245
266
|
&& (action?.providerOutcome !== 'provider_reports_resolved'
|
|
246
267
|
|| !action.history.some((event) => event.applied
|