@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,61 @@
1
+ // Shared strict primitives for internal evidence contracts. These helpers do
2
+ // not infer missing values and keep every string, object, enum, and counter
3
+ // bounded before a provider or connector result reaches its public model.
4
+ export function isRecord(value) {
5
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6
+ }
7
+ export function boundedString(value, maxLength) {
8
+ if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/u.test(value))
9
+ return null;
10
+ const normalized = value.trim().replace(/\s+/gu, ' ');
11
+ return normalized ? normalized.slice(0, maxLength) : null;
12
+ }
13
+ export function strictBoundedString(value, maxLength) {
14
+ if (typeof value !== 'string'
15
+ || value.length > maxLength
16
+ || /[\u0000-\u001f\u007f]/u.test(value))
17
+ return null;
18
+ const normalized = value.trim().replace(/\s+/gu, ' ');
19
+ return normalized && normalized.length <= maxLength ? normalized : null;
20
+ }
21
+ export function isoTimestamp(value) {
22
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
23
+ return null;
24
+ const timestamp = Date.parse(value);
25
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
26
+ }
27
+ export function boundedHttpsUrl(value, maxLength) {
28
+ const raw = strictBoundedString(value, maxLength);
29
+ if (!raw)
30
+ return null;
31
+ try {
32
+ const parsed = new URL(raw);
33
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password)
34
+ return null;
35
+ const normalized = parsed.toString();
36
+ return normalized.length <= maxLength ? normalized : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export function exactKeys(value, allowed, label) {
43
+ if (!isRecord(value))
44
+ throw new TypeError(`${label} must be an object`);
45
+ const unknown = Object.keys(value).find((key) => !allowed.has(key));
46
+ if (unknown)
47
+ throw new TypeError(`${label} contains an unknown field: ${unknown}`);
48
+ }
49
+ export function enumValue(value, allowed, label) {
50
+ if (typeof value !== 'string' || !allowed.has(value)) {
51
+ throw new TypeError(`${label} is invalid`);
52
+ }
53
+ return value;
54
+ }
55
+ export function boundedInteger(value, minimum, maximum, label) {
56
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)
57
+ || value < minimum || value > maximum) {
58
+ throw new TypeError(`${label} must be an integer between ${minimum} and ${maximum}`);
59
+ }
60
+ return value;
61
+ }
@@ -0,0 +1,310 @@
1
+ // Passive browser-library identification over the capped HTML body already
2
+ // collected by an eligible deep lookup. The matcher uses a pinned, reviewed
3
+ // projection of Retire.js's browser catalogue and never fetches referenced
4
+ // scripts. Upstream URLs, filenames, inline bodies, and catalogue expressions
5
+ // are treated as untrusted inputs and are not retained in the result.
6
+ import { createHash } from 'node:crypto';
7
+ import { RETIRE_BROWSER_CATALOG } from "./generated/retire-browser-catalog.mjs";
8
+ import { createObservation } from "./observation.mjs";
9
+ import { MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_SCRIPT_ELEMENTS, MAX_STATIC_HTML_CHARS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
10
+ const BROWSER_LIBRARY_PROFILE_VERSION = 1;
11
+ const MAX_LIBRARY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
12
+ const MAX_LIBRARY_FINDINGS = 16;
13
+ const MAX_ADVISORY_IDENTIFIERS = 16;
14
+ const MAX_WEAKNESS_CLASSES = 12;
15
+ const MAX_MATCHES_PER_PATTERN = 4;
16
+ const MAX_VERSION_LENGTH = 64;
17
+ const COMPONENT_RE = /^[a-z0-9._-]{1,80}$/i;
18
+ const VERSION_RE = /^[0-9][0-9.a-z_-]{0,63}$/i;
19
+ const CVE_RE = /^CVE-[0-9X-]+$/;
20
+ const GHSA_RE = /^GHSA-[A-Z0-9-]+$/;
21
+ const CWE_RE = /^CWE-[0-9]+$/;
22
+ const SEVERITY_WEIGHT = Object.freeze({
23
+ none: 0,
24
+ low: 1,
25
+ medium: 2,
26
+ high: 3,
27
+ critical: 4,
28
+ });
29
+ const CATALOG_COMPONENTS = RETIRE_BROWSER_CATALOG.components;
30
+ function record(value) {
31
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
32
+ ? value
33
+ : {};
34
+ }
35
+ function boundedStringArray(value, limit, pattern) {
36
+ const output = [];
37
+ for (const item of (Array.isArray(value) ? value : []).slice(0, limit)) {
38
+ if (typeof item !== 'string' || item.length === 0 || item.length > 80 || !pattern.test(item))
39
+ continue;
40
+ output.push(item);
41
+ }
42
+ return output;
43
+ }
44
+ function comparableVersionPart(value) {
45
+ if (value === undefined)
46
+ return 0;
47
+ return /^[0-9]+$/.test(value) ? Number.parseInt(value, 10) : value;
48
+ }
49
+ // Retire.js uses a deliberately small dotted/dashed version comparator rather
50
+ // than a package-manager-specific semantic-version parser. Keeping the same
51
+ // comparison contract makes the pinned catalogue thresholds deterministic.
52
+ function isAtOrAbove(left, right) {
53
+ const leftParts = left.split(/[.-]/g);
54
+ const rightParts = right.split(/[.-]/g);
55
+ const length = Math.max(leftParts.length, rightParts.length);
56
+ for (let index = 0; index < length; index += 1) {
57
+ const leftPart = comparableVersionPart(leftParts[index]);
58
+ const rightPart = comparableVersionPart(rightParts[index]);
59
+ if (typeof leftPart !== typeof rightPart)
60
+ return typeof leftPart === 'number';
61
+ if (leftPart > rightPart)
62
+ return true;
63
+ if (leftPart < rightPart)
64
+ return false;
65
+ }
66
+ return true;
67
+ }
68
+ function matchingVulnerabilities(component, version) {
69
+ const vulnerabilities = Array.isArray(component.vulnerabilities) ? component.vulnerabilities : [];
70
+ const matched = [];
71
+ for (const rawVulnerability of vulnerabilities) {
72
+ const vulnerability = record(rawVulnerability);
73
+ if (typeof vulnerability.below !== 'string' || !VERSION_RE.test(vulnerability.below))
74
+ continue;
75
+ if (isAtOrAbove(version, vulnerability.below))
76
+ continue;
77
+ if (typeof vulnerability.atOrAbove === 'string'
78
+ && VERSION_RE.test(vulnerability.atOrAbove)
79
+ && !isAtOrAbove(version, vulnerability.atOrAbove))
80
+ continue;
81
+ if (Array.isArray(vulnerability.excludes) && vulnerability.excludes.includes(version))
82
+ continue;
83
+ matched.push(vulnerability);
84
+ }
85
+ return matched;
86
+ }
87
+ function matchPattern(pattern, value) {
88
+ if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > 2_048)
89
+ return [];
90
+ let expression;
91
+ try {
92
+ expression = new RegExp(pattern, 'g');
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ const matches = [];
98
+ let match;
99
+ while ((match = expression.exec(value)) && matches.length < MAX_MATCHES_PER_PATTERN) {
100
+ if (typeof match[1] === 'string' && VERSION_RE.test(match[1]))
101
+ matches.push(match[1]);
102
+ if (match[0] === '')
103
+ expression.lastIndex += 1;
104
+ }
105
+ return matches;
106
+ }
107
+ function matchReplacementPattern(pattern, value) {
108
+ if (typeof pattern !== 'string' || pattern.length === 0 || pattern.length > 2_048)
109
+ return [];
110
+ const descriptor = /^\/(.*[^\\])\/([^/]+)\/$/.exec(pattern);
111
+ const expressionSource = descriptor?.[1];
112
+ const replacement = descriptor?.[2];
113
+ if (!expressionSource || replacement === undefined)
114
+ return [];
115
+ let expression;
116
+ try {
117
+ expression = new RegExp(expressionSource, 'g');
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ const matches = [];
123
+ let match;
124
+ while ((match = expression.exec(value)) && matches.length < MAX_MATCHES_PER_PATTERN) {
125
+ const replaced = match[0].replace(new RegExp(expressionSource), replacement).replace(/(?:\.|-)?min$/i, '');
126
+ if (VERSION_RE.test(replaced))
127
+ matches.push(replaced);
128
+ if (match[0] === '')
129
+ expression.lastIndex += 1;
130
+ }
131
+ return matches;
132
+ }
133
+ function addDetected(detected, component, version, method) {
134
+ const normalizedVersion = version.replace(/(?:\.|-)?min$/i, '').slice(0, MAX_VERSION_LENGTH);
135
+ if (!COMPONENT_RE.test(component) || !VERSION_RE.test(normalizedVersion))
136
+ return;
137
+ const key = `${component}\u0000${normalizedVersion}`;
138
+ const existing = detected.get(key);
139
+ if (existing) {
140
+ existing.detections.add(method);
141
+ return;
142
+ }
143
+ if (detected.size >= MAX_LIBRARY_FINDINGS * 4)
144
+ return;
145
+ detected.set(key, { component, version: normalizedVersion, detections: new Set([method]) });
146
+ }
147
+ function scanExtractor(detected, extractorName, value, method) {
148
+ for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
149
+ const component = record(rawComponent);
150
+ const extractors = record(component.extractors);
151
+ const patterns = Array.isArray(extractors[extractorName]) ? extractors[extractorName] : [];
152
+ for (const pattern of patterns.slice(0, 64)) {
153
+ for (const version of matchPattern(pattern, value))
154
+ addDetected(detected, componentName, version, method);
155
+ }
156
+ }
157
+ }
158
+ function scanFilename(detected, reference) {
159
+ const filename = (reference.replace(/\\/g, '/').split('/').pop() || '').split(/[?#]/, 1)[0];
160
+ if (!filename)
161
+ return;
162
+ for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
163
+ const component = record(rawComponent);
164
+ const extractors = record(component.extractors);
165
+ const patterns = Array.isArray(extractors.filename) ? extractors.filename : [];
166
+ for (const pattern of patterns.slice(0, 64)) {
167
+ if (typeof pattern !== 'string' || pattern.length > 2_048)
168
+ continue;
169
+ for (const version of matchPattern(`^(?:${pattern})$`, filename)) {
170
+ addDetected(detected, componentName, version, 'script filename');
171
+ }
172
+ }
173
+ }
174
+ }
175
+ function scanInlineContent(detected, content) {
176
+ const normalized = content.replace(/\r\n?|\n/g, '\n');
177
+ const digest = createHash('sha1').update(normalized).digest('hex');
178
+ scanExtractor(detected, 'filecontent', normalized, 'inline signature');
179
+ for (const [componentName, rawComponent] of Object.entries(CATALOG_COMPONENTS)) {
180
+ const component = record(rawComponent);
181
+ const extractors = record(component.extractors);
182
+ const replacementPatterns = Array.isArray(extractors.filecontentreplace) ? extractors.filecontentreplace : [];
183
+ for (const pattern of replacementPatterns.slice(0, 64)) {
184
+ for (const version of matchReplacementPattern(pattern, normalized)) {
185
+ addDetected(detected, componentName, version, 'inline signature');
186
+ }
187
+ }
188
+ const hashes = record(extractors.hashes);
189
+ const version = hashes[digest];
190
+ if (typeof version === 'string')
191
+ addDetected(detected, componentName, version, 'inline hash');
192
+ }
193
+ }
194
+ function severity(value) {
195
+ return typeof value === 'string' && Object.hasOwn(SEVERITY_WEIGHT, value)
196
+ ? value
197
+ : null;
198
+ }
199
+ function findingFromDetection(detected) {
200
+ const component = record(CATALOG_COMPONENTS[detected.component]);
201
+ const vulnerabilities = matchingVulnerabilities(component, detected.version);
202
+ let highestSeverity = null;
203
+ const identifiers = new Set();
204
+ const weaknesses = new Set();
205
+ for (const vulnerability of vulnerabilities) {
206
+ const candidateSeverity = severity(vulnerability.severity);
207
+ if (candidateSeverity
208
+ && (!highestSeverity || SEVERITY_WEIGHT[candidateSeverity] > SEVERITY_WEIGHT[highestSeverity]))
209
+ highestSeverity = candidateSeverity;
210
+ const vulnerabilityIdentifiers = record(vulnerability.identifiers);
211
+ for (const cve of boundedStringArray(vulnerabilityIdentifiers.CVE, 16, CVE_RE)) {
212
+ if (identifiers.size < MAX_ADVISORY_IDENTIFIERS)
213
+ identifiers.add(cve);
214
+ }
215
+ if (typeof vulnerabilityIdentifiers.githubID === 'string'
216
+ && GHSA_RE.test(vulnerabilityIdentifiers.githubID)
217
+ && identifiers.size < MAX_ADVISORY_IDENTIFIERS)
218
+ identifiers.add(vulnerabilityIdentifiers.githubID.toUpperCase());
219
+ for (const cwe of boundedStringArray(vulnerability.cwe, 16, CWE_RE)) {
220
+ if (weaknesses.size < MAX_WEAKNESS_CLASSES)
221
+ weaknesses.add(cwe);
222
+ }
223
+ }
224
+ return {
225
+ id: detected.component,
226
+ name: detected.component,
227
+ apparentVersion: detected.version,
228
+ detectionMethods: [...detected.detections].sort(),
229
+ advisoryCount: vulnerabilities.length,
230
+ highestSeverity,
231
+ advisoryIdentifiers: [...identifiers].sort(),
232
+ weaknessClasses: [...weaknesses].sort(),
233
+ };
234
+ }
235
+ function analyzeBrowserLibraries(input = {}) {
236
+ const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
237
+ const detected = new Map();
238
+ let referencesExamined = 0;
239
+ let inlineScriptsExamined = 0;
240
+ for (const script of htmlAnalysis.scripts) {
241
+ if (script.reference) {
242
+ referencesExamined += 1;
243
+ scanExtractor(detected, 'uri', script.reference, 'script URL');
244
+ scanFilename(detected, script.reference);
245
+ }
246
+ else if (script.inlineContent && script.mediaType !== 'application/ld+json') {
247
+ inlineScriptsExamined += 1;
248
+ scanInlineContent(detected, script.inlineContent);
249
+ }
250
+ }
251
+ const allFindings = [...detected.values()]
252
+ .map(findingFromDetection)
253
+ .sort((left, right) => ((right.highestSeverity ? SEVERITY_WEIGHT[right.highestSeverity] : -1)
254
+ - (left.highestSeverity ? SEVERITY_WEIGHT[left.highestSeverity] : -1)
255
+ || right.advisoryCount - left.advisoryCount
256
+ || left.name.localeCompare(right.name)
257
+ || left.apparentVersion.localeCompare(right.apparentVersion)));
258
+ const findingLimitReached = allFindings.length > MAX_LIBRARY_FINDINGS;
259
+ const truncated = input.sourceTruncated === true
260
+ || htmlAnalysis.inputLimitReached
261
+ || htmlAnalysis.tagLimitReached
262
+ || htmlAnalysis.scriptLimitReached
263
+ || htmlAnalysis.inlineLimitReached
264
+ || findingLimitReached;
265
+ const limitations = [
266
+ 'Library versions are inferred from passive static signatures and may be absent, transformed, or misleading.',
267
+ 'A catalogue advisory match identifies a component version associated with a published advisory; it does not establish reachability or exploitability.',
268
+ 'Referenced scripts are not fetched, executed, or retained, and unmatched scripts are not evidence that no library is present.',
269
+ ];
270
+ if (input.sourceTruncated === true)
271
+ limitations.push('The captured homepage body was truncated, so script evidence may be incomplete.');
272
+ if (htmlAnalysis.inputLimitReached)
273
+ limitations.push(`Only the first ${MAX_LIBRARY_HTML_CHARS} HTML characters were evaluated.`);
274
+ if (htmlAnalysis.tagLimitReached)
275
+ limitations.push('Static HTML tokenization reached its tag or attribute boundary.');
276
+ if (htmlAnalysis.scriptLimitReached)
277
+ limitations.push(`Only the first ${MAX_SCRIPT_ELEMENTS} script elements were evaluated.`);
278
+ if (htmlAnalysis.inlineLimitReached)
279
+ limitations.push(`Inline script evaluation reached its ${MAX_INLINE_SCRIPT_TOTAL_CHARS}-character cumulative boundary.`);
280
+ if (findingLimitReached)
281
+ limitations.push(`Only the first ${MAX_LIBRARY_FINDINGS} library findings were retained.`);
282
+ return {
283
+ profileVersion: BROWSER_LIBRARY_PROFILE_VERSION,
284
+ catalog: {
285
+ name: 'Retire.js',
286
+ version: RETIRE_BROWSER_CATALOG.catalogVersion,
287
+ sourceRevision: RETIRE_BROWSER_CATALOG.sourceRevision,
288
+ },
289
+ ...createObservation({
290
+ status: truncated ? 'partial' : 'success',
291
+ observedAt: input.observedAt,
292
+ scanMode: 'deep',
293
+ source: 'derived',
294
+ complete: !truncated,
295
+ truncated,
296
+ limitations,
297
+ diagnostics: {
298
+ scriptsExamined: htmlAnalysis.scripts.length,
299
+ referencesExamined,
300
+ inlineScriptsExamined,
301
+ inlineCharactersExamined: htmlAnalysis.inlineCharactersExamined,
302
+ catalogComponents: Object.keys(CATALOG_COMPONENTS).length,
303
+ findings: allFindings.length,
304
+ advisoryMatches: allFindings.reduce((sum, finding) => sum + finding.advisoryCount, 0),
305
+ },
306
+ }),
307
+ findings: allFindings.slice(0, MAX_LIBRARY_FINDINGS),
308
+ };
309
+ }
310
+ export { BROWSER_LIBRARY_PROFILE_VERSION, MAX_INLINE_SCRIPT_CHARS, MAX_INLINE_SCRIPT_TOTAL_CHARS, MAX_LIBRARY_FINDINGS, MAX_LIBRARY_HTML_CHARS, MAX_SCRIPT_ELEMENTS, analyzeBrowserLibraries, };
@@ -0,0 +1,83 @@
1
+ // Bounded comparison-only evidence for compact Deep Bulk responses. The
2
+ // complete technology and TLS observations remain transient and separately
3
+ // attributed; this projection retains only stable identifiers and source
4
+ // health needed for analyst-controlled comparisons.
5
+ export const BULK_COMPARISON_EVIDENCE_VERSION = 1;
6
+ export const MAX_BULK_TECHNOLOGY_IDS = 12;
7
+ export const MAX_BULK_TLS_ISSUER_LENGTH = 240;
8
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
9
+ const TECHNOLOGY_ID_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/u;
10
+ const SHA256_RE = /^[a-f0-9]{64}$/iu;
11
+ const STATES = new Set(['error', 'not_found', 'partial', 'success', 'unavailable']);
12
+ function record(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value)
14
+ ? value
15
+ : {};
16
+ }
17
+ function boundedText(value, maximum) {
18
+ return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
19
+ ? value.replace(/\s+/gu, ' ').trim()
20
+ : '';
21
+ }
22
+ function state(value) {
23
+ const normalized = boundedText(value, 40).toLowerCase();
24
+ return STATES.has(normalized)
25
+ ? normalized
26
+ : 'unavailable';
27
+ }
28
+ function technologyIds(value) {
29
+ if (!Array.isArray(value))
30
+ return { ids: [], truncated: false };
31
+ const output = new Set();
32
+ let truncated = value.length > MAX_BULK_TECHNOLOGY_IDS;
33
+ for (const candidate of value.slice(0, MAX_BULK_TECHNOLOGY_IDS * 4)) {
34
+ const id = boundedText(record(candidate).id, 80).toLowerCase();
35
+ if (!TECHNOLOGY_ID_RE.test(id) || output.has(id))
36
+ continue;
37
+ if (output.size >= MAX_BULK_TECHNOLOGY_IDS)
38
+ truncated = true;
39
+ else
40
+ output.add(id);
41
+ }
42
+ return { ids: [...output].sort(), truncated };
43
+ }
44
+ function issuerLabel(value) {
45
+ const issuer = record(value);
46
+ const values = [
47
+ ...(Array.isArray(issuer.commonNames) ? issuer.commonNames : []),
48
+ ...(Array.isArray(issuer.organizations) ? issuer.organizations : []),
49
+ ]
50
+ .slice(0, 8)
51
+ .map((candidate) => boundedText(candidate, 160))
52
+ .filter(Boolean);
53
+ const label = [...new Set(values)].join(' · ');
54
+ return label ? label.slice(0, MAX_BULK_TLS_ISSUER_LENGTH) : null;
55
+ }
56
+ export function buildBulkComparisonEvidence(availabilityValue) {
57
+ const availability = record(availabilityValue);
58
+ const technology = record(availability.technologyProfile);
59
+ const technologyState = state(technology.status);
60
+ const technologyUsable = ['success', 'partial'].includes(technologyState);
61
+ const technologyFindings = technologyUsable
62
+ ? technologyIds(technology.findings)
63
+ : { ids: [], truncated: false };
64
+ const tls = record(availability.tls);
65
+ const tlsState = state(tls.status);
66
+ const tlsUsable = ['success', 'partial'].includes(tlsState);
67
+ const certificate = record(tls.certificate);
68
+ const publicKey = record(certificate.publicKey);
69
+ const spki = boundedText(publicKey.fingerprintSha256, 64);
70
+ return {
71
+ version: BULK_COMPARISON_EVIDENCE_VERSION,
72
+ technology: {
73
+ state: technologyState,
74
+ ids: technologyFindings.ids,
75
+ truncated: technologyUsable && (technology.truncated === true || technologyFindings.truncated),
76
+ },
77
+ tls: {
78
+ state: tlsState,
79
+ issuerLabel: tlsUsable ? issuerLabel(certificate.issuer) : null,
80
+ spkiSha256: tlsUsable && SHA256_RE.test(spki) ? spki.toLowerCase() : null,
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,93 @@
1
+ // Query classification - shared by both the Express server and the Netlify
2
+ // Functions so a domain/IP/ASN query is always parsed identically wherever
3
+ // this code runs. Every classified value eventually reaches a raw protocol
4
+ // call - a WHOIS TCP socket write (lib/whois.mts), a DNS query, an RDAP/
5
+ // MTA-STS fetch URL - and none of those downstream call sites re-validate
6
+ // syntax, so this is the one place responsible for rejecting anything that
7
+ // isn't actually safe to hand to them.
8
+ //
9
+ // Domains are classified to their *registrable* domain via the Public Suffix
10
+ // List (tldts). Registries only publish RDAP/WHOIS records at the registrable-
11
+ // domain level, so a lookup for `login.example.com` must query `example.com` -
12
+ // otherwise an arbitrary subdomain's RDAP 404 would be misread as "available
13
+ // to register" when the underlying registration plainly exists. Both the
14
+ // original input hostname and the resolved registrable domain are returned so
15
+ // callers can look up the latter while still showing the user what they typed.
16
+ import { isIP } from 'node:net';
17
+ import { parse } from 'tldts';
18
+ // A CR/LF here would let one query become multiple lines once it reaches
19
+ // lib/whois.mts's raw `socket.write(query + '\r\n')` - a normal URL-encoded
20
+ // query string (?q=example.com%0D%0AHELP) decodes back into literal control
21
+ // characters before this function ever sees them, so this has to be
22
+ // checked explicitly, before any other parsing, rather than assumed away.
23
+ const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
24
+ // 32-bit ASN ceiling (RFC 6793 four-octet ASNs). AS0 and AS4294967295 are
25
+ // reserved but syntactically valid; anything above is not an ASN at all.
26
+ const MAX_ASN = 4294967295;
27
+ // A registrable-domain label: LDH (letter/digit/hyphen), 1-63 characters, no
28
+ // leading or trailing hyphen. tldts already rejects empty labels, over-long
29
+ // labels, leading/trailing hyphens, and public-suffix-only inputs (all yield
30
+ // a null registrable domain) - but it does NOT reject underscores, so this is
31
+ // the explicit backstop that catches `foo_bar.com` and makes the full rule
32
+ // checkable in one place.
33
+ function isValidRegistrableLabel(label) {
34
+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label);
35
+ }
36
+ function classifyQuery(raw) {
37
+ const trimmed = raw.trim();
38
+ if (CONTROL_CHAR_RE.test(trimmed)) {
39
+ throw new Error('Query contains control characters, which are not valid in a domain, IP, or ASN.');
40
+ }
41
+ // Strip a scheme and any path/query/fragment. A single terminal root dot
42
+ // (`example.com.`, the fully-qualified form) is normalized away, but more
43
+ // than one is malformed - a valid name has at most one root label.
44
+ const withoutScheme = trimmed.replace(/^[a-z]+:\/\//i, '').split(/[/?#]/)[0] ?? '';
45
+ if (withoutScheme.endsWith('..')) {
46
+ throw new Error(`"${trimmed}" has more than one terminal dot.`);
47
+ }
48
+ const q = withoutScheme.replace(/\.$/, '');
49
+ // net.isIP is Node's own IP-address parser - stricter and more correct
50
+ // than a hand-rolled regex (rejects out-of-range IPv4 octets, malformed
51
+ // IPv6 groupings, etc.) and handles both families in one call.
52
+ const ipVersion = isIP(q);
53
+ if (ipVersion === 4)
54
+ return { type: 'ipv4', value: q };
55
+ if (ipVersion === 6)
56
+ return { type: 'ipv6', value: q };
57
+ const asnMatch = q.match(/^AS(\d+)$/i) || q.match(/^(\d+)$/);
58
+ if (asnMatch) {
59
+ const num = Number(asnMatch[1]);
60
+ if (!Number.isInteger(num) || num < 0 || num > MAX_ASN) {
61
+ throw new Error(`"${trimmed}" is not a valid ASN (must be an integer 0-${MAX_ASN}).`);
62
+ }
63
+ return { type: 'asn', value: `AS${num}` };
64
+ }
65
+ // Domain: IDNA-normalize via the WHATWG URL parser (punycode-encodes an
66
+ // internationalized name, strips a stray port), then resolve the registrable
67
+ // domain via the Public Suffix List.
68
+ let inputHostname;
69
+ try {
70
+ inputHostname = new URL(`https://${q}`).hostname.toLowerCase();
71
+ }
72
+ catch {
73
+ inputHostname = '';
74
+ }
75
+ if (!inputHostname || !inputHostname.includes('.') || inputHostname.length > 253) {
76
+ throw new Error(`"${trimmed}" is not a valid domain, IP, or ASN.`);
77
+ }
78
+ const registrableDomain = parse(inputHostname).domain;
79
+ if (!registrableDomain) {
80
+ throw new Error(`"${trimmed}" is not a registrable domain (no public-suffix match).`);
81
+ }
82
+ if (!registrableDomain.split('.').every(isValidRegistrableLabel)) {
83
+ throw new Error(`"${trimmed}" contains an invalid domain label (underscores, empty, or malformed labels are not registrable).`);
84
+ }
85
+ return {
86
+ type: 'domain',
87
+ value: registrableDomain,
88
+ inputHostname,
89
+ registrableDomain,
90
+ isSubdomain: inputHostname !== registrableDomain,
91
+ };
92
+ }
93
+ export { classifyQuery, MAX_ASN };
@@ -0,0 +1,83 @@
1
+ // Static client-side behaviour indicators derived from the existing bounded
2
+ // HTML tokenizer pass. Scripts are never fetched or executed. Only fixed
3
+ // indicator identifiers, descriptions, evidence classes, and bounded counts
4
+ // leave this module; script references and contents are discarded.
5
+ import { createObservation } from "./observation.mjs";
6
+ import { analyzeStaticHtml, } from "./static-html-analysis.mjs";
7
+ const CLIENT_BEHAVIOR_PROFILE_VERSION = 1;
8
+ const MAX_CLIENT_BEHAVIOR_INDICATORS = 12;
9
+ const MAX_INDICATOR_OCCURRENCES = 999;
10
+ function boundedOccurrences(value) {
11
+ return Math.max(1, Math.min(MAX_INDICATOR_OCCURRENCES, value));
12
+ }
13
+ function patternCount(value, pattern) {
14
+ let count = 0;
15
+ for (const _match of value.matchAll(pattern)) {
16
+ count += 1;
17
+ if (count >= MAX_INDICATOR_OCCURRENCES)
18
+ break;
19
+ }
20
+ return count;
21
+ }
22
+ function analyzeClientBehavior(input = {}) {
23
+ const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
24
+ const markup = htmlAnalysis.markup;
25
+ const inlineScripts = htmlAnalysis.scripts
26
+ .filter((script) => script.reference === null)
27
+ .map((script) => script.inlineContent)
28
+ .join('\n');
29
+ const indicators = [];
30
+ function add(id, label, evidenceClass, occurrences, explanation) {
31
+ if (occurrences <= 0 || indicators.length >= MAX_CLIENT_BEHAVIOR_INDICATORS)
32
+ return;
33
+ indicators.push({ id, label, evidenceClass, occurrences: boundedOccurrences(occurrences), explanation });
34
+ }
35
+ add('inline_event_handlers', 'Inline event handlers', 'static_markup', patternCount(markup, /\son[a-z]{2,24}="/gu), 'Inline HTML event-handler attributes were observed.');
36
+ add('client_navigation', 'Client-side navigation', 'inline_script', patternCount(inlineScripts, /\b(?:history\.(?:pushState|replaceState)|location\.(?:assign|replace)|window\.location)\b/gu), 'Inline script contains a client-side navigation API.');
37
+ add('form_interception', 'Form interception', 'inline_script', patternCount(inlineScripts, /(?:addEventListener\s*\(\s*['"]submit['"]|\.onsubmit\s*=|preventDefault\s*\()/gu), 'Inline script contains a form-submission or default-action interception marker.');
38
+ add('service_worker', 'Service worker registration', 'inline_script', patternCount(inlineScripts, /\bnavigator\.serviceWorker\.register\s*\(/gu), 'Inline script contains a service-worker registration call.');
39
+ add('browser_storage', 'Browser storage access', 'inline_script', patternCount(inlineScripts, /\b(?:localStorage|sessionStorage|indexedDB)\b/gu), 'Inline script references a browser-local storage API.');
40
+ add('clipboard_access', 'Clipboard access', 'inline_script', patternCount(inlineScripts, /\bnavigator\.clipboard\b/gu), 'Inline script references the browser clipboard API.');
41
+ add('dynamic_code_evaluation', 'Dynamic code evaluation', 'inline_script', patternCount(inlineScripts, /(?:\beval\s*\(|\bnew\s+Function\s*\()/gu), 'Inline script contains a dynamic code-evaluation API.');
42
+ add('browser_fingerprinting', 'Browser fingerprinting surfaces', 'inline_script', patternCount(inlineScripts, /(?:toDataURL\s*\(|getImageData\s*\(|AudioContext\b|WEBGL_debug_renderer_info\b)/gu), 'Inline script references an API commonly used in browser-fingerprint calculations.');
43
+ add('persistent_connection', 'Persistent client connection', 'inline_script', patternCount(inlineScripts, /\b(?:WebSocket|EventSource)\s*\(/gu), 'Inline script contains a persistent browser-connection constructor.');
44
+ const sourceTruncated = input.sourceTruncated === true;
45
+ const truncated = sourceTruncated
46
+ || htmlAnalysis.inputLimitReached
47
+ || htmlAnalysis.tagLimitReached
48
+ || htmlAnalysis.scriptLimitReached
49
+ || htmlAnalysis.inlineLimitReached;
50
+ const limitations = [
51
+ 'Indicators come only from bounded static markup and retained inline-script prefixes; referenced scripts are not fetched and no code is executed.',
52
+ 'An observed API or handler can be normal application behaviour and does not establish vulnerability, tracking, credential theft, maliciousness, reachability, or execution.',
53
+ 'No script contents, script references, paths, queries, hashes, arbitrary attributes, or matched source fragments are retained.',
54
+ 'No indicator means only that the bounded retained static evidence did not match this fixed catalogue.',
55
+ ];
56
+ if (truncated)
57
+ limitations.push('The captured page or static script/token boundaries were reached, so behaviour indicators may be incomplete.');
58
+ return {
59
+ clientBehaviorProfileVersion: CLIENT_BEHAVIOR_PROFILE_VERSION,
60
+ ...createObservation({
61
+ status: truncated ? 'partial' : 'success',
62
+ observedAt: input.observedAt,
63
+ scanMode: 'deep',
64
+ source: 'derived',
65
+ complete: !truncated,
66
+ truncated,
67
+ limitations,
68
+ diagnostics: {
69
+ indicatorsObserved: indicators.length,
70
+ scriptElementsExamined: htmlAnalysis.scripts.length,
71
+ inlineCharactersExamined: htmlAnalysis.inlineCharactersExamined,
72
+ },
73
+ }),
74
+ scriptSummary: {
75
+ elementsObserved: htmlAnalysis.scripts.length,
76
+ referencedScripts: htmlAnalysis.scripts.filter((script) => script.reference !== null).length,
77
+ inlineScripts: htmlAnalysis.scripts.filter((script) => script.reference === null).length,
78
+ moduleScripts: htmlAnalysis.scripts.filter((script) => script.mediaType === 'module').length,
79
+ },
80
+ indicators,
81
+ };
82
+ }
83
+ export { CLIENT_BEHAVIOR_PROFILE_VERSION, MAX_CLIENT_BEHAVIOR_INDICATORS, analyzeClientBehavior, };