@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- 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/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- 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 +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- 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-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -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 +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -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 +9 -89
- 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 +369 -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 +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- 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/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.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/html-signals.mjs +5 -11
- 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/nameserver-preflight-review.mjs +159 -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/page-language-signals.mjs +124 -0
- 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 +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { normalizeDomain, openOrCreateCase, updateCase, } from "./case-model.js";
|
|
2
|
+
export const EXTERNAL_FINDINGS_SCHEMA = 'whoisleuth.external-findings';
|
|
3
|
+
export const EXTERNAL_FINDINGS_VERSION = 3;
|
|
4
|
+
export const MAX_EXTERNAL_FINDINGS_IMPORT_BYTES = 384 * 1024;
|
|
5
|
+
export const MAX_EXTERNAL_FINDINGS = 100;
|
|
6
|
+
export const MAX_EXTERNAL_FINDINGS_PER_DOMAIN = 20;
|
|
7
|
+
export const MAX_EXTERNAL_FINDING_DOMAINS = 25;
|
|
8
|
+
export const EXTERNAL_FINDING_CATEGORIES = [
|
|
9
|
+
'certificate',
|
|
10
|
+
'dns',
|
|
11
|
+
'http',
|
|
12
|
+
'malware',
|
|
13
|
+
'other',
|
|
14
|
+
'page',
|
|
15
|
+
'registration',
|
|
16
|
+
'reputation',
|
|
17
|
+
];
|
|
18
|
+
const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
|
|
19
|
+
const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
|
|
20
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
21
|
+
const ROOT_KEYS = new Set(['schema', 'schemaVersion', 'source', 'findings']);
|
|
22
|
+
const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
|
|
23
|
+
const FINDING_KEYS = new Set([
|
|
24
|
+
'domain',
|
|
25
|
+
'category',
|
|
26
|
+
'evidenceClass',
|
|
27
|
+
'summary',
|
|
28
|
+
'observedAt',
|
|
29
|
+
'completeness',
|
|
30
|
+
'limitations',
|
|
31
|
+
'reference',
|
|
32
|
+
'structuredObservation',
|
|
33
|
+
]);
|
|
34
|
+
const STRUCTURED_OBSERVATION_KEYS = new Set([
|
|
35
|
+
'sourceSchema',
|
|
36
|
+
'sourceVersion',
|
|
37
|
+
'field',
|
|
38
|
+
'value',
|
|
39
|
+
'issuer',
|
|
40
|
+
'notAfter',
|
|
41
|
+
]);
|
|
42
|
+
const STRUCTURED_OBSERVATION_SCHEMAS = new Set([
|
|
43
|
+
'whoisleuth.domain-observation-rows',
|
|
44
|
+
'whoisleuth.dns-observation-rows',
|
|
45
|
+
'whoisleuth.certificate-observation-rows',
|
|
46
|
+
]);
|
|
47
|
+
function record(value) {
|
|
48
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
49
|
+
? value
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
function hasOnlyKeys(value, allowed) {
|
|
53
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
54
|
+
}
|
|
55
|
+
function requiredText(value, maximum, label) {
|
|
56
|
+
if (typeof value !== 'string'
|
|
57
|
+
|| !value.trim()
|
|
58
|
+
|| value.length > maximum
|
|
59
|
+
|| CONTROL_RE.test(value)) {
|
|
60
|
+
throw new Error(`${label} must be non-empty, bounded text without control characters.`);
|
|
61
|
+
}
|
|
62
|
+
return value.trim();
|
|
63
|
+
}
|
|
64
|
+
function optionalText(value, maximum, label) {
|
|
65
|
+
if (value === undefined || value === null || value === '')
|
|
66
|
+
return null;
|
|
67
|
+
return requiredText(value, maximum, label);
|
|
68
|
+
}
|
|
69
|
+
function iso(value, label, optional = false) {
|
|
70
|
+
if (optional && (value === undefined || value === null || value === ''))
|
|
71
|
+
return null;
|
|
72
|
+
const text = requiredText(value, 64, label);
|
|
73
|
+
const parsed = Date.parse(text);
|
|
74
|
+
if (!Number.isFinite(parsed))
|
|
75
|
+
throw new Error(`${label} must be a valid date and time.`);
|
|
76
|
+
return new Date(parsed).toISOString();
|
|
77
|
+
}
|
|
78
|
+
function limitations(value, index) {
|
|
79
|
+
if (value === undefined)
|
|
80
|
+
return [];
|
|
81
|
+
if (!Array.isArray(value) || value.length > 8) {
|
|
82
|
+
throw new Error(`Finding ${index + 1} limitations must be an array with no more than 8 entries.`);
|
|
83
|
+
}
|
|
84
|
+
const unique = new Set();
|
|
85
|
+
for (const item of value)
|
|
86
|
+
unique.add(requiredText(item, 240, `Finding ${index + 1} limitation`));
|
|
87
|
+
return [...unique];
|
|
88
|
+
}
|
|
89
|
+
function structuredObservation(value, index) {
|
|
90
|
+
if (value === undefined || value === null)
|
|
91
|
+
return null;
|
|
92
|
+
const item = record(value);
|
|
93
|
+
if (!item || !hasOnlyKeys(item, STRUCTURED_OBSERVATION_KEYS)) {
|
|
94
|
+
throw new Error(`Finding ${index + 1} structured observation has an invalid shape or additional fields.`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof item.sourceSchema !== 'string' || !STRUCTURED_OBSERVATION_SCHEMAS.has(item.sourceSchema)) {
|
|
97
|
+
throw new Error(`Finding ${index + 1} structured observation source schema is unsupported.`);
|
|
98
|
+
}
|
|
99
|
+
if (item.sourceVersion !== 1) {
|
|
100
|
+
throw new Error(`Finding ${index + 1} structured observation source version is unsupported.`);
|
|
101
|
+
}
|
|
102
|
+
const field = requiredText(item.field, 40, `Finding ${index + 1} structured observation field`);
|
|
103
|
+
const observationValue = requiredText(item.value, 500, `Finding ${index + 1} structured observation value`);
|
|
104
|
+
const issuer = optionalText(item.issuer, 160, `Finding ${index + 1} certificate issuer`);
|
|
105
|
+
const notAfter = iso(item.notAfter, `Finding ${index + 1} certificate expiry`, true);
|
|
106
|
+
const certificateSchema = item.sourceSchema === 'whoisleuth.certificate-observation-rows';
|
|
107
|
+
if (!certificateSchema && (issuer !== null || notAfter !== null)) {
|
|
108
|
+
throw new Error(`Finding ${index + 1} non-certificate observation cannot declare certificate metadata.`);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
sourceSchema: item.sourceSchema,
|
|
112
|
+
sourceVersion: 1,
|
|
113
|
+
field,
|
|
114
|
+
value: observationValue,
|
|
115
|
+
issuer,
|
|
116
|
+
notAfter,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function findingKey(finding, sourceName) {
|
|
120
|
+
return [
|
|
121
|
+
finding.domain,
|
|
122
|
+
finding.category,
|
|
123
|
+
finding.evidenceClass,
|
|
124
|
+
finding.summary,
|
|
125
|
+
finding.observedAt,
|
|
126
|
+
finding.completeness,
|
|
127
|
+
sourceName,
|
|
128
|
+
].join('\u0000');
|
|
129
|
+
}
|
|
130
|
+
export function parseExternalFindingsDocument(value) {
|
|
131
|
+
const root = record(value);
|
|
132
|
+
if (!root || !hasOnlyKeys(root, ROOT_KEYS)) {
|
|
133
|
+
throw new Error('External findings must use the documented object shape without additional top-level fields.');
|
|
134
|
+
}
|
|
135
|
+
if (root.schema !== EXTERNAL_FINDINGS_SCHEMA
|
|
136
|
+
|| ![1, 2, EXTERNAL_FINDINGS_VERSION].includes(Number(root.schemaVersion))) {
|
|
137
|
+
throw new Error(`External findings must use ${EXTERNAL_FINDINGS_SCHEMA} schema version 1, 2, or ${EXTERNAL_FINDINGS_VERSION}.`);
|
|
138
|
+
}
|
|
139
|
+
const sourceValue = record(root.source);
|
|
140
|
+
if (!sourceValue || !hasOnlyKeys(sourceValue, SOURCE_KEYS)) {
|
|
141
|
+
throw new Error('External findings require a bounded source object without additional fields.');
|
|
142
|
+
}
|
|
143
|
+
const source = {
|
|
144
|
+
name: requiredText(sourceValue.name, 80, 'Source name'),
|
|
145
|
+
reference: optionalText(sourceValue.reference, 500, 'Source reference'),
|
|
146
|
+
collectedAt: iso(sourceValue.collectedAt, 'Source collection time', true),
|
|
147
|
+
};
|
|
148
|
+
if (!Array.isArray(root.findings) || !root.findings.length || root.findings.length > MAX_EXTERNAL_FINDINGS) {
|
|
149
|
+
throw new Error(`External findings must contain between 1 and ${MAX_EXTERNAL_FINDINGS} findings.`);
|
|
150
|
+
}
|
|
151
|
+
const normalized = [];
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
const domainCounts = new Map();
|
|
154
|
+
for (const [index, raw] of root.findings.entries()) {
|
|
155
|
+
const item = record(raw);
|
|
156
|
+
if (!item || !hasOnlyKeys(item, FINDING_KEYS)) {
|
|
157
|
+
throw new Error(`Finding ${index + 1} has an invalid shape or additional fields.`);
|
|
158
|
+
}
|
|
159
|
+
const domainInput = requiredText(item.domain, 253, `Finding ${index + 1} domain`);
|
|
160
|
+
const domain = normalizeDomain(domainInput);
|
|
161
|
+
if (!domain)
|
|
162
|
+
throw new Error(`Finding ${index + 1} domain is invalid.`);
|
|
163
|
+
if (typeof item.category !== 'string' || !CATEGORIES.has(item.category)) {
|
|
164
|
+
throw new Error(`Finding ${index + 1} category is unsupported.`);
|
|
165
|
+
}
|
|
166
|
+
if (typeof item.completeness !== 'string' || !COMPLETENESS.has(item.completeness)) {
|
|
167
|
+
throw new Error(`Finding ${index + 1} completeness is unsupported.`);
|
|
168
|
+
}
|
|
169
|
+
const evidenceClass = item.evidenceClass === undefined && root.schemaVersion === 1
|
|
170
|
+
? 'provider_report'
|
|
171
|
+
: item.evidenceClass;
|
|
172
|
+
if (evidenceClass !== 'deployment_observation' && evidenceClass !== 'provider_report') {
|
|
173
|
+
throw new Error(`Finding ${index + 1} evidence class is unsupported.`);
|
|
174
|
+
}
|
|
175
|
+
const finding = {
|
|
176
|
+
domain,
|
|
177
|
+
category: item.category,
|
|
178
|
+
evidenceClass,
|
|
179
|
+
summary: requiredText(item.summary, 900, `Finding ${index + 1} summary`),
|
|
180
|
+
observedAt: iso(item.observedAt, `Finding ${index + 1} observation time`),
|
|
181
|
+
completeness: item.completeness,
|
|
182
|
+
limitations: limitations(item.limitations, index),
|
|
183
|
+
reference: optionalText(item.reference, 500, `Finding ${index + 1} reference`),
|
|
184
|
+
structuredObservation: structuredObservation(item.structuredObservation, index),
|
|
185
|
+
};
|
|
186
|
+
const key = findingKey(finding, source.name);
|
|
187
|
+
if (seen.has(key))
|
|
188
|
+
continue;
|
|
189
|
+
seen.add(key);
|
|
190
|
+
const count = (domainCounts.get(domain) ?? 0) + 1;
|
|
191
|
+
if (count > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
|
|
192
|
+
throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-finding per-domain limit.`);
|
|
193
|
+
}
|
|
194
|
+
domainCounts.set(domain, count);
|
|
195
|
+
if (domainCounts.size > MAX_EXTERNAL_FINDING_DOMAINS) {
|
|
196
|
+
throw new Error(`External findings exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
|
|
197
|
+
}
|
|
198
|
+
normalized.push(finding);
|
|
199
|
+
}
|
|
200
|
+
if (!normalized.length)
|
|
201
|
+
throw new Error('External findings did not contain a unique valid finding.');
|
|
202
|
+
return {
|
|
203
|
+
schema: EXTERNAL_FINDINGS_SCHEMA,
|
|
204
|
+
schemaVersion: EXTERNAL_FINDINGS_VERSION,
|
|
205
|
+
source,
|
|
206
|
+
findings: normalized,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function pinValue(finding) {
|
|
210
|
+
return finding.reference
|
|
211
|
+
? `${finding.summary} Reference: ${finding.reference}`
|
|
212
|
+
: finding.summary;
|
|
213
|
+
}
|
|
214
|
+
function importedSourceLabel(finding, sourceName) {
|
|
215
|
+
return finding.evidenceClass === 'deployment_observation'
|
|
216
|
+
? `Deployment observation: ${sourceName}`
|
|
217
|
+
: `Provider report: ${sourceName}`;
|
|
218
|
+
}
|
|
219
|
+
function existingPinKey(recordValue, finding, sourceName) {
|
|
220
|
+
const expectedLabel = `External ${finding.category} finding`;
|
|
221
|
+
const expectedValue = finding.structuredObservation?.value ?? pinValue(finding);
|
|
222
|
+
const expectedSource = importedSourceLabel(finding, sourceName);
|
|
223
|
+
const observation = finding.structuredObservation;
|
|
224
|
+
return recordValue.evidencePins.some((pin) => (pin.label === expectedLabel
|
|
225
|
+
&& pin.value === expectedValue
|
|
226
|
+
&& pin.source === expectedSource
|
|
227
|
+
&& pin.observedAt === finding.observedAt
|
|
228
|
+
&& pin.completeness === finding.completeness
|
|
229
|
+
&& (!observation || (pin.field === observation.field
|
|
230
|
+
&& pin.sourceSchema?.collection === 'external_observations'
|
|
231
|
+
&& pin.sourceSchema.schema === observation.sourceSchema
|
|
232
|
+
&& pin.sourceSchema.version === observation.sourceVersion)))) ? findingKey(finding, sourceName) : '';
|
|
233
|
+
}
|
|
234
|
+
function structuredPinFields(finding) {
|
|
235
|
+
const observation = finding.structuredObservation;
|
|
236
|
+
if (!observation)
|
|
237
|
+
return {};
|
|
238
|
+
return {
|
|
239
|
+
field: observation.field,
|
|
240
|
+
category: finding.category,
|
|
241
|
+
sourceSchema: {
|
|
242
|
+
collection: 'external_observations',
|
|
243
|
+
schema: observation.sourceSchema,
|
|
244
|
+
version: observation.sourceVersion,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export function mergeExternalFindingsIntoCases(current, document, now = new Date().toISOString()) {
|
|
249
|
+
let cases = [...current];
|
|
250
|
+
const createdDomains = new Set();
|
|
251
|
+
const updatedDomains = new Set();
|
|
252
|
+
let findingsAdded = 0;
|
|
253
|
+
let duplicatesSkipped = 0;
|
|
254
|
+
for (const finding of document.findings) {
|
|
255
|
+
const existing = cases.find((candidate) => candidate.domain === finding.domain) ?? null;
|
|
256
|
+
const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
|
|
257
|
+
cases = opened.cases;
|
|
258
|
+
const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
|
|
259
|
+
if (existingPinKey(target, finding, document.source.name)) {
|
|
260
|
+
duplicatesSkipped += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const sourceLimitations = [
|
|
264
|
+
finding.evidenceClass === 'deployment_observation'
|
|
265
|
+
? `Imported as an observation made by ${document.source.name}; this browser session did not collect or independently verify it.`
|
|
266
|
+
: `Reported by ${document.source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
|
|
267
|
+
...(document.source.reference ? [`Source reference: ${document.source.reference}`] : []),
|
|
268
|
+
...finding.limitations,
|
|
269
|
+
];
|
|
270
|
+
const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
|
|
271
|
+
const updated = updateCase(cases, target.id, {
|
|
272
|
+
evidencePin: {
|
|
273
|
+
...structuredPinFields(finding),
|
|
274
|
+
label: `External ${finding.category} finding`,
|
|
275
|
+
value: finding.structuredObservation?.value ?? pinValue(finding),
|
|
276
|
+
source: importedSourceLabel(finding, document.source.name),
|
|
277
|
+
observedAt: finding.observedAt,
|
|
278
|
+
completeness: finding.completeness,
|
|
279
|
+
limitations: sourceLimitations,
|
|
280
|
+
},
|
|
281
|
+
}, now);
|
|
282
|
+
cases = updated.cases;
|
|
283
|
+
const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
|
|
284
|
+
if (addedPin) {
|
|
285
|
+
cases = updateCase(cases, updated.record.id, {
|
|
286
|
+
sighting: {
|
|
287
|
+
state: finding.evidenceClass === 'deployment_observation'
|
|
288
|
+
? 'observed_by_deployment'
|
|
289
|
+
: 'reported_by_provider',
|
|
290
|
+
category: finding.category === 'dns'
|
|
291
|
+
? 'delegation'
|
|
292
|
+
: finding.category === 'certificate'
|
|
293
|
+
? 'certificate'
|
|
294
|
+
: finding.category === 'registration'
|
|
295
|
+
? 'registration'
|
|
296
|
+
: finding.category === 'http' || finding.category === 'page'
|
|
297
|
+
? 'website'
|
|
298
|
+
: 'other',
|
|
299
|
+
source: document.source.name,
|
|
300
|
+
observedAt: finding.observedAt,
|
|
301
|
+
completeness: finding.completeness,
|
|
302
|
+
evidencePinId: addedPin.id,
|
|
303
|
+
limitations: sourceLimitations,
|
|
304
|
+
},
|
|
305
|
+
}, now).cases;
|
|
306
|
+
}
|
|
307
|
+
findingsAdded += 1;
|
|
308
|
+
if (existing)
|
|
309
|
+
updatedDomains.add(finding.domain);
|
|
310
|
+
else
|
|
311
|
+
createdDomains.add(finding.domain);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
cases,
|
|
315
|
+
casesCreated: createdDomains.size,
|
|
316
|
+
casesUpdated: updatedDomains.size,
|
|
317
|
+
findingsAdded,
|
|
318
|
+
duplicatesSkipped,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -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
|
+
}
|