From 1775471f86163696226976dbbaf6c8dace6d0dfc Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 8 Apr 2024 13:59:37 +0200 Subject: [PATCH] add detector from `eth-phishing-detector` --- packages/phishing-controller/package.json | 1 + packages/phishing-controller/src/detector.js | 176 ++++ .../phishing-controller/src/detector.test.js | 821 ++++++++++++++++++ yarn.lock | 8 + 4 files changed, 1006 insertions(+) create mode 100644 packages/phishing-controller/src/detector.js create mode 100644 packages/phishing-controller/src/detector.test.js diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 2fe84e6d14..6347b487a6 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -45,6 +45,7 @@ "@metamask/controller-utils": "^9.0.2", "@types/punycode": "^2.1.0", "eth-phishing-detect": "^1.2.0", + "fastest-levenshtein": "^1.0.16", "punycode": "^2.1.1" }, "devDependencies": { diff --git a/packages/phishing-controller/src/detector.js b/packages/phishing-controller/src/detector.js new file mode 100644 index 0000000000..79c2a82756 --- /dev/null +++ b/packages/phishing-controller/src/detector.js @@ -0,0 +1,176 @@ +const { distance } = require('fastest-levenshtein') +const DEFAULT_TOLERANCE = 3 + +class PhishingDetector { + + /** + * Construct a phishing detector, which can check whether origins are known + * to be malicious or similar to common phishing targets. + * + * A list of configurations is accepted. Each origin checked is processed + * using each configuration in sequence, so the order defines which + * configurations take precedence. + * + * @param {LegacyPhishingDetectorConfiguration | PhishingDetectorConfiguration[]} opts - Phishing detection options + */ + constructor (opts) { + // recommended configuration + if (Array.isArray(opts)) { + this.configs = processConfigs(opts) + this.legacyConfig = false + // legacy configuration + } else { + this.configs = [{ + allowlist: processDomainList(opts.whitelist || []), + blocklist: processDomainList(opts.blacklist || []), + fuzzylist: processDomainList(opts.fuzzylist || []), + tolerance: ('tolerance' in opts) ? opts.tolerance : DEFAULT_TOLERANCE + }] + this.legacyConfig = true + } + } + + check(domain) { + const result = this._check(domain) + + if (this.legacyConfig) { + let legacyType = result.type; + if (legacyType === 'allowlist') { + legacyType = 'whitelist' + } else if (legacyType === 'blocklist') { + legacyType = 'blacklist' + } + return { + match: result.match, + result: result.result, + type: legacyType, + } + } + return result + } + + _check (domain) { + let fqdn = domain.substring(domain.length - 1) === "." + ? domain.slice(0, -1) + : domain; + + const source = domainToParts(fqdn) + + for (const { allowlist, name, version } of this.configs) { + // if source matches allowlist hostname (or subdomain thereof), PASS + const allowlistMatch = matchPartsAgainstList(source, allowlist) + if (allowlistMatch) { + const match = domainPartsToDomain(allowlistMatch); + return { match, name, result: false, type: 'allowlist', version } + } + } + + for (const { blocklist, fuzzylist, name, tolerance, version } of this.configs) { + // if source matches blocklist hostname (or subdomain thereof), FAIL + const blocklistMatch = matchPartsAgainstList(source, blocklist) + if (blocklistMatch) { + const match = domainPartsToDomain(blocklistMatch); + return { match, name, result: true, type: 'blocklist', version } + } + + if (tolerance > 0) { + // check if near-match of whitelist domain, FAIL + let fuzzyForm = domainPartsToFuzzyForm(source) + // strip www + fuzzyForm = fuzzyForm.replace(/^www\./, '') + // check against fuzzylist + const levenshteinMatched = fuzzylist.find((targetParts) => { + const fuzzyTarget = domainPartsToFuzzyForm(targetParts) + const dist = distance(fuzzyForm, fuzzyTarget) + return dist <= tolerance + }) + if (levenshteinMatched) { + const match = domainPartsToDomain(levenshteinMatched) + return { name, match, result: true, type: 'fuzzy', version } + } + } + } + + // matched nothing, PASS + return { result: false, type: 'all' } + } + +} + +PhishingDetector.processDomainList = processDomainList +PhishingDetector.domainToParts = domainToParts +PhishingDetector.domainPartsToDomain = domainPartsToDomain +module.exports = PhishingDetector + +// util + +function processConfigs(configs = []) { + return configs.map((config) => { + validateConfig(config) + return Object.assign({}, config, { + allowlist: processDomainList(config.allowlist || []), + blocklist: processDomainList(config.blocklist || []), + fuzzylist: processDomainList(config.fuzzylist || []), + tolerance: ('tolerance' in config) ? config.tolerance : DEFAULT_TOLERANCE + }) + }); +} + +function validateConfig(config) { + if (config === null || typeof config !== 'object') { + throw new Error('Invalid config') + } + + if (config.tolerance && !config.fuzzylist) { + throw new Error('Fuzzylist tolerance provided without fuzzylist') + } + + if ( + typeof config.name !== 'string' || + config.name === '' + ) { + throw new Error("Invalid config parameter: 'name'") + } + + if ( + !['number', 'string'].includes(typeof config.version) || + config.version === '' + ) { + throw new Error("Invalid config parameter: 'version'") + } +} + +function processDomainList (list) { + return list.map(domainToParts) +} + +function domainToParts (domain) { + try { + return domain.split('.').reverse() + } catch (e) { + throw new Error(JSON.stringify(domain)) + } +} + +function domainPartsToDomain(domainParts) { + return domainParts.slice().reverse().join('.') +} + +// for fuzzy search, drop TLD and re-stringify +function domainPartsToFuzzyForm(domainParts) { + return domainParts.slice(1).reverse().join('.') +} + +// match the target parts, ignoring extra subdomains on source +// returns parts for first found matching entry +// source: [io, metamask, xyz] +// target: [io, metamask] +// result: PASS +function matchPartsAgainstList(source, list) { + return list.find((target) => { + // target domain has more parts than source, fail + if (target.length > source.length) return false + // source matches target or (is deeper subdomain) + return target.every((part, index) => source[index] === part) + }) +} diff --git a/packages/phishing-controller/src/detector.test.js b/packages/phishing-controller/src/detector.test.js new file mode 100644 index 0000000000..42d85b303e --- /dev/null +++ b/packages/phishing-controller/src/detector.test.js @@ -0,0 +1,821 @@ +const test = require('tape') +const PhishingDetector = require('../src/detector.js') +const { testDomain } = require('./test.util.js') + +function runTests () { + test('config schema', (t) => { + + // return version with match + testDomain(t, { + domain: 'blocked-by-first.com', + expected: true, + version: 1, + options: [ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + ], + }) + + // return name with match + testDomain(t, { + domain: 'blocked-by-first.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + ], + }) + // allow missing allowlist + try { + new PhishingDetector([ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + ]) + t.pass('Passed validation') + } catch (error) { + t.fail(error.message) + } + + // allow missing blocklist + try { + new PhishingDetector([ + { + allowlist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + ]) + t.pass('Passed validation') + } catch (error) { + t.fail(error.message) + } + + // allow missing fuzzylist and tolerance + try { + new PhishingDetector([ + { + allowlist: [], + blocklist: [], + name: 'first', + version: 1 + }, + ]) + t.pass('Passed validation') + } catch (error) { + t.fail(error.message) + } + + // allow missing tolerance + try { + new PhishingDetector([ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + version: 1 + }, + ]) + t.pass('Passed validation') + } catch (error) { + t.fail(error.message) + } + + // throw when config is invalid + const invalidConfigValues = [ + undefined, + null, + true, + false, + 0, + 1, + 1.1, + '', + 'test', + () => { + return {name: 'test', version: 1 } + }, + ] + for (const invalidValue of invalidConfigValues) { + try { + new PhishingDetector([invalidValue]) + t.fail('Did not fail validation') + } catch (error) { + t.equal(error.message, 'Invalid config') + } + } + + // throw when tolerance is provided without fuzzylist + try { + new PhishingDetector([ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + name: 'first', + tolerance: 2, + version: 1, + }, + ]) + t.fail('Did not fail validation') + } catch (error) { + t.equal(error.message, 'Fuzzylist tolerance provided without fuzzylist') + } + + // throw when config name is invalid + const invalidNameValues = [ + undefined, + null, + true, + false, + 0, + 1, + 1.1, + '', + () => { + return {name: 'test', version: 1 } + }, + {} + ] + for (const invalidValue of invalidNameValues) { + try { + new PhishingDetector([ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: invalidValue, + tolerance: 2, + version: 1 + }, + ]) + t.fail('Did not fail validation') + } catch (error) { + t.equal(error.message, "Invalid config parameter: 'name'") + } + } + + // throw when config version is invalid + const invalidVersionValues = [ + undefined, + null, + true, + false, + '', + () => { + return {name: 'test', version: 1 } + }, + {} + ] + for (const invalidValue of invalidVersionValues) { + try { + new PhishingDetector([ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: invalidValue + }, + ]) + t.fail('Did not fail validation') + } catch (error) { + t.equal(error.message, "Invalid config parameter: 'version'") + } + } + t.end() + }) + + test('multiple configs', (t) => { + + // allow no config + testDomain(t, { + domain: 'default.com', + expected: false, + options: [], + type: 'all' + }) + + // allow by default + testDomain(t, { + domain: 'default.com', + expected: false, + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'all' + }) + + // block origin in first config + testDomain(t, { + domain: 'blocked-by-first.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: ['blocked-by-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'blocklist' + }) + + // block origin in second config + testDomain(t, { + domain: 'blocked-by-second.com', + expected: true, + name: 'second', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: ['blocked-by-second.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'blocklist' + }) + + // prefer first config when origin blocked by both + testDomain(t, { + domain: 'blocked-by-both.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: ['blocked-by-both.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: ['blocked-by-both.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'blocklist' + }) + + // test first fuzzylist + testDomain(t, { + domain: 'fuzzy-first.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'fuzzy' + }) + + // test first fuzzylist at tolerance + testDomain(t, { + domain: 'fuzzy-firstab.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'fuzzy' + }) + + // allow first fuzzylist beyond tolerance + testDomain(t, { + domain: 'fuzzy-firstabc.com', + expected: false, + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'all' + }) + + // test second fuzzylist + testDomain(t, { + domain: 'fuzzy-second.com', + expected: true, + name: 'second', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'fuzzy' + }) + + // test second fuzzylist at tolerance + testDomain(t, { + domain: 'fuzzy-secondab.com', + expected: true, + name: 'second', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'fuzzy' + }) + + // allow second fuzzylist past tolerance + testDomain(t, { + domain: 'fuzzy-secondabc.com', + expected: false, + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'all' + }) + + // prefer first config when blocked by both fuzzylists + testDomain(t, { + domain: 'fuzzy-both.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-both.com'], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-both.com'], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'fuzzy' + }) + + // prefer first config when blocked by first and fuzzy blocked by second + testDomain(t, { + domain: 'blocked-first-fuzzy-second.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: ['blocked-first-fuzzy-second.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['blocked-first-fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'blocklist' + }) + + // prefer first config when fuzzy blocked by first and blocked by second + testDomain(t, { + domain: 'fuzzy-first-blocked-second.com', + expected: true, + name: 'first', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first-blocked-second.com'], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: ['fuzzy-first-blocked-second.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'fuzzy' + }) + + // allow origin that is allowed and not blocked on first config + testDomain(t, { + domain: 'allowed-first.com', + expected: false, + name: 'first', + options: [ + { + allowlist: ['allowed-first.com'], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin that is allowed and not blocked on second config + testDomain(t, { + domain: 'allowed-second.com', + expected: false, + name: 'second', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: ['allowed-second.com'], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin that is blocklisted and allowlisted, both on first config + testDomain(t, { + domain: 'allowed-and-blocked-first.com', + expected: false, + name: 'first', + options: [ + { + allowlist: ['allowed-and-blocked-first.com'], + blocklist: ['allowed-and-blocked-first.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin blocked by fuzzylist and allowlisted, both on first config + testDomain(t, { + domain: 'allowed-and-fuzzy-first.com', + expected: false, + name: 'first', + options: [ + { + allowlist: ['allowed-and-fuzzy-first.com'], + blocklist: [], + fuzzylist: ['allowed-and-fuzzy-first.com'], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin that is blocklisted and allowlisted, both on second config + testDomain(t, { + domain: 'allowed-and-blocked-second.com', + expected: false, + name: 'second', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: ['allowed-and-blocked-second.com'], + blocklist: ['allowed-and-blocked-second.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin blocked by fuzzylist and allowlisted, both on second config + testDomain(t, { + domain: 'allowed-and-fuzzy-second.com', + expected: false, + name: 'second', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: ['allowed-and-fuzzy-second.com'], + blocklist: [], + fuzzylist: ['allowed-and-fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin blocked by first config but allowedlisted by second + testDomain(t, { + domain: 'blocked-first-allowed-second.com', + expected: false, + name: 'second', + options: [ + { + allowlist: [], + blocklist: ['blocked-first-allowed-second.com'], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: ['blocked-first-allowed-second.com'], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin allowed by first config but blocked by second + testDomain(t, { + domain: 'allowed-first-blocked-second.com', + expected: false, + name: 'first', + options: [ + { + allowlist: ['allowed-first-blocked-second.com'], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: ['allowed-first-blocked-second.com'], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin fuzzylist blocked by first config but allowed by second + testDomain(t, { + domain: 'fuzzy-first-allowed-second.com', + expected: false, + name: 'second', + options: [ + { + allowlist: [], + blocklist: [], + fuzzylist: ['fuzzy-first-allowed-second.com'], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: ['fuzzy-first-allowed-second.com'], + blocklist: [], + fuzzylist: [], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + // allow origin allowed by first config but fuzzylist blocked by second + testDomain(t, { + domain: 'allowed-first-fuzzy-second.com', + expected: false, + name: 'first', + options: [ + { + allowlist: ['allowed-first-fuzzy-second.com'], + blocklist: [], + fuzzylist: [], + name: 'first', + tolerance: 2, + version: 1 + }, + { + allowlist: [], + blocklist: [], + fuzzylist: ['allowed-first-fuzzy-second.com'], + name: 'second', + tolerance: 2, + version: 1 + }, + ], + type: 'allowlist' + }) + + t.end() + }) +} + +module.exports = { + runTests, +} diff --git a/yarn.lock b/yarn.lock index e65c0743e9..4ac5d7aee8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2727,6 +2727,7 @@ __metadata: "@types/punycode": ^2.1.0 deepmerge: ^4.2.2 eth-phishing-detect: ^1.2.0 + fastest-levenshtein: ^1.0.16 jest: ^27.5.1 nock: ^13.3.1 punycode: ^2.1.1 @@ -6747,6 +6748,13 @@ __metadata: languageName: node linkType: hard +"fastest-levenshtein@npm:^1.0.16": + version: 1.0.16 + resolution: "fastest-levenshtein@npm:1.0.16" + checksum: a78d44285c9e2ae2c25f3ef0f8a73f332c1247b7ea7fb4a191e6bb51aa6ee1ef0dfb3ed113616dcdc7023e18e35a8db41f61c8d88988e877cf510df8edafbc71 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.15.0 resolution: "fastq@npm:1.15.0"