Copies .dockerignore, .gitignore, .prettierignore, check.yml and REPO_POLICIES.md from sneak/prompts at dd4027b. The repo's own entries (dist/, release/, yarn files) are kept after the canonical content. The Dockerfile gets separate lint and test phases. Its last stage depends on both, checks the git describe version and runs make build. script/lint, test, check, cibuild and docker are the canonical models. check-censored moves into the lint phase and test-verify-build into the test phase. fmt and fmt-check fall back to the nvm-installed node. The e2e image builds are uncached. Comments that cited the old test caps now say 60 seconds, and comments that named what runs a script now name the Dockerfile phase or stage. Model: opus-5-5
220 lines
8.0 KiB
JavaScript
220 lines
8.0 KiB
JavaScript
// 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 60 seconds 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/,
|
|
);
|
|
});
|
|
});
|