@slicedearth/whoisleuth-cli 1.47.4 → 2.0.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/README.md +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/project-metadata.mjs +1 -1
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +13 -5
- package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
- package/third-party-notices.txt +14 -14
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -255
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs}
RENAMED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.
|
|
3
|
+
import { CASE_DISPOSITIONS, CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
|
|
4
|
+
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
|
|
5
|
+
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
4
6
|
// Forward-version policy (two distinct guarantees):
|
|
5
7
|
// - A locally-stored envelope that declares a version greater than this is
|
|
6
8
|
// still read best-effort on load (known fields kept, unknown dropped), but
|
|
@@ -47,6 +49,11 @@ export const REGISTERED_LIKE = new Set([
|
|
|
47
49
|
// URL/DOM/query-string-safe id shape. UUIDs satisfy this; anything else is
|
|
48
50
|
// treated as untrusted and deterministically repaired.
|
|
49
51
|
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
52
|
+
// Unicode U-labels can be substantially longer than their canonical A-label
|
|
53
|
+
// representation (for example, decomposed combining sequences). Keep the raw
|
|
54
|
+
// work bound finite, then enforce DNS' 253/63-byte limits on the URL parser's
|
|
55
|
+
// canonical ASCII hostname below.
|
|
56
|
+
export const MAX_EVIDENCE_DOMAIN_INPUT_LENGTH = 1024;
|
|
50
57
|
export function objectRecord(value) {
|
|
51
58
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
52
59
|
? value
|
|
@@ -89,47 +96,56 @@ export function hashString(value) {
|
|
|
89
96
|
export function deterministicId(domain) {
|
|
90
97
|
return `c-${hashString(domain)}`;
|
|
91
98
|
}
|
|
99
|
+
export { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
92
100
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* whitespace/control characters, underscores, empty/overlong/hyphen-edged
|
|
98
|
-
* labels, and undotted names. Returns '' for anything unusable.
|
|
99
|
-
* @param {unknown} value
|
|
100
|
-
* @returns {string}
|
|
101
|
+
* Canonicalizes a hostname supplied as source evidence without applying the
|
|
102
|
+
* analyst-input conveniences in normalizeDomain. URI syntax, credentials,
|
|
103
|
+
* ports, paths, queries, fragments, separators, whitespace, and controls make
|
|
104
|
+
* the observation invalid rather than being silently discarded.
|
|
101
105
|
*/
|
|
102
|
-
export function
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
export function normalizeEvidenceDomain(value) {
|
|
107
|
+
if (typeof value !== 'string' || !value || value !== value.trim())
|
|
108
|
+
return '';
|
|
109
|
+
if (value.length > MAX_EVIDENCE_DOMAIN_INPUT_LENGTH)
|
|
110
|
+
return '';
|
|
111
|
+
if (/[\s\x00-\x1f\x7f%@/:?#\\]/u.test(value))
|
|
105
112
|
return '';
|
|
106
|
-
|
|
113
|
+
const withoutRootDot = value.endsWith('.') ? value.slice(0, -1) : value;
|
|
114
|
+
if (!withoutRootDot || withoutRootDot.endsWith('.'))
|
|
115
|
+
return '';
|
|
116
|
+
let hostname = '';
|
|
107
117
|
try {
|
|
108
|
-
|
|
109
|
-
hostname = new URL(hasScheme ? raw : `http://${raw}`).hostname;
|
|
118
|
+
hostname = new URL(`http://${withoutRootDot}`).hostname.toLowerCase();
|
|
110
119
|
}
|
|
111
120
|
catch {
|
|
112
121
|
return '';
|
|
113
122
|
}
|
|
114
|
-
hostname
|
|
115
|
-
if (!hostname || hostname.length > MAX_DOMAIN_LENGTH)
|
|
116
|
-
return '';
|
|
117
|
-
// A leftover ':' or '[' means a port/IPv6 the host parser preserved.
|
|
118
|
-
if (hostname.includes(':') || hostname.startsWith('['))
|
|
123
|
+
if (!hostname || hostname.length > MAX_DOMAIN_LENGTH || hostname !== hostname.replace(/\.$/u, ''))
|
|
119
124
|
return '';
|
|
120
125
|
const labels = hostname.split('.');
|
|
121
126
|
if (labels.length < 2)
|
|
122
127
|
return '';
|
|
123
|
-
const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
// An all-numeric final label is an IPv4 address, never a hostname TLD.
|
|
129
|
-
if (/^[0-9]+$/.test(labels.at(-1) ?? ''))
|
|
128
|
+
const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
129
|
+
if (labels.some((label) => !label || label.length > 63 || !labelPattern.test(label)))
|
|
130
|
+
return '';
|
|
131
|
+
if (/^[0-9]+$/u.test(labels.at(-1) ?? ''))
|
|
130
132
|
return '';
|
|
131
133
|
return hostname;
|
|
132
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Retains an exact submitted hostname only when it is strict evidence input
|
|
137
|
+
* for the Case's canonical registrable identity. This boundary deliberately
|
|
138
|
+
* does not repair URI syntax or infer a hostname from other evidence.
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeEvidenceHostnameForCase(value, caseDomain) {
|
|
141
|
+
const hostname = normalizeEvidenceDomain(value);
|
|
142
|
+
const canonicalCase = normalizeEvidenceDomain(caseDomain);
|
|
143
|
+
if (!hostname || !canonicalCase)
|
|
144
|
+
return null;
|
|
145
|
+
if (canonicalRegistrableDomain(canonicalCase) !== canonicalCase)
|
|
146
|
+
return null;
|
|
147
|
+
return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
|
|
148
|
+
}
|
|
133
149
|
export function normalizeStatus(value) {
|
|
134
150
|
return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
|
|
135
151
|
}
|
|
@@ -151,6 +167,17 @@ export function isoOrNull(value) {
|
|
|
151
167
|
}
|
|
152
168
|
return null;
|
|
153
169
|
}
|
|
170
|
+
/** Canonical instant for versioned case data; current schemas require a zone. */
|
|
171
|
+
export function caseTimestampOrNull(value, sourceVersion) {
|
|
172
|
+
const explicit = normalizeExplicitIsoTimestamp(value);
|
|
173
|
+
if (explicit)
|
|
174
|
+
return explicit;
|
|
175
|
+
return sourceVersion !== undefined
|
|
176
|
+
&& sourceVersion !== null
|
|
177
|
+
&& sourceVersion < CASE_SCHEMA_VERSION
|
|
178
|
+
? normalizeLegacyIsoTimestamp(value)
|
|
179
|
+
: null;
|
|
180
|
+
}
|
|
154
181
|
export function isoOrNow(value, fallback) {
|
|
155
182
|
return isoOrNull(value) || fallback;
|
|
156
183
|
}
|
|
@@ -194,12 +221,12 @@ export function normalizeNoteBody(body) {
|
|
|
194
221
|
* @param {string | null} fallback
|
|
195
222
|
* @returns {CaseNote | null}
|
|
196
223
|
*/
|
|
197
|
-
function normalizeNote(raw, fallback) {
|
|
224
|
+
function normalizeNote(raw, fallback, sourceVersion) {
|
|
198
225
|
const record = objectRecord(raw);
|
|
199
226
|
const body = normalizeNoteBody(record.body);
|
|
200
227
|
if (!body)
|
|
201
228
|
return null;
|
|
202
|
-
const createdAt =
|
|
229
|
+
const createdAt = caseTimestampOrNull(record.createdAt, sourceVersion) || fallback;
|
|
203
230
|
if (!createdAt)
|
|
204
231
|
return null;
|
|
205
232
|
return {
|
|
@@ -215,13 +242,13 @@ function normalizeNote(raw, fallback) {
|
|
|
215
242
|
* updatedAt for imports, never the current time)
|
|
216
243
|
* @returns {CaseNote[]}
|
|
217
244
|
*/
|
|
218
|
-
export function normalizeNotes(value, fallback) {
|
|
245
|
+
export function normalizeNotes(value, fallback, sourceVersion) {
|
|
219
246
|
if (!Array.isArray(value))
|
|
220
247
|
return [];
|
|
221
248
|
const seen = new Set();
|
|
222
249
|
const notes = [];
|
|
223
250
|
for (const raw of value) {
|
|
224
|
-
const note = normalizeNote(raw, fallback);
|
|
251
|
+
const note = normalizeNote(raw, fallback, sourceVersion);
|
|
225
252
|
if (!note || seen.has(note.id))
|
|
226
253
|
continue;
|
|
227
254
|
seen.add(note.id);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Stable case-record facade. Core validation, evidence history/comparison,
|
|
2
|
+
// and analyst-record operations remain framework-neutral and independently
|
|
3
|
+
// testable while existing consumers keep one compatibility import.
|
|
4
|
+
export * from "./case-record-contracts.mjs";
|
|
5
|
+
export * from "./case-record-core.mjs";
|
|
6
|
+
export * from "./case-brand-profile-references.mjs";
|
|
7
|
+
export * from "./case-evidence-model.mjs";
|
|
8
|
+
export * from "./case-record-operations.mjs";
|
|
9
|
+
export * from "./case-investigation-branch-model.mjs";
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.
|
|
4
|
-
import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.
|
|
5
|
-
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET,
|
|
6
|
-
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.
|
|
7
|
-
import { normalizeEvidenceHistory, } from "./case-evidence-model.
|
|
8
|
-
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.
|
|
3
|
+
import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
|
|
4
|
+
import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
|
|
5
|
+
import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
|
|
6
|
+
import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
7
|
+
import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
|
|
8
|
+
import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Case normalization
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -18,12 +18,13 @@ function inferCaptureSource(caseSource) {
|
|
|
18
18
|
}
|
|
19
19
|
// Builds a case's bounded current-schema evidence history. Uses a lenient
|
|
20
20
|
// local fallback timestamp so recoverable current data always loads.
|
|
21
|
-
function normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion) {
|
|
21
|
+
function normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion) {
|
|
22
22
|
const localFallback = updatedAt || createdAt || now;
|
|
23
23
|
if (Array.isArray(record.evidenceHistory)) {
|
|
24
24
|
return normalizeEvidenceHistory(record.evidenceHistory, {
|
|
25
25
|
source: DEFAULT_EVIDENCE_SOURCE,
|
|
26
26
|
fallback: localFallback,
|
|
27
|
+
caseDomain: domain,
|
|
27
28
|
...(sourceVersion === undefined ? {} : { sourceVersion }),
|
|
28
29
|
});
|
|
29
30
|
}
|
|
@@ -41,36 +42,50 @@ function normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion)
|
|
|
41
42
|
* @returns {CaseRecord | null}
|
|
42
43
|
*/
|
|
43
44
|
export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
44
|
-
const now = nowIso || new Date().toISOString();
|
|
45
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
45
46
|
const record = objectRecord(raw);
|
|
46
47
|
const domain = normalizeDomain(existing ? existing.domain : record.domain);
|
|
47
48
|
if (!domain)
|
|
48
49
|
return null;
|
|
49
|
-
const createdAt = existing ? existing.createdAt :
|
|
50
|
-
const updatedAt =
|
|
51
|
-
const
|
|
50
|
+
const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
|
|
51
|
+
const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
|
|
52
|
+
const timestampOptions = {
|
|
53
|
+
legacyTimestamps: sourceVersion != null && sourceVersion < CASE_SCHEMA_VERSION,
|
|
54
|
+
...(sourceVersion === undefined ? {} : { sourceVersion }),
|
|
55
|
+
};
|
|
56
|
+
const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
|
|
52
57
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
53
|
-
const actions = normalizeCaseActions(record.actions, updatedAt);
|
|
54
|
-
const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds);
|
|
58
|
+
const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
|
|
59
|
+
const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds, timestampOptions);
|
|
60
|
+
const sightings = normalizeCaseSightings(record.sightings, updatedAt, pinIds, timestampOptions);
|
|
61
|
+
const sightingIds = new Set(sightings.map((item) => item.id));
|
|
62
|
+
const observedEffects = normalizeCaseObservedEffectHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.observedEffects, updatedAt, pinIds, sightingIds, timestampOptions);
|
|
63
|
+
const closures = normalizeCaseClosureHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
|
|
64
|
+
const normalizedStatus = normalizeStatus(record.status);
|
|
55
65
|
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
56
66
|
return {
|
|
57
67
|
id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
|
|
58
68
|
domain,
|
|
59
|
-
status:
|
|
69
|
+
status: normalizedStatus === 'resolved'
|
|
70
|
+
&& closures.records.length === 0 && !closures.preV13HistoryUnavailable
|
|
71
|
+
? 'reviewing'
|
|
72
|
+
: normalizedStatus,
|
|
60
73
|
disposition: normalizeDisposition(record.disposition),
|
|
61
74
|
reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
|
|
62
75
|
brandProfileIds: normalizeCaseBrandProfileIds(record.brandProfileIds),
|
|
63
76
|
tags: normalizeTags(record.tags),
|
|
64
|
-
notes: normalizeNotes(record.notes, now),
|
|
77
|
+
notes: normalizeNotes(record.notes, now, sourceVersion),
|
|
65
78
|
source: normalizeSource(record.source),
|
|
66
|
-
evidenceHistory: normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion),
|
|
79
|
+
evidenceHistory: normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion),
|
|
67
80
|
evidencePins,
|
|
68
|
-
decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds),
|
|
81
|
+
decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds, timestampOptions),
|
|
69
82
|
actions,
|
|
70
83
|
assertions,
|
|
71
|
-
manualTrail: normalizeCaseManualTrail(record.manualTrail, updatedAt),
|
|
72
|
-
sightings
|
|
73
|
-
|
|
84
|
+
manualTrail: normalizeCaseManualTrail(record.manualTrail, updatedAt, timestampOptions),
|
|
85
|
+
sightings,
|
|
86
|
+
observedEffects,
|
|
87
|
+
closures,
|
|
88
|
+
branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences, timestampOptions),
|
|
74
89
|
createdAt,
|
|
75
90
|
updatedAt,
|
|
76
91
|
};
|
|
@@ -81,7 +96,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
|
|
|
81
96
|
* @returns {CaseRecord}
|
|
82
97
|
*/
|
|
83
98
|
export function createCase(input, nowIso) {
|
|
84
|
-
const now = nowIso || new Date().toISOString();
|
|
99
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
85
100
|
const domain = normalizeDomain(input.domain);
|
|
86
101
|
if (!domain)
|
|
87
102
|
throw new Error('A valid domain is required to open a case.');
|
|
@@ -100,10 +115,22 @@ export function createCase(input, nowIso) {
|
|
|
100
115
|
? appendCaseAssertion([], input.assertion, now)
|
|
101
116
|
: [];
|
|
102
117
|
const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
|
|
118
|
+
const sightings = input.sighting !== undefined
|
|
119
|
+
? appendCaseSighting([], input.sighting, now, pinIds)
|
|
120
|
+
: [];
|
|
121
|
+
const observedEffects = input.observedEffectReview !== undefined
|
|
122
|
+
? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
|
|
123
|
+
: normalizeCaseObservedEffectHistory(undefined, now);
|
|
124
|
+
const closures = input.closure !== undefined
|
|
125
|
+
? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
|
|
126
|
+
: normalizeCaseClosureHistory(undefined, now);
|
|
127
|
+
if (normalizeStatus(input.status) === 'resolved' && input.closure === undefined) {
|
|
128
|
+
throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
|
|
129
|
+
}
|
|
103
130
|
return {
|
|
104
131
|
id: makeId(),
|
|
105
132
|
domain,
|
|
106
|
-
status: normalizeStatus(input.status),
|
|
133
|
+
status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
|
|
107
134
|
disposition: normalizeDisposition(input.disposition),
|
|
108
135
|
reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
|
|
109
136
|
brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
|
|
@@ -113,6 +140,7 @@ export function createCase(input, nowIso) {
|
|
|
113
140
|
evidenceHistory: normalizeEvidenceHistory(input.evidence ? [input.evidence] : [], {
|
|
114
141
|
source: inferCaptureSource(source),
|
|
115
142
|
fallback: now,
|
|
143
|
+
caseDomain: domain,
|
|
116
144
|
}),
|
|
117
145
|
evidencePins,
|
|
118
146
|
decisions: input.decision !== undefined
|
|
@@ -123,9 +151,9 @@ export function createCase(input, nowIso) {
|
|
|
123
151
|
manualTrail: input.trailEvent !== undefined
|
|
124
152
|
? appendCaseManualTrailEvent([], input.trailEvent, now)
|
|
125
153
|
: [],
|
|
126
|
-
sightings
|
|
127
|
-
|
|
128
|
-
|
|
154
|
+
sightings,
|
|
155
|
+
observedEffects,
|
|
156
|
+
closures,
|
|
129
157
|
branches: input.branch !== undefined
|
|
130
158
|
? appendCaseInvestigationBranch([], input.branch, now, branchReferences)
|
|
131
159
|
: [],
|
|
@@ -166,7 +194,7 @@ export function openOrCreateCase(cases, input, nowIso) {
|
|
|
166
194
|
* @returns {{ cases: CaseRecord[], record: CaseRecord }}
|
|
167
195
|
*/
|
|
168
196
|
export function updateCase(cases, id, patch, nowIso) {
|
|
169
|
-
const now = nowIso || new Date().toISOString();
|
|
197
|
+
const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
|
|
170
198
|
const index = cases.findIndex((item) => item.id === id);
|
|
171
199
|
if (index < 0)
|
|
172
200
|
throw new Error('That case no longer exists.');
|
|
@@ -186,7 +214,7 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
186
214
|
const source = patch.source !== undefined ? normalizeSource(patch.source) : current.source;
|
|
187
215
|
let evidenceHistory = current.evidenceHistory;
|
|
188
216
|
if (patch.evidence !== undefined) {
|
|
189
|
-
evidenceHistory = normalizeEvidenceHistory([...current.evidenceHistory, ...(patch.evidence ? [patch.evidence] : [])], { source: inferCaptureSource(source), fallback: now });
|
|
217
|
+
evidenceHistory = normalizeEvidenceHistory([...current.evidenceHistory, ...(patch.evidence ? [patch.evidence] : [])], { source: inferCaptureSource(source), fallback: now, caseDomain: current.domain });
|
|
190
218
|
}
|
|
191
219
|
let evidencePins = current.evidencePins;
|
|
192
220
|
if (patch.evidencePins !== undefined) {
|
|
@@ -205,7 +233,7 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
205
233
|
actions = appendCaseAction(current.actions, patch.action, now);
|
|
206
234
|
}
|
|
207
235
|
if (patch.actionUpdate !== undefined) {
|
|
208
|
-
actions = updateCaseAction(actions, patch.actionUpdate, now);
|
|
236
|
+
actions = updateCaseAction(actions, patch.actionUpdate, now, pinIds);
|
|
209
237
|
}
|
|
210
238
|
let assertions = current.assertions;
|
|
211
239
|
if (patch.assertion !== undefined) {
|
|
@@ -230,9 +258,22 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
230
258
|
if (patch.sighting !== undefined) {
|
|
231
259
|
sightings = appendCaseSighting(current.sightings, patch.sighting, now, pinIds);
|
|
232
260
|
}
|
|
261
|
+
let observedEffects = normalizeCaseObservedEffectHistory(current.observedEffects, current.updatedAt, pinIds, new Set(sightings.map((item) => item.id)));
|
|
262
|
+
if (patch.observedEffectReview !== undefined) {
|
|
263
|
+
observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
|
|
264
|
+
}
|
|
265
|
+
let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
|
|
266
|
+
if (patch.closure !== undefined) {
|
|
267
|
+
closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
|
|
268
|
+
}
|
|
269
|
+
if (patch.status === 'resolved' && patch.closure === undefined) {
|
|
270
|
+
throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
|
|
271
|
+
}
|
|
233
272
|
const record = {
|
|
234
273
|
...current,
|
|
235
|
-
status: patch.
|
|
274
|
+
status: patch.closure !== undefined
|
|
275
|
+
? 'resolved'
|
|
276
|
+
: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
|
|
236
277
|
disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
|
|
237
278
|
reviewReasonCode: patch.reviewReasonCode !== undefined
|
|
238
279
|
? normalizeReviewReasonCode(patch.reviewReasonCode)
|
|
@@ -249,6 +290,8 @@ export function updateCase(cases, id, patch, nowIso) {
|
|
|
249
290
|
assertions,
|
|
250
291
|
manualTrail,
|
|
251
292
|
sightings,
|
|
293
|
+
observedEffects,
|
|
294
|
+
closures,
|
|
252
295
|
branches,
|
|
253
296
|
notes,
|
|
254
297
|
updatedAt: now,
|
|
@@ -8,17 +8,17 @@
|
|
|
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 { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.
|
|
12
|
-
import { httpSecurityHeaderLabel } from "./http-summary.
|
|
13
|
-
import { analystInteroperabilityTags } from "
|
|
14
|
-
import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "
|
|
15
|
-
import { CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.
|
|
16
|
-
import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.
|
|
11
|
+
import { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.mjs";
|
|
12
|
+
import { httpSecurityHeaderLabel } from "./http-summary.mjs";
|
|
13
|
+
import { analystInteroperabilityTags } from "../../lib/analyst-taxonomy.mjs";
|
|
14
|
+
import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../../lib/portable-generator.mjs";
|
|
15
|
+
import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
|
|
16
|
+
import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
|
|
17
|
+
import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Constants
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
|
-
export
|
|
21
|
-
export const CASE_REPORT_SCHEMA_VERSION = 8;
|
|
21
|
+
export { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION };
|
|
22
22
|
const LIMITATIONS_TEXT = [
|
|
23
23
|
'This report contains normalised browser-local observations from WHOISleuth analyst cases.',
|
|
24
24
|
'It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks.',
|
|
@@ -26,6 +26,7 @@ const LIMITATIONS_TEXT = [
|
|
|
26
26
|
'Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes.',
|
|
27
27
|
'Scan-depth and scoring-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably.',
|
|
28
28
|
'Brand Profile references record an explicit analyst-selected association only; they do not establish ownership, attribution, intent, safety, or maliciousness.',
|
|
29
|
+
'Provider workflow outcomes and independently observed technical effects remain separately attributed. A provider outcome does not establish independent remediation, absence, or safety; neither evidence family establishes legal sufficiency or provider performance.',
|
|
29
30
|
'Generated locally in the browser. Review the package before sharing it.',
|
|
30
31
|
].join(' ');
|
|
31
32
|
function escapeMarkdownInline(text) {
|
|
@@ -181,7 +182,9 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
181
182
|
if (!previous)
|
|
182
183
|
continue;
|
|
183
184
|
const rawChanges = compareCaseEvidence(previous, snapshot);
|
|
184
|
-
|
|
185
|
+
const allIncomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
|
|
186
|
+
const hostnameContextChanged = allIncomparableReasons.includes('observation-context');
|
|
187
|
+
incomparableReasons = allIncomparableReasons.filter((reason) => reason !== 'observation-context');
|
|
185
188
|
if (rawChanges.length > 0) {
|
|
186
189
|
changes = rawChanges.map((change) => ({
|
|
187
190
|
field: change.field,
|
|
@@ -191,7 +194,9 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
191
194
|
tone: change.tone,
|
|
192
195
|
}));
|
|
193
196
|
}
|
|
194
|
-
else if (snapshot.fingerprint !== previous.fingerprint
|
|
197
|
+
else if (snapshot.fingerprint !== previous.fingerprint
|
|
198
|
+
&& incomparableReasons.length === 0
|
|
199
|
+
&& !hostnameContextChanged) {
|
|
195
200
|
incomparableReasons = ['other'];
|
|
196
201
|
}
|
|
197
202
|
hasIncomparableChange = incomparableReasons.length > 0;
|
|
@@ -208,6 +213,19 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
208
213
|
}
|
|
209
214
|
const latest = latestCaseEvidence({ evidenceHistory: caseRecord.evidenceHistory ?? undefined });
|
|
210
215
|
const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
|
|
216
|
+
const responseLifecycle = buildCaseResponseLifecycleSummary(caseRecord);
|
|
217
|
+
const observedEffects = caseRecord.observedEffects ?? {
|
|
218
|
+
reviews: [],
|
|
219
|
+
omitted: 0,
|
|
220
|
+
preV13HistoryUnavailable: false,
|
|
221
|
+
limitations: ['Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.'],
|
|
222
|
+
};
|
|
223
|
+
const closures = caseRecord.closures ?? {
|
|
224
|
+
records: [],
|
|
225
|
+
omitted: 0,
|
|
226
|
+
preV13HistoryUnavailable: false,
|
|
227
|
+
limitations: ['Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.'],
|
|
228
|
+
};
|
|
211
229
|
const json = {
|
|
212
230
|
schema: CASE_REPORT_SCHEMA,
|
|
213
231
|
schemaVersion: CASE_REPORT_SCHEMA_VERSION,
|
|
@@ -233,7 +251,12 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
233
251
|
analystResponse: {
|
|
234
252
|
evidencePins: caseRecord.evidencePins.map((item) => ({ ...item, limitations: [...item.limitations] })),
|
|
235
253
|
decisions: caseRecord.decisions.map((item) => ({ ...item, evidencePinIds: [...item.evidencePinIds] })),
|
|
236
|
-
actions: caseRecord.actions.map((item) => ({
|
|
254
|
+
actions: caseRecord.actions.map((item) => ({
|
|
255
|
+
...item,
|
|
256
|
+
contactLimitations: [...item.contactLimitations],
|
|
257
|
+
history: item.history.map((event) => ({ ...event, limitations: [...event.limitations] })),
|
|
258
|
+
historyLimitations: [...item.historyLimitations],
|
|
259
|
+
})),
|
|
237
260
|
assertions: caseRecord.assertions.map((item) => ({
|
|
238
261
|
...item,
|
|
239
262
|
evidencePinIds: [...item.evidencePinIds],
|
|
@@ -248,6 +271,18 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
248
271
|
})),
|
|
249
272
|
manualTrail: caseRecord.manualTrail.map((item) => ({ ...item })),
|
|
250
273
|
sightings: caseRecord.sightings.map((item) => ({ ...item, limitations: [...item.limitations] })),
|
|
274
|
+
observedEffects: {
|
|
275
|
+
reviews: observedEffects.reviews.map((item) => ({ ...item, limitations: [...item.limitations] })),
|
|
276
|
+
omitted: observedEffects.omitted,
|
|
277
|
+
preV13HistoryUnavailable: observedEffects.preV13HistoryUnavailable,
|
|
278
|
+
limitations: [...observedEffects.limitations],
|
|
279
|
+
},
|
|
280
|
+
closures: {
|
|
281
|
+
records: closures.records.map((item) => ({ ...item, limitations: [...item.limitations] })),
|
|
282
|
+
omitted: closures.omitted,
|
|
283
|
+
preV13HistoryUnavailable: closures.preV13HistoryUnavailable,
|
|
284
|
+
limitations: [...closures.limitations],
|
|
285
|
+
},
|
|
251
286
|
branches: (caseRecord.branches ?? []).map((item) => ({
|
|
252
287
|
...item,
|
|
253
288
|
evidencePinIds: [...item.evidencePinIds],
|
|
@@ -256,6 +291,7 @@ export function buildCaseReport(caseRecord, options = {}) {
|
|
|
256
291
|
actionIds: [...item.actionIds],
|
|
257
292
|
})),
|
|
258
293
|
},
|
|
294
|
+
responseLifecycle,
|
|
259
295
|
limitations: LIMITATIONS_TEXT,
|
|
260
296
|
};
|
|
261
297
|
// --- Build Markdown ---
|
|
@@ -431,8 +467,8 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
431
467
|
lines.push('## Analyst Decision Packet');
|
|
432
468
|
lines.push('');
|
|
433
469
|
const response = report.analystResponse;
|
|
434
|
-
if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.branches.length) {
|
|
435
|
-
lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records,
|
|
470
|
+
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) {
|
|
471
|
+
lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, response actions, independent effect reviews, closures, or manual investigation steps recorded.');
|
|
436
472
|
lines.push('');
|
|
437
473
|
}
|
|
438
474
|
else {
|
|
@@ -519,16 +555,35 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
519
555
|
for (const action of response.actions) {
|
|
520
556
|
lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
|
|
521
557
|
lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
|
|
558
|
+
if (action.originActionId)
|
|
559
|
+
lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
|
|
522
560
|
if (action.dueAt)
|
|
523
561
|
lines.push(` Due: ${escapeMarkdownInline(action.dueAt)}`);
|
|
524
562
|
if (action.followUpAt)
|
|
525
563
|
lines.push(` Follow-up: ${escapeMarkdownInline(action.followUpAt)}`);
|
|
526
564
|
if (action.reference)
|
|
527
565
|
lines.push(` Reference: ${escapeMarkdownInline(action.reference)}`);
|
|
566
|
+
if (action.providerOutcome)
|
|
567
|
+
lines.push(` Latest typed provider outcome: ${escapeMarkdownInline(action.providerOutcome.replaceAll('_', ' '))}`);
|
|
528
568
|
if (action.outcome)
|
|
529
|
-
lines.push(`
|
|
569
|
+
lines.push(` ${action.providerOutcome ? 'Provider' : 'Legacy recorded'} outcome detail: ${escapeMarkdownInline(action.outcome)}`);
|
|
530
570
|
if (action.contactLimitations.length)
|
|
531
571
|
lines.push(` Contact limitations: ${escapeMarkdownInline(action.contactLimitations.join('; '))}`);
|
|
572
|
+
for (const event of action.history) {
|
|
573
|
+
lines.push(` - ${escapeMarkdownInline(event.occurredAt)} · ${escapeMarkdownInline(event.previousState ?? 'none')} → ${escapeMarkdownInline(event.nextState)} · ${escapeMarkdownInline(event.sourceClass)} · ${event.applied ? 'applied' : 'retained conflict'}`);
|
|
574
|
+
if (event.providerOutcome)
|
|
575
|
+
lines.push(` Provider outcome at this time: ${escapeMarkdownInline(event.providerOutcome.replaceAll('_', ' '))}`);
|
|
576
|
+
if (event.reference)
|
|
577
|
+
lines.push(` Event reference: ${escapeMarkdownInline(event.reference)}`);
|
|
578
|
+
if (event.evidencePinId)
|
|
579
|
+
lines.push(` Evidence pin: ${escapeMarkdownInline(event.evidencePinId)}`);
|
|
580
|
+
if (event.limitations.length)
|
|
581
|
+
lines.push(` Limitations: ${escapeMarkdownInline(event.limitations.join('; '))}`);
|
|
582
|
+
}
|
|
583
|
+
if (action.historyOmitted)
|
|
584
|
+
lines.push(` Earlier transition events omitted by bound: ${action.historyOmitted}`);
|
|
585
|
+
if (action.historyLimitations.length)
|
|
586
|
+
lines.push(` History limitations: ${escapeMarkdownInline(action.historyLimitations.join('; '))}`);
|
|
532
587
|
}
|
|
533
588
|
lines.push('');
|
|
534
589
|
lines.push('### Investigation branches');
|
|
@@ -551,6 +606,45 @@ function buildMarkdown(report, includeAttribution) {
|
|
|
551
606
|
}
|
|
552
607
|
lines.push('');
|
|
553
608
|
}
|
|
609
|
+
lines.push('### Independent remediation review and closure');
|
|
610
|
+
lines.push('');
|
|
611
|
+
const providerOutcome = report.responseLifecycle.latestProviderOutcome;
|
|
612
|
+
const observedEffect = report.responseLifecycle.latestObservedEffect;
|
|
613
|
+
lines.push(providerOutcome
|
|
614
|
+
? `- Latest provider outcome time: ${escapeMarkdownInline(providerOutcome.occurredAt)} (${escapeMarkdownInline(providerOutcome.outcome.replaceAll('_', ' '))})`
|
|
615
|
+
: `- Latest provider outcome time: Withheld because the typed event state is ${escapeMarkdownInline(report.responseLifecycle.providerOutcomeState)}.`);
|
|
616
|
+
lines.push(report.responseLifecycle.latestObservedChangeAt
|
|
617
|
+
? `- Latest independently observed change time: ${escapeMarkdownInline(report.responseLifecycle.latestObservedChangeAt)}`
|
|
618
|
+
: `- Latest independently observed change time: Withheld because the independent change state is ${escapeMarkdownInline(report.responseLifecycle.observedChangeState)}.`);
|
|
619
|
+
if (observedEffect)
|
|
620
|
+
lines.push(`- Latest independent review: ${escapeMarkdownInline(observedEffect.state.replaceAll('_', ' '))} at ${escapeMarkdownInline(observedEffect.observedAt)} from ${escapeMarkdownInline(observedEffect.source)}.`);
|
|
621
|
+
if (!response.observedEffects.reviews.length)
|
|
622
|
+
lines.push('- No independent observed-effect review recorded.');
|
|
623
|
+
for (const review of response.observedEffects.reviews) {
|
|
624
|
+
lines.push(`- **${escapeMarkdownInline(review.state.replaceAll('_', ' '))}** (${escapeMarkdownInline(review.observedAt)}): ${escapeMarkdownInline(review.source)}; class ${escapeMarkdownInline(review.sourceClass)}; completeness ${escapeMarkdownInline(review.completeness)}.`);
|
|
625
|
+
if (review.evidencePinId)
|
|
626
|
+
lines.push(` Evidence pin: ${escapeMarkdownInline(review.evidencePinId)}`);
|
|
627
|
+
if (review.sightingId)
|
|
628
|
+
lines.push(` Sighting: ${escapeMarkdownInline(review.sightingId)}`);
|
|
629
|
+
if (review.followUpAt)
|
|
630
|
+
lines.push(` Scheduled local follow-up: ${escapeMarkdownInline(review.followUpAt)}`);
|
|
631
|
+
if (review.limitations.length)
|
|
632
|
+
lines.push(` Limitations: ${escapeMarkdownInline(review.limitations.join('; '))}`);
|
|
633
|
+
}
|
|
634
|
+
if (response.observedEffects.omitted)
|
|
635
|
+
lines.push(`- Earlier independent reviews omitted by bound: ${response.observedEffects.omitted}`);
|
|
636
|
+
if (!response.closures.records.length)
|
|
637
|
+
lines.push('- No deliberate analyst closure recorded.');
|
|
638
|
+
for (const closure of response.closures.records) {
|
|
639
|
+
lines.push(`- **Closure · ${escapeMarkdownInline(closure.reason.replaceAll('_', ' '))}** (${escapeMarkdownInline(closure.createdAt)}): ${escapeMarkdownInline(closure.summary)}`);
|
|
640
|
+
if (closure.observedEffectReviewId)
|
|
641
|
+
lines.push(` Independent review: ${escapeMarkdownInline(closure.observedEffectReviewId)}`);
|
|
642
|
+
if (closure.actionId)
|
|
643
|
+
lines.push(` Provider action: ${escapeMarkdownInline(closure.actionId)}`);
|
|
644
|
+
if (closure.limitations.length)
|
|
645
|
+
lines.push(` Limitations: ${escapeMarkdownInline(closure.limitations.join('; '))}`);
|
|
646
|
+
}
|
|
647
|
+
lines.push('');
|
|
554
648
|
// Analyst notes (only when included)
|
|
555
649
|
if (report.case.notesIncluded && Array.isArray(report.case.notes) && report.case.notes.length > 0) {
|
|
556
650
|
lines.push('## Analyst Notes');
|