@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
@@ -3,10 +3,11 @@
3
3
  // those intermediate representations exist only long enough to derive a digest.
4
4
  import { createHash } from 'node:crypto';
5
5
  import { analyzeStaticHtml } from "./static-html-analysis.mjs";
6
- const PAGE_FINGERPRINT_VERSION = 1;
7
- const MAX_FINGERPRINT_SOURCE_BYTES = 300000;
6
+ import { PAGE_FINGERPRINT_VERSION, PAGE_FINGERPRINT_PARSERS, PAGE_FINGERPRINT_TOKEN_LIMITS, } from "../packages/contracts/page-fingerprints.mjs";
7
+ import { MAX_HOMEPAGE_BYTES } from "./outbound-request-bounds.mjs";
8
+ const MAX_FINGERPRINT_SOURCE_BYTES = MAX_HOMEPAGE_BYTES;
8
9
  const MAX_FINGERPRINT_TAG_LENGTH = 4096;
9
- const MAX_FINGERPRINT_TOKENS = 4096;
10
+ const MAX_FINGERPRINT_TOKENS = PAGE_FINGERPRINT_TOKEN_LIMITS[PAGE_FINGERPRINT_VERSION];
10
11
  const MAX_FINGERPRINT_ATTRIBUTES = 64;
11
12
  const MAX_VISIBLE_TEXT_TOKENS = 8192;
12
13
  const MAX_FORM_FINGERPRINTS = 50;
@@ -15,11 +16,6 @@ const MAX_RESOURCE_HOSTS = 30;
15
16
  const MAX_IDENTIFIER_VALUES = 30;
16
17
  const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu;
17
18
  const HAS_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
18
- const COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
19
- const RAW_BODY_RE = /<(script|style|noscript|textarea|template)\b([^>]*)>[\s\S]*?(?:<\/\1\s*>|$)/gi;
20
- const MARKUP_TOKEN_RE = /<\/?[A-Za-z][A-Za-z0-9:-]*\b[^>]*>|[^<]+/g;
21
- const TAG_NAME_RE = /^<\s*(\/?)\s*([A-Za-z][A-Za-z0-9:-]*)/;
22
- const ATTRIBUTE_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
23
19
  const WORD_RE = /[\p{L}\p{N}]+/gu;
24
20
  const URL_ATTRIBUTE_NAMES = new Set(['action', 'cite', 'data', 'formaction', 'href', 'poster', 'src']);
25
21
  const OMITTED_ATTRIBUTE_NAMES = new Set(['integrity', 'nonce', 'srcdoc', 'style', 'value']);
@@ -28,16 +24,12 @@ function sha256(value) {
28
24
  return createHash('sha256').update(value).digest('hex');
29
25
  }
30
26
  function boundedSource(html) {
31
- const original = Buffer.from(typeof html === 'string' ? html : String(html || ''), 'utf8');
32
- const truncated = original.length > MAX_FINGERPRINT_SOURCE_BYTES;
33
- const bytes = truncated ? original.subarray(0, MAX_FINGERPRINT_SOURCE_BYTES) : original;
27
+ const supplied = typeof html === 'string' ? html : '';
28
+ const prefix = Buffer.from(supplied.slice(0, MAX_FINGERPRINT_SOURCE_BYTES), 'utf8');
29
+ const truncated = supplied.length > MAX_FINGERPRINT_SOURCE_BYTES || prefix.length > MAX_FINGERPRINT_SOURCE_BYTES;
30
+ const bytes = prefix.subarray(0, MAX_FINGERPRINT_SOURCE_BYTES);
34
31
  return { bytes, text: bytes.toString('utf8'), truncated };
35
32
  }
36
- function staticMarkup(html) {
37
- return html
38
- .replace(COMMENT_RE, ' ')
39
- .replace(RAW_BODY_RE, '<$1$2></$1>');
40
- }
41
33
  function normalizedExactBodyHash(value) {
42
34
  if (!value || typeof value !== 'object')
43
35
  return null;
@@ -56,39 +48,6 @@ function normalizedExactBodyHash(value) {
56
48
  source: 'captured-response-bytes',
57
49
  };
58
50
  }
