@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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 +9 -6
- package/NOTICE +3 -2
- package/README.md +60 -67
- package/cli/artifact-validation/case-response.mjs +6 -814
- package/cli/artifact-validation/lookup-evidence.mjs +30 -3
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +41 -3
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +92 -16
- package/cli/case-pack.mjs +14 -3
- package/cli/ci-report.mjs +17 -5
- package/cli/command-definition.mjs +7 -6
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/doctor.mjs +37 -3
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/html.mjs +3 -0
- package/cli/formatters/json.mjs +9 -14
- package/cli/formatters/terminal-lookup.mjs +24 -1
- package/cli/history-command-definitions.mjs +1 -1
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +15 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-brief.mjs +11 -5
- package/cli/lookup-command-runner.mjs +21 -13
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +23 -17
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/saved-lookup.mjs +22 -7
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/source-reliability.mjs +12 -10
- package/cli/support-arguments.mjs +1 -1
- package/cli/support-command-definitions.mjs +5 -4
- package/cli/support-command-runner.mjs +9 -7
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +27 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli-reference.md +49 -11
- package/docs/cli.md +200 -26
- package/docs/contextual-reviews.md +38 -5
- package/docs/infrastructure-observations.md +135 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dmarc-discovery.mjs +4 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-inheritance-review.mjs +14 -1
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-export.mjs +10 -4
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +17 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +178 -5
- package/lib/html-signals.mjs +6 -0
- package/lib/idn-confusables.mjs +5 -1
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-collection-receipt.mjs +52 -0
- package/lib/lookup-diagnostics.mjs +2 -12
- package/lib/lookup-progress.mjs +29 -31
- package/lib/lookup-response-contract.mjs +10 -0
- package/lib/lookup-response.mjs +15 -14
- package/lib/lookup-source-progress.mjs +4 -2
- package/lib/lookup.mjs +33 -2
- package/lib/operation-budget.mjs +4 -0
- package/lib/rdap-capabilities.mjs +43 -9
- package/lib/registry-insights.mjs +17 -6
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +142 -140
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-authority.mjs +56 -1
- package/lib/whois-chain.mjs +14 -4
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-contracts.mjs +2 -0
- package/lib/whois-normalization.mjs +4 -4
- package/package.json +9 -7
- package/packages/analysis/bounded-json.mjs +12 -2
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-attachment-model.mjs +36 -1
- package/packages/cases/case-checkpoint-receipts.mjs +68 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +44 -0
- package/packages/cases/case-evidence-model.mjs +51 -7
- package/packages/cases/case-migration-model.mjs +9 -1
- package/packages/cases/case-object-coverage.mjs +119 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +87 -9
- package/packages/cases/case-record-projection.mjs +43 -5
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +35 -4
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet-validation.mjs +880 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
- package/packages/cases/case-response-packet.mjs +32 -17
- package/packages/cases/case-response-review-inputs.mjs +66 -8
- package/packages/cases/case-type-presentation.mjs +10 -0
- package/packages/cases/case-workflow-metadata.mjs +1 -17
- package/packages/comparison/capture-context.mjs +3 -2
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/analyst-review-state-contract.mjs +1 -1
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +292 -43
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-doctor.mjs +4 -0
- package/packages/contracts/cli-lookup.mjs +57 -46
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +13 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/registry-insights.mjs +6 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dmarc-policy-context.mjs +80 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-collection-receipt.mjs +140 -0
- package/packages/evidence/lookup-collection-review.mjs +74 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +40 -21
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -2
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +50 -12
- package/packages/investigation/domain-transition-review.mjs +2 -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 +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +241 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +12 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +30 -30
|
@@ -6,20 +6,20 @@ const STATE_LABELS = Object.freeze({
|
|
|
6
6
|
expiring: 'expiring/pending delete',
|
|
7
7
|
registered: 'registered',
|
|
8
8
|
});
|
|
9
|
-
// Version
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
// operational properties remain supporting context, and missing evidence never
|
|
13
|
-
// contributes points.
|
|
14
|
-
export const RISK_MODEL_VERSION = 8;
|
|
9
|
+
// Version 9 requires form-scoped password/destination evidence. Older page-wide
|
|
10
|
+
// flags never establish that a password control belongs to an external form.
|
|
11
|
+
export const RISK_MODEL_VERSION = 9;
|
|
15
12
|
export const RISK_REVIEW_THRESHOLD = 70;
|
|
16
13
|
const CURRENT_RISK_MODEL = Object.freeze({
|
|
17
14
|
modelVersion: RISK_MODEL_VERSION,
|
|
18
15
|
includePageBaselineMatch: false,
|
|
16
|
+
requireFormAttribution: true,
|
|
19
17
|
});
|
|
18
|
+
const RISK_MODEL_V8 = Object.freeze({ modelVersion: 8, includePageBaselineMatch: false, requireFormAttribution: false });
|
|
20
19
|
const RISK_MODEL_V7 = Object.freeze({
|
|
21
20
|
modelVersion: 7,
|
|
22
21
|
includePageBaselineMatch: true,
|
|
22
|
+
requireFormAttribution: false,
|
|
23
23
|
});
|
|
24
24
|
const RISK_STATE_BASE = Object.freeze({
|
|
25
25
|
registered: 6,
|
|
@@ -104,7 +104,7 @@ function scoreQuality(input) {
|
|
|
104
104
|
if ([input.faviconMatch, input.faviconNearMatch, input.reusesOfficialAssets, input.pageBaselineMatch].some(hasBoolean)) {
|
|
105
105
|
observedFamilies.push('brand-presentation');
|
|
106
106
|
}
|
|
107
|
-
if ([input.hasPasswordField, input.hasExternalFormAction].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
|
|
107
|
+
if ([input.hasPasswordField, input.hasExternalFormAction, input.hasExternalPasswordForm].some(hasBoolean) || typeof input.phishingLanguageMatch === 'string') {
|
|
108
108
|
observedFamilies.push('credential-lure');
|
|
109
109
|
}
|
|
110
110
|
if (calibrateExternalIntelligenceRisk(input.threatIntelligence, input.domain).eligibleProviderCount > 0) {
|
|
@@ -159,8 +159,8 @@ function explainRiskScoreInternal(input, excludedFamily, model) {
|
|
|
159
159
|
if (typeof input.phishingLanguageMatch === 'string' && input.phishingLanguageMatch.trim()) {
|
|
160
160
|
add('credential-lure', 'Suspicious urgency language observed', 8);
|
|
161
161
|
}
|
|
162
|
-
if (input.hasPasswordField === true && input.hasExternalFormAction === true) {
|
|
163
|
-
add('credential-lure', 'Password form submits to an external origin', 10);
|
|
162
|
+
if (model.requireFormAttribution ? input.hasExternalPasswordForm === true : input.hasPasswordField === true && input.hasExternalFormAction === true) {
|
|
163
|
+
add('credential-lure', model.requireFormAttribution ? 'Password form declares an external destination' : 'Password form submits to an external origin', 10);
|
|
164
164
|
}
|
|
165
165
|
else if (input.hasPasswordField === true) {
|
|
166
166
|
add('credential-lure', 'Login/password form present', 5);
|
|
@@ -254,6 +254,9 @@ export function computeRiskScore(input) {
|
|
|
254
254
|
export function explainRiskScoreV7(input) {
|
|
255
255
|
return explainRiskScoreInternal(input, null, RISK_MODEL_V7);
|
|
256
256
|
}
|
|
257
|
+
export function explainRiskScoreV8(input) {
|
|
258
|
+
return explainRiskScoreInternal(input, null, RISK_MODEL_V8);
|
|
259
|
+
}
|
|
257
260
|
// Retained only for deterministic offline comparison of reviewed calibration
|
|
258
261
|
// datasets. Runtime lookups always use the current model above.
|
|
259
262
|
export function explainRiskScoreV6(input) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MAX_SELECTED_FILES, SELECTED_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
|
|
2
2
|
import { array, enumeration, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
|
|
3
3
|
import { readRetainedFileReference } from "../evidence/retained-file.mjs";
|
|
4
|
-
import { readImageDerivation } from "../evidence/image-regions.mjs";
|
|
4
|
+
import { declareImageDerivation, readImageDerivation } from "../evidence/image-regions.mjs";
|
|
5
5
|
/** Case provenance is independent of deduplicated immutable content. */
|
|
6
6
|
export function readCaseAttachment(value) {
|
|
7
7
|
const item = exactOptional(value, ['id', 'fileName', 'mediaType', 'source', 'observedAt', 'retainedAt', 'digestSha256', 'byteLength'], ['derivation'], 'Case attachment');
|
|
@@ -50,6 +50,41 @@ export function mergeCaseAttachments(local, incoming) {
|
|
|
50
50
|
export function caseAttachmentReferences(cases) {
|
|
51
51
|
return cases.flatMap(record => (record.attachments ?? []).map(({ digestSha256, byteLength }) => ({ digestSha256, byteLength })));
|
|
52
52
|
}
|
|
53
|
+
/** Follow declared parents in one admitted Case's metadata; never resolve files or other Cases. */
|
|
54
|
+
export function reviewCaseImageDerivation(attachment, retained) {
|
|
55
|
+
if (!attachment.derivation)
|
|
56
|
+
return null;
|
|
57
|
+
if (!Array.isArray(retained) || retained.length > MAX_SELECTED_FILES)
|
|
58
|
+
throw new TypeError('Image history exceeds the Case attachment bound.');
|
|
59
|
+
const references = new Map();
|
|
60
|
+
for (const item of retained)
|
|
61
|
+
references.set(item.id, references.has(item.id) ? null : item);
|
|
62
|
+
const steps = [];
|
|
63
|
+
const finish = (termination) => Object.freeze({ steps: Object.freeze(steps), termination });
|
|
64
|
+
const visited = new Set([attachment.id]);
|
|
65
|
+
let current = attachment;
|
|
66
|
+
for (let depth = 0; depth < MAX_SELECTED_FILES; depth++) {
|
|
67
|
+
const derivation = current.derivation;
|
|
68
|
+
const declaration = declareImageDerivation(derivation);
|
|
69
|
+
const parent = references.get(derivation.sourceAttachmentId);
|
|
70
|
+
const matches = parent?.mediaType === 'image/png'
|
|
71
|
+
&& parent.digestSha256 === declaration.source.digestSha256
|
|
72
|
+
&& parent.byteLength === declaration.source.byteLength;
|
|
73
|
+
const parentReference = parent === undefined ? 'missing' : matches ? 'matching' : 'conflicting';
|
|
74
|
+
steps.push(Object.freeze({ declaration, parentReference, parentHasDeclaredEdits: matches ? Boolean(parent?.derivation) : null }));
|
|
75
|
+
if (parentReference === 'missing')
|
|
76
|
+
return finish('missing_parent');
|
|
77
|
+
if (!matches || !parent)
|
|
78
|
+
return finish('conflicting_parent');
|
|
79
|
+
if (visited.has(parent.id))
|
|
80
|
+
return finish('cycle');
|
|
81
|
+
if (!parent.derivation)
|
|
82
|
+
return finish('no_further_declaration');
|
|
83
|
+
visited.add(parent.id);
|
|
84
|
+
current = parent;
|
|
85
|
+
}
|
|
86
|
+
return finish('limit');
|
|
87
|
+
}
|
|
53
88
|
export function addCaseAttachments(record, attachments, updatedAt) {
|
|
54
89
|
iso(updatedAt, 'Case update time');
|
|
55
90
|
return { ...record, attachments: mergeCaseAttachments(record.attachments, attachments) ?? [], updatedAt };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { MAX_CASE_EVIDENCE_PINS, COLLECTION_RECEIPT_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
2
|
+
import { parseLookupCollectionReceipt, lookupCollectionReceiptIdentity } from "../evidence/lookup-collection-receipt.mjs";
|
|
3
|
+
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
4
|
+
/** Removing the last pin in a group also removes its context. No other pin edit
|
|
5
|
+
* needs bookkeeping, and normalisation never changes a pin or checkpoint ID. */
|
|
6
|
+
export function readCaseCheckpointReceipts(value, pins, sourceVersion) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (sourceVersion != null && sourceVersion < COLLECTION_RECEIPT_CASE_SCHEMA_VERSION) {
|
|
10
|
+
throw new TypeError('Checkpoint collection receipts require Case schema 20.');
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(value) || value.length > MAX_CASE_EVIDENCE_PINS) {
|
|
13
|
+
throw new TypeError('Checkpoint collection receipts must fit the retained evidence-pin limit.');
|
|
14
|
+
}
|
|
15
|
+
const groups = new Set(pins.map(pin => pin.checkpointId).filter(Boolean));
|
|
16
|
+
const entries = new Map();
|
|
17
|
+
for (const raw of value) {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)
|
|
19
|
+
|| Object.keys(raw).length !== 2 || !Object.hasOwn(raw, 'checkpointId') || !Object.hasOwn(raw, 'collectionReceipt')
|
|
20
|
+
|| typeof raw.checkpointId !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(raw.checkpointId)) {
|
|
21
|
+
throw new TypeError('A checkpoint collection receipt is malformed.');
|
|
22
|
+
}
|
|
23
|
+
const collectionReceipt = parseLookupCollectionReceipt(raw.collectionReceipt);
|
|
24
|
+
if (!collectionReceipt || collectionReceipt.targetClass !== 'domain')
|
|
25
|
+
throw new TypeError('A Case checkpoint requires a domain collection receipt.');
|
|
26
|
+
const previous = entries.get(raw.checkpointId);
|
|
27
|
+
if (previous && lookupCollectionReceiptIdentity(previous.collectionReceipt) !== lookupCollectionReceiptIdentity(collectionReceipt)) {
|
|
28
|
+
throw new TypeError('Conflicting collection receipts use the same checkpoint identity. No evidence was changed.');
|
|
29
|
+
}
|
|
30
|
+
entries.set(raw.checkpointId, { checkpointId: raw.checkpointId, collectionReceipt });
|
|
31
|
+
}
|
|
32
|
+
const retained = [...entries.values()].filter(entry => groups.has(entry.checkpointId))
|
|
33
|
+
.sort((a, b) => a.checkpointId < b.checkpointId ? -1 : a.checkpointId > b.checkpointId ? 1 : 0);
|
|
34
|
+
for (const entry of retained) {
|
|
35
|
+
if (pins.some(pin => pin.checkpointId === entry.checkpointId && pin.collectionDepth !== entry.collectionReceipt.requestedDepth)) {
|
|
36
|
+
throw new TypeError('The checkpoint collection receipt does not match its retained pins.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return retained.length ? retained : undefined;
|
|
40
|
+
}
|
|
41
|
+
export function mergeCaseCheckpointReceipts(left, right, pins) {
|
|
42
|
+
const before = readCaseCheckpointReceipts(left.checkpointCollectionReceipts, left.evidencePins) ?? [];
|
|
43
|
+
const incoming = readCaseCheckpointReceipts(right.checkpointCollectionReceipts, right.evidencePins) ?? [];
|
|
44
|
+
// Each input is bounded independently. Deduplicate before applying the final
|
|
45
|
+
// group bound so re-importing a full Case remains idempotent.
|
|
46
|
+
const byId = new Map(before.map(entry => [entry.checkpointId, entry]));
|
|
47
|
+
for (const entry of incoming) {
|
|
48
|
+
const previous = byId.get(entry.checkpointId);
|
|
49
|
+
if (previous && lookupCollectionReceiptIdentity(previous.collectionReceipt) !== lookupCollectionReceiptIdentity(entry.collectionReceipt)) {
|
|
50
|
+
throw new TypeError('Conflicting collection receipts use the same checkpoint identity. No evidence was changed.');
|
|
51
|
+
}
|
|
52
|
+
byId.set(entry.checkpointId, entry);
|
|
53
|
+
}
|
|
54
|
+
const groups = new Set(pins.map(pin => pin.checkpointId));
|
|
55
|
+
const retained = [...byId.values()].filter(entry => groups.has(entry.checkpointId));
|
|
56
|
+
for (const entry of retained) {
|
|
57
|
+
// A colliding group ID must not lend its receipt to unrelated retained
|
|
58
|
+
// pins. Identical imports and partial retention remain valid.
|
|
59
|
+
const sourcePins = [before.some(item => item.checkpointId === entry.checkpointId) ? left.evidencePins : [],
|
|
60
|
+
incoming.some(item => item.checkpointId === entry.checkpointId) ? right.evidencePins : []].flat();
|
|
61
|
+
for (const pin of pins.filter(pin => pin.checkpointId === entry.checkpointId)) {
|
|
62
|
+
if (!sourcePins.some(source => source.id === pin.id && canonicalArtifactJsonV2(source) === canonicalArtifactJsonV2(pin))) {
|
|
63
|
+
throw new TypeError('A checkpoint identity belongs to different retained pins. Its collection context cannot be reassigned.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return readCaseCheckpointReceipts(retained, pins);
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -115,6 +115,34 @@ export function caseEvidenceLinkIssues(links, pins) {
|
|
|
115
115
|
cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
|
|
116
116
|
}));
|
|
117
117
|
}
|
|
118
|
+
/** Follow declared sources, not automatic context matches or withdrawn links. */
|
|
119
|
+
export function traceCaseEvidenceSources(links, rootPinIds) {
|
|
120
|
+
array(links, 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
|
|
121
|
+
const roots = [...new Set(array(rootPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS).map(identifier))];
|
|
122
|
+
const neighbours = new Map();
|
|
123
|
+
function add(from, to, linkId) {
|
|
124
|
+
const entries = neighbours.get(from) ?? [];
|
|
125
|
+
entries.push({ linkId, pinId: to });
|
|
126
|
+
neighbours.set(from, entries);
|
|
127
|
+
}
|
|
128
|
+
for (const link of links)
|
|
129
|
+
if (!link.withdrawal) {
|
|
130
|
+
add(link.fromPinId, link.toPinId, link.id);
|
|
131
|
+
if (link.kind === 'shared_source')
|
|
132
|
+
add(link.toPinId, link.fromPinId, link.id);
|
|
133
|
+
}
|
|
134
|
+
const pinIds = new Set(roots), linkIds = new Set();
|
|
135
|
+
// Set iteration visits newly reached pins once, including unresolved references.
|
|
136
|
+
for (const pinId of pinIds)
|
|
137
|
+
for (const edge of neighbours.get(pinId) ?? []) {
|
|
138
|
+
linkIds.add(edge.linkId);
|
|
139
|
+
pinIds.add(edge.pinId);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
rootPinIds: roots, pinIds: [...pinIds], linkIds: [...linkIds],
|
|
143
|
+
withdrawnLinkIds: links.filter(link => link.withdrawal && (pinIds.has(link.fromPinId) || pinIds.has(link.toPinId))).map(link => link.id),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
118
146
|
/** Matching context is disclosed, never counted as independent corroboration. */
|
|
119
147
|
export function caseEvidenceSharedContext(pins) {
|
|
120
148
|
const groups = new Map();
|
|
@@ -133,3 +161,19 @@ export function caseEvidenceSharedContext(pins) {
|
|
|
133
161
|
}
|
|
134
162
|
return [...groups.values()].filter(group => group.pinIds.length > 1);
|
|
135
163
|
}
|
|
164
|
+
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.';
|
|
165
|
+
/** Qualify a narrow disclosure without exposing relationship identities or private basis text. */
|
|
166
|
+
export function caseSelectedEvidenceSourceLimitations(pins, links, selectedPinIds) {
|
|
167
|
+
array(pins, 'Retained evidence pins', MAX_CASE_EVIDENCE_PINS);
|
|
168
|
+
array(links ?? [], 'Retained evidence relationships', MAX_CASE_EVIDENCE_LINKS);
|
|
169
|
+
array(selectedPinIds, 'Selected evidence pins', MAX_CASE_EVIDENCE_PINS);
|
|
170
|
+
const requested = new Set(selectedPinIds);
|
|
171
|
+
const selectedPins = pins.filter(pin => requested.has(pin.id));
|
|
172
|
+
const selected = new Set(selectedPins.map(pin => pin.id));
|
|
173
|
+
if (!selected.size)
|
|
174
|
+
return [];
|
|
175
|
+
const declared = links?.some(link => !link.withdrawal
|
|
176
|
+
&& (selected.has(link.fromPinId) || selected.has(link.toPinId)));
|
|
177
|
+
return declared || caseEvidenceSharedContext(selectedPins).length
|
|
178
|
+
? [CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION] : [];
|
|
179
|
+
}
|
|
@@ -5,7 +5,8 @@ import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scorin
|
|
|
5
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 { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
8
|
+
import { COLLECTION_RECEIPT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
9
|
+
import { parseLookupCollectionReceipt, lookupCollectionReceiptIdentity } from "../evidence/lookup-collection-receipt.mjs";
|
|
9
10
|
import { validWebObservationMode } from "../evidence/lookup-target.mjs";
|
|
10
11
|
import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
|
|
11
12
|
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";
|
|
@@ -113,6 +114,7 @@ const DEEP_SIGNAL_FIELDS = [
|
|
|
113
114
|
'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
|
|
114
115
|
'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
|
|
115
116
|
'pageBaselineMatch',
|
|
117
|
+
'hasExternalPasswordForm',
|
|
116
118
|
];
|
|
117
119
|
// Ordered list of the fields that make up a snapshot's *material* identity -
|
|
118
120
|
// everything except capture timestamps, snapshot id and source. `scanDepth` is
|
|
@@ -124,6 +126,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
124
126
|
'webObservationMode',
|
|
125
127
|
'scanDepth',
|
|
126
128
|
'webCollectionQuality',
|
|
129
|
+
'collectionReceipt',
|
|
127
130
|
'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
|
|
128
131
|
'riskFactors', 'opportunityFactors',
|
|
129
132
|
'registrar', 'createdDate', 'expiryDate', 'nameservers',
|
|
@@ -135,6 +138,7 @@ const MATERIAL_FIELD_ORDER = [
|
|
|
135
138
|
'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
|
|
136
139
|
'profileContextState', 'profileContextLimitation',
|
|
137
140
|
'mutationTypes',
|
|
141
|
+
'hasExternalPasswordForm',
|
|
138
142
|
];
|
|
139
143
|
// The canonical, comparison-safe value of a material field. Registrar casing,
|
|
140
144
|
// nameserver order, and sub-day timestamps are collapsed so they can never
|
|
@@ -176,7 +180,7 @@ function isEmptyMaterial(value) {
|
|
|
176
180
|
}
|
|
177
181
|
// Fields that describe the capture rather than assert evidence, so they never
|
|
178
182
|
// on their own keep an otherwise-empty snapshot alive.
|
|
179
|
-
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
|
|
183
|
+
const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'collectionReceipt', 'confidence']);
|
|
180
184
|
// A snapshot with no material evidence (only timestamps/source/depth, or only a
|
|
181
185
|
// bare confidence/unknown-availability) is dropped rather than added to a
|
|
182
186
|
// timeline.
|
|
@@ -196,7 +200,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
|
|
|
196
200
|
const value = materialValue(field, snapshot, legacyOrder);
|
|
197
201
|
// Optional collection context does not alter historical fingerprints when
|
|
198
202
|
// absent; recorded context separates otherwise-identical captures.
|
|
199
|
-
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality') && value === null)
|
|
203
|
+
if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality' || field === 'collectionReceipt' || field === 'hasExternalPasswordForm') && value === null)
|
|
200
204
|
continue;
|
|
201
205
|
canonical[field] = value;
|
|
202
206
|
}
|
|
@@ -218,11 +222,30 @@ export function normalizeSnapshot(raw, options = {}) {
|
|
|
218
222
|
const built = buildSnapshot(raw, options);
|
|
219
223
|
return built ? built.snapshot : null;
|
|
220
224
|
}
|
|
225
|
+
/** Shared artefacts use receipt-free identities, not hashes of private choices.
|
|
226
|
+
* Unchanged historical histories keep their exact identifiers. */
|
|
227
|
+
export function caseEvidenceWithoutCollectionReceipts(record) {
|
|
228
|
+
if (!Array.isArray(record.evidenceHistory))
|
|
229
|
+
return [];
|
|
230
|
+
if (!record.evidenceHistory.some(snapshot => snapshot?.collectionReceipt !== undefined)) {
|
|
231
|
+
return structuredClone(record.evidenceHistory);
|
|
232
|
+
}
|
|
233
|
+
return normalizeEvidenceHistory(record.evidenceHistory.map(snapshot => {
|
|
234
|
+
if (!snapshot || typeof snapshot !== 'object')
|
|
235
|
+
return snapshot;
|
|
236
|
+
const { collectionReceipt: _receipt, ...evidence } = snapshot;
|
|
237
|
+
return evidence;
|
|
238
|
+
}), {
|
|
239
|
+
fallback: null, caseDomain: record.domain,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
221
242
|
/** @returns {{ snapshot: CaseEvidenceSnapshot, material: string } | null} */
|
|
222
243
|
function buildSnapshot(raw, options) {
|
|
223
244
|
if (!raw || typeof raw !== 'object')
|
|
224
245
|
return null;
|
|
225
246
|
const record = objectRecord(raw);
|
|
247
|
+
if (record.hasExternalPasswordForm !== undefined && options.sourceVersion != null && options.sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
248
|
+
throw new TypeError('Password-form attribution requires Case schema 18 or later; historical evidence was not reinterpreted.');
|
|
226
249
|
if (record.factorOrder !== undefined && record.factorOrder !== 'code-unit-v1')
|
|
227
250
|
return null;
|
|
228
251
|
const historicalSchema = options.sourceVersion !== undefined && Number(options.sourceVersion) <= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
|
|
@@ -232,6 +255,13 @@ function buildSnapshot(raw, options) {
|
|
|
232
255
|
// captures declare code-unit ordering; the declaration survives every save.
|
|
233
256
|
const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
|
|
234
257
|
const scanDepth = normalizeScanDepth(record.scanDepth);
|
|
258
|
+
const collectionReceipt = parseLookupCollectionReceipt(record.collectionReceipt);
|
|
259
|
+
if (collectionReceipt && options.sourceVersion != null && options.sourceVersion < COLLECTION_RECEIPT_CASE_SCHEMA_VERSION) {
|
|
260
|
+
throw new TypeError('Collection receipts require Case schema 20 or later; historical evidence was not reinterpreted.');
|
|
261
|
+
}
|
|
262
|
+
if (collectionReceipt && (collectionReceipt.targetClass !== 'domain' || collectionReceipt.requestedDepth !== scanDepth)) {
|
|
263
|
+
throw new TypeError('Collection receipt does not match the Case assessment.');
|
|
264
|
+
}
|
|
235
265
|
const httpSummary = normalizeHttpSummary(record);
|
|
236
266
|
const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
|
|
237
267
|
if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
|
|
@@ -255,6 +285,7 @@ function buildSnapshot(raw, options) {
|
|
|
255
285
|
...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
|
|
256
286
|
scanDepth,
|
|
257
287
|
...(webCollectionQuality ? { webCollectionQuality } : {}),
|
|
288
|
+
...(collectionReceipt ? { collectionReceipt } : {}),
|
|
258
289
|
availability: evidenceString(record.availability),
|
|
259
290
|
confidence: evidenceString(record.confidence),
|
|
260
291
|
riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
|
|
@@ -288,6 +319,7 @@ function buildSnapshot(raw, options) {
|
|
|
288
319
|
reusesOfficialAssets: boolOrNull(record.reusesOfficialAssets),
|
|
289
320
|
hasPasswordField: boolOrNull(record.hasPasswordField),
|
|
290
321
|
hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
|
|
322
|
+
...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: boolOrNull(record.hasExternalPasswordForm) }),
|
|
291
323
|
phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
|
|
292
324
|
privacyProtected: boolOrNull(record.privacyProtected),
|
|
293
325
|
idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
|
|
@@ -308,7 +340,7 @@ function buildSnapshot(raw, options) {
|
|
|
308
340
|
if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
|
|
309
341
|
fields.opportunityModelVersion = null;
|
|
310
342
|
if (webCollectionQuality) {
|
|
311
|
-
for (const field of ['hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
|
|
343
|
+
for (const field of ['hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
|
|
312
344
|
if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
|
|
313
345
|
fields[field] = null;
|
|
314
346
|
}
|
|
@@ -338,6 +370,7 @@ function buildSnapshot(raw, options) {
|
|
|
338
370
|
reusesOfficialAssets: null,
|
|
339
371
|
hasPasswordField: null,
|
|
340
372
|
hasExternalFormAction: null,
|
|
373
|
+
...(record.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: null }),
|
|
341
374
|
phishingLanguageMatch: null,
|
|
342
375
|
pageBaselineMatch: null,
|
|
343
376
|
});
|
|
@@ -519,9 +552,14 @@ function buildEvidenceTimeline(history, enforceCollectionQuality) {
|
|
|
519
552
|
const incomparableReasons = orderingLimitation
|
|
520
553
|
? ['other']
|
|
521
554
|
: comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
|
|
522
|
-
|
|
523
|
-
&&
|
|
524
|
-
|
|
555
|
+
const collectionContextChanged = Boolean(comparable
|
|
556
|
+
&& lookupCollectionReceiptIdentity(previous.snapshot.collectionReceipt) !== lookupCollectionReceiptIdentity(snapshot.collectionReceipt));
|
|
557
|
+
if (comparable && !changes.length && !incomparableReasons.length) {
|
|
558
|
+
const { collectionReceipt: _beforeReceipt, ...before } = previous.snapshot;
|
|
559
|
+
const { collectionReceipt: _afterReceipt, ...after } = snapshot;
|
|
560
|
+
if (canonicalMaterialString(before) !== canonicalMaterialString(after))
|
|
561
|
+
incomparableReasons.push('other');
|
|
562
|
+
}
|
|
525
563
|
return {
|
|
526
564
|
snapshot,
|
|
527
565
|
isBaseline,
|
|
@@ -530,6 +568,7 @@ function buildEvidenceTimeline(history, enforceCollectionQuality) {
|
|
|
530
568
|
hasIncomparableChange: incomparableReasons.length > 0,
|
|
531
569
|
incomparableReasons,
|
|
532
570
|
orderingLimitation,
|
|
571
|
+
collectionContextChanged,
|
|
533
572
|
};
|
|
534
573
|
});
|
|
535
574
|
}
|
|
@@ -583,6 +622,7 @@ const COMPARE_FIELDS = [
|
|
|
583
622
|
{ field: 'reusesOfficialAssets', scope: 'web', label: 'Official asset reuse', type: 'signal', depthGate: 'both-deep' },
|
|
584
623
|
{ field: 'hasPasswordField', scope: 'web', label: 'Password form', type: 'signal', depthGate: 'both-deep' },
|
|
585
624
|
{ field: 'hasExternalFormAction', scope: 'web', label: 'External form action', type: 'signal', depthGate: 'both-deep' },
|
|
625
|
+
{ field: 'hasExternalPasswordForm', scope: 'web', label: 'Password form with declared external destination', type: 'signal', depthGate: 'both-deep' },
|
|
586
626
|
{ field: 'phishingLanguageMatch', scope: 'web', label: 'Phishing language', type: 'phishing', depthGate: 'both-deep' },
|
|
587
627
|
{ field: 'mutationTypes', scope: 'hostname', label: 'Mutation types', type: 'set' },
|
|
588
628
|
];
|
|
@@ -809,6 +849,10 @@ function compareField(spec, before, after) {
|
|
|
809
849
|
return null;
|
|
810
850
|
}
|
|
811
851
|
}
|
|
852
|
+
/** Field scope is owned by the same specification that gates comparisons. */
|
|
853
|
+
export function caseEvidenceFieldScope(field) {
|
|
854
|
+
return COMPARE_FIELDS.find(spec => spec.field === field)?.scope ?? null;
|
|
855
|
+
}
|
|
812
856
|
/**
|
|
813
857
|
* Diffs two normalized snapshots into a bounded, stably-ordered list of
|
|
814
858
|
* material changes. Timestamps, source, id and fingerprint are ignored;
|