@slicedearth/whoisleuth-cli 2.3.0 → 2.4.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -0,0 +1,125 @@
1
+ // Native HTML tree construction over an already-captured body. Resource limits
2
+ // are enforced while nodes are created, including implied/reconstructed nodes;
3
+ // a post-parse traversal limit alone would not bound hostile parser work.
4
+ import { parse, defaultTreeAdapter as adapter } from 'parse5';
5
+ import { MAX_HOMEPAGE_BYTES } from "./outbound-request-bounds.mjs";
6
+ import { MAX_STATIC_HTML_TAGS, MAX_STATIC_HTML_NODES, MAX_STATIC_HTML_DEPTH, } from "../packages/contracts/page-fingerprints.mjs";
7
+ export { MAX_STATIC_HTML_TAGS, MAX_STATIC_HTML_NODES, MAX_STATIC_HTML_DEPTH, MAX_STATIC_HTML_TREE_EVENTS, } from "../packages/contracts/page-fingerprints.mjs";
8
+ // UTF-8 decoding cannot produce more UTF-16 code units than captured bytes.
9
+ // Direct string callers share this ceiling; tree construction remains bounded
10
+ // independently of the source size.
11
+ export const MAX_STATIC_HTML_CHARS = MAX_HOMEPAGE_BYTES;
12
+ const MAX_CONSTRUCTED_ATTRIBUTE_CHARS = MAX_STATIC_HTML_CHARS * 4;
13
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
14
+ export function parseBoundedHtml(value) {
15
+ const supplied = typeof value === 'string' ? value : '';
16
+ const stop = new Error('Static HTML construction limit reached.');
17
+ let document;
18
+ let nodes = 0;
19
+ let elements = 0;
20
+ let depth = 0;
21
+ let attributeCharacters = 0;
22
+ let constructionLimitReached = false;
23
+ function reserveNode() {
24
+ if (nodes >= MAX_STATIC_HTML_NODES)
25
+ throw stop;
26
+ nodes += 1;
27
+ }
28
+ function reserveAttributes(attributes) {
29
+ for (const attribute of attributes) {
30
+ attributeCharacters += attribute.name.length + attribute.value.length;
31
+ if (attributeCharacters > MAX_CONSTRUCTED_ATTRIBUTE_CHARS)
32
+ throw stop;
33
+ }
34
+ }
35
+ const boundedAdapter = {
36
+ ...adapter,
37
+ createDocument() {
38
+ reserveNode();
39
+ document = adapter.createDocument();
40
+ return document;
41
+ },
42
+ createDocumentFragment() { reserveNode(); return adapter.createDocumentFragment(); },
43
+ createElement(name, namespace, attributes) {
44
+ // Allow the three mandatory document elements in addition to source tags.
45
+ // Reconstructed formatting elements also consume this finite allowance.
46
+ if (elements >= MAX_STATIC_HTML_TAGS + 3)
47
+ throw stop;
48
+ elements += 1;
49
+ reserveAttributes(attributes);
50
+ reserveNode();
51
+ return adapter.createElement(name, namespace, attributes);
52
+ },
53
+ createCommentNode(text) { reserveNode(); return adapter.createCommentNode(text); },
54
+ insertText(parent, text) {
55
+ const previous = parent.childNodes.at(-1);
56
+ if (!previous || !adapter.isTextNode(previous))
57
+ reserveNode();
58
+ adapter.insertText(parent, text);
59
+ },
60
+ insertTextBefore(parent, text, reference) {
61
+ const previous = parent.childNodes[parent.childNodes.indexOf(reference) - 1];
62
+ if (!previous || !adapter.isTextNode(previous))
63
+ reserveNode();
64
+ adapter.insertTextBefore(parent, text, reference);
65
+ },
66
+ setDocumentType(parent, name, publicId, systemId) {
67
+ if (!parent.childNodes.some((node) => node.nodeName === '#documentType'))
68
+ reserveNode();
69
+ adapter.setDocumentType(parent, name, publicId, systemId);
70
+ },
71
+ adoptAttributes(element, attributes) {
72
+ reserveAttributes(attributes);
73
+ adapter.adoptAttributes(element, attributes);
74
+ },
75
+ onItemPush() {
76
+ depth += 1;
77
+ if (depth > MAX_STATIC_HTML_DEPTH)
78
+ throw stop;
79
+ },
80
+ onItemPop() { depth -= 1; },
81
+ };
82
+ try {
83
+ parse(supplied.slice(0, MAX_STATIC_HTML_CHARS), {
84
+ treeAdapter: boundedAdapter,
85
+ sourceCodeLocationInfo: true,
86
+ scriptingEnabled: true,
87
+ });
88
+ }
89
+ catch (error) {
90
+ if (error !== stop)
91
+ throw error;
92
+ constructionLimitReached = true;
93
+ }
94
+ // Parser or adapter failures are not silently re-labelled as empty evidence.
95
+ if (!document)
96
+ throw new Error('Static HTML document was not initialised.');
97
+ return { document, inputLimitReached: supplied.length > MAX_STATIC_HTML_CHARS, constructionLimitReached };
98
+ }
99
+ export function isHtmlElement(node) {
100
+ return adapter.isElementNode(node) && node.namespaceURI === HTML_NAMESPACE;
101
+ }
102
+ // Iterative native-tree order, without entering inert template fragments.
103
+ // Foreign elements are retained for structure, not interpreted as HTML forms.
104
+ export function* htmlTreeEvents(document) {
105
+ const pending = [{ node: document, exit: false }];
106
+ while (pending.length) {
107
+ const { node, exit } = pending.pop();
108
+ if (adapter.isElementNode(node)) {
109
+ yield { kind: exit ? 'end' : 'start', element: node };
110
+ if (exit)
111
+ continue;
112
+ pending.push({ node, exit: true });
113
+ if (isHtmlElement(node) && node.tagName === 'template')
114
+ continue;
115
+ }
116
+ else if (adapter.isTextNode(node)) {
117
+ yield { kind: 'text', text: node.value };
118
+ }
119
+ if ('childNodes' in node) {
120
+ for (let index = node.childNodes.length - 1; index >= 0; index -= 1) {
121
+ pending.push({ node: node.childNodes[index], exit: false });
122
+ }
123
+ }
124
+ }
125
+ }
@@ -3,6 +3,23 @@ export const MAX_BOUNDED_JSON_KEYS = 50_000;
3
3
  export const MAX_BOUNDED_JSON_VALUES = 100_000;