59
- function parseAttributes(tag) {
60
- const start = tag.search(/\s/);
61
- if (start === -1)
62
- return { values: new Map(), truncated: false };
63
- const values = new Map();
64
- let truncated = false;
65
- let match;
66
- ATTRIBUTE_RE.lastIndex = 0;
67
- while ((match = ATTRIBUTE_RE.exec(tag.slice(start)))) {
68
- const name = match[1]?.toLowerCase();
69
- if (!name)
70
- continue;
71
- if (values.has(name))
72
- continue;
73
- if (values.size >= MAX_FINGERPRINT_ATTRIBUTES) {
74
- truncated = true;
75
- break;
76
- }
77
- values.set(name, match[2] ?? match[3] ?? match[4] ?? '');
78
- }
79
- return { values, truncated };
80
- }
81
- function decodeStableEntities(text) {
82
- const named = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
83
- return text.replace(/&(?:#(\d{1,7})|#x([a-f0-9]{1,6})|([a-z]{2,8}));/gi, (whole, decimal, hex, name) => {
84
- if (name)
85
- return named[name.toLowerCase()] ?? whole.toLowerCase();
86
- const point = Number.parseInt(decimal || hex, decimal ? 10 : 16);
87
- if (!Number.isInteger(point) || point <= 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff))
88
- return ' ';
89
- return String.fromCodePoint(point);
90
- });
91
- }
92
51
  function looksRandom(value) {
93
52
  if (typeof value !== 'string')
94
53
  return false;
@@ -108,7 +67,7 @@ function reduceDynamicValues(value) {
108
67
  .replace(/\b(?=[a-z0-9_-]{20,}\b)(?=[a-z0-9_-]*[a-z])(?=[a-z0-9_-]*\d)[a-z0-9_-]+\b/gi, '<id>');
109
68
  }
110
69
  function normalizeText(value, maxLength = 512) {
111
- const normalized = reduceDynamicValues(decodeStableEntities(String(value || '')))
70
+ const normalized = reduceDynamicValues(String(value || ''))
112
71
  .normalize('NFKC')
113
72
  .replace(CONTROL_RE, ' ')
114
73
  .replace(/\s+/g, ' ')
@@ -150,49 +109,33 @@ function normalizeAttribute(name, value, baseUrl) {
150
109
  return name;
151
110
  return normalized ? `${name}=${normalized}` : name;
152
111
  }
153
- function normalizedMarkupAndStructure(html, baseUrl) {
154
- const markup = staticMarkup(html);
112
+ function normalizedMarkupAndStructure(analysis, baseUrl) {
155
113
  const normalizedTokens = [];
156
- const structureTokens = [];
114
+ const structureTokens = analysis.structureTokens;
157
115
  const visibleSegments = [];
158
- let normalizedTruncated = false;
159
- let structureTruncated = false;
116
+ let normalizedTruncated = analysis.structureLimitReached;
117
+ const structureTruncated = analysis.structureLimitReached;
160
118
  let attributesTruncated = false;
161
- let oversizedTag = false;
162
- let match;
163
- MARKUP_TOKEN_RE.lastIndex = 0;
164
- while ((match = MARKUP_TOKEN_RE.exec(markup))) {
165
- const token = match[0];
166
- if (token.startsWith('<')) {
167
- if (token.length > MAX_FINGERPRINT_TAG_LENGTH) {
168
- oversizedTag = true;
169
- continue;
170
- }
171
- const nameMatch = token.match(TAG_NAME_RE);
172
- const name = nameMatch?.[2]?.toLowerCase();
173
- if (!nameMatch || !name)
174
- continue;
175
- const closing = Boolean(nameMatch[1]);
176
- const selfClosing = !closing && (VOID_TAGS.has(name) || /\/\s*>$/.test(token));
177
- const structure = closing ? `/${name}` : selfClosing ? `${name}/` : name;
178
- if (structureTokens.length < MAX_FINGERPRINT_TOKENS)
179
- structureTokens.push(structure);
180
- else
181
- structureTruncated = true;
119
+ const oversizedTag = analysis.elements.some((element) => element.attributesTruncated);
120
+ for (const token of analysis.tokens) {
121
+ if (token.kind !== 'text') {
182
122
  if (normalizedTokens.length >= MAX_FINGERPRINT_TOKENS) {
183
123
  normalizedTruncated = true;
184
124
  continue;
185
125
  }
186
- if (closing) {
187
- normalizedTokens.push(`</${name}>`);
126
+ if (token.kind === 'end') {
127
+ normalizedTokens.push(`</${token.name}>`);
188
128
  continue;
189
129
  }
190
- const parsed = parseAttributes(token);
191
- if (parsed.truncated)
130
+ const element = token.element;
131
+ const name = element.name;
132
+ const selfClosing = element.html && VOID_TAGS.has(name);
133
+ if (element.attributesTruncated || element.attributes.length > MAX_FINGERPRINT_ATTRIBUTES)
192
134
  attributesTruncated = true;
193
- const attributes = [...parsed.values.entries()]
135
+ const values = new Map(element.attributes.slice(0, MAX_FINGERPRINT_ATTRIBUTES).map(({ name, value }) => [name, value]));
136
+ const attributes = [...values.entries()]
194
137
  .map(([attributeName, value]) => {
195
- const metaKey = name === 'meta' ? String(parsed.values.get('name') || parsed.values.get('property') || '') : '';
138
+ const metaKey = name === 'meta' ? String(values.get('name') || values.get('property') || '') : '';
196
139
  if (attributeName === 'content' && /(?:csrf|nonce|session|token)/i.test(metaKey))
197
140
  return null;
198
141
  return normalizeAttribute(attributeName, value, baseUrl);
@@ -202,7 +145,7 @@ function normalizedMarkupAndStructure(html, baseUrl) {
202
145
  normalizedTokens.push(`<${name}${attributes.length ? ` ${attributes.join(' ')}` : ''}${selfClosing ? '/' : ''}>`);
203
146
  continue;
204
147
  }
205
- const text = normalizeText(token, MAX_FINGERPRINT_SOURCE_BYTES);
148
+ const text = normalizeText(token.value, MAX_FINGERPRINT_SOURCE_BYTES);
206
149
  if (!text)
207
150
  continue;
208
151
  visibleSegments.push(text);
@@ -270,66 +213,55 @@ function formActionClass(value, baseUrl) {
270
213
  return 'external-insecure';
271
214
  return 'external';
272
215
  }
273
- function formStructureFingerprint(markup, baseUrl) {
274
- const forms = [];
275
- let current = null;
216
+ function formStructureFingerprint(analysis, baseUrl) {
217
+ const formByElement = new Map();
218
+ const firstElementById = new Map();
276
219
  let formLimitReached = false;
277
220
  let controlLimitReached = false;
278
221
  let controls = 0;
279
- let match;
280
- MARKUP_TOKEN_RE.lastIndex = 0;
281
- while ((match = MARKUP_TOKEN_RE.exec(markup))) {
282
- const token = match[0];
283
- if (!token.startsWith('<') || token.length > MAX_FINGERPRINT_TAG_LENGTH)
284
- continue;
285
- const nameMatch = token.match(TAG_NAME_RE);
286
- const name = nameMatch?.[2]?.toLowerCase();
287
- if (!nameMatch || !name)
288
- continue;
289
- const closing = Boolean(nameMatch[1]);
290
- if (name === 'form') {
291
- if (closing) {
292
- if (current)
293
- forms.push(current);
294
- current = null;
295
- continue;
296
- }
297
- if (current)
298
- forms.push(current);
299
- if (forms.length >= MAX_FORM_FINGERPRINTS) {
222
+ for (const [index, element] of analysis.elements.entries()) {
223
+ const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
224
+ const id = attributes.get('id');
225
+ if (id && !firstElementById.has(id))
226
+ firstElementById.set(id, index);
227
+ if (element.html && element.name === 'form') {
228
+ if (formByElement.size >= MAX_FORM_FINGERPRINTS) {
300
229
  formLimitReached = true;
301
- current = null;
302
230
  continue;
303
231
  }
304
- const attributes = parseAttributes(token).values;
305
232
  const rawMethod = normalizeText(attributes.get('method') || 'get', 20);
306
- current = {
233
+ formByElement.set(index, {
307
234
  method: /^[a-z]{1,20}$/.test(rawMethod) ? rawMethod : 'other',
308
235
  action: formActionClass(attributes.get('action'), baseUrl),
309
236
  controls: {},
310
- };
237
+ });
238
+ }
239
+ }
240
+ for (const element of analysis.elements) {
241
+ const name = element.name;
242
+ if (!element.html || !['button', 'input', 'select', 'textarea'].includes(name))
311
243
  continue;
244
+ const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
245
+ let owner = attributes.has('form') ? firstElementById.get(attributes.get('form')) ?? null : element.parent;
246
+ if (!attributes.has('form')) {
247
+ while (owner !== null && !formByElement.has(owner))
248
+ owner = analysis.elements[owner]?.parent ?? null;
312
249
  }
313
- if (!current || closing || !['button', 'input', 'select', 'textarea'].includes(name))
250
+ const current = owner === null ? undefined : formByElement.get(owner);
251
+ if (!current)
314
252
  continue;
315
253
  if (controls >= MAX_FORM_CONTROLS) {
316
254
  controlLimitReached = true;
317
255
  continue;
318
256
  }
319
257
  controls += 1;
320
- const attributes = parseAttributes(token).values;
321
258
  const rawType = name === 'input' || name === 'button'
322
259
  ? normalizeText(attributes.get('type') || (name === 'input' ? 'text' : 'submit'), 30)
323
260
  : name;
324
261
  const type = /^[a-z0-9-]{1,30}$/.test(rawType) ? `${name}:${rawType}` : `${name}:other`;
325
262
  current.controls[type] = (current.controls[type] || 0) + 1;
326
263
  }
327
- if (current)
328
- forms.push(current);
329
- if (forms.length > MAX_FORM_FINGERPRINTS) {
330
- forms.length = MAX_FORM_FINGERPRINTS;
331
- formLimitReached = true;
332
- }
264
+ const forms = [...formByElement.values()];
333
265
  for (const form of forms)
334
266
  form.controls = Object.fromEntries(Object.entries(form.controls).sort(([left], [right]) => left.localeCompare(right)));
335
267
  return forms.length ? {
@@ -337,7 +269,7 @@ function formStructureFingerprint(markup, baseUrl) {
337
269
  value: sha256(JSON.stringify(forms)),
338
270
  formCount: forms.length,
339
271
  controlCount: controls,
340
- truncated: formLimitReached || controlLimitReached,
272
+ truncated: formLimitReached || controlLimitReached || analysis.tagLimitReached || analysis.inputLimitReached,
341
273
  } : null;
342
274
  }
343
275
  function normalizedResourceHosts(resources) {
@@ -418,21 +350,25 @@ function createPageFingerprints(html, options = {}) {
418
350
  };
419
351
  if (options.sourceTruncated === true && exact.scope === 'complete-body')
420
352
  exact = { ...exact, scope: 'captured-prefix' };
421
- const normalized = normalizedMarkupAndStructure(source.text, baseUrl);
422
- const parsedStructure = analyzeStaticHtml(source.text, { baseUrl });
353
+ const parsedStructure = !source.truncated && options.htmlAnalysis
354
+ ? options.htmlAnalysis : analyzeStaticHtml(source.text, { baseUrl, includeVisibleText: true });
355
+ const documentIncomplete = options.sourceTruncated === true || source.truncated
356
+ || parsedStructure.inputLimitReached || parsedStructure.tagLimitReached;
357
+ const treeIncomplete = documentIncomplete || parsedStructure.structureLimitReached;
358
+ const resolutionBase = parsedStructure.effectiveBaseUrl ?? baseUrl;
359
+ const normalized = normalizedMarkupAndStructure(parsedStructure, resolutionBase);
423
360
  const text = visibleTextFingerprint(normalized.visibleText);
424
361
  const structureSimilarity = simHash64(parsedStructure.structureTokens);
425
- const forms = formStructureFingerprint(staticMarkup(source.text), baseUrl);
362
+ const forms = formStructureFingerprint(parsedStructure, resolutionBase);
426
363
  const resourceHosts = normalizedResourceHosts(options.resources);
427
364
  const identifiers = normalizedIdentifiers(options.trackingIdentifiers, options.identifiersTruncated === true);
428
- const truncated = options.sourceTruncated === true || source.truncated || normalized.normalizedTruncated
429
- || normalized.structureTruncated || parsedStructure.inputLimitReached || parsedStructure.tagLimitReached
430
- || parsedStructure.structureLimitReached || text?.truncated === true || forms?.truncated === true
365
+ const truncated = treeIncomplete || normalized.normalizedTruncated
366
+ || normalized.structureTruncated || text?.truncated === true || forms?.truncated === true
431
367
  || resourceHosts.truncated || identifiers.truncated;
432
368
  const limitations = [
433
369
  'Fingerprints summarise capped static HTML and are comparison aids, not cryptographic proof of page authorship or intent.',
434
370
  'Visible-text SimHash is a fuzzy similarity fingerprint and must not be treated as a cryptographic digest.',
435
- 'DOM-structure SimHash is derived from a standards-compliant static token stream and is a review aid, not proof that two pages share an author or template.',
371
+ 'DOM-structure and form fingerprints describe parsed ancestry and explicit form references, not every browser form-owner repair, executed behaviour or proof of a shared author.',
436
372
  ];
437
373
  if (source.truncated)
438
374
  limitations.push(`Fingerprint input was capped at ${MAX_FINGERPRINT_SOURCE_BYTES} UTF-8 bytes.`);
@@ -453,24 +389,24 @@ function createPageFingerprints(html, options = {}) {
453
389
  algorithm: 'sha256',
454
390
  value: sha256(normalized.normalizedTokens.join('\n')),
455
391
  tokenCount: normalized.normalizedTokens.length,
456
- truncated: normalized.normalizedTruncated,
392
+ truncated: treeIncomplete || normalized.normalizedTruncated,
457
393
  },
458
- visibleText: text,
394
+ visibleText: text ? { ...text, truncated: treeIncomplete || text.truncated } : null,
459
395
  domStructure: {
460
396
  algorithm: 'sha256',
461
397
  value: sha256(normalized.structureTokens.join('\n')),
462
398
  nodeCount: normalized.structureTokens.length,
463
- parser: 'static-tag-sequence-v1',
464
- truncated: normalized.structureTruncated,
399
+ parser: PAGE_FINGERPRINT_PARSERS[PAGE_FINGERPRINT_VERSION],
400
+ truncated: treeIncomplete || normalized.structureTruncated,
465
401
  similarity: structureSimilarity ? {
466
402
  algorithm: 'simhash64-v1',
467
403
  value: structureSimilarity.value,
468
404
  tokenCount: parsedStructure.structureTokens.length,
469
405
  featureCount: structureSimilarity.featureCount,
470
- truncated: parsedStructure.inputLimitReached || parsedStructure.tagLimitReached || parsedStructure.structureLimitReached,
406
+ truncated: treeIncomplete,
471
407
  } : null,
472
408
  },
473
- formStructure: forms,
409
+ formStructure: forms ? { ...forms, truncated: documentIncomplete || forms.truncated } : null,
474
410
  resourceHosts,
475
411
  identifiers,
476
412
  complete: !truncated,
@@ -24,13 +24,17 @@ function addFinding(findings, role, label, confidence, evidence) {
24
24
  }
25
25
  function analyzePageRole(input = {}) {
26
26
  const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
27
- const markup = htmlAnalysis.markup;
27
+ const elements = htmlAnalysis.elements.filter((element) => element.html && element.name !== 'template');
28
+ // Reuse native element evidence instead of allocating and scanning a second,
29
+ // independently capped reconstruction of the same document.
30
+ const marker = (pattern) => elements.some((element) => pattern.test(element.name)
31
+ || element.attributes.some(({ name, value }) => pattern.test(name) || pattern.test(value.toLowerCase())));
28
32
  const title = boundedLowercase(input.pageTitle, MAX_ROLE_TITLE_CHARS);
29
33
  const activityStatus = boundedLowercase(input.activityStatus, 40);
30
34
  const forms = htmlAnalysis.forms;
31
35
  const findings = [];
32
36
  const challengeEvidence = [];
33
- if (/(?:captcha|recaptcha|hcaptcha|turnstile|cf-chl|challenge-form|verify-human)/u.test(markup)) {
37
+ if (marker(/(?:captcha|recaptcha|hcaptcha|turnstile|cf-chl|challenge-form|verify-human)/u)) {
34
38
  challengeEvidence.push('Static challenge or human-verification marker observed');
35
39
  }
36
40
  if (/(?:just a moment|checking your browser|verify you are human|access denied)/u.test(title)) {
@@ -43,19 +47,19 @@ function analyzePageRole(input = {}) {
43
47
  if (forms.categories.username > 0 || forms.categories.email > 0) {
44
48
  authenticationEvidence.push('Username or email-purpose input observed');
45
49
  }
46
- if (/(?:login|log-in|signin|sign-in|authentication|current-password|new-password)/u.test(markup)) {
50
+ if (marker(/(?:login|log-in|signin|sign-in|authentication|current-password|new-password)/u)) {
47
51
  authenticationEvidence.push('Static authentication marker observed');
48
52
  }
49
53
  addFinding(findings, 'authentication', 'Authentication', forms.categories.password > 0 ? 'high' : 'medium', authenticationEvidence);
50
54
  const commerceEvidence = [];
51
55
  if (forms.categories.payment > 0)
52
56
  commerceEvidence.push('Payment-purpose input observed');
53
- if (/(?:checkout|shopping-cart|add-to-cart|product-price|woocommerce|shopify-payment)/u.test(markup)) {
57
+ if (marker(/(?:checkout|shopping-cart|add-to-cart|product-price|woocommerce|shopify-payment)/u)) {
54
58
  commerceEvidence.push('Static commerce or checkout marker observed');
55
59
  }
56
60
  addFinding(findings, 'commerce', 'Commerce', forms.categories.payment > 0 ? 'high' : 'medium', commerceEvidence);
57
61
  const supportEvidence = [];
58
- if (/(?:contact|support|helpdesk|customer-service)/u.test(markup)) {
62
+ if (marker(/(?:contact|support|helpdesk|customer-service)/u)) {
59
63
  supportEvidence.push('Static support or contact marker observed');
60
64
  }
61
65
  if (forms.formsObserved > 0 && forms.categories.password === 0 && forms.categories.payment === 0) {
@@ -65,14 +69,14 @@ function analyzePageRole(input = {}) {
65
69
  const parkedEvidence = [];
66
70
  if (activityStatus === 'parked')
67
71
  parkedEvidence.push('Existing activity analysis classified the page as parked');
68
- if (/(?:domain-for-sale|buy-this-domain|parking-page|sedoparking)/u.test(markup)) {
72
+ if (marker(/(?:domain-for-sale|buy-this-domain|parking-page|sedoparking)/u)) {
69
73
  parkedEvidence.push('Static domain-sale or parking marker observed');
70
74
  }
71
75
  addFinding(findings, 'parked_sale', 'Parked or for sale', activityStatus === 'parked' ? 'high' : 'medium', parkedEvidence);
72
76
  const contentEvidence = [];
73
- if (/<(?:article|main)(?:\s|>)/u.test(markup))
77
+ if (elements.some(({ name }) => name === 'article' || name === 'main'))
74
78
  contentEvidence.push('Semantic article or main-content element observed');
75
- if (/<(?:h1|h2)(?:\s|>)/u.test(markup))
79
+ if (elements.some(({ name }) => name === 'h1' || name === 'h2'))
76
80
  contentEvidence.push('Static heading structure observed');
77
81
  addFinding(findings, 'content', 'Content or publication', contentEvidence.length > 1 ? 'medium' : 'low', contentEvidence);
78
82
  if (!findings.length) {
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // Pure JS, no external dependency and no native bindings, so it runs the same
10
10
  // on the serverless (Netlify) and self-hosted (Express) paths. That means
11
- // decoding the image ourselves: Node has no built-in image decoder, only
11
+ // decoding the image ourselves: the runtime has no built-in image decoder, only
12
12
  // zlib (used here for PNG inflate). We support the favicon formats that
13
13
  // actually occur in practice - PNG, and PNG- or BMP-encoded ICO - and return
14
14
  // null for anything else (GIF/JPEG/SVG), which simply falls back to
@@ -23,8 +23,8 @@
23
23
  import * as zlib from 'node:zlib';
24
24
  import { hammingDistanceHex, isInformativePerceptualHash, } from "./perceptual-hash-comparison.mjs";
25
25
  // Bound the decode work on attacker-controlled image bytes. Real favicons top
26
- // out around 256x256; anything claiming to be larger is refused rather than
27
- // decoded (it would also never be a legitimate favicon to compare against).
26
+ // out around 256x256; larger inputs above this ceiling remain exact-only
27
+ // evidence rather than causing unbounded pixel allocation.
28
28
  const MAX_DIM = 1024;
29
29
  const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
30
30
  function byteAt(bytes, index) {
@@ -70,14 +70,18 @@ function decodePng(buf) {
70
70
  const dataStart = offset + 8;
71
71
  const dataEnd = dataStart + length;
72
72
  if (dataEnd + 4 > buf.length)
73
- break; // truncated chunk
73
+ return null;
74
74
  const data = buf.subarray(dataStart, dataEnd);
75
75
  if (type === 'IHDR') {
76
+ if (length !== 13 || width !== 0 || offset !== 8)
77
+ return null;
76
78
  width = buf.readUInt32BE(dataStart);
77
79
  height = buf.readUInt32BE(dataStart + 4);
78
80
  bitDepth = byteAt(data, 8);
79
81
  colorType = byteAt(data, 9);
80
82
  interlace = byteAt(data, 12);
83
+ if (byteAt(data, 10) !== 0 || byteAt(data, 11) !== 0)
84
+ return null;
81
85
  }
82
86
  else if (type === 'PLTE') {
83
87
  palette = data;
@@ -100,6 +104,13 @@ function decodePng(buf) {
100
104
  return null;
101
105
  if (colorType === 3 && !palette)
102
106
  return null;
107
+ if (palette && (palette.length < 3 || palette.length > 768 || palette.length % 3 !== 0))
108
+ return null;
109
+ if (transparency && ((colorType === 0 && transparency.length !== 2)
110
+ || (colorType === 2 && transparency.length !== 6)
111
+ || (colorType === 3 && (!palette || transparency.length > palette.length / 3))
112
+ || colorType === 4 || colorType === 6))
113
+ return null;
103
114
  if (idatParts.length === 0)
104
115
  return null;
105
116
  // A valid non-interlaced 8-bit PNG decompresses to exactly one filter byte
@@ -162,6 +173,8 @@ function decodePng(buf) {
162
173
  let alpha = 255;
163
174
  if (colorType === 0) {
164
175
  r = g = b = byteAt(recon, s);
176
+ if (transparency && r === transparency.readUInt16BE(0))
177
+ alpha = 0;
165
178
  }
166
179
  else if (colorType === 4) {
167
180
  r = g = b = byteAt(recon, s);
@@ -171,6 +184,9 @@ function decodePng(buf) {
171
184
  r = byteAt(recon, s);
172
185
  g = byteAt(recon, s + 1);
173
186
  b = byteAt(recon, s + 2);
187
+ if (transparency && r === transparency.readUInt16BE(0)
188
+ && g === transparency.readUInt16BE(2) && b === transparency.readUInt16BE(4))
189
+ alpha = 0;
174
190
  }
175
191
  else if (colorType === 6) {
176
192
  r = byteAt(recon, s);
@@ -200,13 +216,13 @@ function decodePng(buf) {
200
216
  // BITMAPINFOHEADER, an optional palette (for <=8-bit depths), then bottom-up
201
217
  // pixel rows. Supports the depths favicons actually ship: 1/4/8-bit
202
218
  // palettized and 24/32-bit truecolour. The stored height is doubled (it
203
- // includes the 1-bpp AND transparency mask); we use the real image height
204
- // and ignore the mask (opaque is fine for a brightness-based hash).
219
+ // includes the 1-bpp AND transparency mask). Non-alpha and legacy all-zero
220
+ // alpha images use that mask; meaningful 32-bit alpha remains authoritative.
205
221
  function decodeDib(buf) {
206
222
  if (buf.length < 40)
207
223
  return null;
208
224
  const headerSize = buf.readUInt32LE(0);
209
- if (headerSize < 40)
225
+ if (headerSize < 40 || headerSize > buf.length)
210
226
  return null;
211
227
  const width = buf.readInt32LE(4);
212
228
  const rawHeight = buf.readInt32LE(8);
@@ -216,7 +232,9 @@ function decodeDib(buf) {
216
232
  return null; // BI_RGB only
217
233
  if (![1, 4, 8, 24, 32].includes(bitCount))
218
234
  return null;
219
- const height = Math.floor(Math.abs(rawHeight) / 2) || Math.abs(rawHeight);
235
+ if (rawHeight <= 0 || rawHeight % 2 !== 0 || buf.readUInt16LE(12) !== 1)
236
+ return null;
237
+ const height = rawHeight / 2;
220
238
  if (width < 1 || height < 1 || width > MAX_DIM || height > MAX_DIM)
221
239
  return null;
222
240
  // Palette (BGRA quads) for indexed depths, sitting between the header and
@@ -235,6 +253,11 @@ function decodeDib(buf) {
235
253
  const rowSize = Math.floor((bitCount * width + 31) / 32) * 4; // padded to 4 bytes
236
254
  if (pixelStart + rowSize * height > buf.length)
237
255
  return null;
256
+ const maskStart = pixelStart + rowSize * height;
257
+ const maskRowSize = Math.ceil(width / 32) * 4;
258
+ const maskPresent = maskStart + maskRowSize * height <= buf.length;
259
+ if (bitCount !== 32 && !maskPresent)
260
+ return null;
238
261
  const paletteColor = (index) => {
239
262
  if (!palette || index >= palette.count)
240
263
  return [0, 0, 0];
@@ -282,11 +305,16 @@ function decodeDib(buf) {
282
305
  }
283
306
  // Many legacy 32-bit BMP-in-ICO payloads leave the alpha plane all-zero and
284
307
  // rely on the separate 1-bit AND mask for transparency. Trusting those alpha
285
- // bytes literally would composite the whole icon to white and hash to a
286
- // meaningless all-zero value, so treat a fully-zero alpha plane as opaque.
287
- if (bitCount === 32 && !sawNonzeroAlpha) {
288
- for (let i = 3; i < pixels.length; i += 4)
289
- pixels[i] = 255;
308
+ // bytes literally would composite the whole icon to white. Restore opacity
309
+ // only where the legacy mask permits it. No mask plus meaningful 32-bit
310
+ // alpha is valid; an omitted legacy mask retains the opaque fallback.
311
+ if (bitCount !== 32 || !sawNonzeroAlpha) {
312
+ for (let y = 0; y < height; y += 1) {
313
+ for (let x = 0; x < width; x += 1) {
314
+ const mask = maskPresent ? byteAt(buf, maskStart + (height - 1 - y) * maskRowSize + (x >> 3)) : 0;
315
+ pixels[(y * width + x) * 4 + 3] = mask & (0x80 >> (x & 7)) ? 0 : 255;
316
+ }
317
+ }
290
318
  }
291
319
  return { width, height, pixels };
292
320
  }
@@ -428,4 +456,5 @@ function inspectDecodedImage(buf) {
428
456
  // produced by the optional local capture package. Keep the generic name as an
429
457
  // alias so the evidence contract does not imply that a screenshot is a favicon.
430
458
  const imagePerceptualHash = faviconPerceptualHash;
431
- export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash, inspectDecodedImage };
459
+ // Pixel consumers share the same strict byte/dimension admission as hashing.
460
+ export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash, inspectDecodedImage, decodeImage as decodeBoundedImagePixels, dHash as imagePixelsPerceptualHash };