@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -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, };
@@ -3,7 +3,8 @@
3
3
  // retained; malformed extensions fail soft and leave an explicit partial flag.
4
4
  import 'reflect-metadata';
5
5
  import { CertificatePolicyExtension, CRLDistributionPointsExtension, X509Certificate, } from '@peculiar/x509';
6
- export const MAX_CERTIFICATE_POLICIES = 16;
6
+ import { MAX_LOOKUP_TLS_CERTIFICATE_POLICIES } from "./lookup-network-evidence-bounds.mjs";
7
+ export const MAX_CERTIFICATE_POLICIES = MAX_LOOKUP_TLS_CERTIFICATE_POLICIES;
7
8
  export const MAX_CRL_DISTRIBUTION_POINTS = 32;
8
9
  function emptyProfile(partial = false) {
9
10
  return Object.freeze({
@@ -0,0 +1,155 @@
1
+ import { validateDnssecEvidence } from "./dnssec-evidence-validation.mjs";
2
+ import { reviewRpkiRoute } from "./rpki-evidence.mjs";
3
+ import { analyzeTlsaEvidence } from "./tlsa-evidence.mjs";
4
+ const CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA = 'whoisleuth.cryptographic-assurance.input';
5
+ const CRYPTOGRAPHIC_ASSURANCE_SCHEMA = 'whoisleuth.cryptographic-assurance.review';
6
+ const CRYPTOGRAPHIC_ASSURANCE_VERSION = 1;
7
+ function exactRecord(value, keys, label) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value))
9
+ throw new TypeError(`${label} must be one object.`);
10
+ const record = value;
11
+ const unexpected = Object.keys(record).find((key) => !keys.has(key));
12
+ if (unexpected)
13
+ throw new TypeError(`${label} contains unsupported field "${unexpected}".`);
14
+ return record;
15
+ }
16
+ function sourceLabel(value, label) {
17
+ if (typeof value !== 'string')
18
+ throw new TypeError(`${label}.source must be text.`);
19
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim();
20
+ if (!normalized || normalized.length > 160)
21
+ throw new TypeError(`${label}.source must contain from 1 to 160 characters.`);
22
+ return normalized;
23
+ }
24
+ function observationTime(value, label) {
25
+ if (typeof value !== 'string' || value.length > 64 || !Number.isFinite(Date.parse(value))) {
26
+ throw new TypeError(`${label}.observedAt must be one ISO-compatible timestamp.`);
27
+ }
28
+ return new Date(value).toISOString();
29
+ }
30
+ function suppliedEvidence(value, label) {
31
+ if (value === null || value === undefined)
32
+ return null;
33
+ const wrapper = exactRecord(value, new Set(['source', 'observedAt', 'evidence']), label);
34
+ if (!wrapper.evidence || typeof wrapper.evidence !== 'object' || Array.isArray(wrapper.evidence)) {
35
+ throw new TypeError(`${label}.evidence must be one object.`);
36
+ }
37
+ return {
38
+ source: sourceLabel(wrapper.source, label),
39
+ observedAt: observationTime(wrapper.observedAt, label),
40
+ evidence: wrapper.evidence,
41
+ };
42
+ }
43
+ function unavailableCard(family, label, authority) {
44
+ return Object.freeze({
45
+ family,
46
+ label,
47
+ authority,
48
+ source: null,
49
+ observedAt: null,
50
+ state: 'unavailable',
51
+ completeness: 'unavailable',
52
+ result: null,
53
+ limitations: Object.freeze([`No ${label.toLowerCase()} evidence was supplied. No state was inferred from another assurance family.`]),
54
+ });
55
+ }
56
+ function dnssecCompleteness(report) {
57
+ return report.state === 'consistent' || report.state === 'conflict' || report.state === 'unsigned'
58
+ ? 'complete'
59
+ : report.state === 'invalid' ? 'unavailable' : 'partial';
60
+ }
61
+ function rpkiCompleteness(report) {
62
+ return report.state === 'partial' ? 'partial' : report.state === 'invalid_input' ? 'unavailable' : 'complete';
63
+ }
64
+ function tlsaCompleteness(report) {
65
+ const comparisonMaterialComplete = !report.truncated
66
+ && report.rejectedCount === 0
67
+ && !report.authorityMaterialTruncated
68
+ && report.authorityMaterialRejectedCount === 0
69
+ && report.records.every((record) => record.state !== 'unavailable');
70
+ return comparisonMaterialComplete
71
+ && (report.state === 'matched' || report.state === 'different' || report.state === 'untrusted')
72
+ ? 'complete'
73
+ : report.state === 'invalid' || report.state === 'unavailable' ? 'unavailable' : 'partial';
74
+ }
75
+ function buildCryptographicAssuranceReview(inputValue, generatedAt = new Date().toISOString()) {
76
+ const input = exactRecord(inputValue, new Set(['schema', 'version', 'dnssec', 'routeOrigin', 'tlsa']), 'Cryptographic assurance input');
77
+ if (input.schema !== CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA || input.version !== CRYPTOGRAPHIC_ASSURANCE_VERSION) {
78
+ throw new TypeError(`Cryptographic assurance input must use ${CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA} version ${CRYPTOGRAPHIC_ASSURANCE_VERSION}.`);
79
+ }
80
+ const dnssecInput = suppliedEvidence(input.dnssec, 'dnssec');
81
+ const routeInput = suppliedEvidence(input.routeOrigin, 'routeOrigin');
82
+ const tlsaInput = suppliedEvidence(input.tlsa, 'tlsa');
83
+ if (!dnssecInput && !routeInput && !tlsaInput)
84
+ throw new TypeError('Cryptographic assurance input must supply at least one evidence family.');
85
+ const dnssec = dnssecInput ? validateDnssecEvidence({
86
+ ownerName: dnssecInput.evidence.ownerName,
87
+ delegationSigned: dnssecInput.evidence.delegationSigned,
88
+ dsRecords: dnssecInput.evidence.dsRecords,
89
+ dnskeyRecords: dnssecInput.evidence.dnskeyRecords,
90
+ rrSigRecords: dnssecInput.evidence.rrSigRecords,
91
+ observedAt: dnssecInput.evidence.observedAt ?? dnssecInput.observedAt,
92
+ }) : null;
93
+ const routeOrigin = routeInput ? reviewRpkiRoute({
94
+ routePrefix: routeInput.evidence.routePrefix,
95
+ originAsn: routeInput.evidence.originAsn,
96
+ authorizations: routeInput.evidence.authorizations,
97
+ }) : null;
98
+ const tlsa = tlsaInput ? analyzeTlsaEvidence({
99
+ serviceName: tlsaInput.evidence.serviceName,
100
+ dnssecState: tlsaInput.evidence.dnssecState,
101
+ pkixValidationState: tlsaInput.evidence.pkixValidationState,
102
+ records: tlsaInput.evidence.records,
103
+ certificateDerBase64: tlsaInput.evidence.certificateDerBase64,
104
+ spkiDerBase64: tlsaInput.evidence.spkiDerBase64,
105
+ authorityMaterials: tlsaInput.evidence.authorityMaterials,
106
+ }) : null;
107
+ const cards = [
108
+ dnssec && dnssecInput ? Object.freeze({
109
+ family: 'dnssec_validation',
110
+ label: 'DNSSEC evidence review',
111
+ authority: 'Supplied delegation, DS, DNSKEY, and signature-time evidence',
112
+ source: dnssecInput.source,
113
+ observedAt: dnssecInput.observedAt,
114
+ state: dnssec.state,
115
+ completeness: dnssecCompleteness(dnssec),
116
+ result: dnssec,
117
+ limitations: dnssec.limitations,
118
+ }) : unavailableCard('dnssec_validation', 'DNSSEC evidence review', 'Supplied delegation, DS, DNSKEY, and signature-time evidence'),
119
+ routeOrigin && routeInput ? Object.freeze({
120
+ family: 'route_origin_authorisation',
121
+ label: 'Route-origin authorisation',
122
+ authority: 'Analyst-supplied validated route-origin snapshot',
123
+ source: routeInput.source,
124
+ observedAt: routeInput.observedAt,
125
+ state: routeOrigin.state,
126
+ completeness: rpkiCompleteness(routeOrigin),
127
+ result: routeOrigin,
128
+ limitations: routeOrigin.limitations,
129
+ }) : unavailableCard('route_origin_authorisation', 'Route-origin authorisation', 'Analyst-supplied validated route-origin snapshot'),
130
+ tlsa && tlsaInput ? Object.freeze({
131
+ family: 'dane_tlsa',
132
+ label: 'DANE and TLSA evidence review',
133
+ authority: 'Supplied TLSA, DNSSEC, PKIX, and certificate material',
134
+ source: tlsaInput.source,
135
+ observedAt: tlsaInput.observedAt,
136
+ state: tlsa.state,
137
+ completeness: tlsaCompleteness(tlsa),
138
+ result: tlsa,
139
+ limitations: tlsa.limitations,
140
+ }) : unavailableCard('dane_tlsa', 'DANE and TLSA evidence review', 'Supplied TLSA, DNSSEC, PKIX, and certificate material'),
141
+ ];
142
+ return Object.freeze({
143
+ schema: CRYPTOGRAPHIC_ASSURANCE_SCHEMA,
144
+ version: CRYPTOGRAPHIC_ASSURANCE_VERSION,
145
+ generatedAt: observationTime(generatedAt, 'generatedAt'),
146
+ cards: Object.freeze(cards),
147
+ combinedState: null,
148
+ limitations: Object.freeze([
149
+ 'DNSSEC, route-origin authorisation, and DANE or TLSA remain independently sourced evidence families with separate states, observation times, completeness, and limitations.',
150
+ 'No scalar score or combined verdict is produced. A missing, failed, partial, or unsupported family is never inferred from another family.',
151
+ 'These reviews do not establish ownership, availability, activity, safety, or maliciousness.',
152
+ ]),
153
+ });
154
+ }
155
+ export { CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA, CRYPTOGRAPHIC_ASSURANCE_SCHEMA, CRYPTOGRAPHIC_ASSURANCE_VERSION, buildCryptographicAssuranceReview, };
package/lib/ct-search.mjs CHANGED
@@ -11,6 +11,7 @@ import { normalizeCtQuery } from "./ct-query.mjs";
11
11
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
12
12
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
13
13
  import { createObservation } from "./observation.mjs";
