@slicedearth/whoisleuth-cli 1.32.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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,66 @@
1
+ // Privacy-minimized credential-surface evidence derived from live input and
2
+ // form start tags in the already-captured deep-Lookup homepage response. The
3
+ // shared tokenizer projects only fixed categories and bounded counts; field
4
+ // names, values, labels, placeholders, arbitrary attributes, and action URLs
5
+ // never enter this profile.
6
+ import { createObservation } from "./observation.mjs";
7
+ import { MAX_STATIC_FORMS, MAX_STATIC_INPUTS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
8
+ const CREDENTIAL_SURFACE_PROFILE_VERSION = 1;
9
+ function analyzeCredentialSurfaceProfile(input = {}) {
10
+ const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html, { baseUrl: input.baseUrl });
11
+ const forms = htmlAnalysis.forms;
12
+ const sourceTruncated = input.sourceTruncated === true;
13
+ const truncated = sourceTruncated
14
+ || htmlAnalysis.inputLimitReached
15
+ || htmlAnalysis.tagLimitReached
16
+ || forms.truncated;
17
+ const unclassifiedActions = forms.actions.unclassified;
18
+ const partial = truncated || unclassifiedActions > 0;
19
+ const limitations = [
20
+ 'Static input categories use only bounded type and autocomplete declarations; JavaScript-rendered, non-semantic, disabled, and hidden controls are not classified.',
21
+ 'Category counts can overlap when one input declares more than one recognized purpose.',
22
+ 'External submission can be legitimate and does not establish phishing, unsafe handling, ownership, intent, or maliciousness.',
23
+ 'No field names, values, labels, placeholders, complete action URLs, paths, queries, fragments, or arbitrary attributes are retained.',
24
+ ];
25
+ if (sourceTruncated || htmlAnalysis.inputLimitReached) {
26
+ limitations.push('The captured homepage body was truncated, so credential-surface evidence may be incomplete.');
27
+ }
28
+ if (htmlAnalysis.tagLimitReached) {
29
+ limitations.push('Static tokenization reached its tag boundary, so later form or input elements may be absent.');
30
+ }
31
+ if (forms.truncated) {
32
+ limitations.push(`Form or input profiling reached an element-count or attribute-classification boundary. At most the first ${MAX_STATIC_FORMS} forms and ${MAX_STATIC_INPUTS} input elements were eligible.`);
33
+ }
34
+ if (unclassifiedActions > 0) {
35
+ limitations.push(`${unclassifiedActions} form action${unclassifiedActions === 1 ? ' could' : 's could'} not be safely classified.`);
36
+ }
37
+ return {
38
+ credentialSurfaceVersion: CREDENTIAL_SURFACE_PROFILE_VERSION,
39
+ ...createObservation({
40
+ status: partial ? 'partial' : 'success',
41
+ observedAt: input.observedAt,
42
+ scanMode: 'deep',
43
+ source: 'html',
44
+ complete: !partial,
45
+ truncated,
46
+ limitations,
47
+ diagnostics: {
48
+ formsObserved: forms.formsObserved,
49
+ inputsObserved: forms.inputsObserved,
50
+ classifiedInputs: forms.classifiedInputs,
51
+ unclassifiedActions,
52
+ },
53
+ }),
54
+ forms: {
55
+ count: forms.formsObserved,
56
+ methods: { ...forms.methods },
57
+ actions: { ...forms.actions },
58
+ },
59
+ inputs: {
60
+ count: forms.inputsObserved,
61
+ classifiedCount: forms.classifiedInputs,
62
+ categories: { ...forms.categories },
63
+ },
64
+ };
65
+ }
66
+ export { CREDENTIAL_SURFACE_PROFILE_VERSION, analyzeCredentialSurfaceProfile, };
package/lib/csv.mjs ADDED
@@ -0,0 +1,23 @@
1
+ // Shared bounded CSV serialization for browser and CLI exports. Values can
2
+ // contain upstream evidence, so normalize controls and prevent spreadsheet
3
+ // formula execution before applying ordinary CSV quoting.
4
+ const CSV_FORMULA_TRIGGER_RE = /^(?:[\t\r\n ]*[=+\-@]|[\t\r\n])/u;
5
+ export const MAX_CSV_CELL_LENGTH = 32_768;
6
+ function csvText(value) {
7
+ const raw = Array.isArray(value)
8
+ ? value.map((item) => item == null ? '' : String(item)).join(' | ')
9
+ : value == null ? '' : String(value);
10
+ return raw
11
+ .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
12
+ .replace(/\s+/gu, ' ')
13
+ .trim()
14
+ .slice(0, MAX_CSV_CELL_LENGTH);
15
+ }
16
+ export function serializeCsvCell(value) {
17
+ const text = csvText(value);
18
+ const safe = CSV_FORMULA_TRIGGER_RE.test(text) ? `'${text}` : text;
19
+ return /[",\r\n]/u.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
20
+ }
21
+ export function serializeCsvRows(rows) {
22
+ return rows.map((row) => row.map(serializeCsvCell).join(',')).join('\n');
23
+ }
@@ -0,0 +1,27 @@
1
+ const MAX_CT_QUERY_LENGTH = 200;
2
+ const CT_QUERY_ERROR_CODE = 'INVALID_CT_QUERY';
3
+ const CT_QUERY_ERROR_MESSAGE = `Certificate Transparency query must be at most ${MAX_CT_QUERY_LENGTH} characters and contain no control characters.`;
4
+ const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
5
+ class CtQueryError extends Error {
6
+ code = CT_QUERY_ERROR_CODE;
7
+ constructor() {
8
+ super(CT_QUERY_ERROR_MESSAGE);
9
+ this.name = 'CtQueryError';
10
+ }
11
+ }
12
+ function normalizeCtQuery(value) {
13
+ if (value === undefined || value === null)
14
+ return '';
15
+ if (typeof value !== 'string'
16
+ || value.length > MAX_CT_QUERY_LENGTH
17
+ || CONTROL_CHARACTER_RE.test(value)) {
18
+ throw new CtQueryError();
19
+ }
20
+ return value.trim();
21
+ }
22
+ function isCtQueryError(value) {
23
+ return value instanceof Error
24
+ && 'code' in value
25
+ && value.code === CT_QUERY_ERROR_CODE;
26
+ }
27
+ export { CT_QUERY_ERROR_CODE, CT_QUERY_ERROR_MESSAGE, CtQueryError, isCtQueryError, MAX_CT_QUERY_LENGTH, normalizeCtQuery, };
@@ -0,0 +1,393 @@
1
+ // Certificate Transparency search - finds hostnames that have ever had a
2
+ // publicly-issued TLS certificate containing a given keyword, via crt.sh's
3
+ // public JSON API. This catches lookalike domains the typosquat generator's
4
+ // fixed permutation algorithms would never guess (arbitrary misspellings,
5
+ // unrelated TLDs, subdomain tricks) - almost every phishing site gets a
6
+ // free certificate within minutes of going live, so CT logs are often the
7
+ // earliest public signal a new lookalike exists at all. Shared by the
8
+ // Express server and the Netlify Functions.
9
+ import { parse } from 'tldts';
10
+ import { normalizeCtQuery } from "./ct-query.mjs";
11
+ import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
12
+ import { createObservation } from "./observation.mjs";
13
+ const CRT_SH_TIMEOUT_MS = 20000;
14
+ const MAX_RESULTS = 500;
15
+ // crt.sh's response size scales with how many certificates ever matched the
16
+ // keyword, not with MAX_RESULTS - a broad single-word keyword can have
17
+ // millions of matching certificates, and unlike the domain-homepage fetch in
18
+ // lib/availability.mts (fine to scan a truncated prefix for a text match),
19
+ // this response has to be complete, valid JSON to parse at all. Capped so a
20
+ // broad keyword fails with a clear "narrow your search" error instead of
21
+ // buffering an arbitrarily large body in memory first.
22
+ const CRT_SH_MAX_BYTES = 5 * 1024 * 1024;
23
+ // crt.sh is a free, volunteer-run service backed by a Postgres database
24
+ // that gets hammered constantly - broad search terms in particular can make
25
+ // it time out or briefly 502/503 under load, even though the same query
26
+ // often succeeds a few seconds later. Worth a couple of retries before
27
+ // giving up, rather than failing a whole search on a single transient blip.
28
+ const RETRYABLE_STATUSES = new Set([502, 503, 504]);
29
+ const MAX_RETRIES = 2;
30
+ const RETRY_DELAY_MS = 1500;
31
+ // Separate, smaller budget for timeouts specifically - each retry here
32
+ // already costs a full CRT_SH_TIMEOUT_MS wait (unlike the cheap ~1.5-3s
33
+ // delay before a status-code retry above), so this caps the worst case at
34
+ // two attempts (~40s) instead of stacking on top of MAX_RETRIES.
35
+ const MAX_TIMEOUT_RETRIES = 1;
36
+ // Row-count defense: a hostile or malformed response could fit a very large
37
+ // number of tiny rows into the 5 MB byte cap and impose excessive
38
+ // iteration/set overhead after parsing. Legitimate responses under the byte
39
+ // cap stay well within this limit.
40
+ const MAX_CT_ROWS = 50_000;
41
+ // Structured-match bounds.
42
+ const MAX_MATCHES = MAX_RESULTS; // 500
43
+ const MAX_HOSTNAMES_PER_MATCH = 50;
44
+ const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
45
+ // Maximum length for a digit-only certificate ID string before it is
46
+ // rejected as overlong. Bounds decimal crt.sh record identifiers before
47
+ // BigInt canonicalisation.
48
+ const MAX_ID_DIGITS = 32;
49
+ // Maximum length for a serial_number string before it is rejected.
50
+ const MAX_SERIAL_LENGTH = 128;
51
+ // Maximum length for an entry_timestamp string before it is rejected.
52
+ const MAX_TIMESTAMP_LENGTH = 64;
53
+ function normalizeHostname(raw) {
54
+ let h = raw.trim().toLowerCase();
55
+ if (h.startsWith('*.'))
56
+ h = h.slice(2);
57
+ return HOSTNAME_RE.test(h) ? h : null;
58
+ }
59
+ function delay(ms) {
60
+ return new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
62
+ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
63
+ const fetcher = dependencies.fetcher || safeFetch;
64
+ const wait = dependencies.delay || delay;
65
+ const controller = new AbortController();
66
+ const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
67
+ try {
68
+ let res;
69
+ try {
70
+ res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
71
+ headers: { Accept: 'application/json', 'User-Agent': 'Mozilla/5.0 (compatible; DomainStatusChecker/1.0)' },
72
+ signal: controller.signal,
73
+ });
74
+ }
75
+ catch (err) {
76
+ if (!(err instanceof Error) || err.name !== 'AbortError')
77
+ throw err;
78
+ if (attempt < MAX_TIMEOUT_RETRIES)
79
+ return fetchCrtSh(keyword, attempt + 1, dependencies);
80
+ throw new Error(`crt.sh took too long to respond (>${CRT_SH_TIMEOUT_MS / 1000}s per attempt, ${attempt + 1} attempts) - ` +
81
+ "it can be slow for broad search terms or under heavy load; try a narrower keyword or try again shortly.");
82
+ }
83
+ if (!res.ok) {
84
+ // Not reading this body - release it explicitly instead of leaving an
85
+ // unconsumed stream (and the connection it's tied to) open until
86
+ // undici's own idle-timeout eventually notices.
87
+ await res.body?.cancel().catch(() => { });
88
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
89
+ await wait(RETRY_DELAY_MS * (attempt + 1));
90
+ return fetchCrtSh(keyword, attempt + 1, dependencies);
91
+ }
92
+ throw new Error(RETRYABLE_STATUSES.has(res.status)
93
+ ? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
94
+ : `crt.sh returned ${res.status}`);
95
+ }
96
+ const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES);
97
+ if (truncated) {
98
+ throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
99
+ }
100
+ const data = JSON.parse(text);
101
+ if (!Array.isArray(data)) {
102
+ throw new Error('crt.sh returned an unexpected response format (expected a JSON array).');
103
+ }
104
+ if (data.length > MAX_CT_ROWS) {
105
+ throw new Error(`crt.sh returned too many rows (${data.length}) for "${keyword}" - try a narrower keyword.`);
106
+ }
107
+ return data;
108
+ }
109
+ finally {
110
+ clearTimeout(timeout);
111
+ }
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Certificate identity
115
+ // ---------------------------------------------------------------------------
116
+ /**
117
+ * crt.sh `id` identifies a certificate record. Rows with the same usable ID
118
+ * are counted once within a registrable-domain group. When no stable
119
+ * certificate fields exist, the row is counted as a distinct observation
120
+ * based on its input position.
121
+ *
122
+ * Identity resolution (3-tier fallback):
123
+ * 1. Usable crt.sh `id` → `id:<canonical>`
124
+ * 2. Usable `issuer_ca_id` + `serial_number` → `issuer-serial:<ca>:<serial>`
125
+ * 3. Row index → `row:<index>`
126
+ *
127
+ * All identities are namespace-prefixed so fallback forms cannot collide.
128
+ *
129
+ * @param {object} row - a single crt.sh JSON row
130
+ * @param {number} index - row position in the input array
131
+ * @returns {string}
132
+ */
133
+ function resolveCertId(row, index) {
134
+ const id = canonicalCertId(row.id);
135
+ if (id !== null)
136
+ return `id:${id}`;
137
+ const issuerId = canonicalCertId(row.issuer_ca_id);
138
+ const serial = normalizeSerial(row.serial_number);
139
+ if (issuerId !== null && serial !== null) {
140
+ return `issuer-serial:${issuerId}:${serial}`;
141
+ }
142
+ return `row:${index}`;
143
+ }
144
+ /**
145
+ * Canonicalises a crt.sh certificate ID field for use in identity comparison.
146
+ * Accepts only safe positive integers or digit-only strings that can be
147
+ * canonicalised without precision loss. Returns a normalised digit string
148
+ * or null when the value is unusable.
149
+ *
150
+ * @param {unknown} value
151
+ * @returns {string | null}
152
+ */
153
+ function canonicalCertId(value) {
154
+ if (value === null || value === undefined)
155
+ return null;
156
+ if (typeof value === 'number') {
157
+ if (!Number.isSafeInteger(value) || value <= 0)
158
+ return null;
159
+ return String(value);
160
+ }
161
+ if (typeof value === 'string') {
162
+ if (value.length === 0 || value.length > MAX_ID_DIGITS)
163
+ return null;
164
+ const trimmed = value.trim();
165
+ if (trimmed.length === 0)
166
+ return null;
167
+ if (!/^[0-9]+$/.test(trimmed))
168
+ return null;
169
+ // Canonicalise leading zeros via BigInt (safe for up to MAX_ID_DIGITS).
170
+ let canonical;
171
+ try {
172
+ canonical = BigInt(trimmed).toString();
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ if (canonical === '0')
178
+ return null;
179
+ return canonical;
180
+ }
181
+ return null;
182
+ }
183
+ /**
184
+ * Normalises a crt.sh serial_number for use in the issuer-serial composite
185
+ * identity. Returns null when the value is unusable.
186
+ *
187
+ * @param {unknown} value
188
+ * @returns {string | null}
189
+ */
190
+ function normalizeSerial(value) {
191
+ if (typeof value !== 'string')
192
+ return null;
193
+ if (value.length === 0 || value.length > MAX_SERIAL_LENGTH)
194
+ return null;
195
+ // Reject control characters before trimming.
196
+ if (/[\x00-\x1f\x7f]/.test(value))
197
+ return null;
198
+ const trimmed = value.trim();
199
+ if (trimmed.length === 0)
200
+ return null;
201
+ // crt.sh serial numbers are hexadecimal.
202
+ if (!/^[0-9a-fA-F]+$/.test(trimmed))
203
+ return null;
204
+ return trimmed.toLowerCase();
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Timestamp validation
208
+ // ---------------------------------------------------------------------------
209
+ /**
210
+ * Validates and canonicalises a crt.sh `entry_timestamp`. Only accepts
211
+ * strings that parse to a finite timestamp. Returns an ISO-8601 string or
212
+ * null. CT observation timestamps are public-log metadata; they do not prove
213
+ * registration, site activation, exact issuance time, or maliciousness.
214
+ *
215
+ * @param {unknown} value
216
+ * @returns {string | null}
217
+ */
218
+ function validateEntryTimestamp(value) {
219
+ if (typeof value !== 'string')
220
+ return null;
221
+ if (value.length === 0 || value.length > MAX_TIMESTAMP_LENGTH)
222
+ return null;
223
+ // Reject control characters and overlong strings before any parsing.
224
+ if (/[\x00-\x1f\x7f]/.test(value))
225
+ return null;
226
+ const trimmed = value.trim();
227
+ if (trimmed.length === 0)
228
+ return null;
229
+ const parsed = new Date(trimmed);
230
+ if (Number.isNaN(parsed.getTime()))
231
+ return null;
232
+ return parsed.toISOString();
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // Pure summarizer
236
+ // ---------------------------------------------------------------------------
237
+ /**
238
+ * Transforms a parsed crt.sh JSON array into a structured summary with both
239
+ * legacy hostname list and registrable-domain-grouped provenance. Pure
240
+ * function — no network, no side effects.
241
+ *
242
+ * @param {Array<object>} rows - parsed crt.sh JSON rows
243
+ * @returns {{
244
+ * domains: string[],
245
+ * matches: Array<{
246
+ * domain: string,
247
+ * hostnames: string[],
248
+ * firstObservedAt: string | null,
249
+ * lastObservedAt: string | null,
250
+ * certificateCount: number
251
+ * }>,
252
+ * truncated: boolean
253
+ * }}
254
+ */
255
+ function summarizeCtResults(rows) {
256
+ if (!Array.isArray(rows)) {
257
+ throw new Error('crt.sh returned an unexpected response format (expected a JSON array).');
258
+ }
259
+ if (rows.length > MAX_CT_ROWS) {
260
+ throw new Error(`crt.sh returned too many rows (${rows.length}) - try a narrower keyword.`);
261
+ }
262
+ // --- Legacy domains (HOSTNAME_RE only, no tldts dependency) ---
263
+ const legacyDomains = new Set();
264
+ // --- Structured matches ---
265
+ // Cache public-suffix resolution because the same SANs commonly recur
266
+ // across certificate rows.
267
+ const registrableDomainByHostname = new Map();
268
+ // Map<registrableDomain, { hostnames: Set, certIds: Set,
269
+ // firstObservedAt: string|null, lastObservedAt: string|null }>
270
+ const groupMap = new Map();
271
+ for (let i = 0; i < rows.length; i++) {
272
+ const row = rows[i];
273
+ if (!row || typeof row !== 'object')
274
+ continue;
275
+ const certId = resolveCertId(row, i);
276
+ // Collect hostnames from name_value and common_name.
277
+ const blob = `${row.name_value || ''}\n${row.common_name || ''}`;
278
+ const ts = validateEntryTimestamp(row.entry_timestamp);
279
+ for (const line of blob.split('\n')) {
280
+ const host = normalizeHostname(line);
281
+ if (!host)
282
+ continue;
283
+ // Legacy: always add if valid hostname.
284
+ legacyDomains.add(host);
285
+ // Structured: resolve registrable domain.
286
+ let regDomain;
287
+ if (registrableDomainByHostname.has(host)) {
288
+ regDomain = registrableDomainByHostname.get(host);
289
+ }
290
+ else {
291
+ regDomain = parse(host).domain || null;
292
+ registrableDomainByHostname.set(host, regDomain);
293
+ }
294
+ if (!regDomain)
295
+ continue;
296
+ let group = groupMap.get(regDomain);
297
+ if (!group) {
298
+ group = {
299
+ hostnames: new Set(),
300
+ certIds: new Set(),
301
+ firstObservedAt: null,
302
+ lastObservedAt: null,
303
+ };
304
+ groupMap.set(regDomain, group);
305
+ }
306
+ group.hostnames.add(host);
307
+ group.certIds.add(certId);
308
+ if (ts !== null) {
309
+ if (group.firstObservedAt === null || ts < group.firstObservedAt) {
310
+ group.firstObservedAt = ts;
311
+ }
312
+ if (group.lastObservedAt === null || ts > group.lastObservedAt) {
313
+ group.lastObservedAt = ts;
314
+ }
315
+ }
316
+ }
317
+ }
318
+ // --- Build legacy sorted list ---
319
+ const sortedLegacy = [...legacyDomains].sort();
320
+ const legacyTruncated = sortedLegacy.length > MAX_RESULTS;
321
+ // --- Build structured matches ---
322
+ const matches = [];
323
+ for (const [domain, group] of groupMap) {
324
+ const sortedHostnames = [...group.hostnames].sort();
325
+ matches.push({
326
+ domain,
327
+ hostnames: sortedHostnames.slice(0, MAX_HOSTNAMES_PER_MATCH),
328
+ firstObservedAt: group.firstObservedAt,
329
+ lastObservedAt: group.lastObservedAt,
330
+ certificateCount: group.certIds.size,
331
+ });
332
+ }
333
+ // Sort matches: newest lastObservedAt first, then null timestamps to end,
334
+ // then domain alphabetically.
335
+ matches.sort((a, b) => {
336
+ const aLast = a.lastObservedAt;
337
+ const bLast = b.lastObservedAt;
338
+ if (aLast && bLast) {
339
+ const cmp = bLast.localeCompare(aLast);
340
+ if (cmp !== 0)
341
+ return cmp;
342
+ }
343
+ else if (aLast) {
344
+ return -1;
345
+ }
346
+ else if (bLast) {
347
+ return 1;
348
+ }
349
+ return a.domain.localeCompare(b.domain);
350
+ });
351
+ const matchTruncated = matches.length > MAX_MATCHES;
352
+ const perMatchTruncated = matches.some((m) => (groupMap.get(m.domain)?.hostnames.size || 0) > MAX_HOSTNAMES_PER_MATCH);
353
+ return {
354
+ domains: sortedLegacy.slice(0, MAX_RESULTS),
355
+ matches: matches.slice(0, MAX_MATCHES),
356
+ truncated: legacyTruncated || matchTruncated || perMatchTruncated,
357
+ };
358
+ }
359
+ // ---------------------------------------------------------------------------
360
+ // Production search
361
+ // ---------------------------------------------------------------------------
362
+ async function searchCertificateTransparency(keyword, dependencies = {}) {
363
+ const startedAt = Date.now();
364
+ const trimmed = normalizeCtQuery(keyword);
365
+ if (!trimmed) {
366
+ return {
367
+ domains: [], certCount: 0, truncated: false, matches: [],
368
+ observation: createObservation({
369
+ status: 'success', observedAt: new Date().toISOString(), source: 'certificate_transparency',
370
+ durationMs: Date.now() - startedAt, complete: true, truncated: false,
371
+ limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
372
+ diagnostics: { certificateRows: 0, matches: 0 },
373
+ }),
374
+ };
375
+ }
376
+ const data = await fetchCrtSh(trimmed, 0, dependencies);
377
+ const summary = summarizeCtResults(data);
378
+ return {
379
+ certCount: data.length,
380
+ ...summary,
381
+ observation: createObservation({
382
+ status: summary.truncated ? 'partial' : 'success',
383
+ observedAt: new Date().toISOString(),
384
+ source: 'certificate_transparency',
385
+ durationMs: Date.now() - startedAt,
386
+ complete: !summary.truncated,
387
+ truncated: summary.truncated,
388
+ limitations: ['Certificate Transparency observations indicate public certificate logging, not current site activity or maliciousness.'],
389
+ diagnostics: { certificateRows: data.length, matches: summary.matches.length },
390
+ }),
391
+ };
392
+ }
393
+ export { searchCertificateTransparency, summarizeCtResults };