@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,272 @@
1
+ // Browser-local official-site baseline model for Brand Profiles. This module
2
+ // deliberately accepts only bounded, normalized page-identity fields. Raw
3
+ // HTML, URLs, headers, redirect inventories, and parser diagnostics never
4
+ // cross this storage boundary.
5
+ import { normalizeDomain } from "./case-model.js";
6
+ import { isInformativeFaviconHash } from "./utils.js";
7
+ export const PAGE_BASELINE_VERSION = 1;
8
+ export const PAGE_IDENTITY_VERSION = 3;
9
+ export const PAGE_FINGERPRINT_VERSION = 1;
10
+ export const MAX_BASELINE_TITLE_LENGTH = 200;
11
+ export const MAX_BASELINE_RESOURCE_HOSTS = 30;
12
+ export const MAX_BASELINE_IDENTIFIERS = 30;
13
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
14
+ const SIMHASH_RE = /^[a-f0-9]{16}$/i;
15
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
16
+ const MAX_TIMESTAMP_LENGTH = 64;
17
+ const MAX_HTML_TOKENS = 4096;
18
+ const MAX_TEXT_TOKENS = 8192;
19
+ const MAX_FORMS = 50;
20
+ const MAX_FORM_CONTROLS = 500;
21
+ function record(value) {
22
+ return value && typeof value === 'object' && !Array.isArray(value)
23
+ ? value
24
+ : null;
25
+ }
26
+ function timestamp(value) {
27
+ if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
28
+ return null;
29
+ const parsed = new Date(value);
30
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
31
+ }
32
+ function count(value, maximum) {
33
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= maximum
34
+ ? value
35
+ : null;
36
+ }
37
+ function sha256(value) {
38
+ return typeof value === 'string' && SHA256_RE.test(value) ? value.toLowerCase() : null;
39
+ }
40
+ function boundedTitle(value) {
41
+ if (typeof value !== 'string' || value.length > MAX_BASELINE_TITLE_LENGTH || CONTROL_RE.test(value))
42
+ return null;
43
+ return value.trim().replace(/\s+/g, ' ') || null;
44
+ }
45
+ function shaComponent(raw, countKey, maximum) {
46
+ const value = record(raw);
47
+ if (!value || value.algorithm !== 'sha256')
48
+ return null;
49
+ const digest = sha256(value.value);
50
+ const observedCount = count(value[countKey], maximum);
51
+ if (!digest || observedCount === null)
52
+ return null;
53
+ return {
54
+ algorithm: 'sha256',
55
+ value: digest,
56
+ [countKey]: observedCount,
57
+ truncated: value.truncated === true,
58
+ };
59
+ }
60
+ function visibleTextComponent(raw) {
61
+ const value = record(raw);
62
+ if (!value || value.algorithm !== 'simhash64-v1' || typeof value.value !== 'string' || !SIMHASH_RE.test(value.value))
63
+ return null;
64
+ const tokenCount = count(value.tokenCount, MAX_TEXT_TOKENS);
65
+ const featureCount = count(value.featureCount, MAX_TEXT_TOKENS);
66
+ if (tokenCount === null || featureCount === null)
67
+ return null;
68
+ return {
69
+ algorithm: 'simhash64-v1',
70
+ value: value.value.toLowerCase(),
71
+ tokenCount,
72
+ featureCount,
73
+ truncated: value.truncated === true,
74
+ };
75
+ }
76
+ function domComponent(raw) {
77
+ const value = record(raw);
78
+ if (!value || value.algorithm !== 'sha256' || value.parser !== 'static-tag-sequence-v1')
79
+ return null;
80
+ const digest = sha256(value.value);
81
+ const nodeCount = count(value.nodeCount, MAX_HTML_TOKENS);
82
+ if (!digest || nodeCount === null)
83
+ return null;
84
+ const similarity = value.similarity == null ? null : visibleTextComponent(value.similarity);
85
+ return {
86
+ algorithm: 'sha256',
87
+ value: digest,
88
+ nodeCount,
89
+ parser: 'static-tag-sequence-v1',
90
+ truncated: value.truncated === true,
91
+ ...(value.similarity == null ? {} : { similarity }),
92
+ };
93
+ }
94
+ function formComponent(raw) {
95
+ const value = record(raw);
96
+ if (!value || value.algorithm !== 'sha256')
97
+ return null;
98
+ const digest = sha256(value.value);
99
+ const formCount = count(value.formCount, MAX_FORMS);
100
+ const controlCount = count(value.controlCount, MAX_FORM_CONTROLS);
101
+ if (!digest || formCount === null || controlCount === null)
102
+ return null;
103
+ return {
104
+ algorithm: 'sha256',
105
+ value: digest,
106
+ formCount,
107
+ controlCount,
108
+ truncated: value.truncated === true,
109
+ };
110
+ }
111
+ function resourceHostComponent(raw) {
112
+ const value = record(raw);
113
+ if (!value || value.algorithm !== 'set-sha256' || !Array.isArray(value.values))
114
+ return null;
115
+ const values = new Set();
116
+ for (const candidate of value.values.slice(0, MAX_BASELINE_RESOURCE_HOSTS * 4)) {
117
+ const host = normalizeDomain(candidate);
118
+ if (host)
119
+ values.add(host);
120
+ if (values.size >= MAX_BASELINE_RESOURCE_HOSTS)
121
+ break;
122
+ }
123
+ const sorted = [...values].sort();
124
+ const digest = value.value === null ? null : sha256(value.value);
125
+ if ((sorted.length > 0 && !digest) || (sorted.length === 0 && value.value !== null))
126
+ return null;
127
+ return {
128
+ algorithm: 'set-sha256',
129
+ value: digest,
130
+ values: sorted,
131
+ truncated: value.truncated === true || value.values.length > MAX_BASELINE_RESOURCE_HOSTS,
132
+ };
133
+ }
134
+ function identifierComponent(raw) {
135
+ const value = record(raw);
136
+ if (!value || value.algorithm !== 'set-sha256' || !Array.isArray(value.values))
137
+ return null;
138
+ const values = [];
139
+ const seen = new Set();
140
+ for (const candidate of value.values.slice(0, MAX_BASELINE_IDENTIFIERS * 4)) {
141
+ const item = record(candidate);
142
+ if (!item || typeof item.type !== 'string' || typeof item.value !== 'string')
143
+ continue;
144
+ if (!/^[a-z-]{1,40}$/.test(item.type) || !/^[A-Z0-9-]{1,64}$/.test(item.value))
145
+ continue;
146
+ const key = `${item.type}:${item.value}`;
147
+ if (seen.has(key))
148
+ continue;
149
+ seen.add(key);
150
+ values.push({ type: item.type, value: item.value });
151
+ if (values.length >= MAX_BASELINE_IDENTIFIERS)
152
+ break;
153
+ }
154
+ values.sort((left, right) => left.type.localeCompare(right.type) || left.value.localeCompare(right.value));
155
+ const digest = value.value === null ? null : sha256(value.value);
156
+ if ((values.length > 0 && !digest) || (values.length === 0 && value.value !== null))
157
+ return null;
158
+ return {
159
+ algorithm: 'set-sha256',
160
+ value: digest,
161
+ values,
162
+ truncated: value.truncated === true || value.values.length > MAX_BASELINE_IDENTIFIERS,
163
+ };
164
+ }
165
+ function faviconSha(value) {
166
+ return sha256(value);
167
+ }
168
+ function faviconPHash(value) {
169
+ return typeof value === 'string' && isInformativeFaviconHash(value) ? value.toLowerCase() : null;
170
+ }
171
+ /**
172
+ * Strictly normalizes a persisted baseline and drops every unknown field.
173
+ * Unsupported/future schema versions and incomplete core fingerprint shapes
174
+ * fail closed so old code never misinterprets newer evidence.
175
+ * @param {unknown} raw
176
+ */
177
+ export function normalizePageBaseline(raw) {
178
+ const value = record(raw);
179
+ if (!value || value.baselineVersion !== PAGE_BASELINE_VERSION)
180
+ return null;
181
+ const domain = normalizeDomain(value.domain);
182
+ const lookupDomain = normalizeDomain(value.lookupDomain);
183
+ const observedAt = timestamp(value.observedAt);
184
+ const pageIdentityVersion = count(value.pageIdentityVersion, PAGE_IDENTITY_VERSION);
185
+ const fingerprintVersion = count(value.fingerprintVersion, PAGE_FINGERPRINT_VERSION);
186
+ const normalizedHtml = shaComponent(value.normalizedHtml, 'tokenCount', MAX_HTML_TOKENS);
187
+ const domStructure = domComponent(value.domStructure);
188
+ if (!domain || !lookupDomain || !observedAt || pageIdentityVersion !== PAGE_IDENTITY_VERSION
189
+ || fingerprintVersion !== PAGE_FINGERPRINT_VERSION || !normalizedHtml || !domStructure)
190
+ return null;
191
+ const visibleText = value.visibleText == null ? null : visibleTextComponent(value.visibleText);
192
+ const formStructure = value.formStructure == null ? null : formComponent(value.formStructure);
193
+ const resourceHosts = resourceHostComponent(value.resourceHosts);
194
+ const trackingIdentifiers = identifierComponent(value.trackingIdentifiers);
195
+ if (!resourceHosts || !trackingIdentifiers)
196
+ return null;
197
+ const rawDomStructure = record(value.domStructure);
198
+ const optionalComponentInvalid = (value.visibleText != null && !visibleText)
199
+ || (value.formStructure != null && !formStructure)
200
+ || (rawDomStructure?.similarity != null && !domStructure.similarity);
201
+ const truncated = value.truncated === true || optionalComponentInvalid || normalizedHtml.truncated || domStructure.truncated
202
+ || visibleText?.truncated === true || formStructure?.truncated === true
203
+ || resourceHosts.truncated || trackingIdentifiers.truncated;
204
+ return {
205
+ baselineVersion: PAGE_BASELINE_VERSION,
206
+ domain,
207
+ lookupDomain,
208
+ observedAt,
209
+ pageIdentityVersion,
210
+ fingerprintVersion,
211
+ pageTitle: boundedTitle(value.pageTitle),
212
+ canonicalHost: value.canonicalHost == null ? null : normalizeDomain(value.canonicalHost) || null,
213
+ faviconHash: faviconSha(value.faviconHash),
214
+ faviconPHash: faviconPHash(value.faviconPHash),
215
+ normalizedHtml,
216
+ visibleText,
217
+ domStructure,
218
+ formStructure,
219
+ resourceHosts,
220
+ trackingIdentifiers,
221
+ complete: value.complete === true && !truncated,
222
+ truncated,
223
+ };
224
+ }
225
+ function canonicalHost(value) {
226
+ const item = record(value);
227
+ if (!item || typeof item.url !== 'string' || item.url.length > 2048 || CONTROL_RE.test(item.url))
228
+ return null;
229
+ try {
230
+ const url = new URL(item.url);
231
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password
232
+ ? normalizeDomain(url.hostname) || null
233
+ : null;
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
239
+ /**
240
+ * Builds a baseline from one deep availability response. Returns null when
241
+ * the response does not contain the current core page-fingerprint contract.
242
+ * @param {unknown} rawDomain
243
+ * @param {unknown} rawAvailability
244
+ */
245
+ export function createPageBaseline(rawDomain, rawAvailability) {
246
+ const domain = normalizeDomain(rawDomain);
247
+ const availability = record(rawAvailability);
248
+ const identity = record(availability?.pageIdentity);
249
+ const fingerprints = record(identity?.fingerprints);
250
+ if (!domain || !availability || !identity || !fingerprints || identity.source !== 'html')
251
+ return null;
252
+ return normalizePageBaseline({
253
+ baselineVersion: PAGE_BASELINE_VERSION,
254
+ domain,
255
+ lookupDomain: normalizeDomain(availability.domain) || domain,
256
+ observedAt: identity.observedAt,
257
+ pageIdentityVersion: identity.identityVersion,
258
+ fingerprintVersion: fingerprints.fingerprintVersion,
259
+ pageTitle: availability.pageTitle,
260
+ canonicalHost: canonicalHost(identity.canonical),
261
+ faviconHash: availability.faviconHash,
262
+ faviconPHash: availability.faviconPHash,
263
+ normalizedHtml: fingerprints.normalizedHtml,
264
+ visibleText: fingerprints.visibleText,
265
+ domStructure: fingerprints.domStructure,
266
+ formStructure: fingerprints.formStructure,
267
+ resourceHosts: fingerprints.resourceHosts,
268
+ trackingIdentifiers: fingerprints.identifiers,
269
+ complete: identity.complete === true && fingerprints.complete === true,
270
+ truncated: identity.truncated === true || fingerprints.truncated === true,
271
+ });
272
+ }
@@ -0,0 +1,179 @@
1
+ // Explainable, browser-local comparison of two bounded page baselines. This
2
+ // module deliberately reports each component independently: it does not
3
+ // produce an aggregate similarity score, risk factor, or maliciousness
4
+ // verdict. Inputs pass through the strict baseline normalizer before use.
5
+ import { normalizePageBaseline } from "./page-baseline.js";
6
+ import { hammingDistanceHex } from "./utils.js";
7
+ export const PAGE_COMPARISON_VERSION = 3;
8
+ function component(id, label, method, status, outcome, detail, partial) {
9
+ return { id, label, method, status, outcome, detail, partial, sharedValues: [] };
10
+ }
11
+ /**
12
+ * Compares two mandatory digest components. A match or difference applies to
13
+ * the bounded captured material only; the partial flag keeps a capped capture
14
+ * from being presented as a conclusion about the complete page.
15
+ * @param {string} id
16
+ * @param {string} label
17
+ * @param {string} detailLabel
18
+ */
19
+ function digestComponent(id, label, detailLabel, reference, observed) {
20
+ const same = reference.value === observed.value;
21
+ const partial = reference.truncated || observed.truncated;
22
+ return component(id, label, 'Exact SHA-256 equality', same ? 'same' : 'different', `${same ? 'Same' : 'Different'} captured digest${partial ? ' · partial evidence' : ''}`, `${detailLabel} hashes ${same ? 'are equal' : 'differ'} for the bounded material captured on each page.`, partial);
23
+ }
24
+ function visibleTextComponent(reference, observed) {
25
+ if (!reference && !observed) {
26
+ return component('visible_text', 'Visible text', '64-bit SimHash distance', 'not_observed', 'Not observed', 'Neither capture produced a visible-text fingerprint.', false);
27
+ }
28
+ if (!reference || !observed) {
29
+ return component('visible_text', 'Visible text', '64-bit SimHash distance', 'unavailable', 'Not comparable', 'Only one capture produced a visible-text fingerprint.', Boolean(reference?.truncated || observed?.truncated));
30
+ }
31
+ const distance = hammingDistanceHex(reference.value, observed.value);
32
+ if (distance === null) {
33
+ return component('visible_text', 'Visible text', '64-bit SimHash distance', 'unavailable', 'Not comparable', 'The visible-text fingerprints use an unsupported or malformed representation.', true);
34
+ }
35
+ const agreementPercent = Math.round(((64 - distance) / 64) * 100);
36
+ return {
37
+ ...component('visible_text', 'Visible text', '64-bit SimHash distance', distance === 0 ? 'same' : 'different', `${agreementPercent}% bit agreement${reference.truncated || observed.truncated ? ' · partial evidence' : ''}`, `The fingerprints differ by ${distance} of 64 bits. Bit agreement is a fuzzy comparison aid, not a percentage of copied text.`, reference.truncated || observed.truncated),
38
+ hammingDistance: distance,
39
+ agreementPercent,
40
+ };
41
+ }
42
+ function domStructureComponent(reference, observed) {
43
+ const partial = reference.truncated || observed.truncated
44
+ || reference.similarity?.truncated === true || observed.similarity?.truncated === true;
45
+ if (reference.value === observed.value) {
46
+ return component('dom_structure', 'DOM structure', 'Exact SHA-256 equality', 'same', `Same captured structure${partial ? ' · partial evidence' : ''}`, 'The exact static tag-sequence digests are equal for the bounded material captured on each page.', partial);
47
+ }
48
+ if (!reference.similarity || !observed.similarity) {
49
+ return component('dom_structure', 'DOM structure', 'Exact SHA-256 equality', 'different', `Different captured structure${partial ? ' · partial evidence' : ''}`, 'The exact static tag-sequence digests differ. At least one older capture lacks the optional standards-token similarity fingerprint.', partial);
50
+ }
51
+ const distance = hammingDistanceHex(reference.similarity.value, observed.similarity.value);
52
+ if (distance === null) {
53
+ return component('dom_structure', 'DOM structure', 'Exact SHA-256, then 64-bit SimHash distance', 'unavailable', 'Not comparable', 'The DOM-structure similarity fingerprints use an unsupported or malformed representation.', true);
54
+ }
55
+ const agreementPercent = Math.round(((64 - distance) / 64) * 100);
56
+ const near = distance <= 8;
57
+ return {
58
+ ...component('dom_structure', 'DOM structure', 'Exact SHA-256, then 64-bit SimHash distance', near ? 'overlap' : 'different', `${near ? 'Similar' : 'Different'} captured structure · ${agreementPercent}% bit agreement${partial ? ' · partial evidence' : ''}`, `The standards-token fingerprints differ by ${distance} of 64 bits. The bounded eight-bit proximity threshold is a review lead and does not establish copying, ownership, intent, safety, or maliciousness.`, partial),
59
+ hammingDistance: distance,
60
+ agreementPercent,
61
+ };
62
+ }
63
+ function formComponent(reference, observed) {
64
+ if (!reference && !observed) {
65
+ return component('form_structure', 'Form structure', 'Exact SHA-256 equality', 'not_observed', 'No forms fingerprinted', 'Neither capture contained a form structure to fingerprint.', false);
66
+ }
67
+ if (!reference || !observed) {
68
+ return component('form_structure', 'Form structure', 'Exact SHA-256 equality', 'unavailable', 'Not comparable', 'Only one capture produced a form-structure fingerprint.', Boolean(reference?.truncated || observed?.truncated));
69
+ }
70
+ return digestComponent('form_structure', 'Form structure', 'Form-structure', reference, observed);
71
+ }
72
+ function faviconComponent(reference, observed) {
73
+ if (!reference.faviconHash && !observed.faviconHash && !reference.faviconPHash && !observed.faviconPHash) {
74
+ return component('favicon', 'Favicon', 'Exact SHA-256 or 64-bit dHash distance', 'not_observed', 'Not observed', 'Neither capture produced a favicon fingerprint.', false);
75
+ }
76
+ if (reference.faviconHash && observed.faviconHash && reference.faviconHash === observed.faviconHash) {
77
+ return component('favicon', 'Favicon', 'Exact SHA-256 equality', 'same', 'Same favicon digest', 'The bounded favicon byte digests are equal.', false);
78
+ }
79
+ if (!reference.faviconPHash || !observed.faviconPHash) {
80
+ return component('favicon', 'Favicon', 'Exact SHA-256 or 64-bit dHash distance', 'unavailable', 'Not comparable', 'The exact favicon digests differ or are incomplete, and both captures did not retain an informative perceptual fingerprint.', false);
81
+ }
82
+ const distance = hammingDistanceHex(reference.faviconPHash, observed.faviconPHash);
83
+ if (distance === null) {
84
+ return component('favicon', 'Favicon', '64-bit dHash distance', 'unavailable', 'Not comparable', 'The favicon perceptual fingerprints use an unsupported or malformed representation.', true);
85
+ }
86
+ const near = distance <= 6;
87
+ return {
88
+ ...component('favicon', 'Favicon', '64-bit dHash distance', distance === 0 ? 'same' : near ? 'overlap' : 'different', distance === 0 ? 'Same perceptual fingerprint' : near ? 'Perceptually similar' : 'Perceptually different', `The favicon perceptual fingerprints differ by ${distance} of 64 bits. Similarity is a review lead and does not establish copying, ownership, intent, safety, or maliciousness.`, false),
89
+ hammingDistance: distance,
90
+ agreementPercent: Math.round(((64 - distance) / 64) * 100),
91
+ };
92
+ }
93
+ function intersect(left, right) {
94
+ const rightSet = new Set(right);
95
+ return left.filter((value) => rightSet.has(value));
96
+ }
97
+ /**
98
+ * @param {string} id
99
+ * @param {string} label
100
+ * @param {string} noun
101
+ */
102
+ function setComponent(id, label, noun, reference, observed, key) {
103
+ const referenceValues = reference.values.map(key);
104
+ const observedValues = observed.values.map(key);
105
+ const sharedValues = intersect(referenceValues, observedValues);
106
+ const partial = reference.truncated || observed.truncated;
107
+ const quantity = (count) => `${count} ${noun}${count === 1 ? '' : 's'}`;
108
+ if (!referenceValues.length && !observedValues.length) {
109
+ return {
110
+ ...component(id, label, 'Bounded set overlap', 'not_observed', `No ${noun}s observed`, `Neither capture retained any ${noun}s.`, partial),
111
+ referenceCount: 0,
112
+ observedCount: 0,
113
+ sharedCount: 0,
114
+ sharedValues: [],
115
+ };
116
+ }
117
+ const same = referenceValues.length === observedValues.length && sharedValues.length === referenceValues.length;
118
+ const status = same ? 'same' : sharedValues.length ? 'overlap' : 'different';
119
+ const outcome = same
120
+ ? `Same ${noun} set`
121
+ : sharedValues.length
122
+ ? `${quantity(sharedValues.length)} shared`
123
+ : `No shared ${noun}s`;
124
+ return {
125
+ ...component(id, label, 'Bounded set overlap', status, `${outcome}${partial ? ' · partial evidence' : ''}`, `${quantity(sharedValues.length)} shared across ${quantity(referenceValues.length)} in the baseline and ${quantity(observedValues.length)} in this capture.`, partial),
126
+ referenceCount: referenceValues.length,
127
+ observedCount: observedValues.length,
128
+ sharedCount: sharedValues.length,
129
+ sharedValues,
130
+ };
131
+ }
132
+ /**
133
+ * Compares an official-site baseline with a current bounded page capture.
134
+ * Returns null unless both inputs satisfy the current strict baseline schema.
135
+ * @param {unknown} rawReference
136
+ * @param {unknown} rawObserved
137
+ */
138
+ export function comparePageBaselines(rawReference, rawObserved) {
139
+ const reference = normalizePageBaseline(rawReference);
140
+ const observed = normalizePageBaseline(rawObserved);
141
+ if (!reference || !observed)
142
+ return null;
143
+ const components = [
144
+ digestComponent('normalized_html', 'Normalized HTML', 'Normalized-HTML', reference.normalizedHtml, observed.normalizedHtml),
145
+ visibleTextComponent(reference.visibleText, observed.visibleText),
146
+ domStructureComponent(reference.domStructure, observed.domStructure),
147
+ formComponent(reference.formStructure, observed.formStructure),
148
+ faviconComponent(reference, observed),
149
+ setComponent('resource_hosts', 'External resource hosts', 'host', reference.resourceHosts, observed.resourceHosts, (value) => value),
150
+ setComponent('tracking_identifiers', 'Tracking identifiers', 'identifier', reference.trackingIdentifiers, observed.trackingIdentifiers, (value) => `${value.type}:${value.value}`),
151
+ ];
152
+ const counts = { same: 0, overlap: 0, different: 0, notObserved: 0, unavailable: 0 };
153
+ for (const item of components) {
154
+ if (item.status === 'not_observed')
155
+ counts.notObserved += 1;
156
+ else if (item.status === 'unavailable')
157
+ counts.unavailable += 1;
158
+ else
159
+ counts[item.status] += 1;
160
+ }
161
+ return {
162
+ comparisonVersion: PAGE_COMPARISON_VERSION,
163
+ reference: {
164
+ domain: reference.domain,
165
+ observedAt: reference.observedAt,
166
+ complete: reference.complete,
167
+ truncated: reference.truncated,
168
+ },
169
+ observed: {
170
+ domain: observed.domain,
171
+ observedAt: observed.observedAt,
172
+ complete: observed.complete,
173
+ truncated: observed.truncated,
174
+ },
175
+ partial: reference.truncated || observed.truncated || components.some((item) => item.partial),
176
+ components,
177
+ counts,
178
+ };
179
+ }