From b0d948f333f091e147269bef166b9556ed37e163 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 27 Aug 2025 22:51:57 -0500 Subject: [PATCH 01/64] feat: add blocklistPaths --- .../src/PhishingController.test.ts | 29 +++++++++++++++++++ .../src/PhishingController.ts | 5 ++++ .../src/PhishingDetector.ts | 2 ++ 3 files changed, 36 insertions(+) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index e96d44681d6..0334a89eff3 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -193,6 +193,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 0, lastUpdated: 1, @@ -518,6 +519,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 0, lastUpdated: 1, @@ -1373,6 +1375,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [exampleBlockedUrl, exampleBlockedUrlOne], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 0, lastUpdated: 2, @@ -1442,6 +1445,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [exampleBlockedUrlTwo], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 0, version: 0, @@ -1465,6 +1469,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1481,6 +1486,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1508,6 +1514,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1524,6 +1531,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1655,6 +1663,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1671,6 +1680,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [testBlockedDomain], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, name: ListNames.MetaMask, @@ -1692,6 +1702,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1755,6 +1766,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1772,6 +1784,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1804,6 +1817,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1822,6 +1836,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, name: ListNames.MetaMask, @@ -1853,6 +1868,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1871,6 +1887,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, name: ListNames.MetaMask, @@ -1902,6 +1919,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1920,6 +1938,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1942,6 +1961,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1960,6 +1980,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -1992,6 +2013,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [exampleRequestBlockedHashTwo], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -2010,6 +2032,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, name: ListNames.MetaMask, @@ -2038,6 +2061,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -2060,6 +2084,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [exampleRequestBlockedHash], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -2086,6 +2111,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -2104,6 +2130,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -2126,6 +2153,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, @@ -2144,6 +2172,7 @@ describe('PhishingController', () => { allowlist: [], blocklist: [], c2DomainBlocklist: [], + blocklistPaths: {}, fuzzylist: [], tolerance: 3, version: 1, diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 78b1f8fb897..5ecbebeafea 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -121,6 +121,9 @@ export type PhishingStalelist = { * type defining the persisted list state. This is the persisted state that is updated frequently with `this.maybeUpdateState()`. * @property allowlist - List of approved origins (legacy naming "whitelist") * @property blocklist - List of unapproved origins (legacy naming "blacklist") + * @property blocklistPaths - List of unapproved origins with paths (hostname + path, no query params). + * The first key is hostname+first path segment. The second key is the second path segment. + * The value of the second key is an array of blocked third path segments. We only store up to three path segments deep. * @property c2DomainBlocklist - List of hashed hostnames that C2 requests are blocked against. * @property fuzzylist - List of fuzzy-matched unapproved origins * @property tolerance - Fuzzy match tolerance level @@ -131,6 +134,7 @@ export type PhishingStalelist = { export type PhishingListState = { allowlist: string[]; blocklist: string[]; + blocklistPaths: Record>; c2DomainBlocklist: string[]; fuzzylist: string[]; tolerance: number; @@ -949,6 +953,7 @@ export class PhishingController extends BaseController< c2DomainBlocklist: c2DomainBlocklistResponse ? c2DomainBlocklistResponse.recentlyAdded : [], + blocklistPaths: {}, name: phishingListKeyNameMap.eth_phishing_detect_config, }; diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index 3cb35e780fa..ff3f45c445b 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -25,6 +25,7 @@ export type LegacyPhishingDetectorList = { export type PhishingDetectorList = { allowlist?: string[]; blocklist?: string[]; + blocklistPaths?: Record>; c2DomainBlocklist?: string[]; name?: string; version?: string | number; @@ -50,6 +51,7 @@ export type PhishingDetectorConfiguration = { version?: number | string; allowlist: string[][]; blocklist: string[][]; + blocklistPaths?: Record>; c2DomainBlocklist?: string[]; fuzzylist: string[][]; tolerance: number; From e85956f7d36b11a73ed0ae73d82b4a692e81ede9 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 27 Aug 2025 23:36:27 -0500 Subject: [PATCH 02/64] feat: doesURLPathExist --- .../src/PhishingDetector.ts | 15 +++++++++ packages/phishing-controller/src/utils.ts | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index ff3f45c445b..2215eec26f3 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -14,6 +14,7 @@ import { matchPartsAgainstList, processConfigs, sha256Hash, + doesURLPathExist, } from './utils'; export type LegacyPhishingDetectorList = { @@ -160,6 +161,20 @@ export class PhishingDetector { const source = domainToParts(fqdn); + for (const { blocklistPaths, name, version } of this.#configs) { + if (!blocklistPaths) continue; + const pathMatch = doesURLPathExist(url, blocklistPaths); + if (pathMatch) { + return { + match: domainPartsToDomain(source), // TODO: revisit this. do we want to return the path? + name, + result: true, + type: PhishingDetectorResultType.Blocklist, // TODO: do we want to differentiate between path and hostname blocklists? + version: version === undefined ? version : String(version), + }; + } + } + for (const { allowlist, name, version } of this.#configs) { // if source matches allowlist hostname (or subdomain thereof), PASS const allowlistMatch = matchPartsAgainstList(source, allowlist); diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index ea05add282f..57d12b12e53 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -106,6 +106,7 @@ export const applyDiffs = ( allowlist: Array.from(listSets.allowlist), blocklist: Array.from(listSets.blocklist), fuzzylist: Array.from(listSets.fuzzylist), + blocklistPaths: listState.blocklistPaths, version: listState.version, name: phishingListKeyNameMap[listKey], tolerance: listState.tolerance, @@ -262,6 +263,38 @@ export const matchPartsAgainstList = (source: string[], list: string[][]) => { }); }; +/** + * Checks if the hostname and path exists within the list of paths. + * If there are more than 3 path components, we return true if the first 3 path components exist. + * + * @param url - the url to check. + * @param urlPaths - the paths to check. + * @returns true if the hostname and path exists in the path list, false otherwise. + */ +export const doesURLPathExist = ( + url: string, + urlPaths: Record>, +) => { + const { hostname, pathname } = new URL(url); + const [path1, path2, path3] = pathname.split('/').filter(Boolean); + + if (!path1) return false; + + const hostnamePath1 = urlPaths[`${hostname}/${path1}`]; + if (!hostnamePath1) return false; + if (Object.keys(hostnamePath1).length === 0) return true; + + if (!path2) return false; + + const hostnamePath1Path2 = urlPaths[`${hostname}/${path1}`][path2]; + if (!hostnamePath1Path2) return false; + if (hostnamePath1Path2.length === 0) return true; + + if (!path3) return false; + + return hostnamePath1Path2.includes(path3); +}; + /** * Generate the SHA-256 hash of a hostname. * From f972d6e2f08d3fa0b821d32c4866fecef48de25b Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 27 Aug 2025 23:36:39 -0500 Subject: [PATCH 03/64] test: doesURLPathExist --- .../phishing-controller/src/utils.test.ts | 117 +++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index c1bc4ba9ce5..c1eeae07dcb 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -3,6 +3,7 @@ import * as sinon from 'sinon'; import { ListKeys, ListNames } from './PhishingController'; import { applyDiffs, + doesURLPathExist, domainToParts, fetchTimeNow, generateParentDomains, @@ -10,7 +11,6 @@ import { getHostnameFromWebUrl, matchPartsAgainstList, processConfigs, - // processConfigs, processDomainList, roundToNearestMinute, sha256Hash, @@ -24,6 +24,15 @@ const examplec2DomainBlocklistHashOne = '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; const exampleBlocklist = [exampleBlockedUrl, exampleBlockedUrlOne]; const examplec2DomainBlocklist = [examplec2DomainBlocklistHashOne]; +const exampleBlocklistPaths: Record> = { + 'sites.google.com/path1': { + path2: ['path3bad', 'path3good'], + path22: [], + }, + 'example.com/path2': { + path3: ['path4bad', 'path4good'], + }, +}; const exampleAllowUrl = 'https://example-allowlist-item.com'; const exampleFuzzyUrl = 'https://example-fuzzylist-item.com'; @@ -32,6 +41,7 @@ const exampleFuzzylist = [exampleFuzzyUrl]; const exampleListState = { blocklist: exampleBlocklist, c2DomainBlocklist: examplec2DomainBlocklist, + blocklistPaths: exampleBlocklistPaths, fuzzylist: exampleFuzzylist, tolerance: 2, allowlist: exampleAllowlist, @@ -797,3 +807,108 @@ describe('generateParentDomains', () => { expect(generateParentDomains(filteredSourceParts)).toStrictEqual(expected); }); }); + +describe('doesURLPathExist', () => { + const blocklistPaths: Record> = { + 'blocklist.has3paths.com/path1': { path2: ['path3'] }, // explicit third-level allowlist + 'blocklist.has2paths.com/path1': { path2: [] }, // special: exact /path1/path2 only + 'blocklist.has1path.com/path1': {}, // special: exact /path1 only + }; + + // each testcase is [name, input, expected] + describe('input has 3 path components', () => { + it.each([ + [ + 'matches when the 3rd path component is explicitly in the list', + 'https://blocklist.has3paths.com/path1/path2/path3', + true, + ], + [ + 'matches when the first path component has no children', + 'https://blocklist.has1path.com/path1/path2/path3', + true, + ], + [ + 'matches when the first two path components have no children', + 'https://blocklist.has2paths.com/path1/path2/path3', + true, + ], + [ + 'does not match when the 3rd path component is not in the list', + 'https://blocklist.has3paths.com/path1/path2/path4', + false, + ], + ])('should %s', (_name, input, expected) => { + expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); + }); + }); + + describe('input has 2 path components', () => { + it.each([ + [ + 'matches when the 2nd path component has no children', + 'https://blocklist.has2paths.com/path1/path2', + true, + ], + [ + 'matches when the 1st path component has no children', + 'https://blocklist.has1path.com/path1/path2', + true, + ], + [ + 'does not match when the 2nd path component is not in the list', + 'https://blocklist.has2paths.com/path1/path3', + false, + ], + [ + 'does not match when the 1st path component is not in the list', + 'https://blocklist.has2paths.com/path2/path2', + false, + ], + [ + 'does not match when the 2nd path component has children', + 'https://blocklist.has3paths.com/path1/path2', + false, + ], + ])('should %s', (_name, input, expected) => { + expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); + }); + }); + + describe('input has 1 path component', () => { + it.each([ + [ + 'matches when the 1st path component has no children', + 'https://blocklist.has1path.com/path1', + true, + ], + [ + 'does not match when the 1st path component is not in the list', + 'https://blocklist.has1path.com/path2', + false, + ], + [ + 'does not match when the 1st path component has children', + 'https://blocklist.has2paths.com/path1', + false, + ], + ])('should %s', (_name, input, expected) => { + expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); + }); + }); + + it.each([ + [ + 'does not match when the input has no path', + 'https://blocklist.has1path.com', + false, + ], + [ + 'matches with trailing slash', + 'https://blocklist.has1path.com/path1/', + true, + ], + ])('should %s', (_name, input, expected) => { + expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); + }); +}); From 7693283c437eecae5950e5f58fb97e9d0645f184 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 13:06:36 -0500 Subject: [PATCH 04/64] feat: add and removals to blocklistPaths --- packages/phishing-controller/src/utils.ts | 129 +++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 57d12b12e53..418a18bbed6 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -43,6 +43,22 @@ const splitStringByPeriod = ( ]; }; +const isValidURL = (url: string): URL | null => { + try { + return new URL(url); + } catch { + return null; + } +}; + +const hasPath = (url: string): boolean => { + const urlObj = isValidURL(url); + if (!urlObj) { + return false; + } + return urlObj.pathname.split('/').filter(Boolean).length > 0; +}; + /** * Determines which diffs are applicable to the listState, then applies those diffs. * @@ -80,11 +96,23 @@ export const applyDiffs = ( fuzzylist: new Set(listState.fuzzylist), c2DomainBlocklist: new Set(listState.c2DomainBlocklist), }; + const blocklistPaths = JSON.parse(JSON.stringify(listState.blocklistPaths)); + for (const { isRemoval, targetList, url, timestamp } of diffsToApply) { const targetListType = splitStringByPeriod(targetList)[1]; if (timestamp > latestDiffTimestamp) { latestDiffTimestamp = timestamp; } + + if (targetListType === 'blocklist' && hasPath(url)) { + if (isRemoval) { + removeURLFromBlocklistPaths(url, blocklistPaths); + } else { + addURLToBlocklistPaths(url, blocklistPaths); + } + continue; + } + if (isRemoval) { listSets[targetListType].delete(url); } else { @@ -106,7 +134,7 @@ export const applyDiffs = ( allowlist: Array.from(listSets.allowlist), blocklist: Array.from(listSets.blocklist), fuzzylist: Array.from(listSets.fuzzylist), - blocklistPaths: listState.blocklistPaths, + blocklistPaths, version: listState.version, name: phishingListKeyNameMap[listKey], tolerance: listState.tolerance, @@ -295,6 +323,105 @@ export const doesURLPathExist = ( return hostnamePath1Path2.includes(path3); }; +/** + * Adds a URL to the blocklistPaths structure. + * Parses the URL and adds the path components to the appropriate nested structure. + * + * @param url - The URL to add. + * @param blocklistPaths - The blocklistPaths structure to modify. + */ +const addURLToBlocklistPaths = ( + url: string, + blocklistPaths: Record>, +) => { + const { hostname, pathname } = new URL(url); + const [path1, path2, path3] = pathname.split('/').filter(Boolean); + + if (!path1) return; + + const hostnamePath1Key = `${hostname}/${path1}`; + if (!blocklistPaths[hostnamePath1Key]) { + blocklistPaths[hostnamePath1Key] = {}; + } + if (!path2) { + // As a URL entry with only one path component, that would take precedence over a URL entry with 2 path components + // that has the same hostname and path1 component. Hence, we need to remove the values of the hostname/path1 key. + blocklistPaths[hostnamePath1Key] = {}; + return; + } + + if (!blocklistPaths[hostnamePath1Key][path2]) { + blocklistPaths[hostnamePath1Key][path2] = []; + } + if (!path3) { + // As a URL entry with only two path components, that would take precedence over a URL entry with 3 path components + // that has the same hostname, path1, and path2 components. Hence, we need to remove the values of the hostname/path1/path2 key. + blocklistPaths[hostnamePath1Key][path2] = []; + return; + } + + if (!blocklistPaths[hostnamePath1Key][path2].includes(path3)) { + blocklistPaths[hostnamePath1Key][path2].push(path3); + } +}; + +/** + * Removes a URL from the blocklistPaths structure. + * Parses the URL and removes the path components from the appropriate nested structure. + * Cleans up empty nested objects/arrays. + * + * @param url - The URL to remove. + * @param blocklistPaths - The blocklistPaths structure to modify. + */ +const removeURLFromBlocklistPaths = ( + url: string, + blocklistPaths: Record>, +) => { + const { hostname, pathname } = new URL(url); + const [path1, path2, path3] = pathname.split('/').filter(Boolean); + + if (!path1) return; + + const hostnamePath1Key = `${hostname}/${path1}`; + + if (!blocklistPaths[hostnamePath1Key]) return; + + if (!path2) { + // Remove the entire hostname/path1 entry + delete blocklistPaths[hostnamePath1Key]; + return; + } + + if (!blocklistPaths[hostnamePath1Key][path2]) return; + + if (!path3) { + // Remove the entire path2 entry + delete blocklistPaths[hostnamePath1Key][path2]; + if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { + // If the hostname/path1 key has no values after removing the path2 value, + // we must remove the hostname/path1 key as the path2 key would never + // have been added as we do not add path2 keys if the path1 key already exists. + delete blocklistPaths[hostnamePath1Key]; + } + return; + } + + const path3Index = blocklistPaths[hostnamePath1Key][path2].indexOf(path3); + if (path3Index > -1) { + blocklistPaths[hostnamePath1Key][path2].splice(path3Index, 1); + + if (blocklistPaths[hostnamePath1Key][path2].length === 0) { + // Same as removing a hostname/path1 key, if the hostname/path1/path2 key has no values, + // we must remove it as we do not add path3 value if the path2 key already exists. + delete blocklistPaths[hostnamePath1Key][path2]; + if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { + // This is removing the hostname/path1 key if the hostname/path1/path2 key has no values. + delete blocklistPaths[hostnamePath1Key]; + } + } + } +}; + /** * Generate the SHA-256 hash of a hostname. * From e6bfad730340b64f998fbc4e12fe5b2bc6cea191 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 20:33:35 -0500 Subject: [PATCH 05/64] refactor: increase addURLToBlocklistPaths readability --- packages/phishing-controller/src/utils.ts | 106 ++++++++++++++++------ 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 418a18bbed6..c57672fb2e8 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -43,16 +43,17 @@ const splitStringByPeriod = ( ]; }; -const isValidURL = (url: string): URL | null => { +const newURL = (url: string): URL | null => { try { - return new URL(url); + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + return new URL(urlWithProtocol); } catch { return null; } }; const hasPath = (url: string): boolean => { - const urlObj = isValidURL(url); + const urlObj = newURL(url); if (!urlObj) { return false; } @@ -303,7 +304,8 @@ export const doesURLPathExist = ( url: string, urlPaths: Record>, ) => { - const { hostname, pathname } = new URL(url); + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + const { hostname, pathname } = new URL(urlWithProtocol); const [path1, path2, path3] = pathname.split('/').filter(Boolean); if (!path1) return false; @@ -323,6 +325,50 @@ export const doesURLPathExist = ( return hostnamePath1Path2.includes(path3); }; +/** + * isLevel1Blocking checks if a level1 entry blocks all paths after it. + * If the level1Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) + * If the level1Entry has keys, that means the blocking path extends to the next level and we return true. + * @param level1Entry - the level1 entry to check. + * @returns true if the level1 entry blocks all paths after it, false otherwise. + */ +const isLevel1Blocking = ( + level1Entry: Record | undefined, +): boolean => { + return level1Entry !== undefined && Object.keys(level1Entry).length === 0; +}; + +/** + * isLevel2Blocking checks if a level2 entry blocks all paths after it. + * If the level2Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) + * If the level2Entry has keys, that means the blocking path extends to the next level and we return true. + * @param level2Entry - the level2 entry to check. + * @returns true if the level2 entry blocks all paths after it, false otherwise. + */ +const isLevel2Blocking = (level2Entry: string[] | undefined): boolean => { + return level2Entry !== undefined && level2Entry.length === 0; +}; + +/** + * initializeBlocklistPath initializes a blocklist path if it doesn't exist. + * @param blocklistPaths - the blocklistPaths structure to modify. + * @param hostnamePath1Key - the hostname/path1 key to initialize. + * @param path2 - the path2 key to initialize. + */ +const initializeBlocklistPath = ( + blocklistPaths: Record>, + hostnamePath1Key: string, + path2?: string, +) => { + if (!blocklistPaths[hostnamePath1Key]) { + blocklistPaths[hostnamePath1Key] = {}; + } + + if (path2 && !blocklistPaths[hostnamePath1Key][path2]) { + blocklistPaths[hostnamePath1Key][path2] = []; + } +}; + /** * Adds a URL to the blocklistPaths structure. * Parses the URL and adds the path components to the appropriate nested structure. @@ -334,34 +380,42 @@ const addURLToBlocklistPaths = ( url: string, blocklistPaths: Record>, ) => { - const { hostname, pathname } = new URL(url); - const [path1, path2, path3] = pathname.split('/').filter(Boolean); + const urlObj = newURL(url); + if (!urlObj) return; + + const { hostname, pathname } = urlObj; + const pathComponents = pathname.split('/').filter(Boolean); + const [path1, path2, path3] = pathComponents; if (!path1) return; const hostnamePath1Key = `${hostname}/${path1}`; - if (!blocklistPaths[hostnamePath1Key]) { - blocklistPaths[hostnamePath1Key] = {}; - } - if (!path2) { - // As a URL entry with only one path component, that would take precedence over a URL entry with 2 path components - // that has the same hostname and path1 component. Hence, we need to remove the values of the hostname/path1 key. - blocklistPaths[hostnamePath1Key] = {}; - return; - } + const componentCount = pathComponents.length; + + switch (componentCount) { + case 1: + blocklistPaths[hostnamePath1Key] = {}; + break; + case 2: { + const level1Entry = blocklistPaths[hostnamePath1Key]; + if (isLevel1Blocking(level1Entry)) return; + + initializeBlocklistPath(blocklistPaths, hostnamePath1Key); + blocklistPaths[hostnamePath1Key][path2] = []; + break; + } + default: { + const level1Entry = blocklistPaths[hostnamePath1Key]; + if (isLevel1Blocking(level1Entry)) return; + const level2Entry = level1Entry?.[path2]; + if (isLevel2Blocking(level2Entry)) return; - if (!blocklistPaths[hostnamePath1Key][path2]) { - blocklistPaths[hostnamePath1Key][path2] = []; - } - if (!path3) { - // As a URL entry with only two path components, that would take precedence over a URL entry with 3 path components - // that has the same hostname, path1, and path2 components. Hence, we need to remove the values of the hostname/path1/path2 key. - blocklistPaths[hostnamePath1Key][path2] = []; - return; - } + initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2); + if (blocklistPaths[hostnamePath1Key][path2].includes(path3)) return; - if (!blocklistPaths[hostnamePath1Key][path2].includes(path3)) { - blocklistPaths[hostnamePath1Key][path2].push(path3); + blocklistPaths[hostnamePath1Key][path2].push(path3); + break; + } } }; From ec9d63532feb846e64fd963aaba13a60e19baeab Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 20:34:28 -0500 Subject: [PATCH 06/64] fix: add httpPrefix to use URL correctly --- packages/phishing-controller/src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index c57672fb2e8..6c0833984d2 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -431,7 +431,8 @@ const removeURLFromBlocklistPaths = ( url: string, blocklistPaths: Record>, ) => { - const { hostname, pathname } = new URL(url); + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + const { hostname, pathname } = new URL(urlWithProtocol); const [path1, path2, path3] = pathname.split('/').filter(Boolean); if (!path1) return; @@ -441,7 +442,6 @@ const removeURLFromBlocklistPaths = ( if (!blocklistPaths[hostnamePath1Key]) return; if (!path2) { - // Remove the entire hostname/path1 entry delete blocklistPaths[hostnamePath1Key]; return; } From 1c20cb55a27107acd4418f7cf5a0d1c8e62c63b8 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 22:16:04 -0500 Subject: [PATCH 07/64] fix: removeURLFromBlocklistPaths respects hierachy --- packages/phishing-controller/src/utils.ts | 49 ++++++++++++----------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 6c0833984d2..c4e1ee6926f 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -433,45 +433,46 @@ const removeURLFromBlocklistPaths = ( ) => { const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; const { hostname, pathname } = new URL(urlWithProtocol); - const [path1, path2, path3] = pathname.split('/').filter(Boolean); + const pathComponents = pathname.split('/').filter(Boolean); + const [path1, path2, path3] = pathComponents; if (!path1) return; const hostnamePath1Key = `${hostname}/${path1}`; + const componentCount = pathComponents.length; if (!blocklistPaths[hostnamePath1Key]) return; - if (!path2) { - delete blocklistPaths[hostnamePath1Key]; - return; - } - - if (!blocklistPaths[hostnamePath1Key][path2]) return; + switch (componentCount) { + case 1: { + if (!isLevel1Blocking(blocklistPaths[hostnamePath1Key])) return; - if (!path3) { - // Remove the entire path2 entry - delete blocklistPaths[hostnamePath1Key][path2]; - if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - // If the hostname/path1 key has no values after removing the path2 value, - // we must remove the hostname/path1 key as the path2 key would never - // have been added as we do not add path2 keys if the path1 key already exists. delete blocklistPaths[hostnamePath1Key]; + break; } - return; - } - - const path3Index = blocklistPaths[hostnamePath1Key][path2].indexOf(path3); - if (path3Index > -1) { - blocklistPaths[hostnamePath1Key][path2].splice(path3Index, 1); + case 2: { + if (!isLevel2Blocking(blocklistPaths[hostnamePath1Key][path2])) return; - if (blocklistPaths[hostnamePath1Key][path2].length === 0) { - // Same as removing a hostname/path1 key, if the hostname/path1/path2 key has no values, - // we must remove it as we do not add path3 value if the path2 key already exists. delete blocklistPaths[hostnamePath1Key][path2]; if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - // This is removing the hostname/path1 key if the hostname/path1/path2 key has no values. delete blocklistPaths[hostnamePath1Key]; } + break; + } + default: { + if (!blocklistPaths[hostnamePath1Key][path2]) return; + + const path3Index = blocklistPaths[hostnamePath1Key][path2].indexOf(path3); + if (path3Index === -1) return; + + blocklistPaths[hostnamePath1Key][path2].splice(path3Index, 1); + if (blocklistPaths[hostnamePath1Key][path2].length === 0) { + delete blocklistPaths[hostnamePath1Key][path2]; + if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { + delete blocklistPaths[hostnamePath1Key]; + } + } + break; } } }; From 72a42aa4dd8588385dae4c8122ffeaafa481c431 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 22:33:22 -0500 Subject: [PATCH 08/64] fix: handle for longer than 3 paths being added and removed --- .../src/PhishingController.ts | 2 +- .../src/PhishingDetector.ts | 4 +- packages/phishing-controller/src/utils.ts | 147 ++++++++++++++---- 3 files changed, 123 insertions(+), 30 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 5ecbebeafea..9e501e19421 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -134,7 +134,7 @@ export type PhishingStalelist = { export type PhishingListState = { allowlist: string[]; blocklist: string[]; - blocklistPaths: Record>; + blocklistPaths: Record>>; c2DomainBlocklist: string[]; fuzzylist: string[]; tolerance: number; diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index 2215eec26f3..7578b8fb102 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -26,7 +26,7 @@ export type LegacyPhishingDetectorList = { export type PhishingDetectorList = { allowlist?: string[]; blocklist?: string[]; - blocklistPaths?: Record>; + blocklistPaths?: Record>>; c2DomainBlocklist?: string[]; name?: string; version?: string | number; @@ -52,7 +52,7 @@ export type PhishingDetectorConfiguration = { version?: number | string; allowlist: string[][]; blocklist: string[][]; - blocklistPaths?: Record>; + blocklistPaths?: Record>>; c2DomainBlocklist?: string[]; fuzzylist: string[][]; tolerance: number; diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index c4e1ee6926f..e8c56ab3ec2 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -302,27 +302,39 @@ export const matchPartsAgainstList = (source: string[], list: string[][]) => { */ export const doesURLPathExist = ( url: string, - urlPaths: Record>, + urlPaths: Record>>, ) => { const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; const { hostname, pathname } = new URL(urlWithProtocol); - const [path1, path2, path3] = pathname.split('/').filter(Boolean); + const pathComponents = pathname.split('/').filter(Boolean); + const [path1, path2, path3, ...remainingPaths] = pathComponents; if (!path1) return false; - const hostnamePath1 = urlPaths[`${hostname}/${path1}`]; - if (!hostnamePath1) return false; - if (Object.keys(hostnamePath1).length === 0) return true; + // Level 1: hostname/path1 + const level1Entry = urlPaths[`${hostname}/${path1}`]; + if (!level1Entry) return false; + if (Object.keys(level1Entry).length === 0) return true; // Blocks everything under hostname/path1 if (!path2) return false; - const hostnamePath1Path2 = urlPaths[`${hostname}/${path1}`][path2]; - if (!hostnamePath1Path2) return false; - if (hostnamePath1Path2.length === 0) return true; + // Level 2: path2 + const level2Entry = level1Entry[path2]; + if (!level2Entry) return false; + if (Object.keys(level2Entry).length === 0) return true; // Blocks everything under hostname/path1/path2 if (!path3) return false; - return hostnamePath1Path2.includes(path3); + // Level 3: path3 + const level3Entry = level2Entry[path3]; + if (!level3Entry) return false; + if (level3Entry.length === 0) return true; // Blocks everything under hostname/path1/path2/path3 + + // Level 4: remaining path segments + if (remainingPaths.length === 0) return true; // Exact match at 3-segment level + + const remainingPath = remainingPaths.join('/'); + return level3Entry.includes(remainingPath); }; /** @@ -333,7 +345,7 @@ export const doesURLPathExist = ( * @returns true if the level1 entry blocks all paths after it, false otherwise. */ const isLevel1Blocking = ( - level1Entry: Record | undefined, + level1Entry: Record> | undefined, ): boolean => { return level1Entry !== undefined && Object.keys(level1Entry).length === 0; }; @@ -345,8 +357,21 @@ const isLevel1Blocking = ( * @param level2Entry - the level2 entry to check. * @returns true if the level2 entry blocks all paths after it, false otherwise. */ -const isLevel2Blocking = (level2Entry: string[] | undefined): boolean => { - return level2Entry !== undefined && level2Entry.length === 0; +const isLevel2Blocking = ( + level2Entry: Record | undefined, +): boolean => { + return level2Entry !== undefined && Object.keys(level2Entry).length === 0; +}; + +/** + * isLevel3Blocking checks if a level3 entry blocks all paths after it. + * If the level3Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) + * If the level3Entry has items, that means there are specific remaining paths blocked. + * @param level3Entry - the level3 entry to check. + * @returns true if the level3 entry blocks all paths after it, false otherwise. + */ +const isLevel3Blocking = (level3Entry: string[] | undefined): boolean => { + return level3Entry !== undefined && level3Entry.length === 0; }; /** @@ -354,18 +379,40 @@ const isLevel2Blocking = (level2Entry: string[] | undefined): boolean => { * @param blocklistPaths - the blocklistPaths structure to modify. * @param hostnamePath1Key - the hostname/path1 key to initialize. * @param path2 - the path2 key to initialize. + * @param path3 - the path3 key to initialize. */ const initializeBlocklistPath = ( - blocklistPaths: Record>, + blocklistPaths: Record>>, hostnamePath1Key: string, path2?: string, + path3?: string, ) => { if (!blocklistPaths[hostnamePath1Key]) { blocklistPaths[hostnamePath1Key] = {}; } if (path2 && !blocklistPaths[hostnamePath1Key][path2]) { - blocklistPaths[hostnamePath1Key][path2] = []; + blocklistPaths[hostnamePath1Key][path2] = {}; + } + + if (path2 && path3 && !blocklistPaths[hostnamePath1Key][path2][path3]) { + blocklistPaths[hostnamePath1Key][path2][path3] = []; + } +}; + +/** + * Adds remaining path segments to the blocklist if they don't already exist. + */ +const addRemainingPathIfNotExists = ( + blocklistPaths: Record>>, + hostnamePath1Key: string, + path2: string, + path3: string, + remainingPath: string, +) => { + const remainingPathsArray = blocklistPaths[hostnamePath1Key][path2][path3]; + if (!remainingPathsArray.includes(remainingPath)) { + remainingPathsArray.push(remainingPath); } }; @@ -378,7 +425,7 @@ const initializeBlocklistPath = ( */ const addURLToBlocklistPaths = ( url: string, - blocklistPaths: Record>, + blocklistPaths: Record>>, ) => { const urlObj = newURL(url); if (!urlObj) return; @@ -401,19 +448,38 @@ const addURLToBlocklistPaths = ( if (isLevel1Blocking(level1Entry)) return; initializeBlocklistPath(blocklistPaths, hostnamePath1Key); - blocklistPaths[hostnamePath1Key][path2] = []; + blocklistPaths[hostnamePath1Key][path2] = {}; break; } - default: { + case 3: { const level1Entry = blocklistPaths[hostnamePath1Key]; if (isLevel1Blocking(level1Entry)) return; const level2Entry = level1Entry?.[path2]; if (isLevel2Blocking(level2Entry)) return; initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2); - if (blocklistPaths[hostnamePath1Key][path2].includes(path3)) return; - - blocklistPaths[hostnamePath1Key][path2].push(path3); + blocklistPaths[hostnamePath1Key][path2][path3] = []; + break; + } + default: { + const level1Entry = blocklistPaths[hostnamePath1Key]; + if (isLevel1Blocking(level1Entry)) return; + const level2Entry = level1Entry?.[path2]; + if (isLevel2Blocking(level2Entry)) return; + const level3Entry = level2Entry?.[path3]; + if (isLevel3Blocking(level3Entry)) return; + + const remainingPaths = pathComponents.slice(3); + const remainingPath = remainingPaths.join('/'); + + initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2, path3); + addRemainingPathIfNotExists( + blocklistPaths, + hostnamePath1Key, + path2, + path3, + remainingPath, + ); break; } } @@ -429,7 +495,7 @@ const addURLToBlocklistPaths = ( */ const removeURLFromBlocklistPaths = ( url: string, - blocklistPaths: Record>, + blocklistPaths: Record>>, ) => { const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; const { hostname, pathname } = new URL(urlWithProtocol); @@ -451,6 +517,7 @@ const removeURLFromBlocklistPaths = ( break; } case 2: { + if (!blocklistPaths[hostnamePath1Key][path2]) return; if (!isLevel2Blocking(blocklistPaths[hostnamePath1Key][path2])) return; delete blocklistPaths[hostnamePath1Key][path2]; @@ -459,14 +526,14 @@ const removeURLFromBlocklistPaths = ( } break; } - default: { + case 3: { if (!blocklistPaths[hostnamePath1Key][path2]) return; + if (!blocklistPaths[hostnamePath1Key][path2][path3]) return; + if (!isLevel3Blocking(blocklistPaths[hostnamePath1Key][path2][path3])) + return; - const path3Index = blocklistPaths[hostnamePath1Key][path2].indexOf(path3); - if (path3Index === -1) return; - - blocklistPaths[hostnamePath1Key][path2].splice(path3Index, 1); - if (blocklistPaths[hostnamePath1Key][path2].length === 0) { + delete blocklistPaths[hostnamePath1Key][path2][path3]; + if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { delete blocklistPaths[hostnamePath1Key][path2]; if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { delete blocklistPaths[hostnamePath1Key]; @@ -474,6 +541,32 @@ const removeURLFromBlocklistPaths = ( } break; } + default: { + if (!blocklistPaths[hostnamePath1Key][path2]) return; + if (!blocklistPaths[hostnamePath1Key][path2][path3]) return; + + const remainingPaths = pathComponents.slice(3); + const remainingPath = remainingPaths.join('/'); + + const remainingPathsArray = + blocklistPaths[hostnamePath1Key][path2][path3]; + const remainingPathIndex = remainingPathsArray.indexOf(remainingPath); + if (remainingPathIndex === -1) return; + + remainingPathsArray.splice(remainingPathIndex, 1); + + // Clean up empty structures + if (remainingPathsArray.length === 0) { + delete blocklistPaths[hostnamePath1Key][path2][path3]; + if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { + delete blocklistPaths[hostnamePath1Key][path2]; + if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { + delete blocklistPaths[hostnamePath1Key]; + } + } + } + break; + } } }; From 7e071ab17f87b1d26ef75f254e9ab6e29e613d3f Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 22:41:48 -0500 Subject: [PATCH 09/64] tests: applyDiffs --- .../phishing-controller/src/utils.test.ts | 538 +++++++++++++++++- 1 file changed, 515 insertions(+), 23 deletions(-) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index c1eeae07dcb..17aa701044b 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -24,16 +24,6 @@ const examplec2DomainBlocklistHashOne = '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; const exampleBlocklist = [exampleBlockedUrl, exampleBlockedUrlOne]; const examplec2DomainBlocklist = [examplec2DomainBlocklistHashOne]; -const exampleBlocklistPaths: Record> = { - 'sites.google.com/path1': { - path2: ['path3bad', 'path3good'], - path22: [], - }, - 'example.com/path2': { - path3: ['path4bad', 'path4good'], - }, -}; - const exampleAllowUrl = 'https://example-allowlist-item.com'; const exampleFuzzyUrl = 'https://example-fuzzylist-item.com'; const exampleAllowlist = [exampleAllowUrl]; @@ -41,7 +31,24 @@ const exampleFuzzylist = [exampleFuzzyUrl]; const exampleListState = { blocklist: exampleBlocklist, c2DomainBlocklist: examplec2DomainBlocklist, - blocklistPaths: exampleBlocklistPaths, + blocklistPaths: { + 'url1.com/path1': {}, + 'url2.com/path': { + path2: {}, + }, + 'url3.com/path1': { + path2: { + path3: [], + }, + }, + 'url4.com/path1': { + path21: { + path31: ['path41', 'path42'], + path32: [], + }, + path22: {}, + }, + }, fuzzylist: exampleFuzzylist, tolerance: 2, allowlist: exampleAllowlist, @@ -243,6 +250,450 @@ describe('applyDiffs', () => { ); expect(result.c2DomainBlocklist).toStrictEqual(['hash1', 'hash2']); }); + + describe('blocklistPaths handling', () => { + const newAddDiff = (url: string) => ({ + targetList: 'eth_phishing_detect_config.blocklist' as const, + url, + timestamp: 1000000000, + }); + + const newRemoveDiff = (url: string, timestampOffset = 1) => ({ + targetList: 'eth_phishing_detect_config.blocklist' as const, + url, + timestamp: 1000000000 + timestampOffset, // Higher timestamp to ensure it's processed after additions + isRemoval: true, + }); + + describe('adding URLs to blocklistPaths', () => { + describe('when blocklistPaths is empty', () => { + const emptyListState = { + ...exampleListState, + blocklistPaths: {}, + }; + + it.each([ + [ + 'adds a URL with 1 path component', + 'example.com/path1', + { + 'example.com/path1': {}, + }, + ], + [ + 'adds a URL with 2 path components', + 'example.com/path1/path2', + { + 'example.com/path1': { + path2: {}, + }, + }, + ], + [ + 'adds a URL with 3 path components', + 'example.com/path1/path2/path3', + { + 'example.com/path1': { + path2: { + path3: [], + }, + }, + }, + ], + [ + 'adds a URL with 4 path components', + 'example.com/path1/path2/path3/path4', + { + 'example.com/path1': { + path2: { + path3: ['path4'], + }, + }, + }, + ], + ])('%s', (_name, url, expectedBlocklistPaths) => { + const result = applyDiffs( + emptyListState, + [newAddDiff(url)], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual(expectedBlocklistPaths); + }); + }); + + describe('when blocklistPaths has a 1-level entry and the URL shares the hostname/path1', () => { + const listStateWithOneLevel = { + ...exampleListState, + blocklistPaths: { + 'example.com/path1': {}, + }, + }; + it('does not add a URL with 2 path components', () => { + const result = applyDiffs( + listStateWithOneLevel, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithOneLevel.blocklistPaths, + ); + }); + + it('does not add a URL with 3 path components', () => { + const result = applyDiffs( + listStateWithOneLevel, + [newAddDiff('example.com/path1/path2/path3')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithOneLevel.blocklistPaths, + ); + }); + }); + + describe('when blocklistPaths has a 2-level entry and the URL shares the hostname/path1/path2', () => { + const listStateWithTwoLevels = { + ...exampleListState, + blocklistPaths: { + 'example.com/path1': { + path2: {}, + }, + }, + }; + it('does not add a URL with 3 path components', () => { + const result = applyDiffs( + listStateWithTwoLevels, + [newAddDiff('example.com/path1/path2/path3')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithTwoLevels.blocklistPaths, + ); + }); + + it('does not duplicate the path2 entry', () => { + const result = applyDiffs( + listStateWithTwoLevels, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithTwoLevels.blocklistPaths, + ); + }); + }); + + describe('when blocklistPaths has a 3-level entry and the URL shares the hostname/path1/path2/path3', () => { + const listStateWithThreeLevels = { + ...exampleListState, + blocklistPaths: { + 'example.com/path1': { + path2: { + path3: [], + }, + }, + }, + }; + it('does not add a URL with 3 path components when level 3 already blocks everything', () => { + const result = applyDiffs( + listStateWithThreeLevels, + [newAddDiff('example.com/path1/path2/path3')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithThreeLevels.blocklistPaths, + ); + }); + + it('does not add a URL with 4 path components when level 3 already blocks everything', () => { + const result = applyDiffs( + listStateWithThreeLevels, + [newAddDiff('example.com/path1/path2/path3/path4')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithThreeLevels.blocklistPaths, + ); + }); + }); + + describe('when blocklistPaths has 4-level entries and the URL shares the hostname/path1/path2/path3', () => { + const listStateWithFourLevels = { + ...exampleListState, + blocklistPaths: { + 'example.com/path1': { + path2: { + path3: ['path41'], + }, + }, + }, + }; + it('adds a new remaining path to the hostname/path1/path2/path3 array', () => { + const result = applyDiffs( + listStateWithFourLevels, + [newAddDiff('example.com/path1/path2/path3/path42')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com/path1': { + path2: { + path3: ['path41', 'path42'], + }, + }, + }); + }); + it('does not add a remaining path if it already exists', () => { + const result = applyDiffs( + listStateWithFourLevels, + [newAddDiff('example.com/path1/path2/path3/path41')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithFourLevels.blocklistPaths, + ); + }); + }); + + it('properly handles URLs with 4+ path components by storing remaining segments', () => { + const result = applyDiffs( + exampleListState, + [newAddDiff('example.com/path1/path2/path3/path4/path5')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + ...exampleListState.blocklistPaths, + 'example.com/path1': { + path2: { + path3: ['path4/path5'], + }, + }, + }); + }); + + it('does not add a URL with no path', () => { + const result = applyDiffs( + exampleListState, + [newAddDiff('example.com')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + exampleListState.blocklistPaths, + ); + }); + }); + describe('removing URLs from blocklistPaths', () => { + it('removing a non-existent URL does nothing', () => { + const result = applyDiffs( + exampleListState, + [newRemoveDiff('nonexistenturl.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + exampleListState.blocklistPaths, + ); + }); + + describe('when blocklistPaths has a level-1 entry', () => { + const listStateWithOneLevel = { + ...exampleListState, + blocklistPaths: { + 'example.com/path1': {}, + }, + }; + + it('removes the level-1 entry completely', () => { + const result = applyDiffs( + listStateWithOneLevel, + [newRemoveDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({}); + }); + + it('attempting to remove a level-2 path above a level-1 entry does nothing', () => { + const result = applyDiffs( + listStateWithOneLevel, + [newRemoveDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithOneLevel.blocklistPaths, + ); + }); + }); + + describe('when blocklistPaths has a level-2 entry', () => { + const listStateWithTwoLevels = { + ...exampleListState, + blocklistPaths: { + 'example.com/path1': { path21: {}, path22: {} }, + 'url.com/path1': { path2: {} }, + }, + }; + + it('removes a specific level-2 entry', () => { + const result = applyDiffs( + listStateWithTwoLevels, + [newRemoveDiff('example.com/path1/path21')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com/path1': { path22: {} }, + 'url.com/path1': { path2: {} }, + }); + }); + + it('removes the entire level-1 entry when removing the last level-2 entry', () => { + const result = applyDiffs( + listStateWithTwoLevels, + [newRemoveDiff('url.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com/path1': { path21: {}, path22: {} }, + }); + }); + + it('attempting to remove a level-3 path above a level-2 entry does nothing', () => { + const result = applyDiffs( + listStateWithTwoLevels, + [newRemoveDiff('example.com/path1/path21/path3')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithTwoLevels.blocklistPaths, + ); + }); + + it('attempting to remove a level-1 path that has a level-2 entry does nothing', () => { + const result = applyDiffs( + listStateWithTwoLevels, + [newRemoveDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithTwoLevels.blocklistPaths, + ); + }); + }); + + describe('when blocklistPaths has a level-3 entry', () => { + const listStateWithThreeLevels = { + ...exampleListState, + blocklistPaths: { + 'example.com/path1': { + path2: { path3: [] }, + path5: { path6: [] }, + }, + 'other.com/path1': { + path2: { path3: [] }, + }, + }, + }; + + it('removes a specific level-3 entry', () => { + const result = applyDiffs( + listStateWithThreeLevels, + [newRemoveDiff('example.com/path1/path2/path3')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com/path1': { + path5: { path6: [] }, + }, + 'other.com/path1': { + path2: { path3: [] }, + }, + }); + }); + + it('removes the level-2 entry when removing the last level-3 entry', () => { + const result = applyDiffs( + listStateWithThreeLevels, + [newRemoveDiff('example.com/path1/path5/path6')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com/path1': { + path2: { path3: [] }, + }, + 'other.com/path1': { + path2: { path3: [] }, + }, + }); + }); + + it('removes the entire level-1 entry when removing all level-3 entries', () => { + const result = applyDiffs( + listStateWithThreeLevels, + [newRemoveDiff('other.com/path1/path2/path3')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com/path1': { + path2: { path3: [] }, + path5: { path6: [] }, + }, + }); + }); + + it('removing a non-existent level-3 entry does nothing', () => { + const result = applyDiffs( + listStateWithThreeLevels, + [newRemoveDiff('example.com/path1/path2/nonexistent')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual( + listStateWithThreeLevels.blocklistPaths, + ); + }); + }); + + it( + 'if we add 2 URLs with the same hostname/path1/path2/path3 but different remaining paths, ' + + 'they should be stored as separate entries and can be removed independently', + () => { + const emptyListState = { + ...exampleListState, + blocklistPaths: {}, + }; + const result = applyDiffs( + emptyListState, + [ + newAddDiff('example.com/path1/path2/path3/path41'), + newAddDiff('example.com/path1/path2/path3/path42'), + ], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com/path1': { + path2: { + path3: ['path41', 'path42'], + }, + }, + }); + const result2 = applyDiffs( + result, + [newRemoveDiff('example.com/path1/path2/path3/path41', 1)], + ListKeys.EthPhishingDetectConfig, + ); + expect(result2.blocklistPaths).toStrictEqual({ + 'example.com/path1': { + path2: { + path3: ['path42'], + }, + }, + }); + const result3 = applyDiffs( + result2, + [newRemoveDiff('example.com/path1/path2/path3/path42', 2)], + ListKeys.EthPhishingDetectConfig, + ); + expect(result3.blocklistPaths).toStrictEqual({}); + }, + ); + }); + }); }); describe('validateConfig', () => { @@ -809,33 +1260,37 @@ describe('generateParentDomains', () => { }); describe('doesURLPathExist', () => { - const blocklistPaths: Record> = { - 'blocklist.has3paths.com/path1': { path2: ['path3'] }, // explicit third-level allowlist - 'blocklist.has2paths.com/path1': { path2: [] }, // special: exact /path1/path2 only - 'blocklist.has1path.com/path1': {}, // special: exact /path1 only + const blocklistPaths: Record< + string, + Record> + > = { + 'blocklist.has4paths.com/path1': { path2: { path3: ['path4'] } }, // explicit fourth-level blocklist + 'blocklist.has3paths.com/path1': { path2: { path3: [] } }, // blocks everything under /path1/path2/path3 + 'blocklist.has2paths.com/path1': { path2: {} }, // blocks everything under /path1/path2 + 'blocklist.has1path.com/path1': {}, // blocks everything under /path1 }; // each testcase is [name, input, expected] describe('input has 3 path components', () => { it.each([ [ - 'matches when the 3rd path component is explicitly in the list', + 'matches when the 3rd path component blocks everything (level 3 blocking)', 'https://blocklist.has3paths.com/path1/path2/path3', true, ], [ - 'matches when the first path component has no children', + 'matches when the first path component has no children (level 1 blocking)', 'https://blocklist.has1path.com/path1/path2/path3', true, ], [ - 'matches when the first two path components have no children', + 'matches when the first two path components have no children (level 2 blocking)', 'https://blocklist.has2paths.com/path1/path2/path3', true, ], [ - 'does not match when the 3rd path component is not in the list', - 'https://blocklist.has3paths.com/path1/path2/path4', + 'does not match when the 3rd path component is not in the blocklist', + 'https://example.com/path1/path2/path3', false, ], ])('should %s', (_name, input, expected) => { @@ -846,12 +1301,12 @@ describe('doesURLPathExist', () => { describe('input has 2 path components', () => { it.each([ [ - 'matches when the 2nd path component has no children', + 'matches when the 2nd path component blocks everything (level 2 blocking)', 'https://blocklist.has2paths.com/path1/path2', true, ], [ - 'matches when the 1st path component has no children', + 'matches when the 1st path component blocks everything (level 1 blocking)', 'https://blocklist.has1path.com/path1/path2', true, ], @@ -866,7 +1321,7 @@ describe('doesURLPathExist', () => { false, ], [ - 'does not match when the 2nd path component has children', + 'does not match when the 2nd path component has specific level 3 children', 'https://blocklist.has3paths.com/path1/path2', false, ], @@ -897,6 +1352,43 @@ describe('doesURLPathExist', () => { }); }); + describe('input has 4 path components', () => { + it.each([ + [ + 'matches when the 4th path component is explicitly in the list', + 'https://blocklist.has4paths.com/path1/path2/path3/path4', + true, + ], + [ + 'matches when the 3rd path component blocks everything (level 3 blocking)', + 'https://blocklist.has3paths.com/path1/path2/path3/path4', + true, + ], + [ + 'matches when the 2nd path component blocks everything (level 2 blocking)', + 'https://blocklist.has2paths.com/path1/path2/path3/path4', + true, + ], + [ + 'matches when the 1st path component blocks everything (level 1 blocking)', + 'https://blocklist.has1path.com/path1/path2/path3/path4', + true, + ], + [ + 'does not match when the 4th path component is not in the list', + 'https://blocklist.has4paths.com/path1/path2/path3/path5', + false, + ], + [ + 'does not match when the domain is not in the blocklist', + 'https://example.com/path1/path2/path3/path4', + false, + ], + ])('should %s', (_name, input, expected) => { + expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); + }); + }); + it.each([ [ 'does not match when the input has no path', From 9da68b04a08bfbea2ecaa9f5c876a51063ec793c Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 22:55:13 -0500 Subject: [PATCH 10/64] refactor: generic functions and remove helper --- packages/phishing-controller/src/utils.ts | 72 ++++++----------------- 1 file changed, 19 insertions(+), 53 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index e8c56ab3ec2..d876beb2636 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -338,29 +338,14 @@ export const doesURLPathExist = ( }; /** - * isLevel1Blocking checks if a level1 entry blocks all paths after it. - * If the level1Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) - * If the level1Entry has keys, that means the blocking path extends to the next level and we return true. - * @param level1Entry - the level1 entry to check. - * @returns true if the level1 entry blocks all paths after it, false otherwise. + * isLevelBlocking checks if a level entry blocks all paths after it. Only used for level 1 and 2. + * If the levelEntry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) + * If the levelEntry has keys, that means the blocking path extends to the next level and we return true. + * @param levelEntry - the level entry to check. + * @returns true if the level entry blocks all paths after it, false otherwise. */ -const isLevel1Blocking = ( - level1Entry: Record> | undefined, -): boolean => { - return level1Entry !== undefined && Object.keys(level1Entry).length === 0; -}; - -/** - * isLevel2Blocking checks if a level2 entry blocks all paths after it. - * If the level2Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) - * If the level2Entry has keys, that means the blocking path extends to the next level and we return true. - * @param level2Entry - the level2 entry to check. - * @returns true if the level2 entry blocks all paths after it, false otherwise. - */ -const isLevel2Blocking = ( - level2Entry: Record | undefined, -): boolean => { - return level2Entry !== undefined && Object.keys(level2Entry).length === 0; +const isLevelBlocking = (v?: Record): boolean => { + return v !== undefined && Object.keys(v).length === 0; }; /** @@ -400,22 +385,6 @@ const initializeBlocklistPath = ( } }; -/** - * Adds remaining path segments to the blocklist if they don't already exist. - */ -const addRemainingPathIfNotExists = ( - blocklistPaths: Record>>, - hostnamePath1Key: string, - path2: string, - path3: string, - remainingPath: string, -) => { - const remainingPathsArray = blocklistPaths[hostnamePath1Key][path2][path3]; - if (!remainingPathsArray.includes(remainingPath)) { - remainingPathsArray.push(remainingPath); - } -}; - /** * Adds a URL to the blocklistPaths structure. * Parses the URL and adds the path components to the appropriate nested structure. @@ -445,7 +414,7 @@ const addURLToBlocklistPaths = ( break; case 2: { const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevel1Blocking(level1Entry)) return; + if (isLevelBlocking(level1Entry)) return; initializeBlocklistPath(blocklistPaths, hostnamePath1Key); blocklistPaths[hostnamePath1Key][path2] = {}; @@ -453,9 +422,9 @@ const addURLToBlocklistPaths = ( } case 3: { const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevel1Blocking(level1Entry)) return; + if (isLevelBlocking(level1Entry)) return; const level2Entry = level1Entry?.[path2]; - if (isLevel2Blocking(level2Entry)) return; + if (isLevelBlocking(level2Entry)) return; initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2); blocklistPaths[hostnamePath1Key][path2][path3] = []; @@ -463,9 +432,9 @@ const addURLToBlocklistPaths = ( } default: { const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevel1Blocking(level1Entry)) return; + if (isLevelBlocking(level1Entry)) return; const level2Entry = level1Entry?.[path2]; - if (isLevel2Blocking(level2Entry)) return; + if (isLevelBlocking(level2Entry)) return; const level3Entry = level2Entry?.[path3]; if (isLevel3Blocking(level3Entry)) return; @@ -473,13 +442,11 @@ const addURLToBlocklistPaths = ( const remainingPath = remainingPaths.join('/'); initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2, path3); - addRemainingPathIfNotExists( - blocklistPaths, - hostnamePath1Key, - path2, - path3, - remainingPath, - ); + if ( + !blocklistPaths[hostnamePath1Key][path2][path3].includes(remainingPath) + ) { + blocklistPaths[hostnamePath1Key][path2][path3].push(remainingPath); + } break; } } @@ -511,14 +478,14 @@ const removeURLFromBlocklistPaths = ( switch (componentCount) { case 1: { - if (!isLevel1Blocking(blocklistPaths[hostnamePath1Key])) return; + if (!isLevelBlocking(blocklistPaths[hostnamePath1Key])) return; delete blocklistPaths[hostnamePath1Key]; break; } case 2: { if (!blocklistPaths[hostnamePath1Key][path2]) return; - if (!isLevel2Blocking(blocklistPaths[hostnamePath1Key][path2])) return; + if (!isLevelBlocking(blocklistPaths[hostnamePath1Key][path2])) return; delete blocklistPaths[hostnamePath1Key][path2]; if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { @@ -555,7 +522,6 @@ const removeURLFromBlocklistPaths = ( remainingPathsArray.splice(remainingPathIndex, 1); - // Clean up empty structures if (remainingPathsArray.length === 0) { delete blocklistPaths[hostnamePath1Key][path2][path3]; if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { From 8b2a5733ef9d65c0efecdc3e6e10715069da8f98 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 23:04:46 -0500 Subject: [PATCH 11/64] lint: fix --- .../src/PhishingDetector.ts | 4 +- packages/phishing-controller/src/utils.ts | 477 ++++++++++-------- 2 files changed, 271 insertions(+), 210 deletions(-) diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index 7578b8fb102..1cddeb5c860 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -162,7 +162,9 @@ export class PhishingDetector { const source = domainToParts(fqdn); for (const { blocklistPaths, name, version } of this.#configs) { - if (!blocklistPaths) continue; + if (!blocklistPaths) { + continue; + } const pathMatch = doesURLPathExist(url, blocklistPaths); if (pathMatch) { return { diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index d876beb2636..5f2694b8294 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -60,6 +60,244 @@ const hasPath = (url: string): boolean => { return urlObj.pathname.split('/').filter(Boolean).length > 0; }; +/** + * isLevelBlocking checks if a level entry blocks all paths after it. Only used for level 1 and 2. + * If the levelEntry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) + * If the levelEntry has keys, that means the blocking path extends to the next level and we return true. + * + * @param levelEntry - the level entry to check. + * @returns true if the level entry blocks all paths after it, false otherwise. + */ +const isLevelBlocking = (levelEntry?: Record): boolean => { + return levelEntry !== undefined && Object.keys(levelEntry).length === 0; +}; + +/** + * isLevel3Blocking checks if a level3 entry blocks all paths after it. + * If the level3Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) + * If the level3Entry has items, that means there are specific remaining paths blocked. + * + * @param level3Entry - the level3 entry to check. + * @returns true if the level3 entry blocks all paths after it, false otherwise. + */ +const isLevel3Blocking = (level3Entry: string[] | undefined): boolean => { + return level3Entry !== undefined && level3Entry.length === 0; +}; + +/** + * initializeBlocklistPath initializes a blocklist path if it doesn't exist. + * + * @param blocklistPaths - the blocklistPaths structure to modify. + * @param hostnamePath1Key - the hostname/path1 key to initialize. + * @param path2 - the path2 key to initialize. + * @param path3 - the path3 key to initialize. + */ +const initializeBlocklistPath = ( + blocklistPaths: Record>>, + hostnamePath1Key: string, + path2?: string, + path3?: string, +) => { + if (!blocklistPaths[hostnamePath1Key]) { + blocklistPaths[hostnamePath1Key] = {}; + } + + if (path2 && !blocklistPaths[hostnamePath1Key][path2]) { + blocklistPaths[hostnamePath1Key][path2] = {}; + } + + if (path2 && path3 && !blocklistPaths[hostnamePath1Key][path2][path3]) { + blocklistPaths[hostnamePath1Key][path2][path3] = []; + } +}; +/** + * Adds a URL to the blocklistPaths structure. + * Parses the URL and adds the path components to the appropriate nested structure. + * + * @param url - The URL to add. + * @param blocklistPaths - The blocklistPaths structure to modify. + */ +const addURLToBlocklistPaths = ( + url: string, + blocklistPaths: Record>>, +) => { + const urlObj = newURL(url); + if (!urlObj) { + return; + } + + const { hostname, pathname } = urlObj; + const pathComponents = pathname.split('/').filter(Boolean); + const [path1, path2, path3] = pathComponents; + + if (!path1) { + return; + } + + const hostnamePath1Key = `${hostname}/${path1}`; + const componentCount = pathComponents.length; + + switch (componentCount) { + case 1: + blocklistPaths[hostnamePath1Key] = {}; + break; + case 2: { + const level1Entry = blocklistPaths[hostnamePath1Key]; + if (isLevelBlocking(level1Entry)) { + return; + } + + initializeBlocklistPath(blocklistPaths, hostnamePath1Key); + blocklistPaths[hostnamePath1Key][path2] = {}; + break; + } + case 3: { + const level1Entry = blocklistPaths[hostnamePath1Key]; + if (isLevelBlocking(level1Entry)) { + return; + } + const level2Entry = level1Entry?.[path2]; + if (isLevelBlocking(level2Entry)) { + return; + } + + initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2); + blocklistPaths[hostnamePath1Key][path2][path3] = []; + break; + } + default: { + const level1Entry = blocklistPaths[hostnamePath1Key]; + if (isLevelBlocking(level1Entry)) { + return; + } + const level2Entry = level1Entry?.[path2]; + if (isLevelBlocking(level2Entry)) { + return; + } + const level3Entry = level2Entry?.[path3]; + if (isLevel3Blocking(level3Entry)) { + return; + } + + const remainingPaths = pathComponents.slice(3); + const remainingPath = remainingPaths.join('/'); + + initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2, path3); + if ( + !blocklistPaths[hostnamePath1Key][path2][path3].includes(remainingPath) + ) { + blocklistPaths[hostnamePath1Key][path2][path3].push(remainingPath); + } + break; + } + } +}; + +/** + * Removes a URL from the blocklistPaths structure. + * Parses the URL and removes the path components from the appropriate nested structure. + * Cleans up empty nested objects/arrays. + * + * @param url - The URL to remove. + * @param blocklistPaths - The blocklistPaths structure to modify. + */ +const removeURLFromBlocklistPaths = ( + url: string, + blocklistPaths: Record>>, +) => { + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + const { hostname, pathname } = new URL(urlWithProtocol); + const pathComponents = pathname.split('/').filter(Boolean); + const [path1, path2, path3] = pathComponents; + + if (!path1) { + return; + } + + const hostnamePath1Key = `${hostname}/${path1}`; + const componentCount = pathComponents.length; + + if (!blocklistPaths[hostnamePath1Key]) { + return; + } + + switch (componentCount) { + case 1: { + if (!isLevelBlocking(blocklistPaths[hostnamePath1Key])) { + return; + } + + delete blocklistPaths[hostnamePath1Key]; + break; + } + case 2: { + if (!blocklistPaths[hostnamePath1Key][path2]) { + return; + } + if (!isLevelBlocking(blocklistPaths[hostnamePath1Key][path2])) { + return; + } + + delete blocklistPaths[hostnamePath1Key][path2]; + if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { + delete blocklistPaths[hostnamePath1Key]; + } + break; + } + case 3: { + if (!blocklistPaths[hostnamePath1Key][path2]) { + return; + } + if (!blocklistPaths[hostnamePath1Key][path2][path3]) { + return; + } + if (!isLevel3Blocking(blocklistPaths[hostnamePath1Key][path2][path3])) { + return; + } + + delete blocklistPaths[hostnamePath1Key][path2][path3]; + if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { + delete blocklistPaths[hostnamePath1Key][path2]; + if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { + delete blocklistPaths[hostnamePath1Key]; + } + } + break; + } + default: { + if (!blocklistPaths[hostnamePath1Key][path2]) { + return; + } + if (!blocklistPaths[hostnamePath1Key][path2][path3]) { + return; + } + + const remainingPaths = pathComponents.slice(3); + const remainingPath = remainingPaths.join('/'); + + const remainingPathsArray = + blocklistPaths[hostnamePath1Key][path2][path3]; + const remainingPathIndex = remainingPathsArray.indexOf(remainingPath); + if (remainingPathIndex === -1) { + return; + } + + remainingPathsArray.splice(remainingPathIndex, 1); + + if (remainingPathsArray.length === 0) { + delete blocklistPaths[hostnamePath1Key][path2][path3]; + if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { + delete blocklistPaths[hostnamePath1Key][path2]; + if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { + delete blocklistPaths[hostnamePath1Key]; + } + } + } + break; + } + } +}; + /** * Determines which diffs are applicable to the listState, then applies those diffs. * @@ -309,231 +547,52 @@ export const doesURLPathExist = ( const pathComponents = pathname.split('/').filter(Boolean); const [path1, path2, path3, ...remainingPaths] = pathComponents; - if (!path1) return false; + if (!path1) { + return false; + } // Level 1: hostname/path1 const level1Entry = urlPaths[`${hostname}/${path1}`]; - if (!level1Entry) return false; - if (Object.keys(level1Entry).length === 0) return true; // Blocks everything under hostname/path1 + if (!level1Entry) { + return false; + } + if (Object.keys(level1Entry).length === 0) { + return true; // Blocks everything under hostname/path1 + } - if (!path2) return false; + if (!path2) { + return false; + } // Level 2: path2 const level2Entry = level1Entry[path2]; - if (!level2Entry) return false; - if (Object.keys(level2Entry).length === 0) return true; // Blocks everything under hostname/path1/path2 + if (!level2Entry) { + return false; + } + if (Object.keys(level2Entry).length === 0) { + return true; // Blocks everything under hostname/path1/path2 + } - if (!path3) return false; + if (!path3) { + return false; + } // Level 3: path3 const level3Entry = level2Entry[path3]; - if (!level3Entry) return false; - if (level3Entry.length === 0) return true; // Blocks everything under hostname/path1/path2/path3 - - // Level 4: remaining path segments - if (remainingPaths.length === 0) return true; // Exact match at 3-segment level - - const remainingPath = remainingPaths.join('/'); - return level3Entry.includes(remainingPath); -}; - -/** - * isLevelBlocking checks if a level entry blocks all paths after it. Only used for level 1 and 2. - * If the levelEntry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) - * If the levelEntry has keys, that means the blocking path extends to the next level and we return true. - * @param levelEntry - the level entry to check. - * @returns true if the level entry blocks all paths after it, false otherwise. - */ -const isLevelBlocking = (v?: Record): boolean => { - return v !== undefined && Object.keys(v).length === 0; -}; - -/** - * isLevel3Blocking checks if a level3 entry blocks all paths after it. - * If the level3Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) - * If the level3Entry has items, that means there are specific remaining paths blocked. - * @param level3Entry - the level3 entry to check. - * @returns true if the level3 entry blocks all paths after it, false otherwise. - */ -const isLevel3Blocking = (level3Entry: string[] | undefined): boolean => { - return level3Entry !== undefined && level3Entry.length === 0; -}; - -/** - * initializeBlocklistPath initializes a blocklist path if it doesn't exist. - * @param blocklistPaths - the blocklistPaths structure to modify. - * @param hostnamePath1Key - the hostname/path1 key to initialize. - * @param path2 - the path2 key to initialize. - * @param path3 - the path3 key to initialize. - */ -const initializeBlocklistPath = ( - blocklistPaths: Record>>, - hostnamePath1Key: string, - path2?: string, - path3?: string, -) => { - if (!blocklistPaths[hostnamePath1Key]) { - blocklistPaths[hostnamePath1Key] = {}; + if (!level3Entry) { + return false; } - - if (path2 && !blocklistPaths[hostnamePath1Key][path2]) { - blocklistPaths[hostnamePath1Key][path2] = {}; + if (level3Entry.length === 0) { + return true; // Blocks everything under hostname/path1/path2/path3 } - if (path2 && path3 && !blocklistPaths[hostnamePath1Key][path2][path3]) { - blocklistPaths[hostnamePath1Key][path2][path3] = []; - } -}; - -/** - * Adds a URL to the blocklistPaths structure. - * Parses the URL and adds the path components to the appropriate nested structure. - * - * @param url - The URL to add. - * @param blocklistPaths - The blocklistPaths structure to modify. - */ -const addURLToBlocklistPaths = ( - url: string, - blocklistPaths: Record>>, -) => { - const urlObj = newURL(url); - if (!urlObj) return; - - const { hostname, pathname } = urlObj; - const pathComponents = pathname.split('/').filter(Boolean); - const [path1, path2, path3] = pathComponents; - - if (!path1) return; - - const hostnamePath1Key = `${hostname}/${path1}`; - const componentCount = pathComponents.length; - - switch (componentCount) { - case 1: - blocklistPaths[hostnamePath1Key] = {}; - break; - case 2: { - const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevelBlocking(level1Entry)) return; - - initializeBlocklistPath(blocklistPaths, hostnamePath1Key); - blocklistPaths[hostnamePath1Key][path2] = {}; - break; - } - case 3: { - const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevelBlocking(level1Entry)) return; - const level2Entry = level1Entry?.[path2]; - if (isLevelBlocking(level2Entry)) return; - - initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2); - blocklistPaths[hostnamePath1Key][path2][path3] = []; - break; - } - default: { - const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevelBlocking(level1Entry)) return; - const level2Entry = level1Entry?.[path2]; - if (isLevelBlocking(level2Entry)) return; - const level3Entry = level2Entry?.[path3]; - if (isLevel3Blocking(level3Entry)) return; - - const remainingPaths = pathComponents.slice(3); - const remainingPath = remainingPaths.join('/'); - - initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2, path3); - if ( - !blocklistPaths[hostnamePath1Key][path2][path3].includes(remainingPath) - ) { - blocklistPaths[hostnamePath1Key][path2][path3].push(remainingPath); - } - break; - } + // Level 4: remaining path segments + if (remainingPaths.length === 0) { + return true; // Exact match at 3-segment level } -}; - -/** - * Removes a URL from the blocklistPaths structure. - * Parses the URL and removes the path components from the appropriate nested structure. - * Cleans up empty nested objects/arrays. - * - * @param url - The URL to remove. - * @param blocklistPaths - The blocklistPaths structure to modify. - */ -const removeURLFromBlocklistPaths = ( - url: string, - blocklistPaths: Record>>, -) => { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - const { hostname, pathname } = new URL(urlWithProtocol); - const pathComponents = pathname.split('/').filter(Boolean); - const [path1, path2, path3] = pathComponents; - - if (!path1) return; - - const hostnamePath1Key = `${hostname}/${path1}`; - const componentCount = pathComponents.length; - - if (!blocklistPaths[hostnamePath1Key]) return; - switch (componentCount) { - case 1: { - if (!isLevelBlocking(blocklistPaths[hostnamePath1Key])) return; - - delete blocklistPaths[hostnamePath1Key]; - break; - } - case 2: { - if (!blocklistPaths[hostnamePath1Key][path2]) return; - if (!isLevelBlocking(blocklistPaths[hostnamePath1Key][path2])) return; - - delete blocklistPaths[hostnamePath1Key][path2]; - if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - delete blocklistPaths[hostnamePath1Key]; - } - break; - } - case 3: { - if (!blocklistPaths[hostnamePath1Key][path2]) return; - if (!blocklistPaths[hostnamePath1Key][path2][path3]) return; - if (!isLevel3Blocking(blocklistPaths[hostnamePath1Key][path2][path3])) - return; - - delete blocklistPaths[hostnamePath1Key][path2][path3]; - if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { - delete blocklistPaths[hostnamePath1Key][path2]; - if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - delete blocklistPaths[hostnamePath1Key]; - } - } - break; - } - default: { - if (!blocklistPaths[hostnamePath1Key][path2]) return; - if (!blocklistPaths[hostnamePath1Key][path2][path3]) return; - - const remainingPaths = pathComponents.slice(3); - const remainingPath = remainingPaths.join('/'); - - const remainingPathsArray = - blocklistPaths[hostnamePath1Key][path2][path3]; - const remainingPathIndex = remainingPathsArray.indexOf(remainingPath); - if (remainingPathIndex === -1) return; - - remainingPathsArray.splice(remainingPathIndex, 1); - - if (remainingPathsArray.length === 0) { - delete blocklistPaths[hostnamePath1Key][path2][path3]; - if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { - delete blocklistPaths[hostnamePath1Key][path2]; - if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - delete blocklistPaths[hostnamePath1Key]; - } - } - } - break; - } - } + const remainingPath = remainingPaths.join('/'); + return level3Entry.includes(remainingPath); }; /** From 37d14016f7ca9c5790acdcb4352070f9d04974bc Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 28 Aug 2025 23:13:55 -0500 Subject: [PATCH 12/64] chore: update the tag names --- eslint-warning-thresholds.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 3c792584442..bbea870b02f 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -304,7 +304,7 @@ "jsdoc/tag-lines": 1 }, "packages/phishing-controller/src/PhishingController.ts": { - "jsdoc/check-tag-names": 38, + "jsdoc/check-tag-names": 39, "jsdoc/tag-lines": 1 }, "packages/phishing-controller/src/PhishingDetector.ts": { From 7e9b3a50b5e7bc802481d69cbaa9856c29dec3c9 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Sun, 14 Sep 2025 20:39:53 -0500 Subject: [PATCH 13/64] feat: whitelist support --- .../src/PhishingController.ts | 32 +++++++++++++++++-- .../src/PhishingDetector.ts | 22 ++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index a654f13db56..a5b3fb36acb 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -30,6 +30,7 @@ import { getHostnameFromUrl, roundToNearestMinute, getHostnameFromWebUrl, + doesURLPathExist, } from './utils'; export const PHISHING_CONFIG_BASE_URL = @@ -223,6 +224,12 @@ const metadata: StateMetadata = { anonymous: false, usedInUi: false, }, + whitelistPaths: { + includeInStateLogs: false, + persist: true, + anonymous: false, + usedInUi: false, + }, hotlistLastFetched: { includeInStateLogs: true, persist: true, @@ -257,6 +264,7 @@ const getDefaultState = (): PhishingControllerState => { return { phishingLists: [], whitelist: [], + whitelistPaths: [], hotlistLastFetched: 0, stalelistLastFetched: 0, c2DomainBlocklistLastFetched: 0, @@ -274,6 +282,7 @@ const getDefaultState = (): PhishingControllerState => { export type PhishingControllerState = { phishingLists: PhishingListState[]; whitelist: string[]; + whitelistPaths: string[]; hotlistLastFetched: number; stalelistLastFetched: number; c2DomainBlocklistLastFetched: number; @@ -589,6 +598,11 @@ export class PhishingController extends BaseController< test(origin: string): PhishingDetectorResult { const punycodeOrigin = toASCII(origin); const hostname = getHostnameFromUrl(punycodeOrigin); + + if (this.state.whitelistPaths.includes(origin)) { + return { result: false, type: PhishingDetectorResultType.All }; + } + if (this.state.whitelist.includes(hostname || punycodeOrigin)) { return { result: false, type: PhishingDetectorResultType.All }; // Same as whitelisted match returned by detector.check(...). } @@ -622,10 +636,24 @@ export class PhishingController extends BaseController< bypass(origin: string) { const punycodeOrigin = toASCII(origin); const hostname = getHostnameFromUrl(punycodeOrigin); - const { whitelist } = this.state; - if (whitelist.includes(hostname || punycodeOrigin)) { + const { whitelist, whitelistPaths } = this.state; + + if ( + whitelist.includes(hostname || punycodeOrigin) || + whitelistPaths.includes(origin) + ) { + return; + } + + // If the origin was blocked by a path, then we only want to add it to the whitelistPaths since + // other paths with the same hostname may not be blocked. + if (this.#detector.isPathBlocked(origin)) { + this.update((draftState) => { + draftState.whitelistPaths.push(origin); + }); return; } + this.update((draftState) => { draftState.whitelist.push(hostname || punycodeOrigin); }); diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index 1cddeb5c860..1850a67fe4d 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -171,7 +171,7 @@ export class PhishingDetector { match: domainPartsToDomain(source), // TODO: revisit this. do we want to return the path? name, result: true, - type: PhishingDetectorResultType.Blocklist, // TODO: do we want to differentiate between path and hostname blocklists? + type: PhishingDetectorResultType.Blocklist, version: version === undefined ? version : String(version), }; } @@ -235,6 +235,26 @@ export class PhishingDetector { return { result: false, type: PhishingDetectorResultType.All }; } + /** + * Checks if a URL is blocked against the blocklistPaths. + * + * @param url - The URL to check. + * @returns true if the URL is blocked, false otherwise. + */ + isPathBlocked(url: string): boolean { + for (const { blocklistPaths } of this.#configs) { + if (!blocklistPaths) { + continue; + } + const pathMatch = doesURLPathExist(url, blocklistPaths); + if (pathMatch) { + return true; + } + } + + return false; + } + /** * Checks if a URL is blocked against the hashed request blocklist. * This is done by hashing the URL's hostname and checking it against the hashed request blocklist. From 4854c2bebf6263e0bb44235a71462d953bb4e5f7 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Sun, 14 Sep 2025 20:40:02 -0500 Subject: [PATCH 14/64] test: update snapshot --- packages/phishing-controller/src/PhishingController.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 91ca117b33d..fb3613aefa7 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -3423,6 +3423,7 @@ describe('URL Scan Cache', () => { "stalelistLastFetched": 0, "urlScanCache": Object {}, "whitelist": Array [], + "whitelistPaths": Array [], } `); }); From 0dc4d5bf6ffc8efbdd39f3e5d8045587fb761a2d Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Sun, 14 Sep 2025 21:24:27 -0500 Subject: [PATCH 15/64] fix: add only hostname with paths to whitelist --- packages/phishing-controller/src/PhishingController.ts | 7 ++++--- packages/phishing-controller/src/utils.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index a5b3fb36acb..5f26bc88fac 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -30,7 +30,7 @@ import { getHostnameFromUrl, roundToNearestMinute, getHostnameFromWebUrl, - doesURLPathExist, + getPathnameFromUrl, } from './utils'; export const PHISHING_CONFIG_BASE_URL = @@ -636,11 +636,12 @@ export class PhishingController extends BaseController< bypass(origin: string) { const punycodeOrigin = toASCII(origin); const hostname = getHostnameFromUrl(punycodeOrigin); + const hostnameWithPaths = hostname + getPathnameFromUrl(origin); const { whitelist, whitelistPaths } = this.state; if ( whitelist.includes(hostname || punycodeOrigin) || - whitelistPaths.includes(origin) + whitelistPaths.includes(hostnameWithPaths) ) { return; } @@ -649,7 +650,7 @@ export class PhishingController extends BaseController< // other paths with the same hostname may not be blocked. if (this.#detector.isPathBlocked(origin)) { this.update((draftState) => { - draftState.whitelistPaths.push(origin); + draftState.whitelistPaths.push(hostnameWithPaths); }); return; } diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 5f2694b8294..20af09e11dc 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -650,6 +650,14 @@ export const getHostnameFromWebUrl = (url: string): [string, boolean] => { return [hostname || '', Boolean(hostname)]; }; +export const getPathnameFromUrl = (url: string): string => { + const urlObj = newURL(url); + if (!urlObj) { + return ''; + } + return urlObj.pathname; +}; + /** * Generates all possible parent domains up to a specified limit. * From a929ba95c453eb8cd905cde5fae714bcc0560e6d Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Sun, 14 Sep 2025 21:24:48 -0500 Subject: [PATCH 16/64] test: bypass whitelistPaths --- .../src/PhishingController.test.ts | 110 +++++++++++++----- 1 file changed, 83 insertions(+), 27 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index fb3613aefa7..6a67e120fe6 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -92,6 +92,18 @@ describe('PhishingController', () => { type: PhishingDetectorResultType.All, }); }); + + it('returns false if the URL is in the whitelistPaths', async () => { + const whitelistedURL = 'https://example.com/path'; + + const controller = getPhishingController(); + controller.bypass(whitelistedURL); + const result = controller.test(whitelistedURL); + expect(result).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); it('should return false if the URL is in the allowlist', async () => { const allowlistedHostname = 'example.com'; @@ -2412,47 +2424,91 @@ describe('PhishingController', () => { type: PhishingDetectorResultType.Allowlist, }); }); - describe('PhishingController - bypass', () => { + describe('bypass', () => { let controller: PhishingController; beforeEach(() => { - controller = getPhishingController(); + controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: { + 'example.com/path': {}, + }, + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + name: ListNames.MetaMask, + }, + ], + }, + }); }); - it('should do nothing if the origin is already in the whitelist', () => { - const origin = 'https://example.com'; - const hostname = getHostnameFromUrl(origin); + describe('whitelist', () => { + it('should do nothing if the origin is already in the whitelist', () => { + const origin = 'https://example.com'; + const hostname = getHostnameFromUrl(origin); - // Call the bypass function - controller.bypass(origin); - controller.bypass(origin); + // Call the bypass function + controller.bypass(origin); + controller.bypass(origin); - // Verify that the whitelist has not changed - expect(controller.state.whitelist).toContain(hostname); - expect(controller.state.whitelist).toHaveLength(1); // No duplicates added - }); + // Verify that the whitelist has not changed + expect(controller.state.whitelist).toContain(hostname); + expect(controller.state.whitelist).toHaveLength(1); // No duplicates added + expect(controller.state.whitelistPaths).toHaveLength(0); + }); - it('should add the origin to the whitelist if not already present', () => { - const origin = 'https://newsite.com'; - const hostname = getHostnameFromUrl(origin); + it('should add the origin to the whitelist if not already present', () => { + const origin = 'https://newsite.com'; + const hostname = getHostnameFromUrl(origin); - // Call the bypass function - controller.bypass(origin); + // Call the bypass function + controller.bypass(origin); - // Verify that the whitelist now includes the new origin - expect(controller.state.whitelist).toContain(hostname); - expect(controller.state.whitelist).toHaveLength(1); + // Verify that the whitelist now includes the new origin + expect(controller.state.whitelist).toContain(hostname); + expect(controller.state.whitelist).toHaveLength(1); + expect(controller.state.whitelistPaths).toHaveLength(0); + }); + + it('should add punycode origins to the whitelist if not already present', () => { + const punycodeOrigin = 'xn--fsq.com'; // Example punycode domain + + // Call the bypass function + controller.bypass(punycodeOrigin); + + // Verify that the whitelist now includes the punycode origin + expect(controller.state.whitelist).toContain(punycodeOrigin); + expect(controller.state.whitelist).toHaveLength(1); + expect(controller.state.whitelistPaths).toHaveLength(0); + }); }); - it('should add punycode origins to the whitelist if not already present', () => { - const punycodeOrigin = 'xn--fsq.com'; // Example punycode domain + describe('whitelistPaths', () => { + it('adds the hostname + paths to the whitelistPaths if not already present', () => { + const origin = 'https://example.com/path'; + controller.bypass(origin); + + expect(controller.state.whitelistPaths).toContain('example.com/path'); + expect(controller.state.whitelistPaths).toHaveLength(1); + expect(controller.state.whitelist).toHaveLength(0); + }); - // Call the bypass function - controller.bypass(punycodeOrigin); + it('does not add the hostname + paths to the whitelistPaths if already present', () => { + const origin = 'https://example.com/path'; + controller.bypass(origin); + controller.bypass(origin); - // Verify that the whitelist now includes the punycode origin - expect(controller.state.whitelist).toContain(punycodeOrigin); - expect(controller.state.whitelist).toHaveLength(1); + expect(controller.state.whitelistPaths).toContain('example.com/path'); + expect(controller.state.whitelistPaths).toHaveLength(1); + expect(controller.state.whitelist).toHaveLength(0); + }); }); }); From 095f60adf95a9309bcb5a9ad99222e481e98a058 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Mon, 15 Sep 2025 10:05:12 -0500 Subject: [PATCH 17/64] fix: test only checks hostnames with apths --- packages/phishing-controller/src/PhishingController.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 5f26bc88fac..1a92a0e97d1 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -598,8 +598,9 @@ export class PhishingController extends BaseController< test(origin: string): PhishingDetectorResult { const punycodeOrigin = toASCII(origin); const hostname = getHostnameFromUrl(punycodeOrigin); + const hostnameWithPaths = hostname + getPathnameFromUrl(origin); - if (this.state.whitelistPaths.includes(origin)) { + if (this.state.whitelistPaths.includes(hostnameWithPaths)) { return { result: false, type: PhishingDetectorResultType.All }; } From 5bd9365d52504e3de80e56eb65b8d0f84f70ecfd Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Mon, 15 Sep 2025 10:05:22 -0500 Subject: [PATCH 18/64] test: test for whitelistPaths --- .../src/PhishingController.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 6a67e120fe6..9d5ed3604d6 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1330,6 +1330,34 @@ describe('PhishingController', () => { }); }); + it('returns positive result for unsafe hostname+pathname from MetaMask config', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + eth_phishing_detect_config: { + blocklist: ['example.com/path'], + }, + }, + }); + + const controller = getPhishingController(); + await controller.updateStalelist(); + expect(controller.test('https://example.com/path')).toMatchObject({ + result: true, + type: PhishingDetectorResultType.Blocklist, + }); + }); + + it('returns negative result if the hostname+pathname is in the whitelistPaths', async () => { + const controller = getPhishingController(); + controller.bypass('https://example.com/path'); + expect(controller.test('https://example.com/path')).toMatchObject({ + result: false, + type: PhishingDetectorResultType.All, + }); + }); + describe('updateStalelist', () => { it('should update lists with addition to hotlist', async () => { sinon.useFakeTimers(2); From cb623efe879dd17186d31f6224246262b164395e Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 09:50:52 -0500 Subject: [PATCH 19/64] feat: PathTrie --- packages/phishing-controller/src/PathTrie.ts | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/phishing-controller/src/PathTrie.ts diff --git a/packages/phishing-controller/src/PathTrie.ts b/packages/phishing-controller/src/PathTrie.ts new file mode 100644 index 00000000000..5131add1009 --- /dev/null +++ b/packages/phishing-controller/src/PathTrie.ts @@ -0,0 +1,117 @@ +export type PathNode = { + [key: string]: PathNode; +}; + +export type PathTrie = Record; + +const isTerminal = (node: PathNode): boolean => { + return Object.keys(node).length === 0; +}; + +/** + * Insert a URL into the trie, mutating `pathTrie` in place. + * - If an ancestor path already exists as a terminal ({}), do nothing. + * - If inserting an ancestor of existing entries, prune descendants by setting that node to {}. + * - If no path segments exist (bare host or "/"), do nothing. + */ +export const insertToTrie = (url: string, pathTrie: PathTrie) => { + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + var { hostname, pathname } = new URL(urlWithProtocol); + const pathComponents = pathname.split('/').filter(Boolean); + + if (pathComponents.length === 0) { + return; + } + + hostname = hostname.toLowerCase(); + if (!pathTrie[hostname]) { + pathTrie[hostname] = {} as PathNode; + } + + let curr: PathNode = pathTrie[hostname]; + for (let i = 0; i < pathComponents.length; i++) { + const pathComponent = pathComponents[i]; + const isLast = i === pathComponents.length - 1; + const exists = curr[pathComponent] !== undefined; + + if (exists) { + if (!isLast && isTerminal(curr[pathComponent])) { + return; + } + + if (isLast) { + // Prune descendants if the current path component is not terminal + if (!isTerminal(curr[pathComponent])) { + curr[pathComponent] = {}; + } + return; + } + curr = curr[pathComponent]; + continue; + } + + if (isLast) { + curr[pathComponent] = {}; + return; + } + const next: PathNode = {}; + curr[pathComponent] = next; + curr = next; + } +}; + +export const deleteFromTrie = (url: string, pathTrie: PathTrie) => { + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + const { hostname, pathname } = new URL(urlWithProtocol); + const pathComponents = pathname.split('/').filter(Boolean); + + if (pathComponents.length === 0 || !pathTrie[hostname]) { + return; + } + + const pathToNode: { node: PathNode; key: string }[] = [ + { node: pathTrie, key: hostname }, + ]; + let curr: PathNode = pathTrie[hostname]; + for (let i = 0; i < pathComponents.length; i++) { + const pathComponent = pathComponents[i]; + + if (!curr[pathComponent]) { + return; + } + + pathToNode.push({ node: curr, key: pathComponent }); + curr = curr[pathComponent]; + } + + const lastEntry = pathToNode[pathToNode.length - 1]; + delete lastEntry.node[lastEntry.key]; + for (let i = pathToNode.length - 2; i >= 0; i--) { + const { node, key } = pathToNode[i]; + if (isTerminal(node[key])) { + delete node[key]; + } else { + break; + } + } +}; + +export const isTerminalPath = (url: string, pathTrie: PathTrie): boolean => { + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + const { hostname, pathname } = new URL(urlWithProtocol); + const pathComponents = pathname.split('/').filter(Boolean); + + if (pathComponents.length === 0 || !pathTrie[hostname]) { + return false; + } + + let curr: PathNode = pathTrie[hostname]; + for (let i = 0; i < pathComponents.length; i++) { + const pathComponent = pathComponents[i]; + if (!curr[pathComponent]) { + return false; + } + curr = curr[pathComponent]; + } + return isTerminal(curr); +}; From 1179b55d56583cde1e90892b1b6a8e4d42cce881 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 09:50:59 -0500 Subject: [PATCH 20/64] test: PathTrie --- .../phishing-controller/src/PathTrie.test.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 packages/phishing-controller/src/PathTrie.test.ts diff --git a/packages/phishing-controller/src/PathTrie.test.ts b/packages/phishing-controller/src/PathTrie.test.ts new file mode 100644 index 00000000000..c33edb317b3 --- /dev/null +++ b/packages/phishing-controller/src/PathTrie.test.ts @@ -0,0 +1,225 @@ +import { + deleteFromTrie, + isTerminalPath, + insertToTrie, + PathTrie, +} from './PathTrie'; + +const emptyPathTrie: PathTrie = {}; + +describe.only('PathTrie', () => { + describe('insertToTrie', () => { + let pathTrie: PathTrie; + + beforeEach(() => { + pathTrie = {}; + }); + + it('inserts a URL to the path trie', () => { + insertToTrie('example.com/path1/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: {}, + }, + }, + }); + }); + + it('inserts sibling path', () => { + insertToTrie('example.com/path1', pathTrie); + insertToTrie('example.com/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: {}, + path2: {}, + }, + }); + }); + + it('multiple inserts', () => { + insertToTrie('example.com/path1/path2/path31', pathTrie); + insertToTrie('example.com/path1/path2/path32', pathTrie); + insertToTrie('example.com/path1/path2/path33/path4', pathTrie); + insertToTrie('example.com/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: { + path31: {}, + path32: {}, + path33: { + path4: {}, + }, + }, + }, + path2: {}, + }, + }); + }); + + it('idempotent', () => { + insertToTrie('example.com/path1/path2', pathTrie); + insertToTrie('example.com/path1/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: {}, + }, + }, + }); + }); + + it('prunes descendants when adding ancestor', () => { + insertToTrie('example.com/path1/path2/path3', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: { + path2: { + path3: {}, + }, + }, + }, + }); + + insertToTrie('example.com/path1', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: {}, + }, + }); + }); + + it('does not insert path1/path2 if path1 exists', () => { + insertToTrie('example.com/path1', pathTrie); + insertToTrie('example.com/path1/path2', pathTrie); + + expect(pathTrie).toStrictEqual({ + 'example.com': { + path1: {}, + }, + }); + }); + + it('does not insert if no path is provided', () => { + insertToTrie('example.com', pathTrie); + + expect(pathTrie).toStrictEqual(emptyPathTrie); + }); + + it('treats trailing slash as equivalent', () => { + insertToTrie('example.com/path', pathTrie); + insertToTrie('example.com/path/', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { path: {} }, + }); + }); + + it('accepts URLs with a scheme', () => { + insertToTrie('https://example.com/path', pathTrie); + expect(pathTrie).toStrictEqual({ 'example.com': { path: {} } }); + }); + }); + + describe('deleteFromTrie', () => { + let pathTrie: PathTrie; + + beforeEach(() => { + pathTrie = { + 'example.com': { + path11: { + path2: {}, + }, + path12: {}, + }, + }; + }); + + it('deletes a path', () => { + deleteFromTrie('example.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('deletes all paths', () => { + deleteFromTrie('example.com/path11/path2', pathTrie); + deleteFromTrie('example.com/path12', pathTrie); + expect(pathTrie).toStrictEqual(emptyPathTrie); + }); + + it('deletes descendants if the path is not terminal', () => { + deleteFromTrie('example.com/path11', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('idempotent', () => { + deleteFromTrie('example.com/path11/path2', pathTrie); + deleteFromTrie('example.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + + it('does nothing if the path does not exist within the trie', () => { + deleteFromTrie('example.com/nonexistent', pathTrie); + expect(pathTrie).toStrictEqual(pathTrie); + }); + + it('does nothing if the hostname does not exist', () => { + deleteFromTrie('nonexistent.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual(pathTrie); + }); + + it('does nothing if no path is provided', () => { + deleteFromTrie('example.com', pathTrie); + expect(pathTrie).toStrictEqual(pathTrie); + }); + + it('deletes with a scheme', () => { + deleteFromTrie('https://example.com/path11/path2', pathTrie); + expect(pathTrie).toStrictEqual({ + 'example.com': { + path12: {}, + }, + }); + }); + }); + + describe('isTerminalPath', () => { + let pathTrie: PathTrie; + + beforeEach(() => { + pathTrie = { + 'example.com': { + path11: { + path2: {}, + }, + }, + }; + }); + + it.each([ + ['terminal path', 'example.com/path11/path2', true], + ['ancestor path', 'example.com/path11', false], + ['non-existent path', 'example.com/path11/path3', false], + ['no path', 'example.com', false], + ['no hostname', 'nonexistent.com/path11/path2', false], + ['with a scheme', 'https://example.com/path11/path2', true], + ])('returns %s if the path is %s', (_name, path, expected) => { + expect(isTerminalPath(path, pathTrie)).toBe(expected); + }); + }); +}); From ef1e11436575713e369db2d30508155cf5808450 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 09:52:55 -0500 Subject: [PATCH 21/64] chore: update types for blocklistPaths to Trie --- packages/phishing-controller/src/PhishingController.ts | 3 ++- packages/phishing-controller/src/PhishingDetector.ts | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 1a92a0e97d1..27467fa1490 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -32,6 +32,7 @@ import { getHostnameFromWebUrl, getPathnameFromUrl, } from './utils'; +import { PathTrie } from './PathTrie'; export const PHISHING_CONFIG_BASE_URL = 'https://phishing-detection.api.cx.metamask.io'; @@ -136,7 +137,7 @@ export type PhishingStalelist = { export type PhishingListState = { allowlist: string[]; blocklist: string[]; - blocklistPaths: Record>>; + blocklistPaths: PathTrie; c2DomainBlocklist: string[]; fuzzylist: string[]; tolerance: number; diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index 1850a67fe4d..10aafaff821 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -14,8 +14,8 @@ import { matchPartsAgainstList, processConfigs, sha256Hash, - doesURLPathExist, } from './utils'; +import { isTerminalPath, PathTrie } from './PathTrie'; export type LegacyPhishingDetectorList = { whitelist?: string[]; @@ -26,7 +26,7 @@ export type LegacyPhishingDetectorList = { export type PhishingDetectorList = { allowlist?: string[]; blocklist?: string[]; - blocklistPaths?: Record>>; + blocklistPaths?: PathTrie; c2DomainBlocklist?: string[]; name?: string; version?: string | number; @@ -52,7 +52,7 @@ export type PhishingDetectorConfiguration = { version?: number | string; allowlist: string[][]; blocklist: string[][]; - blocklistPaths?: Record>>; + blocklistPaths?: PathTrie; c2DomainBlocklist?: string[]; fuzzylist: string[][]; tolerance: number; @@ -165,7 +165,7 @@ export class PhishingDetector { if (!blocklistPaths) { continue; } - const pathMatch = doesURLPathExist(url, blocklistPaths); + const pathMatch = isTerminalPath(url, blocklistPaths); if (pathMatch) { return { match: domainPartsToDomain(source), // TODO: revisit this. do we want to return the path? @@ -246,7 +246,7 @@ export class PhishingDetector { if (!blocklistPaths) { continue; } - const pathMatch = doesURLPathExist(url, blocklistPaths); + const pathMatch = isTerminalPath(url, blocklistPaths); if (pathMatch) { return true; } From a6b535b99ac00a64fbc8c1ead1f0d8ec27d865e0 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 09:53:26 -0500 Subject: [PATCH 22/64] chore: remove old insertion/deletion/exists methods --- packages/phishing-controller/src/utils.ts | 311 +--------------------- 1 file changed, 4 insertions(+), 307 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 20af09e11dc..d193cc8f01e 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -7,6 +7,7 @@ import type { PhishingDetectorList, PhishingDetectorConfiguration, } from './PhishingDetector'; +import { deleteFromTrie, insertToTrie } from './PathTrie'; const DEFAULT_TOLERANCE = 3; @@ -60,244 +61,6 @@ const hasPath = (url: string): boolean => { return urlObj.pathname.split('/').filter(Boolean).length > 0; }; -/** - * isLevelBlocking checks if a level entry blocks all paths after it. Only used for level 1 and 2. - * If the levelEntry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) - * If the levelEntry has keys, that means the blocking path extends to the next level and we return true. - * - * @param levelEntry - the level entry to check. - * @returns true if the level entry blocks all paths after it, false otherwise. - */ -const isLevelBlocking = (levelEntry?: Record): boolean => { - return levelEntry !== undefined && Object.keys(levelEntry).length === 0; -}; - -/** - * isLevel3Blocking checks if a level3 entry blocks all paths after it. - * If the level3Entry is undefined, we return false (as that means there is no entry for it i.e. it is not in blocklistPaths) - * If the level3Entry has items, that means there are specific remaining paths blocked. - * - * @param level3Entry - the level3 entry to check. - * @returns true if the level3 entry blocks all paths after it, false otherwise. - */ -const isLevel3Blocking = (level3Entry: string[] | undefined): boolean => { - return level3Entry !== undefined && level3Entry.length === 0; -}; - -/** - * initializeBlocklistPath initializes a blocklist path if it doesn't exist. - * - * @param blocklistPaths - the blocklistPaths structure to modify. - * @param hostnamePath1Key - the hostname/path1 key to initialize. - * @param path2 - the path2 key to initialize. - * @param path3 - the path3 key to initialize. - */ -const initializeBlocklistPath = ( - blocklistPaths: Record>>, - hostnamePath1Key: string, - path2?: string, - path3?: string, -) => { - if (!blocklistPaths[hostnamePath1Key]) { - blocklistPaths[hostnamePath1Key] = {}; - } - - if (path2 && !blocklistPaths[hostnamePath1Key][path2]) { - blocklistPaths[hostnamePath1Key][path2] = {}; - } - - if (path2 && path3 && !blocklistPaths[hostnamePath1Key][path2][path3]) { - blocklistPaths[hostnamePath1Key][path2][path3] = []; - } -}; -/** - * Adds a URL to the blocklistPaths structure. - * Parses the URL and adds the path components to the appropriate nested structure. - * - * @param url - The URL to add. - * @param blocklistPaths - The blocklistPaths structure to modify. - */ -const addURLToBlocklistPaths = ( - url: string, - blocklistPaths: Record>>, -) => { - const urlObj = newURL(url); - if (!urlObj) { - return; - } - - const { hostname, pathname } = urlObj; - const pathComponents = pathname.split('/').filter(Boolean); - const [path1, path2, path3] = pathComponents; - - if (!path1) { - return; - } - - const hostnamePath1Key = `${hostname}/${path1}`; - const componentCount = pathComponents.length; - - switch (componentCount) { - case 1: - blocklistPaths[hostnamePath1Key] = {}; - break; - case 2: { - const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevelBlocking(level1Entry)) { - return; - } - - initializeBlocklistPath(blocklistPaths, hostnamePath1Key); - blocklistPaths[hostnamePath1Key][path2] = {}; - break; - } - case 3: { - const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevelBlocking(level1Entry)) { - return; - } - const level2Entry = level1Entry?.[path2]; - if (isLevelBlocking(level2Entry)) { - return; - } - - initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2); - blocklistPaths[hostnamePath1Key][path2][path3] = []; - break; - } - default: { - const level1Entry = blocklistPaths[hostnamePath1Key]; - if (isLevelBlocking(level1Entry)) { - return; - } - const level2Entry = level1Entry?.[path2]; - if (isLevelBlocking(level2Entry)) { - return; - } - const level3Entry = level2Entry?.[path3]; - if (isLevel3Blocking(level3Entry)) { - return; - } - - const remainingPaths = pathComponents.slice(3); - const remainingPath = remainingPaths.join('/'); - - initializeBlocklistPath(blocklistPaths, hostnamePath1Key, path2, path3); - if ( - !blocklistPaths[hostnamePath1Key][path2][path3].includes(remainingPath) - ) { - blocklistPaths[hostnamePath1Key][path2][path3].push(remainingPath); - } - break; - } - } -}; - -/** - * Removes a URL from the blocklistPaths structure. - * Parses the URL and removes the path components from the appropriate nested structure. - * Cleans up empty nested objects/arrays. - * - * @param url - The URL to remove. - * @param blocklistPaths - The blocklistPaths structure to modify. - */ -const removeURLFromBlocklistPaths = ( - url: string, - blocklistPaths: Record>>, -) => { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - const { hostname, pathname } = new URL(urlWithProtocol); - const pathComponents = pathname.split('/').filter(Boolean); - const [path1, path2, path3] = pathComponents; - - if (!path1) { - return; - } - - const hostnamePath1Key = `${hostname}/${path1}`; - const componentCount = pathComponents.length; - - if (!blocklistPaths[hostnamePath1Key]) { - return; - } - - switch (componentCount) { - case 1: { - if (!isLevelBlocking(blocklistPaths[hostnamePath1Key])) { - return; - } - - delete blocklistPaths[hostnamePath1Key]; - break; - } - case 2: { - if (!blocklistPaths[hostnamePath1Key][path2]) { - return; - } - if (!isLevelBlocking(blocklistPaths[hostnamePath1Key][path2])) { - return; - } - - delete blocklistPaths[hostnamePath1Key][path2]; - if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - delete blocklistPaths[hostnamePath1Key]; - } - break; - } - case 3: { - if (!blocklistPaths[hostnamePath1Key][path2]) { - return; - } - if (!blocklistPaths[hostnamePath1Key][path2][path3]) { - return; - } - if (!isLevel3Blocking(blocklistPaths[hostnamePath1Key][path2][path3])) { - return; - } - - delete blocklistPaths[hostnamePath1Key][path2][path3]; - if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { - delete blocklistPaths[hostnamePath1Key][path2]; - if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - delete blocklistPaths[hostnamePath1Key]; - } - } - break; - } - default: { - if (!blocklistPaths[hostnamePath1Key][path2]) { - return; - } - if (!blocklistPaths[hostnamePath1Key][path2][path3]) { - return; - } - - const remainingPaths = pathComponents.slice(3); - const remainingPath = remainingPaths.join('/'); - - const remainingPathsArray = - blocklistPaths[hostnamePath1Key][path2][path3]; - const remainingPathIndex = remainingPathsArray.indexOf(remainingPath); - if (remainingPathIndex === -1) { - return; - } - - remainingPathsArray.splice(remainingPathIndex, 1); - - if (remainingPathsArray.length === 0) { - delete blocklistPaths[hostnamePath1Key][path2][path3]; - if (Object.keys(blocklistPaths[hostnamePath1Key][path2]).length === 0) { - delete blocklistPaths[hostnamePath1Key][path2]; - if (Object.keys(blocklistPaths[hostnamePath1Key]).length === 0) { - delete blocklistPaths[hostnamePath1Key]; - } - } - } - break; - } - } -}; - /** * Determines which diffs are applicable to the listState, then applies those diffs. * @@ -335,7 +98,6 @@ export const applyDiffs = ( fuzzylist: new Set(listState.fuzzylist), c2DomainBlocklist: new Set(listState.c2DomainBlocklist), }; - const blocklistPaths = JSON.parse(JSON.stringify(listState.blocklistPaths)); for (const { isRemoval, targetList, url, timestamp } of diffsToApply) { const targetListType = splitStringByPeriod(targetList)[1]; @@ -345,9 +107,9 @@ export const applyDiffs = ( if (targetListType === 'blocklist' && hasPath(url)) { if (isRemoval) { - removeURLFromBlocklistPaths(url, blocklistPaths); + deleteFromTrie(url, listState.blocklistPaths); } else { - addURLToBlocklistPaths(url, blocklistPaths); + insertToTrie(url, listState.blocklistPaths); } continue; } @@ -373,7 +135,7 @@ export const applyDiffs = ( allowlist: Array.from(listSets.allowlist), blocklist: Array.from(listSets.blocklist), fuzzylist: Array.from(listSets.fuzzylist), - blocklistPaths, + blocklistPaths: listState.blocklistPaths, version: listState.version, name: phishingListKeyNameMap[listKey], tolerance: listState.tolerance, @@ -530,71 +292,6 @@ export const matchPartsAgainstList = (source: string[], list: string[][]) => { }); }; -/** - * Checks if the hostname and path exists within the list of paths. - * If there are more than 3 path components, we return true if the first 3 path components exist. - * - * @param url - the url to check. - * @param urlPaths - the paths to check. - * @returns true if the hostname and path exists in the path list, false otherwise. - */ -export const doesURLPathExist = ( - url: string, - urlPaths: Record>>, -) => { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - const { hostname, pathname } = new URL(urlWithProtocol); - const pathComponents = pathname.split('/').filter(Boolean); - const [path1, path2, path3, ...remainingPaths] = pathComponents; - - if (!path1) { - return false; - } - - // Level 1: hostname/path1 - const level1Entry = urlPaths[`${hostname}/${path1}`]; - if (!level1Entry) { - return false; - } - if (Object.keys(level1Entry).length === 0) { - return true; // Blocks everything under hostname/path1 - } - - if (!path2) { - return false; - } - - // Level 2: path2 - const level2Entry = level1Entry[path2]; - if (!level2Entry) { - return false; - } - if (Object.keys(level2Entry).length === 0) { - return true; // Blocks everything under hostname/path1/path2 - } - - if (!path3) { - return false; - } - - // Level 3: path3 - const level3Entry = level2Entry[path3]; - if (!level3Entry) { - return false; - } - if (level3Entry.length === 0) { - return true; // Blocks everything under hostname/path1/path2/path3 - } - - // Level 4: remaining path segments - if (remainingPaths.length === 0) { - return true; // Exact match at 3-segment level - } - - const remainingPath = remainingPaths.join('/'); - return level3Entry.includes(remainingPath); -}; - /** * Generate the SHA-256 hash of a hostname. * From cbfa2a5a294e1ecde732bac8390f817ef97c741c Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 09:56:43 -0500 Subject: [PATCH 23/64] test: update to new structure --- .../phishing-controller/src/utils.test.ts | 686 +++++------------- 1 file changed, 163 insertions(+), 523 deletions(-) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 17aa701044b..4159a181ee4 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -3,7 +3,6 @@ import * as sinon from 'sinon'; import { ListKeys, ListNames } from './PhishingController'; import { applyDiffs, - doesURLPathExist, domainToParts, fetchTimeNow, generateParentDomains, @@ -32,19 +31,22 @@ const exampleListState = { blocklist: exampleBlocklist, c2DomainBlocklist: examplec2DomainBlocklist, blocklistPaths: { - 'url1.com/path1': {}, - 'url2.com/path': { + 'url1.com': {}, + 'url2.com': { path2: {}, }, - 'url3.com/path1': { + 'url3.com': { path2: { - path3: [], + path3: {}, }, }, - 'url4.com/path1': { + 'url4.com': { path21: { - path31: ['path41', 'path42'], - path32: [], + path31: { + path41: {}, + path42: {}, + }, + path32: {}, }, path22: {}, }, @@ -258,440 +260,224 @@ describe('applyDiffs', () => { timestamp: 1000000000, }); - const newRemoveDiff = (url: string, timestampOffset = 1) => ({ + const newRemoveDiff = (url: string) => ({ targetList: 'eth_phishing_detect_config.blocklist' as const, url, - timestamp: 1000000000 + timestampOffset, // Higher timestamp to ensure it's processed after additions + timestamp: 1000000001, isRemoval: true, }); describe('adding URLs to blocklistPaths', () => { - describe('when blocklistPaths is empty', () => { - const emptyListState = { + let listState: any; + + beforeEach(() => { + listState = { ...exampleListState, blocklistPaths: {}, }; + }); - it.each([ - [ - 'adds a URL with 1 path component', - 'example.com/path1', - { - 'example.com/path1': {}, - }, - ], - [ - 'adds a URL with 2 path components', - 'example.com/path1/path2', - { - 'example.com/path1': { - path2: {}, - }, - }, - ], - [ - 'adds a URL with 3 path components', - 'example.com/path1/path2/path3', - { - 'example.com/path1': { - path2: { - path3: [], - }, - }, - }, - ], - [ - 'adds a URL with 4 path components', - 'example.com/path1/path2/path3/path4', - { - 'example.com/path1': { - path2: { - path3: ['path4'], - }, - }, + it('adds a URL to the path trie', () => { + const result = applyDiffs( + listState, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: { + path2: {}, }, - ], - ])('%s', (_name, url, expectedBlocklistPaths) => { - const result = applyDiffs( - emptyListState, - [newAddDiff(url)], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual(expectedBlocklistPaths); + }, }); }); - describe('when blocklistPaths has a 1-level entry and the URL shares the hostname/path1', () => { - const listStateWithOneLevel = { - ...exampleListState, - blocklistPaths: { - 'example.com/path1': {}, + it('adds sibling paths', () => { + applyDiffs( + listState, + [newAddDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newAddDiff('example.com/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: {}, + path2: {}, }, - }; - it('does not add a URL with 2 path components', () => { - const result = applyDiffs( - listStateWithOneLevel, - [newAddDiff('example.com/path1/path2')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithOneLevel.blocklistPaths, - ); - }); - - it('does not add a URL with 3 path components', () => { - const result = applyDiffs( - listStateWithOneLevel, - [newAddDiff('example.com/path1/path2/path3')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithOneLevel.blocklistPaths, - ); }); }); - describe('when blocklistPaths has a 2-level entry and the URL shares the hostname/path1/path2', () => { - const listStateWithTwoLevels = { - ...exampleListState, - blocklistPaths: { - 'example.com/path1': { + it('is idempotent', () => { + applyDiffs( + listState, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: { path2: {}, }, }, - }; - it('does not add a URL with 3 path components', () => { - const result = applyDiffs( - listStateWithTwoLevels, - [newAddDiff('example.com/path1/path2/path3')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithTwoLevels.blocklistPaths, - ); }); + }); - it('does not duplicate the path2 entry', () => { - const result = applyDiffs( - listStateWithTwoLevels, - [newAddDiff('example.com/path1/path2')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithTwoLevels.blocklistPaths, - ); + it('prunes descendants when adding ancestor', () => { + applyDiffs( + listState, + [newAddDiff('example.com/path1/path2/path3')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newAddDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: {}, + }, }); }); - describe('when blocklistPaths has a 3-level entry and the URL shares the hostname/path1/path2/path3', () => { - const listStateWithThreeLevels = { - ...exampleListState, - blocklistPaths: { - 'example.com/path1': { - path2: { - path3: [], - }, - }, + it('does not insert deeper path if ancestor exists', () => { + applyDiffs( + listState, + [newAddDiff('example.com/path1')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newAddDiff('example.com/path1/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path1: {}, }, - }; - it('does not add a URL with 3 path components when level 3 already blocks everything', () => { - const result = applyDiffs( - listStateWithThreeLevels, - [newAddDiff('example.com/path1/path2/path3')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithThreeLevels.blocklistPaths, - ); }); + }); - it('does not add a URL with 4 path components when level 3 already blocks everything', () => { - const result = applyDiffs( - listStateWithThreeLevels, - [newAddDiff('example.com/path1/path2/path3/path4')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithThreeLevels.blocklistPaths, - ); - }); + it('does not insert if no path is provided', () => { + const result = applyDiffs( + listState, + [newAddDiff('example.com')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({}); }); + }); + + describe('removing URLs from blocklistPaths', () => { + let listState: any; - describe('when blocklistPaths has 4-level entries and the URL shares the hostname/path1/path2/path3', () => { - const listStateWithFourLevels = { + beforeEach(() => { + listState = { ...exampleListState, blocklistPaths: { - 'example.com/path1': { - path2: { - path3: ['path41'], + 'example.com': { + path11: { + path2: {}, }, + path12: {}, }, }, }; - it('adds a new remaining path to the hostname/path1/path2/path3 array', () => { - const result = applyDiffs( - listStateWithFourLevels, - [newAddDiff('example.com/path1/path2/path3/path42')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({ - 'example.com/path1': { - path2: { - path3: ['path41', 'path42'], - }, - }, - }); - }); - it('does not add a remaining path if it already exists', () => { - const result = applyDiffs( - listStateWithFourLevels, - [newAddDiff('example.com/path1/path2/path3/path41')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithFourLevels.blocklistPaths, - ); - }); }); - it('properly handles URLs with 4+ path components by storing remaining segments', () => { + it('deletes a path', () => { const result = applyDiffs( - exampleListState, - [newAddDiff('example.com/path1/path2/path3/path4/path5')], + listState, + [newRemoveDiff('example.com/path11/path2')], ListKeys.EthPhishingDetectConfig, ); expect(result.blocklistPaths).toStrictEqual({ - ...exampleListState.blocklistPaths, - 'example.com/path1': { - path2: { - path3: ['path4/path5'], - }, + 'example.com': { + path12: {}, }, }); }); - it('does not add a URL with no path', () => { - const result = applyDiffs( - exampleListState, - [newAddDiff('example.com')], + it('deletes all paths', () => { + applyDiffs( + listState, + [newRemoveDiff('example.com/path11/path2')], ListKeys.EthPhishingDetectConfig, ); - expect(result.blocklistPaths).toStrictEqual( - exampleListState.blocklistPaths, - ); - }); - }); - describe('removing URLs from blocklistPaths', () => { - it('removing a non-existent URL does nothing', () => { const result = applyDiffs( - exampleListState, - [newRemoveDiff('nonexistenturl.com/path1')], + listState, + [newRemoveDiff('example.com/path12')], ListKeys.EthPhishingDetectConfig, ); - expect(result.blocklistPaths).toStrictEqual( - exampleListState.blocklistPaths, - ); + expect(result.blocklistPaths).toStrictEqual({}); }); - describe('when blocklistPaths has a level-1 entry', () => { - const listStateWithOneLevel = { - ...exampleListState, - blocklistPaths: { - 'example.com/path1': {}, + it('deletes descendants if the path is not terminal', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('example.com/path11')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path12: {}, }, - }; - - it('removes the level-1 entry completely', () => { - const result = applyDiffs( - listStateWithOneLevel, - [newRemoveDiff('example.com/path1')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({}); - }); - - it('attempting to remove a level-2 path above a level-1 entry does nothing', () => { - const result = applyDiffs( - listStateWithOneLevel, - [newRemoveDiff('example.com/path1/path2')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithOneLevel.blocklistPaths, - ); }); }); - describe('when blocklistPaths has a level-2 entry', () => { - const listStateWithTwoLevels = { - ...exampleListState, - blocklistPaths: { - 'example.com/path1': { path21: {}, path22: {} }, - 'url.com/path1': { path2: {} }, + it('is idempotent', () => { + applyDiffs( + listState, + [newRemoveDiff('example.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + const result = applyDiffs( + listState, + [newRemoveDiff('example.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual({ + 'example.com': { + path12: {}, }, - }; - - it('removes a specific level-2 entry', () => { - const result = applyDiffs( - listStateWithTwoLevels, - [newRemoveDiff('example.com/path1/path21')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({ - 'example.com/path1': { path22: {} }, - 'url.com/path1': { path2: {} }, - }); - }); - - it('removes the entire level-1 entry when removing the last level-2 entry', () => { - const result = applyDiffs( - listStateWithTwoLevels, - [newRemoveDiff('url.com/path1/path2')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({ - 'example.com/path1': { path21: {}, path22: {} }, - }); - }); - - it('attempting to remove a level-3 path above a level-2 entry does nothing', () => { - const result = applyDiffs( - listStateWithTwoLevels, - [newRemoveDiff('example.com/path1/path21/path3')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithTwoLevels.blocklistPaths, - ); - }); - - it('attempting to remove a level-1 path that has a level-2 entry does nothing', () => { - const result = applyDiffs( - listStateWithTwoLevels, - [newRemoveDiff('example.com/path1')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithTwoLevels.blocklistPaths, - ); }); }); - describe('when blocklistPaths has a level-3 entry', () => { - const listStateWithThreeLevels = { - ...exampleListState, - blocklistPaths: { - 'example.com/path1': { - path2: { path3: [] }, - path5: { path6: [] }, - }, - 'other.com/path1': { - path2: { path3: [] }, - }, - }, - }; - - it('removes a specific level-3 entry', () => { - const result = applyDiffs( - listStateWithThreeLevels, - [newRemoveDiff('example.com/path1/path2/path3')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({ - 'example.com/path1': { - path5: { path6: [] }, - }, - 'other.com/path1': { - path2: { path3: [] }, - }, - }); - }); - - it('removes the level-2 entry when removing the last level-3 entry', () => { - const result = applyDiffs( - listStateWithThreeLevels, - [newRemoveDiff('example.com/path1/path5/path6')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({ - 'example.com/path1': { - path2: { path3: [] }, - }, - 'other.com/path1': { - path2: { path3: [] }, - }, - }); - }); - - it('removes the entire level-1 entry when removing all level-3 entries', () => { - const result = applyDiffs( - listStateWithThreeLevels, - [newRemoveDiff('other.com/path1/path2/path3')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({ - 'example.com/path1': { - path2: { path3: [] }, - path5: { path6: [] }, - }, - }); - }); + it('does nothing if path does not exist', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('example.com/nonexistent')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual(listState.blocklistPaths); + }); - it('removing a non-existent level-3 entry does nothing', () => { - const result = applyDiffs( - listStateWithThreeLevels, - [newRemoveDiff('example.com/path1/path2/nonexistent')], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual( - listStateWithThreeLevels.blocklistPaths, - ); - }); + it('does nothing if hostname does not exist', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('nonexistent.com/path11/path2')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual(listState.blocklistPaths); }); - it( - 'if we add 2 URLs with the same hostname/path1/path2/path3 but different remaining paths, ' + - 'they should be stored as separate entries and can be removed independently', - () => { - const emptyListState = { - ...exampleListState, - blocklistPaths: {}, - }; - const result = applyDiffs( - emptyListState, - [ - newAddDiff('example.com/path1/path2/path3/path41'), - newAddDiff('example.com/path1/path2/path3/path42'), - ], - ListKeys.EthPhishingDetectConfig, - ); - expect(result.blocklistPaths).toStrictEqual({ - 'example.com/path1': { - path2: { - path3: ['path41', 'path42'], - }, - }, - }); - const result2 = applyDiffs( - result, - [newRemoveDiff('example.com/path1/path2/path3/path41', 1)], - ListKeys.EthPhishingDetectConfig, - ); - expect(result2.blocklistPaths).toStrictEqual({ - 'example.com/path1': { - path2: { - path3: ['path42'], - }, - }, - }); - const result3 = applyDiffs( - result2, - [newRemoveDiff('example.com/path1/path2/path3/path42', 2)], - ListKeys.EthPhishingDetectConfig, - ); - expect(result3.blocklistPaths).toStrictEqual({}); - }, - ); + it('does nothing if no path is provided', () => { + const result = applyDiffs( + listState, + [newRemoveDiff('example.com')], + ListKeys.EthPhishingDetectConfig, + ); + expect(result.blocklistPaths).toStrictEqual(listState.blocklistPaths); + }); }); }); }); @@ -1258,149 +1044,3 @@ describe('generateParentDomains', () => { expect(generateParentDomains(filteredSourceParts)).toStrictEqual(expected); }); }); - -describe('doesURLPathExist', () => { - const blocklistPaths: Record< - string, - Record> - > = { - 'blocklist.has4paths.com/path1': { path2: { path3: ['path4'] } }, // explicit fourth-level blocklist - 'blocklist.has3paths.com/path1': { path2: { path3: [] } }, // blocks everything under /path1/path2/path3 - 'blocklist.has2paths.com/path1': { path2: {} }, // blocks everything under /path1/path2 - 'blocklist.has1path.com/path1': {}, // blocks everything under /path1 - }; - - // each testcase is [name, input, expected] - describe('input has 3 path components', () => { - it.each([ - [ - 'matches when the 3rd path component blocks everything (level 3 blocking)', - 'https://blocklist.has3paths.com/path1/path2/path3', - true, - ], - [ - 'matches when the first path component has no children (level 1 blocking)', - 'https://blocklist.has1path.com/path1/path2/path3', - true, - ], - [ - 'matches when the first two path components have no children (level 2 blocking)', - 'https://blocklist.has2paths.com/path1/path2/path3', - true, - ], - [ - 'does not match when the 3rd path component is not in the blocklist', - 'https://example.com/path1/path2/path3', - false, - ], - ])('should %s', (_name, input, expected) => { - expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); - }); - }); - - describe('input has 2 path components', () => { - it.each([ - [ - 'matches when the 2nd path component blocks everything (level 2 blocking)', - 'https://blocklist.has2paths.com/path1/path2', - true, - ], - [ - 'matches when the 1st path component blocks everything (level 1 blocking)', - 'https://blocklist.has1path.com/path1/path2', - true, - ], - [ - 'does not match when the 2nd path component is not in the list', - 'https://blocklist.has2paths.com/path1/path3', - false, - ], - [ - 'does not match when the 1st path component is not in the list', - 'https://blocklist.has2paths.com/path2/path2', - false, - ], - [ - 'does not match when the 2nd path component has specific level 3 children', - 'https://blocklist.has3paths.com/path1/path2', - false, - ], - ])('should %s', (_name, input, expected) => { - expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); - }); - }); - - describe('input has 1 path component', () => { - it.each([ - [ - 'matches when the 1st path component has no children', - 'https://blocklist.has1path.com/path1', - true, - ], - [ - 'does not match when the 1st path component is not in the list', - 'https://blocklist.has1path.com/path2', - false, - ], - [ - 'does not match when the 1st path component has children', - 'https://blocklist.has2paths.com/path1', - false, - ], - ])('should %s', (_name, input, expected) => { - expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); - }); - }); - - describe('input has 4 path components', () => { - it.each([ - [ - 'matches when the 4th path component is explicitly in the list', - 'https://blocklist.has4paths.com/path1/path2/path3/path4', - true, - ], - [ - 'matches when the 3rd path component blocks everything (level 3 blocking)', - 'https://blocklist.has3paths.com/path1/path2/path3/path4', - true, - ], - [ - 'matches when the 2nd path component blocks everything (level 2 blocking)', - 'https://blocklist.has2paths.com/path1/path2/path3/path4', - true, - ], - [ - 'matches when the 1st path component blocks everything (level 1 blocking)', - 'https://blocklist.has1path.com/path1/path2/path3/path4', - true, - ], - [ - 'does not match when the 4th path component is not in the list', - 'https://blocklist.has4paths.com/path1/path2/path3/path5', - false, - ], - [ - 'does not match when the domain is not in the blocklist', - 'https://example.com/path1/path2/path3/path4', - false, - ], - ])('should %s', (_name, input, expected) => { - expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); - }); - }); - - it.each([ - [ - 'does not match when the input has no path', - 'https://blocklist.has1path.com', - false, - ], - [ - 'matches with trailing slash', - 'https://blocklist.has1path.com/path1/', - true, - ], - ])('should %s', (_name, input, expected) => { - expect(doesURLPathExist(input, blocklistPaths)).toBe(expected); - }); -}); From a98edbb86d1eac5f7ba508631ed5bfb502338bee Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:17:43 -0500 Subject: [PATCH 24/64] refactor: getHostnameAndPathComponents --- packages/phishing-controller/src/PathTrie.ts | 27 +++++++++----------- packages/phishing-controller/src/utils.ts | 18 +++++++++++++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/phishing-controller/src/PathTrie.ts b/packages/phishing-controller/src/PathTrie.ts index 5131add1009..9412c5e7f33 100644 --- a/packages/phishing-controller/src/PathTrie.ts +++ b/packages/phishing-controller/src/PathTrie.ts @@ -1,13 +1,11 @@ +import { getHostnameAndPathComponents } from './utils'; + export type PathNode = { [key: string]: PathNode; }; export type PathTrie = Record; -const isTerminal = (node: PathNode): boolean => { - return Object.keys(node).length === 0; -}; - /** * Insert a URL into the trie, mutating `pathTrie` in place. * - If an ancestor path already exists as a terminal ({}), do nothing. @@ -15,11 +13,9 @@ const isTerminal = (node: PathNode): boolean => { * - If no path segments exist (bare host or "/"), do nothing. */ export const insertToTrie = (url: string, pathTrie: PathTrie) => { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - var { hostname, pathname } = new URL(urlWithProtocol); - const pathComponents = pathname.split('/').filter(Boolean); + var { hostname, pathComponents } = getHostnameAndPathComponents(url); - if (pathComponents.length === 0) { + if (pathComponents.length === 0 || !hostname) { return; } @@ -61,9 +57,7 @@ export const insertToTrie = (url: string, pathTrie: PathTrie) => { }; export const deleteFromTrie = (url: string, pathTrie: PathTrie) => { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - const { hostname, pathname } = new URL(urlWithProtocol); - const pathComponents = pathname.split('/').filter(Boolean); + var { hostname, pathComponents } = getHostnameAndPathComponents(url); if (pathComponents.length === 0 || !pathTrie[hostname]) { return; @@ -97,11 +91,10 @@ export const deleteFromTrie = (url: string, pathTrie: PathTrie) => { }; export const isTerminalPath = (url: string, pathTrie: PathTrie): boolean => { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - const { hostname, pathname } = new URL(urlWithProtocol); - const pathComponents = pathname.split('/').filter(Boolean); + var { hostname, pathComponents } = getHostnameAndPathComponents(url); - if (pathComponents.length === 0 || !pathTrie[hostname]) { + hostname = hostname.toLowerCase(); + if (pathComponents.length === 0 || !hostname || !pathTrie[hostname]) { return false; } @@ -115,3 +108,7 @@ export const isTerminalPath = (url: string, pathTrie: PathTrie): boolean => { } return isTerminal(curr); }; + +const isTerminal = (node: PathNode): boolean => { + return Object.keys(node).length === 0; +}; diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index d193cc8f01e..eea26ac3e03 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -355,6 +355,24 @@ export const getPathnameFromUrl = (url: string): string => { return urlObj.pathname; }; +export const getHostnameAndPathComponents = ( + url: string, +): { hostname: string; pathComponents: string[] } => { + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + try { + const { hostname, pathname } = new URL(urlWithProtocol); + return { + hostname: hostname.toLowerCase(), + pathComponents: pathname.split('/').filter(Boolean), + }; + } catch { + return { + hostname: '', + pathComponents: [], + }; + } +}; + /** * Generates all possible parent domains up to a specified limit. * From 80927ecd35b6392a39cf24a7e5a18b0135d684fd Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:18:08 -0500 Subject: [PATCH 25/64] fix: separate blocklist entries --- .../src/PhishingController.ts | 8 ++++- packages/phishing-controller/src/utils.ts | 32 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 27467fa1490..a4ae692655a 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -31,6 +31,7 @@ import { roundToNearestMinute, getHostnameFromWebUrl, getPathnameFromUrl, + separateBlocklistEntries, } from './utils'; import { PathTrie } from './PathTrie'; @@ -1009,13 +1010,18 @@ export class PhishingController extends BaseController< const { eth_phishing_detect_config, ...partialState } = stalelistResponse.data; + const { blocklist, blocklistPaths } = separateBlocklistEntries( + eth_phishing_detect_config.blocklist, + ); + const metamaskListState: PhishingListState = { ...eth_phishing_detect_config, ...partialState, + blocklist, + blocklistPaths, c2DomainBlocklist: c2DomainBlocklistResponse ? c2DomainBlocklistResponse.recentlyAdded : [], - blocklistPaths: {}, name: phishingListKeyNameMap.eth_phishing_detect_config, }; diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index eea26ac3e03..aec79f26aca 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -7,7 +7,7 @@ import type { PhishingDetectorList, PhishingDetectorConfiguration, } from './PhishingDetector'; -import { deleteFromTrie, insertToTrie } from './PathTrie'; +import { deleteFromTrie, insertToTrie, type PathTrie } from './PathTrie'; const DEFAULT_TOLERANCE = 3; @@ -18,6 +18,36 @@ const DEFAULT_TOLERANCE = 3; */ export const fetchTimeNow = (): number => Math.round(Date.now() / 1000); +/** + * Separates blocklist entries into hostname-only entries and hostname+path entries. + * + * @param blocklist - Array of blocklist entries (hostnames and hostname/path combinations) + * @returns Object containing separated blocklist and blocklistPaths + */ +export const separateBlocklistEntries = ( + blocklist: string[], +): { blocklist: string[]; blocklistPaths: PathTrie } => { + const hostnameOnlyList: string[] = []; + const pathTrie: PathTrie = {}; + + for (const entry of blocklist) { + const { hostname, pathComponents } = getHostnameAndPathComponents(entry); + if (!hostname) { + continue; + } + if (pathComponents.length === 0) { + hostnameOnlyList.push(hostname.toLowerCase()); + } else { + insertToTrie(entry, pathTrie); + } + } + + return { + blocklist: hostnameOnlyList, + blocklistPaths: pathTrie, + }; +}; + /** * Rounds a Unix timestamp down to the nearest minute. * From be8202db367fcfec7b3411d1df38f6482aa98b99 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:18:43 -0500 Subject: [PATCH 26/64] test: separateBlocklistEntries --- .../phishing-controller/src/utils.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 4159a181ee4..8ee57c829ad 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -12,6 +12,7 @@ import { processConfigs, processDomainList, roundToNearestMinute, + separateBlocklistEntries, sha256Hash, validateConfig, } from './utils'; @@ -1044,3 +1045,91 @@ describe('generateParentDomains', () => { expect(generateParentDomains(filteredSourceParts)).toStrictEqual(expected); }); }); + +describe('separateBlocklistEntries', () => { + it('separates hostname-only and hostname+path entries', () => { + const blocklist = [ + 'example.com', + 'malicious.com/phishing', + 'bad.com/scam/page', + 'another-bad.com', + 'evil.com/path1/path2/path3', + ]; + + const result = separateBlocklistEntries(blocklist); + + expect(result.blocklist).toStrictEqual(['example.com', 'another-bad.com']); + expect(result.blocklistPaths).toStrictEqual({ + 'malicious.com': { + phishing: {}, + }, + 'bad.com': { + scam: { + page: {}, + }, + }, + 'evil.com': { + path1: { + path2: { + path3: {}, + }, + }, + }, + }); + }); + + it('handles URLs with protocols', () => { + const blocklist = [ + 'https://example.com', + 'http://malicious.com/phishing', + 'https://bad.com/scam/page', + ]; + + const result = separateBlocklistEntries(blocklist); + + expect(result.blocklist).toStrictEqual(['example.com']); + expect(result.blocklistPaths).toStrictEqual({ + 'malicious.com': { + phishing: {}, + }, + 'bad.com': { + scam: { + page: {}, + }, + }, + }); + }); + + it('handles invalid URLs gracefully', () => { + const blocklist = ['example.com', '', 'malicious.com/phishing']; + + const result = separateBlocklistEntries(blocklist); + + expect(result.blocklist).toStrictEqual(['example.com']); + expect(result.blocklistPaths).toStrictEqual({ + 'malicious.com': { + phishing: {}, + }, + }); + }); + + it('handles empty blocklist', () => { + const result = separateBlocklistEntries([]); + + expect(result.blocklist).toStrictEqual([]); + expect(result.blocklistPaths).toStrictEqual({}); + }); + + it('handles trailing slashes correctly', () => { + const blocklist = ['example.com/', 'malicious.com/phishing/']; + + const result = separateBlocklistEntries(blocklist); + + expect(result.blocklist).toStrictEqual(['example.com']); + expect(result.blocklistPaths).toStrictEqual({ + 'malicious.com': { + phishing: {}, + }, + }); + }); +}); From f283e1166f1370d9ec0b9a6655273a09a8d5a5b7 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:18:53 -0500 Subject: [PATCH 27/64] test: getHostnameAndPathComponents --- .../phishing-controller/src/utils.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 8ee57c829ad..16d64382039 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -6,6 +6,7 @@ import { domainToParts, fetchTimeNow, generateParentDomains, + getHostnameAndPathComponents, getHostnameFromUrl, getHostnameFromWebUrl, matchPartsAgainstList, @@ -1133,3 +1134,25 @@ describe('separateBlocklistEntries', () => { }); }); }); + +describe('getHostnameAndPathComponents', () => { + it.each([ + [ + 'https://example.com/path1/path2', + { hostname: 'example.com', pathComponents: ['path1', 'path2'] }, + ], + [ + 'example.com/path1/path2', + { hostname: 'example.com', pathComponents: ['path1', 'path2'] }, + ], + ['example.com', { hostname: 'example.com', pathComponents: [] }], + [ + 'EXAMPLE.COM/Path1/PATH2', + { hostname: 'example.com', pathComponents: ['Path1', 'PATH2'] }, + ], + ['', { hostname: '', pathComponents: [] }], + ])('parses %s correctly', (input, expected) => { + const result = getHostnameAndPathComponents(input); + expect(result).toStrictEqual(expected); + }); +}); From 22b1ad1282beabe54313c11a63be552c208414f4 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:27:36 -0500 Subject: [PATCH 28/64] test(fix): updateStalelist unit test --- packages/phishing-controller/src/PhishingController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 9d5ed3604d6..61253433f97 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1413,7 +1413,7 @@ describe('PhishingController', () => { expect(controller.state.phishingLists).toStrictEqual([ { allowlist: [], - blocklist: [exampleBlockedUrl, exampleBlockedUrlOne], + blocklist: ['example-blocked-website.com', exampleBlockedUrlOne], c2DomainBlocklist: [exampleRequestBlockedHash], blocklistPaths: {}, fuzzylist: [], From d337406542a014658ccb940785be88cf5dc28d82 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:30:10 -0500 Subject: [PATCH 29/64] test: subdomain case getHostnameAndPathComponents --- packages/phishing-controller/src/utils.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 16d64382039..67606a5fb71 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -1151,6 +1151,10 @@ describe('getHostnameAndPathComponents', () => { { hostname: 'example.com', pathComponents: ['Path1', 'PATH2'] }, ], ['', { hostname: '', pathComponents: [] }], + [ + 'example.sub.com/path1/path2', + { hostname: 'example.sub.com', pathComponents: ['path1', 'path2'] }, + ], ])('parses %s correctly', (input, expected) => { const result = getHostnameAndPathComponents(input); expect(result).toStrictEqual(expected); From a5939448a7d7e9482f2759eff87e360421f5605f Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:36:41 -0500 Subject: [PATCH 30/64] test(fix): update the structure of blocklistPaths --- packages/phishing-controller/src/PhishingController.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 61253433f97..f5aab9a10cc 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -2464,7 +2464,9 @@ describe('PhishingController', () => { blocklist: [], c2DomainBlocklist: [], blocklistPaths: { - 'example.com/path': {}, + 'example.com': { + path: {}, + }, }, fuzzylist: [], tolerance: 0, From 907dd00127c019281d6f5038b72251cabb216929 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:45:33 -0500 Subject: [PATCH 31/64] test: updateStalelist blocklist separation case --- .../src/PhishingController.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index f5aab9a10cc..fe7fe8c93f4 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1495,6 +1495,61 @@ describe('PhishingController', () => { ]); }); + it('should correctly process blocklist entries with paths into blocklistPaths', async () => { + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention + eth_phishing_detect_config: { + allowlist: [], + blocklist: ['example.com', 'malicious.com/phishing'], + fuzzylist: [], + }, + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention + phishfort_hotlist: { + blocklist: [], + }, + tolerance: 0, + allowlist: [], + version: 0, + lastUpdated: 1, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, + }); + + const controller = getPhishingController(); + await controller.updateStalelist(); + expect(controller.state.phishingLists).toStrictEqual([ + { + allowlist: [], + blocklist: ['example.com'], + c2DomainBlocklist: [], + blocklistPaths: { + 'malicious.com': { + phishing: {}, + }, + }, + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 1, + name: ListNames.MetaMask, + }, + ]); + }); + it('should not update phishing lists if fetch returns 304', async () => { nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) From 8578fac32f755e8ddcf7988cd257c46ede55ba47 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:49:16 -0500 Subject: [PATCH 32/64] test(fix): add mocks --- .../src/PhishingController.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index fe7fe8c93f4..2b8e8c82b89 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1335,10 +1335,33 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention eth_phishing_detect_config: { + allowlist: [], blocklist: ['example.com/path'], + fuzzylist: [], + }, + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention + phishfort_hotlist: { + blocklist: [], }, + tolerance: 0, + allowlist: [], + version: 0, + lastUpdated: 1, }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) + .reply(200, { data: [] }); + + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 1, }); const controller = getPhishingController(); From 277ada7b6ef6186e49ac77cb91ea4204f0b696fe Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 20:55:28 -0500 Subject: [PATCH 33/64] test(fix): add url to blocklistPaths before calling bypass --- .../src/PhishingController.test.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 2b8e8c82b89..37cd8176bb5 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1373,7 +1373,27 @@ describe('PhishingController', () => { }); it('returns negative result if the hostname+pathname is in the whitelistPaths', async () => { - const controller = getPhishingController(); + const controller = getPhishingController({ + state: { + phishingLists: [ + { + allowlist: [], + blocklist: [], + c2DomainBlocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + fuzzylist: [], + tolerance: 0, + version: 0, + lastUpdated: 0, + name: ListNames.MetaMask, + }, + ], + }, + }); controller.bypass('https://example.com/path'); expect(controller.test('https://example.com/path')).toMatchObject({ result: false, From 6b4742a3d24874eec8a0f5f86c0469d7ee19da83 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 22:30:44 -0500 Subject: [PATCH 34/64] fix: check that blocklistPaths is empty or undefined --- packages/phishing-controller/src/PhishingDetector.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index 10aafaff821..bf900763ac7 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -162,7 +162,7 @@ export class PhishingDetector { const source = domainToParts(fqdn); for (const { blocklistPaths, name, version } of this.#configs) { - if (!blocklistPaths) { + if (!blocklistPaths || Object.keys(blocklistPaths).length === 0) { continue; } const pathMatch = isTerminalPath(url, blocklistPaths); @@ -243,7 +243,7 @@ export class PhishingDetector { */ isPathBlocked(url: string): boolean { for (const { blocklistPaths } of this.#configs) { - if (!blocklistPaths) { + if (!blocklistPaths || Object.keys(blocklistPaths).length === 0) { continue; } const pathMatch = isTerminalPath(url, blocklistPaths); From 09fbe6197fb5c60b457794e790f60eac8d054ebf Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 22:31:11 -0500 Subject: [PATCH 35/64] refactor: remove old funcs --- packages/phishing-controller/src/utils.ts | 49 +++++++++++------------ 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index aec79f26aca..2a24671b2af 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -74,23 +74,6 @@ const splitStringByPeriod = ( ]; }; -const newURL = (url: string): URL | null => { - try { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - return new URL(urlWithProtocol); - } catch { - return null; - } -}; - -const hasPath = (url: string): boolean => { - const urlObj = newURL(url); - if (!urlObj) { - return false; - } - return urlObj.pathname.split('/').filter(Boolean).length > 0; -}; - /** * Determines which diffs are applicable to the listState, then applies those diffs. * @@ -135,7 +118,10 @@ export const applyDiffs = ( latestDiffTimestamp = timestamp; } - if (targetListType === 'blocklist' && hasPath(url)) { + if ( + targetListType === 'blocklist' && + getHostnameAndPathComponents(url).pathComponents.length > 0 + ) { if (isRemoval) { deleteFromTrie(url, listState.blocklistPaths); } else { @@ -278,10 +264,22 @@ export const processConfigs = ( return false; } }) - .map((config) => ({ - ...config, - ...getDefaultPhishingDetectorConfig(config), - })); + .map((config) => { + // If config already has blocklistPaths, use it as-is + // Otherwise, process the blocklist to separate hostname-only from hostname+path entries + if (config.blocklistPaths !== undefined) { + return { + ...getDefaultPhishingDetectorConfig({}), + ...config, + }; + } + + const processedConfig = getDefaultPhishingDetectorConfig(config); + return { + ...config, + ...processedConfig, + }; + }); }; /** @@ -378,11 +376,12 @@ export const getHostnameFromWebUrl = (url: string): [string, boolean] => { }; export const getPathnameFromUrl = (url: string): string => { - const urlObj = newURL(url); - if (!urlObj) { + try { + const { pathname } = new URL(url); + return pathname; + } catch { return ''; } - return urlObj.pathname; }; export const getHostnameAndPathComponents = ( From 598b78a44c419342606bd310255abbec85987eb1 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 22:31:42 -0500 Subject: [PATCH 36/64] test: check tests path-based blocking version --- .../src/PhishingDetector.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/phishing-controller/src/PhishingDetector.test.ts b/packages/phishing-controller/src/PhishingDetector.test.ts index 38e2f2b8b67..00036609b10 100644 --- a/packages/phishing-controller/src/PhishingDetector.test.ts +++ b/packages/phishing-controller/src/PhishingDetector.test.ts @@ -1223,6 +1223,68 @@ describe('PhishingDetector', () => { ); }); }); + + describe('path-based blocking', () => { + it('blocks a domain with path when version is defined', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + name: 'test-config', + version: 1, + tolerance: 0, + }, + ], + async ({ detector }) => { + const { result, type, name, version } = detector.check( + 'https://example.com/path', + ); + expect(result).toBe(true); + expect(type).toBe(PhishingDetectorResultType.Blocklist); + expect(name).toBe('test-config'); + expect(version).toBe('1'); + }, + ); + }); + + it('blocks a domain with path when version is undefined', async () => { + await withPhishingDetector( + [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + blocklistPaths: { + 'malicious.com': { + phishing: {}, + }, + }, + name: 'test-config', + // version is undefined + tolerance: 0, + }, + ], + async ({ detector }) => { + const { result, type, name, version } = detector.check( + 'https://malicious.com/phishing', + ); + expect(result).toBe(true); + expect(type).toBe(PhishingDetectorResultType.Blocklist); + expect(name).toBe('test-config'); + expect(version).toBe(undefined); + }, + ); + }); + }); + }); + }); describe('isMaliciousC2Domain', () => { From 4f98f3114203964e182d34fac22f28984b287d26 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 22:31:50 -0500 Subject: [PATCH 37/64] test: isPathBlocked --- .../src/PhishingDetector.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/packages/phishing-controller/src/PhishingDetector.test.ts b/packages/phishing-controller/src/PhishingDetector.test.ts index 00036609b10..58902534b51 100644 --- a/packages/phishing-controller/src/PhishingDetector.test.ts +++ b/packages/phishing-controller/src/PhishingDetector.test.ts @@ -1285,6 +1285,91 @@ describe('PhishingDetector', () => { }); }); + describe('isPathBlocked', () => { + it('returns false if blocklistPaths is empty', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: {}, + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isPathBlocked('https://example.com'); + expect(result).toBe(false); + }, + ); + }); + + it('returns false if blocklistPaths is not defined', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isPathBlocked('https://example.com'); + expect(result).toBe(false); + }, + ); + }); + + it('returns true if URL matches a blocked path with version defined', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + name: 'test-config', + version: 1, + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isPathBlocked('https://example.com/path'); + expect(result).toBe(true); + }, + ); + }); + + it('returns true if URL matches a blocked path with version undefined', async () => { + await withPhishingDetector( + [ + { + blocklist: [], + fuzzylist: [], + blocklistPaths: { + 'malicious.com': { + phishing: {}, + }, + }, + name: 'test-config', + // version is undefined + tolerance: 2, + }, + ], + async ({ detector }) => { + const result = detector.isPathBlocked( + 'https://malicious.com/phishing', + ); + expect(result).toBe(true); + }, + ); + }); }); describe('isMaliciousC2Domain', () => { From f8eed59ed899a3ee94dd926d7cac0319be7408df Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 23:20:57 -0500 Subject: [PATCH 38/64] fix: update getDefaultPhishingDetectorConfig --- packages/phishing-controller/src/utils.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 2a24671b2af..43719781748 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -238,12 +238,18 @@ export const getDefaultPhishingDetectorConfig = ({ c2DomainBlocklist?: string[]; fuzzylist?: string[]; tolerance?: number; -}): PhishingDetectorConfiguration => ({ - allowlist: processDomainList(allowlist), - blocklist: processDomainList(blocklist), - fuzzylist: processDomainList(fuzzylist), - tolerance, -}); +}): PhishingDetectorConfiguration => { + const { blocklist: separatedBlocklist, blocklistPaths } = + separateBlocklistEntries(blocklist); + + return { + allowlist: processDomainList(allowlist), + blocklist: processDomainList(separatedBlocklist), + blocklistPaths, + fuzzylist: processDomainList(fuzzylist), + tolerance, + }; +}; /** * Processes the configurations for the phishing detector, filtering out any invalid configs. From 2cd81466c2f00cc7e8796ed44beac60d21824899 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Wed, 17 Sep 2025 23:28:28 -0500 Subject: [PATCH 39/64] fix: revert processConfigs --- packages/phishing-controller/src/utils.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 43719781748..be896e7acc6 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -270,22 +270,10 @@ export const processConfigs = ( return false; } }) - .map((config) => { - // If config already has blocklistPaths, use it as-is - // Otherwise, process the blocklist to separate hostname-only from hostname+path entries - if (config.blocklistPaths !== undefined) { - return { - ...getDefaultPhishingDetectorConfig({}), - ...config, - }; - } - - const processedConfig = getDefaultPhishingDetectorConfig(config); - return { - ...config, - ...processedConfig, - }; - }); + .map((config) => ({ + ...config, + ...getDefaultPhishingDetectorConfig(config), + })); }; /** From 03714bca1b45ff3c1f602054b7e1a97408e2651a Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 18 Sep 2025 10:53:15 -0500 Subject: [PATCH 40/64] fix: remove blocklist separation in getDefaultPhishingDetectorConfig - We can assume that whenever config is given to this function, that separateBlocklistEntries has already been called so this would be unnecessary to do. --- packages/phishing-controller/src/utils.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index be896e7acc6..e12c2620ac4 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -239,13 +239,11 @@ export const getDefaultPhishingDetectorConfig = ({ fuzzylist?: string[]; tolerance?: number; }): PhishingDetectorConfiguration => { - const { blocklist: separatedBlocklist, blocklistPaths } = - separateBlocklistEntries(blocklist); - return { allowlist: processDomainList(allowlist), - blocklist: processDomainList(separatedBlocklist), - blocklistPaths, + // We can assume that blocklist is already separated into hostname-only entries + // and hostname+path entries so we do not need to separate it again. + blocklist: processDomainList(blocklist), fuzzylist: processDomainList(fuzzylist), tolerance, }; From 80882f74039a8f02bae5136a705c5303bb101d75 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 18 Sep 2025 10:53:26 -0500 Subject: [PATCH 41/64] chore: redundant return variable --- packages/phishing-controller/src/PhishingDetector.ts | 1 - packages/phishing-controller/src/utils.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index bf900763ac7..416e433b0cd 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -84,7 +84,6 @@ export class PhishingDetector { getDefaultPhishingDetectorConfig({ allowlist: opts.whitelist, blocklist: opts.blacklist, - c2DomainBlocklist: opts.c2DomainBlocklist, fuzzylist: opts.fuzzylist, tolerance: opts.tolerance, }), diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index e12c2620ac4..75a6c8e9a52 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -235,7 +235,6 @@ export const getDefaultPhishingDetectorConfig = ({ }: { allowlist?: string[]; blocklist?: string[]; - c2DomainBlocklist?: string[]; fuzzylist?: string[]; tolerance?: number; }): PhishingDetectorConfiguration => { From 5de9179ff4ec4218fd461c0ad30cba87c14a39fb Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 18 Sep 2025 16:09:59 -0500 Subject: [PATCH 42/64] test: update tests --- packages/phishing-controller/src/utils.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 67606a5fb71..3771841ccc4 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -6,6 +6,7 @@ import { domainToParts, fetchTimeNow, generateParentDomains, + getDefaultPhishingDetectorConfig, getHostnameAndPathComponents, getHostnameFromUrl, getHostnameFromWebUrl, @@ -554,6 +555,11 @@ describe('processConfigs', () => { { allowlist: ['example.com'], blocklist: ['sub.example.com'], + blocklistPaths: { + 'malicious.com': { + path: {}, + }, + }, fuzzylist: ['fuzzy.example.com'], tolerance: 2, version: 1, @@ -564,6 +570,14 @@ describe('processConfigs', () => { const result = processConfigs(configs); expect(result).toHaveLength(1); + expect(result[0].blocklist).toStrictEqual( + Array.of(['com', 'example', 'sub']), + ); + expect(result[0].blocklistPaths).toStrictEqual({ + 'malicious.com': { + path: {}, + }, + }); expect(result[0].name).toBe('MetaMask'); expect(console.error).not.toHaveBeenCalled(); From ed9a83660e982751c2d948a261ef18d6bad327a5 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 18 Sep 2025 16:40:02 -0500 Subject: [PATCH 43/64] fix: lint issues --- eslint-warning-thresholds.json | 8 +- .../phishing-controller/src/PathTrie.test.ts | 4 +- packages/phishing-controller/src/PathTrie.ts | 63 +++++++----- .../src/PhishingController.ts | 2 +- .../src/PhishingDetector.test.ts | 2 +- .../src/PhishingDetector.ts | 10 +- .../phishing-controller/src/utils.test.ts | 11 ++- packages/phishing-controller/src/utils.ts | 95 +++++++++---------- 8 files changed, 101 insertions(+), 94 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index cfbf5259bc8..f7c362fcf59 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -300,11 +300,6 @@ "jsdoc/check-tag-names": 39, "jsdoc/tag-lines": 1 }, - "packages/phishing-controller/src/PhishingDetector.ts": { - "@typescript-eslint/no-unused-vars": 1, - "@typescript-eslint/prefer-readonly": 2, - "jsdoc/tag-lines": 2 - }, "packages/phishing-controller/src/tests/utils.ts": { "@typescript-eslint/no-unused-vars": 1 }, @@ -312,8 +307,7 @@ "import-x/namespace": 5 }, "packages/phishing-controller/src/utils.ts": { - "@typescript-eslint/no-unsafe-enum-comparison": 1, - "@typescript-eslint/no-unused-vars": 1 + "@typescript-eslint/no-unsafe-enum-comparison": 1 }, "packages/polling-controller/src/AbstractPollingController.ts": { "@typescript-eslint/prefer-readonly": 1 diff --git a/packages/phishing-controller/src/PathTrie.test.ts b/packages/phishing-controller/src/PathTrie.test.ts index c33edb317b3..14e7e98f387 100644 --- a/packages/phishing-controller/src/PathTrie.test.ts +++ b/packages/phishing-controller/src/PathTrie.test.ts @@ -2,12 +2,12 @@ import { deleteFromTrie, isTerminalPath, insertToTrie, - PathTrie, + type PathTrie, } from './PathTrie'; const emptyPathTrie: PathTrie = {}; -describe.only('PathTrie', () => { +describe('PathTrie', () => { describe('insertToTrie', () => { let pathTrie: PathTrie; diff --git a/packages/phishing-controller/src/PathTrie.ts b/packages/phishing-controller/src/PathTrie.ts index 9412c5e7f33..81d59897180 100644 --- a/packages/phishing-controller/src/PathTrie.ts +++ b/packages/phishing-controller/src/PathTrie.ts @@ -6,25 +6,29 @@ export type PathNode = { export type PathTrie = Record; +const isTerminal = (node: PathNode): boolean => { + return Object.keys(node).length === 0; +}; + /** - * Insert a URL into the trie, mutating `pathTrie` in place. - * - If an ancestor path already exists as a terminal ({}), do nothing. - * - If inserting an ancestor of existing entries, prune descendants by setting that node to {}. - * - If no path segments exist (bare host or "/"), do nothing. + * Insert a URL into the trie. + * + * @param url - The URL to insert into the trie. + * @param pathTrie - The trie to insert the URL into. */ export const insertToTrie = (url: string, pathTrie: PathTrie) => { - var { hostname, pathComponents } = getHostnameAndPathComponents(url); + const { hostname, pathComponents } = getHostnameAndPathComponents(url); if (pathComponents.length === 0 || !hostname) { return; } - hostname = hostname.toLowerCase(); - if (!pathTrie[hostname]) { - pathTrie[hostname] = {} as PathNode; + const lowerHostname = hostname.toLowerCase(); + if (!pathTrie[lowerHostname]) { + pathTrie[lowerHostname] = {} as PathNode; } - let curr: PathNode = pathTrie[hostname]; + let curr: PathNode = pathTrie[lowerHostname]; for (let i = 0; i < pathComponents.length; i++) { const pathComponent = pathComponents[i]; const isLast = i === pathComponents.length - 1; @@ -56,20 +60,25 @@ export const insertToTrie = (url: string, pathTrie: PathTrie) => { } }; +/** + * Delete a URL from the trie. + * + * @param url - The URL to delete from the trie. + * @param pathTrie - The trie to delete the URL from. + */ export const deleteFromTrie = (url: string, pathTrie: PathTrie) => { - var { hostname, pathComponents } = getHostnameAndPathComponents(url); + const { hostname, pathComponents } = getHostnameAndPathComponents(url); - if (pathComponents.length === 0 || !pathTrie[hostname]) { + const lowerHostname = hostname.toLowerCase(); + if (pathComponents.length === 0 || !pathTrie[lowerHostname]) { return; } const pathToNode: { node: PathNode; key: string }[] = [ - { node: pathTrie, key: hostname }, + { node: pathTrie, key: lowerHostname }, ]; - let curr: PathNode = pathTrie[hostname]; - for (let i = 0; i < pathComponents.length; i++) { - const pathComponent = pathComponents[i]; - + let curr: PathNode = pathTrie[lowerHostname]; + for (const pathComponent of pathComponents) { if (!curr[pathComponent]) { return; } @@ -90,17 +99,23 @@ export const deleteFromTrie = (url: string, pathTrie: PathTrie) => { } }; +/** + * Check if a URL is a terminal path i.e. the last path component is a terminal node. + * + * @param url - The URL to check. + * @param pathTrie - The trie to check the URL in. + * @returns True if the URL is a terminal path, false otherwise. + */ export const isTerminalPath = (url: string, pathTrie: PathTrie): boolean => { - var { hostname, pathComponents } = getHostnameAndPathComponents(url); + const { hostname, pathComponents } = getHostnameAndPathComponents(url); - hostname = hostname.toLowerCase(); - if (pathComponents.length === 0 || !hostname || !pathTrie[hostname]) { + const lowerHostname = hostname.toLowerCase(); + if (pathComponents.length === 0 || !hostname || !pathTrie[lowerHostname]) { return false; } - let curr: PathNode = pathTrie[hostname]; - for (let i = 0; i < pathComponents.length; i++) { - const pathComponent = pathComponents[i]; + let curr: PathNode = pathTrie[lowerHostname]; + for (const pathComponent of pathComponents) { if (!curr[pathComponent]) { return false; } @@ -108,7 +123,3 @@ export const isTerminalPath = (url: string, pathTrie: PathTrie): boolean => { } return isTerminal(curr); }; - -const isTerminal = (node: PathNode): boolean => { - return Object.keys(node).length === 0; -}; diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index a4ae692655a..6346a440e87 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -11,6 +11,7 @@ import { } from '@metamask/controller-utils'; import { toASCII } from 'punycode/punycode.js'; +import { type PathTrie } from './PathTrie'; import { PhishingDetector } from './PhishingDetector'; import { PhishingDetectorResultType, @@ -33,7 +34,6 @@ import { getPathnameFromUrl, separateBlocklistEntries, } from './utils'; -import { PathTrie } from './PathTrie'; export const PHISHING_CONFIG_BASE_URL = 'https://phishing-detection.api.cx.metamask.io'; diff --git a/packages/phishing-controller/src/PhishingDetector.test.ts b/packages/phishing-controller/src/PhishingDetector.test.ts index 58902534b51..d1c4f5a934d 100644 --- a/packages/phishing-controller/src/PhishingDetector.test.ts +++ b/packages/phishing-controller/src/PhishingDetector.test.ts @@ -1278,7 +1278,7 @@ describe('PhishingDetector', () => { expect(result).toBe(true); expect(type).toBe(PhishingDetectorResultType.Blocklist); expect(name).toBe('test-config'); - expect(version).toBe(undefined); + expect(version).toBeUndefined(); }, ); }); diff --git a/packages/phishing-controller/src/PhishingDetector.ts b/packages/phishing-controller/src/PhishingDetector.ts index 416e433b0cd..9b8ad09acbb 100644 --- a/packages/phishing-controller/src/PhishingDetector.ts +++ b/packages/phishing-controller/src/PhishingDetector.ts @@ -1,5 +1,6 @@ import { distance } from 'fastest-levenshtein'; +import { isTerminalPath, type PathTrie } from './PathTrie'; import { PhishingDetectorResultType, type PhishingDetectorResult, @@ -15,7 +16,6 @@ import { processConfigs, sha256Hash, } from './utils'; -import { isTerminalPath, PathTrie } from './PathTrie'; export type LegacyPhishingDetectorList = { whitelist?: string[]; @@ -59,9 +59,9 @@ export type PhishingDetectorConfiguration = { }; export class PhishingDetector { - #configs: PhishingDetectorConfiguration[]; + readonly #configs: PhishingDetectorConfiguration[]; - #legacyConfig: boolean; + readonly #legacyConfig: boolean; /** * Construct a phishing detector, which can check whether origins are known @@ -149,7 +149,7 @@ export class PhishingDetector { let domain; try { domain = new URL(url).hostname; - } catch (error) { + } catch { return { result: false, type: PhishingDetectorResultType.All, @@ -258,7 +258,6 @@ export class PhishingDetector { * Checks if a URL is blocked against the hashed request blocklist. * This is done by hashing the URL's hostname and checking it against the hashed request blocklist. * - * * @param urlString - The URL to check. * @returns An object indicating if the URL is blocked and relevant metadata. */ @@ -328,6 +327,7 @@ export class PhishingDetector { /** * Runs a regex match to determine if a string is a IPFS CID + * * @returns Regex string for IPFS CID */ function ipfsCidRegex() { diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 3771841ccc4..4786637cd8e 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -1,12 +1,15 @@ import * as sinon from 'sinon'; -import { ListKeys, ListNames } from './PhishingController'; +import { + ListKeys, + ListNames, + type PhishingListState, +} from './PhishingController'; import { applyDiffs, domainToParts, fetchTimeNow, generateParentDomains, - getDefaultPhishingDetectorConfig, getHostnameAndPathComponents, getHostnameFromUrl, getHostnameFromWebUrl, @@ -271,7 +274,7 @@ describe('applyDiffs', () => { }); describe('adding URLs to blocklistPaths', () => { - let listState: any; + let listState: PhishingListState; beforeEach(() => { listState = { @@ -381,7 +384,7 @@ describe('applyDiffs', () => { }); describe('removing URLs from blocklistPaths', () => { - let listState: any; + let listState: PhishingListState; beforeEach(() => { listState = { diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 75a6c8e9a52..316ed3813c9 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -1,13 +1,13 @@ import { bytesToHex } from '@noble/hashes/utils'; import { sha256 } from 'ethereum-cryptography/sha256'; +import { deleteFromTrie, insertToTrie, type PathTrie } from './PathTrie'; import type { Hotlist, PhishingListState } from './PhishingController'; import { ListKeys, phishingListKeyNameMap } from './PhishingController'; import type { PhishingDetectorList, PhishingDetectorConfiguration, } from './PhishingDetector'; -import { deleteFromTrie, insertToTrie, type PathTrie } from './PathTrie'; const DEFAULT_TOLERANCE = 3; @@ -18,36 +18,6 @@ const DEFAULT_TOLERANCE = 3; */ export const fetchTimeNow = (): number => Math.round(Date.now() / 1000); -/** - * Separates blocklist entries into hostname-only entries and hostname+path entries. - * - * @param blocklist - Array of blocklist entries (hostnames and hostname/path combinations) - * @returns Object containing separated blocklist and blocklistPaths - */ -export const separateBlocklistEntries = ( - blocklist: string[], -): { blocklist: string[]; blocklistPaths: PathTrie } => { - const hostnameOnlyList: string[] = []; - const pathTrie: PathTrie = {}; - - for (const entry of blocklist) { - const { hostname, pathComponents } = getHostnameAndPathComponents(entry); - if (!hostname) { - continue; - } - if (pathComponents.length === 0) { - hostnameOnlyList.push(hostname.toLowerCase()); - } else { - insertToTrie(entry, pathTrie); - } - } - - return { - blocklist: hostnameOnlyList, - blocklistPaths: pathTrie, - }; -}; - /** * Rounds a Unix timestamp down to the nearest minute. * @@ -74,6 +44,24 @@ const splitStringByPeriod = ( ]; }; +export const getHostnameAndPathComponents = ( + url: string, +): { hostname: string; pathComponents: string[] } => { + const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; + try { + const { hostname, pathname } = new URL(urlWithProtocol); + return { + hostname: hostname.toLowerCase(), + pathComponents: pathname.split('/').filter(Boolean), + }; + } catch { + return { + hostname: '', + pathComponents: [], + }; + } +}; + /** * Determines which diffs are applicable to the listState, then applies those diffs. * @@ -201,7 +189,7 @@ export function validateConfig( export const domainToParts = (domain: string) => { try { return domain.split('.').reverse(); - } catch (e) { + } catch { throw new Error(JSON.stringify(domain)); } }; @@ -222,7 +210,6 @@ export const processDomainList = (list: string[]) => { * @param override - the optional override for the configuration. * @param override.allowlist - the optional allowlist to override. * @param override.blocklist - the optional blocklist to override. - * @param override.c2DomainBlocklist - the optional c2DomainBlocklist to override. * @param override.fuzzylist - the optional fuzzylist to override. * @param override.tolerance - the optional tolerance to override. * @returns the default phishing detector configuration. @@ -375,22 +362,34 @@ export const getPathnameFromUrl = (url: string): string => { } }; -export const getHostnameAndPathComponents = ( - url: string, -): { hostname: string; pathComponents: string[] } => { - const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`; - try { - const { hostname, pathname } = new URL(urlWithProtocol); - return { - hostname: hostname.toLowerCase(), - pathComponents: pathname.split('/').filter(Boolean), - }; - } catch { - return { - hostname: '', - pathComponents: [], - }; +/** + * Separates blocklist entries into hostname-only entries and hostname+path entries. + * + * @param blocklist - Array of blocklist entries (hostnames and hostname/path combinations) + * @returns Object containing separated blocklist and blocklistPaths + */ +export const separateBlocklistEntries = ( + blocklist: string[], +): { blocklist: string[]; blocklistPaths: PathTrie } => { + const hostnameOnlyList: string[] = []; + const pathTrie: PathTrie = {}; + + for (const entry of blocklist) { + const { hostname, pathComponents } = getHostnameAndPathComponents(entry); + if (!hostname) { + continue; + } + if (pathComponents.length === 0) { + hostnameOnlyList.push(hostname.toLowerCase()); + } else { + insertToTrie(entry, pathTrie); + } } + + return { + blocklist: hostnameOnlyList, + blocklistPaths: pathTrie, + }; }; /** From 302d40e79f575b81a14c1da82db98e7e23192cc6 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Tue, 23 Sep 2025 13:43:39 -0500 Subject: [PATCH 44/64] fix: update PhishingStalelist to use flat structure --- .../src/PhishingController.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 6346a440e87..a77a192d5c6 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -105,15 +105,18 @@ export type C2DomainBlocklistResponse = { * @type PhishingStalelist * * type defining expected type of the stalelist.json file. - * @property eth_phishing_detect_config - Stale list sourced from eth-phishing-detect's config.json. + * @property allowlist - List of approved origins. + * @property blocklist - List of unapproved origins. + * @property fuzzylist - List of fuzzy-matched unapproved origins. * @property tolerance - Fuzzy match tolerance level * @property lastUpdated - Timestamp of last update. * @property version - Stalelist data structure iteration. */ export type PhishingStalelist = { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention eth_phishing_detect_config: Record; + allowlist: string[]; + blocklist: string[]; + fuzzylist: string[]; tolerance: number; version: number; lastUpdated: number; @@ -1006,17 +1009,16 @@ export class PhishingController extends BaseController< return; } - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - const { eth_phishing_detect_config, ...partialState } = - stalelistResponse.data; - const { blocklist, blocklistPaths } = separateBlocklistEntries( - eth_phishing_detect_config.blocklist, + stalelistResponse.data.blocklist, ); const metamaskListState: PhishingListState = { - ...eth_phishing_detect_config, - ...partialState, + allowlist: stalelistResponse.data.allowlist, + fuzzylist: stalelistResponse.data.fuzzylist, + tolerance: stalelistResponse.data.tolerance, + version: stalelistResponse.data.version, + lastUpdated: stalelistResponse.data.lastUpdated, blocklist, blocklistPaths, c2DomainBlocklist: c2DomainBlocklistResponse From 477f90ce98bc2b1936395a4e26e15a95c64496f4 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Tue, 23 Sep 2025 13:46:35 -0500 Subject: [PATCH 45/64] test: phishing list is replaced if different in maybeUpdateState --- .../src/PhishingController.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 37cd8176bb5..25a4e134ffa 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -412,6 +412,85 @@ describe('PhishingController', () => { await controller.maybeUpdateState(); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); }); + + it('replaces existing phishing lists with completely new list from phishing detection API', async () => { + const controller = new PhishingController({ + messenger: getRestrictedMessenger(), + stalelistRefreshInterval: 10, + state: { + phishingLists: [ + { + allowlist: ['initial-safe-site.com'], + blocklist: ['new-phishing-site.com'], + blocklistPaths: {}, + c2DomainBlocklist: [], + fuzzylist: ['new-fuzzy-site.com'], + tolerance: 2, + version: 1, + lastUpdated: 1, + name: ListNames.MetaMask, + }, + ], + whitelist: [], + whitelistPaths: [], + hotlistLastFetched: 0, + stalelistLastFetched: 0, + c2DomainBlocklistLastFetched: 0, + urlScanCache: {}, + }, + }); + + nock.cleanAll(); + nock(PHISHING_CONFIG_BASE_URL) + .get(METAMASK_STALELIST_FILE) + .reply(200, { + data: { + blocklist: ['example.com/path'], + fuzzylist: ['new-fuzzy-site.com'], + allowlist: ['new-safe-site.com'], + tolerance: 2, + version: 2, + lastUpdated: 2, + }, + }) + .get(`${METAMASK_HOTLIST_DIFF_FILE}/${2}`) + .reply(200, { + data: [], + }); + nock(CLIENT_SIDE_DETECION_BASE_URL) + .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) + .reply(200, { + recentlyAdded: [], + recentlyRemoved: [], + lastFetchedAt: 2, + }); + + // Force the stalelist to be out of date and trigger update + const clock = sinon.useFakeTimers(); + clock.tick(1000 * 10); + + await controller.maybeUpdateState(); + + expect(controller.state.phishingLists).toEqual([ + { + allowlist: ['new-safe-site.com'], + blocklist: [], + blocklistPaths: { + 'example.com': { + path: {}, + }, + }, + c2DomainBlocklist: [], + fuzzylist: ['new-fuzzy-site.com'], + tolerance: 2, + version: 2, + lastUpdated: 2, + name: ListNames.MetaMask, + }, + ]); + + clock.restore(); + }); }); describe('isStalelistOutOfDate', () => { From 6d194f1f6860b44684eacb75223770b973ac4973 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Tue, 23 Sep 2025 13:54:04 -0500 Subject: [PATCH 46/64] fix: remove eth_phishing_detect_config from PhishingListState --- packages/phishing-controller/src/PhishingController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index a77a192d5c6..076d24b5679 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -113,7 +113,6 @@ export type C2DomainBlocklistResponse = { * @property version - Stalelist data structure iteration. */ export type PhishingStalelist = { - eth_phishing_detect_config: Record; allowlist: string[]; blocklist: string[]; fuzzylist: string[]; From 42eb75d7a9f75ba16ebc37938b6b2b7dc656e7d9 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Tue, 23 Sep 2025 13:54:14 -0500 Subject: [PATCH 47/64] test: update tests to use flat structure --- .../src/PhishingController.test.ts | 413 ++++-------------- 1 file changed, 86 insertions(+), 327 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 25a4e134ffa..a929d25101b 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -104,6 +104,7 @@ describe('PhishingController', () => { type: PhishingDetectorResultType.All, }); }); + it('should return false if the URL is in the allowlist', async () => { const allowlistedHostname = 'example.com'; @@ -111,16 +112,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [allowlistedHostname], - blocklist: [], - fuzzylist: [], - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [allowlistedHostname], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -234,18 +228,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - blocklist: ['this-should-not-be-in-default-blocklist.com'], - fuzzylist: [], - allowlist: ['this-should-not-be-in-default-allowlist.com'], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + blocklist: ['this-should-not-be-in-default-blocklist.com'], + fuzzylist: [], + allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, version: 0, lastUpdated: 1, @@ -395,6 +380,7 @@ describe('PhishingController', () => { await controller.maybeUpdateState(); expect(controller.isHotlistOutOfDate()).toBe(false); }); + it('should not have c2DomainBlocklist be out of date immediately after maybeUpdateState is called', async () => { nockScope = nock(CLIENT_SIDE_DETECION_BASE_URL) .get(C2_DOMAIN_BLOCKLIST_ENDPOINT) @@ -772,18 +758,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: ['metamask.io'], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['metamask.io'], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -814,18 +791,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -847,18 +815,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -880,18 +839,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['etnerscan.io'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['etnerscan.io'], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -922,18 +872,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - blocklist: ['xn--myetherallet-4k5fwn.com'], - allowlist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + blocklist: ['xn--myetherallet-4k5fwn.com'], + allowlist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -966,18 +907,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['xn--myetherallet-4k5fwn.com'], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1010,18 +942,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1064,18 +987,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1101,18 +1015,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: ['opensea.io'], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['opensea.io'], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1143,18 +1048,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: ['opensea.io'], - blocklist: [], - fuzzylist: ['opensea.io'], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['opensea.io'], + blocklist: [], + fuzzylist: ['opensea.io'], tolerance: 2, version: 0, lastUpdated: 1, @@ -1185,18 +1081,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: ['opensea.io'], - blocklist: [], - fuzzylist: ['opensea.io'], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: ['opensea.io'], + blocklist: [], + fuzzylist: ['opensea.io'], tolerance: 0, version: 0, lastUpdated: 1, @@ -1221,18 +1108,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['electrum.mx'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['electrum.mx'], + fuzzylist: [], tolerance: 2, version: 0, lastUpdated: 1, @@ -1269,18 +1147,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['electrum.mx'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['electrum.mx'], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1318,18 +1187,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['xn--myetherallet-4k5fwn.com'], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1366,18 +1226,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['xn--myetherallet-4k5fwn.com'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: ['xn--myetherallet-4k5fwn.com'], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1414,20 +1265,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['example.com/path'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, - tolerance: 0, allowlist: [], + blocklist: ['example.com/path'], + fuzzylist: [], + tolerance: 0, version: 0, lastUpdated: 1, }, @@ -1492,20 +1333,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [exampleBlockedUrl], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, - tolerance: 0, allowlist: [], + blocklist: [exampleBlockedUrl], + fuzzylist: [], + tolerance: 0, version: 0, lastUpdated: 1, }, @@ -1557,18 +1388,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [exampleBlockedUrl], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [exampleBlockedUrl], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1622,20 +1444,10 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: ['example.com', 'malicious.com/phishing'], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, - tolerance: 0, allowlist: [], + blocklist: ['example.com', 'malicious.com/phishing'], + fuzzylist: [], + tolerance: 0, version: 0, lastUpdated: 1, }, @@ -1782,18 +1594,9 @@ describe('PhishingController', () => { .delay(100) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -1824,18 +1627,9 @@ describe('PhishingController', () => { .delay(100) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -2411,16 +2205,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -2453,16 +2240,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -2496,16 +2276,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -2536,16 +2309,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [], - blocklist: [], - fuzzylist: [], - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, @@ -2592,16 +2358,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - allowlist: [allowlistedDomain], - blocklist: [], - fuzzylist: [], - }, - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + allowlist: [allowlistedDomain], + blocklist: [], + fuzzylist: [], tolerance: 0, version: 0, lastUpdated: 1, From 89e231bea2980e6d2cc870f5908b7c2dc7ded2bb Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 16:37:10 -0500 Subject: [PATCH 48/64] chore: update lint issues --- eslint-warning-thresholds.json | 6 +----- .../src/PhishingController.test.ts | 19 +++++-------------- .../src/PhishingController.ts | 12 ++++++++---- .../phishing-controller/src/tests/utils.ts | 2 +- 4 files changed, 15 insertions(+), 24 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 5f047831297..e4bbe63e2a8 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -297,11 +297,7 @@ "jsdoc/tag-lines": 1 }, "packages/phishing-controller/src/PhishingController.ts": { - "jsdoc/check-tag-names": 39, - "jsdoc/tag-lines": 1 - }, - "packages/phishing-controller/src/tests/utils.ts": { - "@typescript-eslint/no-unused-vars": 1 + "jsdoc/check-tag-names": 39 }, "packages/phishing-controller/src/utils.test.ts": { "import-x/namespace": 5 diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index a929d25101b..b87610e7345 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -146,18 +146,9 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - eth_phishing_detect_config: { - blocklist: [], - fuzzylist: [], - allowlist: [], - }, - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention - phishfort_hotlist: { - blocklist: [], - }, + blocklist: [], + fuzzylist: [], + allowlist: [], tolerance: 0, version: 0, }, @@ -426,7 +417,7 @@ describe('PhishingController', () => { }, }); - nock.cleanAll(); + cleanAll(); nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .reply(200, { @@ -457,7 +448,7 @@ describe('PhishingController', () => { await controller.maybeUpdateState(); - expect(controller.state.phishingLists).toEqual([ + expect(controller.state.phishingLists).toStrictEqual([ { allowlist: ['new-safe-site.com'], blocklist: [], diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 076d24b5679..3c6bdbd4158 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -165,8 +165,6 @@ export type HotlistDiff = { isRemoval?: boolean; }; -// TODO: Either fix this lint violation or explain why it's necessary to ignore. -// eslint-disable-next-line @typescript-eslint/naming-convention export type DataResultWrapper = { data: T; }; @@ -262,6 +260,7 @@ const metadata: StateMetadata = { /** * Get a default empty state for the controller. + * * @returns The default empty state. */ const getDefaultState = (): PhishingControllerState => { @@ -280,8 +279,13 @@ const getDefaultState = (): PhishingControllerState => { * @type PhishingControllerState * * Phishing controller state - * @property phishing - eth-phishing-detect configuration - * @property whitelist - array of temporarily-approved origins + * phishingLists - array of phishing lists + * whitelist - origins that bypass the phishing detector + * whitelistPaths - origins with paths that bypass the phishing detector + * hotlistLastFetched - timestamp of the last hotlist fetch + * stalelistLastFetched - timestamp of the last stalelist fetch + * c2DomainBlocklistLastFetched - timestamp of the last c2 domain blocklist fetch + * urlScanCache - cache of scan results */ export type PhishingControllerState = { phishingLists: PhishingListState[]; diff --git a/packages/phishing-controller/src/tests/utils.ts b/packages/phishing-controller/src/tests/utils.ts index 75e1128213a..96539e743e7 100644 --- a/packages/phishing-controller/src/tests/utils.ts +++ b/packages/phishing-controller/src/tests/utils.ts @@ -11,7 +11,7 @@ export const formatHostnameToUrl = (hostname: string): string => { let url = ''; try { url = new URL(hostname).href; - } catch (e) { + } catch { url = new URL(['https://', hostname].join('')).href; } return url; From 655bc5517f227584f85c0b6f990b51a74dbb7fed Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 16:59:38 -0500 Subject: [PATCH 49/64] refactor: update to handle blocklistPaths as targetListState --- packages/phishing-controller/src/utils.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 316ed3813c9..e62757ffd9f 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -106,22 +106,18 @@ export const applyDiffs = ( latestDiffTimestamp = timestamp; } - if ( - targetListType === 'blocklist' && - getHostnameAndPathComponents(url).pathComponents.length > 0 - ) { - if (isRemoval) { + if (isRemoval) { + if (targetListType === 'blocklistPaths') { deleteFromTrie(url, listState.blocklistPaths); } else { - insertToTrie(url, listState.blocklistPaths); + listSets[targetListType].delete(url); } - continue; - } - - if (isRemoval) { - listSets[targetListType].delete(url); } else { - listSets[targetListType].add(url); + if (targetListType === 'blocklistPaths') { + insertToTrie(url, listState.blocklistPaths); + } else { + listSets[targetListType].add(url); + } } } From 6b66b65c28203e17e8012b019de54965790478c8 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 16:59:57 -0500 Subject: [PATCH 50/64] test: update applyDiffs targetList --- packages/phishing-controller/src/utils.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 4786637cd8e..9b884a42e06 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -261,13 +261,13 @@ describe('applyDiffs', () => { describe('blocklistPaths handling', () => { const newAddDiff = (url: string) => ({ - targetList: 'eth_phishing_detect_config.blocklist' as const, + targetList: 'eth_phishing_detect_config.blocklistPaths' as const, url, timestamp: 1000000000, }); const newRemoveDiff = (url: string) => ({ - targetList: 'eth_phishing_detect_config.blocklist' as const, + targetList: 'eth_phishing_detect_config.blocklistPaths' as const, url, timestamp: 1000000001, isRemoval: true, From 791a4136654e0def8176485678d6b0b2a9d2271d Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:19:49 -0500 Subject: [PATCH 51/64] util: convert convertListToTrie --- packages/phishing-controller/src/PathTrie.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/phishing-controller/src/PathTrie.ts b/packages/phishing-controller/src/PathTrie.ts index 81d59897180..3696c8ebdef 100644 --- a/packages/phishing-controller/src/PathTrie.ts +++ b/packages/phishing-controller/src/PathTrie.ts @@ -123,3 +123,21 @@ export const isTerminalPath = (url: string, pathTrie: PathTrie): boolean => { } return isTerminal(curr); }; + +/** + * Converts an array of paths into a PathTrie structure. This assumes that the + * entries are only hostname+pathname format. + * + * @param paths - Array of hostname+pathname + * @returns PathTrie structure for efficient path checking + */ +export const convertListToTrie = (paths: string[] = []): PathTrie => { + const pathTrie: PathTrie = {}; + if (!paths || !Array.isArray(paths)) { + return pathTrie; + } + for (const path of paths) { + insertToTrie(path, pathTrie); + } + return pathTrie; +}; From 7b73abcd517bc8fe7202c44d93031b5c7a330d4c Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:20:28 -0500 Subject: [PATCH 52/64] test: convertListToTrie --- .../phishing-controller/src/PathTrie.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/phishing-controller/src/PathTrie.test.ts b/packages/phishing-controller/src/PathTrie.test.ts index 14e7e98f387..105fc5a6016 100644 --- a/packages/phishing-controller/src/PathTrie.test.ts +++ b/packages/phishing-controller/src/PathTrie.test.ts @@ -1,4 +1,5 @@ import { + convertListToTrie, deleteFromTrie, isTerminalPath, insertToTrie, @@ -223,3 +224,79 @@ describe('PathTrie', () => { }); }); }); + +describe('convertListToTrie', () => { + it('converts array of URLs with paths to PathTrie structure', () => { + const paths = [ + 'example.com/path1', + 'example.com/path2/subpath', + 'another.com/different/path', + ]; + + const result = convertListToTrie(paths); + + expect(result).toStrictEqual({ + 'example.com': { + path1: {}, + path2: { + subpath: {}, + }, + }, + 'another.com': { + different: { + path: {}, + }, + }, + }); + }); + + it('handles empty array', () => { + const result = convertListToTrie([]); + expect(result).toStrictEqual({}); + }); + + it('handles undefined input gracefully', () => { + const result = convertListToTrie(undefined as any); + expect(result).toStrictEqual({}); + }); + + it('handles non-array input gracefully', () => { + const result = convertListToTrie('not-an-array' as any); + expect(result).toStrictEqual({}); + }); + + it('filters out invalid URLs', () => { + const paths = [ + 'valid.com/path', + '', // empty string + 'invalid-url-without-domain', + ]; + + const result = convertListToTrie(paths); + + expect(result).toStrictEqual({ + 'valid.com': { + path: {}, + }, + }); + }); + + it('handles multiple paths on same domain correctly', () => { + const paths = [ + 'example.com/path1', + 'example.com/path2/subpath', + 'example.com/path1/deeper', + ]; + + const result = convertListToTrie(paths); + + expect(result).toStrictEqual({ + 'example.com': { + path1: {}, + path2: { + subpath: {}, + }, + }, + }); + }); +}); From 80d20787284373656a88622783b8931d3ad80bfe Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:20:54 -0500 Subject: [PATCH 53/64] refactor: blocklistPaths in controller --- .../phishing-controller/src/PhishingController.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 3c6bdbd4158..67b44ca8290 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -11,7 +11,7 @@ import { } from '@metamask/controller-utils'; import { toASCII } from 'punycode/punycode.js'; -import { type PathTrie } from './PathTrie'; +import { type PathTrie, convertListToTrie } from './PathTrie'; import { PhishingDetector } from './PhishingDetector'; import { PhishingDetectorResultType, @@ -65,6 +65,7 @@ export const C2_DOMAIN_BLOCKLIST_URL = `${CLIENT_SIDE_DETECION_BASE_URL}${C2_DOM export type ListTypes = | 'fuzzylist' | 'blocklist' + | 'blocklistPaths' | 'allowlist' | 'c2DomainBlocklist'; @@ -106,7 +107,8 @@ export type C2DomainBlocklistResponse = { * * type defining expected type of the stalelist.json file. * @property allowlist - List of approved origins. - * @property blocklist - List of unapproved origins. + * @property blocklist - List of unapproved origins (hostname-only entries). + * @property blocklistPaths - List of unapproved origins with paths (hostname + path entries). * @property fuzzylist - List of fuzzy-matched unapproved origins. * @property tolerance - Fuzzy match tolerance level * @property lastUpdated - Timestamp of last update. @@ -115,6 +117,7 @@ export type C2DomainBlocklistResponse = { export type PhishingStalelist = { allowlist: string[]; blocklist: string[]; + blocklistPaths: string[]; fuzzylist: string[]; tolerance: number; version: number; @@ -1012,18 +1015,14 @@ export class PhishingController extends BaseController< return; } - const { blocklist, blocklistPaths } = separateBlocklistEntries( - stalelistResponse.data.blocklist, - ); - const metamaskListState: PhishingListState = { allowlist: stalelistResponse.data.allowlist, fuzzylist: stalelistResponse.data.fuzzylist, tolerance: stalelistResponse.data.tolerance, version: stalelistResponse.data.version, lastUpdated: stalelistResponse.data.lastUpdated, - blocklist, - blocklistPaths, + blocklist: stalelistResponse.data.blocklist, + blocklistPaths: convertListToTrie(stalelistResponse.data.blocklistPaths), c2DomainBlocklist: c2DomainBlocklistResponse ? c2DomainBlocklistResponse.recentlyAdded : [], From c0eb43b11cbf8d46769421feedb8276487971b99 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:21:06 -0500 Subject: [PATCH 54/64] test: update for blocklistPaths --- .../src/PhishingController.test.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index b87610e7345..e8ce1bf272c 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -114,6 +114,7 @@ describe('PhishingController', () => { data: { allowlist: [allowlistedHostname], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -147,6 +148,7 @@ describe('PhishingController', () => { .reply(200, { data: { blocklist: [], + blocklistPaths: [], fuzzylist: [], allowlist: [], tolerance: 0, @@ -220,6 +222,7 @@ describe('PhishingController', () => { .reply(200, { data: { blocklist: ['this-should-not-be-in-default-blocklist.com'], + blocklistPaths: [], fuzzylist: [], allowlist: ['this-should-not-be-in-default-allowlist.com'], tolerance: 0, @@ -422,7 +425,8 @@ describe('PhishingController', () => { .get(METAMASK_STALELIST_FILE) .reply(200, { data: { - blocklist: ['example.com/path'], + blocklist: [], + blocklistPaths: ['example.com/path'], fuzzylist: ['new-fuzzy-site.com'], allowlist: ['new-safe-site.com'], tolerance: 2, @@ -751,6 +755,7 @@ describe('PhishingController', () => { data: { allowlist: ['metamask.io'], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -784,6 +789,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -808,6 +814,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -832,6 +839,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: ['etnerscan.io'], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -864,6 +872,7 @@ describe('PhishingController', () => { .reply(200, { data: { blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], allowlist: [], fuzzylist: [], tolerance: 0, @@ -900,6 +909,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -935,6 +945,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -980,6 +991,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -1008,6 +1020,7 @@ describe('PhishingController', () => { data: { allowlist: ['opensea.io'], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -1041,6 +1054,7 @@ describe('PhishingController', () => { data: { allowlist: ['opensea.io'], blocklist: [], + blocklistPaths: [], fuzzylist: ['opensea.io'], tolerance: 2, version: 0, @@ -1074,6 +1088,7 @@ describe('PhishingController', () => { data: { allowlist: ['opensea.io'], blocklist: [], + blocklistPaths: [], fuzzylist: ['opensea.io'], tolerance: 0, version: 0, @@ -1101,6 +1116,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: ['electrum.mx'], + blocklistPaths: [], fuzzylist: [], tolerance: 2, version: 0, @@ -1140,6 +1156,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: ['electrum.mx'], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -1180,6 +1197,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -1219,6 +1237,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: ['xn--myetherallet-4k5fwn.com'], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -1257,7 +1276,8 @@ describe('PhishingController', () => { .reply(200, { data: { allowlist: [], - blocklist: ['example.com/path'], + blocklist: [], + blocklistPaths: ['example.com/path'], fuzzylist: [], tolerance: 0, version: 0, @@ -1315,7 +1335,7 @@ describe('PhishingController', () => { describe('updateStalelist', () => { it('should update lists with addition to hotlist', async () => { sinon.useFakeTimers(2); - const exampleBlockedUrl = 'https://example-blocked-website.com'; + const exampleBlockedUrl = 'example-blocked-website.com'; const exampleRequestBlockedHash = '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; const exampleBlockedUrlOne = @@ -1326,6 +1346,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: [exampleBlockedUrl], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -1357,7 +1378,7 @@ describe('PhishingController', () => { expect(controller.state.phishingLists).toStrictEqual([ { allowlist: [], - blocklist: ['example-blocked-website.com', exampleBlockedUrlOne], + blocklist: [exampleBlockedUrl, exampleBlockedUrlOne], c2DomainBlocklist: [exampleRequestBlockedHash], blocklistPaths: {}, fuzzylist: [], @@ -1381,6 +1402,7 @@ describe('PhishingController', () => { data: { allowlist: [], blocklist: [exampleBlockedUrl], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, @@ -1436,7 +1458,8 @@ describe('PhishingController', () => { .reply(200, { data: { allowlist: [], - blocklist: ['example.com', 'malicious.com/phishing'], + blocklist: ['example.com'], + blocklistPaths: ['malicious.com/phishing'], fuzzylist: [], tolerance: 0, version: 0, @@ -2351,6 +2374,7 @@ describe('PhishingController', () => { data: { allowlist: [allowlistedDomain], blocklist: [], + blocklistPaths: [], fuzzylist: [], tolerance: 0, version: 0, From 50f84b569c92bdee526b02f470c5222d79e93783 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:22:11 -0500 Subject: [PATCH 55/64] chore: remove unused func --- .../src/PhishingController.ts | 1 - .../phishing-controller/src/utils.test.ts | 89 ------------------- packages/phishing-controller/src/utils.ts | 30 ------- 3 files changed, 120 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 67b44ca8290..fd08440ed95 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -32,7 +32,6 @@ import { roundToNearestMinute, getHostnameFromWebUrl, getPathnameFromUrl, - separateBlocklistEntries, } from './utils'; export const PHISHING_CONFIG_BASE_URL = diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 9b884a42e06..16c8e03974f 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -17,7 +17,6 @@ import { processConfigs, processDomainList, roundToNearestMinute, - separateBlocklistEntries, sha256Hash, validateConfig, } from './utils'; @@ -1064,94 +1063,6 @@ describe('generateParentDomains', () => { }); }); -describe('separateBlocklistEntries', () => { - it('separates hostname-only and hostname+path entries', () => { - const blocklist = [ - 'example.com', - 'malicious.com/phishing', - 'bad.com/scam/page', - 'another-bad.com', - 'evil.com/path1/path2/path3', - ]; - - const result = separateBlocklistEntries(blocklist); - - expect(result.blocklist).toStrictEqual(['example.com', 'another-bad.com']); - expect(result.blocklistPaths).toStrictEqual({ - 'malicious.com': { - phishing: {}, - }, - 'bad.com': { - scam: { - page: {}, - }, - }, - 'evil.com': { - path1: { - path2: { - path3: {}, - }, - }, - }, - }); - }); - - it('handles URLs with protocols', () => { - const blocklist = [ - 'https://example.com', - 'http://malicious.com/phishing', - 'https://bad.com/scam/page', - ]; - - const result = separateBlocklistEntries(blocklist); - - expect(result.blocklist).toStrictEqual(['example.com']); - expect(result.blocklistPaths).toStrictEqual({ - 'malicious.com': { - phishing: {}, - }, - 'bad.com': { - scam: { - page: {}, - }, - }, - }); - }); - - it('handles invalid URLs gracefully', () => { - const blocklist = ['example.com', '', 'malicious.com/phishing']; - - const result = separateBlocklistEntries(blocklist); - - expect(result.blocklist).toStrictEqual(['example.com']); - expect(result.blocklistPaths).toStrictEqual({ - 'malicious.com': { - phishing: {}, - }, - }); - }); - - it('handles empty blocklist', () => { - const result = separateBlocklistEntries([]); - - expect(result.blocklist).toStrictEqual([]); - expect(result.blocklistPaths).toStrictEqual({}); - }); - - it('handles trailing slashes correctly', () => { - const blocklist = ['example.com/', 'malicious.com/phishing/']; - - const result = separateBlocklistEntries(blocklist); - - expect(result.blocklist).toStrictEqual(['example.com']); - expect(result.blocklistPaths).toStrictEqual({ - 'malicious.com': { - phishing: {}, - }, - }); - }); -}); - describe('getHostnameAndPathComponents', () => { it.each([ [ diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index e62757ffd9f..c32a6d576f8 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -358,36 +358,6 @@ export const getPathnameFromUrl = (url: string): string => { } }; -/** - * Separates blocklist entries into hostname-only entries and hostname+path entries. - * - * @param blocklist - Array of blocklist entries (hostnames and hostname/path combinations) - * @returns Object containing separated blocklist and blocklistPaths - */ -export const separateBlocklistEntries = ( - blocklist: string[], -): { blocklist: string[]; blocklistPaths: PathTrie } => { - const hostnameOnlyList: string[] = []; - const pathTrie: PathTrie = {}; - - for (const entry of blocklist) { - const { hostname, pathComponents } = getHostnameAndPathComponents(entry); - if (!hostname) { - continue; - } - if (pathComponents.length === 0) { - hostnameOnlyList.push(hostname.toLowerCase()); - } else { - insertToTrie(entry, pathTrie); - } - } - - return { - blocklist: hostnameOnlyList, - blocklistPaths: pathTrie, - }; -}; - /** * Generates all possible parent domains up to a specified limit. * From a7929bebb25597fbdff73db7b279ba1d7080fead Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:26:24 -0500 Subject: [PATCH 56/64] fix: lint --- packages/phishing-controller/src/PathTrie.test.ts | 2 ++ packages/phishing-controller/src/utils.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/phishing-controller/src/PathTrie.test.ts b/packages/phishing-controller/src/PathTrie.test.ts index 105fc5a6016..63cfab6aabe 100644 --- a/packages/phishing-controller/src/PathTrie.test.ts +++ b/packages/phishing-controller/src/PathTrie.test.ts @@ -256,11 +256,13 @@ describe('convertListToTrie', () => { }); it('handles undefined input gracefully', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = convertListToTrie(undefined as any); expect(result).toStrictEqual({}); }); it('handles non-array input gracefully', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = convertListToTrie('not-an-array' as any); expect(result).toStrictEqual({}); }); diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index c32a6d576f8..63fb89c9303 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -1,7 +1,7 @@ import { bytesToHex } from '@noble/hashes/utils'; import { sha256 } from 'ethereum-cryptography/sha256'; -import { deleteFromTrie, insertToTrie, type PathTrie } from './PathTrie'; +import { deleteFromTrie, insertToTrie } from './PathTrie'; import type { Hotlist, PhishingListState } from './PhishingController'; import { ListKeys, phishingListKeyNameMap } from './PhishingController'; import type { @@ -112,12 +112,13 @@ export const applyDiffs = ( } else { listSets[targetListType].delete(url); } + continue; + } + + if (targetListType === 'blocklistPaths') { + insertToTrie(url, listState.blocklistPaths); } else { - if (targetListType === 'blocklistPaths') { - insertToTrie(url, listState.blocklistPaths); - } else { - listSets[targetListType].add(url); - } + listSets[targetListType].add(url); } } From eedc5cd8f54d9657a35ed9031dab046f833f5ebb Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:30:03 -0500 Subject: [PATCH 57/64] chore: lint --- eslint-warning-thresholds.json | 2 +- .../src/PhishingController.ts | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index e4bbe63e2a8..283321e53df 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -297,7 +297,7 @@ "jsdoc/tag-lines": 1 }, "packages/phishing-controller/src/PhishingController.ts": { - "jsdoc/check-tag-names": 39 + "jsdoc/check-tag-names": 32 }, "packages/phishing-controller/src/utils.test.ts": { "import-x/namespace": 5 diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index fd08440ed95..8d6b67cc2e6 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -102,16 +102,15 @@ export type C2DomainBlocklistResponse = { }; /** - * @type PhishingStalelist + * PhishingStalelist defines the expected type of the stalelist from the API. * - * type defining expected type of the stalelist.json file. - * @property allowlist - List of approved origins. - * @property blocklist - List of unapproved origins (hostname-only entries). - * @property blocklistPaths - List of unapproved origins with paths (hostname + path entries). - * @property fuzzylist - List of fuzzy-matched unapproved origins. - * @property tolerance - Fuzzy match tolerance level - * @property lastUpdated - Timestamp of last update. - * @property version - Stalelist data structure iteration. + * allowlist - List of approved origins. + * blocklist - List of unapproved origins (hostname-only entries). + * blocklistPaths - List of unapproved origins with paths (hostname + path entries). + * fuzzylist - List of fuzzy-matched unapproved origins. + * tolerance - Fuzzy match tolerance level + * lastUpdated - Timestamp of last update. + * version - Stalelist data structure iteration. */ export type PhishingStalelist = { allowlist: string[]; From e3c0656d814a77254def1d438c66f974c8be71f9 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Thu, 25 Sep 2025 17:34:34 -0500 Subject: [PATCH 58/64] chore: update test --- packages/phishing-controller/src/PhishingController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 394cf6fd839..14df1f7375a 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -408,7 +408,7 @@ describe('PhishingController', () => { it('replaces existing phishing lists with completely new list from phishing detection API', async () => { const controller = new PhishingController({ - messenger: getRestrictedMessenger(), + messenger: getRestrictedMessengerWithTransactionEvents().messenger, stalelistRefreshInterval: 10, state: { phishingLists: [ From 0b4bb58fdf7e6fc5829f181c3c8bf0cb39230cc3 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Fri, 26 Sep 2025 09:56:30 -0500 Subject: [PATCH 59/64] feat: deepCopyPathTrie --- packages/phishing-controller/src/PathTrie.ts | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/phishing-controller/src/PathTrie.ts b/packages/phishing-controller/src/PathTrie.ts index 3696c8ebdef..87b2059a29e 100644 --- a/packages/phishing-controller/src/PathTrie.ts +++ b/packages/phishing-controller/src/PathTrie.ts @@ -141,3 +141,29 @@ export const convertListToTrie = (paths: string[] = []): PathTrie => { } return pathTrie; }; + +/** + * Creates a deep copy of a PathNode structure. + * + * @param original - The original PathNode to copy. + * @returns A deep copy of the PathNode. + */ +const deepCopyPathNode = (original: PathNode): PathNode => { + const copy: PathNode = {}; + + for (const [key, childNode] of Object.entries(original)) { + copy[key] = deepCopyPathNode(childNode); + } + + return copy; +}; + +/** + * Creates a deep copy of a PathTrie structure. + * + * @param original - The original PathTrie to copy. + * @returns A deep copy of the PathTrie. + */ +export const deepCopyPathTrie = (original: PathTrie): PathTrie => { + return deepCopyPathNode(original) as PathTrie; +}; From 8eaa8224611d7b4ec6529d90c221bba5a1f20bdb Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Fri, 26 Sep 2025 09:56:39 -0500 Subject: [PATCH 60/64] test: deepCopyPathTrie --- .../phishing-controller/src/PathTrie.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/phishing-controller/src/PathTrie.test.ts b/packages/phishing-controller/src/PathTrie.test.ts index 63cfab6aabe..c93f988456c 100644 --- a/packages/phishing-controller/src/PathTrie.test.ts +++ b/packages/phishing-controller/src/PathTrie.test.ts @@ -1,5 +1,6 @@ import { convertListToTrie, + deepCopyPathTrie, deleteFromTrie, isTerminalPath, insertToTrie, @@ -223,6 +224,61 @@ describe('PathTrie', () => { expect(isTerminalPath(path, pathTrie)).toBe(expected); }); }); + + describe('deepCopyPathTrie', () => { + it('creates a deep copy of a simple trie', () => { + const original: PathTrie = { + 'example.com': { + path1: {}, + path2: {}, + }, + }; + + const copy = deepCopyPathTrie(original); + + expect(copy).toStrictEqual(original); + expect(copy).not.toBe(original); + expect(copy['example.com']).not.toBe(original['example.com']); + }); + + it('creates a deep copy of a complex nested trie', () => { + const original: PathTrie = { + 'example.com': { + path1: { + subpath1: { + deeppath: {}, + }, + subpath2: {}, + }, + path2: {}, + }, + 'another.com': { + different: { + nested: {}, + }, + }, + }; + + const copy = deepCopyPathTrie(original); + + expect(copy).toStrictEqual(original); + expect(copy).not.toBe(original); + expect(copy['example.com']).not.toBe(original['example.com']); + expect(copy['example.com'].path1).not.toBe(original['example.com'].path1); + expect(copy['example.com'].path1.subpath1).not.toBe( + original['example.com'].path1.subpath1, + ); + expect(copy['another.com']).not.toBe(original['another.com']); + }); + + it('handles empty trie', () => { + const original: PathTrie = {}; + const copy = deepCopyPathTrie(original); + + expect(copy).toStrictEqual({}); + expect(copy).not.toBe(original); + }); + }); }); describe('convertListToTrie', () => { From 88011a0fae77808f9b566389d700eb7aab8ae780 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Fri, 26 Sep 2025 09:56:52 -0500 Subject: [PATCH 61/64] fix: add deepCopyPathTrie to applyDiffs --- packages/phishing-controller/src/utils.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 60eb24cb5ae..47bf6f59fe0 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -1,7 +1,7 @@ import { bytesToHex } from '@noble/hashes/utils'; import { sha256 } from 'ethereum-cryptography/sha256'; -import { deleteFromTrie, insertToTrie } from './PathTrie'; +import { deleteFromTrie, insertToTrie, deepCopyPathTrie } from './PathTrie'; import type { Hotlist, PhishingListState } from './PhishingController'; import { ListKeys, phishingListKeyNameMap } from './PhishingController'; import type { @@ -105,6 +105,9 @@ export const applyDiffs = ( c2DomainBlocklist: new Set(listState.c2DomainBlocklist), }; + // deep copy of blocklistPaths to avoid mutating the original + const blocklistPaths = deepCopyPathTrie(listState.blocklistPaths); + for (const { isRemoval, targetList, url, timestamp } of diffsToApply) { const targetListType = splitStringByPeriod(targetList)[1]; if (timestamp > latestDiffTimestamp) { @@ -113,7 +116,7 @@ export const applyDiffs = ( if (isRemoval) { if (targetListType === 'blocklistPaths') { - deleteFromTrie(url, listState.blocklistPaths); + deleteFromTrie(url, blocklistPaths); } else { listSets[targetListType].delete(url); } @@ -121,7 +124,7 @@ export const applyDiffs = ( } if (targetListType === 'blocklistPaths') { - insertToTrie(url, listState.blocklistPaths); + insertToTrie(url, blocklistPaths); } else { listSets[targetListType].add(url); } @@ -141,7 +144,7 @@ export const applyDiffs = ( allowlist: Array.from(listSets.allowlist), blocklist: Array.from(listSets.blocklist), fuzzylist: Array.from(listSets.fuzzylist), - blocklistPaths: listState.blocklistPaths, + blocklistPaths: blocklistPaths, version: listState.version, name: phishingListKeyNameMap[listKey], tolerance: listState.tolerance, From 412452c7f4328a611e282a0a957a38da38311157 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Fri, 26 Sep 2025 09:57:01 -0500 Subject: [PATCH 62/64] test: update applyDiffs --- packages/phishing-controller/src/utils.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index 8a4a9ec0ca8..1153b673f08 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -302,14 +302,14 @@ describe('applyDiffs', () => { }); it('adds sibling paths', () => { - applyDiffs( + const firstResult = applyDiffs( listState, [newAddDiff('example.com/path1')], ListKeys.EthPhishingDetectConfig, ); const result = applyDiffs( - listState, - [newAddDiff('example.com/path2')], + firstResult, + [{ ...newAddDiff('example.com/path2'), timestamp: 1000000001 }], ListKeys.EthPhishingDetectConfig, ); expect(result.blocklistPaths).toStrictEqual({ @@ -359,13 +359,13 @@ describe('applyDiffs', () => { }); it('does not insert deeper path if ancestor exists', () => { - applyDiffs( + const firstResult = applyDiffs( listState, [newAddDiff('example.com/path1')], ListKeys.EthPhishingDetectConfig, ); const result = applyDiffs( - listState, + firstResult, [newAddDiff('example.com/path1/path2')], ListKeys.EthPhishingDetectConfig, ); @@ -417,14 +417,14 @@ describe('applyDiffs', () => { }); it('deletes all paths', () => { - applyDiffs( + const firstResult = applyDiffs( listState, [newRemoveDiff('example.com/path11/path2')], ListKeys.EthPhishingDetectConfig, ); const result = applyDiffs( - listState, - [newRemoveDiff('example.com/path12')], + firstResult, + [{ ...newRemoveDiff('example.com/path12'), timestamp: 1000000002 }], ListKeys.EthPhishingDetectConfig, ); expect(result.blocklistPaths).toStrictEqual({}); From 5f2b5e6e5753e15bfc7c51722cc8c89ad3af6a75 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Fri, 26 Sep 2025 10:19:07 -0500 Subject: [PATCH 63/64] chore: fix lint issue --- packages/phishing-controller/src/utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 47bf6f59fe0..bcebcf3ce19 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -106,7 +106,7 @@ export const applyDiffs = ( }; // deep copy of blocklistPaths to avoid mutating the original - const blocklistPaths = deepCopyPathTrie(listState.blocklistPaths); + const newBlocklistPaths = deepCopyPathTrie(listState.blocklistPaths); for (const { isRemoval, targetList, url, timestamp } of diffsToApply) { const targetListType = splitStringByPeriod(targetList)[1]; @@ -116,7 +116,7 @@ export const applyDiffs = ( if (isRemoval) { if (targetListType === 'blocklistPaths') { - deleteFromTrie(url, blocklistPaths); + deleteFromTrie(url, newBlocklistPaths); } else { listSets[targetListType].delete(url); } @@ -124,7 +124,7 @@ export const applyDiffs = ( } if (targetListType === 'blocklistPaths') { - insertToTrie(url, blocklistPaths); + insertToTrie(url, newBlocklistPaths); } else { listSets[targetListType].add(url); } @@ -144,7 +144,7 @@ export const applyDiffs = ( allowlist: Array.from(listSets.allowlist), blocklist: Array.from(listSets.blocklist), fuzzylist: Array.from(listSets.fuzzylist), - blocklistPaths: blocklistPaths, + blocklistPaths: newBlocklistPaths, version: listState.version, name: phishingListKeyNameMap[listKey], tolerance: listState.tolerance, From 46d1e344f388c6e04156e5ffe601d1a19ed71558 Mon Sep 17 00:00:00 2001 From: imblue-dabadee Date: Fri, 26 Sep 2025 10:48:29 -0500 Subject: [PATCH 64/64] chore: update CHANGELOG --- packages/phishing-controller/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index dd9e6cd3e19..94e8ae672cb 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add path-based blocking [#6416](https://github.com/MetaMask/core/pull/6416) + - Add `blocklistPaths` to `PhishingDetectorList` + - Add `blocklistPaths` to `PhishingDetectorConfiguration` + ## [14.0.0] ### Added