Compare commits

...

5 Commits

Author SHA1 Message Date
6bafb18ebd fix: strip wildcard prefixes from vendored blocklist entries
All checks were successful
check / check (push) Successful in 13s
The MetaMask blocklist contains 2 entries with '*.' wildcard prefixes
(e.g. *.coinbase-563513.com). These were stored literally and never
matched because hostnameVariants() doesn't generate '*.' prefixed
strings. Fix: normalizeDomain() strips the '*.' prefix at load time
and during delta computation. The subdomain matching in
hostnameVariants() already handles child domains correctly.

Found during review.
2026-03-01 07:38:01 -08:00
0d06df6cbe refactor: vendor phishing blocklist, delta-only in-memory updates
All checks were successful
check / check (push) Successful in 25s
Vendor the MetaMask eth-phishing-detect config.json (231k domains) into
src/data/phishing-domains.json as the baseline blocklist shipped with
the extension.

On 24h refresh, only the delta (new domains not in the vendored snapshot)
is kept in memory. Domain checks hit the in-memory delta first (fresh
scam sites), then binary-search the vendored sorted array.

If the delta is under 256 KiB it is persisted to chrome.storage.local
so it survives service-worker restarts without re-fetching.

Removes the previous approach of downloading and holding the full
blocklist in memory as a Set.
2026-03-01 07:33:10 -08:00
b8d81a4c8a fix: etherscan label check runs for contracts, UI displays etherscan-phishing warnings
All checks were successful
check / check (push) Successful in 10s
Bug 1: getFullWarnings returned early for contract addresses, skipping
checkEtherscanLabel. Restructured to use isContract flag so the Etherscan
check runs for all addresses (contracts are often the most dangerous).

Bug 2: confirmTx.js only handled 'contract' and 'new-address' warning types,
silently discarding 'etherscan-phishing'. Added confirm-etherscan-warning
HTML element and handler in the async warnings loop.

Style: converted inline style attributes on phishing warning banners
(approve-tx, approve-sign, approve-site) to Tailwind utility classes
(bg-red-100 text-red-800 border-2 border-red-600 rounded-md).
2026-03-01 05:11:54 -08:00
user
01839d9c47 feat: add Etherscan label scraping and MetaMask phishing domain blocklist
All checks were successful
check / check (push) Successful in 22s
- Add etherscanLabels module: scrapes Etherscan address pages for
  phishing/scam labels (Fake_Phishing*, Exploiter, scam warnings).
  Integrated as best-effort async check in addressWarnings.

- Add phishingDomains module: fetches MetaMask's eth-phishing-detect
  blocklist (~231K domains) at runtime, caches in memory, refreshes
  every 24h. Checks hostnames with subdomain matching and whitelist
  overrides.

- Integrate domain phishing checks into all approval flows:
  connection requests, transaction approvals, and signature requests
  show a prominent red warning banner when the requesting site is on
  the MetaMask blocklist.

- Add unit tests for both modules (12 tests for etherscanLabels
  parsing, 15 tests for phishingDomains matching).

Closes #114
2026-03-01 05:03:42 -08:00
clawbot
9eef2ea602 feat: expand confirm-tx warnings — closes #114
- Refactor address warnings into src/shared/addressWarnings.js module
  - getLocalWarnings(address, options): sync checks against local lists
  - getFullWarnings(address, provider, options): async local + RPC checks
- Expand scam address list from 652 to 2417 addresses
  - Added EtherScamDB (MIT) as additional source
- Update confirmTx.js to use the new addressWarnings module
2026-03-01 05:03:42 -08:00
12 changed files with 234860 additions and 39 deletions

View File

