// The phishing blocklist is vendored at build time and shipped as digests: // script/vendor-blocklist writes src/shared/phishingBlocklist.json, and nothing // fetches anything at runtime. Two things therefore have to be proven here, and // the second is the one that would otherwise fail silently: // // - real domains from the vendored list are detected, and clean ones are not. // - a malformed artifact fails loudly. Every way of getting the artifact // wrong produces a blocklist that matches nothing while looking healthy, // which is a phishing check that answers "no" to everything. const { isPhishingDomain, getBlocklistSize, hostnameVariants, } = require("../src/shared/phishingDomains"); const { HASH_HEX_CHARS, hashDomain } = require("../src/shared/domainHash"); const vendored = require("../src/shared/phishingBlocklist.json"); // Domains present in the vendored list at the pinned upstream commit. Upstream // prunes as well as adds, so re-vendoring can retire one of these and turn this // red; that is the intended prompt to pick a current entry, not a licence to // weaken the assertion into "some domain somewhere matches". const LISTED = [ "0-google.ph", "myetheywallet.com", // An underscore is not legal in a hostname, but DNS carries one and // browsers resolve it, and upstream lists well over a hundred phishing // sites that use one. The vendoring transform keeps them. "phntum-wallett.godaddysites.com", "coinbase_prologin1.godaddysites.com", ]; // Not on the list, and the kind of host a user actually visits. const CLEAN = ["etherscan.io", "example.com", "opensea.io", "sneak.berlin"]; describe("vendored blocklist", () => { test("the artifact holds the whole list", () => { expect(getBlocklistSize()).toBeGreaterThan(100000); expect(vendored.hashes).toHaveLength(vendored.count * HASH_HEX_CHARS); }); test("the digests are sorted and unique", () => { // The lookup is a binary search over the concatenated digests. An // unsorted or duplicated artifact would fail lookups quietly rather // than loudly, so the ordering the search depends on is asserted here // against the committed file rather than assumed of the generator. // One assertion at the end rather than one per entry: 100k+ expect() // calls cost seconds, and make test is capped at 30 for the whole // suite. The index of the first offender is reported, so a failure // still says where. let previous = ""; let outOfOrderAt = -1; for (let i = 0; i < vendored.count; i++) { const at = vendored.hashes.slice( i * HASH_HEX_CHARS, (i + 1) * HASH_HEX_CHARS, ); if (at <= previous) { outOfOrderAt = i; break; } previous = at; } expect(outOfOrderAt).toBe(-1); }); test("every digest is lowercase hex of the declared width", () => { expect(vendored.hashes).toMatch(/^[0-9a-f]*$/); }); test("detects domains from the vendored list", () => { for (const domain of LISTED) { expect(isPhishingDomain(domain)).toBe(true); } }); test("does not flag legitimate domains", () => { for (const domain of CLEAN) { expect(isPhishingDomain(domain)).toBe(false); } }); test("detects a subdomain of a listed domain", () => { expect(isPhishingDomain("wallet." + LISTED[0])).toBe(true); expect(isPhishingDomain("a.b.c." + LISTED[0])).toBe(true); }); test("matching is case-insensitive", () => { expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true); }); test("returns false for an empty or missing hostname", () => { expect(isPhishingDomain("")).toBe(false); expect(isPhishingDomain(null)).toBe(false); expect(isPhishingDomain(undefined)).toBe(false); }); test("the first and last entries are both reachable", () => { // The ends are where an off-by-one in a binary search hides: a search // that never examines index 0 or index count-1 still finds everything // in between, and the real list is not searched exhaustively here. const first = vendored.hashes.slice(0, HASH_HEX_CHARS); const last = vendored.hashes.slice(-HASH_HEX_CHARS); const { _hashListed } = require("../src/shared/phishingDomains"); expect(_hashListed(first)).toBe(true); expect(_hashListed(last)).toBe(true); expect(_hashListed("0".repeat(HASH_HEX_CHARS))).toBe(false); expect(_hashListed("f".repeat(HASH_HEX_CHARS))).toBe(false); }); }); describe("hostnameVariants", () => { test("returns exact hostname plus parent domains", () => { expect(hostnameVariants("sub.evil.com")).toEqual([ "sub.evil.com", "evil.com", ]); }); test("returns just the hostname for a bare domain", () => { expect(hostnameVariants("example.com")).toEqual(["example.com"]); }); test("handles deep subdomain chains", () => { expect(hostnameVariants("a.b.c.d.com")).toEqual([ "a.b.c.d.com", "b.c.d.com", "c.d.com", "d.com", ]); }); test("lowercases hostnames", () => { expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]); }); }); describe("domain hashing", () => { test("a digest is the declared width of lowercase hex", () => { const hash = hashDomain("example.com"); expect(hash).toHaveLength(HASH_HEX_CHARS); expect(hash).toMatch(/^[0-9a-f]+$/); }); test("hashing is case-insensitive, so lookups are too", () => { expect(hashDomain("Evil.COM")).toBe(hashDomain("evil.com")); }); test("different domains get different digests", () => { expect(hashDomain("evil.com")).not.toBe(hashDomain("evil.org")); }); }); // A blocklist that silently matches nothing is the failure this module must not // have, so each way of breaking the artifact is required to throw at load. The // generator is the only thing that writes this file, but "the generator is // correct" is not something the shipped extension can check at runtime — this // is what makes a format drift a build failure rather than a silent one. describe("a malformed artifact fails loudly", () => { const GOOD = { algorithm: "sha256", hashHexChars: HASH_HEX_CHARS, count: 2, hashes: "0".repeat(HASH_HEX_CHARS) + "1".repeat(HASH_HEX_CHARS), }; function loadWith(artifact) { let mod; jest.isolateModules(() => { jest.doMock( "../src/shared/phishingBlocklist.json", () => artifact, { virtual: false, }, ); mod = require("../src/shared/phishingDomains"); }); return mod; } afterEach(() => { jest.dontMock("../src/shared/phishingBlocklist.json"); }); test("the control artifact loads", () => { expect(loadWith(GOOD).getBlocklistSize()).toBe(2); }); test("a different digest algorithm throws", () => { expect(() => loadWith({ ...GOOD, algorithm: "md5" })).toThrow( /algorithm/, ); }); test("a different digest width throws", () => { expect(() => loadWith({ ...GOOD, hashHexChars: 8 })).toThrow( /hex characters per entry/, ); }); test("a count that does not match the string length throws", () => { expect(() => loadWith({ ...GOOD, count: 3 })).toThrow( /which is not the/, ); }); test("a missing hashes string throws", () => { expect(() => loadWith({ ...GOOD, hashes: undefined })).toThrow( /no hashes string/, ); }); test("an empty artifact throws rather than matching nothing", () => { expect(() => loadWith({ ...GOOD, count: 0, hashes: "" })).toThrow( /entry count/, ); }); });