diff --git a/.github/README.md b/.github/README.md
index b190226ac..3f2382c72 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -795,6 +795,28 @@ Knowing if a site is listed as a threat by any of these services can be useful f
- [URLHaus](https://urlhaus-api.abuse.ch/)
- [PhishTank](https://www.phishtank.com/)
+
+
+Data Breach History
+
+###### Description
+
+Checks whether the organisation behind the target domain has itself appeared in a catalogued data breach, using the public [Have I Been Pwned](https://haveibeenpwned.com) breach catalogue. For each incident it shows the date, how many accounts were affected, and which classes of data were exposed, ranked by how damaging they are โ credentials and financial records first, plain identifiers last.
+
+Only the registrable domain is ever sent upstream. No email address, account or password is submitted, and this makes no claim about any individual user's data.
+
+###### Use Cases
+
+A breach in a service's own history tells you something no live scan can: that credentials belonging to this site have already circulated, and roughly when. That matters when judging whether password reuse, credential stuffing or account takeover is a live risk for its users, and it is useful background when assessing a vendor.
+
+Bear in mind entries are catalogued per organisation, so a clean result means only that no breach has been catalogued against this domain, not that none occurred. HIBP also flags some entries as unverified, fabricated, or sourced from spam lists and stealer malware, and those are marked as unconfirmed rather than presented as fact.
+
+###### Useful Links
+
+- [Have I Been Pwned](https://haveibeenpwned.com)
+- [HIBP API docs](https://haveibeenpwned.com/API/v3)
+- [Credential stuffing (OWASP)](https://owasp.org/www-community/attacks/Credential_stuffing)
+
TLS Cipher Suites
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a2257b4f6..f213e3bed 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,6 +45,25 @@ jobs:
- name: ๐ Run ESLint
run: yarn lint
+ test:
+ name: ๐งช Unit Tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: ๐๏ธ Checkout Code
+ uses: actions/checkout@v6
+
+ - name: ๐ง Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+ cache: 'yarn'
+
+ - name: ๐ฆ Install Dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: ๐งช Run Tests
+ run: yarn test
+
typecheck:
name: ๐งท Type Check
runs-on: ubuntu-latest
diff --git a/api/_common/breach-history.js b/api/_common/breach-history.js
new file mode 100644
index 000000000..8173e9ea5
--- /dev/null
+++ b/api/_common/breach-history.js
@@ -0,0 +1,247 @@
+// Looks up whether the site's own operator has appeared in a catalogued data
+// breach, using the public Have I Been Pwned breach catalogue. This is the
+// domain-level catalogue only โ no account, email or password is ever sent.
+
+import psl from 'psl';
+import { UA, httpGet } from './http.js';
+
+const HIBP_BREACHES = 'https://haveibeenpwned.com/api/v3/breaches';
+const HIBP_TIMEOUT = 8000;
+const REDIRECT_TIMEOUT = 5000;
+
+// HIBP data is published under CC BY 4.0, so attribution travels with the data
+export const HIBP_SOURCE = {
+ name: 'Have I Been Pwned',
+ url: 'https://haveibeenpwned.com',
+ license: 'CC BY 4.0',
+ licenseUrl: 'https://creativecommons.org/licenses/by/4.0/',
+};
+
+// Anything that hands an attacker the account itself, money, or a legal identity
+const CRITICAL_CLASSES = new Set([
+ 'passwords',
+ 'historical passwords',
+ 'security questions and answers',
+ 'auth tokens',
+ 'encrypted keys',
+ 'pins',
+ 'credit cards',
+ 'credit card cvv',
+ 'bank account numbers',
+ 'government issued ids',
+ 'passport numbers',
+ 'social security numbers',
+ 'biometric data',
+]);
+
+// Anything that makes a convincing targeted attack, or that is sensitive in law
+const HIGH_CLASSES = new Set([
+ 'password hints',
+ 'partial credit card data',
+ 'dates of birth',
+ 'physical addresses',
+ 'phone numbers',
+ 'private messages',
+ 'email messages',
+ 'chat logs',
+ 'account balances',
+ 'payment histories',
+ 'health insurance information',
+ 'sexual orientations',
+ 'religions',
+ 'political views',
+ 'ethnicities',
+]);
+
+// Plain identifiers: unwelcome, but not directly actionable on their own
+const MEDIUM_CLASSES = new Set([
+ 'email addresses',
+ 'usernames',
+ 'names',
+ 'ip addresses',
+ 'geographic locations',
+ 'social media profiles',
+ 'device information',
+ 'browser user agent details',
+ 'profile photos',
+ 'avatars',
+]);
+
+const TIER_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
+
+// Fold a hostname to the registrable domain, since HIBP indexes breaches that
+// way: querying store.adobe.com returns nothing, adobe.com returns the breach
+export const registrableDomain = (hostname) => {
+ if (!hostname || typeof hostname !== 'string') return '';
+ const host = hostname.trim().toLowerCase();
+ if (!host) return '';
+ try {
+ return psl.parse(host)?.domain || host;
+ } catch {
+ return host;
+ }
+};
+
+const ENTITIES = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ ''': "'",
+ ' ': ' ',
+};
+
+// HIBP descriptions contain real markup (, ). Rendering third-party
+// HTML is precisely the flaw this tool reports on other people's sites, so the
+// tags come out here rather than being trusted downstream.
+export const stripHtml = (value) => {
+ if (typeof value !== 'string' || !value) return '';
+ return (
+ value
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/&[#a-z0-9]+;/gi, (entity) => ENTITIES[entity.toLowerCase()] ?? entity)
+ .replace(/\s+/g, ' ')
+ // A tag between a word and its punctuation leaves a gap: "exposed."
+ .replace(/\s+([.,;:!?])/g, '$1')
+ .trim()
+ );
+};
+
+export const classifyDataClass = (name) => {
+ const key = typeof name === 'string' ? name.trim().toLowerCase() : '';
+ if (CRITICAL_CLASSES.has(key)) return 'critical';
+ if (HIGH_CLASSES.has(key)) return 'high';
+ if (MEDIUM_CLASSES.has(key)) return 'medium';
+ return 'low';
+};
+
+const toCount = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
+
+const readDataClasses = (classes) => {
+ if (!Array.isArray(classes)) return [];
+ return classes
+ .filter((name) => typeof name === 'string' && name.trim())
+ .map((name) => ({ name, tier: classifyDataClass(name) }))
+ .sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier]);
+};
+
+const worstTier = (dataClasses) =>
+ dataClasses.length
+ ? dataClasses.reduce(
+ (worst, c) => (TIER_RANK[c.tier] < TIER_RANK[worst] ? c.tier : worst),
+ 'low',
+ )
+ : 'low';
+
+export const parseBreaches = (raw) => {
+ if (!Array.isArray(raw)) return [];
+
+ return raw.flatMap((entry) => {
+ if (!entry || typeof entry !== 'object') return [];
+ const name = entry.Name || entry.Title;
+ if (!name) return [];
+
+ const dataClasses = readDataClasses(entry.DataClasses);
+ const verified = entry.IsVerified === true;
+ const fabricated = entry.IsFabricated === true;
+ const spamList = entry.IsSpamList === true;
+
+ return [
+ {
+ name,
+ title: entry.Title || entry.Name,
+ domain: entry.Domain || null,
+ breachDate: entry.BreachDate || null,
+ addedDate: entry.AddedDate || null,
+ modifiedDate: entry.ModifiedDate || null,
+ accounts: toCount(entry.PwnCount),
+ description: stripHtml(entry.Description),
+ logo: entry.LogoPath || null,
+ dataClasses,
+ severity: worstTier(dataClasses),
+ verified,
+ fabricated,
+ spamList,
+ sensitive: entry.IsSensitive === true,
+ retired: entry.IsRetired === true,
+ malware: entry.IsMalware === true,
+ stealerLog: entry.IsStealerLog === true,
+ // Unverified, fabricated and spam-list entries are shown, but never
+ // presented with the same weight as a confirmed incident
+ trustworthy: verified && !fabricated && !spamList,
+ },
+ ];
+ });
+};
+
+const byRisk = (a, b) => {
+ if (a.trustworthy !== b.trustworthy) return a.trustworthy ? -1 : 1;
+ const tier = TIER_RANK[a.severity] - TIER_RANK[b.severity];
+ if (tier !== 0) return tier;
+ return (b.breachDate || '').localeCompare(a.breachDate || '');
+};
+
+export const sortBreaches = (breaches) => [...breaches].sort(byRisk);
+
+export const summariseBreaches = (breaches) => {
+ const counts = breaches.map((b) => b.accounts).filter((n) => n !== null);
+ const dates = breaches.map((b) => b.breachDate).filter(Boolean);
+ const tiers = breaches.map((b) => TIER_RANK[b.severity]);
+ const worst = tiers.length ? Math.min(...tiers) : null;
+
+ return {
+ total: breaches.length,
+ totalAccounts: counts.reduce((sum, n) => sum + n, 0),
+ worstSeverity:
+ worst === null ? null : Object.keys(TIER_RANK).find((k) => TIER_RANK[k] === worst),
+ latestBreachDate: dates.length ? dates.sort().at(-1) : null,
+ verifiedCount: breaches.filter((b) => b.verified).length,
+ unverifiedCount: breaches.filter((b) => !b.trustworthy).length,
+ };
+};
+
+// The catalogue is queried per domain rather than downloaded whole: a miss is a
+// two-byte response, which suits a serverless deploy far better than holding
+// the full 1.2 MB catalogue in memory on every cold start
+export const fetchBreachesForDomain = async (domain) => {
+ const res = await httpGet(HIBP_BREACHES, { params: { domain }, timeout: HIBP_TIMEOUT });
+ return res.data;
+};
+
+// Whether a redirect landed on a genuinely different site, and so is worth a
+// second lookup: morele.pl 301s to morele.net, and the breach is catalogued
+// against morele.net, so checking only what the user typed reports it clean
+export const isDifferentSite = (scanned, destination) =>
+ Boolean(scanned && destination && destination.includes('.') && destination !== scanned);
+
+export const dedupeBreaches = (breaches) => {
+ const seen = new Set();
+ return breaches.filter((breach) => {
+ if (seen.has(breach.name)) return false;
+ seen.add(breach.name);
+ return true;
+ });
+};
+
+// Where the browser would actually end up. Best-effort: a site that refuses
+// HEAD, hangs or fails simply yields nothing, and the caller carries on with
+// the domain that was typed.
+export const resolveFinalDomain = async (url) => {
+ for (const method of ['HEAD', 'GET']) {
+ try {
+ const response = await fetch(url, {
+ method,
+ redirect: 'follow',
+ signal: AbortSignal.timeout(REDIRECT_TIMEOUT),
+ headers: { 'user-agent': UA },
+ });
+ if (!response.ok && method === 'HEAD') continue;
+ return registrableDomain(new URL(response.url || url).hostname);
+ } catch {
+ // fall through to the next method, then give up
+ }
+ }
+ return null;
+};
diff --git a/api/_common/breach-history.test.js b/api/_common/breach-history.test.js
new file mode 100644
index 000000000..5fb60c2f9
--- /dev/null
+++ b/api/_common/breach-history.test.js
@@ -0,0 +1,332 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ registrableDomain,
+ stripHtml,
+ classifyDataClass,
+ parseBreaches,
+ sortBreaches,
+ summariseBreaches,
+ isDifferentSite,
+ dedupeBreaches,
+} from './breach-history.js';
+
+// --- registrableDomain -------------------------------------------------------
+
+// HIBP indexes breaches against the registrable domain, so store.adobe.com has
+// to be folded down to adobe.com or the lookup silently returns nothing
+test('registrableDomain folds subdomains down to the registrable domain', () => {
+ assert.equal(registrableDomain('store.adobe.com'), 'adobe.com');
+ assert.equal(registrableDomain('adobe.com'), 'adobe.com');
+ assert.equal(registrableDomain('www.jcjk.pl'), 'jcjk.pl');
+});
+
+test('registrableDomain handles multi-part public suffixes', () => {
+ assert.equal(registrableDomain('shop.example.co.uk'), 'example.co.uk');
+ assert.equal(registrableDomain('example.co.uk'), 'example.co.uk');
+});
+
+test('registrableDomain lowercases the result', () => {
+ assert.equal(registrableDomain('ROBLOX.COM'), 'roblox.com');
+ assert.equal(registrableDomain('Store.Adobe.Com'), 'adobe.com');
+});
+
+test('registrableDomain falls back to the input when there is no public suffix', () => {
+ assert.equal(registrableDomain('localhost'), 'localhost');
+ assert.equal(registrableDomain(''), '');
+ assert.equal(registrableDomain(null), '');
+});
+
+// --- stripHtml ---------------------------------------------------------------
+
+// HIBP descriptions ship raw markup, and piping third-party HTML into the DOM
+// is the exact bug this tool exists to find on other people's sites
+test('stripHtml removes the markup HIBP embeds in descriptions', () => {
+ const raw =
+ 'In October 2013, 153 million Adobe accounts were breached with each containing an ' +
+ 'internal ID, username, email, encrypted password and a password hint in plain ' +
+ 'text. The unencrypted hints also disclosed much about the passwords adding further to the risk.';
+ const text = stripHtml(raw);
+ assert.ok(!text.includes('<'), 'no tags should survive');
+ assert.ok(!text.includes('href'), 'no attributes should survive');
+ assert.ok(text.includes('encrypted password'));
+ assert.ok(text.includes('disclosed much about the passwords'));
+});
+
+test('stripHtml decodes the entities that appear in HIBP copy', () => {
+ assert.equal(stripHtml('Ashley & Madison'), 'Ashley & Madison');
+ assert.equal(stripHtml('"quoted"'), '"quoted"');
+ assert.equal(stripHtml('it's'), "it's");
+ assert.equal(stripHtml('a b'), 'a b');
+ assert.equal(stripHtml('<script>'), '