Sync the lists with polkadot-js/phishing every day, and publish them with checks (#4)

* feat: sync the lists with polkadot-js/phishing daily, and check them before publishing

The lists had not moved since January 2026, the site that serves them was
copied by hand, and the rebrand had turned five real phishing domains into
pezkuwichain.* names, unblocking them.

- scripts/syncUpstream.mjs and sync-upstream.yml take upstream's lists every
  day, lay pezkuwi/overlay.json over them, and commit to master. A day out of
  bounds (too many sites unblocked or added, scam addresses dropped, one of our
  domains blocked, one of our entries lost) writes nothing and opens an issue.
- pezkuwi/overlay.json holds our own entries with their reasons: the five
  pezkuwichain.* names stay blocked on purpose, and our domains are protected.
- scripts/lists.mjs holds the rules; scripts/lists.test.mjs tests them and the
  sync end to end; scripts/checkLists.mjs checks the committed files, including
  that they are what sortAll writes. lists.yml runs all three.
- pezkuwi/host/ is what phishing.pezkuwichain.io runs: it pulls master hourly
  and refuses a rewritten history, a blocked own domain, a shrink over 2%, or
  shards that disagree, with a sandbox test of each refusal.
- sortAll strips a trailing dot. Upstream's 817 "scam.xyz." entries were filed
  under an empty top-level domain in all/all.json, where checkIfDenied never
  looks; 816 repeated a listed name, and prenads.xyz was blocked nowhere else.
- sortAll uses Node's own directory functions instead of @pezkuwi/dev, so the
  sync and the checks run without installing the build toolchain.
- The auto-approve, auto-merge and crosscheck workflows, and the IPFS job,
  depended on tokens and secrets this repository does not have; removed. The
  remaining workflows are pinned to commits, with no default permissions and
  a timeout on every job.

* chore(lists): first sync with polkadot-js/phishing

Written by scripts/syncUpstream.mjs from upstream bf5e7c6:
658 sites added; nodelinkapp.xyz, org.in and terra-claim.com unblocked by
upstream; the five polkadot-js domains the rebrand had renamed blocked again;
816 duplicate "scam.xyz." entries folded into their names and the stray
all/all.json gone. 54,051 blocked sites, 279 scam addresses.

* fix(lists): commit the .build shard, and refuse a shard git ignores

.gitignore held "build/", which also matched all/build/all.json, the shard of
the .build top-level domain. Every sort wrote it and no commit held it, so the
eight .build scam sites were never published; upstream has the same gap. The
directory is excluded from the ignore rule, the shard is committed, and
checkLists.mjs now fails on any shard git would ignore (with the old rule
restored it does).

Also clears the repository's lint on lists.test.mjs: imports sorted, and
node:test's describe and it calls marked void.

* test(lists): await describe and it, as the repository's lint requires

no-floating-promises requires the promises node:test's describe and it return
to be handled, and no-void forbids handling them with void. Awaited, the 25
cases run as before.
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-09-13 17:34:51 +04:00
1 parent 70bbc740cf
commit 767a92904d
112 files changed
+5311 -4811

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
// Copyright 2020-2026 @pezkuwi/phishing authors & contributors
// SPDX-License-Identifier: Apache-2.0
// node scripts/checkLists.mjs [dir]
//
// Whether the committed lists can be published: well formed, sharded the way the package reads
// them, none of our own domains blocked, our own entries in place, and every file exactly what
// sortAll writes. CI runs it on every change; the daily sync must pass it before it commits.
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { problems, readLists, readOverlay } from './lists.mjs';
const dir = path.resolve(process.argv[2] ?? '.');
const lists = readLists(dir);
const found = problems(lists, readOverlay(dir));
// A shard on disk is not a shard on master: .gitignore held "build/", so all/build/all.json was
// written by every sort and committed by none, and the host publishes what git holds.
const shardFiles = Object.keys(lists.shards).map((top) => path.join('all', top, 'all.json'));
const ignored = spawnSync('git', ['check-ignore', '--', ...shardFiles], { cwd: dir, encoding: 'utf8' });
if (ignored.status === 0) {
for (const file of ignored.stdout.trim().split('\n')) {
found.push(`${file} is ignored by git and would never be published`);
}
}
/**
* Every file under a directory, relative path to contents.
* @param {string} root
* @returns {Record<string, string>}
*/
function tree (root) {
/** @type {Record<string, string>} */
const files = {};
for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) {
const full = path.join(entry.parentPath, entry.name);
files[path.relative(root, full)] = fs.readFileSync(full, 'utf8');
}
}
return files;
}
// sortAll is the one writer of these files. Run it on a copy: if the copy comes out different,
// the committed files were edited into a shape the next sync would rewrite, or the metadata is
// missing entries that sortAll would stamp with today's date on every run.
const copy = fs.mkdtempSync(path.join(os.tmpdir(), 'phishing-canonical-'));
try {
for (const name of ['all.json', 'address.json', 'meta']) {
fs.cpSync(path.join(dir, name), path.join(copy, name), { recursive: true });
}
const sortAll = fileURLToPath(new URL('./sortAll.mjs', import.meta.url));
const run = spawnSync(process.execPath, [sortAll], { cwd: copy, encoding: 'utf8' });
if (run.status !== 0) {
found.push(`sortAll failed: ${run.stderr.trim()}`);
} else {
for (const name of ['all.json', 'address.json']) {
if (fs.readFileSync(path.join(dir, name), 'utf8') !== fs.readFileSync(path.join(copy, name), 'utf8')) {
found.push(`${name} is not in the form sortAll writes`);
}
}
for (const name of ['all', 'meta']) {
if (JSON.stringify(tree(path.join(dir, name))) !== JSON.stringify(tree(path.join(copy, name)))) {
found.push(`${name}/ is not what sortAll writes`);
}
}
}
} finally {
fs.rmSync(copy, { force: true, recursive: true });
}
if (found.length) {
for (const problem of found) {
console.error(`FAIL ${problem}`);
}
console.error(`${found.length} problem(s)`);
process.exit(1);
}
console.log(`lists OK: ${lists.all.deny.length} blocked sites, ${Object.values(lists.address).flat().length} scam addresses, ${Object.keys(lists.shards).length} shards`);
+312
View File
@@ -0,0 +1,312 @@
// Copyright 2020-2026 @pezkuwi/phishing authors & contributors
// SPDX-License-Identifier: Apache-2.0
// What the Pezkuwi copy of the lists is held to. Plain Node, no dependencies, so the daily
// upstream sync, the lists check and their tests run without the build toolchain:
//
// - how our own entries (pezkuwi/overlay.json) are laid over polkadot-js/phishing;
// - what a list must look like before it is published;
// - how much one sync may change it before a person has to look.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
/** @typedef {{ allow: string[]; deny: string[]; denySub: string[] }} AllList */
/** @typedef {{ date: string; reason: string; url: string }} OverlayEntry */
/** @typedef {{ deny: OverlayEntry[]; protect: string[]; undeny: OverlayEntry[] }} Overlay */
/** @typedef {{ address: Record<string, string[]>; all: AllList; shards: Record<string, string[]> }} Lists */
/** @typedef {{ added: string[]; addressesAdded: string[]; addressesRemoved: string[]; removed: string[] }} Change */
// Upstream added 701 sites and removed 3 in the eight months to September 2026. A sync that
// removes more than this, or adds more, is not an ordinary day, and a person decides.
export const LIMITS = { maxAddressesRemoved: 10, maxDenyAdded: 5000, maxDenyRemoved: 25 };
const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/** @param {string} file */
export function readJson (file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/**
* The layout sortAll writes: tab-indented, with a trailing newline.
* @param {string} file
* @param {unknown} value
*/
export function writeJson (file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, '\t')}\n`);
}
/**
* @param {string} dir
* @returns {Lists}
*/
export function readLists (dir) {
/** @type {Record<string, string[]>} */
const shards = {};
const allDir = path.join(dir, 'all');
// Only directories are shards. Anything else in all/ is not something the package reads; the
// lists check still flags it, because it is not what sortAll writes.
const tops = fs.existsSync(allDir)
? fs.readdirSync(allDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map(({ name }) => name).sort()
: [];
for (const top of tops) {
shards[top] = readJson(path.join(allDir, top, 'all.json'));
}
return {
address: readJson(path.join(dir, 'address.json')),
all: readJson(path.join(dir, 'all.json')),
shards
};
}
/**
* @param {string} dir
* @returns {Overlay}
*/
export function readOverlay (dir) {
return readJson(path.join(dir, 'pezkuwi', 'overlay.json'));
}
/** @param {string} host */
export function hostParts (host) {
return host.toLowerCase().replace(/\.$/, '').split('.').reverse();
}
/**
* Whether a deny entry blocks a host, by the package's own rule (checkHostParts in
* packages/phishing/src/bundle.ts): the entry's labels match the host's, counted from the right.
* @param {string} entry
* @param {string} host
*/
export function blocks (entry, host) {
const e = hostParts(entry);
const h = hostParts(host);
return e.length <= h.length && e.every((part, i) => h[i] === part);
}
/**
* @param {AllList} upstream
* @param {Overlay} overlay
* @returns {AllList}
*/
export function mergeAll (upstream, overlay) {
const undeny = new Set(overlay.undeny.map(({ url }) => url));
return {
allow: upstream.allow,
deny: upstream.deny.filter((url) => !undeny.has(url)).concat(overlay.deny.map(({ url }) => url)),
denySub: upstream.denySub
};
}
/**
* Our entries carry their own date. Without it sortAll would stamp them with the day it runs,
* and every daily sync would rewrite the metadata.
* @param {{ date: string; url: string }[]} meta
* @param {Overlay} overlay
*/
export function mergeMeta (meta, overlay) {
const have = new Set(meta.map(({ url }) => url));
return meta.concat(
overlay.deny
.filter(({ url }) => !have.has(url))
.map(({ date, url }) => ({ date, url }))
);
}
/** @param {string} text */
function base58 (text) {
let n = 0n;
for (const ch of text) {
const value = BASE58.indexOf(ch);
if (value < 0) {
return null;
}
n = (n * 58n) + BigInt(value);
}
/** @type {number[]} */
const bytes = [];
while (n > 0n) {
bytes.unshift(Number(n % 256n));
n /= 256n;
}
for (const ch of text) {
if (ch !== '1') {
break;
}
bytes.unshift(0);
}
return Uint8Array.from(bytes);
}
/**
* An SS58 address holding a 32-byte key with a valid checksum: what the package's decodeAddress
* accepts, and what the upstream test checks with @pezkuwi/util-crypto.
* @param {string} address
*/
export function validAddress (address) {
const bytes = typeof address === 'string' ? base58(address) : null;
if (!bytes || bytes.length < 35) {
return false;
}
const prefixLength = (bytes[0] & 0b0100_0000) ? 2 : 1;
if (bytes.length !== prefixLength + 32 + 2) {
return false;
}
const hash = crypto
.createHash('blake2b512')
.update(Buffer.concat([Buffer.from('SS58PRE'), bytes.subarray(0, bytes.length - 2)]))
.digest();
return hash[0] === bytes[bytes.length - 2] && hash[1] === bytes[bytes.length - 1];
}
/**
* Everything that makes a list unfit to publish.
* @param {Lists} lists
* @param {Overlay} overlay
* @returns {string[]}
*/
export function problems ({ address, all, shards }, overlay) {
/** @type {string[]} */
const found = [];
const seen = new Set();
for (const url of all.deny) {
// A trailing dot sorts the entry under an empty top-level domain, where the package never looks.
if (typeof url !== 'string' || !url.includes('.') || /[/?\s]/.test(url) || url.startsWith('www.') || url.endsWith('.')) {
found.push(`malformed deny entry: ${url}`);
} else if (seen.has(url)) {
found.push(`duplicate deny entry: ${url}`);
}
seen.add(url);
}
// The upstream test's rule: a denied site may not be an allowed one, or match an allowed '*.' pattern.
for (const url of all.deny) {
const allowed = all.allow.find((t) =>
(t.startsWith('*.') && url.split('.').length === t.split('.').length)
? (url.endsWith(t.substring(1)) || url === t.substring(2))
: url === t
);
if (allowed) {
found.push(`${url} is both denied and allowed (${allowed})`);
}
}
// The package reads a site's list from all/<top-level domain>/all.json, not from all.json.
/** @type {Record<string, string[]>} */
const byTop = {};
for (const url of all.deny) {
(byTop[url.split('.').pop() ?? ''] ??= []).push(url);
}
for (const top of new Set([...Object.keys(byTop), ...Object.keys(shards)])) {
if (JSON.stringify(byTop[top] ?? []) !== JSON.stringify(shards[top] ?? [])) {
found.push(`all/${top}/all.json does not match all.json`);
}
}
for (const [site, addresses] of Object.entries(address)) {
const bad = Array.isArray(addresses)
? addresses.filter((a) => !validAddress(a))
: ['(not a list)'];
if (bad.length) {
found.push(`invalid addresses for ${site}: ${bad.join(', ')}`);
}
}
for (const domain of overlay.protect) {
const entry = all.deny.find((url) => blocks(url, domain));
if (entry) {
found.push(`${domain} is ours and would be blocked by the entry ${entry}`);
}
}
for (const { url } of overlay.deny) {
if (!all.deny.some((entry) => blocks(entry, url))) {
found.push(`our entry ${url} is not blocked`);
}
}
for (const { url } of overlay.undeny) {
if (all.deny.includes(url)) {
found.push(`${url} is blocked although we unblocked it`);
}
}
return found;
}
/**
* @param {Lists} before
* @param {Lists} after
* @returns {Change}
*/
export function changes (before, after) {
const was = before.all.deny;
const now = after.all.deny;
const wasSet = new Set(was);
const nowSet = new Set(now);
const wasAddresses = new Set(Object.values(before.address).flat());
const nowAddresses = new Set(Object.values(after.address).flat());
return {
added: now.filter((url) => !wasSet.has(url)),
addressesAdded: [...nowAddresses].filter((a) => !wasAddresses.has(a)),
addressesRemoved: [...wasAddresses].filter((a) => !nowAddresses.has(a)),
// Sites no longer blocked, not entries no longer written. sortAll drops a subdomain once its
// parent is listed (monad.prenads.xyz when prenads.xyz arrived), and folds "scam.xyz." into
// "scam.xyz" (816 entries in September 2026); in both the site stays blocked, by blocks()'s rule.
removed: was.filter((url) => !nowSet.has(url) && !now.some((entry) => blocks(entry, url)))
};
}
/**
* The limits one sync may not cross on its own.
* @param {Change} change
* @param {typeof LIMITS} [limits]
* @returns {string[]}
*/
export function guard (change, limits = LIMITS) {
/** @type {string[]} */
const found = [];
if (change.removed.length > limits.maxDenyRemoved) {
found.push(`${change.removed.length} blocked sites would be unblocked (limit ${limits.maxDenyRemoved}): ${change.removed.slice(0, 20).join(', ')}`);
}
if (change.added.length > limits.maxDenyAdded) {
found.push(`${change.added.length} sites would be added (limit ${limits.maxDenyAdded})`);
}
if (change.addressesRemoved.length > limits.maxAddressesRemoved) {
found.push(`${change.addressesRemoved.length} scam addresses would be dropped (limit ${limits.maxAddressesRemoved})`);
}
return found;
}
+317
View File
@@ -0,0 +1,317 @@
// Copyright 2020-2026 @pezkuwi/phishing authors & contributors
// SPDX-License-Identifier: Apache-2.0
// node --test scripts/lists.test.mjs
//
// The rules in lists.mjs, and the sync end to end against a scratch upstream repository: our
// entries survive every sync, our own domains are never blocked, and a day out of bounds writes
// nothing a commit could pick up.
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { blocks, changes, guard, LIMITS, mergeAll, mergeMeta, problems, validAddress } from './lists.mjs';
const SCRIPTS = path.dirname(fileURLToPath(import.meta.url));
// From the package's own tests: a Polkadot-prefix address and a generic-prefix one.
const ADDRESS = '14Vxs7UB9FqfQ53wwTJUBAJThs5N7b3bg89HscRU6eBqrFhQ';
const ADDRESS_42 = '5FkmzcdNekhdSA7j4teSSyHGUnKT8bzNBFvVVeZSGmbSpYHH';
const OURS = { date: '2026-09-13', reason: 'test', url: 'pezkuwichain.org' };
/** @param {Partial<import('./lists.mjs').Overlay>} [extra] */
function overlay (extra = {}) {
return { deny: [], protect: [], undeny: [], ...extra };
}
/**
* A list with its shards laid out the way sortAll writes them.
* @param {string[]} deny
* @param {Partial<import('./lists.mjs').Lists>} [extra]
*/
function lists (deny, extra = {}) {
/** @type {Record<string, string[]>} */
const shards = {};
for (const url of deny) {
(shards[url.split('.').pop() ?? ''] ??= []).push(url);
}
return { address: {}, all: { allow: [], deny, denySub: [] }, shards, ...extra };
}
await describe('blocks', async () => {
await it('matches the site and every subdomain of it', () => {
assert.equal(blocks('polkadotfund.com', 'polkadotfund.com'), true);
assert.equal(blocks('polkadotfund.com', 'some.where.polkadotfund.com'), true);
});
await it('matches by whole labels, not by text', () => {
assert.equal(blocks('chain.io', 'pezkuwichain.io'), false);
assert.equal(blocks('pezkuwichain.io.evil.com', 'pezkuwichain.io'), false);
});
await it('lets a short entry block everything under it', () => {
assert.equal(blocks('io', 'pezkuwichain.io'), true);
});
});
await describe('validAddress', async () => {
await it('accepts the addresses the package accepts', () => {
assert.equal(validAddress(ADDRESS), true);
assert.equal(validAddress(ADDRESS_42), true);
});
await it('refuses a broken checksum, a foreign alphabet and a short key', () => {
assert.equal(validAddress(`${ADDRESS.slice(0, -1)}R`), false);
assert.equal(validAddress(ADDRESS.replace('V', '0')), false);
assert.equal(validAddress(ADDRESS.slice(0, 20)), false);
});
});
await describe('mergeAll and mergeMeta', async () => {
await it('adds our entries and drops the ones we unblocked', () => {
const merged = mergeAll(
{ allow: ['polkadot.network'], deny: ['scam.com', 'wrong.com'], denySub: [] },
overlay({ deny: [OURS], undeny: [{ date: '2026-09-13', reason: 'test', url: 'wrong.com' }] })
);
assert.deepEqual(merged, { allow: ['polkadot.network'], deny: ['scam.com', 'pezkuwichain.org'], denySub: [] });
});
await it('dates our entries once, and never again', () => {
const meta = [{ date: '2026-01-05', url: 'scam.com' }];
assert.deepEqual(mergeMeta(meta, overlay({ deny: [OURS] })), [...meta, { date: '2026-09-13', url: 'pezkuwichain.org' }]);
assert.deepEqual(mergeMeta([...meta, { date: '2026-09-13', url: 'pezkuwichain.org' }], overlay({ deny: [OURS] })).length, 2);
});
});
await describe('problems', async () => {
await it('finds none in a clean list', () => {
assert.deepEqual(problems(lists(['scam.com', 'pezkuwichain.org'], { address: { 'polkadot.center': [ADDRESS] } }), overlay({ deny: [OURS], protect: ['pezkuwichain.io'] })), []);
});
await it('refuses a list that blocks one of our domains, exactly or from above', () => {
assert.match(problems(lists(['pezkuwichain.io']), overlay({ protect: ['pezkuwichain.io'] })).join(), /is ours/);
assert.match(problems(lists(['wallet.pezkuwichain.io', 'chain.io']), overlay({ protect: ['wallet.pezkuwichain.io'] })).join(), /is ours/);
});
await it('refuses a list that lost one of our entries, or kept one we unblocked', () => {
assert.match(problems(lists(['scam.com']), overlay({ deny: [OURS] })).join(), /is not blocked/);
assert.match(problems(lists(['wrong.com']), overlay({ undeny: [{ date: '2026-09-13', reason: 'test', url: 'wrong.com' }] })).join(), /although we unblocked/);
});
await it('refuses malformed and duplicate entries', () => {
for (const bad of ['scam.com/path', 'www.scam.com', 'nodot', 'sc am.com', 'scam.com?x', 'scam.xyz.']) {
assert.match(problems(lists([bad]), overlay()).join(), /malformed/, bad);
}
assert.match(problems(lists(['scam.com', 'scam.com']), overlay()).join(), /duplicate/);
});
await it('refuses a site both denied and allowed', () => {
const allowed = lists(['polkadot.network', 'app.subsocial.network'], {});
allowed.all.allow = ['polkadot.network', '*.subsocial.network'];
assert.equal(problems(allowed, overlay()).filter((p) => p.includes('denied and allowed')).length, 2);
});
await it('refuses shards that disagree with all.json', () => {
const drifted = lists(['scam.com', 'scam.org']);
drifted.shards['org'] = [];
assert.match(problems(drifted, overlay()).join(), /all\/org\/all\.json/);
});
await it('refuses an invalid scam address', () => {
assert.match(problems(lists(['scam.com'], { address: { 'scam.com': [`${ADDRESS.slice(0, -1)}R`] } }), overlay()).join(), /invalid addresses/);
});
});
await describe('guard', async () => {
const before = lists(Array.from({ length: 40 }, (_, i) => `scam${i}.com`), { address: { 'scam0.com': Array(12).fill(ADDRESS).map((a, i) => i ? `${a}${i}` : a) } });
await it('lets an ordinary day through', () => {
assert.deepEqual(guard(changes(before, lists([...before.all.deny.slice(3), 'new.com'], { address: before.address }))), []);
});
await it('stops a day that unblocks more sites than the limit', () => {
const after = lists(before.all.deny.slice(LIMITS.maxDenyRemoved + 1), { address: before.address });
assert.match(guard(changes(before, after)).join(), /would be unblocked/);
});
await it('stops a day that adds more sites than the limit', () => {
const after = lists([...before.all.deny, ...Array.from({ length: LIMITS.maxDenyAdded + 1 }, (_, i) => `new${i}.com`)], { address: before.address });
assert.match(guard(changes(before, after)).join(), /would be added/);
});
await it('does not count a trailing dot folded away as an unblocked site', () => {
const dotted = lists([...before.all.deny, ...before.all.deny.map((url) => `${url}.`)], { address: before.address });
assert.deepEqual(changes(dotted, before).removed, []);
});
await it('does not count a subdomain folded into its newly listed parent as unblocked', () => {
const after = lists(['prenads.xyz', ...before.all.deny], { address: before.address });
assert.deepEqual(changes(lists(['monad.prenads.xyz', ...before.all.deny], { address: before.address }), after).removed, []);
});
await it('stops a day that drops more scam addresses than the limit', () => {
assert.match(guard(changes(before, lists(before.all.deny))).join(), /scam addresses would be dropped/);
});
});
await describe('syncUpstream.mjs, end to end', async () => {
/**
* @param {string} cwd
* @param {string[]} args
*/
function git (cwd, ...args) {
return execFileSync('git', ['-c', 'user.email=test@example.invalid', '-c', 'user.name=test', ...args], { cwd, encoding: 'utf8' });
}
/**
* @param {string} dir
* @param {string} file
* @param {unknown} value
*/
function write (dir, file, value) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), `${JSON.stringify(value, null, '\t')}\n`);
}
/**
* A new upstream commit holding these lists.
* @param {string} upstream
* @param {string[]} deny
*/
function publish (upstream, deny) {
write(upstream, 'all.json', { allow: [], deny, denySub: [] });
write(upstream, 'address.json', { 'polkadot.center': [ADDRESS] });
write(upstream, 'known.json', {});
write(upstream, 'meta/index.json', ['2026-01']);
write(upstream, 'meta/2026-01.json', [...deny, 'polkadot.center'].map((url) => ({ date: '2026-01-05', url })));
git(upstream, 'add', '-A');
git(upstream, 'commit', '-qm', 'lists');
}
/** @param {(dirs: { ours: string; upstream: string }) => void} body */
function withRepos (body) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'phishing-sync-test-'));
const upstream = path.join(root, 'upstream');
const ours = path.join(root, 'ours');
try {
git(root, 'init', '-q', '-b', 'master', upstream);
git(root, 'init', '-q', '-b', 'master', ours);
// Only the scripts, no node_modules: the sync must run without the build toolchain.
for (const script of ['checkLists.mjs', 'lists.mjs', 'sortAll.mjs', 'syncUpstream.mjs']) {
fs.mkdirSync(path.join(ours, 'scripts'), { recursive: true });
fs.copyFileSync(path.join(SCRIPTS, script), path.join(ours, 'scripts', script));
}
write(ours, 'pezkuwi/overlay.json', overlay({ deny: [OURS], protect: ['pezkuwichain.io'] }));
write(ours, 'all.json', { allow: [], deny: [], denySub: [] });
write(ours, 'address.json', {});
write(ours, 'meta/index.json', []);
git(ours, 'add', '-A');
git(ours, 'commit', '-qm', 'start');
body({ ours, upstream });
} finally {
fs.rmSync(root, { force: true, recursive: true });
}
}
/**
* @param {string} ours
* @param {string} upstream
*/
function sync (ours, upstream) {
git(ours, 'fetch', '-q', upstream, 'master');
const run = spawnSync(process.execPath, ['scripts/syncUpstream.mjs', 'FETCH_HEAD'], { cwd: ours, encoding: 'utf8' });
return { ...run, last: run.stdout.trim().split('\n').pop() ?? '' };
}
const SCAMS = Array.from({ length: 30 }, (_, i) => `scam${i}.com`);
await it('lays our entries over upstream and writes every derived file', () => {
withRepos(({ ours, upstream }) => {
publish(upstream, [...SCAMS, 'polkadot-js.org']);
const run = sync(ours, upstream);
assert.equal(run.status, 0, run.stderr);
assert.match(run.last, /^SYNC upstream=[0-9a-f]{40} changed=yes deny=\+33\/-0 addresses=\+1\/-0$/);
const all = JSON.parse(fs.readFileSync(path.join(ours, 'all.json'), 'utf8'));
const org = JSON.parse(fs.readFileSync(path.join(ours, 'all/org/all.json'), 'utf8'));
assert.equal(all.deny.includes('pezkuwichain.org'), true);
assert.deepEqual(org, ['pezkuwichain.org', 'polkadot-js.org']);
assert.equal(JSON.parse(fs.readFileSync(path.join(ours, 'pezkuwi/upstream.json'), 'utf8')).commit, git(upstream, 'rev-parse', 'HEAD').trim());
const check = spawnSync(process.execPath, ['scripts/checkLists.mjs'], { cwd: ours, encoding: 'utf8' });
assert.equal(check.status, 0, check.stderr);
});
});
await it('files a name written with a trailing dot under its real top-level domain', () => {
withRepos(({ ours, upstream }) => {
publish(upstream, [...SCAMS, 'scam0.com.', 'only-dotted.xyz.']);
const run = sync(ours, upstream);
assert.equal(run.status, 0, run.stderr);
assert.equal(fs.existsSync(path.join(ours, 'all/all.json')), false);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ours, 'all/xyz/all.json'), 'utf8')), ['only-dotted.xyz']);
assert.equal(JSON.parse(fs.readFileSync(path.join(ours, 'all.json'), 'utf8')).deny.filter((url) => url.startsWith('scam0.com')).length, 1);
});
});
await it('changes nothing when upstream has not moved', () => {
withRepos(({ ours, upstream }) => {
publish(upstream, SCAMS);
assert.equal(sync(ours, upstream).status, 0);
git(ours, 'add', '-A');
git(ours, 'commit', '-qm', 'synced');
assert.match(sync(ours, upstream).last, /changed=no deny=\+0\/-0/);
});
});
await it('refuses a day on which upstream would block one of our domains', () => {
withRepos(({ ours, upstream }) => {
publish(upstream, [...SCAMS, 'pezkuwichain.io']);
const run = sync(ours, upstream);
assert.equal(run.status, 2);
assert.match(run.last, /^SYNC-REFUSED /);
assert.match(run.stderr, /pezkuwichain\.io is ours/);
});
});
await it('refuses a day that unblocks more sites than the limit', () => {
withRepos(({ ours, upstream }) => {
publish(upstream, SCAMS);
assert.equal(sync(ours, upstream).status, 0);
publish(upstream, SCAMS.slice(0, 3));
const run = sync(ours, upstream);
assert.equal(run.status, 2);
assert.match(run.stderr, /27 blocked sites would be unblocked/);
});
});
});
+20 -4
View File
@@ -3,8 +3,18 @@
import fs from 'node:fs';
// @ts-expect-error @pezkuwi/dev scripts don't have .d.ts files
import { mkdirpSync, rimrafSync } from '@pezkuwi/dev/scripts/util.mjs';
// Node's own file system only: the daily upstream sync and the lists check run this without
// installing the build toolchain.
/** @param {string} dir */
function mkdirpSync (dir) {
fs.mkdirSync(dir, { recursive: true });
}
/** @param {string} dir */
function rimrafSync (dir) {
fs.rmSync(dir, { force: true, recursive: true });
}
/** @typedef {{ allow: string[]; deny: string[]; denySub: string[] }} AllList */
@@ -16,9 +26,15 @@ const KNOWN_URLS = ['telegra.ph', 'twitter.com', 'youtube.com', 'x.com'];
* @returns {string}
*/
function sanitizeUrl (url, allowSub) {
const naked = url.includes('://')
const naked = (url.includes('://')
? url.split('://')[1]
: url;
: url
// A fully qualified name ends in a dot ("scam.xyz."). Kept, it sorts the entry into all/all.json
// under an empty top-level domain, where checkIfDenied, which reads all/xyz/all.json, never
// looks. Upstream held 817 such entries in September 2026: 816 repeated a name already listed
// without the dot, and one (prenads.xyz.) was blocked nowhere else. The package strips the dot
// from the host it checks, so the entry means the same without it.
).replace(/\.(?=\/|$)/, '');
return allowSub
// return without trailing /
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2020-2026 @pezkuwi/phishing authors & contributors
// SPDX-License-Identifier: Apache-2.0
// node scripts/syncUpstream.mjs <upstream git ref>
//
// Brings the lists to polkadot-js/phishing at the ref given (the caller fetches it), lays our own
// entries over them (pezkuwi/overlay.json), and lets sortAll write every derived file. Then it
// checks the result and compares it with what was committed before:
//
// exit 0 publishable; the working tree holds the lists (unchanged if upstream did not move)
// exit 2 a check or a limit refused them; commit nothing, a person decides
//
// The last line is always one of:
// SYNC upstream=<sha> changed=<yes|no> deny=+<n>/-<n> addresses=+<n>/-<n>
// SYNC-REFUSED upstream=<sha> problems=<n>
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { changes, guard, mergeAll, mergeMeta, problems, readLists, readOverlay, writeJson } from './lists.mjs';
const ref = process.argv[2];
if (!ref) {
console.error('usage: node scripts/syncUpstream.mjs <upstream git ref>');
process.exit(64);
}
/** @param {string[]} args */
function git (...args) {
return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 });
}
/** @param {string} file */
function upstream (file) {
return git('show', `${ref}:${file}`);
}
const upstreamSha = git('rev-parse', '--verify', `${ref}^{commit}`).trim();
const before = readLists('.');
const overlay = readOverlay('.');
// Upstream's metadata as it is, with our entries added: the months sortAll reads it from.
/** @type {{ date: string; url: string }[]} */
const meta = /** @type {string[]} */ (JSON.parse(upstream('meta/index.json')))
.flatMap((month) => JSON.parse(upstream(`meta/${month}.json`)));
/** @type {Record<string, { date: string; url: string }[]>} */
const months = {};
for (const item of mergeMeta(meta, overlay)) {
(months[item.date.slice(0, 7)] ??= []).push(item);
}
fs.rmSync('meta', { force: true, recursive: true });
fs.mkdirSync('meta');
for (const [month, items] of Object.entries(months)) {
writeJson(`meta/${month}.json`, items);
}
writeJson('meta/index.json', Object.keys(months).sort((a, b) => b.localeCompare(a)));
writeJson('all.json', mergeAll(JSON.parse(upstream('all.json')), overlay));
fs.writeFileSync('address.json', upstream('address.json'));
fs.writeFileSync('known.json', upstream('known.json'));
const sortAll = spawnSync(process.execPath, [fileURLToPath(new URL('./sortAll.mjs', import.meta.url))], { stdio: 'inherit' });
const after = readLists('.');
const change = changes(before, after);
const found = sortAll.status === 0
? [...problems(after, overlay), ...guard(change)]
: [`sortAll failed with status ${sortAll.status}`];
if (found.length) {
for (const problem of found) {
console.error(`FAIL ${problem}`);
}
console.log(`SYNC-REFUSED upstream=${upstreamSha} problems=${found.length}`);
process.exit(2);
}
const changed = git('status', '--porcelain', '--', 'all.json', 'address.json', 'known.json', 'all', 'meta').trim() !== '';
// Recorded only when the lists moved: upstream commits that touch only its package would
// otherwise rewrite this file every day.
if (changed) {
fs.mkdirSync('pezkuwi', { recursive: true });
writeJson('pezkuwi/upstream.json', { commit: upstreamSha });
}
for (const url of change.removed) {
console.log(`unblocked: ${url}`);
}
console.log(`SYNC upstream=${upstreamSha} changed=${changed ? 'yes' : 'no'} deny=+${change.added.length}/-${change.removed.length} addresses=+${change.addressesAdded.length}/-${change.addressesRemoved.length}`);