@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { array, enumeration, exact, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
|
|
2
|
+
import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
|
|
3
|
+
import { MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
|
|
4
|
+
import { SAFE_ID_RE, freshId } from "./case-response-values.mjs";
|
|
5
|
+
export const CASE_EVIDENCE_LINK_KINDS = ['derived_from', 'shared_source'];
|
|
6
|
+
/** Enough for both directed relationships between every admitted pin pair. */
|
|
7
|
+
export const MAX_CASE_EVIDENCE_LINKS = MAX_CASE_EVIDENCE_PINS * (MAX_CASE_EVIDENCE_PINS - 1) * CASE_EVIDENCE_LINK_KINDS.length;
|
|
8
|
+
function identifier(value) {
|
|
9
|
+
const id = text(value, 'Evidence link identity', 64);
|
|
10
|
+
if (!SAFE_ID_RE.test(id))
|
|
11
|
+
throw new TypeError('Evidence link identity is invalid.');
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
function explanation(value) {
|
|
15
|
+
const result = text(value, 'Evidence relationship basis', MAX_RESPONSE_RATIONALE_LENGTH).trim();
|
|
16
|
+
if (!result)
|
|
17
|
+
throw new TypeError('Explain the evidence relationship.');
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
export function readCaseEvidenceLinks(value) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
assertWorkspaceInputGraph(value, 'Evidence relationships');
|
|
24
|
+
const links = array(value, 'Evidence relationships', MAX_CASE_EVIDENCE_LINKS).map(raw => {
|
|
25
|
+
const row = exactOptional(raw, ['id', 'fromPinId', 'toPinId', 'kind', 'basis', 'createdAt'], ['withdrawal'], 'Evidence relationship');
|
|
26
|
+
const fromPinId = identifier(row.fromPinId), toPinId = identifier(row.toPinId);
|
|
27
|
+
if (fromPinId === toPinId)
|
|
28
|
+
throw new TypeError('An evidence pin cannot derive from or share a source with itself.');
|
|
29
|
+
iso(row.createdAt, 'Relationship recording time');
|
|
30
|
+
let withdrawal;
|
|
31
|
+
if (row.withdrawal !== undefined) {
|
|
32
|
+
const item = exact(row.withdrawal, ['at', 'reason'], 'Relationship withdrawal');
|
|
33
|
+
iso(item.at, 'Withdrawal time');
|
|
34
|
+
if (Date.parse(item.at) < Date.parse(row.createdAt))
|
|
35
|
+
throw new TypeError('Withdrawal precedes the relationship.');
|
|
36
|
+
withdrawal = { at: item.at, reason: explanation(item.reason) };
|
|
37
|
+
}
|
|
38
|
+
return { id: identifier(row.id), fromPinId, toPinId,
|
|
39
|
+
kind: enumeration(row.kind, CASE_EVIDENCE_LINK_KINDS, 'Evidence relationship kind'),
|
|
40
|
+
basis: explanation(row.basis), createdAt: row.createdAt, ...(withdrawal ? { withdrawal } : {}) };
|
|
41
|
+
});
|
|
42
|
+
if (new Set(links.map(link => link.id)).size !== links.length)
|
|
43
|
+
throw new TypeError('Evidence relationship identities must be unique.');
|
|
44
|
+
return links;
|
|
45
|
+
}
|
|
46
|
+
function derivationGraph(links) {
|
|
47
|
+
const graph = new Map();
|
|
48
|
+
for (const link of links)
|
|
49
|
+
if (!link.withdrawal && link.kind === 'derived_from') {
|
|
50
|
+
const neighbours = graph.get(link.fromPinId) ?? [];
|
|
51
|
+
neighbours.push(link.toPinId);
|
|
52
|
+
graph.set(link.fromPinId, neighbours);
|
|
53
|
+
}
|
|
54
|
+
return graph;
|
|
55
|
+
}
|
|
56
|
+
function reaches(graph, from, to) {
|
|
57
|
+
const pending = [from], visited = new Set();
|
|
58
|
+
while (pending.length) {
|
|
59
|
+
const id = pending.pop();
|
|
60
|
+
if (id === to)
|
|
61
|
+
return true;
|
|
62
|
+
if (visited.has(id))
|
|
63
|
+
continue;
|
|
64
|
+
visited.add(id);
|
|
65
|
+
pending.push(...(graph.get(id) ?? []));
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
export function appendCaseEvidenceLink(current, input, pins, now) {
|
|
70
|
+
const row = exact(input, ['fromPinId', 'toPinId', 'kind', 'basis'], 'New evidence relationship');
|
|
71
|
+
const link = readCaseEvidenceLinks([{ ...row, id: freshId('evidence-link'), createdAt: now }])[0];
|
|
72
|
+
const ids = new Set(pins.map(pin => pin.id));
|
|
73
|
+
if (!ids.has(link.fromPinId) || !ids.has(link.toPinId))
|
|
74
|
+
throw new TypeError('Select two retained evidence pins.');
|
|
75
|
+
if (current.some(item => !item.withdrawal && item.kind === link.kind
|
|
76
|
+
&& ((item.fromPinId === link.fromPinId && item.toPinId === link.toPinId)
|
|
77
|
+
|| (link.kind === 'shared_source' && item.fromPinId === link.toPinId && item.toPinId === link.fromPinId)))) {
|
|
78
|
+
throw new TypeError('This evidence relationship is already recorded. Withdraw it before recording a replacement.');
|
|
79
|
+
}
|
|
80
|
+
if (link.kind === 'derived_from' && reaches(derivationGraph(current), link.toPinId, link.fromPinId))
|
|
81
|
+
throw new TypeError('This derivation would create a cycle. Review the existing links first.');
|
|
82
|
+
return readCaseEvidenceLinks([...current, link]);
|
|
83
|
+
}
|
|
84
|
+
export function withdrawCaseEvidenceLink(current, input, now) {
|
|
85
|
+
const row = exact(input, ['id', 'reason'], 'Evidence relationship withdrawal');
|
|
86
|
+
const id = identifier(row.id), existing = current.find(link => link.id === id);
|
|
87
|
+
if (!existing || existing.withdrawal)
|
|
88
|
+
throw new TypeError('This relationship is missing or already withdrawn. Refresh before reviewing it.');
|
|
89
|
+
return readCaseEvidenceLinks(current.map(link => link.id === id ? { ...link, withdrawal: { at: now, reason: explanation(row.reason) } } : link));
|
|
90
|
+
}
|
|
91
|
+
/** Keep unresolved references; an imported pin can have been omitted by its source. */
|
|
92
|
+
export function mergeCaseEvidenceLinks(local, incoming) {
|
|
93
|
+
if (local === undefined && incoming === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
const merged = new Map((readCaseEvidenceLinks(local) ?? []).map(link => [link.id, link]));
|
|
96
|
+
for (const link of readCaseEvidenceLinks(incoming) ?? []) {
|
|
97
|
+
const existing = merged.get(link.id);
|
|
98
|
+
if (!existing) {
|
|
99
|
+
merged.set(link.id, link);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const { withdrawal: left, ...a } = existing, { withdrawal: right, ...b } = link;
|
|
103
|
+
if (JSON.stringify(a) !== JSON.stringify(b) || (left && right && JSON.stringify(left) !== JSON.stringify(right))) {
|
|
104
|
+
throw new TypeError('An imported evidence relationship conflicts with retained provenance. No relationship was overwritten.');
|
|
105
|
+
}
|
|
106
|
+
merged.set(link.id, { ...a, ...((left ?? right) ? { withdrawal: left ?? right } : {}) });
|
|
107
|
+
}
|
|
108
|
+
return readCaseEvidenceLinks([...merged.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
109
|
+
}
|
|
110
|
+
export function caseEvidenceLinkIssues(links, pins) {
|
|
111
|
+
const ids = new Set(pins.map(pin => pin.id));
|
|
112
|
+
const graph = derivationGraph(links);
|
|
113
|
+
return links.map(link => ({ link,
|
|
114
|
+
missingPinIds: [link.fromPinId, link.toPinId].filter(id => !ids.has(id)),
|
|
115
|
+
cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
/** Matching context is disclosed, never counted as independent corroboration. */
|
|
119
|
+
export function caseEvidenceSharedContext(pins) {
|
|
120
|
+
const groups = new Map();
|
|
121
|
+
function add(kind, identity, label, id) {
|
|
122
|
+
if (!identity?.trim())
|
|
123
|
+
return;
|
|
124
|
+
const key = JSON.stringify([kind, identity]);
|
|
125
|
+
const group = groups.get(key) ?? { kind, label, pinIds: [] };
|
|
126
|
+
group.pinIds.push(id);
|
|
127
|
+
groups.set(key, group);
|
|
128
|
+
}
|
|
129
|
+
for (const pin of pins) {
|
|
130
|
+
add('checkpoint', pin.checkpointId, 'Same collection checkpoint', pin.id);
|
|
131
|
+
add('import', pin.importContentSha256, 'Same imported content', pin.id);
|
|
132
|
+
add('source', pin.source, `Same declared source: ${pin.source}`, pin.id);
|
|
133
|
+
}
|
|
134
|
+
return [...groups.values()].filter(group => group.pinIds.length > 1);
|
|
135
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Pure, framework-neutral analyst-case records, evidence histories, bounded
|
|
2
2
|
// record normalization, and analyst updates.
|
|
3
|
-
import { normalizeHttpSummary } from "./http-summary.mjs";
|
|
4
|
-
import { normalizeOpportunityModelVersion } from "
|
|
5
|
-
import { normalizeRiskModelVersion } from "
|
|
3
|
+
import { httpSummaryFieldIsObserved, normalizeHttpSummary } from "./http-summary.mjs";
|
|
4
|
+
import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scoring.mjs";
|
|
5
|
+
import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
|
|
6
6
|
import { latestObservationCohort } from "../evidence/latest-observations.mjs";
|
|
7
7
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
8
|
-
import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
8
|
+
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
9
9
|
import { validWebObservationMode } from "../evidence/lookup-target.mjs";
|
|
10
|
+
import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
|
|
10
11
|
import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
|
|
11
12
|
import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
|
|
12
13
|
function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
|
|
@@ -122,6 +123,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
122
123
|
'observationHostname',
|
|
123
124
|
'webObservationMode',
|
|
124
125
|
'scanDepth',
|
|
126
|
+
'webCollectionQuality',
|
|
125
127
|
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
|
|
126
128
|
'riskFactors', 'opportunityFactors',
|
|
127
129
|
'registrar', 'createdDate', 'expiryDate', 'nameservers',
|
|
@@ -174,7 +176,7 @@ function isEmptyMaterial(value) {
|
|
|
174
176
|
}
|
|
175
177
|
// Fields that describe the capture rather than assert evidence, so they never
|
|
176
178
|
// on their own keep an otherwise-empty snapshot alive.
|
|
177
|
-
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'confidence']);
|
|
179
|
+
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
|
|
178
180
|
// A snapshot with no material evidence (only timestamps/source/depth, or only a
|
|
179
181
|
// bare confidence/unknown-availability) is dropped rather than added to a
|
|
180
182
|
// timeline.
|
|
@@ -194,7 +196,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
|
|
|
194
196
|
const value = materialValue(field, snapshot, legacyOrder);
|
|
195
197
|
// Optional collection context does not alter historical fingerprints when
|
|
196
198
|
// absent; recorded context separates otherwise-identical captures.
|
|
197
|
-
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode') && value === null)
|
|
199
|
+
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality') && value === null)
|
|
198
200
|
continue;
|
|
199
201
|
canonical[field] = value;
|
|
200
202
|
}
|
|
@@ -231,6 +233,10 @@ function buildSnapshot(raw, options) {
|
|
|
231
233
|
const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
|
|
232
234
|
const scanDepth = normalizeScanDepth(record.scanDepth);
|
|
233
235
|
const httpSummary = normalizeHttpSummary(record);
|
|
236
|
+
const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
|
|
237
|
+
if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
|
|
238
|
+
throw new TypeError('Web collection quality requires Case schema 17 or later; historical evidence was not reinterpreted.');
|
|
239
|
+
}
|
|
234
240
|
const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
|
|
235
241
|
const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
|
|
236
242
|
const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
@@ -248,6 +254,7 @@ function buildSnapshot(raw, options) {
|
|
|
248
254
|
...(observationHostname ? { observationHostname } : {}),
|
|
249
255
|
...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
|
|
250
256
|
scanDepth,
|
|
257
|
+
...(webCollectionQuality ? { webCollectionQuality } : {}),
|
|
251
258
|
availability: evidenceString(record.availability),
|
|
252
259
|
confidence: evidenceString(record.confidence),
|
|
253
260
|
riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
|
|
@@ -300,6 +307,12 @@ function buildSnapshot(raw, options) {
|
|
|
300
307
|
fields.riskModelVersion = null;
|
|
301
308
|
if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
|
|
302
309
|
fields.opportunityModelVersion = null;
|
|
310
|
+
if (webCollectionQuality) {
|
|
311
|
+
for (const field of ['hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
|
|
312
|
+
if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
|
|
313
|
+
fields[field] = null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
303
316
|
// A fast capture never evaluates the deep signals, so any value supplied for
|
|
304
317
|
// them (e.g. a profile's default `false`) is discarded as unevaluated.
|
|
305
318
|
if (scanDepth === 'fast') {
|
|
@@ -404,6 +417,11 @@ function compareSnapshotChrono(a, b) {
|
|
|
404
417
|
* @returns {CaseEvidenceSnapshot[]}
|
|
405
418
|
*/
|
|
406
419
|
export function normalizeEvidenceHistory(rawList, options = {}) {
|
|
420
|
+
const ordered = [...evidenceByMaterial(rawList, options).values()].sort(compareSnapshotChrono);
|
|
421
|
+
const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
|
|
422
|
+
return assignUniqueSnapshotIds(kept);
|
|
423
|
+
}
|
|
424
|
+
function evidenceByMaterial(rawList, options) {
|
|
407
425
|
const list = Array.isArray(rawList) ? rawList : [];
|
|
408
426
|
const byMaterial = new Map();
|
|
409
427
|
for (const raw of list) {
|
|
@@ -415,9 +433,24 @@ export function normalizeEvidenceHistory(rawList, options = {}) {
|
|
|
415
433
|
// collision can never merge two genuinely different snapshots.
|
|
416
434
|
byMaterial.set(built.material, existing ? mergeDuplicateSnapshots(existing, built.snapshot) : built.snapshot);
|
|
417
435
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
436
|
+
return byMaterial;
|
|
437
|
+
}
|
|
438
|
+
/** Imports add observations without evicting existing local material. Ordinary
|
|
439
|
+
* collection still uses the newest-history policy in normalizeEvidenceHistory. */
|
|
440
|
+
export function mergeImportedEvidenceHistory(local, imported, caseDomain) {
|
|
441
|
+
const options = { source: 'import', fallback: null, caseDomain };
|
|
442
|
+
const retained = evidenceByMaterial(normalizeEvidenceHistory(local, options), options);
|
|
443
|
+
let omitted = 0;
|
|
444
|
+
for (const [material, incoming] of evidenceByMaterial(imported, options)) {
|
|
445
|
+
const existing = retained.get(material);
|
|
446
|
+
if (existing)
|
|
447
|
+
retained.set(material, mergeDuplicateSnapshots(existing, incoming));
|
|
448
|
+
else if (retained.size < MAX_EVIDENCE_SNAPSHOTS_PER_CASE)
|
|
449
|
+
retained.set(material, incoming);
|
|
450
|
+
else
|
|
451
|
+
omitted += 1;
|
|
452
|
+
}
|
|
453
|
+
return { records: assignUniqueSnapshotIds([...retained.values()].sort(compareSnapshotChrono)), omitted };
|
|
421
454
|
}
|
|
422
455
|
function assignUniqueSnapshotIds(snapshots) {
|
|
423
456
|
const used = new Set();
|
|
@@ -455,6 +488,13 @@ export function latestCaseEvidence(record) {
|
|
|
455
488
|
}
|
|
456
489
|
/** Chronological presentation and comparison admission shared by the browser and Case report. */
|
|
457
490
|
export function caseEvidenceTimeline(history) {
|
|
491
|
+
return buildEvidenceTimeline(history, true);
|
|
492
|
+
}
|
|
493
|
+
/** Reproduce published report fields only; not current analyst comparisons. */
|
|
494
|
+
export function publishedCaseEvidenceTimelineForVerification(history) {
|
|
495
|
+
return buildEvidenceTimeline(history, false);
|
|
496
|
+
}
|
|
497
|
+
function buildEvidenceTimeline(history, enforceCollectionQuality) {
|
|
458
498
|
const dated = (history ?? []).map((snapshot) => ({
|
|
459
499
|
snapshot,
|
|
460
500
|
at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
|
|
@@ -475,10 +515,10 @@ export function caseEvidenceTimeline(history) {
|
|
|
475
515
|
? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
|
|
476
516
|
: null;
|
|
477
517
|
const comparable = previous && !orderingLimitation;
|
|
478
|
-
const changes = comparable ?
|
|
518
|
+
const changes = comparable ? compareEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
|
|
479
519
|
const incomparableReasons = orderingLimitation
|
|
480
520
|
? ['other']
|
|
481
|
-
: comparable ?
|
|
521
|
+
: comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
|
|
482
522
|
if (comparable && !changes.length && !incomparableReasons.length
|
|
483
523
|
&& canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
|
|
484
524
|
incomparableReasons.push('other');
|
|
@@ -562,6 +602,13 @@ function opportunityModelComparable(previous, current) {
|
|
|
562
602
|
function valuesMateriallyEqual(field, previous, current) {
|
|
563
603
|
return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
|
|
564
604
|
}
|
|
605
|
+
function collectionFieldComparable(field, snapshot) {
|
|
606
|
+
if (field === 'registrar' || field === 'createdDate' || field === 'expiryDate')
|
|
607
|
+
return isPresent(snapshot[field]);
|
|
608
|
+
return field.startsWith('http')
|
|
609
|
+
? httpSummaryFieldIsObserved(field, snapshot)
|
|
610
|
+
: webCollectionAllowsComparison(field, snapshot.webCollectionQuality, snapshot.scanDepth);
|
|
611
|
+
}
|
|
565
612
|
/**
|
|
566
613
|
* Explains material fields that comparison gates deliberately suppress. The
|
|
567
614
|
* reasons are stable machine values for UI/report wording; they are not risk
|
|
@@ -569,14 +616,27 @@ function valuesMateriallyEqual(field, previous, current) {
|
|
|
569
616
|
* in the same observation produced an ordinary material change.
|
|
570
617
|
* @param {CaseEvidenceSnapshot | null | undefined} previous
|
|
571
618
|
* @param {CaseEvidenceSnapshot | null | undefined} current
|
|
572
|
-
* @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
|
|
619
|
+
* @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model' | 'collection-quality'>}
|
|
573
620
|
*/
|
|
574
621
|
export function caseEvidenceIncomparableReasons(previous, current) {
|
|
622
|
+
return incomparableReasonsForEvidence(previous, current, true);
|
|
623
|
+
}
|
|
624
|
+
function incomparableReasonsForEvidence(previous, current, enforceCollectionQuality) {
|
|
575
625
|
if (!previous || !current || previous.fingerprint === current.fingerprint)
|
|
576
626
|
return [];
|
|
577
627
|
const reasons = [];
|
|
578
628
|
if (!sameObservationContext(previous, current))
|
|
579
629
|
reasons.push('observation-context');
|
|
630
|
+
const missingRegistrationValue = ['registrar', 'createdDate', 'expiryDate'].some(field => !valuesMateriallyEqual(field, previous, current) && (!isPresent(previous[field]) || !isPresent(current[field])));
|
|
631
|
+
const incompleteWebFields = previous.scanDepth === 'deep' && current.scanDepth === 'deep'
|
|
632
|
+
&& (JSON.stringify(previous.webCollectionQuality) !== JSON.stringify(current.webCollectionQuality)
|
|
633
|
+
|| COMPARE_FIELDS.some(spec => (spec.modelGate !== 'risk' || riskModelComparable(previous, current))
|
|
634
|
+
&& (spec.modelGate !== 'opportunity' || opportunityModelComparable(previous, current))
|
|
635
|
+
&& !valuesMateriallyEqual(spec.field, previous, current)
|
|
636
|
+
&& (!collectionFieldComparable(spec.field, previous)
|
|
637
|
+
|| !collectionFieldComparable(spec.field, current))));
|
|
638
|
+
if (enforceCollectionQuality && (missingRegistrationValue || incompleteWebFields))
|
|
639
|
+
reasons.push('collection-quality');
|
|
580
640
|
const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
|
|
581
641
|
|| previous.riskFactors.length > 0 || current.riskFactors.length > 0;
|
|
582
642
|
if (hasRiskEvidence && !riskModelComparable(previous, current))
|
|
@@ -763,6 +823,9 @@ function compareField(spec, before, after) {
|
|
|
763
823
|
* @returns {Array<{ field: string, label: string, before: unknown, after: unknown, tone: string }>}
|
|
764
824
|
*/
|
|
765
825
|
export function compareCaseEvidence(previous, current) {
|
|
826
|
+
return compareEvidence(previous, current, true);
|
|
827
|
+
}
|
|
828
|
+
function compareEvidence(previous, current, enforceCollectionQuality) {
|
|
766
829
|
if (!previous || !current)
|
|
767
830
|
return [];
|
|
768
831
|
const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
|
|
@@ -785,6 +848,9 @@ export function compareCaseEvidence(previous, current) {
|
|
|
785
848
|
continue;
|
|
786
849
|
if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
|
|
787
850
|
continue;
|
|
851
|
+
if (enforceCollectionQuality && (!collectionFieldComparable(spec.field, previous)
|
|
852
|
+
|| !collectionFieldComparable(spec.field, current)))
|
|
853
|
+
continue;
|
|
788
854
|
const result = compareField(spec, previous[spec.field], current[spec.field]);
|
|
789
855
|
if (result) {
|
|
790
856
|
changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Collection completeness does not establish a usable date, comparison or conclusion. */
|
|
2
|
+
export function casePinHasCompleteObservation(pin) {
|
|
3
|
+
return pin.completeness === 'complete' && !pin.truncated
|
|
4
|
+
&& ['complete', 'success', 'reviewed', 'not_found'].includes(pin.sourceState ?? '');
|
|
5
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "
|
|
1
|
+
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../analysis/bounded-json.mjs";
|
|
2
2
|
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
3
3
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
-
import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
4
|
+
import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
5
5
|
import { normalizeCaseStore } from "./case-migration-model.mjs";
|
|
6
6
|
import { serializeCaseStore } from "./case-storage-model.mjs";
|
|
7
7
|
/** Historical wire shape remains independent of current in-memory defaults. */
|
|
8
8
|
export function canonicalCaseExportProjection(cases, caseVersion) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
return cases.map((record) => {
|
|
10
|
+
const { workflowMetadata: _workflow, ...historical } = record;
|
|
11
|
+
const value = caseVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION ? historical : record;
|
|
12
|
+
if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
|
|
13
|
+
return value;
|
|
12
14
|
if ([...value.evidencePins, ...value.sightings].some((item) => item.observedAt === null)) {
|
|
13
15
|
throw new TypeError('The published Case 15 format requires a known observation time on each retained pin and sighting.');
|
|
14
16
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Pure incident context parsing and retention projection; no storage or Case mutation.
|
|
2
|
+
import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
|
|
3
|
+
import { MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
|
|
4
|
+
import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
|
|
5
|
+
export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
|
|
6
|
+
function boundedContextText(value, maximum) {
|
|
7
|
+
return typeof value === 'string'
|
|
8
|
+
? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
9
|
+
: '';
|
|
10
|
+
}
|
|
11
|
+
export function normalizeCaseObjective(value) {
|
|
12
|
+
return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
|
|
13
|
+
}
|
|
14
|
+
export function parseIncidentUrlContext(value) {
|
|
15
|
+
const parsed = parseCredentialFreeHttpUrl(value, MAX_CASE_INCIDENT_URL_LENGTH);
|
|
16
|
+
if (!parsed)
|
|
17
|
+
return null;
|
|
18
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
|
|
19
|
+
const registrableDomain = canonicalRegistrableDomain(hostname);
|
|
20
|
+
if (!registrableDomain)
|
|
21
|
+
return null;
|
|
22
|
+
const exactUrl = parsed.toString();
|
|
23
|
+
if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
|
|
24
|
+
return null;
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
exactUrl,
|
|
27
|
+
hostname,
|
|
28
|
+
registrableDomain,
|
|
29
|
+
originUrl: parsed.origin,
|
|
30
|
+
hasPath: parsed.pathname !== '/',
|
|
31
|
+
hasQuery: Boolean(parsed.search),
|
|
32
|
+
hasFragment: Boolean(parsed.hash),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function caseInvestigationContext(record) {
|
|
36
|
+
return record?.workflowMetadata?.investigationContext ?? null;
|
|
37
|
+
}
|
|
38
|
+
export function prepareCaseInvestigationContext(input) {
|
|
39
|
+
const objective = normalizeCaseObjective(input.objective);
|
|
40
|
+
if (!objective)
|
|
41
|
+
throw new Error('Enter the investigation objective before retaining Incident context.');
|
|
42
|
+
const parsed = parseIncidentUrlContext(input.incidentUrl);
|
|
43
|
+
if (!parsed)
|
|
44
|
+
throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
|
|
45
|
+
const retention = input.retainExactUrl ? 'exact' : 'origin_only';
|
|
46
|
+
const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
objective,
|
|
49
|
+
incidentUrl: retainedUrl,
|
|
50
|
+
urlRetention: retention,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Keep untrusted Case prose literal, including block syntax and GFM autolinks. */
|
|
2
|
+
export function escapeCaseMarkdownInline(text) {
|
|
3
|
+
return String(text)
|
|
4
|
+
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
|
5
|
+
.replace(/([\\`*_{}\[\]<>()#+!|~])/g, '\\$1')
|
|
6
|
+
.replace(/^(\s*)([-=]+)(?=\s|$)/u, '$1\\$2')
|
|
7
|
+
.replace(/^(\s*\d+)\.(?=\s)/u, '$1\\.')
|
|
8
|
+
// Break automatic links without altering the underlying evidence value.
|
|
9
|
+
.replace(/\b([a-z][a-z0-9+.-]{1,31}):(?=\/\/)/gi, '$1\\:')
|
|
10
|
+
.replace(/\bwww\.(?=\S)/gi, 'www\\.');
|
|
11
|
+
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION,
|
|
1
|
+
import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "../contracts/case-portability.mjs";
|
|
2
|
+
import { DEFAULT_DISPOSITION, DEFAULT_STATUS, isValidDisposition, isValidStatus } from "./case-record-decisions.mjs";
|
|
3
|
+
import { DEFAULT_SOURCE } from "./case-record-contracts.mjs";
|
|
4
|
+
import { deterministicId, caseTimestampOrNull, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidSource, } from "./case-record-core.mjs";
|
|
5
|
+
import { normalizeCase } from "./case-record-operations.mjs";
|
|
6
|
+
import { normalizeCaseObjective } from "./case-incident-context.mjs";
|
|
7
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
8
|
+
import { mergeImportedEvidenceHistory, normalizeEvidenceHistory } from "./case-evidence-model.mjs";
|
|
2
9
|
import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
|
|
3
10
|
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
4
11
|
import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
|
|
5
|
-
import {
|
|
12
|
+
import { readCaseEvidenceLinks, mergeCaseEvidenceLinks } from "./case-evidence-links.mjs";
|
|
13
|
+
import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
14
|
+
import { assertBoundedJsonStructure } from "../analysis/bounded-json.mjs";
|
|
6
15
|
import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
|
|
7
16
|
import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
|
|
8
17
|
import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseClosureHistories, mergeCaseObservedEffectHistories, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
|
|
18
|
+
import { readCaseWorkflowFields } from "./case-workflow-migration.mjs";
|
|
19
|
+
import { emptyCaseWorkflowMetadata, mergeCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
|
|
9
20
|
export const MAX_CASE_INPUT_RECORDS = 2_000;
|
|
10
21
|
function compareCodeUnits(left, right) {
|
|
11
22
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
@@ -81,6 +92,8 @@ export function normalizeCaseStore(raw) {
|
|
|
81
92
|
function assertModernCaseShape(raw, sourceVersion) {
|
|
82
93
|
for (const item of boundedCaseList(raw).items) {
|
|
83
94
|
const itemRecord = objectRecord(item);
|
|
95
|
+
if (sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'evidenceLinks'))
|
|
96
|
+
throw new TypeError('Evidence relationships require the current Case schema.');
|
|
84
97
|
if (sourceVersion < INCIDENT_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'attachments')) {
|
|
85
98
|
throw new TypeError('Retained file references require the current Case schema; no data was changed.');
|
|
86
99
|
}
|
|
@@ -168,6 +181,8 @@ function boundedCaseList(raw) {
|
|
|
168
181
|
*/
|
|
169
182
|
function extractImportPatch(raw, importedVersion) {
|
|
170
183
|
const record = objectRecord(raw);
|
|
184
|
+
if (record.evidenceLinks !== undefined && importedVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION)
|
|
185
|
+
throw new TypeError('Evidence relationships require the current Case schema.');
|
|
171
186
|
const domain = normalizeDomain(record.domain);
|
|
172
187
|
if (!domain)
|
|
173
188
|
return null;
|
|
@@ -204,6 +219,9 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
204
219
|
[record.sightings, sightings],
|
|
205
220
|
].reduce((total, [candidates, retained]) => total
|
|
206
221
|
+ Math.max(0, (Array.isArray(candidates) ? candidates.length : 0) - retained.length), 0);
|
|
222
|
+
const workflow = readCaseWorkflowFields(record, domain, assertions, importedVersion);
|
|
223
|
+
const hasWorkflow = record.workflowMetadata !== undefined || workflow.workflowMetadata.types.length > 0
|
|
224
|
+
|| workflow.workflowMetadata.incidentTargets.length > 0 || workflow.workflowMetadata.investigationContext !== null;
|
|
207
225
|
return {
|
|
208
226
|
domain,
|
|
209
227
|
rawId: typeof record.id === 'string' ? record.id : null,
|
|
@@ -235,7 +253,9 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
235
253
|
? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
|
|
236
254
|
: [],
|
|
237
255
|
attachments: importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? readCaseAttachments(record.attachments) : undefined,
|
|
238
|
-
|
|
256
|
+
evidenceLinks: readCaseEvidenceLinks(record.evidenceLinks),
|
|
257
|
+
tags: workflow.tags,
|
|
258
|
+
workflowMetadata: hasWorkflow ? workflow.workflowMetadata : undefined,
|
|
239
259
|
// Imported notes fall back only to the imported record's own timestamps
|
|
240
260
|
// (never "now"), so a timestamp-less note gets a stable, deterministic time
|
|
241
261
|
// and id, and re-importing the same file cannot manufacture a duplicate or a
|
|
@@ -245,16 +265,6 @@ function extractImportPatch(raw, importedVersion) {
|
|
|
245
265
|
updatedAt: caseTimestampOrNull(record.updatedAt, importedVersion),
|
|
246
266
|
};
|
|
247
267
|
}
|
|
248
|
-
/** @param {CaseNote[]} a @param {CaseNote[]} b @returns {CaseNote[]} */
|
|
249
|
-
function unionNotes(a, b) {
|
|
250
|
-
const byId = new Map();
|
|
251
|
-
for (const note of [...a, ...b]) {
|
|
252
|
-
if (!byId.has(note.id))
|
|
253
|
-
byId.set(note.id, note);
|
|
254
|
-
}
|
|
255
|
-
const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
|
|
256
|
-
return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
|
|
257
|
-
}
|
|
258
268
|
function retainLocalAuthoredRecords(local, imported, maximum) {
|
|
259
269
|
const records = [...local];
|
|
260
270
|
const retainedIds = new Set(records.map((item) => item.id));
|
|
@@ -271,13 +281,6 @@ function retainLocalAuthoredRecords(local, imported, maximum) {
|
|
|
271
281
|
}
|
|
272
282
|
return { records, omitted };
|
|
273
283
|
}
|
|
274
|
-
// Additive, deduplicated union of two evidence histories. Identical material
|
|
275
|
-
// collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
|
|
276
|
-
// are retained subject to the per-case bound, and an older import can never
|
|
277
|
-
// move an existing observation backwards.
|
|
278
|
-
function mergeEvidenceHistories(local, imported, caseDomain) {
|
|
279
|
-
return normalizeEvidenceHistory([...local, ...imported], { source: 'import', fallback: null, caseDomain });
|
|
280
|
-
}
|
|
281
284
|
/** @param {ImportPatch} patch @param {string} now @returns {CaseRecord} */
|
|
282
285
|
function caseFromPatch(patch, now) {
|
|
283
286
|
return {
|
|
@@ -289,6 +292,7 @@ function caseFromPatch(patch, now) {
|
|
|
289
292
|
reviewReasonCode: patch.reviewReasonCode ?? null,
|
|
290
293
|
brandProfileIds: patch.brandProfileIds,
|
|
291
294
|
tags: patch.tags,
|
|
295
|
+
workflowMetadata: patch.workflowMetadata ?? emptyCaseWorkflowMetadata(),
|
|
292
296
|
notes: patch.notes,
|
|
293
297
|
source: patch.source ?? DEFAULT_SOURCE,
|
|
294
298
|
evidenceHistory: patch.evidenceHistory,
|
|
@@ -302,6 +306,7 @@ function caseFromPatch(patch, now) {
|
|
|
302
306
|
closures: patch.closures,
|
|
303
307
|
branches: patch.branches,
|
|
304
308
|
...(patch.attachments === undefined ? {} : { attachments: patch.attachments }),
|
|
309
|
+
...(patch.evidenceLinks === undefined ? {} : { evidenceLinks: patch.evidenceLinks }),
|
|
305
310
|
createdAt: patch.createdAt || patch.updatedAt || now,
|
|
306
311
|
updatedAt: patch.updatedAt || patch.createdAt || now,
|
|
307
312
|
};
|
|
@@ -318,6 +323,8 @@ function caseFromPatch(patch, now) {
|
|
|
318
323
|
function applyImportPatch(local, patch) {
|
|
319
324
|
const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
|
|
320
325
|
const fallback = patch.updatedAt || local.updatedAt;
|
|
326
|
+
const noteSelection = retainLocalAuthoredRecords(local.notes, patch.notes, MAX_NOTES_PER_CASE);
|
|
327
|
+
const historySelection = mergeImportedEvidenceHistory(local.evidenceHistory, patch.evidenceHistory, local.domain);
|
|
321
328
|
const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
|
|
322
329
|
const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
|
|
323
330
|
const pinIds = new Set(evidencePins.map((item) => item.id));
|
|
@@ -336,7 +343,9 @@ function applyImportPatch(local, patch) {
|
|
|
336
343
|
const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
|
|
337
344
|
const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
|
|
338
345
|
const attachments = mergeCaseAttachments(local.attachments, patch.attachments);
|
|
346
|
+
const evidenceLinks = mergeCaseEvidenceLinks(local.evidenceLinks, patch.evidenceLinks);
|
|
339
347
|
const authoredHistoryOmitted = patch.authoredHistoryOmitted
|
|
348
|
+
+ noteSelection.omitted
|
|
340
349
|
+ pinSelection.omitted
|
|
341
350
|
+ decisionSelection.omitted
|
|
342
351
|
+ assertionSelection.omitted
|
|
@@ -350,7 +359,7 @@ function applyImportPatch(local, patch) {
|
|
|
350
359
|
reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
|
|
351
360
|
brandProfileIds: brandProfileReferences.ids,
|
|
352
361
|
source: patch.source !== undefined && importNewer ? patch.source : local.source,
|
|
353
|
-
evidenceHistory:
|
|
362
|
+
evidenceHistory: historySelection.records,
|
|
354
363
|
evidencePins,
|
|
355
364
|
decisions,
|
|
356
365
|
actions,
|
|
@@ -361,11 +370,15 @@ function applyImportPatch(local, patch) {
|
|
|
361
370
|
closures,
|
|
362
371
|
branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
|
|
363
372
|
...(attachments === undefined ? {} : { attachments }),
|
|
373
|
+
...(evidenceLinks === undefined ? {} : { evidenceLinks }),
|
|
364
374
|
tags: normalizeTags([...local.tags, ...patch.tags]),
|
|
365
|
-
|
|
375
|
+
workflowMetadata: patch.workflowMetadata === undefined
|
|
376
|
+
? local.workflowMetadata ?? emptyCaseWorkflowMetadata()
|
|
377
|
+
: mergeCaseWorkflowMetadata(local.workflowMetadata ?? emptyCaseWorkflowMetadata(), patch.workflowMetadata, importNewer, local.domain),
|
|
378
|
+
notes: normalizeNotes(noteSelection.records, fallback),
|
|
366
379
|
createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
|
|
367
380
|
updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
|
|
368
|
-
}, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted };
|
|
381
|
+
}, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted, evidenceHistoryOmitted: historySelection.omitted };
|
|
369
382
|
}
|
|
370
383
|
function pickFreeId(preferred, domain, used) {
|
|
371
384
|
const wanted = safeId(preferred);
|
|
@@ -478,7 +491,7 @@ function caseCollectionImportEnvelope(importedRaw) {
|
|
|
478
491
|
* reinterpreted.
|
|
479
492
|
* @param {CaseRecord[]} localCases
|
|
480
493
|
* @param {unknown} importedRaw
|
|
481
|
-
* @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number }}
|
|
494
|
+
* @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number, evidenceHistoryOmitted: number }}
|
|
482
495
|
*/
|
|
483
496
|
export function mergeCases(localCases, importedRaw) {
|
|
484
497
|
assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
|
|
@@ -507,6 +520,7 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
507
520
|
let skipped = imported.omitted;
|
|
508
521
|
let brandProfileReferencesOmitted = 0;
|
|
509
522
|
let authoredHistoryOmitted = 0;
|
|
523
|
+
let evidenceHistoryOmitted = 0;
|
|
510
524
|
const fallback = new Date(0).toISOString();
|
|
511
525
|
const importedIds = new Set();
|
|
512
526
|
for (const item of imported.items) {
|
|
@@ -539,6 +553,7 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
539
553
|
byId.set(existing.id, merged.record);
|
|
540
554
|
brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
|
|
541
555
|
authoredHistoryOmitted += merged.authoredHistoryOmitted;
|
|
556
|
+
evidenceHistoryOmitted += merged.evidenceHistoryOmitted;
|
|
542
557
|
updated += 1;
|
|
543
558
|
}
|
|
544
559
|
else if (byId.size < MAX_CASES) {
|
|
@@ -561,5 +576,6 @@ export function mergeCases(localCases, importedRaw) {
|
|
|
561
576
|
skipped,
|
|
562
577
|
brandProfileReferencesOmitted,
|
|
563
578
|
authoredHistoryOmitted,
|
|
579
|
+
evidenceHistoryOmitted,
|
|
564
580
|
};
|
|
565
581
|
}
|