@slicedearth/whoisleuth-cli 1.32.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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// Pure abuse-evidence packet builder. It creates local review artifacts only:
|
|
2
|
+
// no network requests, mailto links, submissions, or provider side effects.
|
|
3
|
+
import { buildCaseActionOutcomeSummary } from "./case-response-model.js";
|
|
4
|
+
export const CASE_RESPONSE_PACKET_SCHEMA = 'whoisleuth.case-response-packet';
|
|
5
|
+
export const CASE_RESPONSE_PACKET_VERSION = 5;
|
|
6
|
+
export const MAX_ABUSIVE_URLS = 20;
|
|
7
|
+
export const MAX_RESPONSE_CONTACTS = 12;
|
|
8
|
+
export const MAX_RESPONSE_ACTION_HISTORY = 20;
|
|
9
|
+
export const MAX_RESPONSE_HARM_LENGTH = 2000;
|
|
10
|
+
export const MAX_AFFECTED_PARTY_LENGTH = 200;
|
|
11
|
+
export const MAX_ABUSE_CATEGORY_LENGTH = 80;
|
|
12
|
+
export const MAX_EXACT_URL_LENGTH = 2048;
|
|
13
|
+
export const RESPONSE_CONTACT_KINDS = [
|
|
14
|
+
'registrar',
|
|
15
|
+
'registry',
|
|
16
|
+
'network_hosting',
|
|
17
|
+
'security_txt',
|
|
18
|
+
];
|
|
19
|
+
export const RESPONSE_PACKET_PROFILE_IDS = [
|
|
20
|
+
'registrar',
|
|
21
|
+
'registry',
|
|
22
|
+
'network_hosting',
|
|
23
|
+
'security_contact',
|
|
24
|
+
'browser_blocklist',
|
|
25
|
+
'internal_soc',
|
|
26
|
+
];
|
|
27
|
+
export const RESPONSE_PACKET_PROFILES = Object.freeze([
|
|
28
|
+
{
|
|
29
|
+
id: 'registrar',
|
|
30
|
+
label: 'Registrar review',
|
|
31
|
+
audience: 'Domain registrar abuse or compliance team',
|
|
32
|
+
subjectPrefix: 'Reviewed domain abuse report',
|
|
33
|
+
requiredContactKind: 'registrar',
|
|
34
|
+
checklist: ['Confirm registrar of record', 'Include exact URLs and observation time', 'Review registrant-data necessity before sharing'],
|
|
35
|
+
evidenceOrder: ['Incident facts', 'Registration evidence', 'Selected observations', 'Action history'],
|
|
36
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Case disposition', 'Selected evidence pins', 'Registrar contact provenance'],
|
|
37
|
+
excludedEvidence: ['Raw WHOIS or RDAP payloads', 'Unselected contacts', 'Provider secrets', 'Unrelated analyst notes'],
|
|
38
|
+
redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
|
|
39
|
+
attachments: ['Reviewed response packet', 'Optional normalized case report'],
|
|
40
|
+
followUpFields: ['Registrar reference', 'Acknowledgement time', 'Resolution or transfer outcome'],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'registry',
|
|
44
|
+
label: 'Registry review',
|
|
45
|
+
audience: 'Domain registry abuse or compliance team',
|
|
46
|
+
subjectPrefix: 'Reviewed registry abuse report',
|
|
47
|
+
requiredContactKind: 'registry',
|
|
48
|
+
checklist: ['Confirm the registry is an appropriate escalation route', 'Include registrar response or reference when available', 'Disclose contradictory evidence'],
|
|
49
|
+
evidenceOrder: ['Incident facts', 'Registry publication', 'Prior escalation', 'Selected observations'],
|
|
50
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Registry contact provenance', 'Selected evidence pins', 'Prior action references'],
|
|
51
|
+
excludedEvidence: ['Raw registry payloads', 'Unselected contacts', 'Secrets', 'Unsupported ownership claims'],
|
|
52
|
+
redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
|
|
53
|
+
attachments: ['Reviewed response packet', 'Optional normalized case report', 'Optional registrar reference'],
|
|
54
|
+
followUpFields: ['Registry reference', 'Registrar referral', 'Delegation or status outcome'],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'network_hosting',
|
|
58
|
+
label: 'Hosting or network review',
|
|
59
|
+
audience: 'Hosting provider, CDN, network, or infrastructure operator',
|
|
60
|
+
subjectPrefix: 'Reviewed hosted-content abuse report',
|
|
61
|
+
requiredContactKind: 'network_hosting',
|
|
62
|
+
checklist: ['Confirm the observed endpoint and collection time', 'Avoid claiming the observed edge is the origin', 'Include exact affected URLs'],
|
|
63
|
+
evidenceOrder: ['Incident facts', 'Observed endpoint context', 'Selected web evidence', 'Action history'],
|
|
64
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Selected HTTP, TLS, DNS, or page-identity pins', 'Contact provenance'],
|
|
65
|
+
excludedEvidence: ['Raw response bodies', 'Cookies', 'Secrets', 'Unproven origin-host claims'],
|
|
66
|
+
redactions: ['Remove credentials, query secrets, fragments, cookies, and unrelated contacts'],
|
|
67
|
+
attachments: ['Reviewed response packet', 'Optional normalized case report'],
|
|
68
|
+
followUpFields: ['Provider ticket', 'Content status', 'Infrastructure change outcome'],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'security_contact',
|
|
72
|
+
label: 'Security contact review',
|
|
73
|
+
audience: 'Published security contact or affected service security team',
|
|
74
|
+
subjectPrefix: 'Reviewed security finding',
|
|
75
|
+
requiredContactKind: 'security_txt',
|
|
76
|
+
checklist: ['Confirm the contact policy covers this report', 'Respect the published communication policy', 'Separate observed facts from hypotheses'],
|
|
77
|
+
evidenceOrder: ['Incident facts', 'Selected observations', 'Analyst reasoning', 'Action history'],
|
|
78
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Contact source and limitations'],
|
|
79
|
+
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected case notes', 'Legal conclusions'],
|
|
80
|
+
redactions: ['Remove credentials, tokens, personal data, and unrelated identifiers'],
|
|
81
|
+
attachments: ['Reviewed response packet', 'Optional normalized case report'],
|
|
82
|
+
followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'browser_blocklist',
|
|
86
|
+
label: 'Browser or blocklist review',
|
|
87
|
+
audience: 'Browser-safety, reputation, or blocklist reviewer',
|
|
88
|
+
subjectPrefix: 'Reviewed unsafe-site report',
|
|
89
|
+
requiredContactKind: null,
|
|
90
|
+
checklist: ['Use the recipient submission form or documented route manually', 'Include exact URLs and current observation time', 'Disclose source gaps and false-positive context'],
|
|
91
|
+
evidenceOrder: ['Exact URLs', 'Observed behavior', 'Selected corroborating evidence', 'Limitations'],
|
|
92
|
+
includedEvidence: ['Exact URLs', 'Observation time', 'Observed harm', 'Selected evidence pins', 'Contradictions and cautions'],
|
|
93
|
+
excludedEvidence: ['Raw provider payloads', 'Secrets', 'Unselected personal data', 'Automated maliciousness claims'],
|
|
94
|
+
redactions: ['Remove credentials, tokens, URL fragments, and unrelated contacts'],
|
|
95
|
+
attachments: ['Reviewed response packet', 'Optional normalized case report'],
|
|
96
|
+
followUpFields: ['Submission reference', 'Review state', 'Listing or delisting outcome'],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'internal_soc',
|
|
100
|
+
label: 'Internal SOC handoff',
|
|
101
|
+
audience: 'Internal security operations or incident-response team',
|
|
102
|
+
subjectPrefix: 'Reviewed domain investigation handoff',
|
|
103
|
+
requiredContactKind: null,
|
|
104
|
+
checklist: ['Identify the internal owner', 'Separate verified facts, hypotheses, unknowns, and contradictions', 'Record the next reviewed action'],
|
|
105
|
+
evidenceOrder: ['Decision packet', 'Selected evidence', 'Incident facts', 'Action and investigation trail'],
|
|
106
|
+
includedEvidence: ['Case disposition', 'Selected evidence pins', 'Analyst decisions and assertions', 'Action history', 'Exact URLs when relevant'],
|
|
107
|
+
excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported attribution'],
|
|
108
|
+
redactions: ['Remove credentials, tokens, and personal data not required for the internal decision'],
|
|
109
|
+
attachments: ['Reviewed response packet', 'Normalized case report when required by internal policy'],
|
|
110
|
+
followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
|
|
114
|
+
const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
|
|
115
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
116
|
+
const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
|
|
117
|
+
function text(value, maximum) {
|
|
118
|
+
if (typeof value !== 'string')
|
|
119
|
+
return '';
|
|
120
|
+
return value.replace(CONTROL_REPLACE_RE, ' ').trim().slice(0, maximum);
|
|
121
|
+
}
|
|
122
|
+
function timestamp(value) {
|
|
123
|
+
if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
|
|
124
|
+
return null;
|
|
125
|
+
const parsed = Date.parse(value);
|
|
126
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
127
|
+
}
|
|
128
|
+
function normalizeExactUrl(value) {
|
|
129
|
+
const candidate = text(value, MAX_EXACT_URL_LENGTH);
|
|
130
|
+
if (!candidate)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = new URL(candidate);
|
|
134
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
135
|
+
return null;
|
|
136
|
+
return parsed.toString();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function normalizeUrls(value) {
|
|
143
|
+
const source = Array.isArray(value)
|
|
144
|
+
? value
|
|
145
|
+
: typeof value === 'string'
|
|
146
|
+
? value.split(/\r?\n/u)
|
|
147
|
+
: [];
|
|
148
|
+
const unique = new Set();
|
|
149
|
+
for (const item of source.slice(0, MAX_ABUSIVE_URLS * 2)) {
|
|
150
|
+
const normalized = normalizeExactUrl(item);
|
|
151
|
+
if (normalized)
|
|
152
|
+
unique.add(normalized);
|
|
153
|
+
if (unique.size >= MAX_ABUSIVE_URLS)
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
return [...unique];
|
|
157
|
+
}
|
|
158
|
+
function normalizeLimitations(value) {
|
|
159
|
+
const source = Array.isArray(value)
|
|
160
|
+
? value
|
|
161
|
+
: typeof value === 'string'
|
|
162
|
+
? value.split(/\r?\n/u)
|
|
163
|
+
: [];
|
|
164
|
+
const unique = new Set();
|
|
165
|
+
for (const item of source.slice(0, 16)) {
|
|
166
|
+
const normalized = text(item, 240);
|
|
167
|
+
if (normalized)
|
|
168
|
+
unique.add(normalized);
|
|
169
|
+
if (unique.size >= 8)
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
return [...unique];
|
|
173
|
+
}
|
|
174
|
+
function normalizeContacts(value) {
|
|
175
|
+
if (!Array.isArray(value))
|
|
176
|
+
return [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const contacts = [];
|
|
179
|
+
for (const raw of value.slice(0, MAX_RESPONSE_CONTACTS * 2)) {
|
|
180
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
181
|
+
continue;
|
|
182
|
+
const item = raw;
|
|
183
|
+
const kind = typeof item.kind === 'string' && CONTACT_KINDS.has(item.kind)
|
|
184
|
+
? item.kind
|
|
185
|
+
: null;
|
|
186
|
+
const contact = text(item.contact, 320);
|
|
187
|
+
if (!kind || !contact)
|
|
188
|
+
continue;
|
|
189
|
+
const key = `${kind}\u0000${contact.toLowerCase()}`;
|
|
190
|
+
if (seen.has(key))
|
|
191
|
+
continue;
|
|
192
|
+
seen.add(key);
|
|
193
|
+
contacts.push({
|
|
194
|
+
kind,
|
|
195
|
+
contact,
|
|
196
|
+
source: text(item.source, 120) || 'analyst supplied',
|
|
197
|
+
limitations: normalizeLimitations(item.limitations),
|
|
198
|
+
});
|
|
199
|
+
if (contacts.length >= MAX_RESPONSE_CONTACTS)
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
return contacts;
|
|
203
|
+
}
|
|
204
|
+
export function responsePacketProfile(value) {
|
|
205
|
+
const id = typeof value === 'string' && RESPONSE_PROFILE_IDS.has(value)
|
|
206
|
+
? value
|
|
207
|
+
: 'internal_soc';
|
|
208
|
+
return RESPONSE_PACKET_PROFILES.find((profile) => profile.id === id) ?? RESPONSE_PACKET_PROFILES.at(-1);
|
|
209
|
+
}
|
|
210
|
+
export function buildResponsePacketProfilePreview(caseRecord, input) {
|
|
211
|
+
const profile = responsePacketProfile(input.profile);
|
|
212
|
+
const contacts = normalizeContacts(input.contacts);
|
|
213
|
+
const missingEvidence = [
|
|
214
|
+
...(!normalizeUrls(input.abusiveUrls).length ? ['At least one exact HTTP(S) URL'] : []),
|
|
215
|
+
...(!timestamp(input.observedAt) && !caseRecord.evidenceHistory.at(-1)?.capturedAt ? ['Observation time'] : []),
|
|
216
|
+
...(!caseRecord.evidencePins.length ? ['Analyst-selected evidence pin'] : []),
|
|
217
|
+
...(profile.requiredContactKind && !contacts.some((contact) => contact.kind === profile.requiredContactKind)
|
|
218
|
+
? [`${contactLabel(profile.requiredContactKind)} contact route`]
|
|
219
|
+
: []),
|
|
220
|
+
];
|
|
221
|
+
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH) || 'domain activity';
|
|
222
|
+
return {
|
|
223
|
+
id: profile.id,
|
|
224
|
+
label: profile.label,
|
|
225
|
+
audience: profile.audience,
|
|
226
|
+
subject: `${profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
|
|
227
|
+
checklist: [...profile.checklist],
|
|
228
|
+
evidenceOrder: [...profile.evidenceOrder],
|
|
229
|
+
includedEvidence: [...profile.includedEvidence],
|
|
230
|
+
excludedEvidence: [...profile.excludedEvidence],
|
|
231
|
+
redactions: [...profile.redactions],
|
|
232
|
+
attachments: [...profile.attachments],
|
|
233
|
+
followUpFields: [...profile.followUpFields],
|
|
234
|
+
missingEvidence,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
238
|
+
const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
|
|
239
|
+
const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
|
|
240
|
+
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
241
|
+
const contacts = normalizeContacts(input.contacts);
|
|
242
|
+
const urls = normalizeUrls(input.abusiveUrls);
|
|
243
|
+
const requiredComplete = Boolean(text(input.category, MAX_ABUSE_CATEGORY_LENGTH)
|
|
244
|
+
&& text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH)
|
|
245
|
+
&& urls.length
|
|
246
|
+
&& text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH)
|
|
247
|
+
&& observedAt);
|
|
248
|
+
const age = observedAt ? observationAge(observedAt, normalizedGeneratedAt) : null;
|
|
249
|
+
const openContradictions = caseRecord.assertions
|
|
250
|
+
.filter((item) => item.kind === 'contradiction' && item.state === 'open')
|
|
251
|
+
.length;
|
|
252
|
+
const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
|
|
253
|
+
const profile = responsePacketProfile(input.profile);
|
|
254
|
+
const checks = [
|
|
255
|
+
{
|
|
256
|
+
id: 'required_incident_fields',
|
|
257
|
+
label: 'Incident facts',
|
|
258
|
+
state: requiredComplete ? 'pass' : 'block',
|
|
259
|
+
detail: requiredComplete
|
|
260
|
+
? `${urls.length} exact HTTP(S) URL${urls.length === 1 ? '' : 's'} and the required incident context are present.`
|
|
261
|
+
: 'Category, affected party, an exact HTTP(S) URL, observed harm, and observation time are required.',
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: 'evidence_pins',
|
|
265
|
+
label: 'Selected evidence',
|
|
266
|
+
state: caseRecord.evidencePins.length ? 'pass' : 'caution',
|
|
267
|
+
detail: caseRecord.evidencePins.length
|
|
268
|
+
? `${caseRecord.evidencePins.length} analyst-selected evidence pin${caseRecord.evidencePins.length === 1 ? '' : 's'} will remain separately attributable.`
|
|
269
|
+
: 'No precise evidence pin is recorded; the packet can be drafted, but its support is less reviewable.',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: 'analyst_decision',
|
|
273
|
+
label: 'Analyst decision',
|
|
274
|
+
state: caseRecord.decisions.length ? 'pass' : 'caution',
|
|
275
|
+
detail: caseRecord.decisions.length
|
|
276
|
+
? `${caseRecord.decisions.length} analyst decision${caseRecord.decisions.length === 1 ? '' : 's'} record the escalation rationale.`
|
|
277
|
+
: 'No explicit analyst decision explains why external reporting is appropriate.',
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'recipient_route',
|
|
281
|
+
label: 'Recipient route',
|
|
282
|
+
state: contacts.length ? 'pass' : 'caution',
|
|
283
|
+
detail: contacts.length
|
|
284
|
+
? `${contacts.length} separately attributed contact route${contacts.length === 1 ? ' is' : 's are'} included.`
|
|
285
|
+
: 'No contact route is included; identify and review the intended recipient before sending.',
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: 'profile_recipient',
|
|
289
|
+
label: 'Audience-specific recipient',
|
|
290
|
+
state: profile.requiredContactKind
|
|
291
|
+
? contacts.some((contact) => contact.kind === profile.requiredContactKind) ? 'pass' : 'caution'
|
|
292
|
+
: 'pass',
|
|
293
|
+
detail: profile.requiredContactKind
|
|
294
|
+
? contacts.some((contact) => contact.kind === profile.requiredContactKind)
|
|
295
|
+
? `The ${contactLabel(profile.requiredContactKind)} route required by the ${profile.label.toLowerCase()} is present.`
|
|
296
|
+
: `The ${profile.label.toLowerCase()} expects a separately attributed ${contactLabel(profile.requiredContactKind)} route.`
|
|
297
|
+
: `${profile.label} has no fixed external contact-kind requirement; confirm the intended manual destination.`,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'case_disposition',
|
|
301
|
+
label: 'Case disposition',
|
|
302
|
+
state: ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition) ? 'pass' : 'caution',
|
|
303
|
+
detail: ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition)
|
|
304
|
+
? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}.`
|
|
305
|
+
: `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}; confirm it before external use.`,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: 'evidence_freshness',
|
|
309
|
+
label: 'Evidence freshness',
|
|
310
|
+
state: age?.refreshRecommended ? 'caution' : observedAt ? 'pass' : 'block',
|
|
311
|
+
detail: age
|
|
312
|
+
? age.refreshRecommended
|
|
313
|
+
? `The selected observation is ${age.band.replaceAll('_', ' ')} and should be refreshed before submission.`
|
|
314
|
+
: `The selected observation is ${age.band.replaceAll('_', ' ')}.`
|
|
315
|
+
: 'A valid observation time is required.',
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: 'contradictory_evidence',
|
|
319
|
+
label: 'Contradictory evidence',
|
|
320
|
+
state: openContradictions ? 'caution' : 'pass',
|
|
321
|
+
detail: openContradictions
|
|
322
|
+
? `${openContradictions} open contradiction${openContradictions === 1 ? '' : 's'} should be addressed or disclosed.`
|
|
323
|
+
: 'No open contradictory-evidence assertion is recorded.',
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: 'action_tracking',
|
|
327
|
+
label: 'Action tracking',
|
|
328
|
+
state: actionSummary.total ? 'pass' : 'caution',
|
|
329
|
+
detail: actionSummary.total
|
|
330
|
+
? `${actionSummary.total} reviewed action${actionSummary.total === 1 ? ' is' : 's are'} tracked; ${actionSummary.overdue} overdue and ${actionSummary.followUpDue} due for follow-up.`
|
|
331
|
+
: 'No reviewed case action is recorded for ownership, submission, or follow-up.',
|
|
332
|
+
},
|
|
333
|
+
];
|
|
334
|
+
const counts = {
|
|
335
|
+
block: checks.filter((item) => item.state === 'block').length,
|
|
336
|
+
caution: checks.filter((item) => item.state === 'caution').length,
|
|
337
|
+
pass: checks.filter((item) => item.state === 'pass').length,
|
|
338
|
+
};
|
|
339
|
+
return {
|
|
340
|
+
version: 1,
|
|
341
|
+
status: counts.block ? 'needs_input' : counts.caution ? 'review_cautions' : 'ready_for_review',
|
|
342
|
+
canExport: counts.block === 0,
|
|
343
|
+
counts,
|
|
344
|
+
checks,
|
|
345
|
+
actionSummary,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function normalizeActionHistory(caseRecord) {
|
|
349
|
+
return caseRecord.actions
|
|
350
|
+
.slice(-MAX_RESPONSE_ACTION_HISTORY)
|
|
351
|
+
.map((action) => ({
|
|
352
|
+
type: text(action.type, 80),
|
|
353
|
+
recipient: text(action.recipient, 320),
|
|
354
|
+
contactSource: text(action.contactSource, 120),
|
|
355
|
+
state: text(action.state, 80),
|
|
356
|
+
reference: text(action.reference, 500) || null,
|
|
357
|
+
outcome: text(action.outcome, 2000) || null,
|
|
358
|
+
createdAt: timestamp(action.createdAt) ?? caseRecord.createdAt,
|
|
359
|
+
updatedAt: timestamp(action.updatedAt) ?? caseRecord.updatedAt,
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
function observationAge(observedAt, generatedAt) {
|
|
363
|
+
const ageSeconds = Math.floor((Date.parse(generatedAt) - Date.parse(observedAt)) / 1000);
|
|
364
|
+
if (ageSeconds < -300) {
|
|
365
|
+
return { ageSeconds, band: 'future_or_clock_skew', refreshRecommended: true };
|
|
366
|
+
}
|
|
367
|
+
if (ageSeconds < 86_400) {
|
|
368
|
+
return { ageSeconds: Math.max(0, ageSeconds), band: 'under_24_hours', refreshRecommended: false };
|
|
369
|
+
}
|
|
370
|
+
if (ageSeconds <= 604_800) {
|
|
371
|
+
return { ageSeconds, band: 'one_to_seven_days', refreshRecommended: false };
|
|
372
|
+
}
|
|
373
|
+
return { ageSeconds, band: 'over_seven_days', refreshRecommended: true };
|
|
374
|
+
}
|
|
375
|
+
function canonicalJson(value) {
|
|
376
|
+
if (value === null || typeof value !== 'object')
|
|
377
|
+
return JSON.stringify(value);
|
|
378
|
+
if (Array.isArray(value))
|
|
379
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
380
|
+
const source = value;
|
|
381
|
+
return `{${Object.keys(source).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(source[key])}`).join(',')}}`;
|
|
382
|
+
}
|
|
383
|
+
async function sha256(value) {
|
|
384
|
+
const bytes = new TextEncoder().encode(value);
|
|
385
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
386
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
387
|
+
}
|
|
388
|
+
export async function verifyCaseResponsePacketIntegrity(packet) {
|
|
389
|
+
const { integrity, ...unsigned } = packet;
|
|
390
|
+
if (integrity.algorithm !== 'SHA-256'
|
|
391
|
+
|| integrity.canonicalization !== 'sorted-json-v1'
|
|
392
|
+
|| integrity.scope !== 'packet excluding integrity'
|
|
393
|
+
|| !/^[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return integrity.digestSha256 === await sha256(canonicalJson(unsigned));
|
|
397
|
+
}
|
|
398
|
+
function escapeMarkdown(value) {
|
|
399
|
+
return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
|
|
400
|
+
}
|
|
401
|
+
function contactLabel(value) {
|
|
402
|
+
if (value === 'network_hosting')
|
|
403
|
+
return 'Network or hosting';
|
|
404
|
+
if (value === 'security_txt')
|
|
405
|
+
return 'security.txt';
|
|
406
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
407
|
+
}
|
|
408
|
+
export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
|
|
409
|
+
const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
|
|
410
|
+
const affectedParty = text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH);
|
|
411
|
+
const abusiveUrls = normalizeUrls(input.abusiveUrls);
|
|
412
|
+
const observedHarm = text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH);
|
|
413
|
+
const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
|
|
414
|
+
const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
|
|
415
|
+
if (!category || !affectedParty || !abusiveUrls.length || !observedHarm || !observedAt) {
|
|
416
|
+
throw new Error('Category, affected party, at least one exact HTTP(S) URL, observed harm, and an observation time are required.');
|
|
417
|
+
}
|
|
418
|
+
const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
|
|
419
|
+
const contacts = normalizeContacts(input.contacts);
|
|
420
|
+
const escalationHistory = normalizeActionHistory(caseRecord);
|
|
421
|
+
const age = observationAge(observedAt, normalizedGeneratedAt);
|
|
422
|
+
const preflight = buildCaseResponsePreflight(caseRecord, input, normalizedGeneratedAt);
|
|
423
|
+
const profile = buildResponsePacketProfilePreview(caseRecord, input);
|
|
424
|
+
const limitations = [
|
|
425
|
+
'This packet contains analyst-selected facts and must be reviewed before submission.',
|
|
426
|
+
'WHOISleuth did not submit this packet or verify that any listed contact is monitored.',
|
|
427
|
+
...(!contacts.length ? ['No escalation contact was included.'] : []),
|
|
428
|
+
...(age.refreshRecommended ? ['The selected observation is over seven days old or appears to be in the future. Refresh evidence before submission.'] : []),
|
|
429
|
+
];
|
|
430
|
+
const unsigned = {
|
|
431
|
+
schema: CASE_RESPONSE_PACKET_SCHEMA,
|
|
432
|
+
schemaVersion: CASE_RESPONSE_PACKET_VERSION,
|
|
433
|
+
generatedAt: normalizedGeneratedAt,
|
|
434
|
+
reviewRequired: true,
|
|
435
|
+
submissionPerformed: false,
|
|
436
|
+
profile: {
|
|
437
|
+
id: profile.id,
|
|
438
|
+
label: profile.label,
|
|
439
|
+
audience: profile.audience,
|
|
440
|
+
subject: profile.subject,
|
|
441
|
+
checklist: profile.checklist,
|
|
442
|
+
evidenceOrder: profile.evidenceOrder,
|
|
443
|
+
includedEvidence: profile.includedEvidence,
|
|
444
|
+
excludedEvidence: profile.excludedEvidence,
|
|
445
|
+
redactions: profile.redactions,
|
|
446
|
+
attachments: profile.attachments,
|
|
447
|
+
followUpFields: profile.followUpFields,
|
|
448
|
+
},
|
|
449
|
+
case: {
|
|
450
|
+
id: caseRecord.id,
|
|
451
|
+
domain: caseRecord.domain,
|
|
452
|
+
status: caseRecord.status,
|
|
453
|
+
disposition: caseRecord.disposition,
|
|
454
|
+
updatedAt: caseRecord.updatedAt,
|
|
455
|
+
},
|
|
456
|
+
incident: {
|
|
457
|
+
category,
|
|
458
|
+
affectedParty,
|
|
459
|
+
abusiveUrls,
|
|
460
|
+
observedHarm,
|
|
461
|
+
observedAt,
|
|
462
|
+
},
|
|
463
|
+
contacts,
|
|
464
|
+
preflight,
|
|
465
|
+
escalationHistory,
|
|
466
|
+
provenance: {
|
|
467
|
+
latestEvidenceCapturedAt: latestEvidence?.capturedAt ?? null,
|
|
468
|
+
evidencePinCount: caseRecord.evidencePins.length,
|
|
469
|
+
decisionCount: caseRecord.decisions.length,
|
|
470
|
+
assertionCount: caseRecord.assertions.length,
|
|
471
|
+
observationAge: age,
|
|
472
|
+
limitations,
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
const digestSha256 = await sha256(canonicalJson(unsigned));
|
|
476
|
+
const json = {
|
|
477
|
+
...unsigned,
|
|
478
|
+
integrity: {
|
|
479
|
+
algorithm: 'SHA-256',
|
|
480
|
+
canonicalization: 'sorted-json-v1',
|
|
481
|
+
scope: 'packet excluding integrity',
|
|
482
|
+
digestSha256,
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
const lines = [
|
|
486
|
+
`# ${escapeMarkdown(profile.label)} packet`,
|
|
487
|
+
'',
|
|
488
|
+
`**Domain:** ${escapeMarkdown(caseRecord.domain)}`,
|
|
489
|
+
`**Category:** ${escapeMarkdown(category)}`,
|
|
490
|
+
`**Affected party:** ${escapeMarkdown(affectedParty)}`,
|
|
491
|
+
`**Observed at (UTC):** ${observedAt}`,
|
|
492
|
+
`**Generated at (UTC):** ${normalizedGeneratedAt}`,
|
|
493
|
+
`**Audience:** ${escapeMarkdown(profile.audience)}`,
|
|
494
|
+
`**Suggested subject:** ${escapeMarkdown(profile.subject)}`,
|
|
495
|
+
'',
|
|
496
|
+
'## Observed harm',
|
|
497
|
+
'',
|
|
498
|
+
escapeMarkdown(observedHarm),
|
|
499
|
+
'',
|
|
500
|
+
'## Exact abusive URLs',
|
|
501
|
+
'',
|
|
502
|
+
...abusiveUrls.map((url) => `- ${escapeMarkdown(url)}`),
|
|
503
|
+
'',
|
|
504
|
+
'## Escalation contacts',
|
|
505
|
+
'',
|
|
506
|
+
...(contacts.length
|
|
507
|
+
? contacts.flatMap((contact) => [
|
|
508
|
+
`### ${contactLabel(contact.kind)}`,
|
|
509
|
+
'',
|
|
510
|
+
`- Contact: ${escapeMarkdown(contact.contact)}`,
|
|
511
|
+
`- Source: ${escapeMarkdown(contact.source)}`,
|
|
512
|
+
`- Limitations: ${contact.limitations.length ? contact.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
|
|
513
|
+
'',
|
|
514
|
+
])
|
|
515
|
+
: ['No escalation contact was included.', '']),
|
|
516
|
+
'## Escalation history',
|
|
517
|
+
'',
|
|
518
|
+
...(escalationHistory.length
|
|
519
|
+
? escalationHistory.flatMap((action) => [
|
|
520
|
+
`- ${escapeMarkdown(action.type.replaceAll('_', ' '))} to ${escapeMarkdown(action.recipient)} · ${escapeMarkdown(action.state.replaceAll('_', ' '))} · updated ${action.updatedAt}`,
|
|
521
|
+
...(action.reference ? [` - Reference: ${escapeMarkdown(action.reference)}`] : []),
|
|
522
|
+
...(action.outcome ? [` - Outcome: ${escapeMarkdown(action.outcome)}`] : []),
|
|
523
|
+
])
|
|
524
|
+
: ['No reviewed case actions were recorded.']),
|
|
525
|
+
'',
|
|
526
|
+
'## Review and provenance',
|
|
527
|
+
'',
|
|
528
|
+
`- Preflight: ${preflight.status.replaceAll('_', ' ')} (${preflight.counts.pass} pass, ${preflight.counts.caution} caution, ${preflight.counts.block} block)`,
|
|
529
|
+
...preflight.checks.map((check) => `- ${escapeMarkdown(check.label)} [${check.state}]: ${escapeMarkdown(check.detail)}`),
|
|
530
|
+
...limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
531
|
+
`- Case evidence pins: ${caseRecord.evidencePins.length}`,
|
|
532
|
+
`- Case decision records: ${caseRecord.decisions.length}`,
|
|
533
|
+
`- Case structured assertions: ${caseRecord.assertions.length}`,
|
|
534
|
+
`- Observation-age band at export: ${age.band.replaceAll('_', ' ')}`,
|
|
535
|
+
`- Canonical packet SHA-256: ${digestSha256}`,
|
|
536
|
+
'- Digest scope: canonical sorted JSON packet excluding the integrity object',
|
|
537
|
+
'',
|
|
538
|
+
'## Audience profile',
|
|
539
|
+
'',
|
|
540
|
+
...profile.checklist.map((item) => `- Checklist: ${escapeMarkdown(item)}`),
|
|
541
|
+
...profile.includedEvidence.map((item) => `- Included: ${escapeMarkdown(item)}`),
|
|
542
|
+
...profile.excludedEvidence.map((item) => `- Excluded: ${escapeMarkdown(item)}`),
|
|
543
|
+
...profile.redactions.map((item) => `- Redaction: ${escapeMarkdown(item)}`),
|
|
544
|
+
...profile.attachments.map((item) => `- Attachment expectation: ${escapeMarkdown(item)}`),
|
|
545
|
+
...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
|
|
546
|
+
];
|
|
547
|
+
const markdown = `${lines.join('\n').trim()}\n`;
|
|
548
|
+
const email = [
|
|
549
|
+
`Subject: ${profile.subject}`,
|
|
550
|
+
'',
|
|
551
|
+
'Hello,',
|
|
552
|
+
'',
|
|
553
|
+
`I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
|
|
554
|
+
`Affected party: ${affectedParty}`,
|
|
555
|
+
`Observed at (UTC): ${observedAt}`,
|
|
556
|
+
'',
|
|
557
|
+
'Observed harm:',
|
|
558
|
+
observedHarm,
|
|
559
|
+
'',
|
|
560
|
+
'Exact URLs:',
|
|
561
|
+
...abusiveUrls.map((url) => `- ${url}`),
|
|
562
|
+
'',
|
|
563
|
+
'Please review this report under the applicable abuse and acceptable-use policies.',
|
|
564
|
+
'',
|
|
565
|
+
'This draft was prepared for human review and was not submitted automatically.',
|
|
566
|
+
].join('\n');
|
|
567
|
+
return { json, markdown, email: `${email}\n` };
|
|
568
|
+
}
|
|
569
|
+
export function caseResponsePacketFilename(domain, format, generatedAt) {
|
|
570
|
+
const safeDomain = domain.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 80) || 'case';
|
|
571
|
+
const date = (timestamp(generatedAt) || new Date().toISOString()).slice(0, 10);
|
|
572
|
+
return `whoisleuth-response-${safeDomain}-${date}.${format}`;
|
|
573
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CASE_SCHEMA_VERSION, MAX_CASE_STORE_BYTES, } from "./case-record-model.js";
|
|
2
|
+
import { normalizeCaseStore } from "./case-migration-model.js";
|
|
3
|
+
/** The exact versioned string persisted by the browser storage adapter. */
|
|
4
|
+
export function serializeCaseStore(cases) {
|
|
5
|
+
return JSON.stringify({ version: CASE_SCHEMA_VERSION, cases });
|
|
6
|
+
}
|
|
7
|
+
function byteLength(text) {
|
|
8
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
9
|
+
return new TextEncoder().encode(text).length;
|
|
10
|
+
}
|
|
11
|
+
return unescape(encodeURIComponent(text)).length;
|
|
12
|
+
}
|
|
13
|
+
function collectPrunableSnapshots(cases, allowLast) {
|
|
14
|
+
const items = [];
|
|
15
|
+
for (let index = 0; index < cases.length; index++) {
|
|
16
|
+
const record = cases[index];
|
|
17
|
+
if (!record)
|
|
18
|
+
continue;
|
|
19
|
+
const history = record.evidenceHistory;
|
|
20
|
+
const limit = history.length - (allowLast ? 0 : 1);
|
|
21
|
+
for (let position = 0; position < limit; position++) {
|
|
22
|
+
const snapshot = history[position];
|
|
23
|
+
if (!snapshot)
|
|
24
|
+
continue;
|
|
25
|
+
items.push({
|
|
26
|
+
index,
|
|
27
|
+
snapshot,
|
|
28
|
+
key: `${snapshot.capturedAt}|${record.domain}|${snapshot.fingerprint}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
items.sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0);
|
|
33
|
+
return items;
|
|
34
|
+
}
|
|
35
|
+
function pruneOldestSnapshots(cases, allowLast) {
|
|
36
|
+
let total = byteLength(serializeCaseStore(cases));
|
|
37
|
+
if (total <= MAX_CASE_STORE_BYTES)
|
|
38
|
+
return 0;
|
|
39
|
+
let removed = 0;
|
|
40
|
+
for (const item of collectPrunableSnapshots(cases, allowLast)) {
|
|
41
|
+
if (total <= MAX_CASE_STORE_BYTES)
|
|
42
|
+
break;
|
|
43
|
+
const record = cases[item.index];
|
|
44
|
+
if (!record)
|
|
45
|
+
continue;
|
|
46
|
+
cases[item.index] = {
|
|
47
|
+
...record,
|
|
48
|
+
evidenceHistory: record.evidenceHistory.filter((snapshot) => snapshot !== item.snapshot),
|
|
49
|
+
};
|
|
50
|
+
total -= byteLength(JSON.stringify(item.snapshot)) + 1;
|
|
51
|
+
removed += 1;
|
|
52
|
+
}
|
|
53
|
+
while (byteLength(serializeCaseStore(cases)) > MAX_CASE_STORE_BYTES) {
|
|
54
|
+
const item = collectPrunableSnapshots(cases, allowLast)[0];
|
|
55
|
+
if (!item)
|
|
56
|
+
break;
|
|
57
|
+
const record = cases[item.index];
|
|
58
|
+
if (!record)
|
|
59
|
+
continue;
|
|
60
|
+
cases[item.index] = {
|
|
61
|
+
...record,
|
|
62
|
+
evidenceHistory: record.evidenceHistory.filter((snapshot) => snapshot !== item.snapshot),
|
|
63
|
+
};
|
|
64
|
+
removed += 1;
|
|
65
|
+
}
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Normalize the store and prune only evidence history until its serialized
|
|
70
|
+
* representation fits. Analyst-authored notes, tags, and decisions are never
|
|
71
|
+
* discarded to satisfy the byte budget.
|
|
72
|
+
*/
|
|
73
|
+
export function enforceStoreBudget(cases) {
|
|
74
|
+
const working = normalizeCaseStore(cases).cases;
|
|
75
|
+
if (byteLength(serializeCaseStore(working)) <= MAX_CASE_STORE_BYTES) {
|
|
76
|
+
return { cases: working, pruned: 0 };
|
|
77
|
+
}
|
|
78
|
+
let pruned = pruneOldestSnapshots(working, false);
|
|
79
|
+
if (byteLength(serializeCaseStore(working)) > MAX_CASE_STORE_BYTES) {
|
|
80
|
+
pruned += pruneOldestSnapshots(working, true);
|
|
81
|
+
}
|
|
82
|
+
if (byteLength(serializeCaseStore(working)) > MAX_CASE_STORE_BYTES) {
|
|
83
|
+
throw new Error('Could not save cases: your notes and tags exceed the browser storage budget. Export and remove some cases to free space.');
|
|
84
|
+
}
|
|
85
|
+
return { cases: working, pruned };
|
|
86
|
+
}
|
|
87
|
+
export function buildCaseExport(cases, nowIso) {
|
|
88
|
+
return {
|
|
89
|
+
version: CASE_SCHEMA_VERSION,
|
|
90
|
+
exportedAt: nowIso || new Date().toISOString(),
|
|
91
|
+
cases: normalizeCaseStore(cases).cases,
|
|
92
|
+
};
|
|
93
|
+
}
|