@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
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
// scripts. Upstream URLs, filenames, inline bodies, and catalogue expressions
|
|
5
5
|
// are treated as untrusted inputs and are not retained in the result.
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { Worker } from 'node:worker_threads';
|
|
7
8
|
import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
|
|
8
9
|
import { CISA_KEV_CATALOG } from "./generated/cisa-kev-catalog.mjs";
|
|
9
10
|
import { createObservation } from "./observation.mjs";
|
|
10
11
|
import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
12
|
+
// Catalogue expressions are reviewed third-party inputs. Keep every individual
|
|
13
|
+
// regular-expression evaluation and the aggregate inline-signature work small
|
|
14
|
+
// enough that a target-controlled script cannot monopolise the Node event loop.
|
|
15
|
+
// Full-content hashes still use the complete already-capped script body.
|
|
16
|
+
const MAX_INLINE_LIBRARY_SCAN_CHARS = 1_024;
|
|
17
|
+
const MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS = 4_096;
|
|
18
|
+
const MAX_INLINE_LIBRARY_SCAN_MS = 750;
|
|
19
|
+
const MAX_INLINE_LIBRARY_WORKER_BYTES = 64 * 1024;
|
|
11
20
|
const BROWSER_LIBRARY_PROFILE_VERSION = 2;
|
|
12
21
|
const MAX_LIBRARY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
|
|
13
22
|
const MAX_LIBRARY_FINDINGS = 16;
|
|
@@ -29,6 +38,68 @@ const SEVERITY_WEIGHT = Object.freeze({
|
|
|
29
38
|
});
|
|
30
39
|
const CATALOG_COMPONENTS = RETIRE_BROWSER_CATALOG.components;
|
|
31
40
|
const KNOWN_EXPLOITED_IDENTIFIERS = new Set(CISA_KEV_CATALOG.identifiers);
|
|
41
|
+
const INLINE_EXTRACTOR_CATALOGUE = Object.freeze(Object.entries(CATALOG_COMPONENTS).map(([component, value]) => {
|
|
42
|
+
const extractors = record(record(value).extractors);
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
component,
|
|
45
|
+
patterns: Object.freeze((Array.isArray(extractors.filecontent) ? extractors.filecontent : [])
|
|
46
|
+
.filter((pattern) => typeof pattern === 'string' && pattern.length > 0 && pattern.length <= 2_048)
|
|
47
|
+
.slice(0, 64)),
|
|
48
|
+
replacements: Object.freeze((Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [])
|
|
49
|
+
.filter((pattern) => typeof pattern === 'string' && pattern.length > 0 && pattern.length <= 2_048)
|
|
50
|
+
.slice(0, 64)),
|
|
51
|
+
});
|
|
52
|
+
}));
|
|
53
|
+
const INLINE_REGEX_WORKER_SOURCE = String.raw `
|
|
54
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
55
|
+
const versions = /^[0-9][0-9.a-z_-]{0,63}$/i;
|
|
56
|
+
parentPort.on('message', (job) => {
|
|
57
|
+
const control = new Int32Array(job.control);
|
|
58
|
+
const output = new Uint8Array(job.output);
|
|
59
|
+
const matches = [];
|
|
60
|
+
const add = (component, version) => {
|
|
61
|
+
const normalized = String(version).replace(/(?:\.|-)?min$/i, '').slice(0, 64);
|
|
62
|
+
if (versions.test(normalized) && matches.length < 64) matches.push([component, normalized]);
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
for (const entry of workerData.catalogue) {
|
|
66
|
+
for (const pattern of entry.patterns) {
|
|
67
|
+
let expression;
|
|
68
|
+
try { expression = new RegExp(pattern, 'g'); } catch { continue; }
|
|
69
|
+
let match;
|
|
70
|
+
let count = 0;
|
|
71
|
+
while ((match = expression.exec(job.value)) && count < 4) {
|
|
72
|
+
if (typeof match[1] === 'string') add(entry.component, match[1]);
|
|
73
|
+
if (match[0] === '') expression.lastIndex += 1;
|
|
74
|
+
count += 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const descriptorValue of entry.replacements) {
|
|
78
|
+
const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(descriptorValue);
|
|
79
|
+
if (!descriptor || !descriptor[1]) continue;
|
|
80
|
+
let expression;
|
|
81
|
+
try { expression = new RegExp(descriptor[1], 'g'); } catch { continue; }
|
|
82
|
+
let match;
|
|
83
|
+
let count = 0;
|
|
84
|
+
while ((match = expression.exec(job.value)) && count < 4) {
|
|
85
|
+
let replaced = match[0];
|
|
86
|
+
try { replaced = match[0].replace(new RegExp(descriptor[1]), descriptor[2]); } catch { break; }
|
|
87
|
+
add(entry.component, replaced);
|
|
88
|
+
if (match[0] === '') expression.lastIndex += 1;
|
|
89
|
+
count += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const encoded = Buffer.from(JSON.stringify(matches));
|
|
94
|
+
if (encoded.length > output.length) Atomics.store(control, 0, -2);
|
|
95
|
+
else { output.set(encoded); Atomics.store(control, 0, encoded.length); }
|
|
96
|
+
} catch {
|
|
97
|
+
Atomics.store(control, 0, -1);
|
|
98
|
+
}
|
|
99
|
+
Atomics.notify(control, 0);
|
|
100
|
+
});
|
|
101
|
+
`;
|
|
102
|
+
let inlineRegexWorker = null;
|
|
32
103
|
function record(value) {
|
|
33
104
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
34
105
|
? value
|
|
@@ -106,32 +177,6 @@ function matchPattern(pattern, value) {
|
|
|
106
177
|
}
|
|
107
178
|
return matches;
|
|
108
179
|
}
|
|
109
|
-
function matchReplacementPattern(pattern, value) {
|
|
110
|
-
if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > 2_048)
|
|
111
|
-
return [];
|
|
112
|
-
const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(pattern);
|
|
113
|
-
const expressionSource = descriptor?.[1];
|
|
114
|
-
const replacement = descriptor?.[2];
|
|
115
|
-
if (!expressionSource || replacement === undefined)
|
|
116
|
-
return [];
|
|
117
|
-
let expression;
|
|
118
|
-
try {
|
|
119
|
-
expression = new RegExp(expressionSource, 'g');
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
return [];
|
|
123
|
-
}
|
|
124
|
-
const matches = [];
|
|
125
|
-
let match;
|
|
126
|
-
while ((match = expression.exec(value)) && matches.length < MAX_MATCHES_PER_PATTERN) {
|
|
127
|
-
const replaced = match[0].replace(new RegExp(expressionSource), replacement).replace(/(?:\.|-)?min$/i, '');
|
|
128
|
-
if (VERSION_RE.test(replaced))
|
|
129
|
-
matches.push(replaced);
|
|
130
|
-
if (match[0] === '')
|
|
131
|
-
expression.lastIndex += 1;
|
|
132
|
-
}
|
|
133
|
-
return matches;
|
|
134
|
-
}
|
|
135
180
|
function addDetected(detected, component, version, method) {
|
|
136
181
|
const normalizedVersion = version.replace(/(?:\.|-)?min$/i, '').slice(0, MAX_VERSION_LENGTH);
|
|
137
182
|
if (!COMPONENT_RE.test(component) || !VERSION_RE.test(normalizedVersion))
|
|
@@ -174,24 +219,87 @@ function scanFilename(detected, reference) {
|
|
|
174
219
|
}
|
|
175
220
|
}
|
|
176
221
|
}
|
|
177
|
-
function
|
|
222
|
+
function scanInlineSignatures(detected, value) {
|
|
223
|
+
if (!value)
|
|
224
|
+
return { timedOut: false, unavailable: false };
|
|
225
|
+
const controlBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
|
226
|
+
const outputBuffer = new SharedArrayBuffer(MAX_INLINE_LIBRARY_WORKER_BYTES);
|
|
227
|
+
const control = new Int32Array(controlBuffer);
|
|
228
|
+
try {
|
|
229
|
+
if (!inlineRegexWorker) {
|
|
230
|
+
const createdWorker = new Worker(INLINE_REGEX_WORKER_SOURCE, {
|
|
231
|
+
eval: true,
|
|
232
|
+
workerData: { catalogue: INLINE_EXTRACTOR_CATALOGUE },
|
|
233
|
+
});
|
|
234
|
+
inlineRegexWorker = createdWorker;
|
|
235
|
+
createdWorker.unref();
|
|
236
|
+
createdWorker.once('exit', () => {
|
|
237
|
+
if (inlineRegexWorker === createdWorker)
|
|
238
|
+
inlineRegexWorker = null;
|
|
239
|
+
});
|
|
240
|
+
createdWorker.once('error', () => {
|
|
241
|
+
if (inlineRegexWorker === createdWorker)
|
|
242
|
+
inlineRegexWorker = null;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
inlineRegexWorker.postMessage({ value, control: controlBuffer, output: outputBuffer });
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
void inlineRegexWorker?.terminate().catch(() => { });
|
|
249
|
+
inlineRegexWorker = null;
|
|
250
|
+
return { timedOut: false, unavailable: true };
|
|
251
|
+
}
|
|
252
|
+
const wait = Atomics.wait(control, 0, 0, MAX_INLINE_LIBRARY_SCAN_MS);
|
|
253
|
+
const length = Atomics.load(control, 0);
|
|
254
|
+
if (wait === 'timed-out') {
|
|
255
|
+
void inlineRegexWorker?.terminate().catch(() => { });
|
|
256
|
+
inlineRegexWorker = null;
|
|
257
|
+
return { timedOut: true, unavailable: false };
|
|
258
|
+
}
|
|
259
|
+
if (length < 1 || length > MAX_INLINE_LIBRARY_WORKER_BYTES) {
|
|
260
|
+
return { timedOut: false, unavailable: true };
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(Buffer.from(new Uint8Array(outputBuffer, 0, length)).toString('utf8'));
|
|
264
|
+
if (!Array.isArray(parsed))
|
|
265
|
+
return { timedOut: false, unavailable: true };
|
|
266
|
+
for (const item of parsed.slice(0, MAX_LIBRARY_FINDINGS * 4)) {
|
|
267
|
+
if (!Array.isArray(item) || item.length !== 2)
|
|
268
|
+
continue;
|
|
269
|
+
const [component, version] = item;
|
|
270
|
+
if (typeof component === 'string' && typeof version === 'string') {
|
|
271
|
+
addDetected(detected, component, version, 'inline signature');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { timedOut: false, unavailable: false };
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return { timedOut: false, unavailable: true };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function scanInlineContent(detected, content, maximumSignatureCharacters) {
|
|
178
281
|
const normalized = content.replace(/\r\n?|\n/g, '\n');
|
|
179
282
|
const digest = createHash('sha1').update(normalized).digest('hex');
|
|
180
|
-
|
|
283
|
+
const retainedCharacters = Math.max(0, Math.min(MAX_INLINE_LIBRARY_SCAN_CHARS, maximumSignatureCharacters, normalized.length));
|
|
284
|
+
const headCharacters = Math.ceil(retainedCharacters / 2);
|
|
285
|
+
const tailCharacters = retainedCharacters - headCharacters;
|
|
286
|
+
const signatureContent = retainedCharacters === 0
|
|
287
|
+
? ''
|
|
288
|
+
: normalized.length <= retainedCharacters
|
|
289
|
+
? normalized
|
|
290
|
+
: `${normalized.slice(0, headCharacters)}${tailCharacters ? normalized.slice(-tailCharacters) : ''}`;
|
|
181
291
|
for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
|
|
182
|
-
const
|
|
183
|
-
const extractors = record(component.extractors);
|
|
184
|
-
const replacementPatterns = Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [];
|
|
185
|
-
for (const pattern of replacementPatterns.slice(0, 64)) {
|
|
186
|
-
for (const version of matchReplacementPattern(pattern, normalized)) {
|
|
187
|
-
addDetected(detected, componentName, version, 'inline signature');
|
|
188
|
-
}
|
|
189
|
-
}
|
|
292
|
+
const extractors = record(record(rawComponent).extractors);
|
|
190
293
|
const hashes = record(extractors.hashes);
|
|
191
294
|
const version = hashes[digest];
|
|
192
295
|
if (typeof version === 'string')
|
|
193
296
|
addDetected(detected, componentName, version, 'inline hash');
|
|
194
297
|
}
|
|
298
|
+
return {
|
|
299
|
+
charactersExamined: retainedCharacters,
|
|
300
|
+
truncated: retainedCharacters < normalized.length,
|
|
301
|
+
signatureContent,
|
|
302
|
+
};
|
|
195
303
|
}
|
|
196
304
|
function severity(value) {
|
|
197
305
|
return typeof value === 'string' && Object.hasOwn(SEVERITY_WEIGHT, value)
|
|
@@ -243,6 +351,9 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
243
351
|
const detected = new Map();
|
|
244
352
|
let referencesExamined = 0;
|
|
245
353
|
let inlineScriptsExamined = 0;
|
|
354
|
+
let inlineSignatureCharactersExamined = 0;
|
|
355
|
+
let inlineSignatureLimitReached = false;
|
|
356
|
+
const inlineSignatureSamples = [];
|
|
246
357
|
for (const script of htmlAnalysis.scripts) {
|
|
247
358
|
if (script.reference) {
|
|
248
359
|
referencesExamined += 1;
|
|
@@ -251,9 +362,16 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
251
362
|
}
|
|
252
363
|
else if (script.inlineContent && script.mediaType !== 'application/ld+json') {
|
|
253
364
|
inlineScriptsExamined += 1;
|
|
254
|
-
|
|
365
|
+
const remaining = Math.max(0, MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS - inlineSignatureCharactersExamined);
|
|
366
|
+
const signatureScan = scanInlineContent(detected, script.inlineContent, remaining);
|
|
367
|
+
inlineSignatureCharactersExamined += signatureScan.charactersExamined;
|
|
368
|
+
inlineSignatureLimitReached ||= signatureScan.truncated;
|
|
369
|
+
if (signatureScan.signatureContent)
|
|
370
|
+
inlineSignatureSamples.push(signatureScan.signatureContent);
|
|
255
371
|
}
|
|
256
372
|
}
|
|
373
|
+
const inlineSignatureResult = scanInlineSignatures(detected, inlineSignatureSamples.join('\u0000'));
|
|
374
|
+
const inlineSignatureUnavailable = inlineSignatureResult.timedOut || inlineSignatureResult.unavailable;
|
|
257
375
|
const allFindings = [...detected.values()]
|
|
258
376
|
.map(findingFromDetection)
|
|
259
377
|
.sort((left, right) => ((right.highestSeverity ? SEVERITY_WEIGHT[right.highestSeverity] : -1)
|
|
@@ -267,6 +385,8 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
267
385
|
|| htmlAnalysis.tagLimitReached
|
|
268
386
|
|| htmlAnalysis.scriptLimitReached
|
|
269
387
|
|| htmlAnalysis.inlineLimitReached
|
|
388
|
+
|| inlineSignatureLimitReached
|
|
389
|
+
|| inlineSignatureUnavailable
|
|
270
390
|
|| findingLimitReached;
|
|
271
391
|
const limitations = [
|
|
272
392
|
'Library versions are inferred from passive static signatures and may be absent, transformed, or misleading.',
|
|
@@ -284,6 +404,12 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
284
404
|
limitations.push(`Only the first ${MAX_SCRIPT_ELEMENTS} script elements were evaluated.`);
|
|
285
405
|
if (htmlAnalysis.inlineLimitReached)
|
|
286
406
|
limitations.push(`Inline script evaluation reached its ${MAX_INLINE_SCRIPT_TOTAL_CHARS}-character cumulative boundary.`);
|
|
407
|
+
if (inlineSignatureLimitReached)
|
|
408
|
+
limitations.push(`Passive library signature matching evaluated a deterministic ${MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS}-character cumulative sample across inline scripts; full-content hashes still used the complete bounded script content.`);
|
|
409
|
+
if (inlineSignatureResult.timedOut)
|
|
410
|
+
limitations.push(`Passive library signature matching exceeded its ${MAX_INLINE_LIBRARY_SCAN_MS} ms isolated-worker deadline; hash evidence was still evaluated.`);
|
|
411
|
+
if (inlineSignatureResult.unavailable)
|
|
412
|
+
limitations.push('Passive library signature matching was unavailable in its isolated worker; hash evidence was still evaluated.');
|
|
287
413
|
if (findingLimitReached)
|
|
288
414
|
limitations.push(`Only the first ${MAX_LIBRARY_FINDINGS} library findings were retained.`);
|
|
289
415
|
return {
|
|
@@ -311,6 +437,9 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
311
437
|
referencesExamined,
|
|
312
438
|
inlineScriptsExamined,
|
|
313
439
|
inlineCharactersExamined: htmlAnalysis.inlineCharactersExamined,
|
|
440
|
+
inlineSignatureCharactersExamined,
|
|
441
|
+
inlineSignatureTimedOut: inlineSignatureResult.timedOut,
|
|
442
|
+
inlineSignatureUnavailable: inlineSignatureResult.unavailable,
|
|
314
443
|
catalogComponents: Object.keys(CATALOG_COMPONENTS).length,
|
|
315
444
|
findings: allFindings.length,
|
|
316
445
|
advisoryMatches: allFindings.reduce((sum, finding) => sum + finding.advisoryCount, 0),
|
|
@@ -320,4 +449,4 @@ function analyzeBrowserLibraries(input = {}) {
|
|
|
320
449
|
findings: allFindings.slice(0, MAX_LIBRARY_FINDINGS),
|
|
321
450
|
};
|
|
322
451
|
}
|
|
323
|
-
export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
|
|
452
|
+
export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_INLINE_LIBRARY_SCAN_CHARS, MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
|
+
import { normaliseRdata } from "./zone-intent-review.mjs";
|
|
3
|
+
export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
|
|
4
|
+
export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
|
|
5
|
+
export const DNS_CONVERGENCE_REVIEW_VERSION = 1;
|
|
6
|
+
export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
|
|
7
|
+
export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
|
|
8
|
+
const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
|
|
9
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
|
|
10
|
+
const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
|
|
11
|
+
const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
|
|
12
|
+
const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
|
|
13
|
+
function type(value, label) {
|
|
14
|
+
const candidate = requireBoundedString(value, label, 16).toUpperCase();
|
|
15
|
+
if (!RECORD_TYPES.has(candidate))
|
|
16
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
17
|
+
return candidate;
|
|
18
|
+
}
|
|
19
|
+
function owner(value, apex, label) {
|
|
20
|
+
const candidate = requireBoundedString(value, label, 253).toLowerCase().replace(/\.$/u, '');
|
|
21
|
+
if (candidate === '@')
|
|
22
|
+
return apex;
|
|
23
|
+
if (candidate.length > 253 || candidate.split('.').some((part) => !/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
|
|
24
|
+
throw new TypeError(`${label} must be a valid DNS owner name.`);
|
|
25
|
+
}
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
function state(value, label) {
|
|
29
|
+
if (value !== 'observed' && value !== 'partial' && value !== 'unavailable')
|
|
30
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function ttl(value, label) {
|
|
34
|
+
if (value === null || value === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > 0x7fff_ffff)
|
|
37
|
+
throw new TypeError(`${label} is outside its supported range.`);
|
|
38
|
+
return Number(value);
|
|
39
|
+
}
|
|
40
|
+
function normaliseValue(recordType, value) {
|
|
41
|
+
if (recordType !== 'SOA')
|
|
42
|
+
return normaliseRdata(recordType, value, null);
|
|
43
|
+
const fields = value.trim().split(/\s+/u);
|
|
44
|
+
if (fields.length >= 2) {
|
|
45
|
+
fields[0] = fields[0]?.toLowerCase().replace(/\.$/u, '') ?? '';
|
|
46
|
+
fields[1] = fields[1]?.toLowerCase().replace(/\.$/u, '') ?? '';
|
|
47
|
+
}
|
|
48
|
+
return { value: fields.join(' '), valueTreatment: 'normalised' };
|
|
49
|
+
}
|
|
50
|
+
function record(value, apex, label) {
|
|
51
|
+
const input = requireRecord(value, label);
|
|
52
|
+
exactKeys(input, RECORD_KEYS, label);
|
|
53
|
+
const recordType = type(input.type, `${label}.type`);
|
|
54
|
+
const normalised = normaliseValue(recordType, requireBoundedString(input.value, `${label}.value`, 16_384));
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
owner: owner(input.owner, apex, `${label}.owner`),
|
|
57
|
+
type: recordType,
|
|
58
|
+
value: normalised.value,
|
|
59
|
+
valueTreatment: normalised.valueTreatment,
|
|
60
|
+
ttl: ttl(input.ttl, `${label}.ttl`),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function expectedRows(value, apex) {
|
|
64
|
+
if (value === null || value === undefined)
|
|
65
|
+
return Object.freeze([]);
|
|
66
|
+
if (!Array.isArray(value) || value.length > 100)
|
|
67
|
+
throw new TypeError('expected must contain no more than 100 rows.');
|
|
68
|
+
const keys = new Set();
|
|
69
|
+
return Object.freeze(value.map((raw, index) => {
|
|
70
|
+
const input = requireRecord(raw, `expected[${index}]`);
|
|
71
|
+
exactKeys(input, EXPECTED_KEYS, `expected[${index}]`);
|
|
72
|
+
const recordType = type(input.type, `expected[${index}].type`);
|
|
73
|
+
const recordOwner = owner(input.owner, apex, `expected[${index}].owner`);
|
|
74
|
+
const key = `${recordOwner}\u0000${recordType}`;
|
|
75
|
+
if (keys.has(key))
|
|
76
|
+
throw new TypeError(`expected[${index}] duplicates an owner and type.`);
|
|
77
|
+
keys.add(key);
|
|
78
|
+
if (!Array.isArray(input.values) || input.values.length > 100)
|
|
79
|
+
throw new TypeError(`expected[${index}].values must contain no more than 100 entries.`);
|
|
80
|
+
const values = [...new Set(input.values.map((entry, valueIndex) => normaliseValue(recordType, requireBoundedString(entry, `expected[${index}].values[${valueIndex}]`, 16_384)).value))].sort();
|
|
81
|
+
return Object.freeze({ owner: recordOwner, type: recordType, values: Object.freeze(values) });
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
85
|
+
const input = requireRecord(inputRaw, 'DNS convergence input');
|
|
86
|
+
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version !== 1) {
|
|
87
|
+
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
|
|
88
|
+
}
|
|
89
|
+
exactKeys(input, ROOT_KEYS, 'DNS convergence input');
|
|
90
|
+
const apex = requireDomainName(input.domain, 'domain');
|
|
91
|
+
const expected = expectedRows(input.expected, apex);
|
|
92
|
+
if (!Array.isArray(input.snapshots) || input.snapshots.length < 2 || input.snapshots.length > MAX_DNS_CONVERGENCE_SNAPSHOTS) {
|
|
93
|
+
throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
|
|
94
|
+
}
|
|
95
|
+
let recordCount = 0;
|
|
96
|
+
const snapshots = input.snapshots.map((raw, index) => {
|
|
97
|
+
const item = requireRecord(raw, `snapshots[${index}]`);
|
|
98
|
+
exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
99
|
+
if (!Array.isArray(item.records))
|
|
100
|
+
throw new TypeError(`snapshots[${index}].records must be an array.`);
|
|
101
|
+
recordCount += item.records.length;
|
|
102
|
+
if (recordCount > MAX_DNS_CONVERGENCE_RECORDS)
|
|
103
|
+
throw new TypeError(`DNS convergence input is limited to ${MAX_DNS_CONVERGENCE_RECORDS} records.`);
|
|
104
|
+
const observationState = state(item.state, `snapshots[${index}].state`);
|
|
105
|
+
if (observationState === 'unavailable' && item.records.length)
|
|
106
|
+
throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
|
|
109
|
+
source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
|
|
110
|
+
observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
|
|
111
|
+
state: observationState,
|
|
112
|
+
records: Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`))),
|
|
113
|
+
});
|
|
114
|
+
}).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
|
|
115
|
+
const observers = [...new Set(snapshots.map((item) => item.observer))];
|
|
116
|
+
if (observers.length < 2)
|
|
117
|
+
throw new TypeError('DNS convergence review requires at least two distinct observer labels.');
|
|
118
|
+
const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
|
|
119
|
+
const keys = [...new Set([
|
|
120
|
+
...expected.map((item) => `${item.owner}\u0000${item.type}`),
|
|
121
|
+
...snapshots.flatMap((item) => item.records.map((entry) => `${entry.owner}\u0000${entry.type}`)),
|
|
122
|
+
])].sort();
|
|
123
|
+
const rows = keys.map((key) => {
|
|
124
|
+
const [recordOwner = '', recordType = ''] = key.split('\u0000');
|
|
125
|
+
const expectedRow = expected.find((item) => item.owner === recordOwner && item.type === recordType);
|
|
126
|
+
const observations = latestByObserver.map((snapshot) => {
|
|
127
|
+
const matching = snapshot.records.filter((entry) => entry.owner === recordOwner && entry.type === recordType);
|
|
128
|
+
const values = [...new Set(matching.map((entry) => entry.value))].sort();
|
|
129
|
+
const ttls = matching.flatMap((entry) => entry.ttl === null ? [] : [entry.ttl]);
|
|
130
|
+
const cacheUntil = ttls.length
|
|
131
|
+
? new Date(Date.parse(snapshot.observedAt) + Math.max(...ttls) * 1_000).toISOString()
|
|
132
|
+
: null;
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
observer: snapshot.observer,
|
|
135
|
+
source: snapshot.source,
|
|
136
|
+
observedAt: snapshot.observedAt,
|
|
137
|
+
state: snapshot.state,
|
|
138
|
+
values: Object.freeze(values),
|
|
139
|
+
cacheUntil,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
const complete = observations.filter((item) => item.state === 'observed');
|
|
143
|
+
const incomplete = observations.some((item) => item.state !== 'observed');
|
|
144
|
+
const signatures = new Set(complete.map((item) => JSON.stringify(item.values)));
|
|
145
|
+
const aligned = complete.length >= 2 && signatures.size === 1;
|
|
146
|
+
const matchesExpected = expectedRow
|
|
147
|
+
? aligned && JSON.stringify(complete[0]?.values ?? []) === JSON.stringify(expectedRow.values)
|
|
148
|
+
: null;
|
|
149
|
+
const rowState = incomplete ? 'incomplete'
|
|
150
|
+
: complete.length < 2 ? 'insufficient'
|
|
151
|
+
: !aligned ? 'divergent'
|
|
152
|
+
: matchesExpected === false ? 'unexpected'
|
|
153
|
+
: 'converged';
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
owner: recordOwner,
|
|
156
|
+
type: recordType,
|
|
157
|
+
state: rowState,
|
|
158
|
+
expectedValues: expectedRow?.values ?? null,
|
|
159
|
+
observations: Object.freeze(observations),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
const reasons = Object.freeze([
|
|
163
|
+
...rows.filter((row) => row.state !== 'converged').map((row) => `${row.owner} ${row.type} is ${row.state}.`),
|
|
164
|
+
...latestByObserver.filter((item) => item.state !== 'observed').map((item) => `${item.observer} latest evidence is ${item.state}.`),
|
|
165
|
+
].slice(0, 200));
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
schema: DNS_CONVERGENCE_REVIEW_SCHEMA,
|
|
168
|
+
version: DNS_CONVERGENCE_REVIEW_VERSION,
|
|
169
|
+
generatedAt: requireIsoTimestamp(generatedAtValue, 'generatedAt'),
|
|
170
|
+
domain: apex,
|
|
171
|
+
state: reasons.length ? 'review' : 'converged',
|
|
172
|
+
observers: Object.freeze(observers),
|
|
173
|
+
snapshots: Object.freeze(snapshots),
|
|
174
|
+
rows: Object.freeze(rows),
|
|
175
|
+
gate: Object.freeze({ pass: reasons.length === 0, reasons }),
|
|
176
|
+
limitations: Object.freeze([
|
|
177
|
+
'This local workbench compares only supplied observations and makes no DNS request.',
|
|
178
|
+
'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
|
|
179
|
+
'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
|
|
180
|
+
'Partial and unavailable snapshots remain incomplete and are never interpreted as record absence.',
|
|
181
|
+
]),
|
|
182
|
+
});
|
|
183
|
+
}
|