@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,127 @@
1
+ // Pure browser-local watchlist collection model. Watchlist history owns the
2
+ // evidence shape and diff semantics; this module owns collection names, schema
3
+ // migration, import merging, and exact serialized-byte accounting.
4
+ import { MAX_WATCHLIST_DOMAINS, normalizeWatchlistEntry } from "./watchlist-history.js";
5
+ export const WATCHLIST_SCHEMA_VERSION = 2;
6
+ export const WATCHLIST_SCHEMA = 'whoisleuth.watchlists';
7
+ export const MAX_WATCHLISTS = 100;
8
+ export const MAX_WATCHLIST_INPUTS = MAX_WATCHLISTS * 4;
9
+ export const MAX_WATCHLIST_NAME_LENGTH = 100;
10
+ // Cases and other analyst stores share the same origin quota. A two-megabyte
11
+ // ceiling accommodates practical Bulk results while failing before the browser
12
+ // turns storage pressure into an opaque quota exception.
13
+ export const MAX_WATCHLIST_STORE_BYTES = 2 * 1024 * 1024;
14
+ const BLOCKED_NAMES = new Set(['__proto__', 'prototype', 'constructor']);
15
+ const CONTROL_RE = /[\x00-\x1f\x7f]/;
16
+ function plainRecord(value) {
17
+ return value && typeof value === 'object' && !Array.isArray(value)
18
+ ? value
19
+ : null;
20
+ }
21
+ function isEnvelope(value) {
22
+ return Boolean(value && value.schema === WATCHLIST_SCHEMA && plainRecord(value.watchlists));
23
+ }
24
+ export function normalizeWatchlistName(value) {
25
+ if (typeof value !== 'string' || CONTROL_RE.test(value))
26
+ return '';
27
+ const name = value.trim();
28
+ if (name.length > MAX_WATCHLIST_NAME_LENGTH)
29
+ return '';
30
+ return name && !BLOCKED_NAMES.has(name.toLowerCase()) ? name : '';
31
+ }
32
+ function watchlistMap(raw) {
33
+ const value = plainRecord(raw);
34
+ if (!value)
35
+ return null;
36
+ return isEnvelope(value) ? plainRecord(value.watchlists) : value;
37
+ }
38
+ export function watchlistStoreVersion(raw) {
39
+ const value = plainRecord(raw);
40
+ if (!value || !isEnvelope(value))
41
+ return value ? 1 : null;
42
+ return typeof value.version === 'number' && Number.isFinite(value.version) && value.version > 0 ? value.version : null;
43
+ }
44
+ function defineEntry(target, name, entry) {
45
+ Object.defineProperty(target, name, { value: entry, writable: true, enumerable: true, configurable: true });
46
+ }
47
+ export function normalizeWatchlistStore(raw) {
48
+ const source = watchlistMap(raw);
49
+ const watchlists = {};
50
+ if (!source)
51
+ return { schema: WATCHLIST_SCHEMA, version: WATCHLIST_SCHEMA_VERSION, watchlists };
52
+ for (const [rawName, rawEntry] of Object.entries(source).slice(0, MAX_WATCHLIST_INPUTS)) {
53
+ const name = normalizeWatchlistName(rawName);
54
+ const entry = plainRecord(rawEntry);
55
+ if (!name || !entry || !Array.isArray(entry.results) || entry.results.length > MAX_WATCHLIST_DOMAINS)
56
+ continue;
57
+ defineEntry(watchlists, name, normalizeWatchlistEntry(entry));
58
+ if (Object.keys(watchlists).length >= MAX_WATCHLISTS)
59
+ break;
60
+ }
61
+ return { schema: WATCHLIST_SCHEMA, version: WATCHLIST_SCHEMA_VERSION, watchlists };
62
+ }
63
+ function byteLength(value) {
64
+ return new TextEncoder().encode(value).byteLength;
65
+ }
66
+ export function assertWatchlistStoreBudget(watchlists) {
67
+ const store = normalizeWatchlistStore(watchlists);
68
+ if (byteLength(JSON.stringify(store)) > MAX_WATCHLIST_STORE_BYTES) {
69
+ throw new Error('Watchlist storage is full. Export and remove a watchlist before saving more.');
70
+ }
71
+ return store;
72
+ }
73
+ export function serializeWatchlistStore(watchlists) {
74
+ return JSON.stringify(assertWatchlistStoreBudget(watchlists));
75
+ }
76
+ function validateImportShape(raw) {
77
+ const value = plainRecord(raw);
78
+ if (!value || value.schema !== WATCHLIST_SCHEMA) {
79
+ throw new Error('This JSON file is not a WHOISleuth watchlist export.');
80
+ }
81
+ if (!plainRecord(value.watchlists)) {
82
+ throw new Error('Expected a current WHOISleuth watchlist export.');
83
+ }
84
+ }
85
+ export function mergeWatchlistStores(localRaw, importedRaw) {
86
+ validateImportShape(importedRaw);
87
+ const importedVersion = watchlistStoreVersion(importedRaw);
88
+ if (importedVersion !== null && importedVersion > WATCHLIST_SCHEMA_VERSION) {
89
+ throw new Error(`This watchlist file uses newer schema ${importedVersion}. Update the app before importing it.`);
90
+ }
91
+ if (importedVersion !== WATCHLIST_SCHEMA_VERSION) {
92
+ throw new Error(`Expected a WHOISleuth watchlist export using schema ${WATCHLIST_SCHEMA_VERSION}.`);
93
+ }
94
+ const local = normalizeWatchlistStore(localRaw).watchlists;
95
+ const source = watchlistMap(importedRaw) || {};
96
+ const entries = Object.entries(source);
97
+ let added = 0;
98
+ let updated = 0;
99
+ let skipped = Math.max(0, entries.length - MAX_WATCHLIST_INPUTS);
100
+ for (const [rawName, rawEntry] of entries.slice(0, MAX_WATCHLIST_INPUTS)) {
101
+ const name = normalizeWatchlistName(rawName);
102
+ const entry = plainRecord(rawEntry);
103
+ if (!name || !entry || !Array.isArray(entry.results) || entry.results.length > MAX_WATCHLIST_DOMAINS) {
104
+ skipped++;
105
+ continue;
106
+ }
107
+ if (Object.prototype.hasOwnProperty.call(local, name))
108
+ updated++;
109
+ else if (Object.keys(local).length >= MAX_WATCHLISTS) {
110
+ skipped++;
111
+ continue;
112
+ }
113
+ else
114
+ added++;
115
+ defineEntry(local, name, normalizeWatchlistEntry(entry));
116
+ }
117
+ return { watchlists: local, added, updated, skipped };
118
+ }
119
+ export function buildWatchlistExport(watchlists, nowIso = new Date().toISOString()) {
120
+ const parsed = typeof nowIso === 'string' ? Date.parse(nowIso) : Number.NaN;
121
+ return {
122
+ schema: WATCHLIST_SCHEMA,
123
+ version: WATCHLIST_SCHEMA_VERSION,
124
+ exportedAt: Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date().toISOString(),
125
+ watchlists: normalizeWatchlistStore(watchlists).watchlists,
126
+ };
127
+ }
@@ -0,0 +1,302 @@
1
+ import { normalizeDomain } from "./case-model.js";
2
+ export const WEBSITE_SNAPSHOT_SCHEMA = 'whoisleuth.website-profile-snapshots';
3
+ export const WEBSITE_SNAPSHOT_SCHEMA_VERSION = 3;
4
+ export const MAX_WEBSITE_SNAPSHOTS = 60;
5
+ export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
6
+ export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
7
+ export const MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES = 768 * 1024;
8
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
9
+ const DIGEST_RE = /^[a-f0-9]{16,128}$/iu;
10
+ const SHA256_RE = /^[a-f0-9]{64}$/iu;
11
+ const SERIAL_RE = /^[a-f0-9]{1,128}$/iu;
12
+ function record(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
14
+ }
15
+ function text(value, maximum) {
16
+ return typeof value === 'string' && value.length <= maximum && !CONTROL_RE.test(value)
17
+ ? value.trim()
18
+ : '';
19
+ }
20
+ function timestamp(value) {
21
+ const candidate = text(value, 64);
22
+ const parsed = Date.parse(candidate);
23
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '';
24
+ }
25
+ function digest(value) {
26
+ const candidate = text(value, 128).toLowerCase();
27
+ return DIGEST_RE.test(candidate) ? candidate : null;
28
+ }
29
+ function values(raw, limit, normalize) {
30
+ if (!Array.isArray(raw))
31
+ return [];
32
+ const output = [];
33
+ const seen = new Set();
34
+ for (const value of raw.slice(0, limit * 4)) {
35
+ const normalized = normalize(value);
36
+ if (!normalized)
37
+ continue;
38
+ const key = JSON.stringify(normalized);
39
+ if (seen.has(key))
40
+ continue;
41
+ seen.add(key);
42
+ output.push(normalized);
43
+ if (output.length >= limit)
44
+ break;
45
+ }
46
+ return output;
47
+ }
48
+ function technology(value) {
49
+ const item = record(value);
50
+ const id = text(item?.id, 80);
51
+ const name = text(item?.name, 120);
52
+ if (!id || !name)
53
+ return null;
54
+ return { id, name, category: text(item?.category, 80) || 'technology', confidence: text(item?.confidence, 40) || 'unknown' };
55
+ }
56
+ function posture(value) {
57
+ const item = record(value);
58
+ const id = text(item?.id, 80);
59
+ const state = text(item?.state, 40);
60
+ return id && state ? { id, state } : null;
61
+ }
62
+ function source(value) {
63
+ const item = record(value);
64
+ const sourceName = text(item?.source, 40);
65
+ const state = text(item?.state, 40);
66
+ return sourceName && state ? { source: sourceName, state } : null;
67
+ }
68
+ function nullableBoolean(value) {
69
+ return typeof value === 'boolean' ? value : null;
70
+ }
71
+ function nullableText(value, maximum) {
72
+ return text(value, maximum) || null;
73
+ }
74
+ function certificate(value) {
75
+ const item = record(value);
76
+ const fingerprintSha256 = text(item?.fingerprintSha256, 64).toLowerCase();
77
+ if (!SHA256_RE.test(fingerprintSha256))
78
+ return null;
79
+ const spkiCandidate = text(item?.spkiFingerprintSha256, 64).toLowerCase();
80
+ const serialCandidate = text(item?.serialNumber, 128).toLowerCase();
81
+ return {
82
+ observationVersion: 1,
83
+ source: 'tls',
84
+ collectionDepth: 'deep',
85
+ fingerprintSha256,
86
+ spkiFingerprintSha256: SHA256_RE.test(spkiCandidate) ? spkiCandidate : null,
87
+ issuer: nullableText(item?.issuer, 180),
88
+ subject: nullableText(item?.subject, 180),
89
+ serialNumber: SERIAL_RE.test(serialCandidate) ? serialCandidate : null,
90
+ validFrom: timestamp(item?.validFrom) || null,
91
+ validTo: timestamp(item?.validTo) || null,
92
+ authorized: nullableBoolean(item?.authorized),
93
+ hostnameMatches: nullableBoolean(item?.hostnameMatches),
94
+ validity: nullableText(item?.validity, 40),
95
+ complete: item?.complete === true,
96
+ truncated: item?.truncated === true,
97
+ };
98
+ }
99
+ function identity(value) {
100
+ const item = record(value);
101
+ return {
102
+ normalizedHtml: digest(item?.normalizedHtml),
103
+ visibleText: digest(item?.visibleText),
104
+ domStructure: digest(item?.domStructure),
105
+ formStructure: digest(item?.formStructure),
106
+ resourceHosts: digest(item?.resourceHosts),
107
+ trackingIdentifiers: digest(item?.trackingIdentifiers),
108
+ faviconHash: digest(item?.faviconHash),
109
+ };
110
+ }
111
+ function origin(value) {
112
+ const candidate = text(value, 500);
113
+ if (!candidate)
114
+ return '';
115
+ try {
116
+ const parsed = new URL(candidate);
117
+ return ['http:', 'https:'].includes(parsed.protocol)
118
+ && !parsed.username
119
+ && !parsed.password
120
+ && parsed.pathname === '/'
121
+ && !parsed.search
122
+ && !parsed.hash
123
+ ? parsed.origin.toLowerCase()
124
+ : '';
125
+ }
126
+ catch {
127
+ return '';
128
+ }
129
+ }
130
+ function identityValues(value) {
131
+ const item = record(value);
132
+ const resourceHosts = values(item?.resourceHosts, 30, (candidate) => normalizeDomain(candidate));
133
+ const trackingIdentifiers = values(item?.trackingIdentifiers, 30, (candidate) => {
134
+ const tracker = record(candidate);
135
+ const type = text(tracker?.type, 40).toLowerCase();
136
+ const trackerValue = text(tracker?.value, 64).toUpperCase();
137
+ return /^[a-z-]{1,40}$/u.test(type) && /^[A-Z0-9-]{1,64}$/u.test(trackerValue)
138
+ ? { type, value: trackerValue }
139
+ : null;
140
+ });
141
+ const formActionOrigins = values(item?.formActionOrigins, 20, origin);
142
+ return { resourceHosts, trackingIdentifiers, formActionOrigins };
143
+ }
144
+ export function normalizeWebsiteProfileSnapshot(raw) {
145
+ const value = record(raw);
146
+ const domain = normalizeDomain(value?.domain);
147
+ const observedAt = timestamp(value?.observedAt);
148
+ const savedAt = timestamp(value?.savedAt);
149
+ const id = text(value?.id, 128);
150
+ if (!domain || !observedAt || !savedAt || !id)
151
+ return null;
152
+ return {
153
+ id,
154
+ domain,
155
+ observedAt,
156
+ savedAt,
157
+ complete: value?.complete === true,
158
+ truncated: value?.truncated === true,
159
+ technologies: values(value?.technologies, 40, technology),
160
+ posture: values(value?.posture, 40, posture),
161
+ identity: identity(value?.identity),
162
+ identityValues: identityValues(value?.identityValues),
163
+ sources: values(value?.sources, 16, source),
164
+ certificate: certificate(value?.certificate),
165
+ };
166
+ }
167
+ export function normalizeWebsiteSnapshotStore(raw) {
168
+ const value = record(raw);
169
+ if (value?.schema === WEBSITE_SNAPSHOT_SCHEMA
170
+ && Number(value?.version) > WEBSITE_SNAPSHOT_SCHEMA_VERSION) {
171
+ throw new Error('This website-snapshot collection uses a newer schema and cannot be read safely.');
172
+ }
173
+ const sourceValues = Array.isArray(raw) ? raw : Array.isArray(value?.snapshots) ? value.snapshots : [];
174
+ const snapshots = values(sourceValues, MAX_WEBSITE_SNAPSHOTS * 2, normalizeWebsiteProfileSnapshot);
175
+ const perDomain = new Map();
176
+ const retained = [];
177
+ for (const snapshot of snapshots.sort((left, right) => right.savedAt.localeCompare(left.savedAt))) {
178
+ const count = perDomain.get(snapshot.domain) ?? 0;
179
+ if (count >= MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN || retained.length >= MAX_WEBSITE_SNAPSHOTS)
180
+ continue;
181
+ perDomain.set(snapshot.domain, count + 1);
182
+ retained.push(snapshot);
183
+ }
184
+ return { schema: WEBSITE_SNAPSHOT_SCHEMA, version: WEBSITE_SNAPSHOT_SCHEMA_VERSION, snapshots: retained };
185
+ }
186
+ export function websiteSnapshotStoreVersion(raw) {
187
+ const value = record(raw);
188
+ return Number.isSafeInteger(value?.version) ? Number(value?.version) : WEBSITE_SNAPSHOT_SCHEMA_VERSION;
189
+ }
190
+ export function serializeWebsiteSnapshotStore(raw) {
191
+ const store = normalizeWebsiteSnapshotStore(raw);
192
+ const serialized = JSON.stringify(store);
193
+ if (new TextEncoder().encode(serialized).byteLength > MAX_WEBSITE_SNAPSHOT_STORE_BYTES) {
194
+ throw new Error('Website snapshots exceed the 512 KiB browser-local limit.');
195
+ }
196
+ return serialized;
197
+ }
198
+ export function saveWebsiteSnapshot(localRaw, candidateRaw) {
199
+ const candidate = normalizeWebsiteProfileSnapshot(candidateRaw);
200
+ if (!candidate)
201
+ throw new Error('The website snapshot is incomplete or invalid.');
202
+ const current = normalizeWebsiteSnapshotStore(localRaw).snapshots;
203
+ const snapshots = current.filter((item) => item.id !== candidate.id);
204
+ snapshots.unshift(candidate);
205
+ return normalizeWebsiteSnapshotStore(snapshots).snapshots;
206
+ }
207
+ export function deleteWebsiteSnapshot(localRaw, id) {
208
+ return normalizeWebsiteSnapshotStore(localRaw).snapshots.filter((item) => item.id !== id);
209
+ }
210
+ export function buildWebsiteSnapshotExport(raw, generatedAt = new Date().toISOString()) {
211
+ const store = normalizeWebsiteSnapshotStore(raw);
212
+ return { ...store, generatedAt: timestamp(generatedAt) || new Date().toISOString() };
213
+ }
214
+ export function mergeWebsiteSnapshots(localRaw, incomingRaw) {
215
+ const local = normalizeWebsiteSnapshotStore(localRaw).snapshots;
216
+ const incoming = normalizeWebsiteSnapshotStore(incomingRaw).snapshots;
217
+ const byId = new Map(local.map((item) => [item.id, item]));
218
+ let added = 0;
219
+ let updated = 0;
220
+ for (const item of incoming) {
221
+ if (byId.has(item.id))
222
+ updated += 1;
223
+ else
224
+ added += 1;
225
+ byId.set(item.id, item);
226
+ }
227
+ const snapshots = normalizeWebsiteSnapshotStore([...byId.values()]).snapshots;
228
+ return { snapshots, added, updated, skipped: Math.max(0, incoming.length - added - updated) };
229
+ }
230
+ function compareMap(field, before, after) {
231
+ const changes = [];
232
+ for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
233
+ const left = before.get(key) ?? null;
234
+ const right = after.get(key) ?? null;
235
+ if (left === right)
236
+ continue;
237
+ changes.push({ field: `${field}.${key}`, state: left === null ? 'added' : right === null ? 'removed' : 'changed', before: left, after: right });
238
+ }
239
+ return changes;
240
+ }
241
+ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
242
+ const before = normalizeWebsiteProfileSnapshot(beforeRaw);
243
+ const after = normalizeWebsiteProfileSnapshot(afterRaw);
244
+ if (!before || !after || before.domain !== after.domain) {
245
+ return { compatible: false, changes: [{ field: 'snapshot', state: 'incomparable', before: before?.domain ?? null, after: after?.domain ?? null }] };
246
+ }
247
+ const changes = [
248
+ ...compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}`]))),
249
+ ...compareMap('posture', new Map(before.posture.map((item) => [item.id, item.state])), new Map(after.posture.map((item) => [item.id, item.state]))),
250
+ ...compareMap('source', new Map(before.sources.map((item) => [item.source, item.state])), new Map(after.sources.map((item) => [item.source, item.state]))),
251
+ ];
252
+ for (const key of Object.keys(before.identity)) {
253
+ const left = before.identity[key];
254
+ const right = after.identity[key];
255
+ if (left === right)
256
+ continue;
257
+ changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
258
+ }
259
+ changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item]))), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value]))), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item]))));
260
+ if (before.certificate || after.certificate) {
261
+ if (!before.certificate || !after.certificate) {
262
+ changes.push({
263
+ field: 'certificate.observation',
264
+ state: 'unavailable',
265
+ before: before.certificate?.fingerprintSha256 ?? null,
266
+ after: after.certificate?.fingerprintSha256 ?? null,
267
+ });
268
+ }
269
+ else {
270
+ const certificateFields = [
271
+ 'fingerprintSha256',
272
+ 'spkiFingerprintSha256',
273
+ 'issuer',
274
+ 'subject',
275
+ 'serialNumber',
276
+ 'validFrom',
277
+ 'validTo',
278
+ 'authorized',
279
+ 'hostnameMatches',
280
+ 'validity',
281
+ 'complete',
282
+ 'truncated',
283
+ ];
284
+ for (const key of certificateFields) {
285
+ const left = before.certificate[key];
286
+ const right = after.certificate[key];
287
+ if (left === right)
288
+ continue;
289
+ changes.push({
290
+ field: `certificate.${key}`,
291
+ state: left === null || right === null ? 'unavailable' : 'changed',
292
+ before: left === null ? null : String(left),
293
+ after: right === null ? null : String(right),
294
+ });
295
+ }
296
+ }
297
+ }
298
+ if (before.complete !== after.complete || before.truncated !== after.truncated) {
299
+ changes.push({ field: 'completeness', state: 'incomparable', before: `${before.complete}/${before.truncated}`, after: `${after.complete}/${after.truncated}` });
300
+ }
301
+ return { compatible: true, changes };
302
+ }