4
4
  export const MAX_BOUNDED_JSON_CONTAINER_ITEMS = 10_000;
5
5
  export const UNSAFE_JSON_OBJECT_KEYS = Object.freeze(['__proto__']);
6
+ /** Aggregate work allowed by a separately enforced UTF-8 byte budget. */
7
+ export function boundedJsonLimitsForBytes(maximumBytes) {
8
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
9
+ throw new TypeError('The JSON byte budget must be a positive safe integer.');
10
+ }
11
+ // A member needs at least five bytes (comma, two quotes, colon, value).
12
+ // Values need at least two bytes each, apart from a scalar root or the last
13
+ // array item. These upper bounds do not depend on a model's current fields.
14
+ // Nesting and individual-container limits remain independently bounded.
15
+ return {
16
+ maximumKeys: Math.floor(maximumBytes / 5),
17
+ maximumValues: Math.ceil(maximumBytes / 2),
18
+ // Decoded text is a lower bound on its UTF-8 JSON representation. Checking
19
+ // it before stringify also prevents an oversized string allocation.
20
+ maximumStringCodeUnits: maximumBytes,
21
+ };
22
+ }
6
23
  function syntaxError() {
7
24
  throw new TypeError('Artefact input is not valid JSON.');
8
25
  }
@@ -16,7 +33,14 @@ export function assertBoundedJsonStructure(value, label = 'JSON value', limits =
16
33
  const maximumContainerItems = limits.maximumContainerItems ?? MAX_BOUNDED_JSON_CONTAINER_ITEMS;
17
34
  let keys = 0;
18
35
  let values = 0;
36
+ let stringCodeUnits = 0;
19
37
  const ancestors = new Set();
38
+ const text = (value) => {
39
+ stringCodeUnits += value.length;
40
+ if (limits.maximumStringCodeUnits !== undefined && stringCodeUnits > limits.maximumStringCodeUnits) {
41
+ throw new TypeError(`${label} exceeds the aggregate text limit.`);
42
+ }
43
+ };
20
44
  const visit = (current, depth) => {
21
45
  values += 1;
22
46
  if (values > maximumValues) {
@@ -25,7 +49,11 @@ export function assertBoundedJsonStructure(value, label = 'JSON value', limits =
25
49
  if (depth > maximumDepth) {
26
50
  throw new TypeError(`${label} exceeds the ${maximumDepth}-level nesting limit.`);
27
51
  }
28
- if (current === null || typeof current === 'string' || typeof current === 'boolean')
52
+ if (typeof current === 'string') {
53
+ text(current);
54
+ return;
55
+ }
56
+ if (current === null || typeof current === 'boolean')
29
57
  return;
30
58
  if (typeof current === 'number') {
31
59
  if (!Number.isFinite(current))
@@ -91,6 +119,7 @@ export function assertBoundedJsonStructure(value, label = 'JSON value', limits =
91
119
  for (const key of objectKeys) {
92
120
  if (!isSafeJsonObjectKey(key))
93
121
  throw new TypeError(`${label} contains an unsafe object key.`);
122
+ text(key);
94
123
  visit(descriptors[key].value, depth + 1);
95
124
  }
96
125
  }
@@ -106,9 +135,14 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
106
135
  let index = 0;
107
136
  let keys = 0;
108
137
  let values = 0;
138
+ let stringCodeUnits = 0;
109
139
  const whitespace = () => {
110
- while (index < raw.length && /[\t\n\r ]/u.test(raw[index]))
140
+ while (index < raw.length) {
141
+ const code = raw.charCodeAt(index);
142
+ if (code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)
143
+ break;
111
144
  index += 1;
145
+ }
112
146
  };
113
147
  const stringToken = () => {
114
148
  const start = index;
@@ -117,12 +151,18 @@ export function scanBoundedJson(raw, limits = {}, observeString) {
117
151
  const character = raw[index];
118
152
  if (character === '"') {
119
153
  index += 1;
154
+ let value;
120
155
  try {
121
- return { value: JSON.parse(raw.slice(start, index)), offset: start };
156
+ value = JSON.parse(raw.slice(start, index));
122
157
  }
123
158
  catch {
124
159
  syntaxError();
125
160
  }
161
+ stringCodeUnits += value.length;
162
+ if (limits.maximumStringCodeUnits !== undefined && stringCodeUnits > limits.maximumStringCodeUnits) {
163
+ throw new TypeError('Artefact JSON exceeds the aggregate text limit.');
164
+ }
165
+ return { value, offset: start };
126
166
  }
127
167
  if (character === '\\') {
128
168
  index += 1;
@@ -5,9 +5,11 @@
5
5
  // are treated as untrusted inputs and are not retained in the result.
6
6
  import { createHash } from 'node:crypto';
7
7
  import { Worker } from 'node:worker_threads';
8
+ import { createInlineLibraryScanner } from "./browser-library-worker.mjs";
8
9
  import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
9
10
  import { CISA_KEV_CATALOG } from "./generated/cisa-kev-catalog.mjs";
10
- import { createObservation } from "../packages/evidence/observation.mjs";
11
+ import { createObservation, normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
12
+ import { isCveIdentifier } from "../packages/contracts/vulnerability-identifiers.mjs";
11
13
  import { BROWSER_LIBRARY_PROFILE_VERSION, MAX_LIBRARY_FINDINGS, } from "./lookup-child-profile-contract.mjs";
12
14
  import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
13
15
  // Catalogue expressions are reviewed third-party inputs. Keep every individual
@@ -25,7 +27,6 @@ const MAX_MATCHES_PER_PATTERN = 4;
25
27
  const MAX_VERSION_LENGTH = 64;
26
28
  const COMPONENT_RE = /^[a-z0-9._-]{1,80}$/i;
27
29
  const VERSION_RE = /^[0-9][0-9.a-z_-]{0,63}$/i;
28
- const CVE_RE = /^CVE-[0-9X-]+$/;
29
30
  const GHSA_RE = /^GHSA-[A-Z0-9-]+$/;
30
31
  const CWE_RE = /^CWE-[0-9]+$/;
31
32
  const SEVERITY_WEIGHT = Object.freeze({
@@ -37,6 +38,9 @@ const SEVERITY_WEIGHT = Object.freeze({
37
38
  });
38
39
  const CATALOG_COMPONENTS = RETIRE_BROWSER_CATALOG.components;
39
40
  const KNOWN_EXPLOITED_IDENTIFIERS = new Set(CISA_KEV_CATALOG.identifiers);
41
+ const KNOWN_EXPLOITED_RELEASED_AT = normalizeExplicitIsoTimestamp(CISA_KEV_CATALOG.releasedAt);
42
+ if (KNOWN_EXPLOITED_RELEASED_AT === null)
43
+ throw new Error('Pinned known-exploited catalogue has an invalid release timestamp.');
40
44
  const INLINE_EXTRACTOR_CATALOGUE = Object.freeze(Object.entries(CATALOG_COMPONENTS).map(([component, value]) => {
41
45
  const extractors = record(record(value).extractors);
42
46
  return Object.freeze({
@@ -95,10 +99,16 @@ parentPort.on('message', (job) => {
95
99
  } catch {
96
100
  Atomics.store(control, 0, -1);
97
101
  }
98
- Atomics.notify(control, 0);
102
+ parentPort.postMessage({ id: job.id });
99
103
  });
100
104
  `;
101
- let inlineRegexWorker = null;
105
+ const scanInlineLibrary = createInlineLibraryScanner(() => new Worker(INLINE_REGEX_WORKER_SOURCE, { eval: true, workerData: { catalogue: INLINE_EXTRACTOR_CATALOGUE } }), {
106
+ maximumCharacters: MAX_INLINE_LIBRARY_SCAN_TOTAL_CHARS + MAX_SCRIPT_ELEMENTS,
107
+ outputBytes: MAX_INLINE_LIBRARY_WORKER_BYTES,
108
+ deadlineMs: MAX_INLINE_LIBRARY_SCAN_MS,
109
+ // One worker/output buffer, with at most sixteen bounded pending inputs.
110
+ pending: 16,
111
+ });
102
112
  function record(value) {
103
113
  return value !== null && typeof value === 'object' && !Array.isArray(value)
104
114
  ? value
@@ -218,48 +228,15 @@ function scanFilename(detected, reference) {
218
228
  }
219
229
  }
220
230
  }
221
- function scanInlineSignatures(detected, value) {
231
+ async function scanInlineSignatures(detected, value, signal) {
222
232
  if (!value)
223
233
  return { timedOut: false, unavailable: false };
224
- const controlBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
225
- const outputBuffer = new SharedArrayBuffer(MAX_INLINE_LIBRARY_WORKER_BYTES);
226
- const control = new Int32Array(controlBuffer);
234
+ const result = await scanInlineLibrary(value, signal);
235
+ signal?.throwIfAborted();
236
+ if (result.output === null)
237
+ return result;
227
238
  try {
228
- if (!inlineRegexWorker) {
229
- const createdWorker = new Worker(INLINE_REGEX_WORKER_SOURCE, {
230
- eval: true,
231
- workerData: { catalogue: INLINE_EXTRACTOR_CATALOGUE },
232
- });
233
- inlineRegexWorker = createdWorker;
234
- createdWorker.unref();
235
- createdWorker.once('exit', () => {
236
- if (inlineRegexWorker === createdWorker)
237
- inlineRegexWorker = null;
238
- });
239
- createdWorker.once('error', () => {
240
- if (inlineRegexWorker === createdWorker)
241
- inlineRegexWorker = null;
242
- });
243
- }
244
- inlineRegexWorker.postMessage({ value, control: controlBuffer, output: outputBuffer });
245
- }
246
- catch {
247
- void inlineRegexWorker?.terminate().catch(() => { });
248
- inlineRegexWorker = null;
249
- return { timedOut: false, unavailable: true };
250
- }
251
- const wait = Atomics.wait(control, 0, 0, MAX_INLINE_LIBRARY_SCAN_MS);
252
- const length = Atomics.load(control, 0);
253
- if (wait === 'timed-out') {
254
- void inlineRegexWorker?.terminate().catch(() => { });
255
- inlineRegexWorker = null;
256
- return { timedOut: true, unavailable: false };
257
- }
258
- if (length < 1 || length > MAX_INLINE_LIBRARY_WORKER_BYTES) {
259
- return { timedOut: false, unavailable: true };
260
- }
261
- try {
262
- const parsed = JSON.parse(Buffer.from(new Uint8Array(outputBuffer, 0, length)).toString('utf8'));
239
+ const parsed = JSON.parse(result.output);
263
240
  if (!Array.isArray(parsed))
264
241
  return { timedOut: false, unavailable: true };
265
242
  for (const item of parsed.slice(0, MAX_LIBRARY_FINDINGS * 4)) {
@@ -311,13 +288,20 @@ function findingFromDetection(detected) {
311
288
  let highestSeverity = null;
312
289
  const identifiers = new Set();
313
290
  const weaknesses = new Set();
291
+ let omittedCveIdentifiers = 0;
314
292
  for (const vulnerability of vulnerabilities) {
315
293
  const candidateSeverity = severity(vulnerability.severity);
316
294
  if (candidateSeverity
317
295
  && (!highestSeverity || SEVERITY_WEIGHT[candidateSeverity] > SEVERITY_WEIGHT[highestSeverity]))
318
296
  highestSeverity = candidateSeverity;
319
297
  const vulnerabilityIdentifiers = record(vulnerability.identifiers);
320
- for (const cve of boundedStringArray(vulnerabilityIdentifiers.CVE, 16, CVE_RE)) {
298
+ omittedCveIdentifiers += typeof vulnerability.omittedCveIdentifiers === 'number'
299
+ && Number.isSafeInteger(vulnerability.omittedCveIdentifiers) && vulnerability.omittedCveIdentifiers > 0
300
+ ? vulnerability.omittedCveIdentifiers : 0;
301
+ const suppliedCve = Array.isArray(vulnerabilityIdentifiers.CVE) ? vulnerabilityIdentifiers.CVE : [];
302
+ const validCve = suppliedCve.filter(isCveIdentifier);
303
+ omittedCveIdentifiers += suppliedCve.length - validCve.length;
304
+ for (const cve of validCve.slice(0, 16)) {
321
305
  if (identifiers.size < MAX_ADVISORY_IDENTIFIERS)
322
306
  identifiers.add(cve);
323
307
  }
@@ -332,7 +316,7 @@ function findingFromDetection(detected) {
332
316
  }
333
317
  const advisoryIdentifiers = [...identifiers].sort();
334
318
  const knownExploitedIdentifiers = advisoryIdentifiers.filter((identifier) => KNOWN_EXPLOITED_IDENTIFIERS.has(identifier));
335
- return {
319
+ const finding = {
336
320
  id: detected.component,
337
321
  name: detected.component,
338
322
  apparentVersion: detected.version,
@@ -344,8 +328,10 @@ function findingFromDetection(detected) {
344
328
  knownExploitedIdentifiers,
345
329
  weaknessClasses: [...weaknesses].sort(),
346
330
  };
331
+ return { finding, omittedCveIdentifiers };
347
332
  }
348
- function analyzeBrowserLibraries(input = {}) {
333
+ async function analyzeBrowserLibraries(input = {}) {
334
+ input.signal?.throwIfAborted();
349
335
  const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
350
336
  const detected = new Map();
351
337
  let referencesExamined = 0;
@@ -369,10 +355,12 @@ function analyzeBrowserLibraries(input = {}) {
369
355
  inlineSignatureSamples.push(signatureScan.signatureContent);
370
356
  }
371
357
  }
372
- const inlineSignatureResult = scanInlineSignatures(detected, inlineSignatureSamples.join('\u0000'));
358
+ const inlineSignatureResult = await scanInlineSignatures(detected, inlineSignatureSamples.join('\u0000'), input.signal);
359
+ input.signal?.throwIfAborted();
373
360
  const inlineSignatureUnavailable = inlineSignatureResult.timedOut || inlineSignatureResult.unavailable;
374
- const allFindings = [...detected.values()]
375
- .map(findingFromDetection)
361
+ const projected = [...detected.values()].map(findingFromDetection);
362
+ const omittedCveIdentifiers = projected.reduce((total, entry) => total + entry.omittedCveIdentifiers, 0);
363
+ const allFindings = projected.map((entry) => entry.finding)
376
364
  .sort((left, right) => ((right.highestSeverity ? SEVERITY_WEIGHT[right.highestSeverity] : -1)
377
365
  - (left.highestSeverity ? SEVERITY_WEIGHT[left.highestSeverity] : -1)
378
366
  || right.advisoryCount - left.advisoryCount
@@ -386,7 +374,8 @@ function analyzeBrowserLibraries(input = {}) {
386
374
  || htmlAnalysis.inlineLimitReached
387
375
  || inlineSignatureLimitReached
388
376
  || inlineSignatureUnavailable
389
- || findingLimitReached;
377
+ || findingLimitReached
378
+ || omittedCveIdentifiers > 0;
390
379
  const limitations = [
391
380
  'Library versions are inferred from passive static signatures and may be absent, transformed, or misleading.',
392
381
  'A catalogue advisory match identifies a component version associated with a published advisory; it does not establish reachability or exploitability.',
@@ -411,6 +400,8 @@ function analyzeBrowserLibraries(input = {}) {
411
400
  limitations.push('Passive library signature matching was unavailable in its isolated worker; hash evidence was still evaluated.');
412
401
  if (findingLimitReached)
413
402
  limitations.push(`Only the first ${MAX_LIBRARY_FINDINGS} library findings were retained.`);
403
+ if (omittedCveIdentifiers)
404
+ limitations.push(`${omittedCveIdentifiers} supplied CVE identifier ${omittedCveIdentifiers === 1 ? 'entry was' : 'entries were'} omitted from matching catalogue advisories because of invalid syntax or a source limit. Advisory matches are still counted.`);
414
405
  return {
415
406
  profileVersion: BROWSER_LIBRARY_PROFILE_VERSION,
416
407
  catalog: {
@@ -421,7 +412,7 @@ function analyzeBrowserLibraries(input = {}) {
421
412
  knownExploitedCatalog: {
422
413
  name: 'CISA KEV',
423
414
  version: CISA_KEV_CATALOG.catalogVersion,
424
- releasedAt: CISA_KEV_CATALOG.releasedAt,
415
+ releasedAt: KNOWN_EXPLOITED_RELEASED_AT,
425
416
  },
426
417
  ...createObservation({
427
418
  status: truncated ? 'partial' : 'success',
@@ -0,0 +1,112 @@
1
+ /** One bounded matcher worker. Queue deadlines include waiting; cancellation never stops a different scan. */
2
+ export function createInlineLibraryScanner(createWorker, limits) {
3
+ let worker = null;
4
+ let active = null;
5
+ let closing = false;
6
+ let nextId = 0;
7
+ const queue = [];
8
+ const pending = new Set();
9
+ function retireWorker() {
10
+ if (!worker)
11
+ return;
12
+ const retired = worker;
13
+ worker = null;
14
+ closing = true;
15
+ void Promise.resolve().then(() => retired.terminate()).catch(() => { }).finally(() => { closing = false; pump(); });
16
+ }
17
+ function pump() {
18
+ if (active || closing)
19
+ return;
20
+ const job = queue.shift();
21
+ if (!job) {
22
+ worker?.unref();
23
+ return;
24
+ }
25
+ active = job;
26
+ let current;
27
+ const control = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
28
+ const output = new Uint8Array(new SharedArrayBuffer(limits.outputBytes));
29
+ const cleanup = job.cleanup;
30
+ const failed = () => settle(job, { timedOut: false, unavailable: true, output: null }, true);
31
+ const message = (value) => {
32
+ if (!value || typeof value !== 'object' || !('id' in value) || value.id !== job.id)
33
+ return;
34
+ const length = Atomics.load(control, 0);
35
+ if (length < 1 || length > output.byteLength) {
36
+ failed();
37
+ return;
38
+ }
39
+ settle(job, { timedOut: false, unavailable: false, output: Buffer.from(output.subarray(0, length)).toString('utf8') });
40
+ };
41
+ job.cleanup = () => {
42
+ cleanup();
43
+ current?.off('message', message);
44
+ current?.off('error', failed);
45
+ current?.off('exit', failed);
46
+ };
47
+ try {
48
+ if (!worker) {
49
+ const created = createWorker();
50
+ worker = created;
51
+ const idleStopped = () => { if (!active && worker === created)
52
+ worker = null; };
53
+ created.on('error', idleStopped);
54
+ created.on('exit', idleStopped);
55
+ }
56
+ current = worker;
57
+ if (active !== job) {
58
+ retireWorker();
59
+ return;
60
+ }
61
+ current.ref();
62
+ current.on('message', message);
63
+ current.on('error', failed);
64
+ current.on('exit', failed);
65
+ current.postMessage({ id: job.id, value: job.value, control: control.buffer, output: output.buffer });
66
+ }
67
+ catch {
68
+ failed();
69
+ }
70
+ }
71
+ function settle(job, result, retire = false, aborted = false) {
72
+ if (!pending.delete(job))
73
+ return;
74
+ job.cleanup();
75
+ if (active === job) {
76
+ active = null;
77
+ if (retire)
78
+ retireWorker();
79
+ }
80
+ else {
81
+ const index = queue.indexOf(job);
82
+ if (index >= 0)
83
+ queue.splice(index, 1);
84
+ }
85
+ if (aborted)
86
+ job.reject(job.signal?.reason ?? new DOMException('Aborted', 'AbortError'));
87
+ else
88
+ job.resolve(result);
89
+ queueMicrotask(pump);
90
+ }
91
+ return function scan(value, signal) {
92
+ signal?.throwIfAborted();
93
+ if (!value)
94
+ return Promise.resolve({ timedOut: false, unavailable: false, output: '[]' });
95
+ if (value.length > limits.maximumCharacters || pending.size >= limits.pending) {
96
+ return Promise.resolve({ timedOut: false, unavailable: true, output: null });
97
+ }
98
+ return new Promise((resolve, reject) => {
99
+ const job = { id: ++nextId, value, ...(signal ? { signal } : {}), resolve, reject, cleanup: () => { } };
100
+ const timer = setTimeout(() => settle(job, { timedOut: true, unavailable: false, output: null }, true), limits.deadlineMs);
101
+ const abort = () => settle(job, { timedOut: false, unavailable: true, output: null }, true, true);
102
+ job.cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
103
+ pending.add(job);
104
+ queue.push(job);
105
+ signal?.addEventListener('abort', abort, { once: true });
106
+ if (signal?.aborted)
107
+ abort();
108
+ else
109
+ pump();
110
+ });
111
+ };
112
+ }
@@ -1,5 +1,5 @@
1
1
  // Static client-side behaviour indicators derived from the existing bounded
2
- // HTML tokenizer pass. Scripts are never fetched or executed. Only fixed
2
+ // HTML parse. Scripts are never fetched or executed. Only fixed
3
3
  // indicator identifiers, descriptions, evidence classes, and bounded counts
4
4
  // leave this module; script references and contents are discarded.
5
5
  import { createObservation } from "../packages/evidence/observation.mjs";
@@ -20,7 +20,6 @@ function patternCount(value, pattern) {
20
20
  }
21
21
  function analyzeClientBehavior(input = {}) {
22
22
  const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
23
- const markup = htmlAnalysis.markup;
24
23
  const inlineScripts = htmlAnalysis.scripts
25
24
  .filter((script) => script.reference === null)
26
25
  .map((script) => script.inlineContent)
@@ -31,7 +30,8 @@ function analyzeClientBehavior(input = {}) {
31
30
  return;
32
31
  indicators.push({ id, label, evidenceClass, occurrences: boundedOccurrences(occurrences), explanation });
33
32
  }
34
- add('inline_event_handlers', 'Inline event handlers', 'static_markup', patternCount(markup, /\son[a-z]{2,24}="/gu), 'Inline HTML event-handler attributes were observed.');
33
+ add('inline_event_handlers', 'Inline event handlers', 'static_markup', htmlAnalysis.elements.reduce((count, element) => count
34
+ + element.attributes.filter((attribute) => /^on[a-z]{2,24}$/u.test(attribute.name)).length, 0), 'Inline HTML event-handler attributes were observed.');
35
35
  add('client_navigation', 'Client-side navigation', 'inline_script', patternCount(inlineScripts, /\b(?:history\.(?:pushState|replaceState)|location\.(?:assign|replace)|window\.location)\b/gu), 'Inline script contains a client-side navigation API.');
36
36
  add('form_interception', 'Form interception', 'inline_script', patternCount(inlineScripts, /(?:addEventListener\s*\(\s*['"]submit['"]|\.onsubmit\s*=|preventDefault\s*\()/gu), 'Inline script contains a form-submission or default-action interception marker.');
37
37
  add('service_worker', 'Service worker registration', 'inline_script', patternCount(inlineScripts, /\bnavigator\.serviceWorker\.register\s*\(/gu), 'Inline script contains a service-worker registration call.');
package/lib/ct-search.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // earliest public signal a new lookalike exists at all. Shared by the
8
8
  // Express server and the Netlify Functions.
9
9
  import { parse } from 'tldts';
10
+ import { setTimeout as sleep } from 'node:timers/promises';
10
11
  import { normalizeCtQuery } from "./ct-query.mjs";
11
12
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
12
13
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
@@ -71,23 +72,26 @@ function normalizeHostname(raw) {
71
72
  h = h.slice(2);
72
73
  return HOSTNAME_RE.test(h) && isValidAsciiHostname(h, { requireLowercase: true }) ? h : null;
73
74
  }
74
- function delay(ms) {
75
- return new Promise((resolve) => setTimeout(resolve, ms));
75
+ function delay(ms, signal) {
76
+ return sleep(ms, undefined, signal ? { signal } : {});
76
77
  }
77
78
  async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
79
+ dependencies.signal?.throwIfAborted();
78
80
  const fetcher = dependencies.fetcher || safeFetch;
79
81
  const wait = dependencies.delay || delay;
80
82
  const controller = new AbortController();
83
+ const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
81
84
  const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
82
85
  try {
83
86
  let res;
84
87
  try {
85
88
  res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
86
89
  headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
87
- signal: controller.signal,
90
+ signal,
88
91
  }, 0);
89
92
  }
90
93
  catch (err) {
94
+ dependencies.signal?.throwIfAborted();
91
95
  if (!(err instanceof Error) || err.name !== 'AbortError')
92
96
  throw err;
93
97
  if (attempt < MAX_TIMEOUT_RETRIES)
@@ -101,7 +105,8 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
101
105
  // undici's own idle-timeout eventually notices.
102
106
  await res.body?.cancel().catch(() => { });
103
107
  if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
104
- await wait(RETRY_DELAY_MS * (attempt + 1));
108
+ await wait(RETRY_DELAY_MS * (attempt + 1), dependencies.signal);
109
+ dependencies.signal?.throwIfAborted();
105
110
  return fetchCrtSh(keyword, attempt + 1, dependencies);
106
111
  }
107
112
  throw new Error(RETRYABLE_STATUSES.has(res.status)
@@ -109,6 +114,7 @@ async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
109
114
  : `crt.sh returned ${res.status}`);
110
115
  }
111
116
  const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
117
+ signal.throwIfAborted();
112
118
  if (truncated) {
113
119
  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.`);
114
120
  }
@@ -464,6 +470,7 @@ function summarizeCtResults(rows) {
464
470
  // Production search
465
471
  // ---------------------------------------------------------------------------
466
472
  async function searchCertificateTransparency(keyword, dependencies = {}) {
473
+ dependencies.signal?.throwIfAborted();
467
474
  const startedAt = Date.now();
468
475
  const trimmed = normalizeCtQuery(keyword);
469
476
  if (!trimmed) {