mirror of
https://github.com/pezkuwichain/phishing.git
synced 2026-10-20 18:56:29 +00:00
* 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.
318 lines
13 KiB
JavaScript
318 lines
13 KiB
JavaScript
// 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/);
|
|
});
|
|
});
|
|
});
|