@slicedearth/whoisleuth-cli 1.39.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 +15 -0
- package/bin/whoisleuth.mjs +13 -2
- 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 +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/completion.mjs +33 -10
- 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/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- 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/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/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 +73 -0
- package/cli/runner.mjs +368 -306
- package/docs/cli-reference.md +240 -14
- package/docs/cli.md +42 -7
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- 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-decision-support.js +7 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +10 -20
- package/frontend/src/lib/analysis/scoring.js +1 -1
- package/frontend/src/lib/analysis/visualization-models.js +2 -3
- package/frontend/src/lib/analysis/workspace-archive.js +2 -0
- package/lib/abusech-auth.mjs +1 -1
- package/lib/availability.mjs +1 -1
- package/lib/bounded-contract-normalizers.mjs +33 -0
- 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/nameserver-preflight-review.mjs +159 -0
- package/lib/page-language-signals.mjs +124 -0
- package/lib/risk-scoring.mjs +42 -1
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/website-technology.mjs +24 -10
- 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
|
+
}
|
|
@@ -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,8 +1,8 @@
|
|
|
1
1
|
export const LOOKUP_PRESENTATION_STORAGE_KEY = 'whoisleuth:lookup-presentation:v1';
|
|
2
2
|
export const LOOKUP_EVIDENCE_DENSITIES = Object.freeze([
|
|
3
|
-
Object.freeze({ id: 'summary', label: '
|
|
4
|
-
Object.freeze({ id: 'standard', label: '
|
|
5
|
-
Object.freeze({ id: 'full', label: '
|
|
3
|
+
Object.freeze({ id: 'summary', label: 'Essentials', detail: 'A compact assessment, key observations, unresolved evidence, and one summary for each evidence family.' }),
|
|
4
|
+
Object.freeze({ id: 'standard', label: 'Evidence', detail: 'Settled evidence is grouped by task while source detail and raw evidence stay collapsed until you inspect them.' }),
|
|
5
|
+
Object.freeze({ id: 'full', label: 'All evidence', detail: 'Every settled evidence family remains available, including advanced and bounded raw evidence.' }),
|
|
6
6
|
]);
|
|
7
7
|
export const LOOKUP_TASK_VIEWS = Object.freeze([
|
|
8
8
|
Object.freeze({ id: 'general', label: 'General investigation' }),
|
|
@@ -16,7 +16,7 @@ const TASKS = new Set(LOOKUP_TASK_VIEWS.map((option) => option.id));
|
|
|
16
16
|
export function normalizeLookupEvidenceDensity(value) {
|
|
17
17
|
return typeof value === 'string' && DENSITIES.has(value)
|
|
18
18
|
? value
|
|
19
|
-
: '
|
|
19
|
+
: 'summary';
|
|
20
20
|
}
|
|
21
21
|
export function normalizeLookupTaskView(value) {
|
|
22
22
|
return typeof value === 'string' && TASKS.has(value)
|
|
@@ -36,7 +36,7 @@ export function readLookupPresentation(storage) {
|
|
|
36
36
|
catch {
|
|
37
37
|
// Invalid or unavailable browser storage falls back to the stable defaults.
|
|
38
38
|
}
|
|
39
|
-
return { density: '
|
|
39
|
+
return { density: 'summary', task: 'general' };
|
|
40
40
|
}
|
|
41
41
|
export function writeLookupPresentation(storage, state) {
|
|
42
42
|
try {
|
|
@@ -51,22 +51,12 @@ export function writeLookupPresentation(storage, state) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
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', '
|
|
54
|
+
general: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
55
|
+
acquisition: Object.freeze(['overview', 'registry', 'relationships-history', 'web-evidence', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
56
|
+
brand: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'registry', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
57
|
+
incident: Object.freeze(['overview', 'web-evidence', 'relationships-history', 'advanced-evidence', 'registry', 'source-quality', 'case-response']),
|
|
58
|
+
owned: Object.freeze(['overview', 'registry', 'web-evidence', 'relationships-history', 'source-quality', 'case-response', 'advanced-evidence']),
|
|
59
59
|
});
|
|
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
60
|
export function prioritizeLookupSectionLinks(links, task) {
|
|
71
61
|
const order = PRIORITY[normalizeLookupTaskView(task)];
|
|
72
62
|
const rank = new Map(order.map((id, index) => [`#${id}`, index]));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD, computeRiskScore, explainRiskScore, formatScoreBreakdown, normalizeRiskModelVersion, riskTone, } from "../../../../lib/risk-scoring.mjs";
|
|
1
|
+
export { RISK_MODEL_VERSION, RISK_REVIEW_THRESHOLD, buildRiskScoreSensitivity, computeRiskScore, explainRiskScore, formatScoreBreakdown, normalizeRiskModelVersion, riskTone, } from "../../../../lib/risk-scoring.mjs";
|
|
2
2
|
export { OPPORTUNITY_MODEL_VERSION, OPPORTUNITY_REVIEW_THRESHOLD, computeOpportunityScore, explainOpportunityScore, normalizeOpportunityModelVersion, opportunityTone, } from "../../../../lib/opportunity-scoring.mjs";
|
|
3
3
|
export function fmtAge(value) {
|
|
4
4
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
@@ -214,8 +214,7 @@ export function projectScoreFactors(rawFactors) {
|
|
|
214
214
|
id: `${boundedId(factor?.label) || 'factor'}-${index}`,
|
|
215
215
|
label: boundedText(factor?.label, 56) || `Factor ${index + 1}`,
|
|
216
216
|
delta: boundedNumber(factor?.delta, -100, 100),
|
|
217
|
-
}))
|
|
218
|
-
.filter((factor) => factor.delta !== 0);
|
|
217
|
+
}));
|
|
219
218
|
const factors = candidates.slice(0, MAX_SCORE_FACTORS);
|
|
220
219
|
const maximum = Math.max(1, ...factors.map((factor) => Math.abs(factor.delta)));
|
|
221
220
|
const x = scaleLinear().domain([-maximum, maximum]).range([220, 860]).clamp(true);
|
|
@@ -231,7 +230,7 @@ export function projectScoreFactors(rawFactors) {
|
|
|
231
230
|
return {
|
|
232
231
|
...factor,
|
|
233
232
|
x: Math.min(zeroX, valueX),
|
|
234
|
-
width: Math.max(2, Math.abs(valueX - zeroX)),
|
|
233
|
+
width: factor.delta === 0 ? 0 : Math.max(2, Math.abs(valueX - zeroX)),
|
|
235
234
|
y: 24 + index * rowHeight,
|
|
236
235
|
};
|
|
237
236
|
}),
|
|
@@ -340,6 +340,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
|
|
|
340
340
|
}
|
|
341
341
|
throw new Error(`Expected workspace archive schema 1, 2, 3, 4, or ${WORKSPACE_ARCHIVE_VERSION}.`);
|
|
342
342
|
}
|
|
343
|
+
const sourceVersion = value.version;
|
|
343
344
|
const { bytes } = ensureArchiveBudget(value);
|
|
344
345
|
const manifest = record(value.manifest);
|
|
345
346
|
const sectionValues = record(value.sections);
|
|
@@ -394,6 +395,7 @@ export async function readWorkspaceArchive(raw, options = {}) {
|
|
|
394
395
|
return {
|
|
395
396
|
schema: WORKSPACE_ARCHIVE_SCHEMA,
|
|
396
397
|
version: WORKSPACE_ARCHIVE_VERSION,
|
|
398
|
+
sourceVersion,
|
|
397
399
|
generatedAt: timestamp(value.generatedAt),
|
|
398
400
|
bytes,
|
|
399
401
|
sections,
|
package/lib/abusech-auth.mjs
CHANGED
|
@@ -16,4 +16,4 @@ function abusechAuthKey(env = process.env) {
|
|
|
16
16
|
return normalizeAbusechAuthKey(source.ABUSECH_AUTH_KEY)
|
|
17
17
|
|| normalizeAbusechAuthKey(source.URLHAUS_AUTH_KEY);
|
|
18
18
|
}
|
|
19
|
-
export {
|
|
19
|
+
export { abusechAuthKey, };
|
package/lib/availability.mjs
CHANGED
|
@@ -237,7 +237,7 @@ async function fetchHomepage(domain, { fetcher = safeFetchDetailed } = {}) {
|
|
|
237
237
|
technologyHeaders: {
|
|
238
238
|
'cf-ray': res.headers.get('cf-ray') || '',
|
|
239
239
|
'x-drupal-cache': res.headers.get('x-drupal-cache') || '',
|
|
240
|
-
'x-
|
|
240
|
+
'x-served-by': res.headers.get('x-served-by') || '',
|
|
241
241
|
'x-powered-by': res.headers.get('x-powered-by') || '',
|
|
242
242
|
'x-shopify-stage': res.headers.get('x-shopify-stage') || '',
|
|
243
243
|
'x-sorting-hat-podid': res.headers.get('x-sorting-hat-podid') || '',
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// Shared strict primitives for internal evidence contracts. These helpers do
|
|
2
2
|
// not infer missing values and keep every string, object, enum, and counter
|
|
3
3
|
// bounded before a provider or connector result reaches its public model.
|
|
4
|
+
import { domainToASCII } from 'node:url';
|
|
4
5
|
export function isRecord(value) {
|
|
5
6
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
6
7
|
}
|
|
8
|
+
export function recordOrNull(value) {
|
|
9
|
+
return isRecord(value) ? value : null;
|
|
10
|
+
}
|
|
11
|
+
export function recordOrEmpty(value) {
|
|
12
|
+
return isRecord(value) ? value : {};
|
|
13
|
+
}
|
|
7
14
|
export function boundedString(value, maxLength) {
|
|
8
15
|
if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
|
|
9
16
|
return null;
|
|
@@ -59,3 +66,29 @@ export function boundedInteger(value, minimum, maximum, label) {
|
|
|
59
66
|
}
|
|
60
67
|
return value;
|
|
61
68
|
}
|
|
69
|
+
export function requireRecord(value, label) {
|
|
70
|
+
if (!isRecord(value))
|
|
71
|
+
throw new TypeError(`${label} must be an object.`);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
export function requireBoundedString(value, label, maxLength) {
|
|
75
|
+
const normalised = strictBoundedString(value, maxLength);
|
|
76
|
+
if (!normalised) {
|
|
77
|
+
throw new TypeError(`${label} must contain from 1 to ${maxLength} characters without control characters.`);
|
|
78
|
+
}
|
|
79
|
+
return normalised;
|
|
80
|
+
}
|
|
81
|
+
export function requireIsoTimestamp(value, label) {
|
|
82
|
+
const normalised = isoTimestamp(value);
|
|
83
|
+
if (!normalised)
|
|
84
|
+
throw new TypeError(`${label} must be a valid timestamp.`);
|
|
85
|
+
return normalised;
|
|
86
|
+
}
|
|
87
|
+
export function requireDomainName(value, label, noun = 'domain name') {
|
|
88
|
+
const supplied = requireBoundedString(value, label, 253).toLowerCase().replace(/\.$/u, '');
|
|
89
|
+
const ascii = domainToASCII(supplied);
|
|
90
|
+
if (!ascii || !ascii.includes('.') || ascii.length > 253 || ascii.split('.').some((part) => (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part)))) {
|
|
91
|
+
throw new TypeError(`${label} must be a valid ${noun}.`);
|
|
92
|
+
}
|
|
93
|
+
return ascii;
|
|
94
|
+
}
|