14
+ import { isValidAsciiHostname } from "./hostname.mjs";
14
15
  import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "./ct-response-bounds.mjs";
15
16
  const CRT_SH_TIMEOUT_MS = 20000;
16
17
  const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
@@ -40,6 +41,12 @@ const MAX_TIMEOUT_RETRIES = 1;
40
41
  // iteration/set overhead after parsing. Legitimate responses under the byte
41
42
  // cap stay well within this limit.
42
43
  const MAX_CT_ROWS = 50_000;
44
+ // crt.sh joins SAN values with newlines. Bound the amount of per-row and
45
+ // whole-response hostname work independently of the JSON byte and row caps so
46
+ // a small number of newline-dense strings cannot drive unbounded PSL parsing,
47
+ // Set growth, or sorting before the result projection is truncated.
48
+ const MAX_CT_NAMES_PER_ROW = 1_000;
49
+ const MAX_CT_NAMES_EXAMINED = 100_000;
43
50
  // Structured-match bounds.
44
51
  const MAX_MATCHES = MAX_RESULTS; // 500
45
52
  const MAX_HOSTNAMES_PER_MATCH = MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH;
@@ -56,10 +63,12 @@ const MAX_SERIAL_LENGTH = 128;
56
63
  // Maximum length for an entry_timestamp string before it is rejected.
