@slicedearth/whoisleuth-cli 2.4.0 → 2.6.0
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/DISCLOSURE +1 -1
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +67 -9
- package/cli/artifact-validation/lookup-evidence.mjs +18 -4
- package/cli/artifact-validation/signed-review.mjs +61 -64
- package/cli/artifact-verify.mjs +11 -2
- 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 +6 -8
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +99 -6
- package/cli/case-pack.mjs +19 -4
- 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 +330 -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 +7 -9
- 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/domain-feed.mjs +61 -0
- 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/json.mjs +1 -13
- 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 +32 -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 +147 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +3 -4
- package/cli/investigation-package-review.mjs +2 -0
- 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 +220 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +19 -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 +85 -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 +132 -0
- package/cli/review-command-definitions.mjs +279 -0
- package/cli/review-command-runner.mjs +72 -159
- package/cli/risk-calibration.mjs +14 -12
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +73 -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 +4 -4
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +100 -0
- package/cli/workflow-command-definitions.mjs +103 -0
- package/cli/workflow-command-runner.mjs +40 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +322 -4
- package/docs/contextual-reviews.md +178 -0
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -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/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- 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 +35 -15
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +27 -11
- 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 +75 -7
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +45 -25
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +27 -17
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +5 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +13 -4
- 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 +13 -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-child-profile-contract.mjs +28 -11
- 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 +70 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/operation-budget.mjs +4 -0
- 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 +46 -2
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +142 -139
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/technology-evidence-role.mjs +1 -51
- 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/tls-intelligence.mjs +30 -5
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +404 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +15 -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 +348 -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-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +151 -0
- package/packages/cases/case-evidence-model.mjs +89 -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-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +124 -36
- 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 +111 -118
- package/packages/cases/case-record-projection.mjs +23 -4
- package/packages/cases/case-record-version-input.mjs +41 -0
- package/packages/cases/case-report.mjs +93 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +159 -1
- package/packages/cases/case-response-model.mjs +16 -1
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +108 -20
- package/packages/cases/case-response-packet-render.mjs +35 -12
- package/packages/cases/case-response-packet.mjs +55 -13
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +60 -8
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +217 -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 +35 -15
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +360 -49
- package/packages/contracts/cli-command-semantics.mjs +5 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +25 -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/external-observation-interchange.mjs +96 -28
- 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/lookup-evidence.mjs +3 -2
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +37 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +144 -28
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +15 -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/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +36 -29
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +36 -0
- package/packages/interchange/managed-indicator-set.mjs +223 -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 +79 -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 +87 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +132 -0
- package/packages/investigation/domain-transition-review.mjs +49 -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/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +20 -0
- package/packages/investigation/investigation-manifest.mjs +35 -9
- package/packages/investigation/link-intake.mjs +209 -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 +235 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +48 -4
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +27 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +11 -3
- 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 +24 -7
- package/packages/workspace/watchlist-history.mjs +59 -16
- package/packages/workspace/watchlist-store.mjs +152 -11
- 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,195 @@
|
|
|
1
|
+
import { projectCaseForAudience } from "./case-record-projection.mjs";
|
|
2
|
+
import { readEditableCaseExport } from "./case-export-input.mjs";
|
|
3
|
+
import { array, enumeration, exact, iso, text } from "../evidence/artifact-structure.mjs";
|
|
4
|
+
import { escapeCaseMarkdownInline } from "./case-markdown.mjs";
|
|
5
|
+
import { MAX_CASE_ASSERTIONS, MAX_CASE_EVIDENCE_PINS } from "../contracts/case-portability.mjs";
|
|
6
|
+
import { caseSelectedEvidenceSourceLimitations } from "./case-evidence-links.mjs";
|
|
7
|
+
export const CASE_CONTAINMENT_INPUT_SCHEMA = 'whoisleuth.internal-containment.input';
|
|
8
|
+
export const CASE_CONTAINMENT_HANDOFF_SCHEMA = 'whoisleuth.internal-containment';
|
|
9
|
+
export const CASE_CONTAINMENT_VERSION = 1;
|
|
10
|
+
export const MAX_CONTAINMENT_ASSERTIONS = 20;
|
|
11
|
+
export const MAX_CONTAINMENT_PINS = 40;
|
|
12
|
+
export const MAX_CONTAINMENT_OUTPUT_BYTES = 128 * 1024;
|
|
13
|
+
export const CONTAINMENT_RECIPIENT_ROLES = [
|
|
14
|
+
'security_operations',
|
|
15
|
+
'identity_response',
|
|
16
|
+
'endpoint_response',
|
|
17
|
+
'network_response',
|
|
18
|
+
];
|
|
19
|
+
const ROLE_GUIDANCE = {
|
|
20
|
+
security_operations: [
|
|
21
|
+
'Confirm the responsible internal owner and track the selected requests independently of external removal.',
|
|
22
|
+
'Separate reported actions, retained observations and unresolved evidence before authorising a control.',
|
|
23
|
+
],
|
|
24
|
+
identity_response: [
|
|
25
|
+
'Review the selected requests against authorised account, session and application-grant evidence in the identity system.',
|
|
26
|
+
'Do not treat a reported password entry, sign-in approval or provider resolution as proof of account compromise or recovery.',
|
|
27
|
+
],
|
|
28
|
+
endpoint_response: [
|
|
29
|
+
'Review the selected requests against authorised device evidence before deciding whether isolation, collection or remediation is needed.',
|
|
30
|
+
'Do not execute supplied commands or attachments; a reported execution is not a verified device outcome.',
|
|
31
|
+
],
|
|
32
|
+
network_response: [
|
|
33
|
+
'Review exact scope, collateral effects and an appropriate review/expiry period before applying an approved network control.',
|
|
34
|
+
'Shared infrastructure and literal indicators do not establish ownership, maliciousness or an appropriate blocking scope.',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
function uniqueIds(value, maximum, label) {
|
|
38
|
+
const ids = array(value, label, maximum).map((id) => text(id, label, 240));
|
|
39
|
+
if (new Set(ids).size !== ids.length)
|
|
40
|
+
throw new TypeError(`${label} must be unique.`);
|
|
41
|
+
return ids;
|
|
42
|
+
}
|
|
43
|
+
export function readContainmentSelection(raw) {
|
|
44
|
+
const value = exact(raw, ['audience', 'recipientRole', 'assertionIds', 'evidencePinIds'], 'Containment handoff selection');
|
|
45
|
+
return {
|
|
46
|
+
audience: enumeration(value.audience, ['internal', 'trusted', 'public'], 'Handoff audience'),
|
|
47
|
+
recipientRole: enumeration(value.recipientRole, CONTAINMENT_RECIPIENT_ROLES, 'Internal recipient role'),
|
|
48
|
+
assertionIds: uniqueIds(value.assertionIds, MAX_CONTAINMENT_ASSERTIONS, 'Selected follow-ups'),
|
|
49
|
+
evidencePinIds: uniqueIds(value.evidencePinIds, MAX_CONTAINMENT_PINS, 'Selected supporting pins'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Select retained assertions; never infer completed containment from Case or provider status. */
|
|
53
|
+
export function previewCaseContainmentHandoff(record, raw, generatedAt) {
|
|
54
|
+
iso(generatedAt, 'Handoff generation time');
|
|
55
|
+
const selection = readContainmentSelection(raw);
|
|
56
|
+
array(record.assertions, 'Retained Case assertions', MAX_CASE_ASSERTIONS);
|
|
57
|
+
array(record.evidencePins, 'Retained Case pins', MAX_CASE_EVIDENCE_PINS);
|
|
58
|
+
const followUps = record.assertions.filter((item) => item.kind === 'next_step');
|
|
59
|
+
const original = selection.assertionIds.map((id) => {
|
|
60
|
+
const matches = followUps.filter((item) => item.id === id);
|
|
61
|
+
if (matches.length !== 1)
|
|
62
|
+
throw new TypeError('A selected follow-up is missing or ambiguous; refresh the selection.');
|
|
63
|
+
return matches[0];
|
|
64
|
+
});
|
|
65
|
+
const linkedIds = new Set(original.flatMap((item) => item.evidencePinIds));
|
|
66
|
+
for (const id of selection.evidencePinIds) {
|
|
67
|
+
if (!linkedIds.has(id) || record.evidencePins.filter((pin) => pin.id === id).length !== 1)
|
|
68
|
+
throw new TypeError('Select only uniquely retained pins linked to the selected follow-ups.');
|
|
69
|
+
}
|
|
70
|
+
const permitted = selection.audience !== 'public';
|
|
71
|
+
// Reuse the canonical audience policy before constructing a narrow projection.
|
|
72
|
+
const projected = projectCaseForAudience(record, selection.audience);
|
|
73
|
+
const pins = permitted
|
|
74
|
+
? projected.evidencePins.filter((pin) => selection.evidencePinIds.includes(pin.id))
|
|
75
|
+
: [];
|
|
76
|
+
const assertions = permitted
|
|
77
|
+
? original.map((item) => {
|
|
78
|
+
const selected = projected.assertions.find((assertion) => assertion.id === item.id);
|
|
79
|
+
return {
|
|
80
|
+
id: selected.id,
|
|
81
|
+
kind: 'next_step',
|
|
82
|
+
statement: selected.statement,
|
|
83
|
+
rationale: selected.rationale,
|
|
84
|
+
state: selected.state,
|
|
85
|
+
createdAt: selected.createdAt,
|
|
86
|
+
updatedAt: selected.updatedAt,
|
|
87
|
+
provenance: selected.provenance ?? null,
|
|
88
|
+
recheck: selected.recheck ?? null,
|
|
89
|
+
evidence: selected.evidencePinIds.map((id) => ({
|
|
90
|
+
evidencePinId: id,
|
|
91
|
+
stance: selected.evidenceRelations?.find((relation) => relation.evidencePinId === id)
|
|
92
|
+
?.stance ?? 'not_recorded',
|
|
93
|
+
state: pins.some((pin) => pin.id === id)
|
|
94
|
+
? 'included'
|
|
95
|
+
: record.evidencePins.some((pin) => pin.id === id)
|
|
96
|
+
? 'not_selected'
|
|
97
|
+
: 'unavailable',
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
})
|
|
101
|
+
: [];
|
|
102
|
+
const missingContext = assertions.some((item) => !item.evidence.length || item.evidence.some((link) => link.state !== 'included'));
|
|
103
|
+
const report = {
|
|
104
|
+
schema: CASE_CONTAINMENT_HANDOFF_SCHEMA,
|
|
105
|
+
version: CASE_CONTAINMENT_VERSION,
|
|
106
|
+
generatedAt,
|
|
107
|
+
audience: selection.audience,
|
|
108
|
+
recipientRole: selection.recipientRole,
|
|
109
|
+
case: { id: projected.id, domain: projected.domain, status: projected.status },
|
|
110
|
+
state: !permitted ||
|
|
111
|
+
!assertions.length ||
|
|
112
|
+
missingContext ||
|
|
113
|
+
pins.some((pin) => pin.completeness !== 'complete' || pin.truncated === true)
|
|
114
|
+
? 'partial'
|
|
115
|
+
: 'reviewed',
|
|
116
|
+
reviewRequired: true,
|
|
117
|
+
actionsPerformed: false,
|
|
118
|
+
assertions,
|
|
119
|
+
evidencePins: pins,
|
|
120
|
+
disclosure: {
|
|
121
|
+
exportAllowed: permitted && assertions.length > 0,
|
|
122
|
+
included: permitted
|
|
123
|
+
? [
|
|
124
|
+
'Case identifier, domain and recorded status',
|
|
125
|
+
'Only selected next-step statements, rationale, states and recording times',
|
|
126
|
+
'Exact selected supporting pins and separately qualified evidence relationships',
|
|
127
|
+
]
|
|
128
|
+
: ['Case identifier, domain and recorded status only'],
|
|
129
|
+
excluded: [
|
|
130
|
+
'Unselected assertions and evidence',
|
|
131
|
+
'Case title, notes, contacts, action recipients and original files',
|
|
132
|
+
'Raw upstream responses, automatic assignments and control execution',
|
|
133
|
+
...(permitted ? [] : ['Internal follow-up statements and their supporting pins']),
|
|
134
|
+
],
|
|
135
|
+
caution: 'Selected statements and pin values can contain sensitive analyst text or exact scope. Review each included value for the intended recipient; minimisation is not automatic anonymisation.',
|
|
136
|
+
},
|
|
137
|
+
nextSteps: [...ROLE_GUIDANCE[selection.recipientRole]],
|
|
138
|
+
limitations: [
|
|
139
|
+
'These are retained analyst requests, not verified assignments, executed controls or independent recovery results.',
|
|
140
|
+
'Open and resolved assertion states are preserved as recorded. External provider resolution, object closure and Case status do not resolve open internal requests.',
|
|
141
|
+
'Unselected or missing supporting pins remain explicit. A linked pin may support, contradict or leave a request unresolved; linkage does not establish truth.',
|
|
142
|
+
'This local handoff sends no message, changes no Case state and includes no original attachment bytes.',
|
|
143
|
+
...caseSelectedEvidenceSourceLimitations(pins, record.evidenceLinks, pins.map(pin => pin.id)),
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
if (new TextEncoder().encode(JSON.stringify(report)).byteLength > MAX_CONTAINMENT_OUTPUT_BYTES)
|
|
147
|
+
throw new TypeError('The selected handoff exceeds 128 KiB. Choose fewer follow-ups or pins; nothing was silently omitted.');
|
|
148
|
+
return report;
|
|
149
|
+
}
|
|
150
|
+
export function buildCaseContainmentHandoff(record, selection, generatedAt, disclosureReviewed) {
|
|
151
|
+
const report = previewCaseContainmentHandoff(record, selection, generatedAt);
|
|
152
|
+
if (!disclosureReviewed || !report.disclosure.exportAllowed)
|
|
153
|
+
throw new TypeError('Review the audience disclosure and select at least one retained follow-up for an internal or trusted handoff.');
|
|
154
|
+
return report;
|
|
155
|
+
}
|
|
156
|
+
export function readCaseContainmentInput(raw, generatedAt) {
|
|
157
|
+
const input = exact(raw, ['schema', 'version', 'caseExport', 'caseId', 'selection', 'disclosureReviewed'], 'Containment handoff input');
|
|
158
|
+
if (input.schema !== CASE_CONTAINMENT_INPUT_SCHEMA || input.version !== CASE_CONTAINMENT_VERSION)
|
|
159
|
+
throw new TypeError('Unsupported containment handoff input version.');
|
|
160
|
+
const caseId = text(input.caseId, 'Selected Case ID', 240);
|
|
161
|
+
const selected = readEditableCaseExport(JSON.stringify(input.caseExport)).filter((record) => record.id === caseId);
|
|
162
|
+
if (selected.length !== 1)
|
|
163
|
+
throw new TypeError('Select one unambiguous Case from the supported export.');
|
|
164
|
+
if (input.disclosureReviewed !== true)
|
|
165
|
+
throw new TypeError('The selected handoff requires an explicit audience-disclosure review.');
|
|
166
|
+
return buildCaseContainmentHandoff(selected[0], readContainmentSelection(input.selection), generatedAt, true);
|
|
167
|
+
}
|
|
168
|
+
export function formatCaseContainmentHandoff(report) {
|
|
169
|
+
const escape = escapeCaseMarkdownInline;
|
|
170
|
+
const lines = [
|
|
171
|
+
`# Internal containment handoff`,
|
|
172
|
+
'',
|
|
173
|
+
`Audience: ${report.audience} · ${report.recipientRole.replaceAll('_', ' ')}`,
|
|
174
|
+
`Case: ${escape(report.case.domain)} · ${escape(report.case.id)} · recorded status ${escape(report.case.status)}`,
|
|
175
|
+
`Generated: ${report.generatedAt} · coverage ${report.state} · no actions performed`,
|
|
176
|
+
'',
|
|
177
|
+
'## Selected retained requests',
|
|
178
|
+
'',
|
|
179
|
+
];
|
|
180
|
+
for (const item of report.assertions) {
|
|
181
|
+
lines.push(`- ${escape(item.statement)} — ${item.state} (${escape(item.id)})`);
|
|
182
|
+
if (item.rationale)
|
|
183
|
+
lines.push(` Rationale: ${escape(item.rationale)}`);
|
|
184
|
+
lines.push(` Recorded ${escape(item.createdAt)}; updated ${escape(item.updatedAt)}.`);
|
|
185
|
+
for (const link of item.evidence)
|
|
186
|
+
lines.push(` Evidence ${escape(link.evidencePinId)}: ${link.stance}; ${link.state.replaceAll('_', ' ')}.`);
|
|
187
|
+
if (!item.evidence.length)
|
|
188
|
+
lines.push(' No supporting context was linked.');
|
|
189
|
+
}
|
|
190
|
+
lines.push('', '## Selected supporting evidence', '');
|
|
191
|
+
for (const pin of report.evidencePins)
|
|
192
|
+
lines.push(`- ${escape(pin.label)} (${escape(pin.id)}): ${escape(pin.value)}`, ` Source: ${escape(pin.source)}; observed ${escape(pin.observedAt ?? 'unknown')}; ${pin.completeness}${pin.truncated ? '; truncated' : ''}.`, ...pin.limitations.map((value) => ` Limitation: ${escape(value)}`));
|
|
193
|
+
lines.push('', '## Recipient review', '', ...report.nextSteps.map((value) => `- ${value}`), '', '## Boundaries', '', ...report.limitations.map((value) => `- ${value}`));
|
|
194
|
+
return `${lines.join('\n')}\n`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
}
|
|
136
|
+
export const CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION = 'Selected evidence has retained shared-source or derivation context. Multiple references do not establish independent observations; underlying reporting or collection methods may remain unknown. Relationship details are not included in this output.';
|
|
137
|
+
/** Qualify a narrow disclosure without exposing relationship identities or private basis text. */
|
|
138
|
+
export function caseSelectedEvidenceSourceLimitations(pins, links, selectedPinIds) {
|
|
139
|
+
array(pins, 'Retained evidence pins', MAX_CASE_EVIDENCE_PINS);
|
|
140
|
+
array(links ?? [], 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
|
|
141
|
+
array(selectedPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS);
|
|
142
|
+
const requested = new Set(selectedPinIds);
|
|
143
|
+
const selectedPins = pins.filter(pin => requested.has(pin.id));
|
|
144
|
+
const selected = new Set(selectedPins.map(pin => pin.id));
|
|
145
|
+
if (!selected.size)
|
|
146
|
+
return [];
|
|
147
|
+
const declared = links?.some(link => !link.withdrawal
|
|
148
|
+
&& (selected.has(link.fromPinId) || selected.has(link.toPinId)));
|
|
149
|
+
return declared || caseEvidenceSharedContext(selectedPins).length
|
|
150
|
+
? [CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION] : [];
|
|
151
|
+
}
|
|
@@ -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, OBJECT_RESPONSE_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) {
|
|
@@ -112,6 +113,7 @@ const DEEP_SIGNAL_FIELDS = [
|
|
|
112
113
|
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
113
114
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
|
|
114
115
|
'pageBaselineMatch',
|
|
116
|
+
'hasExternalPasswordForm',
|
|
115
117
|
];
|
|
116
118
|
// Ordered list of the fields that make up a snapshot's *material* identity -
|
|
117
119
|
// everything except capture timestamps, snapshot id and source. `scanDepth` is
|
|
@@ -122,6 +124,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
122
124
|
'observationHostname',
|
|
123
125
|
'webObservationMode',
|
|
124
126
|
'scanDepth',
|
|
127
|
+
'webCollectionQuality',
|
|
125
128
|
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
|
|
126
129
|
'riskFactors', 'opportunityFactors',
|
|
127
130
|
'registrar', 'createdDate', 'expiryDate', 'nameservers',
|
|
@@ -133,6 +136,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
133
136
|
'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
134
137
|
'profileContextState', 'profileContextLimitation',
|
|
135
138
|
'mutationTypes',
|
|
139
|
+
'hasExternalPasswordForm',
|
|
136
140
|
];
|
|
137
141
|
// The canonical, comparison-safe value of a material field. Registrar casing,
|
|
138
142
|
// nameserver order, and sub-day timestamps are collapsed so they can never
|
|
@@ -174,7 +178,7 @@ function isEmptyMaterial(value) {
|
|
|
174
178
|
}
|
|
175
179
|
// Fields that describe the capture rather than assert evidence, so they never
|
|
176
180
|
// on their own keep an otherwise-empty snapshot alive.
|
|
177
|
-
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'confidence']);
|
|
181
|
+
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
|
|
178
182
|
// A snapshot with no material evidence (only timestamps/source/depth, or only a
|
|
179
183
|
// bare confidence/unknown-availability) is dropped rather than added to a
|
|
180
184
|
// timeline.
|
|
@@ -194,7 +198,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
|
|
|
194
198
|
const value = materialValue(field, snapshot, legacyOrder);
|
|
195
199
|
// Optional collection context does not alter historical fingerprints when
|
|
196
200
|
// absent; recorded context separates otherwise-identical captures.
|
|
197
|
-
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode') && value === null)
|
|
201
|
+
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality' || field === 'hasExternalPasswordForm') && value === null)
|
|
198
202
|
continue;
|
|
199
203
|
canonical[field] = value;
|
|
200
204
|
}
|
|
@@ -221,6 +225,8 @@ function buildSnapshot(raw, options) {
|
|
|
221
225
|
if (!raw || typeof raw !== 'object')
|
|
222
226
|
return null;
|
|
223
227
|
const record = objectRecord(raw);
|
|
228
|
+
if (record.hasExternalPasswordForm !== undefined && options.sourceVersion != null && options.sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
229
|
+
throw new TypeError('Password-form attribution requires Case schema 18 or later; historical evidence was not reinterpreted.');
|
|
224
230
|
if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
|
|
225
231
|
return null;
|
|
226
232
|
const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
@@ -231,6 +237,10 @@ function buildSnapshot(raw, options) {
|
|
|
231
237
|
const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
|
|
232
238
|
const scanDepth = normalizeScanDepth(record.scanDepth);
|
|
233
239
|
const httpSummary = normalizeHttpSummary(record);
|
|
240
|
+
const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
|
|
241
|
+
if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
|
|
242
|
+
throw new TypeError('Web collection quality requires Case schema 17 or later; historical evidence was not reinterpreted.');
|
|
243
|
+
}
|
|
234
244
|
const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
|
|
235
245
|
const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
|
|
236
246
|
const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
@@ -248,6 +258,7 @@ function buildSnapshot(raw, options) {
|
|
|
248
258
|
...(observationHostname ? { observationHostname } : {}),
|
|
249
259
|
...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
|
|
250
260
|
scanDepth,
|
|
261
|
+
...(webCollectionQuality ? { webCollectionQuality } : {}),
|
|
251
262
|
availability: evidenceString(record.availability),
|
|
252
263
|
confidence: evidenceString(record.confidence),
|
|
253
264
|
riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
|
|
@@ -281,6 +292,7 @@ function buildSnapshot(raw, options) {
|
|
|
281
292
|
reusesOfficialAssets: boolOrNull(record.reusesOfficialAssets),
|
|
282
293
|
hasPasswordField: boolOrNull(record.hasPasswordField),
|
|
283
294
|
hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
|
|
295
|
+
...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: boolOrNull(record.hasExternalPasswordForm) }),
|
|
284
296
|
phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
|
|
285
297
|
privacyProtected: boolOrNull(record.privacyProtected),
|
|
286
298
|
idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
|
|
@@ -300,6 +312,12 @@ function buildSnapshot(raw, options) {
|
|
|
300
312
|
fields.riskModelVersion = null;
|
|
301
313
|
if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
|
|
302
314
|
fields.opportunityModelVersion = null;
|
|
315
|
+
if (webCollectionQuality) {
|
|
316
|
+
for (const field of ['hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
|
|
317
|
+
if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
|
|
318
|
+
fields[field] = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
303
321
|
// A fast capture never evaluates the deep signals, so any value supplied for
|
|
304
322
|
// them (e.g. a profile's default `false`) is discarded as unevaluated.
|
|
305
323
|
if (scanDepth === 'fast') {
|
|
@@ -325,6 +343,7 @@ function buildSnapshot(raw, options) {
|
|
|
325
343
|
reusesOfficialAssets: null,
|
|
326
344
|
hasPasswordField: null,
|
|
327
345
|
hasExternalFormAction: null,
|
|
346
|
+
...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: null }),
|
|
328
347
|
phishingLanguageMatch: null,
|
|
329
348
|
pageBaselineMatch: null,
|
|
330
349
|
});
|
|
@@ -404,6 +423,11 @@ function compareSnapshotChrono(a, b) {
|
|
|
404
423
|
* @returns {CaseEvidenceSnapshot[]}
|
|
405
424
|
*/
|
|
406
425
|
export function normalizeEvidenceHistory(rawList, options = {}) {
|
|
426
|
+
const ordered = [...evidenceByMaterial(rawList, options).values()].sort(compareSnapshotChrono);
|
|
427
|
+
const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
|
|
428
|
+
return assignUniqueSnapshotIds(kept);
|
|
429
|
+
}
|
|
430
|
+
function evidenceByMaterial(rawList, options) {
|
|
407
431
|
const list = Array.isArray(rawList) ? rawList : [];
|
|
408
432
|
const byMaterial = new Map();
|
|
409
433
|
for (const raw of list) {
|
|
@@ -415,9 +439,24 @@ export function normalizeEvidenceHistory(rawList, options = {}) {
|
|
|
415
439
|
// collision can never merge two genuinely different snapshots.
|
|
416
440
|
byMaterial.set(built.material, existing ? mergeDuplicateSnapshots(existing, built.snapshot) : built.snapshot);
|
|
417
441
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
442
|
+
return byMaterial;
|
|
443
|
+
}
|
|
444
|
+
/** Imports add observations without evicting existing local material. Ordinary
|
|
445
|
+
* collection still uses the newest-history policy in normalizeEvidenceHistory. */
|
|
446
|
+
export function mergeImportedEvidenceHistory(local, imported, caseDomain) {
|
|
447
|
+
const options = { source: 'import', fallback: null, caseDomain };
|
|
448
|
+
const retained = evidenceByMaterial(normalizeEvidenceHistory(local, options), options);
|
|
449
|
+
let omitted = 0;
|
|
450
|
+
for (const [material, incoming] of evidenceByMaterial(imported, options)) {
|
|
451
|
+
const existing = retained.get(material);
|
|
452
|
+
if (existing)
|
|
453
|
+
retained.set(material, mergeDuplicateSnapshots(existing, incoming));
|
|
454
|
+
else if (retained.size < MAX_EVIDENCE_SNAPSHOTS_PER_CASE)
|
|
455
|
+
retained.set(material, incoming);
|
|
456
|
+
else
|
|
457
|
+
omitted += 1;
|
|
458
|
+
}
|
|
459
|
+
return { records: assignUniqueSnapshotIds([...retained.values()].sort(compareSnapshotChrono)), omitted };
|
|
421
460
|
}
|
|
422
461
|
function assignUniqueSnapshotIds(snapshots) {
|
|
423
462
|
const used = new Set();
|
|
@@ -455,6 +494,13 @@ export function latestCaseEvidence(record) {
|
|
|
455
494
|
}
|
|
456
495
|
/** Chronological presentation and comparison admission shared by the browser and Case report. */
|
|
457
496
|
export function caseEvidenceTimeline(history) {
|
|
497
|
+
return buildEvidenceTimeline(history, true);
|
|
498
|
+
}
|
|
499
|
+
/** Reproduce published report fields only; not current analyst comparisons. */
|
|
500
|
+
export function publishedCaseEvidenceTimelineForVerification(history) {
|
|
501
|
+
return buildEvidenceTimeline(history, false);
|
|
502
|
+
}
|
|
503
|
+
function buildEvidenceTimeline(history, enforceCollectionQuality) {
|
|
458
504
|
const dated = (history ?? []).map((snapshot) => ({
|
|
459
505
|
snapshot,
|
|
460
506
|
at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
|
|
@@ -475,10 +521,10 @@ export function caseEvidenceTimeline(history) {
|
|
|
475
521
|
? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
|
|
476
522
|
: null;
|
|
477
523
|
const comparable = previous && !orderingLimitation;
|
|
478
|
-
const changes = comparable ?
|
|
524
|
+
const changes = comparable ? compareEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
|
|
479
525
|
const incomparableReasons = orderingLimitation
|
|
480
526
|
? ['other']
|
|
481
|
-
: comparable ?
|
|
527
|
+
: comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
|
|
482
528
|
if (comparable && !changes.length && !incomparableReasons.length
|
|
483
529
|
&& canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
|
|
484
530
|
incomparableReasons.push('other');
|
|
@@ -543,6 +589,7 @@ const COMPARE_FIELDS = [
|
|
|
543
589
|
{ field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
|
|
544
590
|
{ field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
|
|
545
591
|
{ field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
|
|
592
|
+
{ field: 'hasExternalPasswordForm', scope: 'web', label: 'Password form with declared external destination', type: 'signal', depthGate: 'both-deep' },
|
|
546
593
|
{ field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
|
|
547
594
|
{ field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
|
|
548
595
|
];
|
|
@@ -562,6 +609,13 @@ function opportunityModelComparable(previous, current) {
|
|
|
562
609
|
function valuesMateriallyEqual(field, previous, current) {
|
|
563
610
|
return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
|
|
564
611
|
}
|
|
612
|
+
function collectionFieldComparable(field, snapshot) {
|
|
613
|
+
if (field === 'registrar' || field === 'createdDate' || field === 'expiryDate')
|
|
614
|
+
return isPresent(snapshot[field]);
|
|
615
|
+
return field.startsWith('http')
|
|
616
|
+
? httpSummaryFieldIsObserved(field, snapshot)
|
|
617
|
+
: webCollectionAllowsComparison(field, snapshot.webCollectionQuality, snapshot.scanDepth);
|
|
618
|
+
}
|
|
565
619
|
/**
|
|
566
620
|
* Explains material fields that comparison gates deliberately suppress. The
|
|
567
621
|
* reasons are stable machine values for UI/report wording; they are not risk
|
|
@@ -569,14 +623,27 @@ function valuesMateriallyEqual(field, previous, current) {
|
|
|
569
623
|
* in the same observation produced an ordinary material change.
|
|
570
624
|
* @param {CaseEvidenceSnapshot | null | undefined} previous
|
|
571
625
|
* @param {CaseEvidenceSnapshot | null | undefined} current
|
|
572
|
-
* @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
|
|
626
|
+
* @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model' | 'collection-quality'>}
|
|
573
627
|
*/
|
|
574
628
|
export function caseEvidenceIncomparableReasons(previous, current) {
|
|
629
|
+
return incomparableReasonsForEvidence(previous, current, true);
|
|
630
|
+
}
|
|
631
|
+
function incomparableReasonsForEvidence(previous, current, enforceCollectionQuality) {
|
|
575
632
|
if (!previous || !current || previous.fingerprint === current.fingerprint)
|
|
576
633
|
return [];
|
|
577
634
|
const reasons = [];
|
|
578
635
|
if (!sameObservationContext(previous, current))
|
|
579
636
|
reasons.push('observation-context');
|
|
637
|
+
const missingRegistrationValue = ['registrar', 'createdDate', 'expiryDate'].some(field => !valuesMateriallyEqual(field, previous, current) && (!isPresent(previous[field]) || !isPresent(current[field])));
|
|
638
|
+
const incompleteWebFields = previous.scanDepth === 'deep' && current.scanDepth === 'deep'
|
|
639
|
+
&& (JSON.stringify(previous.webCollectionQuality) !== JSON.stringify(current.webCollectionQuality)
|
|
640
|
+
|| COMPARE_FIELDS.some(spec => (spec.modelGate !== 'risk' || riskModelComparable(previous, current))
|
|
641
|
+
&& (spec.modelGate !== 'opportunity' || opportunityModelComparable(previous, current))
|
|
642
|
+
&& !valuesMateriallyEqual(spec.field, previous, current)
|
|
643
|
+
&& (!collectionFieldComparable(spec.field, previous)
|
|
644
|
+
|| !collectionFieldComparable(spec.field, current))));
|
|
645
|
+
if (enforceCollectionQuality && (missingRegistrationValue || incompleteWebFields))
|
|
646
|
+
reasons.push('collection-quality');
|
|
580
647
|
const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
|
|
581
648
|
|| previous.riskFactors.length > 0 || current.riskFactors.length > 0;
|
|
582
649
|
if (hasRiskEvidence && !riskModelComparable(previous, current))
|
|
@@ -749,6 +816,10 @@ function compareField(spec, before, after) {
|
|
|
749
816
|
return null;
|
|
750
817
|
}
|
|
751
818
|
}
|
|
819
|
+
/** Field scope is owned by the same specification that gates comparisons. */
|
|
820
|
+
export function caseEvidenceFieldScope(field) {
|
|
821
|
+
return COMPARE_FIELDS.find(spec => spec.field === field)?.scope ?? null;
|
|
822
|
+
}
|
|
752
823
|
/**
|
|
753
824
|
* Diffs two normalized snapshots into a bounded, stably-ordered list of
|
|
754
825
|
* material changes. Timestamps, source, id and fingerprint are ignored;
|
|
@@ -763,6 +834,9 @@ function compareField(spec, before, after) {
|
|
|
763
834
|
* @returns {Array<{ field: string, label: string, before: unknown, after: unknown, tone: string }>}
|
|
764
835
|
*/
|
|
765
836
|
export function compareCaseEvidence(previous, current) {
|
|
837
|
+
return compareEvidence(previous, current, true);
|
|
838
|
+
}
|
|
839
|
+
function compareEvidence(previous, current, enforceCollectionQuality) {
|
|
766
840
|
if (!previous || !current)
|
|
767
841
|
return [];
|
|
768
842
|
const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
|
|
@@ -785,6 +859,9 @@ export function compareCaseEvidence(previous, current) {
|
|
|
785
859
|
continue;
|
|
786
860
|
if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
|
|
787
861
|
continue;
|
|
862
|
+
if (enforceCollectionQuality && (!collectionFieldComparable(spec.field, previous)
|
|
863
|
+
|| !collectionFieldComparable(spec.field, current)))
|
|
864
|
+
continue;
|
|
788
865
|
const result = compareField(spec, previous[spec.field], current[spec.field]);
|
|
789
866
|
if (result) {
|
|
790
867
|
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
|
}
|