All checks were successful
check / check (push) Successful in 25s
- Vendor community-maintained phishing domain blocklist into src/shared/phishingBlocklist.json (bundled at build time by esbuild) - Refactor phishingDomains.js: build vendored Sets at module load, fetch live list periodically, keep only delta (new entries not in vendored) in memory for small runtime footprint - Domain checker checks delta first (fresh scam sites), then vendored - Persist delta to localStorage if under 256 KiB - Load delta from localStorage on startup for instant coverage - Add startPeriodicRefresh() with 24h setInterval in background script - Remove dead code: popup's local isPhishingDomain() re-check was inert (popup never called updatePhishingList so its blacklistSet was always empty); now relies solely on background's authoritative flag - Remove all competitor name mentions from UI warning text and comments - Update README: document phishing domain protection architecture, update external services list - Update tests: cover vendored blocklist loading, delta computation, localStorage persistence, delta+vendored interaction Closes #114
239 lines
7.0 KiB
JavaScript
239 lines
7.0 KiB
JavaScript
// 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<void>}
|
|
*/
|
|
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,
|
|
};
|