Files
AutistMask/script/lib/build-blocklist.js
clawbot ff3387d8cf
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 21s
feat: vendor and censor the phishing blocklist at build time (closes #219)
2026-08-17 10:05:56 +02:00

148 lines
5.3 KiB
JavaScript

// The transform half of script/vendor-blocklist: upstream's config.json in,
// src/shared/phishingBlocklist.json out. Build-time repo tooling; nothing here
// is shipped to users.
//
// Usage: node script/lib/build-blocklist.js <source.json> <output.json>
//
// What it does, and why each step is here:
//
// - only the blacklist is carried over. The extension matches a hostname and
// its parent domains against that one list; upstream's whitelist, fuzzylist
// and version metadata are read by nothing here, so shipping them would add
// megabytes of dead weight to every install.
// - entries are lowercased and de-duplicated, because that is the form
// isPhishingDomain() compares against.
// - entries that cannot be a hostname are dropped and counted. Upstream
// carries the odd URL-shaped entry (a path, a scheme); hostname matching can
// never match one, and once the artifact is hashes nobody can see that it is
// in there, so it is reported at vendoring time instead.
// - entries are hashed (see src/shared/domainHash.js) and sorted, and the
// digests are concatenated into one fixed-width string. Sorted is what makes
// the runtime lookup a binary search over that string, with no set to build
// on every service-worker wake; one string rather than an array of 100k+ is
// what keeps the file, the bundle and the JSON parse small.
//
// Deterministic by construction: same input bytes, same output bytes.
"use strict";
const fs = require("fs");
const {
HASH_ALGORITHM,
HASH_HEX_CHARS,
hashDomain,
} = require("../../src/shared/domainHash");
// A blocklist that has collapsed to a handful of entries is a broken fetch or a
// changed upstream shape, not a quiet day in phishing. Vendoring it would
// disarm the feature, so it fails instead and a human decides.
const MIN_ENTRIES = 10000;
function fail(message) {
process.stderr.write("build-blocklist: " + message + "\n");
process.exit(1);
}
// A hostname, as the matcher understands one: dot-separated labels of letters,
// digits, hyphens and underscores. Anything else — a path, a scheme, a space,
// an empty string, a non-ASCII label a browser would have punycoded before it
// ever reached isPhishingDomain() — cannot be produced by the hostname variants
// the extension looks up, so it could only ever sit in the artifact unused.
//
// Underscores are deliberate. They are not legal in a hostname per RFC 1123,
// but DNS carries them and browsers resolve them, and upstream lists 141 entries
// that use one — real phishing sites on shared subdomain hosts. A stricter
// pattern silently drops every one of them.
const HOSTNAME_RE =
/^[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]*[a-z0-9_])?)+$/;
function main(argv) {
const [source, output] = argv;
if (!source || !output) {
fail("usage: build-blocklist.js <source.json> <output.json>");
}
let config;
try {
config = JSON.parse(fs.readFileSync(source, "utf8"));
} catch (e) {
fail("could not read " + source + " as JSON: " + e.message);
}
if (!Array.isArray(config.blacklist)) {
fail(
"the source has no blacklist array, so its shape is not the one " +
"this transform understands. Refusing to write an artifact.",
);
}
const seen = new Set();
let dropped = 0;
for (const raw of config.blacklist) {
if (typeof raw !== "string") {
dropped++;
continue;
}
const domain = raw.trim().toLowerCase();
if (!HOSTNAME_RE.test(domain)) {
dropped++;
continue;
}
seen.add(domain);
}
if (seen.size < MIN_ENTRIES) {
fail(
"the source yielded " +
seen.size +
" usable entries, below the " +
MIN_ENTRIES +
" floor. That is a broken source or a changed upstream " +
"shape, and vendoring it would disarm phishing detection. " +
"Refusing to write an artifact.",
);
}
const hashes = [];
for (const domain of seen) hashes.push(hashDomain(domain));
hashes.sort();
// Truncation makes collisions possible; they are harmless (both entries are
// blocked either way) but they must not inflate the count the artifact
// claims, which the runtime cross-checks against the string length.
const unique = [];
for (const hash of hashes) {
if (unique.length === 0 || unique[unique.length - 1] !== hash) {
unique.push(hash);
}
}
const artifact = {
algorithm: HASH_ALGORITHM,
hashHexChars: HASH_HEX_CHARS,
count: unique.length,
hashes: unique.join(""),
};
// Four-space JSON with a trailing newline: what prettier emits for this
// shape, so a vendored artifact passes make fmt-check untouched.
fs.writeFileSync(output, JSON.stringify(artifact, null, 4) + "\n");
process.stdout.write(
"build-blocklist: " +
config.blacklist.length +
" source entries -> " +
seen.size +
" usable domains -> " +
unique.length +
" digests (" +
dropped +
" not hostnames, " +
(seen.size - unique.length) +
" digest collisions)\n",
);
}
main(process.argv.slice(2));