@slicedearth/whoisleuth-cli 1.36.1 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +236 -5
- package/cli/artifact-verify.mjs +56 -14
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +18 -3
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-plan.mjs +2 -2
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/offline-evidence-review.mjs +20 -2
- package/cli/registry-doctor.mjs +125 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +236 -17
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +1 -1
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +1 -1
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
const MAX_ENTRIES = 24;
|
|
2
|
+
const MAX_ID_LENGTH = 64;
|
|
3
|
+
const MAX_LABEL_LENGTH = 120;
|
|
4
|
+
const MAX_LIMITATIONS = 8;
|
|
5
|
+
const MAX_LIMITATION_LENGTH = 280;
|
|
6
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
|
|
7
|
+
const COMPLETE_STATES = new Set([
|
|
8
|
+
'available',
|
|
9
|
+
'complete',
|
|
10
|
+
'completed',
|
|
11
|
+
'observed',
|
|
12
|
+
'success',
|
|
13
|
+
'supported',
|
|
14
|
+
]);
|
|
15
|
+
const PARTIAL_STATES = new Set(['incomplete', 'limited', 'partial', 'truncated']);
|
|
16
|
+
const SKIPPED_STATES = new Set(['disabled', 'not_applicable', 'skipped']);
|
|
17
|
+
const UNAVAILABLE_STATES = new Set([
|
|
18
|
+
'blocked',
|
|
19
|
+
'error',
|
|
20
|
+
'failed',
|
|
21
|
+
'rate_limited',
|
|
22
|
+
'timeout',
|
|
23
|
+
'unavailable',
|
|
24
|
+
]);
|
|
25
|
+
function boundedText(value, maximum) {
|
|
26
|
+
return String(value ?? '')
|
|
27
|
+
.replace(CONTROL_CHARACTERS, ' ')
|
|
28
|
+
.replace(/\s+/gu, ' ')
|
|
29
|
+
.trim()
|
|
30
|
+
.slice(0, maximum);
|
|
31
|
+
}
|
|
32
|
+
function record(value) {
|
|
33
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
34
|
+
? value
|
|
35
|
+
: {};
|
|
36
|
+
}
|
|
37
|
+
function stringList(value) {
|
|
38
|
+
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
39
|
+
}
|
|
40
|
+
function rdapLimitations(rdapParsed) {
|
|
41
|
+
if (rdapParsed.serverTruncated !== true)
|
|
42
|
+
return [];
|
|
43
|
+
const reasons = stringList(rdapParsed.serverTruncationReasons).slice(0, MAX_LIMITATIONS);
|
|
44
|
+
return [
|
|
45
|
+
`The registry reported that some RDAP data was omitted.${reasons.length ? ` ${reasons.join(' · ')}.` : ''}`,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
function dnsLimitations(dnsEvidence) {
|
|
49
|
+
const limitations = stringList(dnsEvidence.limitations);
|
|
50
|
+
const failures = Object.entries(record(dnsEvidence.diagnostics))
|
|
51
|
+
.slice(0, 16)
|
|
52
|
+
.filter(([, value]) => record(value).status === 'error')
|
|
53
|
+
.map(([name, value]) => `${name.toUpperCase()}: ${record(value).error || 'query failed'}`)
|
|
54
|
+
.join(' · ');
|
|
55
|
+
return failures ? [...limitations, failures] : limitations;
|
|
56
|
+
}
|
|
57
|
+
function normalizeStatus(value) {
|
|
58
|
+
return boundedText(value, 64)
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/[\s-]+/gu, '_');
|
|
61
|
+
}
|
|
62
|
+
function normalizeLimitations(value) {
|
|
63
|
+
if (!Array.isArray(value))
|
|
64
|
+
return [];
|
|
65
|
+
const output = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const item of value) {
|
|
68
|
+
const text = boundedText(item, MAX_LIMITATION_LENGTH);
|
|
69
|
+
if (!text || seen.has(text))
|
|
70
|
+
continue;
|
|
71
|
+
output.push(text);
|
|
72
|
+
seen.add(text);
|
|
73
|
+
if (output.length >= MAX_LIMITATIONS)
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return output;
|
|
77
|
+
}
|
|
78
|
+
function coverageState(input) {
|
|
79
|
+
const status = normalizeStatus(input.status);
|
|
80
|
+
if (status === 'not_found')
|
|
81
|
+
return 'not_found';
|
|
82
|
+
if (SKIPPED_STATES.has(status))
|
|
83
|
+
return 'skipped';
|
|
84
|
+
if (status === 'unsupported')
|
|
85
|
+
return 'unsupported';
|
|
86
|
+
if (UNAVAILABLE_STATES.has(status))
|
|
87
|
+
return 'unavailable';
|
|
88
|
+
if (input.truncated === true || input.complete === false || PARTIAL_STATES.has(status))
|
|
89
|
+
return 'partial';
|
|
90
|
+
if (COMPLETE_STATES.has(status) || (status === '' && input.complete === true))
|
|
91
|
+
return 'complete';
|
|
92
|
+
return 'unknown';
|
|
93
|
+
}
|
|
94
|
+
function statusLabel(state) {
|
|
95
|
+
return {
|
|
96
|
+
complete: 'Complete',
|
|
97
|
+
not_found: 'Not found',
|
|
98
|
+
partial: 'Partial',
|
|
99
|
+
skipped: 'Skipped',
|
|
100
|
+
unavailable: 'Unavailable',
|
|
101
|
+
unknown: 'Unknown',
|
|
102
|
+
unsupported: 'Unsupported',
|
|
103
|
+
}[state];
|
|
104
|
+
}
|
|
105
|
+
export function buildEvidenceCoverageLedger(inputs) {
|
|
106
|
+
const entries = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const input of inputs.slice(0, MAX_ENTRIES)) {
|
|
109
|
+
const id = boundedText(input.id, MAX_ID_LENGTH).toLowerCase().replace(/[^a-z0-9_-]+/gu, '-');
|
|
110
|
+
const label = boundedText(input.label, MAX_LABEL_LENGTH);
|
|
111
|
+
if (!id || !label || seen.has(id))
|
|
112
|
+
continue;
|
|
113
|
+
seen.add(id);
|
|
114
|
+
const state = coverageState(input);
|
|
115
|
+
entries.push({
|
|
116
|
+
id,
|
|
117
|
+
label,
|
|
118
|
+
category: input.category,
|
|
119
|
+
state,
|
|
120
|
+
statusLabel: statusLabel(state),
|
|
121
|
+
truncated: input.truncated === true,
|
|
122
|
+
limitations: normalizeLimitations(input.limitations),
|
|
123
|
+
manualReviewSuggested: state === 'partial' || state === 'unavailable' || state === 'unknown',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const counts = {
|
|
127
|
+
complete: 0,
|
|
128
|
+
not_found: 0,
|
|
129
|
+
partial: 0,
|
|
130
|
+
skipped: 0,
|
|
131
|
+
unavailable: 0,
|
|
132
|
+
unknown: 0,
|
|
133
|
+
unsupported: 0,
|
|
134
|
+
};
|
|
135
|
+
for (const entry of entries)
|
|
136
|
+
counts[entry.state] += 1;
|
|
137
|
+
return {
|
|
138
|
+
version: 1,
|
|
139
|
+
entries,
|
|
140
|
+
counts,
|
|
141
|
+
completeCount: counts.complete,
|
|
142
|
+
limitedCount: counts.partial + counts.unavailable + counts.unknown,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function buildLookupEvidenceCoverageLedger(input) {
|
|
146
|
+
const targetType = boundedText(input.targetType, 20);
|
|
147
|
+
const availability = record(input.availability);
|
|
148
|
+
const diagnostics = record(input.diagnostics);
|
|
149
|
+
const rdapDiagnostic = record(diagnostics.rdap);
|
|
150
|
+
const whoisDiagnostic = record(diagnostics.whois);
|
|
151
|
+
const registrarRdap = record(input.registrarRdap);
|
|
152
|
+
const reverseDns = record(input.reverseDns);
|
|
153
|
+
const observedNetworkContext = record(input.observedNetworkContext);
|
|
154
|
+
const dnsEvidence = record(input.dnsEvidence);
|
|
155
|
+
const httpEvidence = record(input.httpEvidence);
|
|
156
|
+
const httpResponse = record(input.httpResponse);
|
|
157
|
+
const tlsEvidence = record(input.tlsEvidence);
|
|
158
|
+
const pageIdentity = record(input.pageIdentity);
|
|
159
|
+
const pageRoleProfile = record(input.pageRoleProfile);
|
|
160
|
+
const clientBehaviorProfile = record(input.clientBehaviorProfile);
|
|
161
|
+
const technologyProfile = record(input.technologyProfile);
|
|
162
|
+
const securityPosture = record(input.securityPosture);
|
|
163
|
+
const securityTxt = record(input.securityTxt);
|
|
164
|
+
const sslbl = record(input.sslbl);
|
|
165
|
+
const rdapParsed = record(input.rdapParsed);
|
|
166
|
+
const whoisParsed = record(input.whoisParsed);
|
|
167
|
+
const items = [
|
|
168
|
+
{
|
|
169
|
+
id: 'rdap',
|
|
170
|
+
label: targetType === 'domain' ? 'Registry RDAP' : 'RDAP',
|
|
171
|
+
category: 'registry',
|
|
172
|
+
status: rdapDiagnostic.status,
|
|
173
|
+
truncated: rdapParsed.serverTruncated,
|
|
174
|
+
limitations: rdapLimitations(rdapParsed),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'whois',
|
|
178
|
+
label: 'WHOIS',
|
|
179
|
+
category: 'registry',
|
|
180
|
+
status: whoisDiagnostic.status,
|
|
181
|
+
truncated: Array.isArray(whoisParsed.fieldsTruncated) && whoisParsed.fieldsTruncated.length > 0,
|
|
182
|
+
limitations: stringList(whoisParsed.limitations),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'availability',
|
|
186
|
+
label: 'Availability decision',
|
|
187
|
+
category: 'analysis',
|
|
188
|
+
status: record(diagnostics.availability).status,
|
|
189
|
+
complete: availability.complete,
|
|
190
|
+
limitations: stringList(availability.limitations),
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
if (registrarRdap.status) {
|
|
194
|
+
items.push({
|
|
195
|
+
id: 'registrar-rdap',
|
|
196
|
+
label: 'Registrar RDAP',
|
|
197
|
+
category: 'registry',
|
|
198
|
+
status: registrarRdap.status,
|
|
199
|
+
limitations: stringList(registrarRdap.limitations),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (reverseDns.source === 'reverse_dns' || record(diagnostics.reverseDns).status) {
|
|
203
|
+
items.push({
|
|
204
|
+
id: 'reverse-dns',
|
|
205
|
+
label: 'Reverse DNS',
|
|
206
|
+
category: 'network',
|
|
207
|
+
status: reverseDns.status || record(diagnostics.reverseDns).status,
|
|
208
|
+
complete: reverseDns.complete,
|
|
209
|
+
truncated: reverseDns.truncated,
|
|
210
|
+
limitations: stringList(reverseDns.limitations),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (observedNetworkContext.contextVersion === 1) {
|
|
214
|
+
items.push({
|
|
215
|
+
id: 'network-context',
|
|
216
|
+
label: 'Observed network context',
|
|
217
|
+
category: 'network',
|
|
218
|
+
status: observedNetworkContext.status,
|
|
219
|
+
complete: observedNetworkContext.complete,
|
|
220
|
+
limitations: stringList(observedNetworkContext.limitations),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (targetType === 'domain') {
|
|
224
|
+
items.push({
|
|
225
|
+
id: 'dns',
|
|
226
|
+
label: 'DNS',
|
|
227
|
+
category: 'network',
|
|
228
|
+
status: dnsEvidence.status,
|
|
229
|
+
complete: dnsEvidence.complete,
|
|
230
|
+
truncated: dnsEvidence.truncated,
|
|
231
|
+
limitations: dnsLimitations(dnsEvidence),
|
|
232
|
+
}, {
|
|
233
|
+
id: 'http',
|
|
234
|
+
label: 'HTTP',
|
|
235
|
+
category: 'web',
|
|
236
|
+
status: httpEvidence.status,
|
|
237
|
+
complete: httpEvidence.complete,
|
|
238
|
+
truncated: httpResponse.bodyTruncated,
|
|
239
|
+
limitations: stringList(httpEvidence.limitations),
|
|
240
|
+
}, {
|
|
241
|
+
id: 'tls',
|
|
242
|
+
label: 'TLS',
|
|
243
|
+
category: 'web',
|
|
244
|
+
status: tlsEvidence.status,
|
|
245
|
+
complete: tlsEvidence.complete,
|
|
246
|
+
truncated: tlsEvidence.chainTruncated,
|
|
247
|
+
limitations: stringList(tlsEvidence.limitations),
|
|
248
|
+
}, {
|
|
249
|
+
id: 'page-identity',
|
|
250
|
+
label: 'Page identity',
|
|
251
|
+
category: 'web',
|
|
252
|
+
status: pageIdentity.status,
|
|
253
|
+
complete: pageIdentity.complete,
|
|
254
|
+
truncated: pageIdentity.truncated,
|
|
255
|
+
limitations: stringList(pageIdentity.limitations),
|
|
256
|
+
}, {
|
|
257
|
+
id: 'technology',
|
|
258
|
+
label: 'Technology indicators',
|
|
259
|
+
category: 'analysis',
|
|
260
|
+
status: technologyProfile.status,
|
|
261
|
+
complete: technologyProfile.complete,
|
|
262
|
+
truncated: technologyProfile.truncated,
|
|
263
|
+
limitations: stringList(technologyProfile.limitations),
|
|
264
|
+
}, {
|
|
265
|
+
id: 'page-role',
|
|
266
|
+
label: 'Page role classification',
|
|
267
|
+
category: 'analysis',
|
|
268
|
+
status: pageRoleProfile.status,
|
|
269
|
+
complete: pageRoleProfile.complete,
|
|
270
|
+
truncated: pageRoleProfile.truncated,
|
|
271
|
+
limitations: stringList(pageRoleProfile.limitations),
|
|
272
|
+
}, {
|
|
273
|
+
id: 'client-behavior',
|
|
274
|
+
label: 'Client-side behaviour indicators',
|
|
275
|
+
category: 'analysis',
|
|
276
|
+
status: clientBehaviorProfile.status,
|
|
277
|
+
complete: clientBehaviorProfile.complete,
|
|
278
|
+
truncated: clientBehaviorProfile.truncated,
|
|
279
|
+
limitations: stringList(clientBehaviorProfile.limitations),
|
|
280
|
+
}, {
|
|
281
|
+
id: 'security-posture',
|
|
282
|
+
label: 'Security posture',
|
|
283
|
+
category: 'analysis',
|
|
284
|
+
status: securityPosture.status,
|
|
285
|
+
complete: securityPosture.complete,
|
|
286
|
+
truncated: securityPosture.truncated,
|
|
287
|
+
limitations: stringList(securityPosture.limitations),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (securityTxt.securityTxtVersion === 1) {
|
|
291
|
+
items.push({
|
|
292
|
+
id: 'security-txt',
|
|
293
|
+
label: 'security.txt',
|
|
294
|
+
category: 'web',
|
|
295
|
+
status: securityTxt.state,
|
|
296
|
+
limitations: stringList(securityTxt.limitations),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (sslbl.sslblVersion === 1) {
|
|
300
|
+
items.push({
|
|
301
|
+
id: 'sslbl-certificate',
|
|
302
|
+
label: 'SSLBL certificate comparison',
|
|
303
|
+
category: 'external',
|
|
304
|
+
status: sslbl.status,
|
|
305
|
+
complete: sslbl.complete,
|
|
306
|
+
limitations: stringList(sslbl.limitations),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const providers = Array.isArray(input.threatIntelligenceProviders)
|
|
310
|
+
? input.threatIntelligenceProviders
|
|
311
|
+
: [];
|
|
312
|
+
for (const [providerIndex, providerValue] of providers.entries()) {
|
|
313
|
+
if (items.length >= MAX_ENTRIES)
|
|
314
|
+
break;
|
|
315
|
+
const provider = record(providerValue);
|
|
316
|
+
const identity = record(provider.provider);
|
|
317
|
+
const fallback = `external-${providerIndex + 1}`;
|
|
318
|
+
const id = boundedText(identity.id || fallback, MAX_ID_LENGTH);
|
|
319
|
+
items.push({
|
|
320
|
+
id: `external-${id}`,
|
|
321
|
+
label: boundedText(identity.label || `External source ${providerIndex + 1}`, MAX_LABEL_LENGTH),
|
|
322
|
+
category: 'external',
|
|
323
|
+
status: provider.state,
|
|
324
|
+
limitations: [boundedText(provider.detail, MAX_LIMITATION_LENGTH)].filter(Boolean),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return buildEvidenceCoverageLedger(items);
|
|
328
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { canonicalArtifactJson, sha256ArtifactDigest } from "./artifact-integrity.js";
|
|
2
|
+
export const INVESTIGATION_CAPSULE_SCHEMA = 'whoisleuth.investigation-capsule';
|
|
3
|
+
export const INVESTIGATION_CAPSULE_VERSION = 1;
|
|
4
|
+
function safeVersion(value) {
|
|
5
|
+
return typeof value === 'string' && /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(value)
|
|
6
|
+
? value
|
|
7
|
+
: '0.0.0';
|
|
8
|
+
}
|
|
9
|
+
function safeSchema(value) {
|
|
10
|
+
return typeof value === 'string' && /^[a-z0-9.-]{1,120}$/u.test(value) ? value : 'unknown';
|
|
11
|
+
}
|
|
12
|
+
function safeSchemaVersion(value) {
|
|
13
|
+
return Number.isSafeInteger(value) && Number(value) > 0 && Number(value) <= 10_000 ? Number(value) : 0;
|
|
14
|
+
}
|
|
15
|
+
function iso(value) {
|
|
16
|
+
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)))
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
return new Date(value).toISOString();
|
|
19
|
+
}
|
|
20
|
+
function analystProjection(record) {
|
|
21
|
+
if (!record)
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
caseId: record.id,
|
|
25
|
+
status: record.status,
|
|
26
|
+
disposition: record.disposition,
|
|
27
|
+
decisions: record.decisions.map(({ id, summary, rationale, evidencePinIds, createdAt }) => ({
|
|
28
|
+
id,
|
|
29
|
+
summary,
|
|
30
|
+
rationale,
|
|
31
|
+
evidencePinIds: [...evidencePinIds],
|
|
32
|
+
createdAt,
|
|
33
|
+
})),
|
|
34
|
+
assertions: record.assertions.map(({ id, kind, statement, rationale, evidencePinIds, state, createdAt, updatedAt }) => ({
|
|
35
|
+
id,
|
|
36
|
+
kind,
|
|
37
|
+
statement,
|
|
38
|
+
rationale,
|
|
39
|
+
evidencePinIds: [...evidencePinIds],
|
|
40
|
+
state,
|
|
41
|
+
createdAt,
|
|
42
|
+
updatedAt,
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export async function buildInvestigationCapsule(input) {
|
|
47
|
+
const analystRecords = input.includeAnalystRecords ? analystProjection(input.caseRecord) : null;
|
|
48
|
+
const [evidenceDigest, briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
|
|
49
|
+
sha256ArtifactDigest(input.lookupEvidence),
|
|
50
|
+
sha256ArtifactDigest(input.brief),
|
|
51
|
+
sha256ArtifactDigest(input.graph),
|
|
52
|
+
analystRecords ? sha256ArtifactDigest(analystRecords) : Promise.resolve(null),
|
|
53
|
+
]);
|
|
54
|
+
const evidenceSchema = safeSchema(input.lookupEvidence.schema);
|
|
55
|
+
const evidenceVersion = safeSchemaVersion(input.lookupEvidence.schemaVersion);
|
|
56
|
+
return {
|
|
57
|
+
schema: INVESTIGATION_CAPSULE_SCHEMA,
|
|
58
|
+
schemaVersion: INVESTIGATION_CAPSULE_VERSION,
|
|
59
|
+
generatedAt: iso(input.generatedAt),
|
|
60
|
+
application: { name: 'WHOISleuth', version: safeVersion(input.applicationVersion) },
|
|
61
|
+
target: { value: input.brief.target, type: input.brief.targetType },
|
|
62
|
+
sourceContracts: [
|
|
63
|
+
{ id: 'lookup-evidence', schema: evidenceSchema, version: evidenceVersion, digest: evidenceDigest, embedded: false },
|
|
64
|
+
{ id: 'investigation-brief', schema: input.brief.schema, version: input.brief.schemaVersion, digest: briefDigest, embedded: true },
|
|
65
|
+
{ id: 'asset-graph', schema: 'whoisleuth.lookup-asset-graph', version: input.graph.version, digest: graphDigest, embedded: true },
|
|
66
|
+
...(analystRecords && analystRecordsDigest
|
|
67
|
+
? [{ id: 'analyst-records', schema: 'whoisleuth.case-analyst-records', version: 1, digest: analystRecordsDigest, embedded: true }]
|
|
68
|
+
: []),
|
|
69
|
+
],
|
|
70
|
+
investigationBrief: input.brief,
|
|
71
|
+
graphSnapshot: input.graph,
|
|
72
|
+
analystRecords,
|
|
73
|
+
integrity: { algorithm: 'SHA-256', briefDigest, graphDigest, analystRecordsDigest },
|
|
74
|
+
limitations: [
|
|
75
|
+
'The Lookup evidence file is linked by digest but is not embedded; keep or share that exact file separately when verification is required.',
|
|
76
|
+
'Digest verification can detect changed content but does not establish who created the capsule. No digital signature is applied.',
|
|
77
|
+
'The graph and brief are bounded projections of collected and derived evidence, not attribution, ownership, safety, availability, or maliciousness conclusions.',
|
|
78
|
+
analystRecords
|
|
79
|
+
? 'Analyst decisions and assertions were deliberately included; review them for sensitive or personal information before sharing.'
|
|
80
|
+
: 'Analyst decisions, assertions, notes, contacts, actions, and raw source payloads are excluded.',
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export async function verifyInvestigationCapsule(capsule) {
|
|
85
|
+
const [briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
|
|
86
|
+
sha256ArtifactDigest(capsule.investigationBrief),
|
|
87
|
+
sha256ArtifactDigest(capsule.graphSnapshot),
|
|
88
|
+
capsule.analystRecords ? sha256ArtifactDigest(capsule.analystRecords) : Promise.resolve(null),
|
|
89
|
+
]);
|
|
90
|
+
const brief = briefDigest === capsule.integrity.briefDigest;
|
|
91
|
+
const graph = graphDigest === capsule.integrity.graphDigest;
|
|
92
|
+
const analystRecords = capsule.analystRecords
|
|
93
|
+
? analystRecordsDigest === capsule.integrity.analystRecordsDigest
|
|
94
|
+
: capsule.integrity.analystRecordsDigest === null
|
|
95
|
+
? null
|
|
96
|
+
: false;
|
|
97
|
+
return { valid: brief && graph && analystRecords !== false, brief, graph, analystRecords };
|
|
98
|
+
}
|
|
99
|
+
export function investigationCapsuleFilename(capsule) {
|
|
100
|
+
const target = capsule.target.value.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 80) || 'lookup';
|
|
101
|
+
return `whoisleuth-investigation-capsule-${target}-${capsule.generatedAt.slice(0, 10)}.json`;
|
|
102
|
+
}
|
|
103
|
+
export function serializeInvestigationCapsule(capsule) {
|
|
104
|
+
return `${canonicalArtifactJson(capsule)}\n`;
|
|
105
|
+
}
|