@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,451 @@
1
+ // Perceptual (fuzzy) favicon hashing - complements the exact SHA-256 in
2
+ // lib/favicon.mts. An exact hash only catches a byte-for-byte copied favicon;
3
+ // a lookalike that resized, recompressed, or re-saved the brand's icon (very
4
+ // common - the copy passes through a different tool than the original) slips
5
+ // past it while still being visually identical. A perceptual hash catches
6
+ // those near-clones, with the exact hash retained as the higher-confidence
7
+ // tier.
8
+ //
9
+ // Pure JS, no external dependency and no native bindings, so it runs the same
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
12
+ // zlib (used here for PNG inflate). We support the favicon formats that
13
+ // actually occur in practice - PNG, and PNG- or BMP-encoded ICO - and return
14
+ // null for anything else (GIF/JPEG/SVG), which simply falls back to
15
+ // exact-hash-only, exactly as before this module existed.
16
+ //
17
+ // The hash itself is a 64-bit dHash (difference hash): downscale to 9x8
18
+ // grayscale and record, per row, whether each pixel is brighter than its
19
+ // right-hand neighbour. dHash is intentionally robust to scale and to
20
+ // uniform brightness/contrast shifts, which is exactly the resave/recompress
21
+ // case, while staying cheap (no DCT). Output is 16 lowercase hex chars;
22
+ // similarity is the Hamming distance between two such strings.
23
+ import * as zlib from 'node:zlib';
24
+ // Bound the decode work on attacker-controlled image bytes. Real favicons top
25
+ // out around 256x256; anything claiming to be larger is refused rather than
26
+ // decoded (it would also never be a legitimate favicon to compare against).
27
+ const MAX_DIM = 1024;
28
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
29
+ function byteAt(bytes, index) {
30
+ return bytes[index] ?? 0;
31
+ }
32
+ function isPng(buf) {
33
+ return buf.length >= 8 && buf.subarray(0, 8).equals(PNG_SIGNATURE);
34
+ }
35
+ // ICO container: reserved(0) + type(1 = icon) as two little-endian uint16s.
36
+ function isIco(buf) {
37
+ return buf.length >= 6 && buf.readUInt16LE(0) === 0 && buf.readUInt16LE(2) === 1;
38
+ }
39
+ function paethPredictor(a, b, c) {
40
+ const p = a + b - c;
41
+ const pa = Math.abs(p - a);
42
+ const pb = Math.abs(p - b);
43
+ const pc = Math.abs(p - c);
44
+ if (pa <= pb && pa <= pc)
45
+ return a;
46
+ if (pb <= pc)
47
+ return b;
48
+ return c;
49
+ }
50
+ // Decodes the subset of PNG that favicons actually use: 8-bit depth,
51
+ // non-interlaced, colour types 0/2/3/6 (gray, RGB, palette, RGBA). Returns
52
+ // { width, height, pixels } with pixels as RGBA bytes, or null for anything
53
+ // outside that subset (or malformed input). CRCs are intentionally not
54
+ // verified - we're computing a fuzzy hash, not validating integrity.
55
+ function decodePng(buf) {
56
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
57
+ let width = 0;
58
+ let height = 0;
59
+ let bitDepth = 0;
60
+ let colorType = 0;
61
+ let interlace = 0;
62
+ let palette = null;
63
+ let transparency = null;
64
+ const idatParts = [];
65
+ let offset = 8;
66
+ while (offset + 8 <= buf.length) {
67
+ const length = buf.readUInt32BE(offset);
68
+ const type = buf.toString('ascii', offset + 4, offset + 8);
69
+ const dataStart = offset + 8;
70
+ const dataEnd = dataStart + length;
71
+ if (dataEnd + 4 > buf.length)
72
+ break; // truncated chunk
73
+ const data = buf.subarray(dataStart, dataEnd);
74
+ if (type === 'IHDR') {
75
+ width = buf.readUInt32BE(dataStart);
76
+ height = buf.readUInt32BE(dataStart + 4);
77
+ bitDepth = byteAt(data, 8);
78
+ colorType = byteAt(data, 9);
79
+ interlace = byteAt(data, 12);
80
+ }
81
+ else if (type === 'PLTE') {
82
+ palette = data;
83
+ }
84
+ else if (type === 'tRNS') {
85
+ transparency = data;
86
+ }
87
+ else if (type === 'IDAT') {
88
+ idatParts.push(Buffer.from(data));
89
+ }
90
+ else if (type === 'IEND') {
91
+ break;
92
+ }
93
+ offset = dataEnd + 4; // skip the 4-byte CRC
94
+ }
95
+ const channels = CHANNELS[colorType];
96
+ if (!channels || bitDepth !== 8 || interlace !== 0)
97
+ return null;
98
+ if (width < 1 || height < 1 || width > MAX_DIM || height > MAX_DIM)
99
+ return null;
100
+ if (colorType === 3 && !palette)
101
+ return null;
102
+ if (idatParts.length === 0)
103
+ return null;
104
+ // A valid non-interlaced 8-bit PNG decompresses to exactly one filter byte
105
+ // plus one scanline per row. Cap the inflate at that size so a malicious
106
+ // IDAT can't expand a sub-200KB favicon into hundreds of MB (a zip bomb)
107
+ // and exhaust the process during concurrent deep scans - dimensions are
108
+ // already bounded by MAX_DIM above, so this ceiling is a few MB at most.
109
+ const stride = width * channels;
110
+ const expandedLength = (stride + 1) * height;
111
+ let raw;
112
+ try {
113
+ raw = zlib.inflateSync(Buffer.concat(idatParts), { maxOutputLength: expandedLength });
114
+ }
115
+ catch {
116
+ return null; // malformed, or output would exceed the expected size
117
+ }
118
+ if (raw.length < expandedLength)
119
+ return null;
120
+ // Reverse the per-scanline PNG filters into the raw sample bytes.
121
+ const recon = new Uint8Array(stride * height);
122
+ let pos = 0;
123
+ for (let y = 0; y < height; y += 1) {
124
+ const filterType = byteAt(raw, pos);
125
+ pos += 1;
126
+ for (let x = 0; x < stride; x += 1) {
127
+ const rawVal = byteAt(raw, pos + x);
128
+ const a = x >= channels ? byteAt(recon, y * stride + x - channels) : 0;
129
+ const b = y > 0 ? byteAt(recon, (y - 1) * stride + x) : 0;
130
+ const c = x >= channels && y > 0 ? byteAt(recon, (y - 1) * stride + x - channels) : 0;
131
+ let value;
132
+ switch (filterType) {
133
+ case 0:
134
+ value = rawVal;
135
+ break;
136
+ case 1:
137
+ value = rawVal + a;
138
+ break;
139
+ case 2:
140
+ value = rawVal + b;
141
+ break;
142
+ case 3:
143
+ value = rawVal + ((a + b) >> 1);
144
+ break;
145
+ case 4:
146
+ value = rawVal + paethPredictor(a, b, c);
147
+ break;
148
+ default: return null;
149
+ }
150
+ recon[y * stride + x] = value & 0xff;
151
+ }
152
+ pos += stride;
153
+ }
154
+ // Expand whatever colour type we decoded into a uniform RGBA buffer.
155
+ const pixels = new Uint8ClampedArray(width * height * 4);
156
+ for (let i = 0; i < width * height; i += 1) {
157
+ const s = i * channels;
158
+ let r = 0;
159
+ let g = 0;
160
+ let b = 0;
161
+ let alpha = 255;
162
+ if (colorType === 0) {
163
+ r = g = b = byteAt(recon, s);
164
+ }
165
+ else if (colorType === 4) {
166
+ r = g = b = byteAt(recon, s);
167
+ alpha = byteAt(recon, s + 1);
168
+ }
169
+ else if (colorType === 2) {
170
+ r = byteAt(recon, s);
171
+ g = byteAt(recon, s + 1);
172
+ b = byteAt(recon, s + 2);
173
+ }
174
+ else if (colorType === 6) {
175
+ r = byteAt(recon, s);
176
+ g = byteAt(recon, s + 1);
177
+ b = byteAt(recon, s + 2);
178
+ alpha = byteAt(recon, s + 3);
179
+ }
180
+ else { // palette
181
+ const idx = byteAt(recon, s);
182
+ if (!palette || idx * 3 + 2 >= palette.length)
183
+ return null;
184
+ r = byteAt(palette, idx * 3);
185
+ g = byteAt(palette, idx * 3 + 1);
186
+ b = byteAt(palette, idx * 3 + 2);
187
+ if (transparency && idx < transparency.length)
188
+ alpha = byteAt(transparency, idx);
189
+ }
190
+ const d = i * 4;
191
+ pixels[d] = r;
192
+ pixels[d + 1] = g;
193
+ pixels[d + 2] = b;
194
+ pixels[d + 3] = alpha;
195
+ }
196
+ return { width, height, pixels };
197
+ }
198
+ // Decodes an uncompressed BMP DIB (the classic favicon.ico payload) - a
199
+ // BITMAPINFOHEADER, an optional palette (for <=8-bit depths), then bottom-up
200
+ // pixel rows. Supports the depths favicons actually ship: 1/4/8-bit
201
+ // palettized and 24/32-bit truecolour. The stored height is doubled (it
202
+ // includes the 1-bpp AND transparency mask); we use the real image height
203
+ // and ignore the mask (opaque is fine for a brightness-based hash).
204
+ function decodeDib(buf) {
205
+ if (buf.length < 40)
206
+ return null;
207
+ const headerSize = buf.readUInt32LE(0);
208
+ if (headerSize < 40)
209
+ return null;
210
+ const width = buf.readInt32LE(4);
211
+ const rawHeight = buf.readInt32LE(8);
212
+ const bitCount = buf.readUInt16LE(14);
213
+ const compression = buf.readUInt32LE(16);
214
+ if (compression !== 0)
215
+ return null; // BI_RGB only
216
+ if (![1, 4, 8, 24, 32].includes(bitCount))
217
+ return null;
218
+ const height = Math.floor(Math.abs(rawHeight) / 2) || Math.abs(rawHeight);
219
+ if (width < 1 || height < 1 || width > MAX_DIM || height > MAX_DIM)
220
+ return null;
221
+ // Palette (BGRA quads) for indexed depths, sitting between the header and
222
+ // the pixel data. biClrUsed (offset 32) overrides the default 2^depth count.
223
+ let palette = null;
224
+ let pixelStart = headerSize;
225
+ if (bitCount <= 8) {
226
+ const declared = buf.readUInt32LE(32);
227
+ const paletteCount = declared > 0 ? declared : (1 << bitCount);
228
+ const paletteBytes = paletteCount * 4;
229
+ if (headerSize + paletteBytes > buf.length)
230
+ return null;
231
+ palette = { start: headerSize, count: paletteCount };
232
+ pixelStart = headerSize + paletteBytes;
233
+ }
234
+ const rowSize = Math.floor((bitCount * width + 31) / 32) * 4; // padded to 4 bytes
235
+ if (pixelStart + rowSize * height > buf.length)
236
+ return null;
237
+ const paletteColor = (index) => {
238
+ if (!palette || index >= palette.count)
239
+ return [0, 0, 0];
240
+ const p = palette.start + index * 4;
241
+ return [byteAt(buf, p + 2), byteAt(buf, p + 1), byteAt(buf, p)]; // stored BGRA -> RGB
242
+ };
243
+ const pixels = new Uint8ClampedArray(width * height * 4);
244
+ let sawNonzeroAlpha = false;
245
+ for (let y = 0; y < height; y += 1) {
246
+ const srcRow = pixelStart + (height - 1 - y) * rowSize; // stored bottom-up
247
+ for (let x = 0; x < width; x += 1) {
248
+ let r = 0;
249
+ let g = 0;
250
+ let b = 0;
251
+ let alpha = 255;
252
+ if (bitCount === 24 || bitCount === 32) {
253
+ const s = srcRow + x * (bitCount / 8);
254
+ b = byteAt(buf, s);
255
+ g = byteAt(buf, s + 1);
256
+ r = byteAt(buf, s + 2);
257
+ if (bitCount === 32)
258
+ alpha = byteAt(buf, s + 3);
259
+ }
260
+ else if (bitCount === 8) {
261
+ [r, g, b] = paletteColor(byteAt(buf, srcRow + x));
262
+ }
263
+ else if (bitCount === 4) {
264
+ const byte = byteAt(buf, srcRow + (x >> 1));
265
+ const index = x & 1 ? byte & 0x0f : byte >> 4;
266
+ [r, g, b] = paletteColor(index);
267
+ }
268
+ else { // 1-bit
269
+ const byte = byteAt(buf, srcRow + (x >> 3));
270
+ const index = (byte >> (7 - (x & 7))) & 1;
271
+ [r, g, b] = paletteColor(index);
272
+ }
273
+ if (alpha !== 0)
274
+ sawNonzeroAlpha = true;
275
+ const d = (y * width + x) * 4;
276
+ pixels[d] = r;
277
+ pixels[d + 1] = g;
278
+ pixels[d + 2] = b;
279
+ pixels[d + 3] = alpha;
280
+ }
281
+ }
282
+ // Many legacy 32-bit BMP-in-ICO payloads leave the alpha plane all-zero and
283
+ // rely on the separate 1-bit AND mask for transparency. Trusting those alpha
284
+ // bytes literally would composite the whole icon to white and hash to a
285
+ // meaningless all-zero value, so treat a fully-zero alpha plane as opaque.
286
+ if (bitCount === 32 && !sawNonzeroAlpha) {
287
+ for (let i = 3; i < pixels.length; i += 4)
288
+ pixels[i] = 255;
289
+ }
290
+ return { width, height, pixels };
291
+ }
292
+ // Decodes an ICO directory, preferring the largest image (best detail for
293
+ // hashing) but falling back to progressively smaller entries when a larger
294
+ // one is in a variant we don't decode (e.g. a 4-bit-palette PNG sub-entry
295
+ // alongside a plain BMP one). A multi-size favicon.ico only needs one usable
296
+ // entry, and any size hashes to nearly the same dHash since it downsamples.
297
+ function decodeIco(buf) {
298
+ const count = buf.readUInt16LE(4);
299
+ if (count < 1 || 6 + count * 16 > buf.length)
300
+ return null;
301
+ const entries = [];
302
+ for (let i = 0; i < count; i += 1) {
303
+ const entry = 6 + i * 16;
304
+ const w = buf[entry] || 256;
305
+ const h = buf[entry + 1] || 256;
306
+ const size = buf.readUInt32LE(entry + 8);
307
+ const dataOffset = buf.readUInt32LE(entry + 12);
308
+ if (dataOffset + size > buf.length || size < 1)
309
+ continue;
310
+ entries.push({ area: w * h, dataOffset, size });
311
+ }
312
+ entries.sort((a, b) => b.area - a.area);
313
+ for (const { dataOffset, size } of entries) {
314
+ const payload = buf.subarray(dataOffset, dataOffset + size);
315
+ const image = isPng(payload) ? decodePng(payload) : decodeDib(payload);
316
+ if (image)
317
+ return image;
318
+ }
319
+ return null;
320
+ }
321
+ function decodeImage(buf) {
322
+ if (!Buffer.isBuffer(buf))
323
+ return null;
324
+ if (isPng(buf))
325
+ return decodePng(buf);
326
+ if (isIco(buf))
327
+ return decodeIco(buf);
328
+ return null;
329
+ }
330
+ // Downscales to cols x rows of grayscale luma. Transparency is composited
331
+ // over white first, so a favicon with a transparent background hashes the
332
+ // same way it visually renders on this app's (and most) light surfaces,
333
+ // rather than letting undefined transparent RGB values swing the hash.
334
+ function grayscaleGrid(image, cols, rows) {
335
+ const { width, height, pixels } = image;
336
+ const grid = new Float64Array(cols * rows);
337
+ for (let ty = 0; ty < rows; ty += 1) {
338
+ const y0 = Math.floor((ty * height) / rows);
339
+ const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * height) / rows));
340
+ for (let tx = 0; tx < cols; tx += 1) {
341
+ const x0 = Math.floor((tx * width) / cols);
342
+ const x1 = Math.max(x0 + 1, Math.floor(((tx + 1) * width) / cols));
343
+ let sum = 0;
344
+ let n = 0;
345
+ for (let y = y0; y < y1 && y < height; y += 1) {
346
+ for (let x = x0; x < x1 && x < width; x += 1) {
347
+ const d = (y * width + x) * 4;
348
+ const a = byteAt(pixels, d + 3) / 255;
349
+ const r = byteAt(pixels, d) * a + 255 * (1 - a);
350
+ const g = byteAt(pixels, d + 1) * a + 255 * (1 - a);
351
+ const b = byteAt(pixels, d + 2) * a + 255 * (1 - a);
352
+ sum += 0.299 * r + 0.587 * g + 0.114 * b;
353
+ n += 1;
354
+ }
355
+ }
356
+ grid[ty * cols + tx] = n ? sum / n : 0;
357
+ }
358
+ }
359
+ return grid;
360
+ }
361
+ // dHash only records the *direction* of horizontal brightness changes, so a
362
+ // whole class of icons - a solid-colour square, but also any image whose
363
+ // brightness is monotonic left-to-right or varies only vertically - produces
364
+ // the same all-zero hash regardless of their actual appearance. Two such
365
+ // icons would then read as a perceptual match, falsely scoring risk and
366
+ // clustering unrelated domains. This is a property of the resulting *hash*,
367
+ // not the source image's variance (a bright left-to-right gradient has high
368
+ // variance yet still hashes to all zeros), so it's checked on the bit
369
+ // population of the finished hash: too few or too many set bits carries too
370
+ // little structure to compare safely, so we return no perceptual hash
371
+ // (callers fall back to the exact hash) rather than a collision-prone one.
372
+ // isInformativeHash re-checks this at comparison time too, since a Brand
373
+ // Profile saved before this guard existed may already hold a degenerate hash.
374
+ const MIN_INFORMATIVE_BITS = 10;
375
+ function popcountHex(hex) {
376
+ let bits = 0;
377
+ for (let i = 0; i < hex.length; i += 1)
378
+ bits += POPCOUNT[parseInt(hex.charAt(i), 16)] ?? 0;
379
+ return bits;
380
+ }
381
+ function isInformativeHash(hex) {
382
+ if (typeof hex !== 'string' || !HEX_HASH_RE.test(hex))
383
+ return false;
384
+ const bits = popcountHex(hex);
385
+ return bits >= MIN_INFORMATIVE_BITS && bits <= 64 - MIN_INFORMATIVE_BITS;
386
+ }
387
+ // 64-bit dHash over a 9x8 grid -> 16 hex chars, or null when the resulting
388
+ // hash is degenerate (see MIN_INFORMATIVE_BITS). Each row contributes 8 bits:
389
+ // bit set when a cell is brighter than its right-hand neighbour.
390
+ function dHash(image) {
391
+ const cols = 9;
392
+ const rows = 8;
393
+ const grid = grayscaleGrid(image, cols, rows);
394
+ let hex = '';
395
+ for (let y = 0; y < rows; y += 1) {
396
+ let nibble = 0;
397
+ let bitsInNibble = 0;
398
+ for (let x = 0; x < cols - 1; x += 1) {
399
+ const bit = (grid[y * cols + x] ?? 0) > (grid[y * cols + x + 1] ?? 0) ? 1 : 0;
400
+ nibble = (nibble << 1) | bit;
401
+ bitsInNibble += 1;
402
+ if (bitsInNibble === 4) {
403
+ hex += nibble.toString(16);
404
+ nibble = 0;
405
+ bitsInNibble = 0;
406
+ }
407
+ }
408
+ }
409
+ return isInformativeHash(hex) ? hex : null;
410
+ }
411
+ /**
412
+ * Computes a perceptual (dHash) hex string for favicon bytes, or null if the
413
+ * image can't be decoded (unsupported format, malformed, oversized). Never
414
+ * throws - callers treat null as "no perceptual signal available", falling
415
+ * back to the exact hash.
416
+ */
417
+ function faviconPerceptualHash(buf) {
418
+ try {
419
+ const image = decodeImage(buf);
420
+ if (!image)
421
+ return null;
422
+ return dHash(image);
423
+ }
424
+ catch {
425
+ return null;
426
+ }
427
+ }
428
+ // The same bounded decoder is also suitable for fixed-size PNG screenshots
429
+ // produced by the optional local capture package. Keep the generic name as an
430
+ // alias so the evidence contract does not imply that a screenshot is a favicon.
431
+ const imagePerceptualHash = faviconPerceptualHash;
432
+ const HEX_HASH_RE = /^[0-9a-f]{16}$/;
433
+ const POPCOUNT = new Uint8Array(16);
434
+ for (let i = 0; i < 16; i += 1)
435
+ POPCOUNT[i] = (i & 1) + ((i >> 1) & 1) + ((i >> 2) & 1) + ((i >> 3) & 1);
436
+ /**
437
+ * Hamming distance (0-64) between two 16-hex dHash strings, or null if either
438
+ * isn't a well-formed hash. Smaller = more visually similar.
439
+ */
440
+ function hammingDistanceHex(a, b) {
441
+ if (typeof a !== 'string' || typeof b !== 'string')
442
+ return null;
443
+ if (!HEX_HASH_RE.test(a) || !HEX_HASH_RE.test(b))
444
+ return null;
445
+ let distance = 0;
446
+ for (let i = 0; i < 16; i += 1) {
447
+ distance += POPCOUNT[parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16)] ?? 0;
448
+ }
449
+ return distance;
450
+ }
451
+ export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash };
@@ -0,0 +1,34 @@
1
+ import { MAX_RDAP_ENDPOINT_LENGTH } from "./rdap-bootstrap.mjs";
2
+ const MAX_RDAP_ATTEMPT_DETAIL_LENGTH = 240;
3
+ function safeAttemptDetail(value) {
4
+ if (typeof value !== 'string')
5
+ return null;
6
+ const normalized = value
7
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
8
+ .replace(/\s+/g, ' ')
9
+ .trim();
10
+ return normalized ? normalized.slice(0, MAX_RDAP_ATTEMPT_DETAIL_LENGTH) : null;
11
+ }
12
+ export function rdapAttempt(endpoint, outcome, options = {}) {
13
+ const { status = null, detail = null, selected = false } = options;
14
+ return {
15
+ endpoint: String(endpoint).slice(0, MAX_RDAP_ENDPOINT_LENGTH),
16
+ transportSecurity: /^https:\/\//i.test(endpoint) ? 'https' : 'http',
17
+ status: Number.isInteger(status) ? status : null,
18
+ outcome,
19
+ detail: safeAttemptDetail(detail),
20
+ selected,
21
+ };
22
+ }
23
+ export function rdapFailure(outcome, status) {
24
+ if (outcome === 'invalid_json')
25
+ return 'returned invalid JSON';
26
+ if (outcome === 'invalid_response')
27
+ return 'returned an invalid RDAP object';
28
+ if (outcome === 'rate_limited')
29
+ return `returned HTTP ${status}`;
30
+ if (outcome === 'server_error' || outcome === 'client_error') {
31
+ return `returned HTTP ${status}`;
32
+ }
33
+ return outcome.replaceAll('_', ' ');
34
+ }
@@ -0,0 +1,173 @@
1
+ // IANA RDAP bootstrap retrieval, bounded stale caching, and authority
2
+ // selection for domain, IP, and ASN queries.
3
+ import { fetchRdapDetailedWithTimeout, } from "./rdap-transport.mjs";
4
+ const BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
5
+ const BOOTSTRAP_STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
6
+ const BOOTSTRAP_KINDS = new Set(['dns', 'ipv4', 'ipv6', 'asn']);
7
+ const BOOTSTRAP_FETCH_TIMEOUT_MS = 7000;
8
+ const MAX_RDAP_ENDPOINT_LENGTH = 2048;
9
+ const bootstrapCache = new Map();
10
+ const bootstrapInflight = new Map();
11
+ function ipv4ToLong(ip) {
12
+ return ip.split('.').reduce((accumulator, octet) => (accumulator << 8) + (parseInt(octet, 10) & 0xff), 0) >>> 0;
13
+ }
14
+ function ipInCidrV4(ip, cidr) {
15
+ const [range, bitsString] = cidr.split('/');
16
+ if (!range)
17
+ return false;
18
+ const bits = bitsString !== undefined ? parseInt(bitsString, 10) : 32;
19
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
20
+ return (ipv4ToLong(ip) & mask) === (ipv4ToLong(range) & mask);
21
+ }
22
+ function expandIpv6(ip) {
23
+ let head = ip;
24
+ let tail = '';
25
+ if (ip.includes('::')) {
26
+ const [headPart = '', tailPart = ''] = ip.split('::');
27
+ head = headPart;
28
+ tail = tailPart;
29
+ }
30
+ const headParts = head ? head.split(':').filter(Boolean) : [];
31
+ const tailParts = tail ? tail.split(':').filter(Boolean) : [];
32
+ const missing = 8 - headParts.length - tailParts.length;
33
+ const parts = ip.includes('::')
34
+ ? [...headParts, ...Array(Math.max(missing, 0)).fill('0'), ...tailParts]
35
+ : headParts;
36
+ while (parts.length < 8)
37
+ parts.push('0');
38
+ return parts.slice(0, 8);
39
+ }
40
+ function ipv6ToBigInt(ip) {
41
+ return expandIpv6(ip).reduce((accumulator, part) => (accumulator << 16n) + BigInt(parseInt(part || '0', 16)), 0n);
42
+ }
43
+ function ipInCidrV6(ip, cidr) {
44
+ const [range, bitsString] = cidr.split('/');
45
+ if (!range)
46
+ return false;
47
+ const bits = bitsString !== undefined ? parseInt(bitsString, 10) : 128;
48
+ const full = (1n << 128n) - 1n;
49
+ const mask = bits === 0 ? 0n : (full << BigInt(128 - bits)) & full;
50
+ return (ipv6ToBigInt(ip) & mask) === (ipv6ToBigInt(range) & mask);
51
+ }
52
+ function validBootstrap(data) {
53
+ const record = data && typeof data === 'object' && !Array.isArray(data)
54
+ ? data
55
+ : null;
56
+ return Boolean(record
57
+ && Array.isArray(record.services) && record.services.length > 0
58
+ && record.services.every((service) => Array.isArray(service) && service.length >= 2
59
+ && Array.isArray(service[0]) && service[0].length > 0
60
+ && service[0].every((entry) => typeof entry === 'string' && entry.length > 0)
61
+ && Array.isArray(service[1]) && service[1].length > 0
62
+ && service[1].some((url) => typeof url === 'string' && /^https?:\/\//i.test(url))));
63
+ }
64
+ async function fetchBootstrap(kind, options = {}) {
65
+ if (!BOOTSTRAP_KINDS.has(kind))
66
+ throw new Error(`Unsupported RDAP bootstrap kind: ${kind}`);
67
+ const now = typeof options.now === 'function' ? options.now : Date.now;
68
+ const fetchUpstream = options.fetchUpstream || fetchRdapDetailedWithTimeout;
69
+ const cached = bootstrapCache.get(kind);
70
+ if (cached && now() - cached.fetchedAt < BOOTSTRAP_TTL_MS)
71
+ return cached.data;
72
+ const inflight = bootstrapInflight.get(kind);
73
+ if (inflight)
74
+ return inflight;
75
+ const request = (async () => {
76
+ try {
77
+ const response = await fetchUpstream(`https://data.iana.org/rdap/${kind}.json`, {}, BOOTSTRAP_FETCH_TIMEOUT_MS);
78
+ if (!response.ok) {
79
+ throw new Error(`IANA bootstrap fetch failed for ${kind} (${response.status})`);
80
+ }
81
+ let data;
82
+ try {
83
+ data = JSON.parse(response.text);
84
+ }
85
+ catch {
86
+ throw new Error(`IANA bootstrap returned invalid JSON for ${kind}`);
87
+ }
88
+ if (!validBootstrap(data)) {
89
+ throw new Error(`IANA bootstrap returned an unexpected format for ${kind}`);
90
+ }
91
+ bootstrapCache.set(kind, { data, fetchedAt: now() });
92
+ return data;
93
+ }
94
+ catch (cause) {
95
+ const fallback = bootstrapCache.get(kind);
96
+ if (fallback && now() - fallback.fetchedAt <= BOOTSTRAP_STALE_TTL_MS) {
97
+ return fallback.data;
98
+ }
99
+ throw cause;
100
+ }
101
+ finally {
102
+ bootstrapInflight.delete(kind);
103
+ }
104
+ })();
105
+ bootstrapInflight.set(kind, request);
106
+ return request;
107
+ }
108
+ function clearRdapBootstrapCache() {
109
+ bootstrapCache.clear();
110
+ bootstrapInflight.clear();
111
+ }
112
+ function uniqueRdapBases(urls) {
113
+ const seen = new Set();
114
+ return (Array.isArray(urls) ? urls : [])
115
+ .filter((url) => typeof url === 'string'
116
+ && url.length <= MAX_RDAP_ENDPOINT_LENGTH
117
+ && !/[\u0000-\u001f\u007f]/.test(url)
118
+ && /^https?:\/\//i.test(url))
119
+ .sort((left, right) => Number(/^http:\/\//i.test(left)) - Number(/^http:\/\//i.test(right)))
120
+ .filter((url) => {
121
+ const key = url.replace(/\/$/, '').toLowerCase();
122
+ if (seen.has(key))
123
+ return false;
124
+ seen.add(key);
125
+ return true;
126
+ });
127
+ }
128
+ async function findRdapBases(type, value) {
129
+ if (type === 'domain') {
130
+ const bootstrap = await fetchBootstrap('dns');
131
+ const tld = value.split('.').pop()?.toLowerCase() || '';
132
+ for (const [tlds, urls] of bootstrap.services) {
133
+ if (tlds.some((entry) => entry.toLowerCase() === tld))
134
+ return uniqueRdapBases(urls);
135
+ }
136
+ return [];
137
+ }
138
+ if (type === 'ipv4' || type === 'ipv6') {
139
+ const bootstrap = await fetchBootstrap(type);
140
+ const matcher = type === 'ipv4' ? ipInCidrV4 : ipInCidrV6;
141
+ let best = null;
142
+ let bestPrefix = -1;
143
+ for (const [cidrs, urls] of bootstrap.services) {
144
+ for (const cidr of cidrs) {
145
+ if (!matcher(value, cidr))
146
+ continue;
147
+ const fallbackPrefix = type === 'ipv4' ? '32' : '128';
148
+ const prefix = parseInt(cidr.split('/')[1] ?? fallbackPrefix, 10);
149
+ if (prefix > bestPrefix) {
150
+ bestPrefix = prefix;
151
+ best = uniqueRdapBases(urls);
152
+ }
153
+ }
154
+ }
155
+ return best || [];
156
+ }
157
+ if (type === 'asn') {
158
+ const bootstrap = await fetchBootstrap('asn');
159
+ const number = parseInt(value.replace(/^AS/i, ''), 10);
160
+ for (const [ranges, urls] of bootstrap.services) {
161
+ for (const range of ranges) {
162
+ const [start, end] = range.includes('-')
163
+ ? range.split('-').map(Number)
164
+ : [Number(range), Number(range)];
165
+ if (start !== undefined && end !== undefined && number >= start && number <= end) {
166
+ return uniqueRdapBases(urls);
167
+ }
168
+ }
169
+ }
170
+ }
171
+ return [];
172
+ }
173
+ export { BOOTSTRAP_STALE_TTL_MS, BOOTSTRAP_TTL_MS, MAX_RDAP_ENDPOINT_LENGTH, clearRdapBootstrapCache, fetchBootstrap, findRdapBases, ipv4ToLong, ipv6ToBigInt, uniqueRdapBases, };