@slicedearth/whoisleuth-cli 1.39.1 → 1.45.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 +23 -4
- package/bin/whoisleuth.mjs +31 -9
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +361 -256
- package/cli/artifact-verify.mjs +35 -1
- package/cli/bulk-checkpoint.mjs +72 -39
- package/cli/bulk-command-runner.mjs +34 -4
- package/cli/bulk.mjs +18 -7
- 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/completion.mjs +67 -13
- package/cli/config-profile.mjs +183 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +29 -2
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/errors.mjs +7 -1
- package/cli/evidence-command-arguments.mjs +11 -0
- package/cli/evidence-command-runner.mjs +1 -0
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-run.mjs +112 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +8 -1
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +105 -8
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +106 -11
- package/cli/registry-fixture-scaffold.mjs +74 -0
- package/cli/runner.mjs +388 -315
- package/docs/cli-reference.md +266 -21
- package/docs/cli.md +43 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
- package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -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/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +5 -7
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- 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-portfolio-review.mjs +151 -0
- package/lib/evidence-export.mjs +1 -1
- package/lib/html-signals.mjs +5 -11
- package/lib/lookup-cache.mjs +5 -1
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-transport.mjs +6 -6
- package/lib/risk-scoring.mjs +42 -1
- package/lib/safe-fetch.mjs +37 -32
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +22 -12
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +1 -1
|
@@ -245,6 +245,42 @@ export function evaluateRuleSet(records, rawRules) {
|
|
|
245
245
|
return [];
|
|
246
246
|
return records.slice(0, 500).map((record) => evaluateDetectionRules(record, rawRules));
|
|
247
247
|
}
|
|
248
|
+
function ruleSignature(rule) {
|
|
249
|
+
return JSON.stringify({
|
|
250
|
+
match: rule.match,
|
|
251
|
+
conditions: [...rule.conditions]
|
|
252
|
+
.map((condition) => ({ ...condition }))
|
|
253
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
export function previewDetectionRule(records, existingRules, rawCandidate) {
|
|
257
|
+
const normalized = normalizeDetectionRule({ ...record(rawCandidate), id: 'preview-rule', enabled: true });
|
|
258
|
+
if (!normalized?.id)
|
|
259
|
+
return null;
|
|
260
|
+
const candidate = { ...normalized, id: normalized.id };
|
|
261
|
+
const sourceRecords = Array.isArray(records) ? records.slice(0, 500) : [];
|
|
262
|
+
const evaluations = evaluateRuleSet(sourceRecords, [candidate]).filter((item) => item.matchedRules.length > 0);
|
|
263
|
+
const recordsById = new Map(sourceRecords.map((item) => [String(record(item).id ?? ''), record(item)]));
|
|
264
|
+
const affectedDispositionCounts = {};
|
|
265
|
+
for (const evaluation of evaluations) {
|
|
266
|
+
const disposition = String(recordsById.get(evaluation.caseId)?.disposition ?? 'unreviewed');
|
|
267
|
+
affectedDispositionCounts[disposition] = (affectedDispositionCounts[disposition] ?? 0) + 1;
|
|
268
|
+
}
|
|
269
|
+
const signature = ruleSignature(candidate);
|
|
270
|
+
const collisionRuleIds = normalizeDetectionRuleStore(existingRules).rules
|
|
271
|
+
.filter((rule) => rule.name.toLowerCase() === candidate.name.toLowerCase() || ruleSignature(rule) === signature)
|
|
272
|
+
.map((rule) => rule.id)
|
|
273
|
+
.slice(0, MAX_DETECTION_RULES);
|
|
274
|
+
return Object.freeze({
|
|
275
|
+
candidate: Object.freeze(candidate),
|
|
276
|
+
matchCount: evaluations.length,
|
|
277
|
+
affectedCaseIds: Object.freeze(evaluations.map((item) => item.caseId).filter(Boolean)),
|
|
278
|
+
affectedDispositionCounts: Object.freeze(affectedDispositionCounts),
|
|
279
|
+
unreviewedMatchCount: affectedDispositionCounts.unreviewed ?? 0,
|
|
280
|
+
collisionRuleIds: Object.freeze(collisionRuleIds),
|
|
281
|
+
limitation: 'This preview evaluates the latest bounded evidence already retained in local cases. It does not save the rule, change a score, refresh evidence, or establish whether a match is correct.',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
248
284
|
export function mergeDetectionRules(localRaw, importedRaw) {
|
|
249
285
|
const importedRecord = record(importedRaw);
|
|
250
286
|
if (typeof importedRecord.schema === 'string' && importedRecord.schema !== DETECTION_RULE_SCHEMA) {
|
|
@@ -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
|
+
}
|
|
@@ -372,7 +372,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
372
372
|
completeness: sourceCompleteness(networkContext),
|
|
373
373
|
limitations: limitations(networkContext.limitations),
|
|
374
374
|
lenses: ['all'],
|
|
375
|
-
href: '#evidence-network
|
|
375
|
+
href: '#evidence-network',
|
|
376
376
|
});
|
|
377
377
|
}
|
|
378
378
|
const networkLabel = text(network.name || network.holder || network.handle, 180);
|
|
@@ -390,7 +390,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
390
390
|
completeness: sourceCompleteness(networkContext),
|
|
391
391
|
limitations: limitations(networkContext.limitations),
|
|
392
392
|
lenses: ['all'],
|
|
393
|
-
href: '#evidence-network
|
|
393
|
+
href: '#evidence-network',
|
|
394
394
|
});
|
|
395
395
|
}
|
|
396
396
|
for (const cidr of textList(network.cidrs, 8)) {
|
|
@@ -407,7 +407,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
407
407
|
completeness: sourceCompleteness(networkContext),
|
|
408
408
|
limitations: limitations(networkContext.limitations),
|
|
409
409
|
lenses: ['all'],
|
|
410
|
-
href: '#evidence-network
|
|
410
|
+
href: '#evidence-network',
|
|
411
411
|
});
|
|
412
412
|
}
|
|
413
413
|
}
|
|
@@ -437,7 +437,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
437
437
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
438
438
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
439
439
|
lenses: ['identity'],
|
|
440
|
-
href: '#evidence-page
|
|
440
|
+
href: '#evidence-page',
|
|
441
441
|
boundary: trustBoundary(canonicalHost, finalHost || target),
|
|
442
442
|
}, 'Publisher-declared canonical origin', identitySource);
|
|
443
443
|
}
|
|
@@ -451,7 +451,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
451
451
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
452
452
|
limitations: limitations(record(input.pageIdentity).limitations),
|
|
453
453
|
lenses: ['identity'],
|
|
454
|
-
href: '#evidence-page
|
|
454
|
+
href: '#evidence-page',
|
|
455
455
|
boundary: trustBoundary(openGraphHost, finalHost || target),
|
|
456
456
|
}, 'Publisher-declared Open Graph origin', identitySource);
|
|
457
457
|
}
|
|
@@ -466,7 +466,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
466
466
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
467
467
|
limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
|
|
468
468
|
lenses: ['identity'],
|
|
469
|
-
href: '#evidence-page
|
|
469
|
+
href: '#evidence-page',
|
|
470
470
|
boundary: trustBoundary(host, finalHost || target),
|
|
471
471
|
}, 'External form-action origin', identitySource);
|
|
472
472
|
}
|
|
@@ -482,7 +482,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
482
482
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
483
483
|
limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
|
|
484
484
|
lenses: ['identity'],
|
|
485
|
-
href: '#evidence-page
|
|
485
|
+
href: '#evidence-page',
|
|
486
486
|
boundary: trustBoundary(host, finalHost || target),
|
|
487
487
|
}, 'External resource origin', identitySource);
|
|
488
488
|
}
|
|
@@ -498,7 +498,7 @@ export function buildLookupAssetGraph(input) {
|
|
|
498
498
|
completeness: sourceCompleteness(input.pageIdentity),
|
|
499
499
|
limitations: ['A shared identifier is a relationship lead and does not establish common ownership or control.'],
|
|
500
500
|
lenses: ['identity'],
|
|
501
|
-
href: '#evidence-page
|
|
501
|
+
href: '#evidence-page',
|
|
502
502
|
}, text(identifier.type, 80), identitySource);
|
|
503
503
|
}
|
|
504
504
|
}
|
|
@@ -275,7 +275,7 @@ function identityEntries(input) {
|
|
|
275
275
|
title: 'The declared canonical origin differs from the observed final origin',
|
|
276
276
|
detail: `${finalHost} served the page while the document declared ${canonicalHost} as canonical.`,
|
|
277
277
|
sources: ['HTTP', 'HTML'],
|
|
278
|
-
href: '#evidence-page
|
|
278
|
+
href: '#evidence-page',
|
|
279
279
|
});
|
|
280
280
|
}
|
|
281
281
|
if (finalHost && openGraphHost && !sameRegistrableScope(finalHost, openGraphHost, registrableDomain)) {
|
|
@@ -286,7 +286,7 @@ function identityEntries(input) {
|
|
|
286
286
|
title: 'The Open Graph URL points outside the observed final origin',
|
|
287
287
|
detail: `${finalHost} served the page while Open Graph metadata declared ${openGraphHost}. Publisher metadata is a claim, not identity proof.`,
|
|
288
288
|
sources: ['HTTP', 'HTML'],
|
|
289
|
-
href: '#evidence-page
|
|
289
|
+
href: '#evidence-page',
|
|
290
290
|
});
|
|
291
291
|
}
|
|
292
292
|
return output;
|
|
@@ -357,6 +357,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
357
357
|
id: 'review-priority-conflict',
|
|
358
358
|
label: 'Review the highest-priority disagreement',
|
|
359
359
|
reason: firstConflict.title,
|
|
360
|
+
expectedOutcome: 'Establish which separately attributed observation is current, authoritative, or still unresolved.',
|
|
360
361
|
href: firstConflict.href,
|
|
361
362
|
priority: 'high',
|
|
362
363
|
});
|
|
@@ -366,6 +367,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
366
367
|
id: 'review-refresh-options',
|
|
367
368
|
label: 'Review limited or stale sources',
|
|
368
369
|
reason: `${input.refreshPlan.items.length} deliberate source refresh option${input.refreshPlan.items.length === 1 ? ' is' : 's are'} available.`,
|
|
370
|
+
expectedOutcome: 'Determine whether the limitation is transient, persistent, or an expected source constraint.',
|
|
369
371
|
href: '#evidence-quality',
|
|
370
372
|
priority: 'medium',
|
|
371
373
|
});
|
|
@@ -376,6 +378,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
376
378
|
id: 'inspect-limited-source',
|
|
377
379
|
label: `Inspect ${firstLimited.label}`,
|
|
378
380
|
reason: `${firstLimited.statusLabel} evidence may limit downstream conclusions.`,
|
|
381
|
+
expectedOutcome: `Clarify which conclusions ${firstLimited.label} can and cannot support in its current state.`,
|
|
379
382
|
href: '#evidence-quality',
|
|
380
383
|
priority: 'medium',
|
|
381
384
|
});
|
|
@@ -385,6 +388,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
385
388
|
id: 'review-page-identity',
|
|
386
389
|
label: 'Review identity and credential evidence',
|
|
387
390
|
reason: 'Compare declared identity, forms, redirects, and external destinations before making a brand assessment.',
|
|
391
|
+
expectedOutcome: 'Separate observed identity signals from publisher claims, shared services, and unresolved resemblance leads.',
|
|
388
392
|
href: '#web-evidence',
|
|
389
393
|
priority: 'medium',
|
|
390
394
|
});
|
|
@@ -394,6 +398,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
394
398
|
id: 'review-acquisition-dependencies',
|
|
395
399
|
label: 'Review transfer dependencies',
|
|
396
400
|
reason: 'Registration dates alone do not describe the DNS, mail, certificate, and website services that require transition.',
|
|
401
|
+
expectedOutcome: 'Identify dependencies that must be retained, transferred, replaced, or deliberately retired.',
|
|
397
402
|
href: '#web-evidence',
|
|
398
403
|
priority: 'medium',
|
|
399
404
|
});
|
|
@@ -403,6 +408,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
403
408
|
id: 'review-owned-posture',
|
|
404
409
|
label: 'Review delegation and posture evidence',
|
|
405
410
|
reason: 'Confirm registry publication, authoritative responses, mail, and certificate state together.',
|
|
411
|
+
expectedOutcome: 'Identify whether observed control state aligns with the reviewed owned-domain baseline.',
|
|
406
412
|
href: '#web-evidence',
|
|
407
413
|
priority: 'medium',
|
|
408
414
|
});
|
|
@@ -412,6 +418,7 @@ export function buildLookupDecisionSupport(input) {
|
|
|
412
418
|
id: 'review-case-handoff',
|
|
413
419
|
label: 'Record or hand off reviewed evidence',
|
|
414
420
|
reason: 'Keep observed facts, hypotheses, unknowns, and analyst decisions separate in the case workflow.',
|
|
421
|
+
expectedOutcome: 'Preserve a reviewable record of what is known, inferred, unresolved, and recommended next.',
|
|
415
422
|
href: '#case-response',
|
|
416
423
|
priority: 'low',
|
|
417
424
|
});
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
|
|
2
|
-
export const
|
|
3
|
-
Object.freeze({ id: 'summary', label: 'Summary', detail: 'Overview and source-state ledger, with detailed sections reduced to headings.' }),
|
|
4
|
-
Object.freeze({ id: 'standard', label: 'Standard', detail: 'Evidence sections remain available while the raw response stays collapsed until you choose to inspect it.' }),
|
|
5
|
-
Object.freeze({ id: 'full', label: 'Full', detail: 'Every settled evidence section, including the bounded raw response view.' }),
|
|
6
|
-
]);
|
|
2
|
+
export const MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES = 1_024;
|
|
7
3
|
export const LOOKUP_TASK_VIEWS = Object.freeze([
|
|
8
4
|
Object.freeze({ id: 'general', label: 'General investigation' }),
|
|
9
5
|
Object.freeze({ id: 'acquisition', label: 'Acquisition review' }),
|
|
@@ -11,13 +7,7 @@ export const LOOKUP_TASK_VIEWS = Object.freeze([
|
|
|
11
7
|
Object.freeze({ id: 'incident', label: 'Incident response' }),
|
|
12
8
|
Object.freeze({ id: 'owned', label: 'Owned-domain posture' }),
|
|
13
9
|
]);
|
|
14
|
-
const DENSITIES = new Set(LOOKUP_EVIDENCE_DENSITIES.map((option) => option.id));
|
|
15
10
|
const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
|
|
16
|
-
export function normalizeLookupEvidenceDensity(value) {
|
|
17
|
-
return typeof value === 'string' && DENSITIES.has(value)
|
|
18
|
-
? value
|
|
19
|
-
: 'standard';
|
|
20
|
-
}
|
|
21
11
|
export function normalizeLookupTaskView(value) {
|
|
22
12
|
return typeof value === 'string' && TASKS.has(value)
|
|
23
13
|
? value
|
|
@@ -25,10 +15,15 @@ export function normalizeLookupTaskView(value) {
|
|
|
25
15
|
}
|
|
26
16
|
export function readLookupPresentation(storage) {
|
|
27
17
|
try {
|
|
28
|
-
const
|
|
18
|
+
const serialized = storage.getItem(LOOKUP_PRESENTATION_STORAGE_KEY);
|
|
19
|
+
if (serialized === null
|
|
20
|
+
|| serialized.length > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES
|
|
21
|
+
|| new TextEncoder().encode(serialized).byteLength > MAX_LOOKUP_PRESENTATION_SERIALIZED_BYTES) {
|
|
22
|
+
return { task: 'general' };
|
|
23
|
+
}
|
|
24
|
+
const stored = JSON.parse(serialized);
|
|
29
25
|
if (stored?.version === 1) {
|
|
30
26
|
return {
|
|
31
|
-
density: normalizeLookupEvidenceDensity(stored.density),
|
|
32
27
|
task: normalizeLookupTaskView(stored.task),
|
|
33
28
|
};
|
|
34
29
|
}
|
|
@@ -36,13 +31,12 @@ export function readLookupPresentation(storage) {
|
|
|
36
31
|
catch {
|
|
37
32
|
// Invalid or unavailable browser storage falls back to the stable defaults.
|
|
38
33
|
}
|
|
39
|
-
return {
|
|
34
|
+
return { task: 'general' };
|
|
40
35
|
}
|
|
41
36
|
export function writeLookupPresentation(storage, state) {
|
|
42
37
|
try {
|
|
43
38
|
storage.setItem(LOOKUP_PRESENTATION_STORAGE_KEY, JSON.stringify({
|
|
44
39
|
version: 1,
|
|
45
|
-
density: normalizeLookupEvidenceDensity(state.density),
|
|
46
40
|
task: normalizeLookupTaskView(state.task),
|
|
47
41
|
}));
|
|
48
42
|
}
|
|
@@ -51,22 +45,12 @@ export function writeLookupPresentation(storage, state) {
|
|
|
51
45
|
}
|
|
52
46
|
}
|
|
53
47
|
const PRIORITY = Object.freeze({
|
|
54
|
-
general: Object.freeze(['overview', 'web-evidence', '
|
|
55
|
-
acquisition: Object.freeze(['overview', 'registry', 'web-evidence', '
|
|
56
|
-
brand: Object.freeze(['overview', 'web-evidence', '
|
|
57
|
-
incident: Object.freeze(['overview', '
|
|
58
|
-
owned: Object.freeze(['overview', 'registry', 'web-evidence', '
|
|
48
|
+
general: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
49
|
+
acquisition: Object.freeze(['overview', 'registry', 'relationships-history', 'web-evidence', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
50
|
+
brand: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'registry', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
51
|
+
incident: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'advanced-evidence', 'registry', 'source-quality', 'case-response']),
|
|
52
|
+
owned: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
59
53
|
});
|
|
60
|
-
const INITIAL_PANELS = {
|
|
61
|
-
general: [],
|
|
62
|
-
acquisition: ['dns', 'network-context', 'tls'],
|
|
63
|
-
brand: ['http', 'page-identity', 'credential-surface', 'structured-identity', 'technology'],
|
|
64
|
-
incident: ['http', 'tls', 'credential-surface', 'security-posture'],
|
|
65
|
-
owned: ['dns', 'network-context', 'tls', 'security-posture'],
|
|
66
|
-
};
|
|
67
|
-
export function lookupTaskInitiallyExpands(task, panel) {
|
|
68
|
-
return INITIAL_PANELS[normalizeLookupTaskView(task)].includes(panel);
|
|
69
|
-
}
|
|
70
54
|
export function prioritizeLookupSectionLinks(links, task) {
|
|
71
55
|
const order = PRIORITY[normalizeLookupTaskView(task)];
|
|
72
56
|
const rank = new Map(order.map((id, index) => [`#${id}`, index]));
|