Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/trusted-list-bypass-reviewers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
"samczsun",
"409h",
"tayvano",
"mindofmar",
"0xOhm",
"security-alliance-bot",
"pcaversaccio"
]
108 changes: 108 additions & 0 deletions .github/workflows/trusted-list-bypass.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: Trusted List Bypass

on:
issue_comment:
types: [created]

permissions:
contents: write
issues: write
pull-requests: read

jobs:
trusted-list-bypass:
name: Trusted List Bypass
if: github.event.issue.pull_request && (startsWith(github.event.comment.body, '/skip-trusted-lists') || github.event.comment.body == 'Skip trusted lists')
Comment thread
cursor[bot] marked this conversation as resolved.
runs-on: ubuntu-latest
steps:
- name: Get pull request
id: pr
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const { data: pull } = await github.rest.pulls.get({
owner,
repo,
pull_number: context.issue.number,
});

core.setOutput('head_repo', pull.head.repo.full_name);
core.setOutput('head_ref', pull.head.ref);
core.setOutput('is_fork', String(pull.head.repo.full_name !== `${owner}/${repo}`));

- name: Report unsupported fork
if: steps.pr.outputs.is_fork == 'true'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: 'Trusted-list bypass automation can only push updates to branches in this repository. Please have a maintainer apply the bypass file update manually.',
});

- name: Checkout automation
if: steps.pr.outputs.is_fork == 'false'
uses: actions/checkout@v4
with:
path: automation

- name: Use Node.js
if: steps.pr.outputs.is_fork == 'false'
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: yarn
cache-dependency-path: automation/yarn.lock

- name: Install automation dependencies
if: steps.pr.outputs.is_fork == 'false'
working-directory: automation
run: yarn --frozen-lockfile

- name: Checkout pull request branch
if: steps.pr.outputs.is_fork == 'false'
uses: actions/checkout@v4
with:
repository: ${{ steps.pr.outputs.head_repo }}
ref: ${{ steps.pr.outputs.head_ref }}
path: pull-request

- name: Apply trusted-list bypass
if: steps.pr.outputs.is_fork == 'false'
working-directory: automation
env:
TRUSTED_LIST_BYPASS_APPROVED_BY: ${{ github.event.comment.user.login }}
TRUSTED_LIST_BYPASS_COMMENT: ${{ github.event.comment.body }}
TRUSTED_LIST_BYPASS_PR_NUMBER: ${{ github.event.issue.number }}
TRUSTED_LIST_BYPASS_TARGET: ${{ github.workspace }}/pull-request
run: node --import tsx bin/apply-trusted-list-bypass.ts

- name: Commit trusted-list bypass
if: success() && steps.pr.outputs.is_fork == 'false'
working-directory: pull-request
env:
HEAD_REF: ${{ steps.pr.outputs.head_ref }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add test/resources/trusted-list-bypass.txt
if git diff --cached --quiet; then
echo "No trusted-list bypass changes to commit."
exit 0
fi
git commit -m "Add trusted list bypass for PR #${PR_NUMBER}"
git push origin "HEAD:${HEAD_REF}"

- name: Report completion
if: success() && steps.pr.outputs.is_fork == 'false'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: 'Trusted-list bypass check completed. If a bypass was needed, I pushed it to this PR branch and CI will rerun.',
});
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,24 @@ removeDomains(config, "allowlist", ["crypto-phishing-site.tld"]);

## Safeguards

We maintain a list of domains pulled from various sources in `test/resources`. Each file is plaintext with one host per domain. These domains are used to reduce the risk of false positives. If you need to block a domain that is featured on one of these lists, you'll need to add a bypass to `test/test-lists.ts`.
We maintain trusted comparison lists in `test/resources`. Each file is plaintext with one host per line and is used by CI to reduce the risk of false positives. These lists include sources such as Tranco, CoinMarketCap, CoinGecko, the Snaps registry, and known dapps.

During `yarn test` / `yarn ci`, the list tests compare `src/config.json`'s `blacklist` entries against these trusted lists. If a blocklisted domain appears on one of the trusted lists and is not already bypassed, CI fails with the domain in the failure message. This is intentional: domains on these lists are often legitimate, so blocking them should require extra review.