@@ -12,6 +12,10 @@ const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log");
const { decryptWithPassword } = require("../shared/vault");
const { getSignerForAddress } = require("../shared/wallet");
const {
isPhishingDomain,
updatePhishingList,
} = require("../shared/phishingDomains");
const storageApi =
typeof browser !== "undefined"
@@ -571,6 +575,10 @@ async function backgroundRefresh() {
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// Fetch the MetaMask eth-phishing-detect domain blocklist on startup.
// Refreshes every 24 hours automatically.
updatePhishingList();
// When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => {
@@ -643,6 +651,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.type = "sign";
resp.signParams = approval.signParams;
}
// Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
sendResponse(resp);
} else {
sendResponse(null);

231428
src/data/phishing-domains.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -605,6 +605,43 @@
Double-check the address before sending.
</div>
</div>
<div
id="confirm-contract-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient is a smart contract. Sending ETH
or tokens directly to a contract may result in permanent
loss of funds.
</div>
</div>
<div
id="confirm-burn-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: This is a known null/burn address. Funds sent
here are permanently destroyed and cannot be recovered.
</div>
</div>
<div
id="confirm-etherscan-warning"
class="mb-2"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: Etherscan has flagged this address as
phishing/scam. Do not send funds to this address.
</div>
</div>
<div
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2"
@@ -1124,6 +1161,14 @@
<!-- ============ TRANSACTION APPROVAL ============ -->
<div id="view-approve-tx" class="view hidden">
<h2 class="font-bold mb-2">Transaction Request</h2>
<div
id="approve-tx-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. This transaction may steal your funds. Proceed
with extreme caution.
</div>
<p class="mb-2">
<span id="approve-tx-hostname" class="font-bold"></span>
wants to send a transaction.
@@ -1190,6 +1235,14 @@
<!-- ============ SIGNATURE APPROVAL ============ -->
<div id="view-approve-sign" class="view hidden">
<h2 class="font-bold mb-2">Signature Request</h2>
<div
id="approve-sign-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Signing this message may authorize theft of your
funds. Proceed with extreme caution.
</div>
<p class="mb-2">
<span id="approve-sign-hostname" class="font-bold"></span>
wants you to sign a message.
@@ -1259,6 +1312,14 @@
<!-- ============ SITE APPROVAL ============ -->
<div id="view-approve-site" class="view hidden">
<h2 class="font-bold mb-2">Connection Request</h2>
<div
id="approve-site-phishing-warning"
class="mb-3 p-2 text-xs font-bold hidden bg-red-100 text-red-800 border-2 border-red-600 rounded-md"
>
⚠️ PHISHING WARNING: This site is on MetaMask's phishing
blocklist. Connecting your wallet may result in loss of
funds. Proceed with extreme caution.
</div>
<div class="mb-3">
<p class="mb-2">
<span id="approve-hostname" class="font-bold"></span>

View File

@@ -13,6 +13,7 @@ const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const { isPhishingDomain } = require("../../shared/phishingDomains");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
@@ -155,7 +156,24 @@ function decodeCalldata(data, toAddress) {
return null;
}
function showPhishingWarning(elementId, hostname, isPhishing) {
const el = $(elementId);
if (!el) return;
// Check both the flag from background and a local re-check
if (isPhishing || isPhishingDomain(hostname)) {
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
}
function showTxApproval(details) {
showPhishingWarning(
"approve-tx-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(details.txParams.value || "0");
@@ -323,6 +341,12 @@ function formatTypedDataHtml(jsonStr) {
}
function showSignApproval(details) {
showPhishingWarning(
"approve-sign-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
const sp = details.signParams;
$("approve-sign-hostname").textContent = details.hostname;
@@ -382,6 +406,12 @@ function show(id) {
showSignApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.hostname,
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,

View File

@@ -25,8 +25,11 @@ const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
const { getProvider } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { ERC20_ABI } = require("../../shared/constants");
const {
getLocalWarnings,
getFullWarnings,
} = require("../../shared/addressWarnings");
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const txStatus = require("./txStatus");
@@ -167,23 +170,17 @@ function show(txInfo) {
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
}
// Check for warnings
const warnings = [];
if (isScamAddress(txInfo.to)) {
warnings.push(
"This address is on a known scam/fraud list. Do not send funds to this address.",
);
}
if (txInfo.to.toLowerCase() === txInfo.from.toLowerCase()) {
warnings.push("You are sending to your own address.");
}
// Check for warnings (synchronous local checks)
const localWarnings = getLocalWarnings(txInfo.to, {
fromAddress: txInfo.from,
});
const warningsEl = $("confirm-warnings");
if (warnings.length > 0) {
warningsEl.innerHTML = warnings
if (localWarnings.length > 0) {
warningsEl.innerHTML = localWarnings
.map(
(w) =>
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w}</div>`,
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
)
.join("");
warningsEl.style.visibility = "visible";
@@ -247,8 +244,16 @@ function show(txInfo) {
state.viewData = { pendingTx: txInfo };
showView("confirm-tx");
// Reset recipient warning to hidden (space always reserved, no layout shift)
// Reset async warnings to hidden (space always reserved, no layout shift)
$("confirm-recipient-warning").style.visibility = "hidden";
$("confirm-contract-warning").style.visibility = "hidden";
$("confirm-burn-warning").style.visibility = "hidden";
$("confirm-etherscan-warning").style.visibility = "hidden";
// Show burn warning via reserved element (in addition to inline warning)
if (isBurnAddress(txInfo.to)) {
$("confirm-burn-warning").style.visibility = "visible";
}
estimateGas(txInfo);
checkRecipientHistory(txInfo);
@@ -295,19 +300,21 @@ async function estimateGas(txInfo) {
}
async function checkRecipientHistory(txInfo) {
const el = $("confirm-recipient-warning");
try {
const provider = getProvider(state.rpcUrl);
// Skip warning for contract addresses — they may legitimately
// have zero outgoing transactions (getTransactionCount returns
// the nonce, i.e. sent-tx count only).
const code = await provider.getCode(txInfo.to);
if (code && code !== "0x") {
return;
}
const txCount = await provider.getTransactionCount(txInfo.to);
if (txCount === 0) {
el.style.visibility = "visible";
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
});
for (const w of asyncWarnings) {
if (w.type === "contract") {
$("confirm-contract-warning").style.visibility = "visible";
}
if (w.type === "new-address") {
$("confirm-recipient-warning").style.visibility = "visible";
}
if (w.type === "etherscan-phishing") {
$("confirm-etherscan-warning").style.visibility = "visible";
}
}
} catch (e) {
log.errorf("recipient history check failed:", e.message);

View File

@@ -0,0 +1,114 @@
// Address warning module.
// Provides local and async (RPC-based) warning checks for Ethereum addresses.
// Returns arrays of {type, message, severity} objects.
const { isScamAddress } = require("./scamlist");
const { isBurnAddress } = require("./constants");
const { checkEtherscanLabel } = require("./etherscanLabels");
const { log } = require("./log");
/**
* Check an address against local-only lists (scam, burn, self-send).
* Synchronous — no network calls.
*
* @param {string} address - The target address to check.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @returns {Array<{type: string, message: string, severity: string}>}
*/
function getLocalWarnings(address, options = {}) {
const warnings = [];
const addr = address.toLowerCase();
if (isScamAddress(addr)) {
warnings.push({
type: "scam",
message:
"This address is on a known scam/fraud list. Do not send funds to this address.",
severity: "critical",
});
}
if (isBurnAddress(addr)) {
warnings.push({
type: "burn",
message:
"This is a known null/burn address. Funds sent here are permanently destroyed and cannot be recovered.",
severity: "critical",
});
}
if (options.fromAddress && addr === options.fromAddress.toLowerCase()) {
warnings.push({
type: "self-send",
message: "You are sending to your own address.",
severity: "warning",
});
}
return warnings;
}
/**
* Check an address against local lists AND via RPC queries.
* Async — performs network calls to check contract status and tx history.
*
* @param {string} address - The target address to check.
* @param {object} provider - An ethers.js provider instance.
* @param {object} [options] - Optional context.
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/
async function getFullWarnings(address, provider, options = {}) {
const warnings = getLocalWarnings(address, options);
let isContract = false;
try {
const code = await provider.getCode(address);
if (code && code !== "0x") {
isContract = true;
warnings.push({
type: "contract",
message:
"This address is a smart contract, not a regular wallet.",
severity: "warning",
});
}
} catch (e) {
log.errorf("contract check failed:", e.message);
}
// Skip tx count check for contracts — they may legitimately have
// zero inbound EOA transactions.
if (!isContract) {
try {
const txCount = await provider.getTransactionCount(address);
if (txCount === 0) {
warnings.push({
type: "new-address",
message:
"This address has never sent a transaction. Double-check it is correct.",
severity: "info",
});
}
} catch (e) {
log.errorf("tx count check failed:", e.message);
}
}
// Etherscan label check (best-effort async — network failures are silent).
// Runs for ALL addresses including contracts, since many dangerous
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
try {
const etherscanWarning = await checkEtherscanLabel(address);
if (etherscanWarning) {
warnings.push(etherscanWarning);
}
} catch (e) {
log.errorf("etherscan label check failed:", e.message);
}
return warnings;
}
module.exports = { getLocalWarnings, getFullWarnings };

View File

@@ -20,6 +20,19 @@ const ERC20_ABI = [
"function approve(address spender, uint256 amount) returns (bool)",
];
// Known null/burn addresses that permanently destroy funds.
const BURN_ADDRESSES = new Set([
"0x0000000000000000000000000000000000000000",
"0x0000000000000000000000000000000000000001",
"0x000000000000000000000000000000000000dead",
"0xdead000000000000000000000000000000000000",
"0x00000000000000000000000000000000deadbeef",
]);
function isBurnAddress(address) {
return BURN_ADDRESSES.has(address.toLowerCase());
}
module.exports = {
DEBUG,
DEBUG_MNEMONIC,
@@ -28,4 +41,6 @@ module.exports = {
DEFAULT_BLOCKSCOUT_URL,
BIP44_ETH_PATH,
ERC20_ABI,
BURN_ADDRESSES,
isBurnAddress,
};

View File

@@ -0,0 +1,102 @@
// Etherscan address label lookup via page scraping.
// Extension users make the requests directly to Etherscan — no proxy needed.
// This is a best-effort enrichment: network failures return null silently.
const ETHERSCAN_BASE = "https://etherscan.io/address/";
// Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
// Patterns in the page body that indicate a scam/phishing warning.
const SCAM_BODY_PATTERNS = [
/used in a\s+(?:\w+\s+)?phishing scam/i,
/used in a\s+(?:\w+\s+)?scam/i,
/wallet\s+drainer/i,
];
/**
* Parse the Etherscan address page HTML to extract label info.
* Exported for unit testing (no fetch needed).
*
* @param {string} html - Raw HTML of the Etherscan address page.
* @returns {{ label: string|null, isPhishing: boolean, warning: string|null }}
*/
function parseEtherscanPage(html) {
// Extract <title> content
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
let label = null;
let isPhishing = false;
let warning = null;
if (titleMatch) {
const title = titleMatch[1].trim();
// Title: "LABEL | Address: 0x... | Etherscan" or "Address: 0x... | Etherscan"
const labelMatch = title.match(/^(.+?)\s*\|\s*Address:/);
if (labelMatch) {
const candidate = labelMatch[1].trim();
// Only treat as a label if it's not just "Address" (unlabeled addresses)
if (candidate.toLowerCase() !== "address") {
label = candidate;
}
}
}
// Check label against phishing patterns
if (label) {
for (const pat of PHISHING_LABEL_PATTERNS) {
if (pat.test(label)) {
isPhishing = true;
warning = `Etherscan labels this address as "${label}" (Phish/Hack).`;
break;
}
}
}
// Check page body for scam warning banners
if (!isPhishing) {
for (const pat of SCAM_BODY_PATTERNS) {
if (pat.test(html)) {
isPhishing = true;
warning = label
? `Etherscan labels this address as "${label}" and reports it was used in a scam.`
: "Etherscan reports this address was flagged for phishing/scam activity.";
break;
}
}
}
return { label, isPhishing, warning };
}
/**
* Fetch an address page from Etherscan and check for scam/phishing labels.
* Returns a warning object if the address is flagged, or null.
* Network failures return null silently (best-effort check).
*
* @param {string} address - Ethereum address to check.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address) {
try {
const resp = await fetch(ETHERSCAN_BASE + address, {
headers: { Accept: "text/html" },
});
if (!resp.ok) return null;
const html = await resp.text();
const result = parseEtherscanPage(html);
if (result.isPhishing) {
return {
type: "etherscan-phishing",
message: result.warning,
severity: "critical",
};
}
return null;
} catch {
// Network errors are expected — Etherscan may rate-limit or block.
return null;
}
}
module.exports = { parseEtherscanPage, checkEtherscanLabel };

View File

@@ -0,0 +1,297 @@
// Domain-based phishing detection using MetaMask's eth-phishing-detect blocklist.
//
// Architecture:
// 1. A vendored copy of the blocklist ships with the extension
// (src/data/phishing-domains.json — sorted blacklist for binary search).
// 2. Every 24h we fetch the latest list from MetaMask's repo and compute
// the delta (new domains not in the vendored snapshot).
// 3. Only the delta is kept in memory / persisted to chrome.storage.local.
// 4. Domain checks hit the delta first (fresh scam sites), then the
// vendored baseline via binary search.
//
// Source: https://github.com/MetaMask/eth-phishing-detect (src/config.json)
const vendoredConfig = require("../data/phishing-domains.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 DELTA_STORAGE_KEY = "phishing_domain_delta";
const DELTA_MAX_BYTES = 256 * 1024; // 256 KiB
// Vendored baseline — sorted arrays for binary search (no extra Set needed).
const vendoredBlacklist = vendoredConfig.blacklist; // pre-sorted lowercase
const vendoredWhitelist = new Set(
(vendoredConfig.whitelist || []).map((d) => d.toLowerCase()),
);
// Delta state — only domains added upstream since the vendored snapshot.
let deltaBlacklistSet = new Set();
let deltaWhitelistSet = new Set();
let lastFetchTime = 0;
let fetchPromise = null;
let persistedDeltaLoaded = false;
/**
* Normalize a domain entry: lowercase and strip wildcard prefix ("*.").
* Wildcard domains like "*.evil.com" become "evil.com" — our subdomain
* matching in hostnameVariants() already covers child domains.
*
* @param {string} domain
* @returns {string}
*/
function normalizeDomain(domain) {
const d = domain.toLowerCase();
return d.startsWith("*.") ? d.slice(2) : d;
}
/**
* Binary search on a sorted string array.
*
* @param {string[]} sorted - Sorted array of lowercase strings.
* @param {string} target - Lowercase string to find.
* @returns {boolean}
*/
function binarySearch(sorted, target) {
let lo = 0;
let hi = sorted.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (sorted[mid] === target) return true;
if (sorted[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return false;
}
/**
* 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(".");
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 (fresh additions) first, then vendored baseline.
* Whitelisted domains (vendored + delta) 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 (both vendored and delta)
for (const v of variants) {
if (vendoredWhitelist.has(v) || deltaWhitelistSet.has(v)) return false;
}
// Check delta first — fresh scam sites hit here
for (const v of variants) {
if (deltaBlacklistSet.has(v)) return true;
}
// Check vendored baseline via binary search
for (const v of variants) {
if (binarySearch(vendoredBlacklist, v)) return true;
}
return false;
}
/**
* Get the storage API if available (chrome.storage.local / browser.storage.local).
*
* @returns {object|null}
*/
function getStorageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
/**
* Load persisted delta from chrome.storage.local.
* Called once on first update to restore delta across restarts.
*
* @returns {Promise<void>}
*/
async function loadPersistedDelta() {
const storage = getStorageApi();
if (!storage) return;
try {
const result = await storage.get(DELTA_STORAGE_KEY);
const data = result[DELTA_STORAGE_KEY];
if (data && data.blacklist && data.whitelist) {
deltaBlacklistSet = new Set(data.blacklist);
deltaWhitelistSet = new Set(data.whitelist);
if (data.fetchTime) {
lastFetchTime = data.fetchTime;
}
}
} catch {
// Storage unavailable or corrupted — start fresh.
}
persistedDeltaLoaded = true;
}
/**
* Persist the current delta to chrome.storage.local if it fits in 256 KiB.
*
* @returns {Promise<void>}
*/
async function persistDelta() {
const storage = getStorageApi();
if (!storage) return;
const data = {
blacklist: Array.from(deltaBlacklistSet),
whitelist: Array.from(deltaWhitelistSet),
fetchTime: lastFetchTime,
};
const serialized = JSON.stringify(data);
if (serialized.length > DELTA_MAX_BYTES) {
// Delta too large to persist — keep in memory only.
return;
}
try {
await storage.set({ [DELTA_STORAGE_KEY]: data });
} catch {
// Storage write failed — non-fatal.
}
}
/**
* Fetch the latest blocklist, compute delta against vendored baseline,
* and update in-memory state. De-duplicates concurrent fetches.
*
* @returns {Promise<void>}
*/
async function updatePhishingList() {
// Load persisted delta on first call
if (!persistedDeltaLoaded) {
await loadPersistedDelta();
}
// Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS) {
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();
// Compute blacklist delta: remote items not in vendored baseline
const newDeltaBl = new Set();
for (const domain of config.blacklist || []) {
const d = normalizeDomain(domain);
if (!binarySearch(vendoredBlacklist, d)) {
newDeltaBl.add(d);
}
}
// Compute whitelist delta: remote items not in vendored whitelist
const newDeltaWl = new Set();
for (const domain of config.whitelist || []) {
const d = normalizeDomain(domain);
if (!vendoredWhitelist.has(d)) {
newDeltaWl.add(d);
}
}
deltaBlacklistSet = newDeltaBl;
deltaWhitelistSet = newDeltaWl;
lastFetchTime = Date.now();
await persistDelta();
} catch {
// Fetch failed — keep existing delta, retry next time.
} finally {
fetchPromise = null;
}
})();
return fetchPromise;
}
/**
* Load a pre-parsed config directly into state (vendored + delta combined).
* Used for testing.
*
* @param {{ blacklist?: string[], whitelist?: string[] }} config
*/
function loadConfig(config) {
// For tests: treat the entire config as delta (overlaid on vendored).
// Clear existing delta first.
deltaBlacklistSet = new Set((config.blacklist || []).map(normalizeDomain));
deltaWhitelistSet = new Set((config.whitelist || []).map(normalizeDomain));
lastFetchTime = Date.now();
persistedDeltaLoaded = true;
}
/**
* Return total blocklist size (vendored + delta, for diagnostics).
*
* @returns {number}
*/
function getBlocklistSize() {
return vendoredBlacklist.length + deltaBlacklistSet.size;
}
/**
* Return delta size (for diagnostics).
*
* @returns {number}
*/
function getDeltaSize() {
return deltaBlacklistSet.size;
}
/**
* Reset internal state (for testing).
*/
function _reset() {
deltaBlacklistSet = new Set();
deltaWhitelistSet = new Set();
lastFetchTime = 0;
fetchPromise = null;
persistedDeltaLoaded = false;
}
module.exports = {
isPhishingDomain,
updatePhishingList,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
binarySearch,
normalizeDomain,
_reset,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
const { parseEtherscanPage } = require("../src/shared/etherscanLabels");
describe("etherscanLabels", () => {
describe("parseEtherscanPage", () => {
test("detects Fake_Phishing label in title", () => {
const html = `<html><head><title>Fake_Phishing184810 | Address: 0x00000c07...3ea470000 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Fake_Phishing184810");
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("Fake_Phishing184810");
expect(result.warning).toContain("Phish/Hack");
});
test("detects Fake_Phishing with different number", () => {
const html = `<html><head><title>Fake_Phishing5169 | Address: 0x3e0defb8...99a7a8a74 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Fake_Phishing5169");
expect(result.isPhishing).toBe(true);
});
test("detects Exploiter label", () => {
const html = `<html><head><title>Exploiter 42 | Address: 0xabcdef...1234 | Etherscan</title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Exploiter 42");
expect(result.isPhishing).toBe(true);
});
test("detects scam warning in body text", () => {
const html =
`<html><head><title>Address: 0xabcdef...1234 | Etherscan</title></head>` +
`<body>There are reports that this address was used in a Phishing scam.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("phishing/scam");
});
test("detects scam warning with label in body", () => {
const html =
`<html><head><title>SomeScammer | Address: 0xabcdef...1234 | Etherscan</title></head>` +
`<body>There are reports that this address was used in a scam.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("SomeScammer");
expect(result.isPhishing).toBe(true);
expect(result.warning).toContain("SomeScammer");
});
test("returns clean result for legitimate address", () => {
const html = `<html><head><title>vitalik.eth | Address: 0xd8dA6BF2...37aA96045 | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("vitalik.eth");
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("returns clean result for unlabeled address", () => {
const html = `<html><head><title>Address: 0x1234567890...abcdef | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("handles exchange labels correctly (not phishing)", () => {
const html = `<html><head><title>Coinbase 10 | Address: 0xa9d1e08c...b81d3e43 | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Coinbase 10");
expect(result.isPhishing).toBe(false);
});
test("handles contract names correctly (not phishing)", () => {
const html = `<html><head><title>Beacon Deposit Contract | Address: 0x00000000...03d7705Fa | Etherscan</title></head><body>Overview</body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBe("Beacon Deposit Contract");
expect(result.isPhishing).toBe(false);
});
test("handles empty HTML gracefully", () => {
const result = parseEtherscanPage("");
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
expect(result.warning).toBeNull();
});
test("handles malformed title tag", () => {
const html = `<html><head><title></title></head><body></body></html>`;
const result = parseEtherscanPage(html);
expect(result.label).toBeNull();
expect(result.isPhishing).toBe(false);
});
test("detects wallet drainer warning", () => {
const html =
`<html><head><title>Address: 0xabc...def | Etherscan</title></head>` +
`<body>This is a known wallet drainer contract.</body></html>`;
const result = parseEtherscanPage(html);
expect(result.isPhishing).toBe(true);
});
});
});

View File

@@ -0,0 +1,247 @@
const {
isPhishingDomain,
loadConfig,
getBlocklistSize,
getDeltaSize,
hostnameVariants,
binarySearch,
normalizeDomain,
_reset,
} = require("../src/shared/phishingDomains");
// The vendored baseline is loaded automatically via require().
// _reset() clears only the delta state, not the vendored baseline.
beforeEach(() => {
_reset();
});
describe("phishingDomains", () => {
describe("hostnameVariants", () => {
test("returns exact hostname plus parent domains", () => {
const variants = hostnameVariants("sub.evil.com");
expect(variants).toEqual(["sub.evil.com", "evil.com"]);
});
test("returns just the hostname for a bare domain", () => {
const variants = hostnameVariants("example.com");
expect(variants).toEqual(["example.com"]);
});
test("handles deep subdomain chains", () => {
const variants = hostnameVariants("a.b.c.d.com");
expect(variants).toEqual([
"a.b.c.d.com",
"b.c.d.com",
"c.d.com",
"d.com",
]);
});
test("lowercases hostnames", () => {
const variants = hostnameVariants("Evil.COM");
expect(variants).toEqual(["evil.com"]);
});
});
describe("binarySearch", () => {
const sorted = ["alpha.com", "beta.com", "gamma.com", "zeta.com"];
test("finds existing elements", () => {
expect(binarySearch(sorted, "alpha.com")).toBe(true);
expect(binarySearch(sorted, "gamma.com")).toBe(true);
expect(binarySearch(sorted, "zeta.com")).toBe(true);
});
test("returns false for missing elements", () => {
expect(binarySearch(sorted, "aaa.com")).toBe(false);
expect(binarySearch(sorted, "delta.com")).toBe(false);
expect(binarySearch(sorted, "zzz.com")).toBe(false);
});
test("handles empty array", () => {
expect(binarySearch([], "anything")).toBe(false);
});
test("handles single-element array", () => {
expect(binarySearch(["only.com"], "only.com")).toBe(true);
expect(binarySearch(["only.com"], "other.com")).toBe(false);
});
});
describe("normalizeDomain", () => {
test("strips *. wildcard prefix", () => {
expect(normalizeDomain("*.evil.com")).toBe("evil.com");
expect(normalizeDomain("*.sub.evil.com")).toBe("sub.evil.com");
});
test("lowercases domains", () => {
expect(normalizeDomain("Evil.COM")).toBe("evil.com");
expect(normalizeDomain("*.Evil.COM")).toBe("evil.com");
});
test("passes through normal domains unchanged", () => {
expect(normalizeDomain("example.com")).toBe("example.com");
});
});
describe("wildcard domain handling", () => {
test("wildcard blacklist entries match via loadConfig", () => {
loadConfig({
blacklist: ["*.scam-site.com", "normal-scam.com"],
whitelist: [],
});
// *.scam-site.com is normalized to scam-site.com
expect(isPhishingDomain("scam-site.com")).toBe(true);
expect(isPhishingDomain("sub.scam-site.com")).toBe(true);
expect(isPhishingDomain("normal-scam.com")).toBe(true);
});
});
describe("vendored baseline detection", () => {
// These tests verify that the vendored phishing-domains.json
// is loaded and searchable without any delta loaded.
test("getBlocklistSize reflects vendored list (no delta)", () => {
// The vendored list has 231k+ domains; delta is empty after reset.
expect(getBlocklistSize()).toBeGreaterThan(200000);
expect(getDeltaSize()).toBe(0);
});
test("returns false for clean domains against vendored list", () => {
expect(isPhishingDomain("google.com")).toBe(false);
expect(isPhishingDomain("github.com")).toBe(false);
});
test("returns false for empty/null hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
});
});
describe("delta (loadConfig) + isPhishingDomain", () => {
test("detects domains loaded into delta via loadConfig", () => {
loadConfig({
blacklist: ["evil-phishing.com", "scam-swap.xyz"],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("scam-swap.xyz")).toBe(true);
});
test("detects subdomain of delta-blacklisted domain", () => {
loadConfig({
blacklist: ["evil-phishing.com"],
whitelist: [],
});
expect(isPhishingDomain("app.evil-phishing.com")).toBe(true);
expect(isPhishingDomain("sub.app.evil-phishing.com")).toBe(true);
});
test("delta whitelist overrides delta blacklist", () => {
loadConfig({
blacklist: ["metamask.io"],
whitelist: ["metamask.io"],
});
expect(isPhishingDomain("metamask.io")).toBe(false);
});
test("delta whitelist on parent domain overrides blacklist", () => {
loadConfig({
blacklist: ["sub.legit.com"],
whitelist: ["legit.com"],
});
expect(isPhishingDomain("sub.legit.com")).toBe(false);
});
test("case-insensitive matching in delta", () => {
loadConfig({
blacklist: ["Evil-Phishing.COM"],
whitelist: [],
});
expect(isPhishingDomain("evil-phishing.com")).toBe(true);
expect(isPhishingDomain("EVIL-PHISHING.COM")).toBe(true);
});
test("getDeltaSize reflects loaded delta", () => {
loadConfig({
blacklist: ["a.com", "b.com", "c.com"],
whitelist: ["d.com"],
});
expect(getDeltaSize()).toBe(3);
});
test("re-loading config replaces previous delta", () => {
loadConfig({
blacklist: ["old-scam.com"],
whitelist: [],
});
expect(isPhishingDomain("old-scam.com")).toBe(true);
loadConfig({
blacklist: ["new-scam.com"],
whitelist: [],
});
expect(isPhishingDomain("old-scam.com")).toBe(false);
expect(isPhishingDomain("new-scam.com")).toBe(true);
});
test("handles config with no blacklist/whitelist keys", () => {
loadConfig({});
expect(getDeltaSize()).toBe(0);
});
});
describe("real-world MetaMask blocklist patterns (via delta)", () => {
test("detects known phishing domains loaded as delta", () => {
loadConfig({
blacklist: [
"uniswap-trade.web.app",
"hopprotocol.pro",
"blast-pools.pages.dev",
],
whitelist: [],
});
expect(isPhishingDomain("uniswap-trade.web.app")).toBe(true);
expect(isPhishingDomain("hopprotocol.pro")).toBe(true);
expect(isPhishingDomain("blast-pools.pages.dev")).toBe(true);
});
test("delta whitelist overrides vendored blacklist entries", () => {
// If a domain is in the vendored blacklist but a fresh whitelist
// update adds it, the whitelist should win.
loadConfig({
blacklist: [],
whitelist: ["opensea.io", "metamask.io", "etherscan.io"],
});
expect(isPhishingDomain("opensea.io")).toBe(false);
expect(isPhishingDomain("metamask.io")).toBe(false);
expect(isPhishingDomain("etherscan.io")).toBe(false);
});
});
describe("delta + vendored interaction", () => {
test("delta blacklist entries are found even with empty vendored match", () => {
// This domain is (almost certainly) not in the vendored list
const uniqueDomain =
"test-unique-domain-not-in-vendored-" +
Date.now() +
".example.com";
expect(isPhishingDomain(uniqueDomain)).toBe(false);
loadConfig({
blacklist: [uniqueDomain],
whitelist: [],
});
expect(isPhishingDomain(uniqueDomain)).toBe(true);
});
test("getBlocklistSize includes both vendored and delta", () => {
const baseSize = getBlocklistSize();
loadConfig({
blacklist: ["new-a.com", "new-b.com"],
whitelist: [],
});
expect(getBlocklistSize()).toBe(baseSize + 2);
});
});
});