@slicedearth/whoisleuth-cli 2.1.0 → 2.3.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/README.md +2 -1
- package/cli/arguments.mjs +389 -1745
- package/cli/artifact-validation/case-response.mjs +55 -30
- package/cli/artifact-validation/signed-review.mjs +30 -12
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/case-pack.mjs +39 -92
- package/cli/command-argument-grammar.mjs +211 -0
- package/cli/command-reference.mjs +200 -213
- package/cli/completion.mjs +5 -1
- package/cli/discovery-observation-snapshot.mjs +16 -3
- package/cli/discovery-scan-command-runner.mjs +19 -0
- package/cli/discovery-scan.mjs +6 -0
- package/cli/domain-control-observations.mjs +1 -1
- package/cli/inline-command-families.mjs +1 -0
- package/cli/input.mjs +116 -0
- package/cli/interchange-report.mjs +14 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/mail-header-review.mjs +467 -0
- package/cli/review-command-runner.mjs +25 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +7 -1
- package/docs/cli-reference.md +8 -1
- package/docs/cli.md +24 -1
- package/lib/bounded-json.mjs +9 -6
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-intelligence.mjs +37 -3
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/domain-change-packet.mjs +42 -14
- package/lib/domain-control-flight-recorder.mjs +4 -1
- package/lib/domain-control-manifest.mjs +16 -16
- package/lib/domain-posture-parsers.mjs +114 -3
- package/lib/domain-posture.mjs +34 -3
- package/lib/evidence-export.mjs +63 -13
- package/lib/evidence-report.mjs +2 -2
- package/lib/html-signals.mjs +39 -33
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-response.mjs +5 -4
- package/lib/lookup-source-progress.mjs +27 -4
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/registry-insights.mjs +35 -22
- package/lib/risk-calibration-summary.mjs +1 -3
- package/lib/security-txt.mjs +33 -1
- package/lib/static-html-analysis.mjs +69 -13
- package/lib/website-technology.mjs +79 -11
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +1 -1
- package/packages/cases/case-migration-model.mjs +71 -22
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +10 -21
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +240 -3
- package/packages/cases/case-record-projection.mjs +218 -0
- package/packages/cases/case-report.mjs +11 -6
- package/packages/cases/case-response-model.mjs +43 -6
- package/packages/cases/case-response-packet.mjs +78 -27
- package/packages/cases/case-storage-model.mjs +6 -2
- package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
- package/packages/comparison/page-similarity.mjs +3 -1
- package/packages/contracts/case-portability.mjs +183 -53
- package/packages/contracts/cli-command-semantics.mjs +93 -0
- package/packages/contracts/risk-calibration.mjs +15 -10
- package/packages/contracts/workspace-portability.mjs +44 -7
- package/packages/evidence/domain-control-runtime.mjs +46 -6
- package/packages/interchange/external-findings-import.mjs +115 -46
- package/packages/investigation/lookup-asset-graph.mjs +35 -27
- package/packages/workspace/brand-profile-model.mjs +118 -2
- package/packages/workspace/bulk-review-model.mjs +13 -7
- package/packages/workspace/bulk-session-model.mjs +5 -4
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/relationship-observation-model.mjs +4 -1
- package/packages/workspace/website-snapshot-model.mjs +106 -8
- package/packages/workspace/workspace-archive.mjs +2 -0
- package/cli/evidence-command-arguments.mjs +0 -156
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Pure abuse-evidence packet builder. It creates local review artifacts only:
|
|
2
2
|
// no network requests, mailto links, submissions, or provider side effects.
|
|
3
|
+
import { caseDispositionSupportsDefensiveResponse } from "./case-record-operations.mjs";
|
|
3
4
|
import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_ASSERTION_STATES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PIN_COMPLETENESS, CASE_PROVIDER_OUTCOMES, } from "./case-response-model.mjs";
|
|
4
5
|
import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
|
|
5
6
|
import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
|
|
6
|
-
import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
|
|
7
|
-
export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
|
|
7
|
+
import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
|
|
8
|
+
export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
|
|
8
9
|
export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
|
|
9
10
|
version: 1,
|
|
10
11
|
owner: 'case',
|
|
@@ -25,12 +26,14 @@ export const RESPONSE_CONTACT_KINDS = [
|
|
|
25
26
|
'registry',
|
|
26
27
|
'network_hosting',
|
|
27
28
|
'security_txt',
|
|
29
|
+
'application_platform',
|
|
28
30
|
];
|
|
29
31
|
export const RESPONSE_PACKET_PROFILE_IDS = [
|
|
30
32
|
'registrar',
|
|
31
33
|
'registry',
|
|
32
34
|
'network_hosting',
|
|
33
35
|
'security_contact',
|
|
36
|
+
'application_platform',
|
|
34
37
|
'browser_blocklist',
|
|
35
38
|
'internal_soc',
|
|
36
39
|
];
|
|
@@ -111,6 +114,20 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
111
114
|
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
112
115
|
followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
|
|
113
116
|
},
|
|
117
|
+
{
|
|
118
|
+
id: 'application_platform',
|
|
119
|
+
label: 'Platform report review',
|
|
120
|
+
audience: 'Application-platform safety, impersonation, commerce, or rights team',
|
|
121
|
+
subjectPrefix: 'Reviewed platform report',
|
|
122
|
+
requiredContactKind: 'application_platform',
|
|
123
|
+
checklist: ['Confirm the exact account or content URL', 'Choose the route matching the evidence and reporter authority', 'Review the provider notice before sharing identity or rights-owner details'],
|
|
124
|
+
evidenceOrder: ['Incident facts', 'Exact platform URLs', 'Selected observations', 'Rights or authority evidence', 'Action history'],
|
|
125
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Route provenance and review deadline', 'Relevant rights or authority context'],
|
|
126
|
+
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported policy or legal conclusions'],
|
|
127
|
+
redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, identity documents, and unrelated personal data before sharing'],
|
|
128
|
+
attachments: ['Reviewed response packet', 'Optional normalised case report'],
|
|
129
|
+
followUpFields: ['Provider reference', 'Acknowledgement time', 'Policy response', 'Independent observed effect'],
|
|
130
|
+
},
|
|
114
131
|
{
|
|
115
132
|
id: 'browser_blocklist',
|
|
116
133
|
label: 'Browser or blocklist review',
|
|
@@ -142,6 +159,9 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
|
142
159
|
]);
|
|
143
160
|
const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
|
|
144
161
|
const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
|
|
162
|
+
const PRE_PLATFORM_CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform'));
|
|
163
|
+
const PRE_PLATFORM_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS.filter((id) => id !== 'application_platform'));
|
|
164
|
+
const PRE_PLATFORM_ACTION_TYPES = CASE_ACTION_TYPES.filter((type) => type !== 'platform_report');
|
|
145
165
|
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
146
166
|
const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
|
|
147
167
|
function text(value, maximum) {
|
|
@@ -158,10 +178,17 @@ function timestamp(value) {
|
|
|
158
178
|
const parsed = Date.parse(value);
|
|
159
179
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
160
180
|
}
|
|
161
|
-
function normalizeExactUrl(value) {
|
|
162
|
-
|
|
181
|
+
function normalizeExactUrl(value, rejectOverlong) {
|
|
182
|
+
if (typeof value !== 'string' || CONTROL_RE.test(value))
|
|
183
|
+
return null;
|
|
184
|
+
const candidate = value.trim();
|
|
163
185
|
if (!candidate)
|
|
164
186
|
return null;
|
|
187
|
+
if (candidate.length > MAX_EXACT_URL_LENGTH) {
|
|
188
|
+
if (rejectOverlong)
|
|
189
|
+
throw new Error(`Each exact HTTP(S) URL is limited to ${MAX_EXACT_URL_LENGTH} characters; shorten or remove the overlong URL before continuing.`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
165
192
|
try {
|
|
166
193
|
const parsed = new URL(candidate);
|
|
167
194
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
@@ -172,7 +199,7 @@ function normalizeExactUrl(value) {
|
|
|
172
199
|
return null;
|
|
173
200
|
}
|
|
174
201
|
}
|
|
175
|
-
function normalizeUrls(value) {
|
|
202
|
+
function normalizeUrls(value, rejectOverlong = false) {
|
|
176
203
|
const source = Array.isArray(value)
|
|
177
204
|
? value
|
|
178
205
|
: typeof value === 'string'
|
|
@@ -180,7 +207,7 @@ function normalizeUrls(value) {
|
|
|
180
207
|
: [];
|
|
181
208
|
const unique = new Set();
|
|
182
209
|
for (const item of source.slice(0, MAX_ABUSIVE_URLS * 2)) {
|
|
183
|
-
const normalized = normalizeExactUrl(item);
|
|
210
|
+
const normalized = normalizeExactUrl(item, rejectOverlong);
|
|
184
211
|
if (normalized)
|
|
185
212
|
unique.add(normalized);
|
|
186
213
|
if (unique.size >= MAX_ABUSIVE_URLS)
|
|
@@ -213,14 +240,18 @@ function actionContactKind(type) {
|
|
|
213
240
|
return 'network_hosting';
|
|
214
241
|
if (type === 'security_contact_report')
|
|
215
242
|
return 'security_txt';
|
|
243
|
+
if (type === 'platform_report')
|
|
244
|
+
return 'application_platform';
|
|
216
245
|
return 'manual';
|
|
217
246
|
}
|
|
218
|
-
function routeFreshness(observedAt, generatedAt) {
|
|
247
|
+
function routeFreshness(observedAt, reviewAfter, generatedAt) {
|
|
219
248
|
if (!observedAt)
|
|
220
249
|
return 'unknown';
|
|
221
250
|
const routeAge = Date.parse(generatedAt) - Date.parse(observedAt);
|
|
251
|
+
const reviewExpired = reviewAfter !== null && Date.parse(generatedAt) >= Date.parse(reviewAfter);
|
|
222
252
|
return routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
|
|
223
|
-
||
|
|
253
|
+
|| reviewExpired
|
|
254
|
+
|| (reviewAfter === null && routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000)
|
|
224
255
|
? 'stale'
|
|
225
256
|
: 'current';
|
|
226
257
|
}
|
|
@@ -282,13 +313,15 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
|
|
|
282
313
|
};
|
|
283
314
|
}
|
|
284
315
|
const observedAt = timestamp(action.routeObservedAt);
|
|
316
|
+
const reviewAfter = timestamp(action.routeReviewAfter);
|
|
285
317
|
const recipientRoute = {
|
|
286
318
|
actionId: action.id,
|
|
287
319
|
kind,
|
|
288
320
|
contact: text(action.recipient, 320),
|
|
289
321
|
source: text(action.contactSource, 120) || 'analyst supplied',
|
|
290
322
|
observedAt,
|
|
291
|
-
|
|
323
|
+
reviewAfter,
|
|
324
|
+
freshness: routeFreshness(observedAt, reviewAfter, generatedAt),
|
|
292
325
|
limitations: normalizeLimitations(action.contactLimitations),
|
|
293
326
|
};
|
|
294
327
|
const contacts = kind === 'manual' ? [] : [{
|
|
@@ -296,6 +329,7 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
|
|
|
296
329
|
contact: recipientRoute.contact,
|
|
297
330
|
source: recipientRoute.source,
|
|
298
331
|
observedAt,
|
|
332
|
+
reviewAfter,
|
|
299
333
|
freshness: recipientRoute.freshness,
|
|
300
334
|
limitations: [...recipientRoute.limitations],
|
|
301
335
|
}];
|
|
@@ -553,7 +587,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
553
587
|
const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
|
|
554
588
|
const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
|
|
555
589
|
const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
|
|
556
|
-
const responseDisposition =
|
|
590
|
+
const responseDisposition = caseDispositionSupportsDefensiveResponse(caseRecord.disposition);
|
|
557
591
|
const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
|
|
558
592
|
const profile = responsePacketProfile(input.profile);
|
|
559
593
|
const checks = [
|
|
@@ -607,6 +641,8 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
|
|
|
607
641
|
: 'The browser or blocklist profile requires a manually reviewed submission destination recorded as a Case action.'
|
|
608
642
|
: `${profile.label} has no fixed external contact-kind requirement.`,
|
|
609
643
|
},
|
|
644
|
+
// Packet v9 retains its established machine-token wording for immutable
|
|
645
|
+
// fixture compatibility; interactive Case surfaces use the canonical label.
|
|
610
646
|
{
|
|
611
647
|
id: 'case_disposition',
|
|
612
648
|
label: 'Case disposition',
|
|
@@ -681,6 +717,7 @@ function normalizeActionHistory(caseRecord, input) {
|
|
|
681
717
|
recipient: text(action.recipient, 320),
|
|
682
718
|
contactSource: text(action.contactSource, 120),
|
|
683
719
|
routeObservedAt: timestamp(action.routeObservedAt),
|
|
720
|
+
routeReviewAfter: timestamp(action.routeReviewAfter),
|
|
684
721
|
state: text(action.state, 80),
|
|
685
722
|
reference: text(action.reference, 500) || null,
|
|
686
723
|
providerOutcome: text(action.providerOutcome, 80) || null,
|
|
@@ -804,7 +841,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
|
804
841
|
incident: {
|
|
805
842
|
category,
|
|
806
843
|
affectedParty: text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH),
|
|
807
|
-
abusiveUrls: normalizeUrls(input.abusiveUrls),
|
|
844
|
+
abusiveUrls: normalizeUrls(input.abusiveUrls, true),
|
|
808
845
|
observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
|
|
809
846
|
observedAt: timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null,
|
|
810
847
|
},
|
|
@@ -955,17 +992,20 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
955
992
|
|| !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
|
|
956
993
|
throw new TypeError('Case-response review inputs must not contain accessors.');
|
|
957
994
|
}
|
|
958
|
-
const
|
|
959
|
-
const
|
|
960
|
-
|
|
995
|
+
const version = descriptors.version.value;
|
|
996
|
+
const current = version === CASE_RESPONSE_REVIEW_INPUTS_VERSION;
|
|
997
|
+
const hasActionBinding = version === LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION || current;
|
|
998
|
+
const publishedV2 = version === PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
|
|
999
|
+
if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA || (!current && !hasActionBinding && !publishedV2)) {
|
|
961
1000
|
throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
|
|
962
1001
|
}
|
|
963
|
-
const source = exactReviewRecord(value,
|
|
1002
|
+
const source = exactReviewRecord(value, hasActionBinding ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
|
|
964
1003
|
const profile = exactReviewRecord(source.profile, [
|
|
965
1004
|
'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
|
|
966
1005
|
'excludedEvidence', 'redactions',
|
|
967
1006
|
], 'Case-response review profile');
|
|
968
|
-
|
|
1007
|
+
const supportedProfileIds = current ? RESPONSE_PROFILE_IDS : PRE_PLATFORM_PROFILE_IDS;
|
|
1008
|
+
if (typeof profile.id !== 'string' || !supportedProfileIds.has(profile.id)) {
|
|
969
1009
|
throw new TypeError('Case-response review profile is unsupported.');
|
|
970
1010
|
}
|
|
971
1011
|
for (const key of ['label', 'audience', 'subject']) {
|
|
@@ -992,9 +1032,10 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
992
1032
|
reviewText(incident.observedAt, 64, 'Case-response observation time', true);
|
|
993
1033
|
for (const candidate of boundedReviewArray(source.contacts, MAX_RESPONSE_CONTACTS, 'Case-response contacts')) {
|
|
994
1034
|
const contact = exactReviewRecord(candidate, [
|
|
995
|
-
'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
|
|
1035
|
+
'kind', 'contact', 'source', 'observedAt', ...(current ? ['reviewAfter'] : []), 'freshness', 'limitations',
|
|
996
1036
|
], 'Case-response contact');
|
|
997
|
-
|
|
1037
|
+
const supportedContactKinds = current ? CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS;
|
|
1038
|
+
if (typeof contact.kind !== 'string' || !supportedContactKinds.has(contact.kind)
|
|
998
1039
|
|| typeof contact.freshness !== 'string'
|
|
999
1040
|
|| !['current', 'stale', 'unknown'].includes(contact.freshness)) {
|
|
1000
1041
|
throw new TypeError('Case-response contact contains an unsupported enum.');
|
|
@@ -1002,22 +1043,26 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
1002
1043
|
reviewText(contact.contact, 320, 'Case-response contact value');
|
|
1003
1044
|
reviewText(contact.source, 120, 'Case-response contact source');
|
|
1004
1045
|
reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
|
|
1046
|
+
if (current)
|
|
1047
|
+
reviewText(contact.reviewAfter, 64, 'Case-response contact review deadline', true);
|
|
1005
1048
|
reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
|
|
1006
1049
|
}
|
|
1007
|
-
if (
|
|
1050
|
+
if (hasActionBinding && source.recipientRoute !== null) {
|
|
1008
1051
|
const route = exactReviewRecord(source.recipientRoute, [
|
|
1009
|
-
'actionId', 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
|
|
1052
|
+
'actionId', 'kind', 'contact', 'source', 'observedAt', ...(current ? ['reviewAfter'] : []), 'freshness', 'limitations',
|
|
1010
1053
|
], 'Case-response recipient route');
|
|
1011
1054
|
reviewText(route.actionId, 64, 'Case-response recipient action id');
|
|
1012
|
-
reviewEnum(route.kind, [...RESPONSE_CONTACT_KINDS, 'manual'], 'Case-response recipient kind');
|
|
1055
|
+
reviewEnum(route.kind, [...(current ? RESPONSE_CONTACT_KINDS : [...PRE_PLATFORM_CONTACT_KINDS]), 'manual'], 'Case-response recipient kind');
|
|
1013
1056
|
reviewText(route.contact, 320, 'Case-response recipient value');
|
|
1014
1057
|
reviewText(route.source, 120, 'Case-response recipient source');
|
|
1015
1058
|
reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
|
|
1059
|
+
if (current)
|
|
1060
|
+
reviewText(route.reviewAfter, 64, 'Case-response recipient review deadline', true);
|
|
1016
1061
|
reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
|
|
1017
1062
|
reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
|
|
1018
1063
|
}
|
|
1019
1064
|
let currentLineageActionIds = null;
|
|
1020
|
-
if (
|
|
1065
|
+
if (hasActionBinding) {
|
|
1021
1066
|
const actionBinding = exactReviewRecord(source.actionBinding, [
|
|
1022
1067
|
'state', 'selectedActionId', 'lineageActionIds', 'limitations',
|
|
1023
1068
|
], 'Case-response action binding');
|
|
@@ -1059,7 +1104,7 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
1059
1104
|
const readiness = exactReviewRecord(source.readiness, [
|
|
1060
1105
|
'profileId', 'rows', 'counts', 'limitations',
|
|
1061
1106
|
], 'Case-response readiness');
|
|
1062
|
-
if (typeof readiness.profileId !== 'string' || !
|
|
1107
|
+
if (typeof readiness.profileId !== 'string' || !supportedProfileIds.has(readiness.profileId)) {
|
|
1063
1108
|
throw new TypeError('Case-response readiness profile is unsupported.');
|
|
1064
1109
|
}
|
|
1065
1110
|
const readinessRows = boundedReviewArray(readiness.rows, RESPONSE_READINESS_ROW_IDS.length, 'Case-response readiness rows');
|
|
@@ -1111,7 +1156,8 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
1111
1156
|
for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
|
|
1112
1157
|
const action = exactReviewRecord(candidate, [
|
|
1113
1158
|
'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
|
|
1114
|
-
...(
|
|
1159
|
+
...(hasActionBinding ? ['routeObservedAt'] : []),
|
|
1160
|
+
...(current ? ['routeReviewAfter'] : []),
|
|
1115
1161
|
'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
|
|
1116
1162
|
'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
|
|
1117
1163
|
], 'Case-response escalation action');
|
|
@@ -1122,9 +1168,11 @@ export function validateCaseResponseReviewInputs(value) {
|
|
|
1122
1168
|
throw new TypeError('Case-response escalation action identities must be unique.');
|
|
1123
1169
|
}
|
|
1124
1170
|
escalationActionIds.push(action.actionId);
|
|
1125
|
-
if (
|
|
1171
|
+
if (hasActionBinding)
|
|
1126
1172
|
reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
|
|
1127
|
-
|
|
1173
|
+
if (current)
|
|
1174
|
+
reviewText(action.routeReviewAfter, 64, 'Case-response action route review deadline', true);
|
|
1175
|
+
reviewEnum(action.type, current ? CASE_ACTION_TYPES : PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
|
|
1128
1176
|
reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
|
|
1129
1177
|
for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
|
|
1130
1178
|
reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`, true);
|
|
@@ -1240,6 +1288,8 @@ function contactLabel(value) {
|
|
|
1240
1288
|
return 'Observed endpoint network registration';
|
|
1241
1289
|
if (value === 'security_txt')
|
|
1242
1290
|
return 'security.txt';
|
|
1291
|
+
if (value === 'application_platform')
|
|
1292
|
+
return 'Application platform';
|
|
1243
1293
|
if (value === 'manual')
|
|
1244
1294
|
return 'Manual submission';
|
|
1245
1295
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
@@ -1247,7 +1297,7 @@ function contactLabel(value) {
|
|
|
1247
1297
|
export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
1248
1298
|
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
|
|
1249
1299
|
const affectedParty = text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH);
|
|
1250
|
-
const abusiveUrls = normalizeUrls(input.abusiveUrls);
|
|
1300
|
+
const abusiveUrls = normalizeUrls(input.abusiveUrls, true);
|
|
1251
1301
|
const observedHarm = text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH);
|
|
1252
1302
|
const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
|
|
1253
1303
|
const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
|
|
@@ -1374,6 +1424,7 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
1374
1424
|
`- Contact: ${escapeMarkdown(recipientRoute.contact)}`,
|
|
1375
1425
|
`- Source: ${escapeMarkdown(recipientRoute.source)}`,
|
|
1376
1426
|
`- Route observed: ${recipientRoute.observedAt ?? 'Not provided'} (${recipientRoute.freshness})`,
|
|
1427
|
+
`- Route review after: ${recipientRoute.reviewAfter ?? 'Not provided'}`,
|
|
1377
1428
|
`- Limitations: ${recipientRoute.limitations.length ? recipientRoute.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
|
|
1378
1429
|
'',
|
|
1379
1430
|
]
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { CASE_SCHEMA_VERSION, MAX_CASE_STORE_BYTES, } from "./case-record-model.mjs";
|
|
2
2
|
import { normalizeCaseStore } from "./case-migration-model.mjs";
|
|
3
|
+
import { projectCaseForDurableWrite } from "./case-record-projection.mjs";
|
|
3
4
|
/** The exact versioned string persisted by the browser storage adapter. */
|
|
4
5
|
export function serializeCaseStore(cases) {
|
|
5
|
-
return JSON.stringify({
|
|
6
|
+
return JSON.stringify({
|
|
7
|
+
version: CASE_SCHEMA_VERSION,
|
|
8
|
+
cases: cases.map(projectCaseForDurableWrite),
|
|
9
|
+
});
|
|
6
10
|
}
|
|
7
11
|
function byteLength(text) {
|
|
8
12
|
if (typeof TextEncoder !== 'undefined') {
|
|
@@ -88,6 +92,6 @@ export function buildCaseExport(cases, nowIso) {
|
|
|
88
92
|
return {
|
|
89
93
|
version: CASE_SCHEMA_VERSION,
|
|
90
94
|
exportedAt: nowIso || new Date().toISOString(),
|
|
91
|
-
cases: normalizeCaseStore(cases).cases,
|
|
95
|
+
cases: normalizeCaseStore(cases).cases.map(projectCaseForDurableWrite),
|
|
92
96
|
};
|
|
93
97
|
}
|
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import { MAX_BULK_SESSIONS, compareBulkSessions, normalizeBulkSession, } from "../workspace/bulk-session-model.mjs";
|
|
1
|
+
import { MAX_BULK_SESSIONS, areBulkRiskScoresComparable, compareBulkSessions, normalizeBulkSession, } from "../workspace/bulk-session-model.mjs";
|
|
2
2
|
import { MAX_COMPARISON_LEDGER_BULK_PAIRS, boundedComparisonLedgerId, comparisonLedgerCollector, comparisonLedgerInputArray, comparisonLedgerRecord, comparisonLedgerValuePresent, comparisonLedgerValueState, makeComparisonLedgerCandidate, stableComparisonLedgerId, } from "./comparison-ledger-contract.mjs";
|
|
3
|
-
function bulkCompleteness(session) {
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
function sourceMap(result) {
|
|
7
|
-
return new Map(result.sourceCoverage.map((item) => [item.source, item.state]));
|
|
8
|
-
}
|
|
9
|
-
function namedBulkSourceState(result, source) {
|
|
10
|
-
return result.sourceCoverage.find((item) => item.source === source)?.state ?? 'not_reported';
|
|
11
|
-
}
|
|
3
|
+
function bulkCompleteness(session) { return session.state === 'complete' ? 'complete' : session.state === 'partial' ? 'partial' : 'unavailable'; }
|
|
4
|
+
function sourceMap(result) { return new Map(result.sourceCoverage.map((item) => [item.source, item.state])); }
|
|
5
|
+
function namedBulkSourceState(result, source) { return result.sourceCoverage.find((item) => item.source === source)?.state ?? 'not_reported'; }
|
|
12
6
|
function bulkFamilyState(result, family) {
|
|
13
7
|
if (family === 'model')
|
|
14
8
|
return 'derived';
|
|
@@ -47,6 +41,9 @@ function bulkFieldDecision(field, earlier, later, before, after) {
|
|
|
47
41
|
if (field.kind === 'registrar')
|
|
48
42
|
return { state: 'not_compared', completeness: 'not_reported', earlierState, laterState, limitation: 'The exact retained registrar texts are shown, but saved Bulk rows do not retain field-level registrar provenance; these values are not compared as a publication or target change.' };
|
|
49
43
|
if (field.kind === 'model') {
|
|
44
|
+
if (field.field === 'Risk score' && !areBulkRiskScoresComparable(earlier, later)) {
|
|
45
|
+
return { state: 'not_compared', completeness: 'partial', earlierState, laterState, limitation: 'Risk scores require the same ready Brand Profile provenance, scan depth, and versioned Risk model before comparison.' };
|
|
46
|
+
}
|
|
50
47
|
const comparable = comparisonLedgerValuePresent(before) && comparisonLedgerValuePresent(after);
|
|
51
48
|
return { state: comparable ? 'different' : 'not_compared', completeness: comparable ? 'complete' : 'partial', earlierState, laterState, limitation: comparable ? null : 'A score absent from either retained row is not represented as an observed score change.' };
|
|
52
49
|
}
|
|
@@ -69,6 +66,8 @@ function bulkPairCompleteness(earlier, later) {
|
|
|
69
66
|
const right = after.get(domain);
|
|
70
67
|
if (!right || left.scanDepth !== right.scanDepth)
|
|
71
68
|
return 'partial';
|
|
69
|
+
if (!areBulkRiskScoresComparable(left, right))
|
|
70
|
+
return 'partial';
|
|
72
71
|
if (left.riskModelVersion === null || right.riskModelVersion === null || left.riskModelVersion !== right.riskModelVersion
|
|
73
72
|
|| left.opportunityModelVersion === null || right.opportunityModelVersion === null || left.opportunityModelVersion !== right.opportunityModelVersion)
|
|
74
73
|
return 'partial';
|
|
@@ -173,7 +173,9 @@ export function comparePageBaselines(rawReference, rawObserved) {
|
|
|
173
173
|
complete: observed.complete,
|
|
174
174
|
truncated: observed.truncated,
|
|
175
175
|
},
|
|
176
|
-
partial: reference.
|
|
176
|
+
partial: !reference.complete || !observed.complete
|
|
177
|
+
|| reference.truncated || observed.truncated
|
|
178
|
+
|| components.some((item) => item.partial),
|
|
177
179
|
components,
|
|
178
180
|
counts,
|
|
179
181
|
};
|