Sometimes a trusted domain still needs to be blocked, for example during a DNS compromise, frontend compromise, or active malicious takeover. In those cases, add the exact blocklist entry to `test/resources/trusted-list-bypass.txt` with evidence or a short reason when possible:

```text
example.com # DNS compromise confirmed: https://example.com/evidence
```

Reviewers listed in `.github/trusted-list-bypass-reviewers.json` can also comment `/skip-trusted-lists` on a pull request. The automation will:

1. Confirm that the commenter is approved to request trusted-list bypasses.
2. Find trusted-list failures caused by the PR's current blocklist changes.
3. Append the required entries to `test/resources/trusted-list-bypass.txt`.
4. Commit the bypass file update back to the pull request branch so CI can rerun.

The automation can only push updates to pull request branches in this repository. For forked pull requests, a maintainer must apply the bypass file update manually.

To update the lists, run `yarn update:lists`. Note that you'll need a CoinMarketCap Pro API key.

Expand Down
149 changes: 149 additions & 0 deletions bin/apply-trusted-list-bypass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env node

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { detectFalsePositives, parseTrustedListBypass } from "../test/trusted-list-utils.js";
import { Config } from "../src/types.js";

const TRUSTED_LIST_IDS = ["tranco", "coinmarketcap", "snapsregistry", "coingecko", "dapps"];
const COMMANDS = new Set(["/skip-trusted-lists", "skip trusted lists"]);

const policyRoot = path.dirname(__dirname);
const targetRoot = process.env.TRUSTED_LIST_BYPASS_TARGET || policyRoot;
const resourcesDir = path.join(targetRoot, "test", "resources");
const bypassPath = path.join(resourcesDir, "trusted-list-bypass.txt");

const readJson = async <T>(filePath: string): Promise<T> => {
return JSON.parse(await readFile(filePath, { encoding: "utf-8" })) as T;
};

const normalizeHandle = (handle: string): string => {
return handle.replace(/^@/u, "").toLowerCase();
};

const readExistingBypass = async (): Promise<string> => {
try {
return await readFile(bypassPath, { encoding: "utf-8" });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}

return [
"# These domains are intentionally blocklisted even though they appear on a trusted comparison list.",
"# Add evidence or a short reason after the domain when possible.",
"",
].join("\n");
}
};

const getCommand = (): string => {
const firstLine = (process.env.TRUSTED_LIST_BYPASS_COMMENT || "").trim().split(/\r?\n/u)[0]?.trim() || "";
const normalizedFirstLine = firstLine.toLowerCase();

if (normalizedFirstLine.startsWith("/skip-trusted-lists")) {
return "/skip-trusted-lists";
}

return normalizedFirstLine;
};

const getApprovedBy = (): string => {
const approvedBy = process.env.TRUSTED_LIST_BYPASS_APPROVED_BY;
if (!approvedBy) {
throw new Error("TRUSTED_LIST_BYPASS_APPROVED_BY must be set");
}

return normalizeHandle(approvedBy);
};

const getPrNumber = (): string => {
const prNumber = process.env.TRUSTED_LIST_BYPASS_PR_NUMBER;
if (!prNumber) {
throw new Error("TRUSTED_LIST_BYPASS_PR_NUMBER must be set");
}

return prNumber;
};

const assertCommand = () => {
const command = getCommand();
if (!COMMANDS.has(command)) {
throw new Error(`Unsupported trusted-list bypass command: ${command}`);
}
};

const assertApprovedReviewer = async (approvedBy: string) => {
const reviewers = await readJson<string[]>(path.join(policyRoot, ".github", "trusted-list-bypass-reviewers.json"));
const normalizedReviewers = new Set(reviewers.map(normalizeHandle));

if (!normalizedReviewers.has(approvedBy)) {
throw new Error(`@${approvedBy} is not approved to apply trusted-list bypasses`);
}
};

const findBypassableEntries = async (config: Config, bypass: Set<string>): Promise<string[]> => {
const entries = new Set<string>();

for (const listId of TRUSTED_LIST_IDS) {
const contents = await readFile(path.join(resourcesDir, `${listId}.txt`), { encoding: "utf-8" });
const comparisonList = new Set(contents.split("\n"));
const falsePositives = detectFalsePositives(config.blacklist!, comparisonList, bypass);

for (const entry of falsePositives) {
const bypassWithEntry = new Set([...bypass, entry]);
const remaining = detectFalsePositives(config.blacklist!, comparisonList, bypassWithEntry);

if (!remaining.includes(entry)) {
entries.add(entry);
}
}
}

return Array.from(entries).sort();
};

