diff --git a/.github/trusted-list-bypass-reviewers.json b/.github/trusted-list-bypass-reviewers.json new file mode 100644 index 000000000000..d05c5be949d6 --- /dev/null +++ b/.github/trusted-list-bypass-reviewers.json @@ -0,0 +1,9 @@ +[ + "samczsun", + "409h", + "tayvano", + "mindofmar", + "0xOhm", + "security-alliance-bot", + "pcaversaccio" +] diff --git a/.github/workflows/trusted-list-bypass.yml b/.github/workflows/trusted-list-bypass.yml new file mode 100644 index 000000000000..0289168eb32a --- /dev/null +++ b/.github/workflows/trusted-list-bypass.yml @@ -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') + 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.', + }); diff --git a/README.md b/README.md index d21831cef56f..1b1205384dcc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/apply-trusted-list-bypass.ts b/bin/apply-trusted-list-bypass.ts new file mode 100644 index 000000000000..99d7725612b5 --- /dev/null +++ b/bin/apply-trusted-list-bypass.ts @@ -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 (filePath: string): Promise => { + 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 => { + 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(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): Promise => { + const entries = new Set(); + + 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(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); +}); diff --git a/test/resources/trusted-list-bypass.txt b/test/resources/trusted-list-bypass.txt new file mode 100644 index 000000000000..818904c646ab --- /dev/null +++ b/test/resources/trusted-list-bypass.txt @@ -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 diff --git a/test/test-lists.ts b/test/test-lists.ts index 18a1eba50858..0881b349629e 100644 --- a/test/test-lists.ts +++ b/test/test-lists.ts @@ -1,91 +1,21 @@ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; +import { readFile } from "node:fs/promises"; +import path from "node:path"; import test from "tape"; -import { Config } from '../src/types'; -import { detectFalsePositives, parseDomainWithCustomPSL } from './utils'; +import { Config } from "../src/types"; +import { detectFalsePositives, parseTrustedListBypass } from "./trusted-list-utils.js"; -// This is a list of "bad domains" (false positive) that we don't want to include in the Tranco test -const bypass = new Set([ - "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 repuatation 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 CICD 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 -]); +const resourcesDir = path.join(__dirname, "resources"); export const runTests = (config: Config) => { const testList = (listId: string) => { - test(`ensure no domains on allowlist are blocked: ${listId}`, async (t) => { - const contents = await readFile(path.join(__dirname, "resources", `${listId}.txt`), { encoding: 'utf-8' }); + test(`ensure no trusted-list domains are blocked: ${listId}`, async (t) => { + const [contents, bypassContents] = await Promise.all([ + readFile(path.join(resourcesDir, `${listId}.txt`), { encoding: "utf-8" }), + readFile(path.join(resourcesDir, "trusted-list-bypass.txt"), { encoding: "utf-8" }), + ]); const domains = new Set(contents.split("\n")); + const bypass = parseTrustedListBypass(bypassContents); const falsePositives = detectFalsePositives(config.blacklist!, domains, bypass); @@ -101,4 +31,3 @@ export const runTests = (config: Config) => { testList("coingecko"); testList("dapps"); }; - diff --git a/test/trusted-list-detection.ts b/test/trusted-list-detection.ts new file mode 100644 index 000000000000..c18afa8c7360 --- /dev/null +++ b/test/trusted-list-detection.ts @@ -0,0 +1,85 @@ +import { parse } from "tldts"; +import { customTlds } from "./custom-tlds.js"; +import { PATH_REQUIRED_DOMAINS } from "./path-enabled-domains.js"; + +type CustomParseResult = { + domain: string | null; + subdomain: string | null; + publicSuffix: string | null; +}; + +export function parseDomainWithCustomPSL(domain: string): CustomParseResult { + const customSuffix = customTlds.find((suffix) => domain === suffix || domain.endsWith("." + suffix)); + + if (customSuffix) { + const parts = domain.split("."); + const suffixParts = customSuffix.split("."); + const domainParts = parts.slice(0, parts.length - suffixParts.length); + const mainDomain = domainParts.length > 0 ? domainParts.join(".") : ""; + + return { + domain: mainDomain ? `${mainDomain}.${customSuffix}` : customSuffix, + subdomain: mainDomain, + publicSuffix: customSuffix, + }; + } + + const parsedDomain = parse(domain, { + allowPrivateDomains: true, + }); + + return { + domain: parsedDomain.domain, + subdomain: parsedDomain.subdomain, + publicSuffix: parsedDomain.publicSuffix, + }; +} + +export function extractHostname(domainWithPath: string): string { + try { + const url = new URL(`https://${domainWithPath}`); + return url.hostname; + } catch { + return domainWithPath.substring(0, domainWithPath.indexOf("/")); + } +} + +function isInvalidPathDomain(hostname: string): boolean { + const hasPath = hostname.includes("/"); + if (!hasPath) return false; + + const domainPart = extractHostname(hostname); + return !PATH_REQUIRED_DOMAINS.includes(domainPart); +} + +function isOnComparisonListNotBypassed( + hostname: string, + comparisonList: Set, + bypassList: Set, +): boolean { + if (bypassList.has(hostname)) return false; + + // Only check domains without paths - domains with paths are allowed to be blocked specifically. + if (hostname.includes("/")) return false; + + const parsedDomain = parseDomainWithCustomPSL(hostname); + return comparisonList.has(parsedDomain.domain || ""); +} + +export function detectFalsePositives( + blocklist: string[], + comparisonList: Set, + bypassList: Set, +): string[] { + return blocklist.filter((hostname) => { + if (isInvalidPathDomain(hostname)) { + return true; + } + + if (isOnComparisonListNotBypassed(hostname, comparisonList, bypassList)) { + return true; + } + + return false; + }); +} diff --git a/test/trusted-list-utils.ts b/test/trusted-list-utils.ts new file mode 100644 index 000000000000..72d329e97d9e --- /dev/null +++ b/test/trusted-list-utils.ts @@ -0,0 +1,10 @@ +export { detectFalsePositives, parseDomainWithCustomPSL } from "./trusted-list-detection.js"; + +export function parseTrustedListBypass(contents: string): Set { + return new Set( + contents + .split(/\r?\n/u) + .map((line) => line.replace(/^\s*#.*$/u, "").replace(/\s+#.*$/u, "").trim()) + .filter(Boolean), + ); +} diff --git a/test/utils.ts b/test/utils.ts index 6b5e5cee171e..21e8fb085550 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -3,9 +3,8 @@ import punycode from "punycode/"; import { cleanAllowlist, cleanBlocklist } from "../src/clean-config.js"; import PhishingDetector from "../src/detector.js"; import { Config } from "../src/types.js"; -import { parse } from 'tldts'; -import { customTlds } from './custom-tlds.js'; -import { PATH_REQUIRED_DOMAINS } from './path-enabled-domains.js'; +import { PATH_REQUIRED_DOMAINS } from "./path-enabled-domains.js"; +import { detectFalsePositives, extractHostname, parseDomainWithCustomPSL } from "./trusted-list-detection.js"; export const testBlocklist = (t: Test, domains: string[], options: Config) => { const detector = new PhishingDetector(options); @@ -230,40 +229,6 @@ export const testDomainWithDetector = (t: Test, { domain, name, type, expected, t.equal(value.result, expected, `result: "${domain}" should be match "${expected}"`); }; -type CustomParseResult = { - domain: string | null; - subdomain: string | null; - publicSuffix: string | null; -}; - -// Create a wrapper function for PSL parsing -export function parseDomainWithCustomPSL(domain: string): CustomParseResult { - // Check if the domain ends with any custom suffix - const customSuffix = customTlds.find(suffix => domain === suffix || domain.endsWith('.' + suffix)); - - if (customSuffix) { - const parts = domain.split('.'); - const suffixParts = customSuffix.split('.'); - const domainParts = parts.slice(0, parts.length - suffixParts.length); - const mainDomain = domainParts.length > 0 ? domainParts.join('.') : ''; - - return { - domain: mainDomain ? `${mainDomain}.${customSuffix}` : customSuffix, - subdomain: mainDomain, - publicSuffix: customSuffix, - }; - } - // Fallback to tldts parse - const parsedDomain = parse(domain, { - allowPrivateDomains: true, - }); - return { - domain: parsedDomain.domain, - subdomain: parsedDomain.subdomain, - publicSuffix: parsedDomain.publicSuffix, - } -}; - test("parseDomainWithCustomPSL", (t) => { const testCases = [ { @@ -364,52 +329,6 @@ test("parseDomainWithCustomPSL", (t) => { t.end(); }); -// Helper function to extract hostname from URL with path -function extractHostname(domainWithPath: string): string { - try { - const url = new URL(`https://${domainWithPath}`); - return url.hostname; - } catch { - return domainWithPath.substring(0, domainWithPath.indexOf("/")); - } -} - -// Check if a domain with path is invalid (not in allowed path domains) -function isInvalidPathDomain(hostname: string): boolean { - const hasPath = hostname.includes("/"); - if (!hasPath) return false; - - const domainPart = extractHostname(hostname); - return !PATH_REQUIRED_DOMAINS.includes(domainPart); -} - -// Check if a domain WITHOUT path is on the comparison list but not bypassed -function isOnComparisonListNotBypassed(hostname: string, comparisonList: Set, bypassList: Set): boolean { - if (bypassList.has(hostname)) return false; - - // Only check domains without paths - domains with paths are allowed to be blocked specifically - if (hostname.includes("/")) return false; - - const parsedDomain = parseDomainWithCustomPSL(hostname); - return comparisonList.has(parsedDomain.domain || ""); -} - -export function detectFalsePositives(blocklist: string[], comparisonList: Set, bypassList: Set): string[] { - return blocklist.filter(hostname => { - // Case 1: Domain has path but isn't in PATH_REQUIRED_DOMAINS = false positive - if (isInvalidPathDomain(hostname)) { - return true; - } - - // Case 2: Domain is on comparison list but not bypassed = false positive - if (isOnComparisonListNotBypassed(hostname, comparisonList, bypassList)) { - return true; - } - - return false; - }); -} - test("detectFalsePositives", (t) => { const testCases = [ {