@slicedearth/whoisleuth-cli 1.45.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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  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 +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. package/third-party-notices.txt +95 -5817
@@ -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
  }
@@ -7,11 +7,12 @@ import * as net from 'node:net';
7
7
  import { classifyMxRecords } from "./dns-mx.mjs";
8
8
  import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
9
9
  import { createObservation } from "./observation.mjs";
10
+ import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
10
11
  import { isPrivateAddress } from "./safe-fetch.mjs";
11
12
  import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
12
13
  const DNS_TIMEOUT_MS = 5000;
13
- const MAX_RECORDS_PER_TYPE = 16;
14
- const MAX_PTR_RECORDS = 8;
14
+ const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
15
+ const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
15
16
  const MAX_HOSTNAME_LENGTH = 253;
16
17
  const MAX_POLICY_LENGTH = 1024;
17
18
  const MAX_ERROR_LENGTH = 180;
@@ -1,5 +1,6 @@
1
1
  import { Resolver } from 'node:dns/promises';
2
2
  import { isIP } from 'node:net';
3
+ import { isPrivateAddress } from "./safe-fetch.mjs";
3
4
  const MAX_SELECTED_DNS_RESOLVERS = 3;
4
5
  const MAX_SELECTED_DNS_RESOLVER_TEXT = 256;
5
6
  function normalizeSelectedDnsResolvers(value) {
@@ -7,15 +8,16 @@ function normalizeSelectedDnsResolvers(value) {
7
8
  throw new TypeError(`DNS resolver selection must contain at most ${MAX_SELECTED_DNS_RESOLVER_TEXT} characters.`);
8
9
  }
9
10
  const values = [...new Set(value.split(',').map((item) => item.trim()).filter(Boolean))];
10
- if (!values.length || values.length > MAX_SELECTED_DNS_RESOLVERS || values.some((item) => isIP(item) === 0)) {
11
- throw new TypeError(`DNS resolver selection requires 1 to ${MAX_SELECTED_DNS_RESOLVERS} literal IPv4 or IPv6 addresses.`);
11
+ if (!values.length || values.length > MAX_SELECTED_DNS_RESOLVERS
12
+ || values.some((item) => isIP(item) === 0 || isPrivateAddress(item))) {
13
+ throw new TypeError(`DNS resolver selection requires 1 to ${MAX_SELECTED_DNS_RESOLVERS} publicly routable literal IPv4 or IPv6 addresses.`);
12
14
  }
13
15
  return values;
14
16
  }
15
17
  function createSelectedDnsResolvers(servers) {
16
18
  if (!Array.isArray(servers) || !servers.length || servers.length > MAX_SELECTED_DNS_RESOLVERS
17
- || servers.some((item) => typeof item !== 'string' || isIP(item) === 0)) {
18
- throw new TypeError('Validated DNS resolver addresses are required.');
19
+ || servers.some((item) => typeof item !== 'string' || isIP(item) === 0 || isPrivateAddress(item))) {
20
+ throw new TypeError('Validated publicly routable DNS resolver addresses are required.');
19
21
  }
20
22
  const resolver = new Resolver();
21
23
  resolver.setServers([...servers]);