// Domain-based phishing detection using a vendored blocklist with delta updates. // // A community-maintained phishing domain blocklist is vendored in // phishingBlocklist.json and bundled at build time. At runtime, we fetch // the live list periodically and keep only the delta (new entries not in // the vendored list) in memory. This keeps runtime memory usage small. // // The domain-checker checks the in-memory delta first (fresh/recent scam // sites), then falls back to the vendored list. // // If the delta is under 256 KiB it is persisted to localStorage so it // survives extension/service-worker restarts. const vendoredConfig = require("./phishingBlocklist.json"); const BLOCKLIST_URL = "https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours const DELTA_STORAGE_KEY = "phishing-delta"; const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB // Vendored sets — built once from the bundled JSON. const vendoredBlacklist = new Set( (vendoredConfig.blacklist || []).map((d) => d.toLowerCase()), ); const vendoredWhitelist = new Set( (vendoredConfig.whitelist || []).map((d) => d.toLowerCase()), ); // Delta sets — only entries from live list that are NOT in vendored. let deltaBlacklist = new Set(); let deltaWhitelist = new Set(); let lastFetchTime = 0; let fetchPromise = null; let refreshTimer = null; /** * Load delta entries from localStorage on startup. * Called once during module initialization in the background script. */ function loadDeltaFromStorage() { try { const raw = localStorage.getItem(DELTA_STORAGE_KEY); if (!raw) return; const data = JSON.parse(raw); if (data.blacklist && Array.isArray(data.blacklist)) { deltaBlacklist = new Set( data.blacklist.map((d) => d.toLowerCase()), ); } if (data.whitelist && Array.isArray(data.whitelist)) { deltaWhitelist = new Set( data.whitelist.map((d) => d.toLowerCase()), ); } } catch { // localStorage unavailable or corrupt — start empty } } /** * Persist delta to localStorage if it fits within MAX_DELTA_BYTES. */ function saveDeltaToStorage() { try { const data = { blacklist: Array.from(deltaBlacklist), whitelist: Array.from(deltaWhitelist), }; const json = JSON.stringify(data); if (json.length < MAX_DELTA_BYTES) { localStorage.setItem(DELTA_STORAGE_KEY, json); } else { // Too large — remove stale key if present localStorage.removeItem(DELTA_STORAGE_KEY); } } catch { // localStorage unavailable — skip silently } } /** * Load a pre-parsed config and compute the delta against the vendored list. * Used for both live fetches and testing. * * @param {{ blacklist?: string[], whitelist?: string[] }} config */ function loadConfig(config) { const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase()); const liveWhitelist = (config.whitelist || []).map((d) => d.toLowerCase()); // Delta = entries in the live list that are NOT in the vendored list deltaBlacklist = new Set( liveBlacklist.filter((d) => !vendoredBlacklist.has(d)), ); deltaWhitelist = new Set( liveWhitelist.filter((d) => !vendoredWhitelist.has(d)), ); lastFetchTime = Date.now(); saveDeltaToStorage(); } /** * Generate hostname variants for subdomain matching. * "sub.evil.com" yields ["sub.evil.com", "evil.com"]. * * @param {string} hostname * @returns {string[]} */ function hostnameVariants(hostname) { const h = hostname.toLowerCase(); const variants = [h]; const parts = h.split("."); // Parent domains: a.b.c.d -> b.c.d, c.d for (let i = 1; i < parts.length - 1; i++) { variants.push(parts.slice(i).join(".")); } return variants; } /** * Check if a hostname is on the phishing blocklist. * Checks delta first (fresh/recent scam sites), then vendored list. * Whitelisted domains (delta + vendored) are never flagged. * * @param {string} hostname - The hostname to check. * @returns {boolean} */ function isPhishingDomain(hostname) { if (!hostname) return false; const variants = hostnameVariants(hostname); // Whitelist takes priority — check delta whitelist first, then vendored for (const v of variants) { if (deltaWhitelist.has(v) || vendoredWhitelist.has(v)) return false; } // Check delta blacklist first (fresh/recent scam sites), then vendored for (const v of variants) { if (deltaBlacklist.has(v) || vendoredBlacklist.has(v)) return true; } return false; } /** * Fetch the latest blocklist and compute delta against vendored data. * De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS. * * @returns {Promise} */ async function updatePhishingList() { // Skip if recently fetched if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) { return; } // De-duplicate concurrent calls if (fetchPromise) return fetchPromise; fetchPromise = (async () => { try { const resp = await fetch(BLOCKLIST_URL); if (!resp.ok) throw new Error("HTTP " + resp.status); const config = await resp.json(); loadConfig(config); } catch { // Silently fail — vendored list still provides coverage. // We'll retry next time. } finally { fetchPromise = null; } })(); return fetchPromise; } /** * Start periodic refresh of the phishing list. * Should be called once from the background script on startup. */ function startPeriodicRefresh() { if (refreshTimer) return; refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS); } /** * Return the total blocklist size (vendored + delta) for diagnostics. * * @returns {number} */ function getBlocklistSize() { return vendoredBlacklist.size + deltaBlacklist.size; } /** * Return the delta blocklist size for diagnostics. * * @returns {number} */ function getDeltaSize() { return deltaBlacklist.size; } /** * Reset internal state (for testing). */ function _reset() { deltaBlacklist = new Set(); deltaWhitelist = new Set(); lastFetchTime = 0; fetchPromise = null; if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } } // Load persisted delta on module initialization loadDeltaFromStorage(); module.exports = { isPhishingDomain, updatePhishingList, startPeriodicRefresh, loadConfig, getBlocklistSize, getDeltaSize, hostnameVariants, _reset, // Exposed for testing only _getVendoredBlacklistSize: () => vendoredBlacklist.size, _getVendoredWhitelistSize: () => vendoredWhitelist.size, _getDeltaBlacklist: () => deltaBlacklist, _getDeltaWhitelist: () => deltaWhitelist, };