57
64
  const MAX_TIMESTAMP_LENGTH = MAX_CT_RESPONSE_TIMESTAMP_LENGTH;
58
65
  function normalizeHostname(raw) {
66
+ if (raw.length > 255)
67
+ return null;
59
68
  let h = raw.trim().toLowerCase();
60
69
  if (h.startsWith('*.'))
61
70
  h = h.slice(2);
62
- return HOSTNAME_RE.test(h) ? h : null;
71
+ return HOSTNAME_RE.test(h) && isValidAsciiHostname(h, { requireLowercase: true }) ? h : null;
63
72
  }
64
73
  function delay(ms) {
65
74
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -75,7 +84,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
75
84
  res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
76
85
  headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
77
86
  signal: controller.signal,
78
- });
87
+ }, 0);
79
88
  }
80
89
  catch (err) {
81
90
  if (!(err instanceof Error) || err.name !== 'AbortError')
@@ -255,6 +264,8 @@ function validateEntryTimestamp(value) {
255
264
  * certificateCount: number
256
265
  * }>,
257
266
  * certificateGroups: CtCertificateGroup[],
267
+ * namesExamined: number,
268
+ * workTruncated: boolean,
258
269
  * truncated: boolean
259
270
  * }}
260
271
  */
@@ -275,14 +286,20 @@ function summarizeCtResults(rows) {
275
286
  // firstObservedAt: string|null, lastObservedAt: string|null }>
276
287
  const groupMap = new Map();
277
288
  const certificateMap = new Map();
278
- for (let i = 0; i < rows.length; i++) {
289
+ let namesExamined = 0;
290
+ let workTruncated = false;
291
+ rowLoop: for (let i = 0; i < rows.length; i++) {
279
292
  const row = rows[i];
280
293
  if (!row || typeof row !== 'object')
281
294
  continue;
282
295
  const certId = resolveCertId(row, i);
283
- // Collect hostnames from name_value and common_name.
284
- const rawBlob = `${row.name_value || ''}\n${row.common_name || ''}`;
285
- const blob = rawBlob;
296
+ // Collect only direct string fields. Implicitly stringifying a nested
297
+ // array or object would traverse attacker-controlled structure before any
298
+ // hostname bound could take effect.
299
+ const nameFields = [
300
+ typeof row.name_value === 'string' ? row.name_value : '',
301
+ typeof row.common_name === 'string' ? row.common_name : '',
302
+ ];
286
303
  const ts = validateEntryTimestamp(row.entry_timestamp);
287
304
  let certificate = certificateMap.get(certId);
288
305
  if (!certificate) {
@@ -297,46 +314,65 @@ function summarizeCtResults(rows) {
297
314
  else if (ts !== null && (certificate.observedAt === null || ts > certificate.observedAt)) {
298
315
  certificate.observedAt = ts;
299
316
  }
300
- if (rawBlob.split('\n').some((line) => line.trim().startsWith('*.')))
301
- certificate.wildcardObserved = true;
302
- for (const line of blob.split('\n')) {
303
- const host = normalizeHostname(line);
304
- if (!host)
305
- continue;
306
- // Legacy: always add if valid hostname.
307
- legacyDomains.add(host);
308
- // Structured: resolve registrable domain.
309
- let regDomain;
310
- if (registrableDomainByHostname.has(host)) {
311
- regDomain = registrableDomainByHostname.get(host);
312
- }
313
- else {
314
- regDomain = parse(host).domain || null;
315
- registrableDomainByHostname.set(host, regDomain);
316
- }
317
- if (!regDomain)
318
- continue;
319
- certificate.hostnames.add(host);
320
- certificate.domains.add(regDomain);
321
- let group = groupMap.get(regDomain);
322
- if (!group) {
323
- group = {
324
- hostnames: new Set(),
325
- certIds: new Set(),
326
- firstObservedAt: null,
327
- lastObservedAt: null,
328
- };
329
- groupMap.set(regDomain, group);
330
- }
331
- group.hostnames.add(host);
332
- group.certIds.add(certId);
333
- if (ts !== null) {
334
- if (group.firstObservedAt === null || ts < group.firstObservedAt) {
335
- group.firstObservedAt = ts;
317
+ let rowNamesExamined = 0;
318
+ for (const field of nameFields) {
319
+ let offset = 0;
320
+ while (offset <= field.length) {
321
+ if (namesExamined >= MAX_CT_NAMES_EXAMINED) {
322
+ workTruncated = true;
323
+ break rowLoop;
324
+ }
325
+ if (rowNamesExamined >= MAX_CT_NAMES_PER_ROW) {
326
+ workTruncated = true;
327
+ continue rowLoop;
336
328
  }
337
- if (group.lastObservedAt === null || ts > group.lastObservedAt) {
338
- group.lastObservedAt = ts;
329
+ const newline = field.indexOf('\n', offset);
330
+ const line = newline === -1 ? field.slice(offset) : field.slice(offset, newline);
331
+ namesExamined += 1;
332
+ rowNamesExamined += 1;
333
+ if (line.trim().startsWith('*.'))
334
+ certificate.wildcardObserved = true;
335
+ const host = normalizeHostname(line);
336
+ if (host) {
337
+ // Legacy: always add if valid hostname.
338
+ legacyDomains.add(host);
339
+ // Structured: resolve registrable domain.
340
+ let regDomain;
341
+ if (registrableDomainByHostname.has(host)) {
342
+ regDomain = registrableDomainByHostname.get(host);
343
+ }
344
+ else {
345
+ regDomain = parse(host).domain || null;
346
+ registrableDomainByHostname.set(host, regDomain);
347
+ }
348
+ if (regDomain) {
349
+ certificate.hostnames.add(host);
350
+ certificate.domains.add(regDomain);
351
+ let group = groupMap.get(regDomain);
352
+ if (!group) {
353
+ group = {
354
+ hostnames: new Set(),
355
+ certIds: new Set(),
356
+ firstObservedAt: null,
357
+ lastObservedAt: null,
358
+ };
359
+ groupMap.set(regDomain, group);
360
+ }
361
+ group.hostnames.add(host);
362
+ group.certIds.add(certId);
363
+ if (ts !== null) {
364
+ if (group.firstObservedAt === null || ts < group.firstObservedAt) {
365
+ group.firstObservedAt = ts;
366
+ }
367
+ if (group.lastObservedAt === null || ts > group.lastObservedAt) {
368
+ group.lastObservedAt = ts;
369
+ }
370
+ }
371
+ }
339
372
  }
373
+ if (newline === -1)
374
+ break;
375
+ offset = newline + 1;
340
376
  }
341
377
  }
342
378
  }
@@ -404,7 +440,9 @@ function summarizeCtResults(rows) {
404
440
  wildcardObserved: group.wildcardObserved,
405
441
  })),
406
442
  certificateGroupsTruncated,
407
- truncated: legacyTruncated || matchTruncated || perMatchTruncated,
443
+ namesExamined,
444
+ workTruncated,
445
+ truncated: workTruncated || legacyTruncated || matchTruncated || perMatchTruncated,
408
446
  };
409
447
  }
410
448
  // ---------------------------------------------------------------------------
@@ -416,11 +454,12 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
416
454
  if (!trimmed) {
417
455
  return {
418
456
  domains: [], certCount: 0, truncated: false, matches: [], certificateGroups: [], certificateGroupsTruncated: false,
457
+ namesExamined: 0, workTruncated: false,
419
458
  observation: createObservation({
420
459
  status: 'success', observedAt: new Date().toISOString(), source: 'certificate_transparency',
421
460
  durationMs: Date.now() - startedAt, complete: true, truncated: false,
422
461
  limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
423
- diagnostics: { certificateRows: 0, matches: 0, certificateGroups: 0 },
462
+ diagnostics: { certificateRows: 0, namesExamined: 0, workTruncated: false, matches: 0, certificateGroups: 0 },
424
463
  }),
425
464
  };
426
465
  }
@@ -438,9 +477,10 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
438
477
  truncated: summary.truncated || summary.certificateGroupsTruncated,
439
478
  limitations: [
440
479
  'Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.',
480
+ ...(summary.workTruncated ? ['The response contained more hostname entries than the bounded local analysis could examine; retained results are partial.'] : []),
441
481
  ...(summary.certificateGroupsTruncated ? ['The optional certificate-group projection was capped independently of the registrable-domain result set.'] : []),
442
482
  ],
443
- diagnostics: { certificateRows: data.length, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
483
+ diagnostics: { certificateRows: data.length, namesExamined: summary.namesExamined, workTruncated: summary.workTruncated, matches: summary.matches.length, certificateGroups: summary.certificateGroups.length, certificateGroupsTruncated: summary.certificateGroupsTruncated },
444
484
  }),
445
485
  };
446
486
  }