const appendBypasses = async (contents: string, entries: string[], approvedBy: string, prNumber: string) => {
if (entries.length === 0) {
console.log("No trusted-list bypass entries need to be added.");
return;
}

const existing = parseTrustedListBypass(contents);
const newEntries = entries.filter((entry) => !existing.has(entry));

if (newEntries.length === 0) {
console.log("All trusted-list bypass entries are already present.");
return;
}

const lines = newEntries.map((entry) => `${entry} # trusted-list bypass approved by @${approvedBy} in PR #${prNumber}`);
const nextContents = `${contents.trimEnd()}\n${lines.join("\n")}\n`;

await writeFile(bypassPath, nextContents);
console.log(`Added trusted-list bypass entries: ${newEntries.join(", ")}`);
};

const main = async () => {
assertCommand();

const approvedBy = getApprovedBy();
const prNumber = getPrNumber();

await assertApprovedReviewer(approvedBy);

const [config, bypassContents] = await Promise.all([
readJson<Config>(path.join(targetRoot, "src", "config.json")),
readExistingBypass(),
]);

const bypass = parseTrustedListBypass(bypassContents);
const entries = await findBypassableEntries(config, bypass);

await appendBypasses(bypassContents, entries, approvedBy, prNumber);
};

main().catch((error) => {
console.error(error);
process.exit(1);
});
72 changes: 72 additions & 0 deletions test/resources/trusted-list-bypass.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# These domains are intentionally blocklisted even though they appear on a trusted comparison list.
# Add evidence or a short reason after the domain when possible.
mystrikingly.com
simdif.com
gb.net
btcs.love
ferozo.com
im-creator.com
free-ethereum.io
890m.com
b5z.net
test.com
multichain.org # https://twitter.com/MultichainOrg/status/1677180114227056641
dydx.exchange # https://x.com/dydx/status/1815780835473129702
ambient.finance # https://x.com/pcaversaccio/status/1846851269207392722
xyz.cutestat.com

# Below are unknown websites that should stay on the blocklist for brevity but make tests fail.
# This is likely because they exist on the Tranco list and for one reason or another have a high reputation score.
# NOTE: If it is on the Tranco list, please CONFIRM that you are NOT adding a false positive.
# This will trigger a manual review within the CI pipeline.
# Only once it is confirmed not to be a false positive can it be added to this list.
azureserv.com
dnset.com
dnsrd.com
prohoster.biz
kucoin.plus
ewp.live
sdstarfx.com
1mobile.com
v6.rocks
linkpc.net
bookmanga.com
lihi.cc
mytradift.com
anondns.net
bitkeep.vip
temporary.site
misecure.com
myz.info
ton-claim.org
servehalflife.com
earnstations.com
web3quests.com
qubitscube.com
teknik.io
nflfan.org
purworejokab.go.id
ditchain.org
kuex.com
cloud.dbank.com
bybi75-alternate.app.link
mz4t6.rdtk.io
tornadoeth.cash
ether.fi # https://x.com/ether_fi/status/1838643492102283571
curve.fi # https://x.com/CurveFinance/status/1922040492121829678
coinmarketcap.com # https://x.com/Auri_0x/status/1936173321244066273
cointelegraph.com # https://x.com/Cointelegraph/status/1936959898094583916
card.inertix.pro
pepe.vip # FE Compromise
kuroro.com # FE Compromise?
maxidogetoken.com
bondex.app # XSS
irys.xyz # Drainer frontends being hosted on IPFS
25u.com # DNS may be hijacked.
decentreland.live # somehow on tranco
mssg.me # hosting drainers
defi2026.z13.web.core.windows.net
samouraiwallet.com # https://x.com/econoalchemist/status/2035735206691315848
chainge.finance
cow.fi # https://x.com/CoWSwap/status/2044078590514327888
eth.limo # https://x.com/eth_limo/status/2045413512986411467
Loading
Loading