@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,481 @@
1
+ // Privacy-bounded fingerprints derived from the homepage HTML already captured
2
+ // for deep Lookup. The module never retains normalized markup or visible text:
3
+ // those intermediate representations exist only long enough to derive a digest.
4
+ import { createHash } from 'node:crypto';
5
+ import { analyzeStaticHtml } from "./static-html-analysis.mjs";
6
+ const PAGE_FINGERPRINT_VERSION = 1;
7
+ const MAX_FINGERPRINT_SOURCE_BYTES = 300000;
8
+ const MAX_FINGERPRINT_TAG_LENGTH = 4096;
9
+ const MAX_FINGERPRINT_TOKENS = 4096;
10
+ const MAX_FINGERPRINT_ATTRIBUTES = 64;
11
+ const MAX_VISIBLE_TEXT_TOKENS = 8192;
12
+ const MAX_FORM_FINGERPRINTS = 50;
13
+ const MAX_FORM_CONTROLS = 500;
14
+ const MAX_RESOURCE_HOSTS = 30;
15
+ const MAX_IDENTIFIER_VALUES = 30;
16
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/g;
17
+ const HAS_CONTROL_RE = /[\u0000-\u001f\u007f]/;
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
+ const WORD_RE = /[\p{L}\p{N}]+/gu;
24
+ const URL_ATTRIBUTE_NAMES = new Set(['action', 'cite', 'data', 'formaction', 'href', 'poster', 'src']);
25
+ const OMITTED_ATTRIBUTE_NAMES = new Set(['integrity', 'nonce', 'srcdoc', 'style', 'value']);
26
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
27
+ function sha256(value) {
28
+ return createHash('sha256').update(value).digest('hex');
29
+ }
30
+ 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;
34
+ return { bytes, text: bytes.toString('utf8'), truncated };
35
+ }
36
+ function staticMarkup(html) {
37
+ return html
38
+ .replace(COMMENT_RE, ' ')
39
+ .replace(RAW_BODY_RE, '<$1$2></$1>');
40
+ }
41
+ function normalizedExactBodyHash(value) {
42
+ if (!value || typeof value !== 'object')
43
+ return null;
44
+ const record = value;
45
+ if (record.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/i.test(String(record.value || '')))
46
+ return null;
47
+ if (!['complete-body', 'captured-prefix'].includes(String(record.scope)))
48
+ return null;
49
+ if (!Number.isSafeInteger(record.bytes) || Number(record.bytes) < 0 || Number(record.bytes) > MAX_FINGERPRINT_SOURCE_BYTES)
50
+ return null;
51
+ return {
52
+ algorithm: 'sha256',
53
+ value: String(record.value).toLowerCase(),
54
+ scope: record.scope,
55
+ bytes: Number(record.bytes),
56
+ source: 'captured-response-bytes',
57
+ };
58
+ }
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
+ function looksRandom(value) {
93
+ if (typeof value !== 'string')
94
+ return false;
95
+ const compact = value.replace(/[-_]/g, '');
96
+ if (/^[a-f0-9]{16,}$/i.test(compact))
97
+ return true;
98
+ if (/^[a-z0-9+/]{20,}={0,2}$/i.test(compact) && /[a-z]/i.test(compact) && /\d/.test(compact))
99
+ return true;
100
+ return false;
101
+ }
102
+ function reduceDynamicValues(value) {
103
+ return value
104
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, '<id>')
105
+ .replace(/\b\d{4}-\d{1,2}-\d{1,2}(?:[t\s]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:z|[+-]\d{2}:?\d{2})?)?\b/gi, '<time>')
106
+ .replace(/\b\d{10,13}\b/g, '<time>')
107
+ .replace(/\b[a-f0-9]{16,}\b/gi, '<id>')
108
+ .replace(/\b(?=[a-z0-9_-]{20,}\b)(?=[a-z0-9_-]*[a-z])(?=[a-z0-9_-]*\d)[a-z0-9_-]+\b/gi, '<id>');
109
+ }
110
+ function normalizeText(value, maxLength = 512) {
111
+ const normalized = reduceDynamicValues(decodeStableEntities(String(value || '')))
112
+ .normalize('NFKC')
113
+ .replace(CONTROL_RE, ' ')
114
+ .replace(/\s+/g, ' ')
115
+ .trim()
116
+ .toLowerCase();
117
+ return normalized.slice(0, maxLength);
118
+ }
119
+ function normalizeUrlAttribute(value, baseUrl) {
120
+ if (typeof value !== 'string' || !value || value.length > MAX_FINGERPRINT_TAG_LENGTH || HAS_CONTROL_RE.test(value))
121
+ return null;
122
+ try {
123
+ const url = new URL(value.trim(), baseUrl);
124
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || !url.hostname)
125
+ return null;
126
+ url.search = '';
127
+ url.hash = '';
128
+ return url.toString().slice(0, MAX_FINGERPRINT_TAG_LENGTH);
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ function normalizeAttribute(name, value, baseUrl) {
135
+ if (OMITTED_ATTRIBUTE_NAMES.has(name) || name.startsWith('data-') || name.startsWith('on')
136
+ || /(?:csrf|nonce|session|token)/i.test(name))
137
+ return null;
138
+ if (name === 'srcset')
139
+ return null;
140
+ if (URL_ATTRIBUTE_NAMES.has(name)) {
141
+ const normalized = normalizeUrlAttribute(value, baseUrl);
142
+ return normalized ? `${name}=${normalized}` : name;
143
+ }
144
+ if (name === 'class') {
145
+ const classes = normalizeText(value, 1024).split(' ').filter((item) => item && !looksRandom(item)).sort();
146
+ return classes.length ? `${name}=${classes.join('.')}` : name;
147
+ }
148
+ const normalized = normalizeText(value, 256);
149
+ if ((name === 'id' || name === 'name') && looksRandom(normalized))
150
+ return name;
151
+ return normalized ? `${name}=${normalized}` : name;
152
+ }
153
+ function normalizedMarkupAndStructure(html, baseUrl) {
154
+ const markup = staticMarkup(html);
155
+ const normalizedTokens = [];
156
+ const structureTokens = [];
157
+ const visibleSegments = [];
158
+ let normalizedTruncated = false;
159
+ let structureTruncated = false;
160
+ 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;
182
+ if (normalizedTokens.length >= MAX_FINGERPRINT_TOKENS) {
183
+ normalizedTruncated = true;
184
+ continue;
185
+ }
186
+ if (closing) {
187
+ normalizedTokens.push(`</${name}>`);
188
+ continue;
189
+ }
190
+ const parsed = parseAttributes(token);
191
+ if (parsed.truncated)
192
+ attributesTruncated = true;
193
+ const attributes = [...parsed.values.entries()]
194
+ .map(([attributeName, value]) => {
195
+ const metaKey = name === 'meta' ? String(parsed.values.get('name') || parsed.values.get('property') || '') : '';
196
+ if (attributeName === 'content' && /(?:csrf|nonce|session|token)/i.test(metaKey))
197
+ return null;
198
+ return normalizeAttribute(attributeName, value, baseUrl);
199
+ })
200
+ .filter(Boolean)
201
+ .sort();
202
+ normalizedTokens.push(`<${name}${attributes.length ? ` ${attributes.join(' ')}` : ''}${selfClosing ? '/' : ''}>`);
203
+ continue;
204
+ }
205
+ const text = normalizeText(token, MAX_FINGERPRINT_SOURCE_BYTES);
206
+ if (!text)
207
+ continue;
208
+ visibleSegments.push(text);
209
+ if (normalizedTokens.length < MAX_FINGERPRINT_TOKENS)
210
+ normalizedTokens.push(`#text:${text.slice(0, 512)}`);
211
+ else
212
+ normalizedTruncated = true;
213
+ }
214
+ return {
215
+ normalizedTokens,
216
+ structureTokens,
217
+ visibleText: visibleSegments.join(' '),
218
+ normalizedTruncated: normalizedTruncated || attributesTruncated || oversizedTag,
219
+ structureTruncated: structureTruncated || oversizedTag,
220
+ attributesTruncated,
221
+ oversizedTag,
222
+ };
223
+ }
224
+ function simHash64(tokens) {
225
+ if (!tokens.length)
226
+ return null;
227
+ const features = new Map();
228
+ const width = tokens.length >= 3 ? 3 : 1;
229
+ for (let index = 0; index <= tokens.length - width; index += 1) {
230
+ const feature = tokens.slice(index, index + width).join('\u001f');
231
+ features.set(feature, Math.min(3, (features.get(feature) || 0) + 1));
232
+ }
233
+ const vector = new Int32Array(64);
234
+ for (const [feature, weight] of features) {
235
+ const hash = createHash('sha256').update(feature).digest().readBigUInt64BE(0);
236
+ for (let bit = 0; bit < 64; bit += 1) {
237
+ vector[bit] = (vector[bit] ?? 0) + (((hash >> BigInt(bit)) & 1n) === 1n ? weight : -weight);
238
+ }
239
+ }
240
+ let result = 0n;
241
+ for (let bit = 0; bit < 64; bit += 1)
242
+ if ((vector[bit] ?? 0) > 0)
243
+ result |= 1n << BigInt(bit);
244
+ return { value: result.toString(16).padStart(16, '0'), featureCount: features.size };
245
+ }
246
+ function visibleTextFingerprint(text) {
247
+ const allTokens = text.match(WORD_RE) || [];
248
+ const truncated = allTokens.length > MAX_VISIBLE_TEXT_TOKENS;
249
+ const tokens = allTokens.slice(0, MAX_VISIBLE_TEXT_TOKENS).map((token) => normalizeText(token, 128)).filter(Boolean);
250
+ const result = simHash64(tokens);
251
+ return result ? {
252
+ algorithm: 'simhash64-v1',
253
+ value: result.value,
254
+ tokenCount: tokens.length,
255
+ featureCount: result.featureCount,
256
+ truncated,
257
+ } : null;
258
+ }
259
+ function formActionClass(value, baseUrl) {
260
+ if (typeof value !== 'string' || !value.trim())
261
+ return 'self';
262
+ const normalized = normalizeUrlAttribute(value, baseUrl);
263
+ if (!normalized)
264
+ return 'invalid';
265
+ const base = new URL(baseUrl);
266
+ const action = new URL(normalized);
267
+ if (action.origin === base.origin)
268
+ return 'same-origin';
269
+ if (base.protocol === 'https:' && action.protocol === 'http:')
270
+ return 'external-insecure';
271
+ return 'external';
272
+ }
273
+ function formStructureFingerprint(markup, baseUrl) {
274
+ const forms = [];
275
+ let current = null;
276
+ let formLimitReached = false;
277
+ let controlLimitReached = false;
278
+ 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) {
300
+ formLimitReached = true;
301
+ current = null;
302
+ continue;
303
+ }
304
+ const attributes = parseAttributes(token).values;
305
+ const rawMethod = normalizeText(attributes.get('method') || 'get', 20);
306
+ current = {
307
+ method: /^[a-z]{1,20}$/.test(rawMethod) ? rawMethod : 'other',
308
+ action: formActionClass(attributes.get('action'), baseUrl),
309
+ controls: {},
310
+ };
311
+ continue;
312
+ }
313
+ if (!current || closing || !['button', 'input', 'select', 'textarea'].includes(name))
314
+ continue;
315
+ if (controls >= MAX_FORM_CONTROLS) {
316
+ controlLimitReached = true;
317
+ continue;
318
+ }
319
+ controls += 1;
320
+ const attributes = parseAttributes(token).values;
321
+ const rawType = name === 'input' || name === 'button'
322
+ ? normalizeText(attributes.get('type') || (name === 'input' ? 'text' : 'submit'), 30)
323
+ : name;
324
+ const type = /^[a-z0-9-]{1,30}$/.test(rawType) ? `${name}:${rawType}` : `${name}:other`;
325
+ current.controls[type] = (current.controls[type] || 0) + 1;
326
+ }
327
+ if (current)
328
+ forms.push(current);
329
+ if (forms.length > MAX_FORM_FINGERPRINTS) {
330
+ forms.length = MAX_FORM_FINGERPRINTS;
331
+ formLimitReached = true;
332
+ }
333
+ for (const form of forms)
334
+ form.controls = Object.fromEntries(Object.entries(form.controls).sort(([left], [right]) => left.localeCompare(right)));
335
+ return forms.length ? {
336
+ algorithm: 'sha256',
337
+ value: sha256(JSON.stringify(forms)),
338
+ formCount: forms.length,
339
+ controlCount: controls,
340
+ truncated: formLimitReached || controlLimitReached,
341
+ } : null;
342
+ }
343
+ function normalizedResourceHosts(resources) {
344
+ const input = Array.isArray(resources?.externalOrigins) ? resources.externalOrigins : [];
345
+ const hosts = new Set();
346
+ let truncated = resources?.truncated === true || input.length > MAX_RESOURCE_HOSTS;
347
+ for (const value of input) {
348
+ if (hosts.size >= MAX_RESOURCE_HOSTS) {
349
+ truncated = true;
350
+ break;
351
+ }
352
+ if (typeof value !== 'string')
353
+ continue;
354
+ try {
355
+ const url = new URL(value);
356
+ if (['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && url.hostname)
357
+ hosts.add(url.hostname.toLowerCase());
358
+ }
359
+ catch {
360
+ // Invalid retained input cannot contribute to a new fingerprint.
361
+ }
362
+ }
363
+ const values = [...hosts].sort();
364
+ return {
365
+ algorithm: 'set-sha256',
366
+ value: values.length ? sha256(JSON.stringify(values)) : null,
367
+ values,
368
+ truncated,
369
+ };
370
+ }
371
+ function normalizedIdentifiers(identifiers, explicitlyTruncated = false) {
372
+ const values = [];
373
+ const seen = new Set();
374
+ let truncated = explicitlyTruncated || (Array.isArray(identifiers) && identifiers.length > MAX_IDENTIFIER_VALUES);
375
+ for (const item of Array.isArray(identifiers) ? identifiers : []) {
376
+ if (values.length >= MAX_IDENTIFIER_VALUES) {
377
+ truncated = true;
378
+ break;
379
+ }
380
+ if (!item || typeof item !== 'object')
381
+ continue;
382
+ const record = item;
383
+ const type = String(record.type || '');
384
+ const value = String(record.value || '');
385
+ if (!/^[a-z-]{1,40}$/.test(type) || !/^[A-Z0-9-]{1,64}$/.test(value))
386
+ continue;
387
+ const key = `${type}:${value}`;
388
+ if (seen.has(key))
389
+ continue;
390
+ seen.add(key);
391
+ values.push({ type, value });
392
+ }
393
+ values.sort((left, right) => left.type.localeCompare(right.type) || left.value.localeCompare(right.value));
394
+ return {
395
+ algorithm: 'set-sha256',
396
+ value: values.length ? sha256(JSON.stringify(values)) : null,
397
+ values,
398
+ truncated,
399
+ };
400
+ }
401
+ function createPageFingerprints(html, options = {}) {
402
+ const source = boundedSource(html);
403
+ const baseUrl = (() => {
404
+ try {
405
+ const url = new URL(typeof options.baseUrl === 'string' ? options.baseUrl : 'https://invalid.example/');
406
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.toString() : 'https://invalid.example/';
407
+ }
408
+ catch {
409
+ return 'https://invalid.example/';
410
+ }
411
+ })();
412
+ let exact = normalizedExactBodyHash(options.exactBodyHash) || {
413
+ algorithm: 'sha256',
414
+ value: sha256(source.bytes),
415
+ scope: options.sourceTruncated === true || source.truncated ? 'captured-prefix' : 'complete-body',
416
+ bytes: source.bytes.length,
417
+ source: 'decoded-markup',
418
+ };
419
+ if (options.sourceTruncated === true && exact.scope === 'complete-body')
420
+ exact = { ...exact, scope: 'captured-prefix' };
421
+ const normalized = normalizedMarkupAndStructure(source.text, baseUrl);
422
+ const parsedStructure = analyzeStaticHtml(source.text, { baseUrl });
423
+ const text = visibleTextFingerprint(normalized.visibleText);
424
+ const structureSimilarity = simHash64(parsedStructure.structureTokens);
425
+ const forms = formStructureFingerprint(staticMarkup(source.text), baseUrl);
426
+ const resourceHosts = normalizedResourceHosts(options.resources);
427
+ 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
431
+ || resourceHosts.truncated || identifiers.truncated;
432
+ const limitations = [
433
+ 'Fingerprints summarize capped static HTML and are comparison aids, not cryptographic proof of page authorship or intent.',
434
+ '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.',
436
+ ];
437
+ if (source.truncated)
438
+ limitations.push(`Fingerprint input was capped at ${MAX_FINGERPRINT_SOURCE_BYTES} UTF-8 bytes.`);
439
+ if (normalized.normalizedTruncated || normalized.structureTruncated)
440
+ limitations.push(`Fingerprint tokenization reached the ${MAX_FINGERPRINT_TOKENS}-token or ${MAX_FINGERPRINT_TAG_LENGTH}-character tag boundary.`);
441
+ if (parsedStructure.tagLimitReached || parsedStructure.structureLimitReached)
442
+ limitations.push('Standards-compliant DOM-structure tokenization reached its bounded tag or token limit.');
443
+ if (normalized.attributesTruncated)
444
+ limitations.push(`Some tags exceeded the ${MAX_FINGERPRINT_ATTRIBUTES}-attribute fingerprint boundary.`);
445
+ if (text?.truncated)
446
+ limitations.push(`Visible-text fingerprinting retained only the first ${MAX_VISIBLE_TEXT_TOKENS} normalized tokens.`);
447
+ if (forms?.truncated)
448
+ limitations.push(`Form fingerprinting retained at most ${MAX_FORM_FINGERPRINTS} forms and ${MAX_FORM_CONTROLS} controls.`);
449
+ return {
450
+ fingerprintVersion: PAGE_FINGERPRINT_VERSION,
451
+ exact,
452
+ normalizedHtml: {
453
+ algorithm: 'sha256',
454
+ value: sha256(normalized.normalizedTokens.join('\n')),
455
+ tokenCount: normalized.normalizedTokens.length,
456
+ truncated: normalized.normalizedTruncated,
457
+ },
458
+ visibleText: text,
459
+ domStructure: {
460
+ algorithm: 'sha256',
461
+ value: sha256(normalized.structureTokens.join('\n')),
462
+ nodeCount: normalized.structureTokens.length,
463
+ parser: 'static-tag-sequence-v1',
464
+ truncated: normalized.structureTruncated,
465
+ similarity: structureSimilarity ? {
466
+ algorithm: 'simhash64-v1',
467
+ value: structureSimilarity.value,
468
+ tokenCount: parsedStructure.structureTokens.length,
469
+ featureCount: structureSimilarity.featureCount,
470
+ truncated: parsedStructure.inputLimitReached || parsedStructure.tagLimitReached || parsedStructure.structureLimitReached,
471
+ } : null,
472
+ },
473
+ formStructure: forms,
474
+ resourceHosts,
475
+ identifiers,
476
+ complete: !truncated,
477
+ truncated,
478
+ limitations,
479
+ };
480
+ }
481
+ export { PAGE_FINGERPRINT_VERSION, MAX_FINGERPRINT_SOURCE_BYTES, MAX_FINGERPRINT_TOKENS, MAX_VISIBLE_TEXT_TOKENS, MAX_FORM_FINGERPRINTS, MAX_FORM_CONTROLS, createPageFingerprints, };
@@ -0,0 +1,117 @@
1
+ // Bounded, explainable page-role classification over the static HTML already
2
+ // captured by Deep Lookup. The classifier emits fixed labels and evidence
3
+ // descriptions only; it does not retain page text, arbitrary attributes, URLs,
4
+ // or matched markup, and it never treats a role as proof of intent or safety.
5
+ import { createObservation } from "./observation.mjs";
6
+ import { analyzeStaticHtml, } from "./static-html-analysis.mjs";
7
+ const PAGE_ROLE_PROFILE_VERSION = 1;
8
+ const MAX_PAGE_ROLE_FINDINGS = 4;
9
+ const MAX_PAGE_ROLE_EVIDENCE = 4;
10
+ const MAX_ROLE_TITLE_CHARS = 200;
11
+ const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
12
+ function boundedLowercase(value, maximum) {
13
+ if (typeof value !== 'string' || value.length > maximum || CONTROL_CHARACTER_RE.test(value))
14
+ return '';
15
+ return value.trim().toLowerCase();
16
+ }
17
+ function addFinding(findings, role, label, confidence, evidence) {
18
+ if (!evidence.length || findings.length >= MAX_PAGE_ROLE_FINDINGS)
19
+ return;
20
+ findings.push({
21
+ role,
22
+ label,
23
+ confidence,
24
+ evidence: evidence.slice(0, MAX_PAGE_ROLE_EVIDENCE),
25
+ });
26
+ }
27
+ function analyzePageRole(input = {}) {
28
+ const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
29
+ const markup = htmlAnalysis.markup;
30
+ const title = boundedLowercase(input.pageTitle, MAX_ROLE_TITLE_CHARS);
31
+ const activityStatus = boundedLowercase(input.activityStatus, 40);
32
+ const forms = htmlAnalysis.forms;
33
+ const findings = [];
34
+ const challengeEvidence = [];
35
+ if (/(?:captcha|recaptcha|hcaptcha|turnstile|cf-chl|challenge-form|verify-human)/u.test(markup)) {
36
+ challengeEvidence.push('Static challenge or human-verification marker observed');
37
+ }
38
+ if (/(?:just a moment|checking your browser|verify you are human|access denied)/u.test(title)) {
39
+ challengeEvidence.push('Page title is consistent with an access challenge');
40
+ }
41
+ addFinding(findings, 'access_challenge', 'Access challenge', challengeEvidence.length > 1 ? 'high' : 'medium', challengeEvidence);
42
+ const authenticationEvidence = [];
43
+ if (forms.categories.password > 0)
44
+ authenticationEvidence.push('Password-purpose input observed');
45
+ if (forms.categories.username > 0 || forms.categories.email > 0) {
46
+ authenticationEvidence.push('Username or email-purpose input observed');
47
+ }
48
+ if (/(?:login|log-in|signin|sign-in|authentication|current-password|new-password)/u.test(markup)) {
49
+ authenticationEvidence.push('Static authentication marker observed');
50
+ }
51
+ addFinding(findings, 'authentication', 'Authentication', forms.categories.password > 0 ? 'high' : 'medium', authenticationEvidence);
52
+ const commerceEvidence = [];
53
+ if (forms.categories.payment > 0)
54
+ commerceEvidence.push('Payment-purpose input observed');
55
+ if (/(?:checkout|shopping-cart|add-to-cart|product-price|woocommerce|shopify-payment)/u.test(markup)) {
56
+ commerceEvidence.push('Static commerce or checkout marker observed');
57
+ }
58
+ addFinding(findings, 'commerce', 'Commerce', forms.categories.payment > 0 ? 'high' : 'medium', commerceEvidence);
59
+ const supportEvidence = [];
60
+ if (/(?:contact|support|helpdesk|customer-service)/u.test(markup)) {
61
+ supportEvidence.push('Static support or contact marker observed');
62
+ }
63
+ if (forms.formsObserved > 0 && forms.categories.password === 0 && forms.categories.payment === 0) {
64
+ supportEvidence.push('Non-credential form surface observed');
65
+ }
66
+ addFinding(findings, 'support_contact', 'Support or contact', 'low', supportEvidence);
67
+ const parkedEvidence = [];
68
+ if (activityStatus === 'parked')
69
+ parkedEvidence.push('Existing activity analysis classified the page as parked');
70
+ if (/(?:domain-for-sale|buy-this-domain|parking-page|sedoparking)/u.test(markup)) {
71
+ parkedEvidence.push('Static domain-sale or parking marker observed');
72
+ }
73
+ addFinding(findings, 'parked_sale', 'Parked or for sale', activityStatus === 'parked' ? 'high' : 'medium', parkedEvidence);
74
+ const contentEvidence = [];
75
+ if (/<(?:article|main)(?:\s|>)/u.test(markup))
76
+ contentEvidence.push('Semantic article or main-content element observed');
77
+ if (/<(?:h1|h2)(?:\s|>)/u.test(markup))
78
+ contentEvidence.push('Static heading structure observed');
79
+ addFinding(findings, 'content', 'Content or publication', contentEvidence.length > 1 ? 'medium' : 'low', contentEvidence);
80
+ if (!findings.length) {
81
+ findings.push({
82
+ role: 'unknown',
83
+ label: 'Unclassified',
84
+ confidence: 'low',
85
+ evidence: ['No bounded role signature matched the retained static evidence'],
86
+ });
87
+ }
88
+ const sourceTruncated = input.sourceTruncated === true;
89
+ const truncated = sourceTruncated || htmlAnalysis.inputLimitReached || htmlAnalysis.tagLimitReached;
90
+ const limitations = [
91
+ 'Roles are heuristic review labels derived from bounded static HTML and semantic form declarations, not proof of a site purpose, legitimacy, safety, ownership, or intent.',
92
+ 'JavaScript-rendered content, page interaction, authenticated content, and text outside the retained title are not evaluated.',
93
+ 'Only fixed role labels and evidence descriptions are retained; page text, arbitrary attributes, matched markup, and URLs are discarded.',
94
+ ];
95
+ if (truncated)
96
+ limitations.push('The captured page or static tokenization reached a limit, so later role evidence may be absent.');
97
+ return {
98
+ pageRoleProfileVersion: PAGE_ROLE_PROFILE_VERSION,
99
+ ...createObservation({
100
+ status: truncated ? 'partial' : 'success',
101
+ observedAt: input.observedAt,
102
+ scanMode: 'deep',
103
+ source: 'derived',
104
+ complete: !truncated,
105
+ truncated,
106
+ limitations,
107
+ diagnostics: {
108
+ rolesObserved: findings.length,
109
+ formsObserved: forms.formsObserved,
110
+ tagsExamined: htmlAnalysis.tagsExamined,
111
+ },
112
+ }),
113
+ primaryRole: findings[0]?.role ?? 'unknown',
114
+ findings,
115
+ };
116
+ }
117
+ export { MAX_PAGE_ROLE_EVIDENCE, MAX_PAGE_ROLE_FINDINGS, PAGE_ROLE_PROFILE_VERSION, analyzePageRole, };