@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.
Files changed (42) hide show
  1. package/README.md +8 -4
  2. package/bin/whoisleuth.mjs +20 -9
  3. package/cli/bulk-checkpoint.mjs +65 -28
  4. package/cli/bulk-command-runner.mjs +15 -2
  5. package/cli/bulk.mjs +18 -7
  6. package/cli/completion.mjs +34 -3
  7. package/cli/config-profile.mjs +15 -12
  8. package/cli/discovery-observation-snapshot.mjs +8 -14
  9. package/cli/discovery-scan-command-runner.mjs +14 -1
  10. package/cli/discovery-snapshot.mjs +8 -15
  11. package/cli/errors.mjs +7 -1
  12. package/cli/formatters/json.mjs +12 -4
  13. package/cli/formatters/terminal.mjs +1 -1
  14. package/cli/http.mjs +2 -1
  15. package/cli/lookup-reconcile.mjs +1 -1
  16. package/cli/mail-review.mjs +5 -5
  17. package/cli/manual.mjs +1 -1
  18. package/cli/registry-fixture-scaffold.mjs +4 -3
  19. package/cli/runner.mjs +20 -9
  20. package/docs/cli-reference.md +26 -7
  21. package/docs/cli.md +1 -0
  22. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  23. package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
  24. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  25. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  26. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  27. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  28. package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
  29. package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
  30. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  31. package/frontend/src/lib/analysis/utils.js +1 -1
  32. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  33. package/frontend/src/lib/analysis/workspace-archive.js +3 -7
  34. package/lib/bounded-file.mjs +69 -0
  35. package/lib/bounded-json-response.mjs +157 -0
  36. package/lib/browser-library-profile.mjs +167 -38
  37. package/lib/lookup-cache.mjs +5 -1
  38. package/lib/rdap-transport.mjs +6 -6
  39. package/lib/safe-fetch.mjs +37 -32
  40. package/lib/threat-intelligence-runtime.mjs +1 -2
  41. package/lib/website-technology.mjs +2 -6
  42. 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 scanInlineContent(detected, content) {
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
- scanExtractor(detected, 'filecontent', normalized, 'inline signature');
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 component = record(rawComponent);
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
- scanInlineContent(detected, script.inlineContent);
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, };
@@ -42,7 +42,7 @@ function approxByteSize(value) {
42
42
  return Buffer.byteLength(JSON.stringify(value));
43
43
  }
44
44
  catch {
45
- return 0;
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)) {
@@ -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 {
@@ -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 (::ffff:0:0/96) and the deprecated IPv4-compatible
132
- // (::0:0/96) forms both put the embedded address in the last two groups -
133
- // checked on the canonical expanded form above, so it doesn't matter
134
- // whether the address was originally written in dotted-decimal or hex.
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
- // 6to4 (2002::/16) - the embedded IPv4 lives in groups 1-2, not the tail.
154
- if (g0 === '2002') {
155
- const embedded = groupsToIpv4(g1, g2);
156
- if (embedded && isPrivateIpv4(embedded))
157
- return true;
158
- }
159
- // Teredo (2001:0000::/32) carries both a server IPv4 address and an
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
- return false;
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
- const reader = res.body && res.body.getReader ? res.body.getReader() : null;
377
- if (!reader) {
378
- const full = Buffer.from(await res.arrayBuffer());
379
- const captured = full.subarray(0, maxBytes);
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
- const reader = res.body && res.body.getReader ? res.body.getReader() : null;
417
- if (!reader) {
418
- const buf = Buffer.from(await res.arrayBuffer());
419
- const bytes = buf.subarray(0, maxBytes);
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, boundedInteger, boundedString, isRecord, isoTimestamp, strictBoundedString, } from "./bounded-contract-normalizers.mjs";
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 = 8;
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
- generatorEvidence(/^weebly(?:\s|$)/i, 'Generator metadata identifies Weebly.'),
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
  },
package/package.json CHANGED
@@ -33,7 +33,7 @@
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "1.43.0",
36
+ "version": "1.45.0",
37
37
  "dependencies": {
38
38
  "@peculiar/x509": "2.0.0",
39
39
  "maxmind": "5.0.7",