@slicedearth/whoisleuth-cli 1.43.0 → 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 +8 -4
- package/bin/whoisleuth.mjs +20 -9
- package/cli/bulk-checkpoint.mjs +65 -28
- package/cli/bulk-command-runner.mjs +15 -2
- package/cli/bulk.mjs +18 -7
- package/cli/completion.mjs +34 -3
- package/cli/config-profile.mjs +15 -12
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +14 -1
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/errors.mjs +7 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/runner.mjs +20 -9
- package/docs/cli-reference.md +26 -7
- package/docs/cli.md +1 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
- 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/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +3 -7
- 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/lookup-cache.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/safe-fetch.mjs +37 -32
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/website-technology.mjs +2 -6
- 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, };
|
package/lib/lookup-cache.mjs
CHANGED
|
@@ -42,7 +42,7 @@ function approxByteSize(value) {
|
|
|
42
42
|
return Buffer.byteLength(JSON.stringify(value));
|
|
43
43
|
}
|
|
44
44
|
catch {
|
|
45
|
-
return
|
|
45
|
+
return null;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
function deleteEntry(key) {
|
|
@@ -76,6 +76,10 @@ function setCached(key, value) {
|
|
|
76
76
|
sweepExpiredEntries(Date.now());
|
|
77
77
|
deleteEntry(key); // avoid double-counting bytes if this key is already cached
|
|
78
78
|
const size = approxByteSize(value);
|
|
79
|
+
// A value whose serialized size cannot be measured cannot participate in a
|
|
80
|
+
// byte-bounded cache. Return it to the current caller without retaining it.
|
|
81
|
+
if (size === null)
|
|
82
|
+
return;
|
|
79
83
|
store.set(key, { value, expiresAt: Date.now() + TTL_MS, size });
|
|
80
84
|
totalBytes += size;
|
|
81
85
|
while (store.size > 0 && (store.size > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES)) {
|
package/lib/rdap-transport.mjs
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// lib/rdap.mts.
|
|
4
4
|
import { readTextCapped, safeFetch, safeFetchDetailed } from "./safe-fetch.mjs";
|
|
5
5
|
const MAX_RDAP_BYTES = 2000000;
|
|
6
|
-
async function fetchRdapWithTimeout(url, options, timeoutMs) {
|
|
6
|
+
async function fetchRdapWithTimeout(url, options, timeoutMs, dependencies = {}) {
|
|
7
7
|
const controller = new AbortController();
|
|
8
8
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
9
9
|
try {
|
|
10
|
-
const response = await safeFetch(url, { ...options, signal: controller.signal });
|
|
11
|
-
const { text, truncated } = await readTextCapped(response, MAX_RDAP_BYTES);
|
|
10
|
+
const response = await (dependencies.fetch ?? safeFetch)(url, { ...options, signal: controller.signal });
|
|
11
|
+
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(response, MAX_RDAP_BYTES);
|
|
12
12
|
if (truncated)
|
|
13
13
|
throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
|
|
14
14
|
return { status: response.status, ok: response.ok, text };
|
|
@@ -17,12 +17,12 @@ async function fetchRdapWithTimeout(url, options, timeoutMs) {
|
|
|
17
17
|
clearTimeout(timeout);
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
async function fetchRdapDetailedWithTimeout(url, options, timeoutMs) {
|
|
20
|
+
async function fetchRdapDetailedWithTimeout(url, options, timeoutMs, dependencies = {}) {
|
|
21
21
|
const controller = new AbortController();
|
|
22
22
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
23
23
|
try {
|
|
24
|
-
const result = await safeFetchDetailed(url, { ...options, signal: controller.signal });
|
|
25
|
-
const { text, truncated } = await readTextCapped(result.response, MAX_RDAP_BYTES);
|
|
24
|
+
const result = await (dependencies.fetchDetailed ?? safeFetchDetailed)(url, { ...options, signal: controller.signal });
|
|
25
|
+
const { text, truncated } = await (dependencies.readText ?? readTextCapped)(result.response, MAX_RDAP_BYTES);
|
|
26
26
|
if (truncated)
|
|
27
27
|
throw new Error(`Response from ${url} exceeded ${MAX_RDAP_BYTES} bytes`);
|
|
28
28
|
return {
|
package/lib/safe-fetch.mjs
CHANGED
|
@@ -128,21 +128,26 @@ function isPrivateIpv6(ip) {
|
|
|
128
128
|
return true; // ::1 (loopback)
|
|
129
129
|
if (g0.startsWith('ff'))
|
|
130
130
|
return true; // multicast ff00::/8
|
|
131
|
-
// IPv4-mapped
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
if ([g0, g1, g2, g3, g4].every((g) => g === '0000') && (g5 === 'ffff' || g5 === '0000')) {
|
|
131
|
+
// IPv4-mapped addresses put the embedded address in the last two groups.
|
|
132
|
+
// Accept only a mapped public IPv4 target. The deprecated IPv4-compatible
|
|
133
|
+
// form is rejected below rather than treated as a routable IPv6 address.
|
|
134
|
+
if ([g0, g1, g2, g3, g4].every((g) => g === '0000') && g5 === 'ffff') {
|
|
136
135
|
const embedded = groupsToIpv4(g6, g7);
|
|
137
136
|
if (embedded && isPrivateIpv4(embedded))
|
|
138
137
|
return true;
|
|
138
|
+
if (embedded)
|
|
139
|
+
return false;
|
|
139
140
|
}
|
|
141
|
+
if ([g0, g1, g2, g3, g4, g5].every((g) => g === '0000'))
|
|
142
|
+
return true;
|
|
140
143
|
// NAT64 well-known prefix (64:ff9b::/96) - same tail embedding, different
|
|
141
144
|
// prefix; some IPv6-only networks synthesize these to reach IPv4 hosts.
|
|
142
145
|
if (g0 === '0064' && g1 === 'ff9b' && [g2, g3, g4, g5].every((g) => g === '0000')) {
|
|
143
146
|
const embedded = groupsToIpv4(g6, g7);
|
|
144
147
|
if (embedded && isPrivateIpv4(embedded))
|
|
145
148
|
return true;
|
|
149
|
+
if (embedded)
|
|
150
|
+
return false;
|
|
146
151
|
}
|
|
147
152
|
// RFC 8215 local-use NAT64 prefix (64:ff9b:1::/48). Unlike the well-known
|
|
148
153
|
// /96 above, operators may choose how IPv4 bits are embedded within this
|
|
@@ -150,21 +155,13 @@ function isPrivateIpv6(ip) {
|
|
|
150
155
|
// rather than attempting to decode one layout and leaving others open.
|
|
151
156
|
if (g0 === '0064' && g1 === 'ff9b' && g2 === '0001')
|
|
152
157
|
return true;
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// obfuscated client IPv4 address. Decode both so a transition address
|
|
161
|
-
// cannot smuggle a private/loopback target past the IPv4 guard.
|
|
162
|
-
if (g0 === '2001' && g1 === '0000') {
|
|
163
|
-
const server = groupsToIpv4(g2, g3);
|
|
164
|
-
const client = groupsToIpv4(((~parseInt(g6, 16)) & 0xffff).toString(16), ((~parseInt(g7, 16)) & 0xffff).toString(16));
|
|
165
|
-
if ((server && isPrivateIpv4(server)) || (client && isPrivateIpv4(client)))
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
158
|
+
// Deprecated transition mechanisms are not direct global-unicast targets.
|
|
159
|
+
// Reject their entire ranges instead of attempting to classify embedded
|
|
160
|
+
// addresses whose relays and routing behaviour are outside this boundary.
|
|
161
|
+
if (g0 === '2002')
|
|
162
|
+
return true; // 6to4 2002::/16
|
|
163
|
+
if (g0 === '2001' && g1 === '0000')
|
|
164
|
+
return true; // Teredo 2001::/32
|
|
168
165
|
if (/^fe[89ab][0-9a-f]$/.test(g0))
|
|
169
166
|
return true; // link-local fe80::/10
|
|
170
167
|
if (/^fe[cdef][0-9a-f]$/.test(g0))
|
|
@@ -175,11 +172,20 @@ function isPrivateIpv6(ip) {
|
|
|
175
172
|
return true; // discard-only 100::/64
|
|
176
173
|
if (g0 === '2001' && g1 === '0db8')
|
|
177
174
|
return true; // documentation 2001:db8::/32
|
|
175
|
+
if (g0 === '3fff' && parseInt(g1, 16) <= 0x0fff)
|
|
176
|
+
return true; // documentation 3fff::/20
|
|
178
177
|
if (g0 === '2001' && g1 === '0002' && g2 === '0000')
|
|
179
178
|
return true; // benchmarking 2001:2::/48
|
|
180
179
|
if (g0 === '2001' && /^00[1-2][0-9a-f]$/.test(g1))
|
|
181
180
|
return true; // ORCHID/ORCHIDv2
|
|
182
|
-
|
|
181
|
+
// Ordinary publicly routed IPv6 unicast addresses are allocated from
|
|
182
|
+
// 2000::/3. Treat everything outside that allocation as reserved unless it
|
|
183
|
+
// matched one of the explicitly supported public transition forms above.
|
|
184
|
+
// An allow-list boundary is intentional here: newly written or unassigned
|
|
185
|
+
// address space must not silently become an outbound SSRF target merely
|
|
186
|
+
// because it was absent from this file's special-purpose exclusions.
|
|
187
|
+
const firstGroup = parseInt(g0, 16);
|
|
188
|
+
return !Number.isInteger(firstGroup) || firstGroup < 0x2000 || firstGroup > 0x3fff;
|
|
183
189
|
}
|
|
184
190
|
function isPrivateAddress(ip) {
|
|
185
191
|
const version = net.isIP(ip);
|
|
@@ -373,13 +379,12 @@ async function readTextCapped(res, maxBytes, options = {}) {
|
|
|
373
379
|
bytesRead,
|
|
374
380
|
...(hasher ? { sha256: hasher.digest('hex') } : {}),
|
|
375
381
|
});
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
hasher?.update(captured);
|
|
381
|
-
return result(new TextDecoder().decode(captured), full.length > maxBytes, captured.length);
|
|
382
|
+
if (res.body === null)
|
|
383
|
+
return result('', false, 0);
|
|
384
|
+
if (typeof res.body?.getReader !== 'function') {
|
|
385
|
+
throw new Error('Response body does not expose a bounded readable stream');
|
|
382
386
|
}
|
|
387
|
+
const reader = res.body.getReader();
|
|
383
388
|
const decoder = new TextDecoder();
|
|
384
389
|
let text = '';
|
|
385
390
|
let received = 0;
|
|
@@ -413,12 +418,12 @@ async function readTextCapped(res, maxBytes, options = {}) {
|
|
|
413
418
|
// text (a favicon image) - collects raw bytes into a Buffer instead of
|
|
414
419
|
// decoding as UTF-8, which would corrupt non-text bytes.
|
|
415
420
|
async function readBytesCapped(res, maxBytes) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
return { bytes, truncated: buf.length > maxBytes, bytesRead: bytes.length };
|
|
421
|
+
if (res.body === null)
|
|
422
|
+
return { bytes: Buffer.alloc(0), truncated: false, bytesRead: 0 };
|
|
423
|
+
if (typeof res.body?.getReader !== 'function') {
|
|
424
|
+
throw new Error('Response body does not expose a bounded readable stream');
|
|
421
425
|
}
|
|
426
|
+
const reader = res.body.getReader();
|
|
422
427
|
const chunks = [];
|
|
423
428
|
let received = 0;
|
|
424
429
|
let truncated = false;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// output budgets before producing separately attributed normalized evidence.
|
|
6
6
|
import { createObservation } from "./observation.mjs";
|
|
7
7
|
import { assertCuratedConnectorDefinition, assertThreatIntelligenceProvider, } from "./threat-intelligence-definition-registry.mjs";
|
|
8
|
-
import { boundedHttpsUrl,
|
|
8
|
+
import { boundedHttpsUrl, boundedString, isRecord, isoTimestamp, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
|
|
9
9
|
import { CURATED_CONNECTOR_ENTITY_TYPES, MAX_THREAT_INTELLIGENCE_URL_LENGTH, normalizeCuratedConnectorTarget, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-targets.mjs";
|
|
10
10
|
const THREAT_INTELLIGENCE_CONTRACT_VERSION = 1;
|
|
11
11
|
const THREAT_INTELLIGENCE_SCHEMA = 'whoisleuth.threat-intelligence-result';
|
|
@@ -78,7 +78,6 @@ const CONNECTOR_RELATIONSHIP_ENDPOINTS = Object.freeze({
|
|
|
78
78
|
});
|
|
79
79
|
// createObservation() retains at most 40 source characters, so provider IDs use
|
|
80
80
|
// the same ceiling and can never be truncated at the provenance boundary.
|
|
81
|
-
const MAX_PROVIDER_ID_LENGTH = 40;
|
|
82
81
|
const MAX_PROVIDER_LABEL_LENGTH = 100;
|
|
83
82
|
const MAX_DETAIL_LENGTH = 500;
|
|
84
83
|
const MAX_FINDING_ID_LENGTH = 160;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { analyzeBrowserLibraries } from "./browser-library-profile.mjs";
|
|
8
8
|
import { createObservation } from "./observation.mjs";
|
|
9
9
|
import { MAX_STATIC_HTML_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
|
|
10
|
-
const TECHNOLOGY_PROFILE_VERSION =
|
|
10
|
+
const TECHNOLOGY_PROFILE_VERSION = 10;
|
|
11
11
|
const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
|
|
12
12
|
const MAX_TECHNOLOGY_TAG_LENGTH = MAX_TAG_LENGTH;
|
|
13
13
|
const MAX_TECHNOLOGY_FINDINGS = 24;
|
|
@@ -151,7 +151,6 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
151
151
|
{
|
|
152
152
|
id: 'craft-cms', name: 'Craft CMS', category: 'content management',
|
|
153
153
|
evidence: [
|
|
154
|
-
generatorEvidence(/^craft cms(?:\s|$)/i, 'Generator metadata identifies Craft CMS.'),
|
|
155
154
|
responseHeaderEvidence('x-powered-by', /(?:^|,\s*)craft cms(?:\s|$|\/)/i, 'The passive X-Powered-By response header identifies Craft CMS.'),
|
|
156
155
|
],
|
|
157
156
|
},
|
|
@@ -204,14 +203,12 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
204
203
|
{
|
|
205
204
|
id: 'opencart', name: 'OpenCart', category: 'commerce',
|
|
206
205
|
evidence: [
|
|
207
|
-
generatorEvidence(/^opencart(?:\s|$)/i, 'Generator metadata identifies OpenCart.'),
|
|
208
206
|
htmlEvidence(['index.php?route=common/home', 'image/catalog/opencart-logo.png'], 'Static markup contains OpenCart routing or default asset conventions.'),
|
|
209
207
|
],
|
|
210
208
|
},
|
|
211
209
|
{
|
|
212
210
|
id: 'prestashop', name: 'PrestaShop', category: 'commerce',
|
|
213
211
|
evidence: [
|
|
214
|
-
generatorEvidence(/^prestashop(?:\s|$)/i, 'Generator metadata identifies PrestaShop.'),
|
|
215
212
|
htmlEvidence(['/modules/ps_'], 'Static resource paths use PrestaShop module conventions.'),
|
|
216
213
|
],
|
|
217
214
|
},
|
|
@@ -253,7 +250,7 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
253
250
|
id: 'weebly', name: 'Weebly', category: 'site builder',
|
|
254
251
|
requiresNonResourceEvidence: true,
|
|
255
252
|
evidence: [
|
|
256
|
-
|
|
253
|
+
htmlEvidence(['id="wsite-base-style"', "id='wsite-base-style'", 'title="wsite-theme-css"', "title='wsite-theme-css'"], 'Static markup contains Weebly-specific theme attributes.'),
|
|
257
254
|
resourceEvidence(['editmysite.com'], 'A retained resource origin uses Weebly delivery infrastructure.'),
|
|
258
255
|
],
|
|
259
256
|
},
|
|
@@ -321,7 +318,6 @@ const TECHNOLOGY_SIGNATURES = [
|
|
|
321
318
|
{
|
|
322
319
|
id: 'cloudfront', name: 'Amazon CloudFront', category: 'delivery platform',
|
|
323
320
|
evidence: [
|
|
324
|
-
serverEvidence(/^cloudfront(?:\s|$|\/)/i, 'The selected response server header identifies Amazon CloudFront.'),
|
|
325
321
|
resourceEvidence(['cloudfront.net'], 'A retained resource origin uses Amazon CloudFront delivery infrastructure.'),
|
|
326
322
|
],
|
|
327
323
|
},
|