├── .nvmrc ├── .github ├── FUNDING.yml └── workflows │ ├── bundlewatch-pr.yml │ ├── bundlewatch-push.yml │ ├── cd.yml │ └── ci.yml ├── serve.json ├── docs ├── logo.png ├── _config.yml └── index.md ├── eslint.config.js ├── prettier.config.js ├── .codecov.yml ├── .prettierignore ├── tsconfig.esbuild.json ├── mocks └── server.ts ├── playwright └── tests │ └── browser.spec.ts ├── .editorconfig ├── .vscode └── settings.json ├── src ├── api │ ├── pwnedpasswords │ │ ├── responses.ts │ │ ├── __tests__ │ │ │ └── fetch-from-api.test.ts │ │ └── fetch-from-api.ts │ ├── haveibeenpwned │ │ ├── responses.ts │ │ ├── types.ts │ │ ├── fetch-from-api.ts │ │ └── __tests__ │ │ │ └── fetch-from-api.test.ts │ ├── base-fetch.ts │ └── __tests__ │ │ └── base-fetch.test.ts ├── __tests__ │ ├── hibp.test.ts │ ├── latest-breach.test.ts │ ├── data-classes.test.ts │ ├── subscription-status.test.ts │ ├── subscribed-domains.test.ts │ ├── breach.test.ts │ ├── search.test.ts │ ├── breaches.test.ts │ ├── breached-domain.test.ts │ ├── paste-account.test.ts │ ├── stealer-logs-by-email.test.ts │ ├── stealer-logs-by-website-domain.test.ts │ ├── stealer-logs-by-email-domain.test.ts │ ├── pwned-password.test.ts │ ├── pwned-password-range.test.ts │ └── breached-account.test.ts ├── data-classes.ts ├── latest-breach.ts ├── breaches.ts ├── hibp.ts ├── breach.ts ├── subscription-status.ts ├── paste-account.ts ├── pwned-password.ts ├── stealer-logs-by-email.ts ├── stealer-logs-by-website-domain.ts ├── breached-domain.ts ├── stealer-logs-by-email-domain.ts ├── subscribed-domains.ts ├── pwned-password-range.ts ├── breached-account.ts └── search.ts ├── tsconfig.tsc.json ├── .changeset ├── config.json └── README.md ├── test ├── setup.ts ├── esm.html └── fixtures.ts ├── vitest.config.ts ├── tsconfig.json ├── scripts ├── build-package-info.ts └── fix-api-docs.js ├── jsdoc2md.json ├── CONTRIBUTING.md ├── renovate.json ├── LICENSE.txt ├── RELEASING.md ├── playwright.config.ts ├── .bundlewatch.config.json ├── .all-contributorsrc ├── .gitignore ├── .gitattributes ├── package.json ├── MIGRATION.md ├── README.md └── CHANGELOG-7.x.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: wKovacs64 2 | -------------------------------------------------------------------------------- /serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": ".", 3 | "cleanUrls": false 4 | } 5 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wKovacs64/hibp/HEAD/docs/logo.png -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from '@wkovacs64/eslint-config'; 2 | 3 | export default config; 4 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | import prettierConfig from '@wkovacs64/prettier-config'; 2 | 3 | export default prettierConfig; 4 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: 'header, diff, files' 3 | require_changes: true # if true: only post the comment if coverage changes 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # .gitignore and... 2 | 3 | .all-contributorsrc 4 | .changeset 5 | package.json 6 | package-lock.json 7 | pnpm-lock.yaml 8 | API.md 9 | -------------------------------------------------------------------------------- /tsconfig.esbuild.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "Preserve", 5 | "sourceMap": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | 3 | // Setup Node (Vitest) request interception using the given mocks. 4 | export const server = setupServer(); 5 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: hibp 2 | description: An unofficial TypeScript SDK for the 'Have I been pwned?' service. 3 | google_analytics: 4 | show_downloads: true 5 | theme: jekyll-theme-cayman 6 | 7 | gems: 8 | - jekyll-mentions 9 | -------------------------------------------------------------------------------- /playwright/tests/browser.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('ESM for browsers', async ({ page }) => { 4 | await page.goto('/test/esm.html'); 5 | 6 | await expect(page.getByText('success')).toBeVisible(); 7 | }); 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "bradzacher", 4 | "breachedaccount", 5 | "danieladams", 6 | "dataclasses", 7 | "pasteaccount", 8 | "pwnage", 9 | "pwnedpassword", 10 | "pwnedpasswords", 11 | "ssat" 12 | ], 13 | "eslint.useFlatConfig": true 14 | } 15 | -------------------------------------------------------------------------------- /src/api/pwnedpasswords/responses.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Known potential responses from the remote API. 3 | * 4 | * https://haveibeenpwned.com/api/v3#PwnedPasswords 5 | * 6 | */ 7 | 8 | /** @internal */ 9 | export const BAD_REQUEST = { 10 | status: 400 as const, 11 | body: 'The hash prefix was not in a valid format', 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.tsc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist/esm", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "stripInternal": true, 9 | "noEmit": false 10 | }, 11 | "include": ["src/**/*"], 12 | "exclude": ["**/__tests__/*"] 13 | } 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "wKovacs64/hibp" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, afterAll, afterEach } from 'vitest'; 2 | import { server } from '../mocks/server.js'; 3 | 4 | // Establish API mocking before all tests. 5 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 6 | 7 | // Reset any request handlers that we may add during the tests, so they don't 8 | // affect other tests. 9 | afterEach(() => server.resetHandlers()); 10 | 11 | // Clean up after the tests are finished. 12 | afterAll(() => server.close()); 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, configDefaults } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['./src/**/__tests__/*'], 6 | setupFiles: './test/setup.ts', 7 | coverage: { 8 | exclude: [...(configDefaults.coverage.exclude ?? []), '**/types.ts'], 9 | include: ['**/src/**/*'], 10 | reporter: ['text', 'lcov', 'clover'], 11 | }, 12 | clearMocks: true, 13 | environment: 'node', 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "ES2022", 7 | "module": "NodeNext", 8 | "moduleDetection": "force", 9 | "allowJs": true, 10 | "isolatedModules": true, 11 | "resolveJsonModule": true, 12 | "verbatimModuleSyntax": true, 13 | "noEmit": true, 14 | 15 | "strict": true, 16 | "noImplicitOverride": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scripts/build-package-info.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'node:fs'; 2 | import path from 'pathe'; 3 | import packageJson from '../package.json' with { type: 'json' }; 4 | 5 | const content = `// This file is auto-generated. Do not edit. 6 | export const PACKAGE_NAME = ${JSON.stringify(packageJson.name)}; 7 | export const PACKAGE_VERSION = ${JSON.stringify(packageJson.version)}; 8 | `; 9 | 10 | const outPath = path.resolve('./src/api/haveibeenpwned/package-info.ts'); 11 | writeFileSync(outPath, content); 12 | -------------------------------------------------------------------------------- /jsdoc2md.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "includePattern": ".+\\.(j|t)s(doc|x)?$", 4 | "excludePattern": ".+\\.(test|spec).ts" 5 | }, 6 | "plugins": ["node_modules/jsdoc-babel"], 7 | "babel": { 8 | "extensions": ["ts", "tsx"], 9 | "ignore": ["**/*.(test|spec).ts"], 10 | "babelrc": false, 11 | "presets": [["@babel/preset-env", { "targets": { "node": "current" } }], "@babel/typescript"], 12 | "plugins": ["@babel/proposal-class-properties", "@babel/proposal-object-rest-spread"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) 4 | 5 | We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you would like to contribute code to this project, you can do so by forking the repository and 4 | sending a merge request. 5 | 6 | When submitting code, please make every effort to follow existing conventions and style in order to 7 | keep the code as readable as possible. This project roughly adheres to the [Airbnb][airbnb] code 8 | style (particularly for linting) and is formatted using [prettier][prettier]. 9 | 10 | If adding new functionality, please be sure to include the appropriate tests as well. All tests must 11 | pass before a merge can be accepted. 12 | 13 | Thank you. 14 | 15 | [airbnb]: https://github.com/airbnb/javascript 16 | [prettier]: https://prettier.io 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:js-lib", ":semanticCommits", ":semanticCommitTypeAll(chore)"], 4 | "commitMessageTopic": "{{depName}}", 5 | "automergeType": "branch", 6 | "automerge": true, 7 | "major": { 8 | "automerge": false 9 | }, 10 | "packageRules": [ 11 | { 12 | "matchDatasources": ["npm"], 13 | "minimumReleaseAge": "7 days" 14 | }, 15 | { 16 | "matchPackageNames": ["eslint"], 17 | "matchUpdateTypes": ["major"], 18 | "enabled": false 19 | }, 20 | { 21 | "matchPackageNames": ["typescript"], 22 | "matchUpdateTypes": ["minor"], 23 | "automerge": false 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/bundlewatch-pr.yml: -------------------------------------------------------------------------------- 1 | name: ⚖ Bundlewatch on PR 2 | 3 | on: 4 | pull_request: 5 | types: [synchronize, opened] 6 | 7 | jobs: 8 | bundlewatch: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: ⬇️ Checkout repo 12 | uses: actions/checkout@v6 13 | 14 | - name: 📦 Install pnpm 15 | uses: pnpm/action-setup@v4 16 | 17 | - name: ⎔ Setup node 18 | uses: actions/setup-node@v6 19 | with: 20 | cache: pnpm 21 | node-version-file: '.nvmrc' 22 | 23 | - name: 📥 Install deps 24 | run: pnpm install 25 | 26 | - name: 🛠️ Build 27 | run: pnpm run build 28 | 29 | - uses: jackyef/bundlewatch-gh-action@0.3.0 30 | with: 31 | bundlewatch-config: .bundlewatch.config.json 32 | bundlewatch-github-token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/bundlewatch-push.yml: -------------------------------------------------------------------------------- 1 | name: ⚖ Bundlewatch on Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | bundlewatch: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: ⬇️ Checkout repo 13 | uses: actions/checkout@v6 14 | 15 | - name: 📦 Install pnpm 16 | uses: pnpm/action-setup@v4 17 | 18 | - name: ⎔ Setup node 19 | uses: actions/setup-node@v6 20 | with: 21 | cache: pnpm 22 | node-version-file: '.nvmrc' 23 | 24 | - name: 📥 Install deps 25 | run: pnpm install 26 | 27 | - name: 🛠️ Build 28 | run: pnpm run build 29 | 30 | - uses: jackyef/bundlewatch-gh-action@0.3.0 31 | with: 32 | branch-base: main 33 | bundlewatch-config: .bundlewatch.config.json 34 | bundlewatch-github-token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /scripts/fix-api-docs.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | const filename = 'API.md'; 4 | const generatedApiDocs = fs.readFileSync(filename, 'utf8'); 5 | const newApiDocs = generatedApiDocs 6 | // Replace the generated links to certain objects with more specific anchor 7 | // names as the generated ones collide with functions that share a name with 8 | // the objects (as anchor links are case insensitive). 9 | .replace(/#Breach/g, '#breach--object') 10 | .replace(/#SubscriptionStatus/g, '#subscriptionstatus--object') 11 | // Surround all the generated Promise.> strings with links to the 12 | // corresponding typedef object as jsdoc2md seems to have an issue parsing the 13 | // syntax for a promise that resolves to an array of custom types. 14 | .replace( 15 | /(Promise\.<Array\.<([A-Z].*)>>)/g, 16 | (_match, g1, g2) => `${g1}`, 17 | ); 18 | 19 | fs.writeFileSync(filename, newApiDocs, 'utf8'); 20 | -------------------------------------------------------------------------------- /src/__tests__/hibp.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import * as hibp from '../hibp.js'; 3 | 4 | describe('hibp', () => { 5 | it('exports an object containing the advertised functions', () => { 6 | expect(hibp).toMatchInlineSnapshot(` 7 | { 8 | "RateLimitError": [Function], 9 | "breach": [Function], 10 | "breachedAccount": [Function], 11 | "breachedDomain": [Function], 12 | "breaches": [Function], 13 | "dataClasses": [Function], 14 | "latestBreach": [Function], 15 | "pasteAccount": [Function], 16 | "pwnedPassword": [Function], 17 | "pwnedPasswordRange": [Function], 18 | "search": [Function], 19 | "stealerLogsByEmail": [Function], 20 | "stealerLogsByEmailDomain": [Function], 21 | "stealerLogsByWebsiteDomain": [Function], 22 | "subscribedDomains": [Function], 23 | "subscriptionStatus": [Function], 24 | } 25 | `); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Justin R. Hall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /test/esm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ESM Test 7 | 8 | 9 | 10 |
ESM Test Page
11 | 12 |

Test Results: failed (or incomplete)

13 | 14 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: 🌐 CD 2 | 3 | on: 4 | push: 5 | pull_request: 6 | pull_request_review: 7 | types: [submitted] 8 | 9 | jobs: 10 | publish_to_pkg_pr_new: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 2 13 | if: | 14 | github.event_name != 'push' || 15 | !contains(github.event.head_commit.message, 'chore: release') 16 | steps: 17 | - name: Bail early if PR is from a fork and not approved 18 | if: | 19 | github.event_name == 'pull_request_review' && 20 | github.event.pull_request.head.repo.fork == true && 21 | github.event.review.state != 'approved' 22 | run: | 23 | echo "PR from fork is not approved. Skipping workflow." 24 | exit 78 25 | 26 | - name: ⬇️ Checkout repo 27 | uses: actions/checkout@v6 28 | 29 | - name: 📦 Install pnpm 30 | uses: pnpm/action-setup@v4 31 | 32 | - name: ⎔ Setup node 33 | uses: actions/setup-node@v6 34 | with: 35 | cache: pnpm 36 | node-version-file: '.nvmrc' 37 | 38 | - name: 📥 Install deps 39 | run: pnpm install 40 | 41 | - name: 🛠️ Build 42 | run: pnpm run build 43 | 44 | - name: ⚡ pkg.pr.new 45 | run: pnpm dlx pkg-pr-new publish --compact 46 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | ## Overview 4 | 5 | This package is versioned and released using [changesets](https://github.com/changesets/changesets). 6 | A changesets pull request with the title "chore: release" should be opened automatically and get 7 | updated automatically with the current list of pending changes since the previous release. Merging 8 | this PR will release those pending changes under the next appropriate version. 9 | 10 | ## How 11 | 12 | Each commit or pull request should include a changeset if it should (eventually) have an impact on 13 | the package version. To create one: run `npx changeset`, answer the questions, and commit the 14 | results. Changes that don't facilitate a version bump (i.e., "chores") don't necessarily need a 15 | changeset. 16 | 17 | ### The flow: 18 | 19 | 1. Checkout a new branch 20 | 1. Make your changes 21 | 1. Run `npx changeset`, answer the questions, commit the results 22 | 1. Push your branch and open a pull request 23 | 1. Once these changes are in the `main` branch, the changesets pull request will be updated 24 | automatically. 25 | 1. When you're ready to release a new version, merge the changesets PR to `main` and the GitHub 26 | "Release" workflow will publish the package to npm and commit the updated `CHANGELOG.md` file 27 | back to the repository. 28 | -------------------------------------------------------------------------------- /src/api/pwnedpasswords/__tests__/fetch-from-api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../../../mocks/server.js'; 4 | import { BAD_REQUEST } from '../responses.js'; 5 | import { fetchFromApi } from '../fetch-from-api.js'; 6 | 7 | describe('internal (pwnedpassword): fetchFromApi', () => { 8 | describe('request failure', () => { 9 | it('re-throws request setup errors', () => { 10 | return expect( 11 | fetchFromApi('/service', { baseUrl: 'relativeBaseUrl' }), 12 | ).rejects.toMatchInlineSnapshot(`[TypeError: Invalid URL]`); 13 | }); 14 | }); 15 | 16 | describe('invalid range', () => { 17 | it('throws a "Bad Request" error', async () => { 18 | server.use( 19 | http.get('*', () => { 20 | return new Response(BAD_REQUEST.body, { status: BAD_REQUEST.status }); 21 | }), 22 | ); 23 | 24 | return expect(fetchFromApi('/service/bad_request')).rejects.toMatchInlineSnapshot( 25 | `[Error: The hash prefix was not in a valid format]`, 26 | ); 27 | }); 28 | }); 29 | 30 | describe('unexpected HTTP error', () => { 31 | it('throws an error with the response status text', () => { 32 | server.use( 33 | http.get('*', () => { 34 | return new Response(null, { 35 | status: 599, 36 | statusText: 'Unknown - something unexpected happened.', 37 | }); 38 | }), 39 | ); 40 | 41 | return expect(fetchFromApi('/service/unknown_response')).rejects.toMatchInlineSnapshot( 42 | `[Error: Unknown - something unexpected happened.]`, 43 | ); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/data-classes.ts: -------------------------------------------------------------------------------- 1 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 2 | 3 | /** 4 | * Fetches all data classes in the system. 5 | * 6 | * @param {object} [options] a configuration object 7 | * @param {string} [options.baseUrl] a custom base URL for the 8 | * haveibeenpwned.com API endpoints (default: 9 | * `https://haveibeenpwned.com/api/v3`) 10 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 11 | * (default: none) 12 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 13 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 14 | * field in the request headers (default: `hibp `) 15 | * @returns {(Promise | Promise)} a Promise which resolves to an 16 | * array of strings (or null if no data classes were found), or rejects with an 17 | * Error 18 | * @example 19 | * try { 20 | * const data = await dataClasses(); 21 | * if (data) { 22 | * // ... 23 | * } else { 24 | * // ... 25 | * } 26 | * } catch (err) { 27 | * // ... 28 | * } 29 | */ 30 | export function dataClasses( 31 | options: { 32 | /** 33 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 34 | * `https://haveibeenpwned.com/api/v3`) 35 | */ 36 | baseUrl?: string; 37 | /** 38 | * timeout for the request in milliseconds (default: none) 39 | */ 40 | timeoutMs?: number; 41 | /** 42 | * an AbortSignal to cancel the request (default: none) 43 | */ 44 | signal?: AbortSignal; 45 | /** 46 | * a custom string to send as the User-Agent field in the request headers 47 | * (default: `hibp `) 48 | */ 49 | userAgent?: string; 50 | } = {}, 51 | ): Promise { 52 | return fetchFromApi('/dataclasses', options) as Promise; 53 | } 54 | -------------------------------------------------------------------------------- /src/latest-breach.ts: -------------------------------------------------------------------------------- 1 | import type { Breach } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * Fetches the most recently added breach. 6 | * 7 | * @param {object} [options] a configuration object 8 | * @param {string} [options.baseUrl] a custom base URL for the 9 | * haveibeenpwned.com API endpoints (default: 10 | * `https://haveibeenpwned.com/api/v3`) 11 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 12 | * (default: none) 13 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 14 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 15 | * field in the request headers (default: `hibp `) 16 | * @returns {(Promise|Promise)} a Promise which resolves to an 17 | * object representing a breach (or null if no breach was found), or rejects 18 | * with an Error 19 | * @example 20 | * try { 21 | * const data = await latestBreach(); 22 | * if (data) { 23 | * // ... 24 | * } else { 25 | * // ... 26 | * } 27 | * } catch (err) { 28 | * // ... 29 | * } 30 | */ 31 | export function latestBreach( 32 | options: { 33 | /** 34 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 35 | * `https://haveibeenpwned.com/api/v3`) 36 | */ 37 | baseUrl?: string; 38 | /** 39 | * timeout for the request in milliseconds (default: none) 40 | */ 41 | timeoutMs?: number; 42 | /** 43 | * an AbortSignal to cancel the request (default: none) 44 | */ 45 | signal?: AbortSignal; 46 | /** 47 | * a custom string to send as the User-Agent field in the request headers 48 | * (default: `hibp `) 49 | */ 50 | userAgent?: string; 51 | } = {}, 52 | ): Promise { 53 | return fetchFromApi('/latestbreach', options) as Promise; 54 | } 55 | -------------------------------------------------------------------------------- /src/api/haveibeenpwned/responses.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Known potential responses from the remote API. 3 | * 4 | * Unfortunately, the API does not send a decent human-readable message back with each response, but 5 | * they are documented on the website: https://haveibeenpwned.com/api/v3#ResponseCodes 6 | * 7 | * These objects simply provide a mapping between the HTTP response status code and the 8 | * corresponding human-readable message so we can throw a more descriptive error for the consumer. 9 | * (They are also leveraged in our tests.) 10 | */ 11 | 12 | /** @internal */ 13 | export const BAD_REQUEST = { 14 | status: 400 as const, 15 | statusText: 'Bad request — the account does not comply with an acceptable format.', 16 | }; 17 | 18 | /** 19 | * The API includes a human-readable error message as text in the body of this 20 | * response type. Manually populating the message here purely for use in tests. 21 | * 22 | * @internal 23 | */ 24 | export const UNAUTHORIZED = { 25 | status: 401 as const, 26 | body: `Your request to the API couldn't be authorised. Check you have the right value in the "hibp-api-key" header, refer to the documentation for more: https://haveibeenpwned.com/API/v3#Authorisation`, 27 | }; 28 | 29 | /** @internal */ 30 | export const FORBIDDEN = { 31 | status: 403 as const, 32 | statusText: 'Forbidden - access denied.', 33 | }; 34 | 35 | /** @internal */ 36 | export const BLOCKED = { 37 | headers: new Map([['cf-ray', 'someRayId']]), 38 | status: 403 as const, 39 | }; 40 | 41 | /** @internal */ 42 | export const NOT_FOUND = { 43 | status: 404 as const, 44 | }; 45 | 46 | /** 47 | * The API includes a JSON object containing a human-readable message in the 48 | * body of this response type. Manually populating the message here purely for 49 | * use in tests. 50 | * 51 | * @internal 52 | */ 53 | export const TOO_MANY_REQUESTS = { 54 | headers: new Map([['retry-after', '2']]), 55 | status: 429 as const, 56 | body: { 57 | statusCode: 429 as const, 58 | message: 'Rate limit is exceeded. Try again in 2 seconds.', 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/__tests__/latest-breach.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { VERIFIED_BREACH } from '../../test/fixtures.js'; 5 | import { latestBreach } from '../latest-breach.js'; 6 | 7 | describe('latestBreach', () => { 8 | describe('found', () => { 9 | it('resolves with data from the remote API', () => { 10 | server.use( 11 | http.get('*', () => { 12 | return new Response(JSON.stringify(VERIFIED_BREACH)); 13 | }), 14 | ); 15 | 16 | return expect(latestBreach()).resolves.toEqual(VERIFIED_BREACH); 17 | }); 18 | }); 19 | 20 | describe('baseUrl option', () => { 21 | it('is the beginning of the final URL', () => { 22 | const baseUrl = 'https://my-hibp-proxy:8080'; 23 | server.use( 24 | http.get(new RegExp(`^${baseUrl}`), () => { 25 | return new Response(JSON.stringify(VERIFIED_BREACH)); 26 | }), 27 | ); 28 | 29 | return expect(latestBreach({ baseUrl })).resolves.toEqual(VERIFIED_BREACH); 30 | }); 31 | }); 32 | 33 | describe('timeoutMs option', () => { 34 | it('aborts the request after the given value', () => { 35 | expect.assertions(1); 36 | const timeoutMs = 1; 37 | server.use( 38 | http.get('*', async () => { 39 | await new Promise((resolve) => { 40 | setTimeout(resolve, timeoutMs + 1); 41 | }); 42 | return new Response(JSON.stringify(VERIFIED_BREACH)); 43 | }), 44 | ); 45 | 46 | return expect(latestBreach({ timeoutMs })).rejects.toThrow(); 47 | }); 48 | }); 49 | 50 | describe('userAgent option', () => { 51 | it('is passed on as a request header', () => { 52 | expect.assertions(1); 53 | const userAgent = 'Custom UA'; 54 | server.use( 55 | http.get('*', ({ request }) => { 56 | expect(request.headers.get('User-Agent')).toBe(userAgent); 57 | return new Response(JSON.stringify(VERIFIED_BREACH)); 58 | }), 59 | ); 60 | 61 | return latestBreach({ userAgent }); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/api/base-fetch.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore - package-info.js is generated 2 | import { PACKAGE_NAME, PACKAGE_VERSION } from './haveibeenpwned/package-info.js'; 3 | 4 | export async function baseFetch({ 5 | baseUrl, 6 | endpoint, 7 | headers, 8 | timeoutMs, 9 | signal, 10 | userAgent, 11 | queryParams, 12 | }: { 13 | baseUrl: string; 14 | endpoint: string; 15 | headers?: Record; 16 | timeoutMs?: number; 17 | signal?: AbortSignal; 18 | userAgent?: string; 19 | queryParams?: Record; 20 | }): Promise { 21 | const requestInit: RequestInit = { 22 | headers: buildHeaders(userAgent, headers), 23 | signal: buildSignal(timeoutMs, signal), 24 | }; 25 | 26 | const url = buildUrl(baseUrl, endpoint, queryParams); 27 | 28 | return fetch(url, requestInit); 29 | } 30 | 31 | export function buildUrl( 32 | baseUrl: string, 33 | endpoint: string, 34 | queryParams?: Record, 35 | ): string { 36 | const base = baseUrl.replace(/\/$/g, ''); 37 | const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; 38 | const url = new URL(`${base}${normalizedEndpoint}`); 39 | 40 | if (queryParams) { 41 | for (const [key, value] of Object.entries(queryParams)) { 42 | url.searchParams.set(key, value); 43 | } 44 | } 45 | 46 | return url.toString(); 47 | } 48 | 49 | export function buildHeaders( 50 | userAgent?: string, 51 | extra?: Record, 52 | ): Record { 53 | const headers: Record = { ...extra }; 54 | 55 | if (userAgent) { 56 | headers['User-Agent'] = userAgent; 57 | } else if (typeof navigator === 'undefined') { 58 | headers['User-Agent'] = `${PACKAGE_NAME} ${PACKAGE_VERSION}`; 59 | } 60 | 61 | return headers; 62 | } 63 | 64 | function buildSignal(timeoutMs?: number, signal?: AbortSignal): AbortSignal | undefined { 65 | const signals: AbortSignal[] = []; 66 | 67 | if (timeoutMs) signals.push(AbortSignal.timeout(timeoutMs)); 68 | if (signal) signals.push(signal); 69 | 70 | if (signals.length === 0) return undefined; 71 | if (signals.length === 1) return signals[0]; 72 | 73 | return AbortSignal.any(signals); 74 | } 75 | -------------------------------------------------------------------------------- /src/__tests__/data-classes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { dataClasses } from '../data-classes.js'; 5 | 6 | describe('dataClasses', () => { 7 | const DATA_CLASSES = ['some', 'data', 'classes']; 8 | 9 | describe('no parameters', () => { 10 | it('resolves with data from the remote API', () => { 11 | server.use( 12 | http.get('*', () => { 13 | return new Response(JSON.stringify(DATA_CLASSES)); 14 | }), 15 | ); 16 | 17 | return expect(dataClasses()).resolves.toEqual(DATA_CLASSES); 18 | }); 19 | }); 20 | 21 | describe('baseUrl option', () => { 22 | it('is the beginning of the final URL', () => { 23 | const baseUrl = 'https://my-hibp-proxy:8080'; 24 | server.use( 25 | http.get(new RegExp(`^${baseUrl}`), () => { 26 | return new Response(JSON.stringify(DATA_CLASSES)); 27 | }), 28 | ); 29 | 30 | return expect(dataClasses({ baseUrl })).resolves.toEqual(DATA_CLASSES); 31 | }); 32 | }); 33 | 34 | describe('timeoutMs option', () => { 35 | it('aborts the request after the given value', () => { 36 | expect.assertions(1); 37 | const timeoutMs = 1; 38 | server.use( 39 | http.get('*', async () => { 40 | await new Promise((resolve) => { 41 | setTimeout(resolve, timeoutMs + 1); 42 | }); 43 | return new Response(JSON.stringify(DATA_CLASSES)); 44 | }), 45 | ); 46 | 47 | return expect(dataClasses({ timeoutMs })).rejects.toMatchInlineSnapshot( 48 | `[TimeoutError: The operation was aborted due to timeout]`, 49 | ); 50 | }); 51 | }); 52 | 53 | describe('userAgent option', () => { 54 | it('is passed on as a request header', () => { 55 | expect.assertions(1); 56 | const userAgent = 'Custom UA'; 57 | server.use( 58 | http.get('*', ({ request }) => { 59 | expect(request.headers.get('User-Agent')).toBe(userAgent); 60 | return new Response(JSON.stringify(DATA_CLASSES)); 61 | }), 62 | ); 63 | 64 | return dataClasses({ userAgent }); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/api/haveibeenpwned/types.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Data Models from the API 3 | // 4 | 5 | export interface Breach { 6 | Name: string; 7 | Title: string; 8 | Domain: string; 9 | BreachDate: string; 10 | AddedDate: string; 11 | ModifiedDate: string; 12 | PwnCount: number; 13 | Description: string; 14 | DataClasses: string[]; 15 | IsVerified: boolean; 16 | IsFabricated: boolean; 17 | IsSensitive: boolean; 18 | IsRetired: boolean; 19 | IsSpamList: boolean; 20 | IsMalware: boolean; 21 | IsSubscriptionFree: boolean; 22 | LogoPath: string; 23 | } 24 | 25 | export interface Paste { 26 | Id: string; 27 | Source: string; 28 | Title: string; 29 | Date: string; 30 | EmailCount: number; 31 | } 32 | 33 | export interface SubscriptionStatus { 34 | SubscriptionName: string; 35 | Description: string; 36 | SubscribedUntil: string; 37 | Rpm: number; 38 | DomainSearchMaxBreachedAccounts: number; 39 | IncludesStealerLogs: boolean; 40 | } 41 | 42 | export interface SubscribedDomain { 43 | DomainName: string; 44 | PwnCount: number | null; 45 | PwnCountExcludingSpamLists: number | null; 46 | PwnCountExcludingSpamListsAtLastSubscriptionRenewal: number | null; 47 | NextSubscriptionRenewal: string | null; 48 | } 49 | 50 | export type BreachedDomainsByEmailAlias = Record; 51 | 52 | export type StealerLogDomainsByEmailAlias = Record; 53 | 54 | // 55 | // Internal convenience types 56 | // 57 | 58 | /** 59 | * Data returned in the response body of a successful API query 60 | * 61 | * @internal 62 | */ 63 | export type ApiData = 64 | | Breach // breach 65 | | Breach[] // breachedaccount, breaches 66 | | BreachedDomainsByEmailAlias // breacheddomain 67 | | Paste[] // pasteaccount 68 | | string[] // dataclasses, stealerlogsbyemail, stealerlogsbywebsitedomain 69 | | StealerLogDomainsByEmailAlias // stealerlogsbyemaildomain 70 | | SubscriptionStatus // subscription/status 71 | | SubscribedDomain[] // subscribeddomains 72 | | null; // most endpoints can return an empty response (404, but not an error) 73 | 74 | /** 75 | * Data returned in the response body of a failed API query 76 | * 77 | * @internal 78 | */ 79 | export interface ErrorData { 80 | statusCode: number; 81 | message: string; 82 | } 83 | -------------------------------------------------------------------------------- /src/api/pwnedpasswords/fetch-from-api.ts: -------------------------------------------------------------------------------- 1 | import { baseFetch } from '../base-fetch.js'; 2 | import { BAD_REQUEST } from './responses.js'; 3 | 4 | /** 5 | * Fetches data from the supplied API endpoint. 6 | * 7 | * HTTP status code 200 returns plain text (data found). 8 | * HTTP status code 400 throws an Error (bad request). 9 | * 10 | * @internal 11 | * @private 12 | * @param {string} endpoint the API endpoint to query 13 | * @param {object} [options] a configuration object 14 | * @param {string} [options.baseUrl] a custom base URL for the 15 | * pwnedpasswords.com API endpoints (default: `https://api.pwnedpasswords.com`) 16 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 17 | * (default: none) 18 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 19 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 20 | * field in the request headers (default: `hibp `) 21 | * @param {boolean} [options.addPadding] ask the remote API to add padding to 22 | * the response to obscure the password prefix (default: `false`) 23 | * @param {'sha1' | 'ntlm'} [options.mode] return SHA-1 or NTLM hashes 24 | * (default: `sha1`) 25 | * @returns {Promise} a Promise which resolves to the data resulting 26 | * from the query, or rejects with an Error 27 | */ 28 | export async function fetchFromApi( 29 | endpoint: string, 30 | options: { 31 | baseUrl?: string; 32 | timeoutMs?: number; 33 | signal?: AbortSignal; 34 | userAgent?: string; 35 | addPadding?: boolean; 36 | mode?: 'sha1' | 'ntlm'; 37 | } = {}, 38 | ): Promise { 39 | const { 40 | baseUrl = 'https://api.pwnedpasswords.com', 41 | timeoutMs, 42 | signal, 43 | userAgent, 44 | addPadding = false, 45 | mode = 'sha1', 46 | } = options; 47 | 48 | const headers: Record = {}; 49 | if (addPadding) headers['Add-Padding'] = 'true'; 50 | 51 | const response = await baseFetch({ 52 | baseUrl, 53 | endpoint, 54 | headers, 55 | timeoutMs, 56 | signal, 57 | userAgent, 58 | queryParams: { mode }, 59 | }); 60 | 61 | if (response.ok) return response.text(); 62 | 63 | if (response.status === BAD_REQUEST.status) { 64 | const text = await response.text(); 65 | throw new Error(text); 66 | } 67 | 68 | throw new Error(response.statusText); 69 | } 70 | -------------------------------------------------------------------------------- /src/__tests__/subscription-status.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { SUBSCRIPTION_STATUS } from '../../test/fixtures.js'; 5 | import { subscriptionStatus } from '../subscription-status.js'; 6 | 7 | describe('subscriptionStatus', () => { 8 | const apiKey = 'my-api-key'; 9 | 10 | describe('apiKey parameter', () => { 11 | it('sets the hibp-api-key header', async () => { 12 | expect.assertions(1); 13 | server.use( 14 | http.get('*', ({ request }) => { 15 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 16 | return new Response(JSON.stringify(SUBSCRIPTION_STATUS)); 17 | }), 18 | ); 19 | 20 | return subscriptionStatus({ apiKey }); 21 | }); 22 | }); 23 | 24 | describe('baseUrl option', () => { 25 | it('is the beginning of the final URL', () => { 26 | const baseUrl = 'https://my-hibp-proxy:8080'; 27 | server.use( 28 | http.get(new RegExp(`^${baseUrl}`), () => { 29 | return new Response(JSON.stringify(SUBSCRIPTION_STATUS)); 30 | }), 31 | ); 32 | 33 | return expect(subscriptionStatus({ baseUrl })).resolves.toEqual(SUBSCRIPTION_STATUS); 34 | }); 35 | }); 36 | 37 | describe('timeoutMs option', () => { 38 | it('aborts the request after the given value', () => { 39 | expect.assertions(1); 40 | const timeoutMs = 1; 41 | server.use( 42 | http.get('*', async () => { 43 | await new Promise((resolve) => { 44 | setTimeout(resolve, timeoutMs + 1); 45 | }); 46 | return new Response(JSON.stringify(SUBSCRIPTION_STATUS)); 47 | }), 48 | ); 49 | 50 | return expect(subscriptionStatus({ timeoutMs })).rejects.toMatchInlineSnapshot( 51 | `[TimeoutError: The operation was aborted due to timeout]`, 52 | ); 53 | }); 54 | }); 55 | 56 | describe('userAgent option', () => { 57 | it('is passed on as a request header', () => { 58 | expect.assertions(1); 59 | const userAgent = 'Custom UA'; 60 | server.use( 61 | http.get('*', ({ request }) => { 62 | expect(request.headers.get('User-Agent')).toBe(userAgent); 63 | return new Response(JSON.stringify(SUBSCRIPTION_STATUS)); 64 | }), 65 | ); 66 | 67 | return subscriptionStatus({ userAgent }); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | const PORT = process.env.PORT || '3000'; 4 | 5 | /** 6 | * See https://playwright.dev/docs/test-configuration. 7 | */ 8 | export default defineConfig({ 9 | testDir: './playwright/tests', 10 | outputDir: './playwright/results', 11 | /* Run tests in files in parallel */ 12 | fullyParallel: true, 13 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 14 | forbidOnly: Boolean(process.env.CI), 15 | /* Retry on CI only */ 16 | retries: process.env.CI ? 2 : 0, 17 | /* Opt out of parallel tests on CI. */ 18 | workers: process.env.CI ? 1 : undefined, 19 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 20 | reporter: [['html', { outputFolder: './playwright/report' }]], 21 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 22 | use: { 23 | /* Base URL to use in actions like `await page.goto('/')`. */ 24 | // baseURL: 'http://127.0.0.1:3000', 25 | 26 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 27 | trace: 'on-first-retry', 28 | }, 29 | 30 | /* Configure projects for major browsers */ 31 | projects: [ 32 | { 33 | name: 'chromium', 34 | use: { ...devices['Desktop Chrome'] }, 35 | }, 36 | 37 | // { 38 | // name: 'firefox', 39 | // use: { ...devices['Desktop Firefox'] }, 40 | // }, 41 | 42 | // { 43 | // name: 'webkit', 44 | // use: { ...devices['Desktop Safari'] }, 45 | // }, 46 | 47 | /* Test against mobile viewports. */ 48 | // { 49 | // name: 'Mobile Chrome', 50 | // use: { ...devices['Pixel 5'] }, 51 | // }, 52 | // { 53 | // name: 'Mobile Safari', 54 | // use: { ...devices['iPhone 12'] }, 55 | // }, 56 | 57 | /* Test against branded browsers. */ 58 | // { 59 | // name: 'Microsoft Edge', 60 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 61 | // }, 62 | // { 63 | // name: 'Google Chrome', 64 | // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, 65 | // }, 66 | ], 67 | 68 | /* Run your local dev server before starting the tests */ 69 | webServer: { 70 | command: `npx serve --no-clipboard --listen ${PORT}`, 71 | port: Number.parseInt(PORT, 10), 72 | reuseExistingServer: true, 73 | stdout: 'pipe', 74 | stderr: 'pipe', 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /.bundlewatch.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | { 4 | "//": "Pre-bundled ESM for the Browser", 5 | "path": "dist/browser/hibp.module.js", 6 | "maxSize": "2.1 kB" 7 | }, 8 | { 9 | "//": "Unbundled ESM", 10 | "path": "dist/esm/api/base-fetch.js", 11 | "maxSize": "1 kB" 12 | }, 13 | { 14 | "path": "dist/esm/api/haveibeenpwned/fetch-from-api.js", 15 | "maxSize": "1.4 kB" 16 | }, 17 | { 18 | "path": "dist/esm/api/haveibeenpwned/package-info.js", 19 | "maxSize": "1 kB" 20 | }, 21 | { 22 | "path": "dist/esm/api/haveibeenpwned/responses.js", 23 | "maxSize": "1 kB" 24 | }, 25 | { 26 | "path": "dist/esm/api/haveibeenpwned/types.js", 27 | "maxSize": "1 kB" 28 | }, 29 | { 30 | "path": "dist/esm/api/pwnedpasswords/fetch-from-api.js", 31 | "maxSize": "1 kB" 32 | }, 33 | { 34 | "path": "dist/esm/api/pwnedpasswords/responses.js", 35 | "maxSize": "1 kB" 36 | }, 37 | { 38 | "path": "dist/esm/breach.js", 39 | "maxSize": "1 kB" 40 | }, 41 | { 42 | "path": "dist/esm/breached-account.js", 43 | "maxSize": "1.2 kB" 44 | }, 45 | { 46 | "path": "dist/esm/breached-domain.js", 47 | "maxSize": "1.2 kB" 48 | }, 49 | { 50 | "path": "dist/esm/breaches.js", 51 | "maxSize": "1 kB" 52 | }, 53 | { 54 | "path": "dist/esm/data-classes.js", 55 | "maxSize": "1 kB" 56 | }, 57 | { 58 | "path": "dist/esm/latest-breach.js", 59 | "maxSize": "1 kB" 60 | }, 61 | { 62 | "path": "dist/esm/paste-account.js", 63 | "maxSize": "1 kB" 64 | }, 65 | { 66 | "path": "dist/esm/pwned-password.js", 67 | "maxSize": "1.1 kB" 68 | }, 69 | { 70 | "path": "dist/esm/pwned-password-range.js", 71 | "maxSize": "1.5 kB" 72 | }, 73 | { 74 | "path": "dist/esm/search.js", 75 | "maxSize": "1.4 kB" 76 | }, 77 | { 78 | "path": "dist/esm/stealer-logs-by-email.js", 79 | "maxSize": "1 kB" 80 | }, 81 | { 82 | "path": "dist/esm/stealer-logs-by-email-domain.js", 83 | "maxSize": "1.2 kB" 84 | }, 85 | { 86 | "path": "dist/esm/stealer-logs-by-website-domain.js", 87 | "maxSize": "1.1 kB" 88 | }, 89 | { 90 | "path": "dist/esm/subscribed-domains.js", 91 | "maxSize": "1.2 kB" 92 | }, 93 | { 94 | "path": "dist/esm/subscription-status.js", 95 | "maxSize": "1 kB" 96 | } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /src/__tests__/subscribed-domains.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { subscribedDomains } from '../subscribed-domains.js'; 5 | 6 | describe('subscribedDomains', () => { 7 | const DOMAINS = [ 8 | { 9 | DomainName: 'example.com', 10 | PwnCount: 3, 11 | PwnCountExcludingSpamLists: 2, 12 | PwnCountExcludingSpamListsAtLastSubscriptionRenewal: 1, 13 | NextSubscriptionRenewal: '2025-12-31T23:59:59Z', 14 | }, 15 | ]; 16 | 17 | describe('apiKey parameter', () => { 18 | it('sets the hibp-api-key header', async () => { 19 | expect.assertions(1); 20 | const apiKey = 'my-api-key'; 21 | server.use( 22 | http.get('*', ({ request }) => { 23 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 24 | return new Response(JSON.stringify(DOMAINS)); 25 | }), 26 | ); 27 | 28 | return subscribedDomains({ apiKey }); 29 | }); 30 | }); 31 | 32 | describe('baseUrl option', () => { 33 | it('is the beginning of the final URL', () => { 34 | const baseUrl = 'https://my-hibp-proxy:8080'; 35 | server.use( 36 | http.get(new RegExp(`^${baseUrl}`), () => { 37 | return new Response(JSON.stringify(DOMAINS)); 38 | }), 39 | ); 40 | 41 | return expect(subscribedDomains({ baseUrl })).resolves.toEqual(DOMAINS); 42 | }); 43 | }); 44 | 45 | describe('timeoutMs option', () => { 46 | it('aborts the request after the given value', () => { 47 | expect.assertions(1); 48 | const timeoutMs = 1; 49 | server.use( 50 | http.get('*', async () => { 51 | await new Promise((resolve) => { 52 | setTimeout(resolve, timeoutMs + 1); 53 | }); 54 | return new Response(JSON.stringify(DOMAINS)); 55 | }), 56 | ); 57 | 58 | return expect(subscribedDomains({ timeoutMs })).rejects.toMatchInlineSnapshot( 59 | `[TimeoutError: The operation was aborted due to timeout]`, 60 | ); 61 | }); 62 | }); 63 | 64 | describe('userAgent option', () => { 65 | it('is passed on as a request header', () => { 66 | expect.assertions(1); 67 | const userAgent = 'Custom UA'; 68 | server.use( 69 | http.get('*', ({ request }) => { 70 | expect(request.headers.get('User-Agent')).toBe(userAgent); 71 | return new Response(JSON.stringify(DOMAINS)); 72 | }), 73 | ); 74 | 75 | return subscribedDomains({ userAgent }); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/__tests__/breach.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { VERIFIED_BREACH } from '../../test/fixtures.js'; 5 | import { NOT_FOUND } from '../api/haveibeenpwned/responses.js'; 6 | import { breach } from '../breach.js'; 7 | 8 | describe('breach', () => { 9 | describe('found', () => { 10 | it('resolves with data from the remote API', () => { 11 | server.use( 12 | http.get('*', () => { 13 | return new Response(JSON.stringify(VERIFIED_BREACH)); 14 | }), 15 | ); 16 | 17 | return expect(breach('found')).resolves.toEqual(VERIFIED_BREACH); 18 | }); 19 | }); 20 | 21 | describe('not found', () => { 22 | it('resolves with null', () => { 23 | server.use( 24 | http.get('*', () => { 25 | return new Response(null, { status: NOT_FOUND.status }); 26 | }), 27 | ); 28 | 29 | return expect(breach('not found')).resolves.toBeNull(); 30 | }); 31 | }); 32 | 33 | describe('baseUrl option', () => { 34 | it('is the beginning of the final URL', () => { 35 | const baseUrl = 'https://my-hibp-proxy:8080'; 36 | server.use( 37 | http.get(new RegExp(`^${baseUrl}`), () => { 38 | return new Response(JSON.stringify(VERIFIED_BREACH)); 39 | }), 40 | ); 41 | 42 | return expect(breach('found', { baseUrl })).resolves.toEqual(VERIFIED_BREACH); 43 | }); 44 | }); 45 | 46 | describe('timeoutMs option', () => { 47 | it('aborts the request after the given value', () => { 48 | expect.assertions(1); 49 | const timeoutMs = 1; 50 | server.use( 51 | http.get('*', async () => { 52 | await new Promise((resolve) => { 53 | setTimeout(resolve, timeoutMs + 1); 54 | }); 55 | return new Response(JSON.stringify(VERIFIED_BREACH)); 56 | }), 57 | ); 58 | 59 | return expect(breach('found', { timeoutMs })).rejects.toMatchInlineSnapshot( 60 | `[TimeoutError: The operation was aborted due to timeout]`, 61 | ); 62 | }); 63 | }); 64 | 65 | describe('userAgent option', () => { 66 | it('is passed on as a request header', () => { 67 | expect.assertions(1); 68 | const userAgent = 'Custom UA'; 69 | server.use( 70 | http.get('*', ({ request }) => { 71 | expect(request.headers.get('User-Agent')).toBe(userAgent); 72 | return new Response(JSON.stringify(VERIFIED_BREACH)); 73 | }), 74 | ); 75 | 76 | return breach('found', { userAgent }); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/__tests__/search.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { VERIFIED_BREACH, PASTE } from '../../test/fixtures.js'; 5 | import { search } from '../search.js'; 6 | 7 | describe('search', () => { 8 | const BREACHES = [{ Name: VERIFIED_BREACH.Name }]; 9 | const BREACHES_EXPANDED = [VERIFIED_BREACH]; 10 | const PASTES = [PASTE]; 11 | 12 | it('searches breaches by username', () => { 13 | server.use( 14 | http.get(/breachedaccount/, () => { 15 | return new Response(JSON.stringify(BREACHES)); 16 | }), 17 | ); 18 | 19 | return expect(search('breached')).resolves.toEqual({ 20 | breaches: BREACHES, 21 | pastes: null, 22 | }); 23 | }); 24 | 25 | it('searches breaches and pastes by email address', () => { 26 | server.use( 27 | http.get(/breachedaccount/, () => { 28 | return new Response(JSON.stringify(BREACHES)); 29 | }), 30 | http.get(/pasteaccount/, () => { 31 | return new Response(JSON.stringify(PASTES)); 32 | }), 33 | ); 34 | 35 | return expect(search('pasted@email.com')).resolves.toEqual({ 36 | breaches: BREACHES, 37 | pastes: PASTES, 38 | }); 39 | }); 40 | 41 | it('forwards the apiKey option correctly', async () => { 42 | expect.assertions(2); 43 | const apiKey = 'my-api-key'; 44 | server.use( 45 | http.get(/breachedaccount/, ({ request }) => { 46 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 47 | return new Response(JSON.stringify(BREACHES)); 48 | }), 49 | http.get(/pasteaccount/, ({ request }) => { 50 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 51 | return new Response(JSON.stringify(PASTES)); 52 | }), 53 | ); 54 | 55 | return search('breached@foo.bar', { apiKey }); 56 | }); 57 | 58 | it('forwards the truncate option correctly', async () => { 59 | expect.assertions(2); 60 | server.use( 61 | http.get(/breachedaccount/, ({ request }) => { 62 | const { searchParams } = new URL(request.url); 63 | expect(searchParams.get('truncateResponse')).toBe('false'); 64 | return new Response(JSON.stringify(BREACHES_EXPANDED)); 65 | }), 66 | http.get(/pasteaccount/, ({ request }) => { 67 | const { searchParams } = new URL(request.url); 68 | expect(searchParams.has('truncateResponse')).toBe(false); 69 | return new Response(JSON.stringify(PASTES)); 70 | }), 71 | ); 72 | 73 | return search('breached@foo.bar', { truncate: false }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/__tests__/breaches.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { VERIFIED_BREACH } from '../../test/fixtures.js'; 5 | import { breaches } from '../breaches.js'; 6 | 7 | describe('breaches', () => { 8 | const BREACHES = [VERIFIED_BREACH]; 9 | 10 | describe('no parameters', () => { 11 | it('resolves with data from the remote API', () => { 12 | server.use( 13 | http.get('*', () => { 14 | return new Response(JSON.stringify(BREACHES)); 15 | }), 16 | ); 17 | 18 | return expect(breaches()).resolves.toEqual(BREACHES); 19 | }); 20 | }); 21 | 22 | describe('domain option', () => { 23 | it('sets the domain query parameter in the request', () => { 24 | expect.assertions(1); 25 | server.use( 26 | http.get('*', ({ request }) => { 27 | const { searchParams } = new URL(request.url); 28 | expect(searchParams.get('domain')).toBe('foo.bar'); 29 | return new Response(JSON.stringify(BREACHES)); 30 | }), 31 | ); 32 | 33 | return breaches({ domain: 'foo.bar' }); 34 | }); 35 | }); 36 | 37 | describe('baseUrl option', () => { 38 | it('is the beginning of the final URL', () => { 39 | const baseUrl = 'https://my-hibp-proxy:8080'; 40 | server.use( 41 | http.get(new RegExp(`^${baseUrl}`), () => { 42 | return new Response(JSON.stringify(BREACHES)); 43 | }), 44 | ); 45 | 46 | return expect(breaches({ baseUrl })).resolves.toEqual(BREACHES); 47 | }); 48 | }); 49 | 50 | describe('timeoutMs option', () => { 51 | it('aborts the request after the given value', () => { 52 | expect.assertions(1); 53 | const timeoutMs = 1; 54 | server.use( 55 | http.get('*', async () => { 56 | await new Promise((resolve) => { 57 | setTimeout(resolve, timeoutMs + 1); 58 | }); 59 | return new Response(JSON.stringify(BREACHES)); 60 | }), 61 | ); 62 | 63 | return expect(breaches({ timeoutMs })).rejects.toThrow(); 64 | }); 65 | }); 66 | 67 | describe('userAgent option', () => { 68 | it('is passed on as a request header', () => { 69 | expect.assertions(1); 70 | const userAgent = 'Custom UA'; 71 | server.use( 72 | http.get('*', ({ request }) => { 73 | expect(request.headers.get('User-Agent')).toBe(userAgent); 74 | return new Response(JSON.stringify(BREACHES)); 75 | }), 76 | ); 77 | 78 | return breaches({ userAgent }); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/breaches.ts: -------------------------------------------------------------------------------- 1 | import type { Breach } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * Fetches all breach events in the system. 6 | * 7 | * @param {object} [options] a configuration object 8 | * @param {string} [options.domain] a domain by which to filter the results 9 | * (default: all domains) 10 | * @param {string} [options.baseUrl] a custom base URL for the 11 | * haveibeenpwned.com API endpoints (default: 12 | * `https://haveibeenpwned.com/api/v3`) 13 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 14 | * (default: none) 15 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 16 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 17 | * field in the request headers (default: `hibp `) 18 | * @returns {Promise} a Promise which resolves to an array of breach 19 | * objects (an empty array if no breaches were found), or rejects with an Error 20 | * @example 21 | * try { 22 | * const data = await breaches(); 23 | * if (data) { 24 | * // ... 25 | * } else { 26 | * // ... 27 | * } 28 | * } catch (err) { 29 | * // ... 30 | * } 31 | * @example 32 | * try { 33 | * const data = await breaches({ domain: "adobe.com" }); 34 | * if (data) { 35 | * // ... 36 | * } else { 37 | * // ... 38 | * } 39 | * } catch (err) { 40 | * // ... 41 | * } 42 | */ 43 | export function breaches( 44 | options: { 45 | /** 46 | * a domain by which to filter the results (default: all domains) 47 | */ 48 | domain?: string; 49 | /** 50 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 51 | * `https://haveibeenpwned.com/api/v3`) 52 | */ 53 | baseUrl?: string; 54 | /** 55 | * timeout for the request in milliseconds (default: none) 56 | */ 57 | timeoutMs?: number; 58 | /** 59 | * an AbortSignal to cancel the request (default: none) 60 | */ 61 | signal?: AbortSignal; 62 | /** 63 | * a custom string to send as the User-Agent field in the request headers 64 | * (default: `hibp `) 65 | */ 66 | userAgent?: string; 67 | } = {}, 68 | ): Promise { 69 | const { domain, baseUrl, timeoutMs, signal, userAgent } = options; 70 | const endpoint = '/breaches?'; 71 | const params: string[] = []; 72 | 73 | if (domain) { 74 | params.push(`domain=${encodeURIComponent(domain)}`); 75 | } 76 | 77 | return fetchFromApi(`${endpoint}${params.join('&')}`, { 78 | baseUrl, 79 | timeoutMs, 80 | signal, 81 | userAgent, 82 | }) as Promise; 83 | } 84 | -------------------------------------------------------------------------------- /src/hibp.ts: -------------------------------------------------------------------------------- 1 | import { RateLimitError } from './api/haveibeenpwned/fetch-from-api.js'; 2 | import { breach } from './breach.js'; 3 | import { breachedAccount } from './breached-account.js'; 4 | import { breachedDomain } from './breached-domain.js'; 5 | import { breaches } from './breaches.js'; 6 | import { dataClasses } from './data-classes.js'; 7 | import { latestBreach } from './latest-breach.js'; 8 | import { pasteAccount } from './paste-account.js'; 9 | import { pwnedPassword } from './pwned-password.js'; 10 | import { pwnedPasswordRange } from './pwned-password-range.js'; 11 | import { search } from './search.js'; 12 | import { stealerLogsByEmail } from './stealer-logs-by-email.js'; 13 | import { stealerLogsByEmailDomain } from './stealer-logs-by-email-domain.js'; 14 | import { stealerLogsByWebsiteDomain } from './stealer-logs-by-website-domain.js'; 15 | import { subscribedDomains } from './subscribed-domains.js'; 16 | import { subscriptionStatus } from './subscription-status.js'; 17 | 18 | export type * from './api/haveibeenpwned/types.js'; 19 | 20 | /* 21 | * Export individual named functions to allow the following: 22 | * 23 | * import * as hibp from 'hibp'; // ESM (with tree-shaking) 24 | * import { search } from 'hibp'; // ESM (with tree-shaking) 25 | * const { search } = require('hibp'); // CommonJS 26 | * const hibp = require('hibp'); // CommonJS 27 | */ 28 | 29 | export { 30 | breach, 31 | breachedAccount, 32 | breachedDomain, 33 | breaches, 34 | dataClasses, 35 | latestBreach, 36 | pasteAccount, 37 | pwnedPassword, 38 | pwnedPasswordRange, 39 | search, 40 | stealerLogsByEmail, 41 | stealerLogsByEmailDomain, 42 | stealerLogsByWebsiteDomain, 43 | subscribedDomains, 44 | subscriptionStatus, 45 | RateLimitError, 46 | }; 47 | 48 | // Export the overall interface 49 | export interface HIBP { 50 | breach: typeof breach; 51 | breachedAccount: typeof breachedAccount; 52 | breachedDomain: typeof breachedDomain; 53 | breaches: typeof breaches; 54 | dataClasses: typeof dataClasses; 55 | latestBreach: typeof latestBreach; 56 | pasteAccount: typeof pasteAccount; 57 | pwnedPassword: typeof pwnedPassword; 58 | pwnedPasswordRange: typeof pwnedPasswordRange; 59 | search: typeof search; 60 | stealerLogsByEmail: typeof stealerLogsByEmail; 61 | stealerLogsByEmailDomain: typeof stealerLogsByEmailDomain; 62 | stealerLogsByWebsiteDomain: typeof stealerLogsByWebsiteDomain; 63 | subscribedDomains: typeof subscribedDomains; 64 | subscriptionStatus: typeof subscriptionStatus; 65 | RateLimitError: typeof RateLimitError; 66 | } 67 | 68 | // https://github.com/jsdoc2md/jsdoc-to-markdown/wiki/How-to-document-TypeScript#jsdoc-comments-disappear 69 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 70 | const JSDOC2MARKDOWN_STUB = undefined; 71 | -------------------------------------------------------------------------------- /src/breach.ts: -------------------------------------------------------------------------------- 1 | import type { Breach } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * An object representing a breach. 6 | * 7 | * @typedef {object} Breach 8 | * @property {string} Name 9 | * @property {string} Title 10 | * @property {string} Domain 11 | * @property {string} BreachDate 12 | * @property {string} AddedDate 13 | * @property {string} ModifiedDate 14 | * @property {number} PwnCount 15 | * @property {string} Description 16 | * @property {string[]} DataClasses 17 | * @property {boolean} IsVerified 18 | * @property {boolean} IsFabricated 19 | * @property {boolean} IsSensitive 20 | * @property {boolean} IsRetired 21 | * @property {boolean} IsSpamList 22 | * @property {boolean} IsMalware 23 | * @property {boolean} IsSubscriptionFree 24 | * @property {string} LogoPath 25 | */ 26 | 27 | /** 28 | * Fetches data for a specific breach event. 29 | * 30 | * @param {string} breachName the name of a breach in the system 31 | * @param {object} [options] a configuration object 32 | * @param {string} [options.baseUrl] a custom base URL for the 33 | * haveibeenpwned.com API endpoints (default: 34 | * `https://haveibeenpwned.com/api/v3`) 35 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 36 | * (default: none) 37 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 38 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 39 | * field in the request headers (default: `hibp `) 40 | * @returns {(Promise|Promise)} a Promise which resolves to an 41 | * object representing a breach (or null if no breach was found), or rejects 42 | * with an Error 43 | * @example 44 | * try { 45 | * const data = await breach("Adobe"); 46 | * if (data) { 47 | * // ... 48 | * } else { 49 | * // ... 50 | * } 51 | * } catch (err) { 52 | * // ... 53 | * } 54 | */ 55 | export function breach( 56 | breachName: string, 57 | options: { 58 | /** 59 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 60 | * `https://haveibeenpwned.com/api/v3`) 61 | */ 62 | baseUrl?: string; 63 | /** 64 | * timeout for the request in milliseconds (default: none) 65 | */ 66 | timeoutMs?: number; 67 | /** 68 | * an AbortSignal to cancel the request (default: none) 69 | */ 70 | signal?: AbortSignal; 71 | /** 72 | * a custom string to send as the User-Agent field in the request headers 73 | * (default: `hibp `) 74 | */ 75 | userAgent?: string; 76 | } = {}, 77 | ): Promise { 78 | return fetchFromApi( 79 | `/breach/${encodeURIComponent(breachName)}`, 80 | options, 81 | ) as Promise; 82 | } 83 | -------------------------------------------------------------------------------- /src/subscription-status.ts: -------------------------------------------------------------------------------- 1 | import type { SubscriptionStatus } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * An object representing the status of your HIBP subscription. 6 | * 7 | * @typedef {object} SubscriptionStatus 8 | * @property {string} SubscriptionName 9 | * @property {string} Description 10 | * @property {string} SubscribedUntil 11 | * @property {number} Rpm 12 | * @property {number} DomainSearchMaxBreachedAccounts 13 | * @property {boolean} IncludesStealerLogs 14 | */ 15 | 16 | /** 17 | * Fetches the current status of your HIBP subscription (API key). 18 | * 19 | * 🔑 `haveibeenpwned.com` requires an API key from 20 | * https://haveibeenpwned.com/API/Key for the `subscription/status` endpoint. 21 | * The `apiKey` option here is not explicitly required, but direct requests made 22 | * without it will fail (unless you specify a `baseUrl` to a proxy that inserts 23 | * a valid API key on your behalf). 24 | * 25 | * @param {object} [options] a configuration object 26 | * @param {string} [options.apiKey] an API key from 27 | * https://haveibeenpwned.com/API/Key (default: undefined) 28 | * @param {string} [options.baseUrl] a custom base URL for the 29 | * haveibeenpwned.com API endpoints (default: 30 | * `https://haveibeenpwned.com/api/v3`) 31 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 32 | * (default: none) 33 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 34 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 35 | * field in the request headers (default: `hibp `) 36 | * @returns {Promise} a Promise which resolves to a 37 | * subscription status object, or rejects with an Error 38 | * @example 39 | * try { 40 | * const data = await subscriptionStatus({ apiKey: "my-api-key" }); 41 | * // ... 42 | * } catch (err) { 43 | * // ... 44 | * } 45 | * @example 46 | * try { 47 | * const data = await subscriptionStatus({ 48 | * baseUrl: "https://my-hibp-proxy:8080", 49 | * }); 50 | * // ... 51 | * } catch (err) { 52 | * // ... 53 | * } 54 | */ 55 | export async function subscriptionStatus( 56 | options: { 57 | /** 58 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 59 | */ 60 | apiKey?: string; 61 | /** 62 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 63 | * `https://haveibeenpwned.com/api/v3`) 64 | */ 65 | baseUrl?: string; 66 | /** 67 | * timeout for the request in milliseconds (default: none) 68 | */ 69 | timeoutMs?: number; 70 | /** 71 | * an AbortSignal to cancel the request (default: none) 72 | */ 73 | signal?: AbortSignal; 74 | /** 75 | * a custom string to send as the User-Agent field in the request headers 76 | * (default: `hibp `) 77 | */ 78 | userAgent?: string; 79 | } = {}, 80 | ) { 81 | const endpoint = '/subscription/status'; 82 | 83 | return fetchFromApi(endpoint, options) as Promise; 84 | } 85 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "hibp", 3 | "projectOwner": "wKovacs64", 4 | "imageSize": 100, 5 | "commit": false, 6 | "contributorsPerLine": 7, 7 | "repoType": "github", 8 | "repoHost": "https://github.com", 9 | "skipCi": true, 10 | "files": [ 11 | "README.md", 12 | "docs/index.md" 13 | ], 14 | "contributors": [ 15 | { 16 | "login": "wKovacs64", 17 | "name": "Justin Hall", 18 | "avatar_url": "https://avatars.githubusercontent.com/u/1288694?v=4", 19 | "profile": "https://github.com/wKovacs64", 20 | "contributions": [ 21 | "code", 22 | "doc", 23 | "infra", 24 | "maintenance", 25 | "review", 26 | "test" 27 | ] 28 | }, 29 | { 30 | "login": "troyhunt", 31 | "name": "Troy Hunt", 32 | "avatar_url": "https://avatars.githubusercontent.com/u/273244?v=4", 33 | "profile": "https://www.troyhunt.com", 34 | "contributions": [ 35 | "data" 36 | ] 37 | }, 38 | { 39 | "login": "jellekralt", 40 | "name": "Jelle Kralt", 41 | "avatar_url": "https://avatars.githubusercontent.com/u/214558?v=4", 42 | "profile": "https://jellekralt.com", 43 | "contributions": [ 44 | "code" 45 | ] 46 | }, 47 | { 48 | "login": "timaschew", 49 | "name": "Anton W", 50 | "avatar_url": "https://avatars.githubusercontent.com/u/110870?v=4", 51 | "profile": "https://github.com/timaschew", 52 | "contributions": [ 53 | "bug" 54 | ] 55 | }, 56 | { 57 | "login": "danieladams456", 58 | "name": "Daniel Adams", 59 | "avatar_url": "https://avatars.githubusercontent.com/u/3953840?v=4", 60 | "profile": "https://github.com/danieladams456", 61 | "contributions": [ 62 | "code" 63 | ] 64 | }, 65 | { 66 | "login": "yelworc", 67 | "name": "Markus Dolic", 68 | "avatar_url": "https://avatars.githubusercontent.com/u/454308?v=4", 69 | "profile": "https://twitter.com/d0gb3r7", 70 | "contributions": [ 71 | "bug" 72 | ] 73 | }, 74 | { 75 | "login": "textbook", 76 | "name": "Jonathan Sharpe", 77 | "avatar_url": "https://avatars.githubusercontent.com/u/785939?v=4", 78 | "profile": "https://github.com/textbook/about", 79 | "contributions": [ 80 | "code" 81 | ] 82 | }, 83 | { 84 | "login": "ArcadeRenegade", 85 | "name": "Ryan", 86 | "avatar_url": "https://avatars.githubusercontent.com/u/13874898?v=4", 87 | "profile": "https://github.com/ArcadeRenegade", 88 | "contributions": [ 89 | "bug" 90 | ] 91 | }, 92 | { 93 | "login": "PodStuart", 94 | "name": "Stuart McGregor", 95 | "avatar_url": "https://avatars.githubusercontent.com/u/107403965?v=4", 96 | "profile": "https://github.com/PodStuart", 97 | "contributions": [ 98 | "bug" 99 | ] 100 | } 101 | ], 102 | "commitType": "docs", 103 | "commitConvention": "angular" 104 | } 105 | -------------------------------------------------------------------------------- /src/paste-account.ts: -------------------------------------------------------------------------------- 1 | import type { Paste } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * An object representing a paste. 6 | * 7 | * @typedef {object} Paste 8 | * @property {string} Id 9 | * @property {string} Source 10 | * @property {string} Title 11 | * @property {string} Date 12 | * @property {number} EmailCount 13 | */ 14 | 15 | /** 16 | * Fetches paste data for a specific account (email address). 17 | * 18 | * 🔑 `haveibeenpwned.com` requires an API key from 19 | * https://haveibeenpwned.com/API/Key for the `pasteaccount` endpoint. The 20 | * `apiKey` option here is not explicitly required, but direct requests made 21 | * without it will fail (unless you specify a `baseUrl` to a proxy that inserts 22 | * a valid API key on your behalf). 23 | * 24 | * @param {string} email the email address to query 25 | * @param {object} [options] a configuration object 26 | * @param {string} [options.apiKey] an API key from 27 | * https://haveibeenpwned.com/API/Key (default: undefined) 28 | * @param {string} [options.baseUrl] a custom base URL for the 29 | * haveibeenpwned.com API endpoints (default: 30 | * `https://haveibeenpwned.com/api/v3`) 31 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 32 | * (default: none) 33 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 34 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 35 | * field in the request headers (default: `hibp `) 36 | * @returns {(Promise | Promise)} a Promise which resolves to an 37 | * array of paste objects (or null if no pastes were found), or rejects with an 38 | * Error 39 | * @example 40 | * try { 41 | * const data = await pasteAccount("foo@bar.com", { apiKey: "my-api-key" }); 42 | * if (data) { 43 | * // ... 44 | * } else { 45 | * // ... 46 | * } 47 | * } catch (err) { 48 | * // ... 49 | * } 50 | * @example 51 | * try { 52 | * const data = await pasteAccount("foo@bar.com", { 53 | * baseUrl: "https://my-hibp-proxy:8080", 54 | * }); 55 | * if (data) { 56 | * // ... 57 | * } else { 58 | * // ... 59 | * } 60 | * } catch (err) { 61 | * // ... 62 | * } 63 | */ 64 | export function pasteAccount( 65 | email: string, 66 | options: { 67 | /** 68 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 69 | */ 70 | apiKey?: string; 71 | /** 72 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 73 | * `https://haveibeenpwned.com/api/v3`) 74 | */ 75 | baseUrl?: string; 76 | /** 77 | * timeout for the request in milliseconds (default: none) 78 | */ 79 | timeoutMs?: number; 80 | /** 81 | * an AbortSignal to cancel the request (default: none) 82 | */ 83 | signal?: AbortSignal; 84 | /** 85 | * a custom string to send as the User-Agent field in the request headers 86 | * (default: `hibp `) 87 | */ 88 | userAgent?: string; 89 | } = {}, 90 | ): Promise { 91 | return fetchFromApi(`/pasteaccount/${encodeURIComponent(email)}`, options) as Promise< 92 | Paste[] | null 93 | >; 94 | } 95 | -------------------------------------------------------------------------------- /src/pwned-password.ts: -------------------------------------------------------------------------------- 1 | import { pwnedPasswordRange } from './pwned-password-range.js'; 2 | 3 | /** 4 | * Fetches the number of times the the given password has been exposed in a 5 | * breach (0 indicating no exposure). The password is given in plain text, but 6 | * only the first 5 characters of its SHA-1 hash will be submitted to the API. 7 | * 8 | * @param {string} password a password in plain text 9 | * @param {object} [options] a configuration object 10 | * @param {boolean} [options.addPadding] ask the remote API to add padding to 11 | * the response to obscure the password prefix (default: `false`) 12 | * @param {string} [options.baseUrl] a custom base URL for the 13 | * pwnedpasswords.com API endpoints (default: `https://api.pwnedpasswords.com`) 14 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 15 | * (default: none) 16 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 17 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 18 | * field in the request headers (default: `hibp `) 19 | * @returns {Promise} a Promise which resolves to the number of times 20 | * the password has been exposed in a breach, or rejects with an Error 21 | * @example 22 | * try { 23 | * const numPwns = await pwnedPassword("f00b4r"); 24 | * // truthy check or numeric condition 25 | * if (numPwns) { 26 | * // ... 27 | * } else { 28 | * // ... 29 | * } 30 | * } catch (err) { 31 | * // ... 32 | * } 33 | * @see https://haveibeenpwned.com/api/v3#PwnedPasswords 34 | */ 35 | export async function pwnedPassword( 36 | password: string, 37 | options: { 38 | /** 39 | * ask the remote API to add padding to the response to obscure the password 40 | * prefix (default: `false`) 41 | */ 42 | addPadding?: boolean; 43 | /** 44 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 45 | * `https://haveibeenpwned.com/api/v3`) 46 | */ 47 | baseUrl?: string; 48 | /** 49 | * timeout for the request in milliseconds (default: none) 50 | */ 51 | timeoutMs?: number; 52 | /** 53 | * an AbortSignal to cancel the request (default: none) 54 | */ 55 | signal?: AbortSignal; 56 | /** 57 | * a custom string to send as the User-Agent field in the request headers 58 | * (default: `hibp `) 59 | */ 60 | userAgent?: string; 61 | } = {}, 62 | ): Promise { 63 | const [prefix, suffix] = await getPasswordHashParts(password); 64 | const range = await pwnedPasswordRange(prefix, options); 65 | return range[suffix] || 0; 66 | } 67 | 68 | async function getPasswordHashParts(password: string) { 69 | if (typeof crypto === 'object' && crypto.subtle) { 70 | const msgUint8 = new TextEncoder().encode(password); 71 | const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); 72 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 73 | const hashHex = hashArray 74 | .map((byte) => byte.toString(16).padStart(2, '0')) 75 | .join('') 76 | .toUpperCase(); 77 | 78 | return [hashHex.slice(0, 5), hashHex.slice(5)] as const; 79 | } 80 | 81 | throw new Error('The Web Crypto API is not available in this environment.'); 82 | } 83 | -------------------------------------------------------------------------------- /src/__tests__/breached-domain.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { NOT_FOUND } from '../api/haveibeenpwned/responses.js'; 5 | import { breachedDomain } from '../breached-domain.js'; 6 | 7 | describe('breachedDomain', () => { 8 | const DOMAIN_RESULTS = { alias1: ['Adobe'], alias2: ['Adobe', 'Gawker'] }; 9 | 10 | describe('found', () => { 11 | it('resolves with data from the remote API', () => { 12 | server.use( 13 | http.get('*', () => { 14 | return new Response(JSON.stringify(DOMAIN_RESULTS)); 15 | }), 16 | ); 17 | 18 | return expect(breachedDomain('example.com', { apiKey: 'k' })).resolves.toEqual( 19 | DOMAIN_RESULTS, 20 | ); 21 | }); 22 | }); 23 | 24 | describe('not found', () => { 25 | it('resolves with null', () => { 26 | server.use( 27 | http.get('*', () => { 28 | return new Response(null, { status: NOT_FOUND.status }); 29 | }), 30 | ); 31 | 32 | return expect(breachedDomain('example.com')).resolves.toBeNull(); 33 | }); 34 | }); 35 | 36 | describe('apiKey option', () => { 37 | it('sets the hibp-api-key header', async () => { 38 | expect.assertions(1); 39 | const apiKey = 'my-api-key'; 40 | server.use( 41 | http.get('*', ({ request }) => { 42 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 43 | return new Response(JSON.stringify(DOMAIN_RESULTS)); 44 | }), 45 | ); 46 | 47 | return breachedDomain('example.com', { apiKey }); 48 | }); 49 | }); 50 | 51 | describe('baseUrl option', () => { 52 | it('is the beginning of the final URL', () => { 53 | const baseUrl = 'https://my-hibp-proxy:8080'; 54 | server.use( 55 | http.get(new RegExp(`^${baseUrl}`), () => { 56 | return new Response(JSON.stringify(DOMAIN_RESULTS)); 57 | }), 58 | ); 59 | 60 | return expect(breachedDomain('example.com', { baseUrl })).resolves.toEqual(DOMAIN_RESULTS); 61 | }); 62 | }); 63 | 64 | describe('timeoutMs option', () => { 65 | it('aborts the request after the given value', () => { 66 | expect.assertions(1); 67 | const timeoutMs = 1; 68 | server.use( 69 | http.get('*', async () => { 70 | await new Promise((resolve) => { 71 | setTimeout(resolve, timeoutMs + 1); 72 | }); 73 | return new Response(JSON.stringify(DOMAIN_RESULTS)); 74 | }), 75 | ); 76 | 77 | return expect(breachedDomain('example.com', { timeoutMs })).rejects.toMatchInlineSnapshot( 78 | `[TimeoutError: The operation was aborted due to timeout]`, 79 | ); 80 | }); 81 | }); 82 | 83 | describe('userAgent option', () => { 84 | it('is passed on as a request header', () => { 85 | expect.assertions(1); 86 | const userAgent = 'Custom UA'; 87 | server.use( 88 | http.get('*', ({ request }) => { 89 | expect(request.headers.get('User-Agent')).toBe(userAgent); 90 | return new Response(JSON.stringify(DOMAIN_RESULTS)); 91 | }), 92 | ); 93 | 94 | return breachedDomain('example.com', { userAgent }); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/stealer-logs-by-email.ts: -------------------------------------------------------------------------------- 1 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 2 | 3 | /** 4 | * Fetches all stealer log domains for an email address. 5 | * 6 | * Returns an array of domains for which stealer logs contain entries for the 7 | * supplied email address. 8 | * 9 | * 🔑 `haveibeenpwned.com` requires an API key from 10 | * https://haveibeenpwned.com/API/Key for the `stealerlogsbyemail` endpoint. The 11 | * `apiKey` option here is not explicitly required, but direct requests made 12 | * without it will fail (unless you specify a `baseUrl` to a proxy that inserts 13 | * a valid API key on your behalf). 14 | * 15 | * @param {string} emailAddress the email address to query 16 | * @param {object} [options] a configuration object 17 | * @param {string} [options.apiKey] an API key from 18 | * https://haveibeenpwned.com/API/Key (default: undefined) 19 | * @param {string} [options.baseUrl] a custom base URL for the 20 | * haveibeenpwned.com API endpoints (default: 21 | * `https://haveibeenpwned.com/api/v3`) 22 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 23 | * (default: none) 24 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 25 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 26 | * field in the request headers (default: `hibp `) 27 | * @returns {(Promise | Promise)} a Promise which resolves to an 28 | * array of domain strings (or null if none were found), or rejects with an 29 | * Error 30 | * @example 31 | * try { 32 | * const data = await stealerLogsByEmail("foo@bar.com", { apiKey: "my-api-key" }); 33 | * if (data) { 34 | * // ... 35 | * } else { 36 | * // ... 37 | * } 38 | * } catch (err) { 39 | * // ... 40 | * } 41 | * @example 42 | * try { 43 | * const data = await stealerLogsByEmail("foo@bar.com", { 44 | * baseUrl: "https://my-hibp-proxy:8080", 45 | * }); 46 | * if (data) { 47 | * // ... 48 | * } else { 49 | * // ... 50 | * } 51 | * } catch (err) { 52 | * // ... 53 | * } 54 | */ 55 | export function stealerLogsByEmail( 56 | emailAddress: string, 57 | options: { 58 | /** 59 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 60 | */ 61 | apiKey?: string; 62 | /** 63 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 64 | * `https://haveibeenpwned.com/api/v3`) 65 | */ 66 | baseUrl?: string; 67 | /** 68 | * timeout for the request in milliseconds (default: none) 69 | */ 70 | timeoutMs?: number; 71 | /** 72 | * an AbortSignal to cancel the request (default: none) 73 | */ 74 | signal?: AbortSignal; 75 | /** 76 | * a custom string to send as the User-Agent field in the request headers 77 | * (default: `hibp `) 78 | */ 79 | userAgent?: string; 80 | } = {}, 81 | ): Promise { 82 | const { apiKey, baseUrl, timeoutMs, signal, userAgent } = options; 83 | const endpoint = `/stealerlogsbyemail/${encodeURIComponent(emailAddress)}`; 84 | 85 | return fetchFromApi(endpoint, { 86 | apiKey, 87 | baseUrl, 88 | timeoutMs, 89 | signal, 90 | userAgent, 91 | }) as Promise; 92 | } 93 | -------------------------------------------------------------------------------- /src/__tests__/paste-account.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { PASTE } from '../../test/fixtures.js'; 5 | import { NOT_FOUND } from '../api/haveibeenpwned/responses.js'; 6 | import { pasteAccount } from '../paste-account.js'; 7 | 8 | describe('pasteAccount', () => { 9 | const PASTE_ACCOUNT_DATA = [PASTE]; 10 | 11 | describe('pasted email', () => { 12 | it('resolves with data from the remote API', () => { 13 | server.use( 14 | http.get('*', () => { 15 | return new Response(JSON.stringify(PASTE_ACCOUNT_DATA)); 16 | }), 17 | ); 18 | 19 | return expect(pasteAccount('pasted@email.com')).resolves.toEqual(PASTE_ACCOUNT_DATA); 20 | }); 21 | }); 22 | 23 | describe('clean email', () => { 24 | it('resolves with null', () => { 25 | server.use( 26 | http.get('*', () => { 27 | return new Response(null, { status: NOT_FOUND.status }); 28 | }), 29 | ); 30 | 31 | return expect(pasteAccount('clean@whistle.com')).resolves.toBeNull(); 32 | }); 33 | }); 34 | 35 | describe('apiKey option', () => { 36 | it('sets the hibp-api-key header', async () => { 37 | expect.assertions(1); 38 | const apiKey = 'my-api-key'; 39 | server.use( 40 | http.get('*', ({ request }) => { 41 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 42 | return new Response(JSON.stringify(PASTE_ACCOUNT_DATA)); 43 | }), 44 | ); 45 | 46 | return pasteAccount('whatever@example.com', { apiKey }); 47 | }); 48 | }); 49 | 50 | describe('baseUrl option', () => { 51 | it('is the beginning of the final URL', () => { 52 | const baseUrl = 'https://my-hibp-proxy:8080'; 53 | server.use( 54 | http.get(new RegExp(`^${baseUrl}`), () => { 55 | return new Response(JSON.stringify(PASTE_ACCOUNT_DATA)); 56 | }), 57 | ); 58 | 59 | return expect(pasteAccount('whatever@example.com', { baseUrl })).resolves.toEqual( 60 | PASTE_ACCOUNT_DATA, 61 | ); 62 | }); 63 | }); 64 | 65 | describe('timeoutMs option', () => { 66 | it('aborts the request after the given value', () => { 67 | expect.assertions(1); 68 | const timeoutMs = 1; 69 | server.use( 70 | http.get('*', async () => { 71 | await new Promise((resolve) => { 72 | setTimeout(resolve, timeoutMs + 1); 73 | }); 74 | return new Response(JSON.stringify(PASTE_ACCOUNT_DATA)); 75 | }), 76 | ); 77 | 78 | return expect( 79 | pasteAccount('whatever@example.com', { timeoutMs }), 80 | ).rejects.toMatchInlineSnapshot(`[TimeoutError: The operation was aborted due to timeout]`); 81 | }); 82 | }); 83 | 84 | describe('userAgent option', () => { 85 | it('is passed on as a request header', () => { 86 | expect.assertions(1); 87 | const userAgent = 'Custom UA'; 88 | server.use( 89 | http.get('*', ({ request }) => { 90 | expect(request.headers.get('User-Agent')).toBe(userAgent); 91 | return new Response(JSON.stringify(PASTE_ACCOUNT_DATA)); 92 | }), 93 | ); 94 | 95 | return pasteAccount('whatever@example.com', { userAgent }); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/__tests__/stealer-logs-by-email.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { NOT_FOUND } from '../api/haveibeenpwned/responses.js'; 5 | import { stealerLogsByEmail } from '../stealer-logs-by-email.js'; 6 | 7 | describe('stealerLogsByEmail', () => { 8 | const DOMAINS = ['netflix.com', 'spotify.com']; 9 | 10 | describe('found', () => { 11 | it('resolves with data from the remote API', () => { 12 | server.use( 13 | http.get('*', () => { 14 | return new Response(JSON.stringify(DOMAINS)); 15 | }), 16 | ); 17 | 18 | return expect(stealerLogsByEmail('person@example.com', { apiKey: 'k' })).resolves.toEqual( 19 | DOMAINS, 20 | ); 21 | }); 22 | }); 23 | 24 | describe('not found', () => { 25 | it('resolves with null', () => { 26 | server.use( 27 | http.get('*', () => { 28 | return new Response(null, { status: NOT_FOUND.status }); 29 | }), 30 | ); 31 | 32 | return expect(stealerLogsByEmail('person@example.com')).resolves.toBeNull(); 33 | }); 34 | }); 35 | 36 | describe('apiKey option', () => { 37 | it('sets the hibp-api-key header', async () => { 38 | expect.assertions(1); 39 | const apiKey = 'my-api-key'; 40 | server.use( 41 | http.get('*', ({ request }) => { 42 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 43 | return new Response(JSON.stringify(DOMAINS)); 44 | }), 45 | ); 46 | 47 | return stealerLogsByEmail('whatever@example.com', { apiKey }); 48 | }); 49 | }); 50 | 51 | describe('baseUrl option', () => { 52 | it('is the beginning of the final URL', () => { 53 | const baseUrl = 'https://my-hibp-proxy:8080'; 54 | server.use( 55 | http.get(new RegExp(`^${baseUrl}`), () => { 56 | return new Response(JSON.stringify(DOMAINS)); 57 | }), 58 | ); 59 | 60 | return expect(stealerLogsByEmail('whatever@example.com', { baseUrl })).resolves.toEqual( 61 | DOMAINS, 62 | ); 63 | }); 64 | }); 65 | 66 | describe('timeoutMs option', () => { 67 | it('aborts the request after the given value', () => { 68 | expect.assertions(1); 69 | const timeoutMs = 1; 70 | server.use( 71 | http.get('*', async () => { 72 | await new Promise((resolve) => { 73 | setTimeout(resolve, timeoutMs + 1); 74 | }); 75 | return new Response(JSON.stringify(DOMAINS)); 76 | }), 77 | ); 78 | 79 | return expect( 80 | stealerLogsByEmail('whatever@example.com', { timeoutMs }), 81 | ).rejects.toMatchInlineSnapshot(`[TimeoutError: The operation was aborted due to timeout]`); 82 | }); 83 | }); 84 | 85 | describe('userAgent option', () => { 86 | it('is passed on as a request header', () => { 87 | expect.assertions(1); 88 | const userAgent = 'Custom UA'; 89 | server.use( 90 | http.get('*', ({ request }) => { 91 | expect(request.headers.get('User-Agent')).toBe(userAgent); 92 | return new Response(JSON.stringify(DOMAINS)); 93 | }), 94 | ); 95 | 96 | return stealerLogsByEmail('whatever@example.com', { userAgent }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/__tests__/stealer-logs-by-website-domain.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { NOT_FOUND } from '../api/haveibeenpwned/responses.js'; 5 | import { stealerLogsByWebsiteDomain } from '../stealer-logs-by-website-domain.js'; 6 | 7 | describe('stealerLogsByWebsiteDomain', () => { 8 | const RESULTS = ['andy@gmail.com', 'jane@gmail.com']; 9 | 10 | describe('found', () => { 11 | it('resolves with data from the remote API', () => { 12 | server.use( 13 | http.get('*', () => { 14 | return new Response(JSON.stringify(RESULTS)); 15 | }), 16 | ); 17 | 18 | return expect(stealerLogsByWebsiteDomain('example.com', { apiKey: 'k' })).resolves.toEqual( 19 | RESULTS, 20 | ); 21 | }); 22 | }); 23 | 24 | describe('not found', () => { 25 | it('resolves with null', () => { 26 | server.use( 27 | http.get('*', () => { 28 | return new Response(null, { status: NOT_FOUND.status }); 29 | }), 30 | ); 31 | 32 | return expect(stealerLogsByWebsiteDomain('example.com')).resolves.toBeNull(); 33 | }); 34 | }); 35 | 36 | describe('apiKey option', () => { 37 | it('sets the hibp-api-key header', async () => { 38 | expect.assertions(1); 39 | const apiKey = 'my-api-key'; 40 | server.use( 41 | http.get('*', ({ request }) => { 42 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 43 | return new Response(JSON.stringify(RESULTS)); 44 | }), 45 | ); 46 | 47 | return stealerLogsByWebsiteDomain('example.com', { apiKey }); 48 | }); 49 | }); 50 | 51 | describe('baseUrl option', () => { 52 | it('is the beginning of the final URL', () => { 53 | const baseUrl = 'https://my-hibp-proxy:8080'; 54 | server.use( 55 | http.get(new RegExp(`^${baseUrl}`), () => { 56 | return new Response(JSON.stringify(RESULTS)); 57 | }), 58 | ); 59 | 60 | return expect(stealerLogsByWebsiteDomain('example.com', { baseUrl })).resolves.toEqual( 61 | RESULTS, 62 | ); 63 | }); 64 | }); 65 | 66 | describe('timeoutMs option', () => { 67 | it('aborts the request after the given value', () => { 68 | expect.assertions(1); 69 | const timeoutMs = 1; 70 | server.use( 71 | http.get('*', async () => { 72 | await new Promise((resolve) => { 73 | setTimeout(resolve, timeoutMs + 1); 74 | }); 75 | return new Response(JSON.stringify(RESULTS)); 76 | }), 77 | ); 78 | 79 | return expect( 80 | stealerLogsByWebsiteDomain('example.com', { timeoutMs }), 81 | ).rejects.toMatchInlineSnapshot(`[TimeoutError: The operation was aborted due to timeout]`); 82 | }); 83 | }); 84 | 85 | describe('userAgent option', () => { 86 | it('is passed on as a request header', () => { 87 | expect.assertions(1); 88 | const userAgent = 'Custom UA'; 89 | server.use( 90 | http.get('*', ({ request }) => { 91 | expect(request.headers.get('User-Agent')).toBe(userAgent); 92 | return new Response(JSON.stringify(RESULTS)); 93 | }), 94 | ); 95 | 96 | return stealerLogsByWebsiteDomain('example.com', { userAgent }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/stealer-logs-by-website-domain.ts: -------------------------------------------------------------------------------- 1 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 2 | 3 | /** 4 | * Fetches all stealer log email addresses for a website domain. 5 | * 6 | * The result is an array of strings representing email addresses found in 7 | * stealer logs for the specified website domain (e.g., "example.com"). 8 | * 9 | * 🔑 `haveibeenpwned.com` requires an API key from 10 | * https://haveibeenpwned.com/API/Key for the `stealerlogsbywebsitedomain` 11 | * endpoint. The `apiKey` option here is not explicitly required, but direct 12 | * requests made without it will fail (unless you specify a `baseUrl` to a proxy 13 | * that inserts a valid API key on your behalf). 14 | * 15 | * @param {string} websiteDomain the website domain to query (e.g., "example.com") 16 | * @param {object} [options] a configuration object 17 | * @param {string} [options.apiKey] an API key from 18 | * https://haveibeenpwned.com/API/Key (default: undefined) 19 | * @param {string} [options.baseUrl] a custom base URL for the 20 | * haveibeenpwned.com API endpoints (default: 21 | * `https://haveibeenpwned.com/api/v3`) 22 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 23 | * (default: none) 24 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 25 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 26 | * field in the request headers (default: `hibp `) 27 | * @returns {(Promise | Promise)} a Promise which resolves to an 28 | * array of email addresses (or null if no results were found), or rejects with 29 | * an Error 30 | * @example 31 | * try { 32 | * const data = await stealerLogsByWebsiteDomain("example.com", { apiKey: "my-api-key" }); 33 | * if (data) { 34 | * // ["andy@gmail.com", "jane@gmail.com"] 35 | * } else { 36 | * // no results 37 | * } 38 | * } catch (err) { 39 | * // ... 40 | * } 41 | * @example 42 | * try { 43 | * const data = await stealerLogsByWebsiteDomain("example.com", { 44 | * baseUrl: "https://my-hibp-proxy:8080", 45 | * }); 46 | * if (data) { 47 | * // ... 48 | * } else { 49 | * // ... 50 | * } 51 | * } catch (err) { 52 | * // ... 53 | * } 54 | */ 55 | export function stealerLogsByWebsiteDomain( 56 | websiteDomain: string, 57 | options: { 58 | /** 59 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 60 | */ 61 | apiKey?: string; 62 | /** 63 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 64 | * `https://haveibeenpwned.com/api/v3`) 65 | */ 66 | baseUrl?: string; 67 | /** 68 | * timeout for the request in milliseconds (default: none) 69 | */ 70 | timeoutMs?: number; 71 | /** 72 | * an AbortSignal to cancel the request (default: none) 73 | */ 74 | signal?: AbortSignal; 75 | /** 76 | * a custom string to send as the User-Agent field in the request headers 77 | * (default: `hibp `) 78 | */ 79 | userAgent?: string; 80 | } = {}, 81 | ): Promise { 82 | const { apiKey, baseUrl, timeoutMs, signal, userAgent } = options; 83 | const endpoint = `/stealerlogsbywebsitedomain/${encodeURIComponent(websiteDomain)}`; 84 | 85 | return fetchFromApi(endpoint, { 86 | apiKey, 87 | baseUrl, 88 | timeoutMs, 89 | signal, 90 | userAgent, 91 | }) as Promise; 92 | } 93 | -------------------------------------------------------------------------------- /src/__tests__/stealer-logs-by-email-domain.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { NOT_FOUND } from '../api/haveibeenpwned/responses.js'; 5 | import { stealerLogsByEmailDomain } from '../stealer-logs-by-email-domain.js'; 6 | 7 | describe('stealerLogsByEmailDomain', () => { 8 | const STEALER_RESULTS = { andy: ['netflix.com'], jane: ['netflix.com', 'spotify.com'] }; 9 | 10 | describe('found', () => { 11 | it('resolves with data from the remote API', () => { 12 | server.use( 13 | http.get('*', () => { 14 | return new Response(JSON.stringify(STEALER_RESULTS)); 15 | }), 16 | ); 17 | 18 | return expect(stealerLogsByEmailDomain('example.com', { apiKey: 'k' })).resolves.toEqual( 19 | STEALER_RESULTS, 20 | ); 21 | }); 22 | }); 23 | 24 | describe('not found', () => { 25 | it('resolves with null', () => { 26 | server.use( 27 | http.get('*', () => { 28 | return new Response(null, { status: NOT_FOUND.status }); 29 | }), 30 | ); 31 | 32 | return expect(stealerLogsByEmailDomain('example.com')).resolves.toBeNull(); 33 | }); 34 | }); 35 | 36 | describe('apiKey option', () => { 37 | it('sets the hibp-api-key header', async () => { 38 | expect.assertions(1); 39 | const apiKey = 'my-api-key'; 40 | server.use( 41 | http.get('*', ({ request }) => { 42 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 43 | return new Response(JSON.stringify(STEALER_RESULTS)); 44 | }), 45 | ); 46 | 47 | return stealerLogsByEmailDomain('example.com', { apiKey }); 48 | }); 49 | }); 50 | 51 | describe('baseUrl option', () => { 52 | it('is the beginning of the final URL', () => { 53 | const baseUrl = 'https://my-hibp-proxy:8080'; 54 | server.use( 55 | http.get(new RegExp(`^${baseUrl}`), () => { 56 | return new Response(JSON.stringify(STEALER_RESULTS)); 57 | }), 58 | ); 59 | 60 | return expect(stealerLogsByEmailDomain('example.com', { baseUrl })).resolves.toEqual( 61 | STEALER_RESULTS, 62 | ); 63 | }); 64 | }); 65 | 66 | describe('timeoutMs option', () => { 67 | it('aborts the request after the given value', () => { 68 | expect.assertions(1); 69 | const timeoutMs = 1; 70 | server.use( 71 | http.get('*', async () => { 72 | await new Promise((resolve) => { 73 | setTimeout(resolve, timeoutMs + 1); 74 | }); 75 | return new Response(JSON.stringify(STEALER_RESULTS)); 76 | }), 77 | ); 78 | 79 | return expect( 80 | stealerLogsByEmailDomain('example.com', { timeoutMs }), 81 | ).rejects.toMatchInlineSnapshot(`[TimeoutError: The operation was aborted due to timeout]`); 82 | }); 83 | }); 84 | 85 | describe('userAgent option', () => { 86 | it('is passed on as a request header', () => { 87 | expect.assertions(1); 88 | const userAgent = 'Custom UA'; 89 | server.use( 90 | http.get('*', ({ request }) => { 91 | expect(request.headers.get('User-Agent')).toBe(userAgent); 92 | return new Response(JSON.stringify(STEALER_RESULTS)); 93 | }), 94 | ); 95 | 96 | return stealerLogsByEmailDomain('example.com', { userAgent }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/breached-domain.ts: -------------------------------------------------------------------------------- 1 | import type { BreachedDomainsByEmailAlias } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * An object mapping an email alias (local-part before the '@') to the list of 6 | * breach names that alias has appeared in for the specified domain. 7 | * 8 | * @typedef {Object.} BreachedDomainsByEmailAlias 9 | */ 10 | 11 | /** 12 | * Fetches all breached email addresses for a domain. 13 | * 14 | * The result maps email aliases (the local-part before the '@') to an array of 15 | * breach names. For example, querying `example.com` could return an object like 16 | * `{ "john": ["Adobe"], "jane": ["Adobe", "Gawker"] }`, corresponding to 17 | * `john@example.com` and `jane@example.com`. 18 | * 19 | * 🔑 `haveibeenpwned.com` requires an API key from 20 | * https://haveibeenpwned.com/API/Key for the `breacheddomain` endpoint. The 21 | * `apiKey` option here is not explicitly required, but direct requests made 22 | * without it will fail (unless you specify a `baseUrl` to a proxy that inserts 23 | * a valid API key on your behalf). 24 | * 25 | * @param {string} domain the domain to query (e.g., "example.com") 26 | * @param {object} [options] a configuration object 27 | * @param {string} [options.apiKey] an API key from 28 | * https://haveibeenpwned.com/API/Key (default: undefined) 29 | * @param {string} [options.baseUrl] a custom base URL for the 30 | * haveibeenpwned.com API endpoints (default: 31 | * `https://haveibeenpwned.com/api/v3`) 32 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 33 | * (default: none) 34 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 35 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 36 | * field in the request headers (default: `hibp `) 37 | * @returns {(Promise | Promise)} a Promise which 38 | * resolves to an object mapping aliases to breach name arrays (or null if no 39 | * results were found), or rejects with an Error 40 | * @example 41 | * try { 42 | * const data = await breachedDomain("example.com", { apiKey: "my-api-key" }); 43 | * if (data) { 44 | * // { "john": ["Adobe"], "jane": ["Adobe", "Gawker"] } 45 | * } else { 46 | * // no results 47 | * } 48 | * } catch (err) { 49 | * // ... 50 | * } 51 | */ 52 | export function breachedDomain( 53 | domain: string, 54 | options: { 55 | /** 56 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 57 | */ 58 | apiKey?: string; 59 | /** 60 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 61 | * `https://haveibeenpwned.com/api/v3`) 62 | */ 63 | baseUrl?: string; 64 | /** 65 | * timeout for the request in milliseconds (default: none) 66 | */ 67 | timeoutMs?: number; 68 | /** 69 | * an AbortSignal to cancel the request (default: none) 70 | */ 71 | signal?: AbortSignal; 72 | /** 73 | * a custom string to send as the User-Agent field in the request headers 74 | * (default: `hibp `) 75 | */ 76 | userAgent?: string; 77 | } = {}, 78 | ): Promise { 79 | const { apiKey, baseUrl, timeoutMs, signal, userAgent } = options; 80 | const endpoint = `/breacheddomain/${encodeURIComponent(domain)}`; 81 | 82 | return fetchFromApi(endpoint, { 83 | apiKey, 84 | baseUrl, 85 | timeoutMs, 86 | signal, 87 | userAgent, 88 | }) as Promise; 89 | } 90 | -------------------------------------------------------------------------------- /src/__tests__/pwned-password.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { PASSWORD, SHA1_RESPONSE_BODY } from '../../test/fixtures.js'; 5 | import { pwnedPassword } from '../pwned-password.js'; 6 | 7 | describe('pwnedPassword', () => { 8 | describe('environment', () => { 9 | it('rejects when the Web Crypto API is unavailable', async () => { 10 | expect.assertions(1); 11 | vi.stubGlobal('crypto', undefined as unknown as Crypto); 12 | try { 13 | await pwnedPassword('anything'); 14 | } catch (error) { 15 | expect(error).toMatchInlineSnapshot( 16 | `[Error: The Web Crypto API is not available in this environment.]`, 17 | ); 18 | } finally { 19 | vi.unstubAllGlobals(); 20 | } 21 | }); 22 | }); 23 | 24 | describe('pwned', () => { 25 | it('resolves to number > 0', () => { 26 | server.use( 27 | http.get('*', () => { 28 | return new Response(SHA1_RESPONSE_BODY); 29 | }), 30 | ); 31 | 32 | return expect(pwnedPassword(PASSWORD)).resolves.toBeGreaterThan(0); 33 | }); 34 | }); 35 | 36 | describe('clean', () => { 37 | it('resolves to 0', () => { 38 | server.use( 39 | http.get('*', () => { 40 | return new Response(SHA1_RESPONSE_BODY); 41 | }), 42 | ); 43 | 44 | return expect(pwnedPassword('kjfhsdksjf454145jkhk!!!')).resolves.toBe(0); 45 | }); 46 | }); 47 | 48 | describe('baseUrl option', () => { 49 | it('is the beginning of the final URL', () => { 50 | const baseUrl = 'https://my-hibp-proxy:8080'; 51 | server.use( 52 | http.get(new RegExp(`^${baseUrl}`), () => { 53 | return new Response(SHA1_RESPONSE_BODY); 54 | }), 55 | ); 56 | 57 | return expect(pwnedPassword(PASSWORD, { baseUrl })).resolves.toBeGreaterThanOrEqual(0); 58 | }); 59 | }); 60 | 61 | describe('timeoutMs option', () => { 62 | it('aborts the request after the given value', () => { 63 | expect.assertions(1); 64 | const timeoutMs = 1; 65 | server.use( 66 | http.get('*', async () => { 67 | await new Promise((resolve) => { 68 | setTimeout(resolve, timeoutMs + 1); 69 | }); 70 | return new Response(SHA1_RESPONSE_BODY); 71 | }), 72 | ); 73 | 74 | return expect(pwnedPassword(PASSWORD, { timeoutMs })).rejects.toMatchInlineSnapshot( 75 | `[TimeoutError: The operation was aborted due to timeout]`, 76 | ); 77 | }); 78 | }); 79 | 80 | describe('userAgent option', () => { 81 | it('is passed on as a request header', () => { 82 | expect.assertions(1); 83 | const userAgent = 'Custom UA'; 84 | server.use( 85 | http.get('*', ({ request }) => { 86 | expect(request.headers.get('User-Agent')).toBe(userAgent); 87 | return new Response(SHA1_RESPONSE_BODY); 88 | }), 89 | ); 90 | 91 | return pwnedPassword(PASSWORD, { userAgent }); 92 | }); 93 | }); 94 | 95 | describe('addPadding option', () => { 96 | it('causes Add-Padding header to be included in the request', () => { 97 | expect.assertions(1); 98 | server.use( 99 | http.get('*', ({ request }) => { 100 | expect(request.headers.get('Add-Padding')).toBe('true'); 101 | return new Response(SHA1_RESPONSE_BODY); 102 | }), 103 | ); 104 | 105 | return pwnedPassword(PASSWORD, { addPadding: true }); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/__tests__/pwned-password-range.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { 5 | NTLM_RESPONSE_BODY, 6 | NTLM_SUFFIXES_OBJECT, 7 | SHA1_PREFIX, 8 | SHA1_RESPONSE_BODY, 9 | SHA1_SUFFIXES_OBJECT, 10 | } from '../../test/fixtures.js'; 11 | import { pwnedPasswordRange } from '../pwned-password-range.js'; 12 | 13 | describe('pwnedPasswordRange', () => { 14 | describe('valid range', () => { 15 | it('resolves with an object', () => { 16 | server.use( 17 | http.get('*', () => { 18 | return new Response(SHA1_RESPONSE_BODY); 19 | }), 20 | ); 21 | 22 | return expect(pwnedPasswordRange(SHA1_PREFIX)).resolves.toEqual(SHA1_SUFFIXES_OBJECT); 23 | }); 24 | }); 25 | 26 | describe('baseUrl option', () => { 27 | it('is the beginning of the final URL', () => { 28 | const baseUrl = 'https://my-hibp-proxy:8080'; 29 | server.use( 30 | http.get(new RegExp(`^${baseUrl}`), () => { 31 | return new Response(SHA1_RESPONSE_BODY); 32 | }), 33 | ); 34 | 35 | return expect(pwnedPasswordRange(SHA1_PREFIX, { baseUrl })).resolves.toEqual( 36 | SHA1_SUFFIXES_OBJECT, 37 | ); 38 | }); 39 | }); 40 | 41 | describe('timeoutMs option', () => { 42 | it('aborts the request after the given value', () => { 43 | expect.assertions(1); 44 | const timeoutMs = 1; 45 | server.use( 46 | http.get('*', async () => { 47 | await new Promise((resolve) => { 48 | setTimeout(resolve, timeoutMs + 1); 49 | }); 50 | return new Response(SHA1_RESPONSE_BODY); 51 | }), 52 | ); 53 | 54 | return expect(pwnedPasswordRange(SHA1_PREFIX, { timeoutMs })).rejects.toMatchInlineSnapshot( 55 | `[TimeoutError: The operation was aborted due to timeout]`, 56 | ); 57 | }); 58 | }); 59 | 60 | describe('userAgent option', () => { 61 | it('is passed on as a request header', () => { 62 | expect.assertions(2); 63 | const userAgent = 'Custom UA'; 64 | server.use( 65 | http.get('*', ({ request }) => { 66 | expect(request.headers.get('User-Agent')).toBe(userAgent); 67 | return new Response(SHA1_RESPONSE_BODY); 68 | }), 69 | ); 70 | 71 | return expect(pwnedPasswordRange(SHA1_PREFIX, { userAgent })).resolves.toEqual( 72 | SHA1_SUFFIXES_OBJECT, 73 | ); 74 | }); 75 | }); 76 | 77 | describe('addPadding option', () => { 78 | it('causes Add-Padding header to be included in the request', async () => { 79 | expect.assertions(1); 80 | server.use( 81 | http.get('*', ({ request }) => { 82 | expect(request.headers.get('Add-Padding')).toBe('true'); 83 | return new Response(SHA1_RESPONSE_BODY); 84 | }), 85 | ); 86 | 87 | await pwnedPasswordRange(SHA1_PREFIX, { addPadding: true }); 88 | }); 89 | }); 90 | 91 | describe('mode option', () => { 92 | it('sets the mode query parameter in the request', async () => { 93 | expect.assertions(2); 94 | server.use( 95 | http.get('*', ({ request }) => { 96 | const { searchParams } = new URL(request.url); 97 | expect(searchParams.get('mode')).toBe('ntlm'); 98 | return new Response(NTLM_RESPONSE_BODY); 99 | }), 100 | ); 101 | 102 | return expect(pwnedPasswordRange(SHA1_PREFIX, { mode: 'ntlm' })).resolves.toEqual( 103 | NTLM_SUFFIXES_OBJECT, 104 | ); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/windows,linux,osx,node,intellij,sublimetext 3 | 4 | ### Windows ### 5 | # Windows image file caches 6 | Thumbs.db 7 | ehthumbs.db 8 | 9 | # Folder config file 10 | Desktop.ini 11 | 12 | # Recycle Bin used on file shares 13 | $RECYCLE.BIN/ 14 | 15 | # Windows Installer files 16 | *.cab 17 | *.msi 18 | *.msm 19 | *.msp 20 | 21 | # Windows shortcuts 22 | *.lnk 23 | 24 | 25 | ### Linux ### 26 | *~ 27 | 28 | # temporary files which can be created if a process still has a handle open of a deleted file 29 | .fuse_hidden* 30 | 31 | # KDE directory preferences 32 | .directory 33 | 34 | # Linux trash folder which might appear on any partition or disk 35 | .Trash-* 36 | 37 | 38 | ### OSX ### 39 | .DS_Store 40 | .AppleDouble 41 | .LSOverride 42 | 43 | # Icon must end with two \r 44 | Icon 45 | 46 | 47 | # Thumbnails 48 | ._* 49 | 50 | # Files that might appear in the root of a volume 51 | .DocumentRevisions-V100 52 | .fseventsd 53 | .Spotlight-V100 54 | .TemporaryItems 55 | .Trashes 56 | .VolumeIcon.icns 57 | 58 | # Directories potentially created on remote AFP share 59 | .AppleDB 60 | .AppleDesktop 61 | Network Trash Folder 62 | Temporary Items 63 | .apdisk 64 | 65 | 66 | ### Node ### 67 | # Logs 68 | logs 69 | *.log 70 | npm-debug.log* 71 | 72 | # Runtime data 73 | pids 74 | *.pid 75 | *.seed 76 | 77 | # Directory for instrumented libs generated by jscoverage/JSCover 78 | lib-cov 79 | 80 | # Coverage directory used by tools like istanbul 81 | coverage 82 | 83 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 84 | .grunt 85 | 86 | # node-waf configuration 87 | .lock-wscript 88 | 89 | # Compiled binary addons (http://nodejs.org/api/addons.html) 90 | build/Release 91 | 92 | # Dependency directories 93 | node_modules 94 | jspm_packages 95 | 96 | # Optional npm cache directory 97 | .npm 98 | 99 | # Optional REPL history 100 | .node_repl_history 101 | 102 | 103 | ### Intellij ### 104 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 105 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 106 | 107 | .idea/ 108 | 109 | ## File-based project format: 110 | *.iws 111 | 112 | ## Plugin-specific files: 113 | 114 | # IntelliJ 115 | /out/ 116 | 117 | # mpeltonen/sbt-idea plugin 118 | .idea_modules/ 119 | 120 | # JIRA plugin 121 | atlassian-ide-plugin.xml 122 | 123 | # Crashlytics plugin (for Android Studio and IntelliJ) 124 | com_crashlytics_export_strings.xml 125 | crashlytics.properties 126 | crashlytics-build.properties 127 | fabric.properties 128 | 129 | ### Intellij Patch ### 130 | *.iml 131 | 132 | 133 | ### SublimeText ### 134 | # cache files for sublime text 135 | *.tmlanguage.cache 136 | *.tmPreferences.cache 137 | *.stTheme.cache 138 | 139 | # workspace files are user-specific 140 | *.sublime-workspace 141 | 142 | # project files should be checked into the repository, unless a significant 143 | # proportion of contributors will probably not be using SublimeText 144 | # *.sublime-project 145 | 146 | # sftp configuration file 147 | sftp-config.json 148 | 149 | 150 | ### nyc output ### 151 | .nyc_output/ 152 | 153 | 154 | ### Buildoutput ### 155 | dist/ 156 | 157 | 158 | ### VSCode ### 159 | .vscode/* 160 | !.vscode/settings.json 161 | !.vscode/tasks.json 162 | !.vscode/launch.json 163 | !.vscode/extensions.json 164 | !.vscode/*.code-snippets 165 | 166 | ### rollup-plugin-typescript2 ### 167 | .rpt2_cache 168 | 169 | ### Playwright ### 170 | /playwright/results/ 171 | /playwright/report/ 172 | /playwright/.cache/ 173 | 174 | # Generated at build time 175 | src/api/haveibeenpwned/package-info.ts 176 | -------------------------------------------------------------------------------- /src/stealer-logs-by-email-domain.ts: -------------------------------------------------------------------------------- 1 | import type { StealerLogDomainsByEmailAlias } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * An object mapping an email alias (local-part before the '@') to the list of 6 | * email domains that alias has appeared in within stealer logs for the specified 7 | * email domain. 8 | * 9 | * @typedef {Object.} StealerLogDomainsByEmailAlias 10 | */ 11 | 12 | /** 13 | * Fetches all stealer log email aliases for an email domain. 14 | * 15 | * The result maps email aliases (the local-part before the '@') to an array of 16 | * email domains found in stealer logs. For example, querying `example.com` 17 | * could return an object like `{ "andy": ["netflix.com"], "jane": ["netflix.com", 18 | * "spotify.com"] }`, corresponding to `andy@example.com` and `jane@example.com`. 19 | * 20 | * 🔑 `haveibeenpwned.com` requires an API key from 21 | * https://haveibeenpwned.com/API/Key for the `stealerlogsbyemaildomain` endpoint. 22 | * The `apiKey` option here is not explicitly required, but direct requests made 23 | * without it will fail (unless you specify a `baseUrl` to a proxy that inserts 24 | * a valid API key on your behalf). 25 | * 26 | * @param {string} emailDomain the email domain to query (e.g., "example.com") 27 | * @param {object} [options] a configuration object 28 | * @param {string} [options.apiKey] an API key from 29 | * https://haveibeenpwned.com/API/Key (default: undefined) 30 | * @param {string} [options.baseUrl] a custom base URL for the 31 | * haveibeenpwned.com API endpoints (default: 32 | * `https://haveibeenpwned.com/api/v3`) 33 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 34 | * (default: none) 35 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 36 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 37 | * field in the request headers (default: `hibp `) 38 | * @returns {(Promise | Promise)} a Promise 39 | * which resolves to an object mapping aliases to stealer log email domain arrays 40 | * (or null if no results were found), or rejects with an Error 41 | * @example 42 | * try { 43 | * const data = await stealerLogsByEmailDomain("example.com", { apiKey: "my-api-key" }); 44 | * if (data) { 45 | * // { "andy": ["netflix.com"], "jane": ["netflix.com", "spotify.com"] } 46 | * } else { 47 | * // no results 48 | * } 49 | * } catch (err) { 50 | * // ... 51 | * } 52 | */ 53 | export function stealerLogsByEmailDomain( 54 | emailDomain: string, 55 | options: { 56 | /** 57 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 58 | */ 59 | apiKey?: string; 60 | /** 61 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 62 | * `https://haveibeenpwned.com/api/v3`) 63 | */ 64 | baseUrl?: string; 65 | /** 66 | * timeout for the request in milliseconds (default: none) 67 | */ 68 | timeoutMs?: number; 69 | /** 70 | * an AbortSignal to cancel the request (default: none) 71 | */ 72 | signal?: AbortSignal; 73 | /** 74 | * a custom string to send as the User-Agent field in the request headers 75 | * (default: `hibp `) 76 | */ 77 | userAgent?: string; 78 | } = {}, 79 | ): Promise { 80 | const { apiKey, baseUrl, timeoutMs, signal, userAgent } = options; 81 | const endpoint = `/stealerlogsbyemaildomain/${encodeURIComponent(emailDomain)}`; 82 | 83 | return fetchFromApi(endpoint, { 84 | apiKey, 85 | baseUrl, 86 | timeoutMs, 87 | signal, 88 | userAgent, 89 | }) as Promise; 90 | } 91 | -------------------------------------------------------------------------------- /src/subscribed-domains.ts: -------------------------------------------------------------------------------- 1 | import type { SubscribedDomain } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * An object representing a subscribed domain. 6 | * 7 | * @typedef {object} SubscribedDomain 8 | * @property {string} DomainName - the fully qualified domain name 9 | * @property {(number|null)} PwnCount - total breached addresses at last search 10 | * @property {(number|null)} PwnCountExcludingSpamLists - breached addresses excluding spam lists at last search 11 | * @property {(number|null)} PwnCountExcludingSpamListsAtLastSubscriptionRenewal - breached addresses excluding spam lists at the time of last subscription renewal 12 | * @property {(string|null)} NextSubscriptionRenewal - ISO 8601 datetime when the current subscription ends 13 | */ 14 | 15 | /** 16 | * Fetches all subscribed domains for your HIBP account. 17 | * 18 | * Returns domains that have been successfully added to the Domain Search dashboard 19 | * after verifying control. Each domain includes metadata about breach counts and 20 | * the next renewal date, where available. 21 | * 22 | * 🔑 `haveibeenpwned.com` requires an API key from 23 | * https://haveibeenpwned.com/API/Key for the `subscribeddomains` endpoint. The 24 | * `apiKey` option here is not explicitly required, but direct requests made 25 | * without it will fail (unless you specify a `baseUrl` to a proxy that inserts 26 | * a valid API key on your behalf). 27 | * 28 | * @param {object} [options] a configuration object 29 | * @param {string} [options.apiKey] an API key from 30 | * https://haveibeenpwned.com/API/Key (default: undefined) 31 | * @param {string} [options.baseUrl] a custom base URL for the 32 | * haveibeenpwned.com API endpoints (default: 33 | * `https://haveibeenpwned.com/api/v3`) 34 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 35 | * (default: none) 36 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 37 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 38 | * field in the request headers (default: `hibp `) 39 | * @returns {Promise} a Promise which resolves to an array of 40 | * subscribed domain objects (an empty array if none), or rejects with an Error 41 | * @example 42 | * try { 43 | * const data = await subscribedDomains({ apiKey: "my-api-key" }); 44 | * // ... 45 | * } catch (err) { 46 | * // ... 47 | * } 48 | * @example 49 | * try { 50 | * const data = await subscribedDomains({ 51 | * baseUrl: "https://my-hibp-proxy:8080", 52 | * }); 53 | * // ... 54 | * } catch (err) { 55 | * // ... 56 | * } 57 | */ 58 | export function subscribedDomains( 59 | options: { 60 | /** 61 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 62 | */ 63 | apiKey?: string; 64 | /** 65 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 66 | * `https://haveibeenpwned.com/api/v3`) 67 | */ 68 | baseUrl?: string; 69 | /** 70 | * timeout for the request in milliseconds (default: none) 71 | */ 72 | timeoutMs?: number; 73 | /** 74 | * an AbortSignal to cancel the request (default: none) 75 | */ 76 | signal?: AbortSignal; 77 | /** 78 | * a custom string to send as the User-Agent field in the request headers 79 | * (default: `hibp `) 80 | */ 81 | userAgent?: string; 82 | } = {}, 83 | ): Promise { 84 | const { apiKey, baseUrl, timeoutMs, signal, userAgent } = options; 85 | const endpoint = '/subscribeddomains'; 86 | 87 | return fetchFromApi(endpoint, { 88 | apiKey, 89 | baseUrl, 90 | timeoutMs, 91 | signal, 92 | userAgent, 93 | }) as Promise; 94 | } 95 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Ignore vendor test-only JS files when calculating language stats on GitHub 2 | test/*.js linguist-vendored 3 | 4 | # Adapted from: 5 | # https://github.com/alexkaratarakis/gitattributes/blob/master/Web.gitattributes 6 | 7 | ## GITATTRIBUTES FOR WEB PROJECTS 8 | # 9 | # These settings are for any web project. 10 | # 11 | # Details per file setting: 12 | # text These files should be normalized (i.e. convert CRLF to LF). 13 | # binary These files are binary and should be left untouched. 14 | # 15 | # Note that binary is a macro for -text -diff. 16 | ###################################################################### 17 | 18 | ## AUTO-DETECT - Handle line endings automatically for files detected 19 | ## as text and leave all files detected as binary untouched. 20 | ## This will handle all files NOT defined below. 21 | * text=auto 22 | 23 | ## SOURCE CODE 24 | *.bat text 25 | *.coffee text 26 | *.css text 27 | *.htm text 28 | *.html text 29 | *.inc text 30 | *.ini text 31 | *.js text 32 | *.jsx text 33 | *.json text 34 | *.less text 35 | *.php text 36 | *.pl text 37 | *.py text 38 | *.rb text 39 | *.sass text 40 | *.scm text 41 | *.scss text 42 | *.sh text 43 | *.sql text 44 | *.styl text 45 | *.ts text 46 | *.xml text 47 | *.xhtml text 48 | 49 | ## DOCUMENTATION 50 | *.markdown text 51 | *.md text 52 | *.mdwn text 53 | *.mdown text 54 | *.mkd text 55 | *.mkdn text 56 | *.mdtxt text 57 | *.mdtext text 58 | *.txt text 59 | AUTHORS text 60 | CHANGELOG text 61 | CHANGES text 62 | CONTRIBUTING text 63 | COPYING text 64 | INSTALL text 65 | license text 66 | LICENSE text 67 | NEWS text 68 | readme text 69 | *README* text 70 | TODO text 71 | 72 | ## TEMPLATES 73 | *.dot text 74 | *.ejs text 75 | *.haml text 76 | *.handlebars text 77 | *.hbs text 78 | *.hbt text 79 | *.jade text 80 | *.latte text 81 | *.mustache text 82 | *.phtml text 83 | *.tmpl text 84 | 85 | ## LINTERS 86 | .csslintrc text 87 | .eslintrc text 88 | .jscsrc text 89 | .jshintrc text 90 | .jshintignore text 91 | .stylelintrc text 92 | 93 | ## CONFIGS 94 | *.bowerrc text 95 | *.cnf text 96 | *.conf text 97 | *.config text 98 | .babelrc text 99 | .editorconfig text 100 | .gitattributes text 101 | .gitconfig text 102 | .gitignore text 103 | .htaccess text 104 | .nycrc text 105 | *.npmignore text 106 | *.yaml text 107 | *.yml text 108 | Makefile text 109 | makefile text 110 | 111 | ## HEROKU 112 | Procfile text 113 | .slugignore text 114 | 115 | ## GRAPHICS 116 | *.ai binary 117 | *.bmp binary 118 | *.eps binary 119 | *.gif binary 120 | *.ico binary 121 | *.jng binary 122 | *.jp2 binary 123 | *.jpg binary 124 | *.jpeg binary 125 | *.jpx binary 126 | *.jxr binary 127 | *.pdf binary 128 | *.png binary 129 | *.psb binary 130 | *.psd binary 131 | *.svg text 132 | *.svgz binary 133 | *.tif binary 134 | *.tiff binary 135 | *.wbmp binary 136 | *.webp binary 137 | 138 | ## AUDIO 139 | *.kar binary 140 | *.m4a binary 141 | *.mid binary 142 | *.midi binary 143 | *.mp3 binary 144 | *.ogg binary 145 | *.ra binary 146 | 147 | ## VIDEO 148 | *.3gpp binary 149 | *.3gp binary 150 | *.as binary 151 | *.asf binary 152 | *.asx binary 153 | *.fla binary 154 | *.flv binary 155 | *.m4v binary 156 | *.mng binary 157 | *.mov binary 158 | *.mp4 binary 159 | *.mpeg binary 160 | *.mpg binary 161 | *.swc binary 162 | *.swf binary 163 | *.webm binary 164 | 165 | ## ARCHIVES 166 | *.7z binary 167 | *.gz binary 168 | *.rar binary 169 | *.tar binary 170 | *.zip binary 171 | 172 | ## FONTS 173 | *.ttf binary 174 | *.eot binary 175 | *.otf binary 176 | *.woff binary 177 | *.woff2 binary 178 | 179 | ## EXECUTABLES 180 | *.exe binary 181 | *.pyc binary 182 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hibp", 3 | "version": "15.2.0", 4 | "description": "An unofficial TypeScript SDK for the 'Have I been pwned?' service.", 5 | "keywords": [ 6 | "haveibeenpwned", 7 | "hibp", 8 | "pwned", 9 | "security", 10 | "hack", 11 | "dump", 12 | "breach", 13 | "pastes", 14 | "passwords", 15 | "client" 16 | ], 17 | "author": { 18 | "name": "Justin Hall", 19 | "email": "justin.r.hall@gmail.com" 20 | }, 21 | "license": "MIT", 22 | "exports": { 23 | ".": { 24 | "browser": "./dist/browser/hibp.module.js", 25 | "default": "./dist/esm/hibp.js" 26 | }, 27 | "./package.json": "./package.json" 28 | }, 29 | "type": "module", 30 | "main": "dist/esm/hibp.js", 31 | "module": "dist/esm/hibp.js", 32 | "files": [ 33 | "dist", 34 | "API.md", 35 | "CHANGELOG.md", 36 | "MIGRATION.md" 37 | ], 38 | "sideEffects": false, 39 | "scripts": { 40 | "build": "run-s --silent build:package-info build:lib build:browser build:docs", 41 | "build:package-info": "node --experimental-strip-types scripts/build-package-info.ts", 42 | "build:lib": "tsc --project tsconfig.tsc.json", 43 | "build:browser": "esbuild src/hibp.ts --minify --bundle --format=esm --platform=browser --tsconfig=tsconfig.esbuild.json --outfile=dist/browser/hibp.module.js", 44 | "build:docs": "jsdoc2md --no-cache --files src/*.ts --configure jsdoc2md.json > API.md && node scripts/fix-api-docs.js", 45 | "changeset": "changeset", 46 | "changeset:version": "changeset version && pnpm install", 47 | "changeset:publish": "changeset publish", 48 | "typecheck": "tsc --noEmit", 49 | "clean": "rimraf dist coverage", 50 | "format": "prettier --cache --write .", 51 | "format:check": "prettier --cache --check .", 52 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 53 | "prebuild": "pnpm run --silent clean", 54 | "prepublishOnly": "pnpm run build", 55 | "size": "bundlewatch --config .bundlewatch.config.json", 56 | "pretest": "pnpm run --silent build:package-info", 57 | "test": "vitest run", 58 | "pretest:coverage": "pnpm run --silent build:package-info", 59 | "test:coverage": "vitest run --coverage", 60 | "pretest:browser:open": "pnpm run --silent build:package-info && pnpm run --silent build:browser", 61 | "test:browser:open": "playwright test --ui", 62 | "pretest:browser:run": "pnpm run --silent build:package-info && pnpm run --silent build:browser", 63 | "test:browser:run": "playwright test", 64 | "pretest:watch": "pnpm run --silent build:package-info", 65 | "test:watch": "vitest watch" 66 | }, 67 | "private": false, 68 | "publishConfig": { 69 | "access": "public" 70 | }, 71 | "repository": { 72 | "url": "git+https://github.com/wKovacs64/hibp.git" 73 | }, 74 | "bugs": { 75 | "url": "https://github.com/wKovacs64/hibp/issues" 76 | }, 77 | "homepage": "https://wkovacs64.github.io/hibp", 78 | "engines": { 79 | "node": ">= 20.19.0" 80 | }, 81 | "packageManager": "pnpm@10.25.0", 82 | "devDependencies": { 83 | "@arethetypeswrong/cli": "0.18.2", 84 | "@babel/plugin-proposal-class-properties": "7.18.6", 85 | "@babel/plugin-proposal-object-rest-spread": "7.20.7", 86 | "@babel/preset-env": "7.28.5", 87 | "@babel/preset-typescript": "7.28.5", 88 | "@changesets/changelog-github": "0.5.2", 89 | "@changesets/cli": "2.29.8", 90 | "@playwright/test": "1.57.0", 91 | "@types/common-tags": "1.8.4", 92 | "@types/node": "24.10.3", 93 | "@vitest/coverage-v8": "4.0.15", 94 | "@wkovacs64/eslint-config": "7.11.1", 95 | "@wkovacs64/prettier-config": "4.2.3", 96 | "bundlewatch": "0.4.1", 97 | "common-tags": "1.8.2", 98 | "cross-env": "10.1.0", 99 | "esbuild": "0.27.1", 100 | "eslint": "9.39.1", 101 | "jsdoc-babel": "0.5.0", 102 | "jsdoc-to-markdown": "9.1.3", 103 | "msw": "2.12.4", 104 | "npm-run-all2": "8.0.4", 105 | "pathe": "2.0.3", 106 | "prettier": "3.7.4", 107 | "rimraf": "6.1.2", 108 | "serve": "14.2.5", 109 | "typescript": "5.9.3", 110 | "vitest": "4.0.15" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { stripIndents } from 'common-tags'; 2 | import type { Breach, Paste, SubscriptionStatus } from '../src/api/haveibeenpwned/types.js'; 3 | 4 | export const VERIFIED_BREACH: Breach = { 5 | Name: 'Adobe', 6 | Title: 'Adobe', 7 | Domain: 'adobe.com', 8 | BreachDate: '2013-10-04', 9 | AddedDate: '2013-12-04T00:00:00Z', 10 | ModifiedDate: '2013-12-04T00:00:00Z', 11 | PwnCount: 152445165, 12 | Description: 13 | '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 password cryptography was poorly done and many were quickly resolved back to plain text. The unencrypted hints also disclosed much about the passwords adding further to the risk that hundreds of millions of Adobe customers already faced.', 14 | DataClasses: ['Email addresses', 'Password hints', 'Passwords', 'Usernames'], 15 | IsVerified: true, 16 | IsFabricated: false, 17 | IsSensitive: false, 18 | IsRetired: false, 19 | IsSpamList: false, 20 | IsMalware: false, 21 | IsSubscriptionFree: false, 22 | LogoPath: 'https://haveibeenpwned.com/Content/Images/PwnedLogos/Adobe.png', 23 | }; 24 | 25 | export const UNVERIFIED_BREACH: Breach = { 26 | Name: 'Badoo', 27 | Title: 'Badoo', 28 | Domain: 'badoo.com', 29 | BreachDate: '2013-06-01', 30 | AddedDate: '2016-07-06T08:16:03Z', 31 | ModifiedDate: '2016-07-06T08:16:03Z', 32 | PwnCount: 112005531, 33 | Description: 34 | 'In June 2016, a data breach allegedly originating from the social website Badoo was found to be circulating amongst traders. Likely obtained several years earlier, the data contained 112 million unique email addresses with personal data including names, birthdates and passwords stored as MD5 hashes. Whilst there are many indicators suggesting Badoo did indeed suffer a data breach, the legitimacy of the data could not be emphatically proven so this breach has been categorised as "unverified".', 35 | DataClasses: ['Dates of birth', 'Email addresses', 'Genders', 'Names', 'Passwords', 'Usernames'], 36 | IsVerified: false, 37 | IsFabricated: false, 38 | IsSensitive: true, 39 | IsRetired: false, 40 | IsSpamList: false, 41 | IsMalware: false, 42 | IsSubscriptionFree: false, 43 | LogoPath: 'https://haveibeenpwned.com/Content/Images/PwnedLogos/Badoo.png', 44 | }; 45 | 46 | export const PASTE: Paste = { 47 | Source: 'Pastebin', 48 | Id: '8Q0BvKD8', 49 | Title: 'syslog', 50 | Date: '2014-03-04T19:14:54Z', 51 | EmailCount: 139, 52 | }; 53 | 54 | export const SUBSCRIPTION_STATUS: SubscriptionStatus = { 55 | SubscriptionName: 'Pwned 1', 56 | Description: 57 | 'Domains with up to 25 breached addresses each, and a rate limited API key allowing 10 email address searches per minute', 58 | SubscribedUntil: '2024-04-02T12:34:56', 59 | Rpm: 10, 60 | DomainSearchMaxBreachedAccounts: 25, 61 | IncludesStealerLogs: false, 62 | }; 63 | 64 | export const PASSWORD = 'password'; 65 | 66 | export const SHA1_PREFIX = '5BAA6'; 67 | 68 | export const SHA1_RESPONSE_BODY = stripIndents` 69 | 003D68EB55068C33ACE09247EE4C639306B:3 70 | 1E4C9B93F3F0682250B6CF8331B7EE68FD8:3303003 71 | 01330C689E5D64F660D6947A93AD634EF8F:1 72 | `; 73 | 74 | export const SHA1_SUFFIXES_OBJECT = { 75 | '003D68EB55068C33ACE09247EE4C639306B': 3, 76 | '1E4C9B93F3F0682250B6CF8331B7EE68FD8': 3303003, 77 | '01330C689E5D64F660D6947A93AD634EF8F': 1, 78 | }; 79 | 80 | export const NTLM_RESPONSE_BODY = stripIndents` 81 | B95AF67BEE5270A681E5410D611: 1 82 | B964C3513680B4C0204A157CCF5: 1110 83 | B9697A53922A10401EAB7504866: 1 84 | `; 85 | 86 | export const NTLM_SUFFIXES_OBJECT = { 87 | B95AF67BEE5270A681E5410D611: 1, 88 | B964C3513680B4C0204A157CCF5: 1110, 89 | B9697A53922A10401EAB7504866: 1, 90 | }; 91 | -------------------------------------------------------------------------------- /src/api/haveibeenpwned/fetch-from-api.ts: -------------------------------------------------------------------------------- 1 | import { baseFetch } from '../base-fetch.js'; 2 | import { BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, TOO_MANY_REQUESTS } from './responses.js'; 3 | import type { ApiData, ErrorData } from './types.js'; 4 | 5 | /** 6 | * Custom error thrown when the haveibeenpwned.com API responds with 429 Too 7 | * Many Requests. See the `retryAfterSeconds` property for the number of seconds 8 | * to wait before attempting the request again. 9 | * 10 | * @see https://haveibeenpwned.com/API/v3#RateLimiting 11 | */ 12 | export class RateLimitError extends Error { 13 | /** 14 | * The number of seconds to wait before attempting the request again. May be 15 | * `undefined` if the API does not provide a `retry-after` header, but this 16 | * should never happen. 17 | */ 18 | public retryAfterSeconds: number | undefined; 19 | 20 | constructor( 21 | retryAfter: ReturnType, 22 | message: string | undefined, 23 | options?: ErrorOptions, 24 | ) { 25 | super(message, options); 26 | this.name = this.constructor.name; 27 | this.retryAfterSeconds = 28 | typeof retryAfter === 'string' ? Number.parseInt(retryAfter, 10) : undefined; 29 | } 30 | } 31 | 32 | function blockedWithRayId(rayId: string) { 33 | return `Request blocked, contact haveibeenpwned.com if this continues (Ray ID: ${rayId})`; 34 | } 35 | 36 | /** 37 | * Fetches data from the supplied API endpoint. 38 | * 39 | * HTTP status code 200 returns an Object (data found). 40 | * HTTP status code 404 returns null (no data found). 41 | * HTTP status code 400 throws an Error (bad request). 42 | * HTTP status code 401 throws an Error (unauthorized). 43 | * HTTP status code 403 throws an Error (forbidden). 44 | * HTTP status code 429 throws an Error (too many requests). 45 | * 46 | * @internal 47 | * @private 48 | * @param {string} endpoint the API endpoint to query 49 | * @param {object} [options] a configuration object 50 | * @param {string} [options.apiKey] an API key from 51 | * https://haveibeenpwned.com/API/Key 52 | * @param {string} [options.baseUrl] a custom base URL for the 53 | * haveibeenpwned.com API endpoints (default: 54 | * `https://haveibeenpwned.com/api/v3`) 55 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 56 | * (default: none) 57 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 58 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 59 | * field in the request headers (default: `hibp `) 60 | * @returns {Promise} a Promise which resolves to the data resulting 61 | * from the query (or null for 404 Not Found responses), or rejects with an 62 | * Error 63 | */ 64 | export async function fetchFromApi( 65 | endpoint: string, 66 | options: { 67 | apiKey?: string; 68 | baseUrl?: string; 69 | timeoutMs?: number; 70 | signal?: AbortSignal; 71 | userAgent?: string; 72 | } = {}, 73 | ): Promise { 74 | const { 75 | apiKey, 76 | baseUrl = 'https://haveibeenpwned.com/api/v3', 77 | timeoutMs, 78 | signal, 79 | userAgent, 80 | } = options; 81 | 82 | const headers: Record = {}; 83 | if (apiKey) headers['HIBP-API-Key'] = apiKey; 84 | 85 | const response = await baseFetch({ 86 | baseUrl, 87 | endpoint, 88 | headers, 89 | timeoutMs, 90 | signal, 91 | userAgent, 92 | }); 93 | 94 | if (response.ok) return response.json() as Promise; 95 | 96 | switch (response.status) { 97 | case BAD_REQUEST.status: { 98 | throw new Error(BAD_REQUEST.statusText); 99 | } 100 | case UNAUTHORIZED.status: { 101 | const message = await response.text(); 102 | throw new Error(message); 103 | } 104 | case FORBIDDEN.status: { 105 | const rayId = response.headers.get('cf-ray'); 106 | if (rayId) throw new Error(blockedWithRayId(rayId)); 107 | throw new Error(FORBIDDEN.statusText); 108 | } 109 | case NOT_FOUND.status: { 110 | return null; 111 | } 112 | case TOO_MANY_REQUESTS.status: { 113 | const body = (await response.json()) as unknown as ErrorData; 114 | const retryAfter = response.headers.get('retry-after'); 115 | throw new RateLimitError(retryAfter, body.message); 116 | } 117 | default: { 118 | throw new Error(response.statusText); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/__tests__/breached-account.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../mocks/server.js'; 4 | import { VERIFIED_BREACH, UNVERIFIED_BREACH } from '../../test/fixtures.js'; 5 | import { breachedAccount } from '../breached-account.js'; 6 | 7 | describe('breachedAccount', () => { 8 | const BREACHED_ACCOUNT_DATA = [{ Name: VERIFIED_BREACH.Name }, { Name: UNVERIFIED_BREACH.Name }]; 9 | const BREACHED_ACCOUNT_DATA_EXPANDED = [VERIFIED_BREACH, UNVERIFIED_BREACH]; 10 | const BREACHED_ACCOUNT_DATA_NO_UNVERIFIED = [{ Name: VERIFIED_BREACH.Name }]; 11 | 12 | describe('truncate option', () => { 13 | it('sets the truncateResponse query parameter in the request', async () => { 14 | expect.assertions(1); 15 | server.use( 16 | http.get('*', ({ request }) => { 17 | const { searchParams } = new URL(request.url); 18 | expect(searchParams.get('truncateResponse')).toBe('false'); 19 | return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA_EXPANDED)); 20 | }), 21 | ); 22 | 23 | return breachedAccount('breached', { truncate: false }); 24 | }); 25 | }); 26 | 27 | describe('includeUnverified option', () => { 28 | it('sets the includeUnverified query parameter in the request', async () => { 29 | expect.assertions(1); 30 | server.use( 31 | http.get('*', ({ request }) => { 32 | const { searchParams } = new URL(request.url); 33 | expect(searchParams.get('includeUnverified')).toBe('false'); 34 | return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA_NO_UNVERIFIED)); 35 | }), 36 | ); 37 | 38 | return breachedAccount('breached', { includeUnverified: false }); 39 | }); 40 | }); 41 | 42 | describe('domain option', () => { 43 | it('sets the domain query parameter in the request', () => { 44 | expect.assertions(1); 45 | server.use( 46 | http.get('*', ({ request }) => { 47 | const { searchParams } = new URL(request.url); 48 | expect(searchParams.get('domain')).toBe('foo.bar'); 49 | return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA)); 50 | }), 51 | ); 52 | 53 | return breachedAccount('breached', { domain: 'foo.bar' }); 54 | }); 55 | }); 56 | 57 | describe('apiKey option', () => { 58 | it('sets the hibp-api-key header', async () => { 59 | expect.assertions(1); 60 | const apiKey = 'my-api-key'; 61 | server.use( 62 | http.get('*', ({ request }) => { 63 | expect(request.headers.get('hibp-api-key')).toBe(apiKey); 64 | return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA)); 65 | }), 66 | ); 67 | 68 | return breachedAccount('breached', { apiKey }); 69 | }); 70 | }); 71 | 72 | describe('baseUrl option', () => { 73 | it('is the beginning of the final URL', () => { 74 | const baseUrl = 'https://my-hibp-proxy:8080'; 75 | server.use( 76 | http.get(new RegExp(`^${baseUrl}`), () => { 77 | return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA)); 78 | }), 79 | ); 80 | 81 | return expect(breachedAccount('breached', { baseUrl })).resolves.toEqual( 82 | BREACHED_ACCOUNT_DATA, 83 | ); 84 | }); 85 | }); 86 | 87 | describe('timeoutMs option', () => { 88 | it('aborts the request after the given value', () => { 89 | expect.assertions(1); 90 | const timeoutMs = 1; 91 | server.use( 92 | http.get('*', async () => { 93 | await new Promise((resolve) => { 94 | setTimeout(resolve, timeoutMs + 1); 95 | }); 96 | return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA)); 97 | }), 98 | ); 99 | 100 | return expect(breachedAccount('breached', { timeoutMs })).rejects.toMatchInlineSnapshot( 101 | `[TimeoutError: The operation was aborted due to timeout]`, 102 | ); 103 | }); 104 | }); 105 | 106 | describe('userAgent option', () => { 107 | it('is passed on as a request header', () => { 108 | expect.assertions(1); 109 | const userAgent = 'Custom UA'; 110 | server.use( 111 | http.get('*', ({ request }) => { 112 | expect(request.headers.get('User-Agent')).toBe(userAgent); 113 | return new Response(JSON.stringify(BREACHED_ACCOUNT_DATA)); 114 | }), 115 | ); 116 | 117 | return breachedAccount('breached', { userAgent }); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/pwned-password-range.ts: -------------------------------------------------------------------------------- 1 | import { fetchFromApi } from './api/pwnedpasswords/fetch-from-api.js'; 2 | 3 | export type PwnedPasswordSuffixes = Record; 4 | 5 | /** 6 | * An object mapping an exposed password hash suffix (corresponding to a given 7 | * hash prefix) to how many times it occurred in the Pwned Passwords repository. 8 | * 9 | * @typedef {Object.} PwnedPasswordSuffixes 10 | */ 11 | 12 | /** 13 | * Fetches the SHA-1 or NTLM hash suffixes for the given 5-character hash 14 | * prefix. 15 | * 16 | * When a password hash with the same first 5 characters is found in the Pwned 17 | * Passwords repository, the API will respond with an HTTP 200 and include the 18 | * suffix of every hash beginning with the specified prefix, followed by a count 19 | * of how many times it appears in the data set. This function parses the 20 | * response and returns a more structured format. 21 | * 22 | * @param {string} prefix the first 5 characters of a password hash (case 23 | * insensitive) 24 | * @param {object} [options] a configuration object 25 | * @param {boolean} [options.addPadding] ask the remote API to add padding to 26 | * the response to obscure the password prefix (default: `false`) 27 | * @param {'sha1' | 'ntlm'} [options.mode] return SHA-1 or NTLM hashes 28 | * (default: `sha1`) 29 | * @param {string} [options.baseUrl] a custom base URL for the 30 | * pwnedpasswords.com API endpoints (default: `https://api.pwnedpasswords.com`) 31 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 32 | * (default: none) 33 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 34 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 35 | * field in the request headers (default: `hibp `) 36 | * @returns {Promise} a Promise which resolves to an 37 | * object mapping the `suffix` that when matched with the prefix composes the 38 | * complete hash, to the `count` of how many times it appears in the breached 39 | * password data set, or rejects with an Error 40 | * 41 | * @example 42 | * try { 43 | * const results = await pwnedPasswordRange("5BAA6"); 44 | * // results will have the following shape: 45 | * // { 46 | * // "003D68EB55068C33ACE09247EE4C639306B": 3, 47 | * // "012C192B2F16F82EA0EB9EF18D9D539B0DD": 1, 48 | * // ... 49 | * // } 50 | * } catch (err) { 51 | * // ... 52 | * } 53 | * @example 54 | * try { 55 | * const suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8"; 56 | * const results = await pwnedPasswordRange("5BAA6"); 57 | * const numPwns = results[suffix] || 0; 58 | * } catch (err) { 59 | * // ... 60 | * } 61 | * @see https://haveibeenpwned.com/api/v3#SearchingPwnedPasswordsByRange 62 | */ 63 | export async function pwnedPasswordRange( 64 | prefix: string, 65 | options: { 66 | /** 67 | * ask the remote API to add padding to the response to obscure the password 68 | * prefix (default: `false`) 69 | */ 70 | addPadding?: boolean; 71 | /** 72 | * return SHA-1 or NTLM hashes (default: `sha1`) 73 | */ 74 | mode?: 'sha1' | 'ntlm'; 75 | /** 76 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 77 | * `https://haveibeenpwned.com/api/v3`) 78 | */ 79 | baseUrl?: string; 80 | /** 81 | * timeout for the request in milliseconds (default: none) 82 | */ 83 | timeoutMs?: number; 84 | /** 85 | * an AbortSignal to cancel the request (default: none) 86 | */ 87 | signal?: AbortSignal; 88 | /** 89 | * a custom string to send as the User-Agent field in the request headers 90 | * (default: `hibp `) 91 | */ 92 | userAgent?: string; 93 | } = {}, 94 | ): Promise { 95 | const { baseUrl, timeoutMs, signal, userAgent, addPadding = false, mode = 'sha1' } = options; 96 | 97 | const data = await fetchFromApi(`/range/${encodeURIComponent(prefix)}`, { 98 | baseUrl, 99 | timeoutMs, 100 | signal, 101 | userAgent, 102 | addPadding, 103 | mode, 104 | }); 105 | 106 | // create array from lines of text in response body 107 | const results = data.split('\n').filter(Boolean); 108 | 109 | // convert into an object mapping suffix to count for each line 110 | return results.reduce((acc, row) => { 111 | const [suffix, countString] = row.split(':'); 112 | acc[suffix] = Number.parseInt(countString, 10); 113 | return acc; 114 | }, {}); 115 | } 116 | -------------------------------------------------------------------------------- /src/breached-account.ts: -------------------------------------------------------------------------------- 1 | import type { Breach } from './api/haveibeenpwned/types.js'; 2 | import { fetchFromApi } from './api/haveibeenpwned/fetch-from-api.js'; 3 | 4 | /** 5 | * Fetches breach data for a specific account. 6 | * 7 | * 🔑 `haveibeenpwned.com` requires an API key from 8 | * https://haveibeenpwned.com/API/Key for the `breachedaccount` endpoint. The 9 | * `apiKey` option here is not explicitly required, but direct requests made 10 | * without it will fail (unless you specify a `baseUrl` to a proxy that inserts 11 | * a valid API key on your behalf). 12 | * 13 | * @param {string} account a username or email address 14 | * @param {object} [options] a configuration object 15 | * @param {string} [options.apiKey] an API key from 16 | * https://haveibeenpwned.com/API/Key (default: undefined) 17 | * @param {string} [options.domain] a domain by which to filter the results 18 | * (default: all domains) 19 | * @param {boolean} [options.includeUnverified] include "unverified" breaches in 20 | * the results (default: true) 21 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 22 | * (default: none) 23 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 24 | * @param {boolean} [options.truncate] truncate the results to only include 25 | * the name of each breach (default: true) 26 | * @param {string} [options.baseUrl] a custom base URL for the 27 | * haveibeenpwned.com API endpoints (default: 28 | * `https://haveibeenpwned.com/api/v3`) 29 | * @param {string} [options.userAgent] a custom string to send as the User-Agent 30 | * field in the request headers (default: `hibp `) 31 | * @returns {(Promise | Promise)} a Promise which resolves to an 32 | * array of breach objects (or null if no breaches were found), or rejects with 33 | * an Error 34 | * @example 35 | * try { 36 | * const data = await breachedAccount("foo", { apiKey: "my-api-key" }); 37 | * if (data) { 38 | * // ... 39 | * } else { 40 | * // ... 41 | * } 42 | * } catch (err) { 43 | * // ... 44 | * } 45 | * @example 46 | * try { 47 | * const data = await breachedAccount("bar", { 48 | * includeUnverified: false, 49 | * baseUrl: "https://my-hibp-proxy:8080", 50 | * }); 51 | * if (data) { 52 | * // ... 53 | * } else { 54 | * // ... 55 | * } 56 | * } catch (err) { 57 | * // ... 58 | * } 59 | * @example 60 | * try { 61 | * const data = await breachedAccount("baz", { 62 | * apiKey: "my-api-key", 63 | * domain: "adobe.com", 64 | * truncate: false, 65 | * userAgent: "my-app 1.0", 66 | * }); 67 | * if (data) { 68 | * // ... 69 | * } else { 70 | * // ... 71 | * } 72 | * } catch (err) { 73 | * // ... 74 | * } 75 | */ 76 | export function breachedAccount( 77 | account: string, 78 | options: { 79 | /** 80 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 81 | */ 82 | apiKey?: string; 83 | /** 84 | * a domain by which to filter the results (default: all domains) 85 | */ 86 | domain?: string; 87 | /** 88 | * include "unverified" breaches in the results (default: true) 89 | */ 90 | includeUnverified?: boolean; 91 | /** 92 | * timeout for the request in milliseconds (default: none) 93 | */ 94 | timeoutMs?: number; 95 | /** 96 | * an AbortSignal to cancel the request (default: none) 97 | */ 98 | signal?: AbortSignal; 99 | /** 100 | * truncate the results to only include the name of each breach (default: 101 | * true) 102 | */ 103 | truncate?: boolean; 104 | /** 105 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 106 | * `https://haveibeenpwned.com/api/v3`) 107 | */ 108 | baseUrl?: string; 109 | /** 110 | * a custom string to send as the User-Agent field in the request headers 111 | * (default: `hibp `) 112 | */ 113 | userAgent?: string; 114 | } = {}, 115 | ): Promise { 116 | const { 117 | apiKey, 118 | domain, 119 | includeUnverified = true, 120 | timeoutMs, 121 | signal, 122 | truncate = true, 123 | baseUrl, 124 | userAgent, 125 | } = options; 126 | const endpoint = `/breachedaccount/${encodeURIComponent(account)}?`; 127 | const params: string[] = []; 128 | 129 | if (domain) { 130 | params.push(`domain=${encodeURIComponent(domain)}`); 131 | } 132 | 133 | if (!includeUnverified) { 134 | params.push('includeUnverified=false'); 135 | } 136 | 137 | if (!truncate) { 138 | params.push('truncateResponse=false'); 139 | } 140 | 141 | return fetchFromApi(`${endpoint}${params.join('&')}`, { 142 | apiKey, 143 | baseUrl, 144 | timeoutMs, 145 | signal, 146 | userAgent, 147 | }) as Promise; 148 | } 149 | -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- 1 | import type { Breach, Paste } from './api/haveibeenpwned/types.js'; 2 | import { breachedAccount } from './breached-account.js'; 3 | import { pasteAccount } from './paste-account.js'; 4 | 5 | export interface SearchResults { 6 | breaches: Breach[] | null; 7 | pastes: Paste[] | null; 8 | } 9 | 10 | /** 11 | * An object representing search results. 12 | * 13 | * @typedef {object} SearchResults 14 | * @property {(Breach[] | null)} breaches 15 | * @property {(Paste[] | null)} pastes 16 | */ 17 | 18 | /** 19 | * Fetches all breaches and all pastes associated with the provided account 20 | * (email address or username). Note that the remote API does not support 21 | * querying pastes by username (only email addresses), so in the event the 22 | * provided account is not a valid email address, only breach data is queried 23 | * and the "pastes" field of the resulting object will always be null. This is 24 | * exactly how searching via the current web interface behaves, which this 25 | * convenience method is designed to mimic. 26 | * 27 | * 🔑 `haveibeenpwned.com` requires an API key from 28 | * https://haveibeenpwned.com/API/Key for the `breachedaccount` and 29 | * `pasteaccount` endpoints. The `apiKey` option here is not explicitly 30 | * required, but direct requests made without it will fail (unless you specify a 31 | * `baseUrl` to a proxy that inserts a valid API key on your behalf). 32 | * 33 | * @param {string} account an email address or username 34 | * @param {object} [options] a configuration object 35 | * @param {string} [options.apiKey] an API key from 36 | * https://haveibeenpwned.com/API/Key (default: undefined) 37 | * @param {string} [options.domain] a domain by which to filter the breach 38 | * results (default: all domains) 39 | * @param {boolean} [options.truncate] truncate the breach results to only 40 | * include the name of each breach (default: true) 41 | * @param {string} [options.baseUrl] a custom base URL for the 42 | * haveibeenpwned.com API endpoints (default: 43 | * `https://haveibeenpwned.com/api/v3`) 44 | * @param {number} [options.timeoutMs] timeout for the request in milliseconds 45 | * (default: none) 46 | * @param {AbortSignal} [options.signal] an AbortSignal to cancel the request (default: none) 47 | * @param {string} [options.userAgent] a custom string to send as the 48 | * User-Agent field in the request headers (default: `hibp `) 49 | * @returns {Promise} a Promise which resolves to an object 50 | * containing a "breaches" key (which can be null or an array of breach objects) 51 | * and a "pastes" key (which can be null or an array of paste objects), or 52 | * rejects with an Error 53 | * @example 54 | * try { 55 | * const data = await search("foo", { apiKey: "my-api-key" }); 56 | * if (data.breaches || data.pastes) { 57 | * // ... 58 | * } else { 59 | * // ... 60 | * } 61 | * } catch (err) { 62 | * // ... 63 | * } 64 | * @example 65 | * try { 66 | * const data = await search("nobody@nowhere.com", { 67 | * baseUrl: "https://my-hibp-proxy:8080", 68 | * truncate: false, 69 | * }); 70 | * if (data.breaches || data.pastes) { 71 | * // ... 72 | * } else { 73 | * // ... 74 | * } 75 | * } catch (err) { 76 | * // ... 77 | * } 78 | * @see https://haveibeenpwned.com/ 79 | */ 80 | export async function search( 81 | account: string, 82 | options: { 83 | /** 84 | * an API key from https://haveibeenpwned.com/API/Key (default: undefined) 85 | */ 86 | apiKey?: string; 87 | /** 88 | * a domain by which to filter the results (default: all domains) 89 | */ 90 | domain?: string; 91 | /** 92 | * truncate the results to only include the name of each breach (default: 93 | * true) 94 | */ 95 | truncate?: boolean; 96 | /** 97 | * a custom base URL for the haveibeenpwned.com API endpoints (default: 98 | * `https://haveibeenpwned.com/api/v3`) 99 | */ 100 | baseUrl?: string; 101 | /** 102 | * timeout for the request in milliseconds (default: none) 103 | */ 104 | timeoutMs?: number; 105 | /** 106 | * an AbortSignal to cancel the request (default: none) 107 | */ 108 | signal?: AbortSignal; 109 | /** 110 | * a custom string to send as the User-Agent field in the request headers 111 | * (default: `hibp `) 112 | */ 113 | userAgent?: string; 114 | } = {}, 115 | ): Promise { 116 | const { apiKey, domain, truncate = true, baseUrl, timeoutMs, signal, userAgent } = options; 117 | 118 | const [breaches, pastes] = await Promise.all([ 119 | breachedAccount(account, { 120 | apiKey, 121 | domain, 122 | truncate, 123 | baseUrl, 124 | timeoutMs, 125 | signal, 126 | userAgent, 127 | }), 128 | // This email regex is garbage but it seems to be what the API uses: 129 | /^.+@.+$/.test(account) 130 | ? pasteAccount(account, { apiKey, baseUrl, timeoutMs, signal, userAgent }) 131 | : null, 132 | ]); 133 | 134 | return { breaches, pastes }; 135 | } 136 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 🤖 CI 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: read 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | CI: true 14 | 15 | jobs: 16 | prettier: 17 | name: 🅿️ Prettier 18 | runs-on: ubuntu-latest 19 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 20 | steps: 21 | - name: ⬇️ Checkout repo 22 | uses: actions/checkout@v6 23 | 24 | - name: 📦 Install pnpm 25 | uses: pnpm/action-setup@v4 26 | 27 | - name: ⎔ Setup node 28 | uses: actions/setup-node@v6 29 | with: 30 | cache: pnpm 31 | node-version-file: '.nvmrc' 32 | 33 | - name: 📥 Install deps 34 | run: pnpm install 35 | 36 | - name: 💅 Format check 37 | run: pnpm run format:check 38 | 39 | lint: 40 | name: ⬣ ESLint 41 | runs-on: ubuntu-latest 42 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 43 | steps: 44 | - name: ⬇️ Checkout repo 45 | uses: actions/checkout@v6 46 | 47 | - name: 📦 Install pnpm 48 | uses: pnpm/action-setup@v4 49 | 50 | - name: ⎔ Setup node 51 | uses: actions/setup-node@v6 52 | with: 53 | cache: pnpm 54 | node-version-file: '.nvmrc' 55 | 56 | - name: 📥 Install deps 57 | run: pnpm install 58 | 59 | - name: 🔬 Lint 60 | run: pnpm run lint 61 | 62 | typecheck: 63 | name: ʦ TypeScript 64 | runs-on: ubuntu-latest 65 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 66 | steps: 67 | - name: ⬇️ Checkout repo 68 | uses: actions/checkout@v6 69 | 70 | - name: 📦 Install pnpm 71 | uses: pnpm/action-setup@v4 72 | 73 | - name: ⎔ Setup node 74 | uses: actions/setup-node@v6 75 | with: 76 | cache: pnpm 77 | node-version-file: '.nvmrc' 78 | 79 | - name: 📥 Install deps 80 | run: pnpm install 81 | 82 | - name: 🛠️ Build 83 | run: pnpm run build 84 | 85 | - name: 🔎 Type check 86 | run: pnpm run typecheck 87 | 88 | - name: 📦 Are the types wrong? 89 | run: pnpm dlx @arethetypeswrong/cli --pack --format table --profile esm-only 90 | 91 | test: 92 | name: 🩺 Tests 93 | runs-on: ubuntu-latest 94 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 95 | steps: 96 | - name: ⬇️ Checkout repo 97 | uses: actions/checkout@v6 98 | 99 | - name: 📦 Install pnpm 100 | uses: pnpm/action-setup@v4 101 | 102 | - name: ⎔ Setup node 103 | uses: actions/setup-node@v6 104 | with: 105 | cache: pnpm 106 | node-version-file: '.nvmrc' 107 | 108 | - name: 📥 Install deps 109 | run: pnpm install 110 | 111 | - name: ⚡ Vitest with coverage 112 | run: pnpm run test:coverage 113 | 114 | - name: ☂ Codecov 115 | uses: codecov/codecov-action@v5 116 | with: 117 | fail_ci_if_error: true 118 | 119 | playwright: 120 | name: 🎭 Playwright 121 | runs-on: ubuntu-latest 122 | timeout-minutes: 10 123 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 124 | steps: 125 | - name: ⬇️ Checkout repo 126 | uses: actions/checkout@v6 127 | 128 | - name: 📦 Install pnpm 129 | uses: pnpm/action-setup@v4 130 | 131 | - name: ⎔ Setup node 132 | uses: actions/setup-node@v6 133 | with: 134 | cache: pnpm 135 | node-version-file: '.nvmrc' 136 | 137 | - name: 📥 Install deps 138 | run: pnpm install 139 | 140 | - name: 🔢 Get Playwright version 141 | run: echo "PLAYWRIGHT_VERSION=$(pnpm exec playwright --version | tr ' ' '-')" >> $GITHUB_ENV 142 | 143 | - name: 📦 Cache Playwright binaries 144 | id: playwright-cache 145 | uses: actions/cache@v5 146 | with: 147 | path: ~/.cache/ms-playwright 148 | key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} 149 | 150 | - name: 📥 Install Playwright Browsers 151 | run: pnpm exec playwright install --with-deps 152 | if: steps.playwright-cache.outputs.cache-hit != 'true' 153 | 154 | - name: 🎭 Playwright tests 155 | run: pnpm run test:browser:run 156 | 157 | - name: 📊 Upload report 158 | uses: actions/upload-artifact@v6 159 | if: always() 160 | with: 161 | name: playwright-report 162 | path: playwright/report/ 163 | retention-days: 30 164 | 165 | release: 166 | name: 🚀 Release 167 | runs-on: ubuntu-latest 168 | needs: [prettier, lint, typecheck, test, playwright] 169 | if: github.ref == 'refs/heads/main' 170 | permissions: 171 | contents: write 172 | pull-requests: write 173 | id-token: write 174 | steps: 175 | - name: ⬇️ Checkout repo 176 | uses: actions/checkout@v6 177 | 178 | - name: 📦 Install pnpm 179 | uses: pnpm/action-setup@v4 180 | 181 | - name: ⎔ Setup node 182 | uses: actions/setup-node@v6 183 | with: 184 | cache: pnpm 185 | node-version-file: '.nvmrc' 186 | 187 | - name: 📥 Install deps 188 | run: pnpm install 189 | 190 | - name: 🦋 Create Release Pull Request or Publish to npm 191 | id: changesets 192 | uses: changesets/action@v1 193 | with: 194 | version: pnpm run changeset:version 195 | publish: pnpm run changeset:publish 196 | commit: 'chore: release' 197 | title: 'chore: release' 198 | env: 199 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 200 | -------------------------------------------------------------------------------- /src/api/__tests__/base-fetch.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../../mocks/server.js'; 4 | import { buildUrl, buildHeaders, baseFetch } from '../base-fetch.js'; 5 | 6 | describe('internal: buildUrl', () => { 7 | describe('base URL normalization', () => { 8 | it('strips trailing slash from base URL', () => { 9 | expect(buildUrl('https://example.com/', '/endpoint')).toBe('https://example.com/endpoint'); 10 | }); 11 | 12 | it('handles base URL without trailing slash', () => { 13 | expect(buildUrl('https://example.com', '/endpoint')).toBe('https://example.com/endpoint'); 14 | }); 15 | }); 16 | 17 | describe('endpoint normalization', () => { 18 | it('handles endpoint with leading slash', () => { 19 | expect(buildUrl('https://example.com', '/endpoint')).toBe('https://example.com/endpoint'); 20 | }); 21 | 22 | it('adds leading slash to endpoint when missing', () => { 23 | expect(buildUrl('https://example.com', 'endpoint')).toBe('https://example.com/endpoint'); 24 | }); 25 | }); 26 | 27 | describe('query params', () => { 28 | it('appends query params to URL', () => { 29 | expect(buildUrl('https://example.com', '/endpoint', { foo: 'bar' })).toBe( 30 | 'https://example.com/endpoint?foo=bar', 31 | ); 32 | }); 33 | 34 | it('appends multiple query params', () => { 35 | expect(buildUrl('https://example.com', '/endpoint', { foo: 'bar', baz: 'qux' })).toBe( 36 | 'https://example.com/endpoint?foo=bar&baz=qux', 37 | ); 38 | }); 39 | 40 | it('encodes query param values', () => { 41 | expect(buildUrl('https://example.com', '/endpoint', { foo: 'bar baz' })).toBe( 42 | 'https://example.com/endpoint?foo=bar+baz', 43 | ); 44 | }); 45 | 46 | it('returns URL without query string when queryParams is undefined', () => { 47 | expect(buildUrl('https://example.com', '/endpoint')).toBe('https://example.com/endpoint'); 48 | }); 49 | 50 | it('returns URL without query string when queryParams is empty', () => { 51 | expect(buildUrl('https://example.com', '/endpoint', {})).toBe('https://example.com/endpoint'); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('internal: buildHeaders', () => { 57 | describe('extra headers', () => { 58 | it('returns extra headers when provided', () => { 59 | expect(buildHeaders(undefined, { 'X-Custom': 'value' })).toEqual({ 'X-Custom': 'value' }); 60 | }); 61 | 62 | it('returns empty object when no args provided', () => { 63 | expect(buildHeaders()).toEqual({}); 64 | }); 65 | }); 66 | 67 | describe('User-Agent', () => { 68 | it('uses custom userAgent when provided', () => { 69 | const result = buildHeaders('custom-agent'); 70 | expect(result['User-Agent']).toBe('custom-agent'); 71 | }); 72 | 73 | it('merges custom userAgent with extra headers', () => { 74 | const result = buildHeaders('custom-agent', { 'X-Custom': 'value' }); 75 | expect(result).toEqual({ 'User-Agent': 'custom-agent', 'X-Custom': 'value' }); 76 | }); 77 | 78 | it('uses default User-Agent when outside browser (no navigator)', () => { 79 | const originalNavigator = global.navigator; 80 | // @ts-expect-error: faking a non-browser (Node) environment 81 | delete global.navigator; 82 | 83 | const result = buildHeaders(); 84 | expect(result['User-Agent']).toMatch(/^hibp \d+\.\d+\.\d+/); 85 | 86 | global.navigator = originalNavigator; 87 | }); 88 | 89 | it('does not set User-Agent when inside browser (navigator exists)', () => { 90 | const originalNavigator = global.navigator; 91 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 92 | global.navigator = {} as Navigator; 93 | 94 | const result = buildHeaders(); 95 | expect(result['User-Agent']).toBeUndefined(); 96 | 97 | global.navigator = originalNavigator; 98 | }); 99 | }); 100 | }); 101 | 102 | describe('internal: baseFetch', () => { 103 | beforeEach(() => { 104 | server.use( 105 | http.get('*', () => { 106 | return new Response(JSON.stringify({ success: true }), { 107 | headers: { 'Content-Type': 'application/json' }, 108 | }); 109 | }), 110 | ); 111 | }); 112 | 113 | describe('AbortSignal', () => { 114 | it('cancels request when signal is aborted', async () => { 115 | const controller = new AbortController(); 116 | const abortPromise = baseFetch({ 117 | baseUrl: 'https://example.com', 118 | endpoint: '/test', 119 | signal: controller.signal, 120 | }); 121 | 122 | controller.abort(); 123 | 124 | await expect(abortPromise).rejects.toThrow(); 125 | }); 126 | 127 | it('works with timeout alone', async () => { 128 | const response = await baseFetch({ 129 | baseUrl: 'https://example.com', 130 | endpoint: '/test', 131 | timeoutMs: 5000, 132 | }); 133 | 134 | expect(response.ok).toBe(true); 135 | }); 136 | 137 | it('timeout fires first when both timeout and signal are provided', async () => { 138 | server.use( 139 | http.get('*', async () => { 140 | await new Promise((resolve) => setTimeout(resolve, 1000)); 141 | return new Response(JSON.stringify({ success: true }), { 142 | headers: { 'Content-Type': 'application/json' }, 143 | }); 144 | }), 145 | ); 146 | 147 | const controller = new AbortController(); 148 | const timeoutPromise = baseFetch({ 149 | baseUrl: 'https://example.com', 150 | endpoint: '/test', 151 | timeoutMs: 100, 152 | signal: controller.signal, 153 | }); 154 | 155 | await expect(timeoutPromise).rejects.toThrow(); 156 | }); 157 | 158 | it('signal fires first when aborted before timeout', async () => { 159 | const controller = new AbortController(); 160 | const abortPromise = baseFetch({ 161 | baseUrl: 'https://example.com', 162 | endpoint: '/test', 163 | timeoutMs: 5000, 164 | signal: controller.signal, 165 | }); 166 | 167 | controller.abort(); 168 | 169 | await expect(abortPromise).rejects.toThrow(); 170 | }); 171 | 172 | it('does not set signal when neither timeout nor signal provided', async () => { 173 | const response = await baseFetch({ 174 | baseUrl: 'https://example.com', 175 | endpoint: '/test', 176 | }); 177 | 178 | expect(response.ok).toBe(true); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /src/api/haveibeenpwned/__tests__/fetch-from-api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { http } from 'msw'; 3 | import { server } from '../../../../mocks/server.js'; 4 | import { BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, BLOCKED, TOO_MANY_REQUESTS } from '../responses.js'; 5 | import { fetchFromApi, RateLimitError } from '../fetch-from-api.js'; 6 | 7 | describe('internal (haveibeenpwned): fetchFromApi', () => { 8 | const apiKey = 'my-api-key'; 9 | 10 | describe('User-Agent', () => { 11 | // Node 12 | it('sends a custom User-Agent request header when outside the browser', async () => { 13 | server.use( 14 | http.get('*', ({ request }) => { 15 | return request.headers.get('User-Agent')?.includes('hibp') 16 | ? new Response(JSON.stringify({})) 17 | : new Response(null, { status: FORBIDDEN.status }); 18 | }), 19 | ); 20 | 21 | const originalNavigator = global.navigator; 22 | 23 | // @ts-expect-error: faking a non-browser (Node) environment 24 | delete global.navigator; 25 | 26 | await expect(fetchFromApi('/service')).resolves.toEqual({}); 27 | 28 | global.navigator = originalNavigator; 29 | }); 30 | 31 | // Browser 32 | it('sends a natural User-Agent request header when inside the browser', () => { 33 | // Note: this test is _kinda_ bogus because it runs in Node so node-fetch 34 | // is used to make the request and node-fetch sends its own UA, whereas in 35 | // a browser environment, the browser would send its own UA. But I think 36 | // it accomplishes the same thing by checking for our custom UA (which we 37 | // don't want to see). 38 | server.use( 39 | http.get('*', ({ request }) => { 40 | return !request.headers.get('User-Agent')?.includes('hibp') 41 | ? new Response(JSON.stringify({})) 42 | : new Response(null, { status: FORBIDDEN.status }); 43 | }), 44 | ); 45 | 46 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 47 | global.navigator = {} as Navigator; 48 | 49 | return expect(fetchFromApi('/service')).resolves.toEqual({}); 50 | }); 51 | }); 52 | 53 | describe('request failure', () => { 54 | it('re-throws request setup errors', () => { 55 | return expect( 56 | fetchFromApi('/service', { baseUrl: 'relativeBaseUrl' }), 57 | ).rejects.toMatchInlineSnapshot(`[TypeError: Invalid URL]`); 58 | }); 59 | }); 60 | 61 | describe('invalid account format', () => { 62 | it('throws a "Bad Request" error', () => { 63 | server.use( 64 | http.get('*', () => { 65 | return new Response(null, { 66 | status: BAD_REQUEST.status, 67 | }); 68 | }), 69 | ); 70 | 71 | return expect(fetchFromApi('/service/bad_request', { apiKey })).rejects.toMatchInlineSnapshot( 72 | `[Error: Bad request — the account does not comply with an acceptable format.]`, 73 | ); 74 | }); 75 | }); 76 | 77 | describe('unauthorized', () => { 78 | it('throws an "Unauthorized" error', () => { 79 | server.use( 80 | http.get('*', () => { 81 | return new Response(UNAUTHORIZED.body, { status: UNAUTHORIZED.status }); 82 | }), 83 | ); 84 | 85 | return expect(fetchFromApi('/service/unauthorized')).rejects.toMatchInlineSnapshot( 86 | `[Error: Your request to the API couldn't be authorised. Check you have the right value in the "hibp-api-key" header, refer to the documentation for more: https://haveibeenpwned.com/API/v3#Authorisation]`, 87 | ); 88 | }); 89 | }); 90 | 91 | describe('forbidden request', () => { 92 | it('throws a "Forbidden" error if no cf-ray header is present', () => { 93 | server.use( 94 | http.get('*', () => { 95 | return new Response(null, { 96 | status: FORBIDDEN.status, 97 | statusText: FORBIDDEN.statusText, 98 | }); 99 | }), 100 | ); 101 | 102 | return expect(fetchFromApi('/service/forbidden', { apiKey })).rejects.toMatchInlineSnapshot( 103 | `[Error: Forbidden - access denied.]`, 104 | ); 105 | }); 106 | 107 | it('throws a "Blocked Request" error if a cf-ray header is present', () => { 108 | server.use( 109 | http.get('*', () => { 110 | return new Response(null, { 111 | status: BLOCKED.status, 112 | headers: Array.from(BLOCKED.headers), 113 | }); 114 | }), 115 | ); 116 | 117 | return expect(fetchFromApi('/service/blocked', { apiKey })).rejects.toMatchInlineSnapshot( 118 | `[Error: Request blocked, contact haveibeenpwned.com if this continues (Ray ID: someRayId)]`, 119 | ); 120 | }); 121 | }); 122 | 123 | describe('rate limited', () => { 124 | it('throws a "Too Many Requests" rate limit error', async () => { 125 | server.use( 126 | http.get('*', () => { 127 | return new Response(JSON.stringify(TOO_MANY_REQUESTS.body), { 128 | status: TOO_MANY_REQUESTS.status, 129 | headers: Array.from(TOO_MANY_REQUESTS.headers), 130 | }); 131 | }), 132 | ); 133 | 134 | expect.assertions(3); 135 | try { 136 | await fetchFromApi('/service/rate_limited', { apiKey }); 137 | } catch (error) { 138 | expect(error).toBeInstanceOf(RateLimitError); 139 | expect(error).toHaveProperty('retryAfterSeconds', 2); 140 | expect(error).toMatchInlineSnapshot( 141 | '[RateLimitError: Rate limit is exceeded. Try again in 2 seconds.]', 142 | ); 143 | } 144 | }); 145 | 146 | it('sets retryAfterSeconds to undefined when header is missing', async () => { 147 | server.use( 148 | http.get('*', () => { 149 | return new Response(JSON.stringify(TOO_MANY_REQUESTS.body), { 150 | status: TOO_MANY_REQUESTS.status, 151 | }); 152 | }), 153 | ); 154 | 155 | expect.assertions(2); 156 | try { 157 | await fetchFromApi('/service/rate_limited', { apiKey }); 158 | } catch (error) { 159 | expect(error).toBeInstanceOf(RateLimitError); 160 | expect((error as RateLimitError).retryAfterSeconds).toBeUndefined(); 161 | } 162 | }); 163 | }); 164 | 165 | describe('unexpected HTTP error', () => { 166 | it('throws an error with the response status text', () => { 167 | server.use( 168 | http.get('*', () => { 169 | return new Response(null, { 170 | status: 599, 171 | statusText: 'Unknown - something unexpected happened.', 172 | }); 173 | }), 174 | ); 175 | 176 | return expect(fetchFromApi('/service/unknown_response')).rejects.toMatchInlineSnapshot( 177 | `[Error: Unknown - something unexpected happened.]`, 178 | ); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | ## Migration Notes 2 | 3 | #### 10.0.1 → 11.0.0 4 | 5 | - `pwnedPasswordRange` now returns an object mapping the matching suffix to a count representing the 6 | number of occurrences, rather than an array of objects each containing a matching suffix and its 7 | count. Code dependent on parsing the response text will need updated to deal with the new data 8 | format: 9 | ```js 10 | { 11 | "003D68EB55068C33ACE09247EE4C639306B": 3, 12 | "012C192B2F16F82EA0EB9EF18D9D539B0DD": 1, 13 | ... 14 | } 15 | ``` 16 | 17 | #### 9.0.3 → 10.0.0 18 | 19 | - The production/minified versions of the browser build targets have been renamed: 20 | - ESM for Browsers (` 150 | ``` 151 | 152 | For more information on ESM in the browser, check out 153 | [Using JS modules in the browser](https://v8.dev/features/modules#browser). 154 | 155 | ## Try It Out 156 | 157 | [Test hibp in your browser with StackBlitz.](https://stackblitz.com/edit/stackblitz-starters-atyrc52c?file=index.js) 158 | 159 | ## Projects Using hibp 160 | 161 | - [pwned](https://github.com/wKovacs64/pwned) - a command-line tool for querying the 162 | '[Have I been pwned?](https://haveibeenpwned.com)' service 163 | - [Password Lense](https://pwl.netlify.com/) - a static web application to reveal character types in 164 | a password 165 | - [Plasmic](https://www.plasmic.app/) - the open-source visual builder for your tech stack 166 | - [Medplum](https://www.medplum.com/) - fast and easy healthcare dev 167 | - [Hasura Backend Plus](https://nhost.github.io/hasura-backend-plus/) - Authentication & Storage for 168 | Hasura 169 | - [Staart API](https://staart.js.org/api/) - a Node.js backend starter for SaaS startups 170 | - [BanManager-WebUI](https://github.com/BanManagement/BanManager-WebUI) - Web interface for 171 | BanManager 172 | 173 | Send me a [PR](https://github.com/wKovacs64/hibp/pulls) or an email and I'll add yours to the list! 174 | 175 | ## License 176 | 177 | This module is distributed under the 178 | [MIT License](https://github.com/wKovacs64/hibp/tree/main/LICENSE.txt). 179 | 180 | ## Contributors ✨ 181 | 182 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 |
Justin Hall
Justin Hall

💻 📖 🚇 🚧 👀 ⚠️
Troy Hunt
Troy Hunt

🔣
Jelle Kralt
Jelle Kralt

💻
Anton W
Anton W

🐛
Daniel Adams
Daniel Adams

💻
Markus Dolic
Markus Dolic

🐛
Jonathan Sharpe
Jonathan Sharpe

💻
Ryan
Ryan

🐛
Stuart McGregor
Stuart McGregor

🐛
204 | 205 | 206 | 207 | 208 | 209 | 210 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) 211 | specification. Contributions of any kind welcome! 212 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | logo 9 | 10 | 11 | # hibp 12 | 13 | _An unofficial TypeScript SDK for [Troy Hunt][troy]'s [Have I been pwned?][haveibeenpwned] service._ 14 | 15 | [![npm Version][npm-image]][npm-url] [![Build Status][ci-image]][ci-url] 16 | [![Code Coverage][coverage-image]][coverage-url] 17 | [![All Contributors](https://img.shields.io/github/all-contributors/wKovacs64/hibp?style=flat-square)](#contributors-) 18 | 19 | ## Installation 20 | 21 | In Node.js: 22 | 23 | ```shell 24 | npm install hibp 25 | ``` 26 | 27 | In [Deno][deno]: 28 | 29 | ```ts 30 | // Replace x.y.z with the desired hibp version 31 | import * as hibp from 'npm:hibp@x.y.z'; 32 | ``` 33 | 34 | See the [browser](#using-in-the-browser) section below for information on how to use it in the 35 | browser. 36 | 37 | ## Features (🔑 = [requires][api-key-blog-post] an [API key][get-api-key]) 38 | 39 | - Get the most recently added breach 40 | - Get a single breach event 41 | - Get all breaches for an account 🔑 42 | - Get all breached email addresses for a domain 🔑 43 | - Get all breach events in the system 44 | - Get all data classes 45 | - Get all pastes for an account 🔑 46 | - [Securely][search-by-range] check a password to see if it has been exposed in a data breach 47 | - Check a SHA-1 or NTLM prefix to see if it has been exposed in a data breach 48 | - Search for an account in both breaches and pastes at the same time 🔑 49 | - Get all stealer log domains for an email address 🔑 50 | - Get all stealer log email aliases for an email domain 🔑 51 | - Get all stealer log email addresses for a website domain 🔑 52 | - Get all subscribed domains 🔑 53 | - Get your subscription status 🔑 54 | - All queries return a Promise 55 | - Provide your own `AbortSignal` to cancel in-flight requests 56 | - Available server-side (e.g., Node.js) and client-side (browser) 57 | - Written in TypeScript, so all modules come fully typed 58 | 59 | ## Usage 60 | 61 | ```typescript 62 | // import individual modules as needed 63 | import { dataClasses, search } from 'hibp'; 64 | 65 | // or, import all modules into a local namespace 66 | import * as hibp from 'hibp'; 67 | ``` 68 | 69 | The following modules are available: 70 | 71 | - [breach](API.md#breach) 72 | - [breachedAccount](API.md#breachedaccount) 73 | - [breachedDomain](API.md#breacheddomain) 74 | - [breaches](API.md#breaches) 75 | - [dataClasses](API.md#dataclasses) 76 | - [latestBreach](API.md#latestbreach) 77 | - [pasteAccount](API.md#pasteaccount) 78 | - [pwnedPassword](API.md#pwnedpassword) 79 | - [pwnedPasswordRange](API.md#pwnedpasswordrange) 80 | - [search](API.md#search) 81 | - [stealerLogsByEmail](API.md#stealerlogsbyemail) 82 | - [stealerLogsByEmailDomain](API.md#stealerlogsbyemaildomain) 83 | - [stealerLogsByWebsiteDomain](API.md#stealerlogsbywebsitedomain) 84 | - [subscribedDomains](API.md#subscribeddomains) 85 | - [subscriptionStatus](API.md#subscriptionstatus) 86 | 87 | Please see the [API reference](API.md) for more detailed usage information and examples. 88 | 89 | #### Quick-Start Example 90 | 91 | ```javascript 92 | import { search } from 'hibp'; 93 | 94 | async function main() { 95 | try { 96 | const data = await search('someAccountOrEmail', { apiKey: 'my-api-key' }); 97 | if (data.breaches || data.pastes) { 98 | // Bummer... 99 | console.log(data); 100 | } else { 101 | // Phew! We're clear. 102 | console.log('Good news — no pwnage found!'); 103 | } 104 | } catch (err) { 105 | // Something went wrong. 106 | console.log(err.message); 107 | } 108 | } 109 | 110 | void main(); 111 | ``` 112 | 113 | #### Rate Limiting 114 | 115 | The haveibeenpwned.com API [rate limits][haveibeenpwned-rate-limiting] requests to prevent abuse. In 116 | the event you get rate limited, the module will throw a custom `RateLimitError` which will include a 117 | `retryAfterSeconds` property so you know when you can try the call again (as a `number`, unless the 118 | remote API did not provide one, in which case it will be `undefined` - but that _should_ never 119 | happen). 120 | 121 | #### Using in the browser 122 | 123 | You have a couple of options for using this library in a browser environment: 124 | 125 | 1. Bundled 126 | 127 | The most efficient and recommended method is to bundle it with client-side code using a module 128 | bundler, most likely dictated by your web application framework of choice. 129 | 130 | 1. ESM for Browsers 131 | 132 | Alternatively, you can also import the library directly in your HTML via ` 151 | ``` 152 | 153 | For more information on ESM in the browser, check out [Using JS modules in the 154 | browser][js-modules]. 155 | 156 | ## Try It Out 157 | 158 | [Test hibp in your browser with StackBlitz.][stackblitz] 159 | 160 | ## Projects Using hibp 161 | 162 | - [pwned][pwned] - a command-line tool for querying the '[Have I been pwned?][haveibeenpwned]' 163 | service 164 | - [Password Lense][pwl] - a static web application to reveal character types in a password 165 | - [Plasmic](https://www.plasmic.app/) - the open-source visual builder for your tech stack 166 | - [Medplum](https://www.medplum.com/) - fast and easy healthcare dev 167 | - [Hasura Backend Plus](https://nhost.github.io/hasura-backend-plus/) - Authentication & Storage for 168 | Hasura 169 | - [Staart API](https://staart.js.org/api/) - a Node.js backend starter for SaaS startups 170 | - [BanManager-WebUI](https://github.com/BanManagement/BanManager-WebUI) - Web interface for 171 | BanManager 172 | 173 | Send me a [PR][pulls] or an email and I'll add yours to the list! 174 | 175 | ## License 176 | 177 | This module is distributed under the [MIT License][license]. 178 | 179 | [npm-image]: https://img.shields.io/npm/v/hibp.svg?style=flat-square 180 | [npm-url]: https://www.npmjs.com/package/hibp 181 | [ci-image]: 182 | https://img.shields.io/github/actions/workflow/status/wKovacs64/hibp/ci.yml?logo=github&style=flat-square 183 | [ci-url]: https://github.com/wKovacs64/hibp/actions?query=workflow%3Aci 184 | [coverage-image]: https://img.shields.io/codecov/c/github/wKovacs64/hibp/main.svg?style=flat-square 185 | [coverage-url]: https://codecov.io/gh/wKovacs64/hibp/branch/main 186 | [deno]: https://deno.com/ 187 | [troy]: https://www.troyhunt.com 188 | [haveibeenpwned]: https://haveibeenpwned.com 189 | [haveibeenpwned-rate-limiting]: https://haveibeenpwned.com/API/v3#RateLimiting 190 | [search-by-range]: https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange 191 | [api-key-blog-post]: https://www.troyhunt.com/authentication-and-the-have-i-been-pwned-api/ 192 | [get-api-key]: https://haveibeenpwned.com/API/Key 193 | [unpkg]: https://unpkg.com 194 | [caniuse-esm]: https://caniuse.com/#feat=es6-module 195 | [js-modules]: https://v8.dev/features/modules#browser 196 | [stackblitz]: https://stackblitz.com/edit/stackblitz-starters-atyrc52c?file=index.js 197 | [pwned]: https://github.com/wKovacs64/pwned 198 | [pulls]: https://github.com/wKovacs64/hibp/pulls 199 | [pwl]: https://pwl.netlify.com/ 200 | [license]: https://github.com/wKovacs64/hibp/tree/main/LICENSE.txt 201 | 202 | ## Contributors ✨ 203 | 204 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 |
Justin Hall
Justin Hall

💻 📖 🚇 🚧 👀 ⚠️
Troy Hunt
Troy Hunt

🔣
Jelle Kralt
Jelle Kralt

💻
Anton W
Anton W

🐛
Daniel Adams
Daniel Adams

💻
Markus Dolic
Markus Dolic

🐛
Jonathan Sharpe
Jonathan Sharpe

💻
Ryan
Ryan

🐛
Stuart McGregor
Stuart McGregor

🐛
226 | 227 | 228 | 229 | 230 | 231 | 232 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) 233 | specification. Contributions of any kind welcome! 234 | -------------------------------------------------------------------------------- /CHANGELOG-7.x.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 8.x-11.x 4 | 5 | **Please refer to the [releases page](../../releases) for the 8.x-11.x versions and 6 | [CHANGELOG.md](CHANGELOG.md) for the newer versions.** 7 | 8 | ## 7.x and Older 9 | 10 | #### Version 7.5.1 _(2019-03-05)_ 11 | 12 | - Fixed an issue preventing the use of `hibp` in React Native development mode 13 | ([8e5b4de7][8e5b4de7]) 14 | 15 | #### Version 7.5.0 _(2019-01-27)_ 16 | 17 | - Added a `userAgent` option to all functions to facilitate specifying your own `User-Agent` header 18 | value for requests made to the haveibeenpwned.com and pwnedpasswords.com APIs ([#63][63]) 19 | - Added a `baseUrl` option to all functions to facilitate specifying your own URL for requests that 20 | would normally be made to `https://haveibeenpwned.com/api` and `https://api.pwnedpasswords.com` to 21 | facilitate proxying the requests through your own server (which may be necessary if you wish to 22 | use the `breachedAccount` and `search` functions after January, 2019 as `haveibeenpwned.com` no 23 | longer accepts `breachedaccount` endpoint requests originating from a browser) 24 | 25 | See issue [#60][60] for more details and discussion. 26 | 27 | #### Version 7.4.0 _(2019-01-19)_ 28 | 29 | - Added an `includeUnverified` option to the `breachedAccount` function to include "unverified" 30 | breaches in the results ([be01ad12][be01ad12]) 31 | - Generalized the 403 Forbidden response message to simply "access denied" as this type of response 32 | from `haveibeenpwned.com` is no longer limited to a missing `User-Agent` header field 33 | ([15e02f97][15e02f97]) 34 | - Added a new error specific to 403 Forbidden responses that includes the Ray ID from Cloudflare so 35 | users can contact `haveibeenpwned.com` when they are being blocked ([cd74e40d][cd74e40d]) 36 | - Removed (and prevented future creation of) empty `remote-api` bundle in the ESM build 37 | - Defined and exported the `hibp` namespace for typing the UMD build 38 | 39 | #### Version 7.3.0 _(2019-01-05)_ 40 | 41 | - Converted to TypeScript ([#56][56]) 42 | 43 | #### Version 7.2.3 _(2018-12-20)_ 44 | 45 | - Fixed build on Windows ([48d25282][48d25282]) 46 | - Moved CI from Travis to Circle ([#52][52]) 47 | - Moved coverage reports from Coveralls to Codecov ([#53][53]) 48 | 49 | #### Version 7.2.2 _(2018-11-26)_ 50 | 51 | - Updated a **development-only** dependency (`start-server-and-test`) to remove a compromised 52 | transitive dependency (`flatmap-stream@0.1.1`). See 53 | [dominictarr/event-stream#116][dominictarr/event-stream#116] for further details. 54 | - Removed redundant pre-publish build step 55 | 56 | #### Version 7.2.1 _(2018-10-23)_ 57 | 58 | - Fixed the CommonJS build ([3f33becf][3f33becf]) 59 | 60 | #### Version 7.2.0 _(2018-10-16)_ 61 | 62 | - Added an ESM for browsers build ([#49][49]) 63 | 64 | #### Version 7.1.3 _(2018-06-26)_ 65 | 66 | - Fixed custom `User-Agent` request header implementation ([#40][40]) 67 | 68 | #### Version 7.1.2 _(2018-06-26)_ 69 | 70 | - Fixed `Forbidden` errors by adding a custom `User-Agent` request header when running outside the 71 | browser ([#39][39]) 72 | 73 | #### Version 7.1.1 _(2018-04-04)_ 74 | 75 | - Fixed build scripts to prevent including test-only mocks in published output 76 | 77 | #### Version 7.1.0 _(2018-04-04)_ 78 | 79 | - Added npm `prepare` script to facilitate installing from hosted git 80 | - Replaced [js-sha1][js-sha1] with [jsSHA][jssha] 81 | - Fixed a misleading comment in the `hibp` export documentation 82 | - Integrated [Renovate][renovate] for automated dependency updates 83 | - Changed mocking strategy and refactored tests 84 | 85 | #### Version 7.0.0 _(2018-03-13)_ 86 | 87 | ##### Breaking Changes (see [MIGRATION.md](MIGRATION.md) for details): 88 | 89 | - Modified `pwnedPassword` to use the more secure hash range API ([@danieladams456][danieladams456] 90 | in [#23][23]) 91 | - Modified `pwnedPasswordRange` to resolve with array of objects ([@danieladams456][danieladams456] 92 | in [#24][24]) 93 | 94 | #### Version 6.0.0 _(2018-02-25)_ 95 | 96 | - Restored `puppeteer` to a development dependency 97 | - Cleaned up some tests 98 | 99 | ##### Breaking Changes (see [MIGRATION.md](MIGRATION.md) for details): 100 | 101 | - Dropped support for Node < 6 102 | 103 | #### Version 5.3.0 _(2018-02-24)_ 104 | 105 | - Added `"sideEffects": false` to support Webpack 4 tree-shaking 106 | - Added support for searching pwned passwords by range (#21) 107 | - Switched API endpoint for `pwnedPassword` module to new `pwnedpasswords.com` domain 108 | 109 | #### Version 5.2.5 _(2017-12-07)_ 110 | 111 | - Removed `puppeteer` optional dependency as it was causing downstream consumers to download 112 | Chromium (particularly, when running things with `npx`). The `test:umd` script now requires you 113 | manually install `puppeteer` before running it, which will be done automatically in CI. 114 | 115 | #### Version 5.2.4 _(2017-12-07)_ 116 | 117 | - Reverted `puppeteer` to `0.12.0` ~~as `0.13.0` was causing downstream consumers to download 118 | Chromium.~~ 119 | 120 | #### Version 5.2.3 _(2017-12-07)_ 121 | 122 | - Reformated some documentation files 123 | - Updated dependencies 124 | 125 | #### Version 5.2.2 _(2017-11-08)_ 126 | 127 | - Internal maintenance 128 | 129 | #### Version 5.2.1 _(2017-11-07)_ 130 | 131 | - Internal maintenance 132 | 133 | #### Version 5.2.0 _(2017-08-04)_ 134 | 135 | - Added [`pwnedPassword`][hibp-pwnedpassword] method to check a password to see if it has been 136 | previously exposed in a data breach (#16) 137 | 138 | #### Version 5.1.0 _(2017-07-10)_ 139 | 140 | - Replaced webpack with rollup for UMD bundling (#15) 141 | - Updated dependencies 142 | 143 | #### Version 5.0.0 _(2017-07-01)_ 144 | 145 | - Targeted browsers in CommonJS/ES Module builds (#11) 146 | - Updated dependencies 147 | 148 | ##### Breaking Changes (see [MIGRATION.md](MIGRATION.md) for details): 149 | 150 | - Removed `index.js`, the `source-map-support` entry point (#7) 151 | - Replaced `browser` field in package.json with `unpkg` (#12) 152 | - Removed the top-level `default` export (#14) 153 | 154 | #### Version 4.4.0 _(2017-06-22)_ 155 | 156 | - Separated functions into individual modules (fixed tree-shaking) 157 | - Provided safer UMD script tag instructions 158 | - Explicitly targeted browsers in UMD build (resulting in reduced file size) 159 | - Updated dependencies 160 | 161 | #### Version 4.3.0 _(2017-06-08)_ 162 | 163 | - Added [`search`][hibp-search] method for querying breaches and pastes simultaneously (like the 164 | search form on the [website][haveibeenpwned]) 165 | - Set the AMD module name in the UMD build to `hibp` rather than anonymous 166 | - Updated dependencies 167 | 168 | #### Version 4.2.1 _(2017-05-27)_ 169 | 170 | - Fixed UMD build that broke in 4.2.0 171 | 172 | #### Version 4.2.0 _(2017-05-25)_ 173 | 174 | - Fixed return type in `breachedAccount` documentation 175 | - Added support for tree-shaking bundlers 176 | - Optimized tests 177 | - Updated dependencies 178 | 179 | #### Version 4.1.1 _(2017-01-16)_ 180 | 181 | - Published `example` directory for RunKit support 182 | - Removed `old` directory from package that slipped in by mistake 183 | 184 | #### Version 4.1.0 _(2017-01-16)_ 185 | 186 | - Encoded user input used in API query string parameters 187 | - Added RunKit information for live trial usage 188 | 189 | #### Version 4.0.1 _(2017-01-04)_ 190 | 191 | - First release of 2017! :tada: 192 | - Reduced size of UMD build by 75% 193 | - Updated dependencies 194 | 195 | #### Version 4.0.0 _(2016-12-10)_ 196 | 197 | - Tweaked toolchain configs 198 | - Restructured test data 199 | - Updated dependencies 200 | 201 | ##### Breaking Changes (see [MIGRATION.md](MIGRATION.md) for details): 202 | 203 | - Dropped support for Node < 4 204 | 205 | #### Version 3.0.0 _(2016-10-23)_ 206 | 207 | - Added `yarn.lock` for experimental [yarn][yarn] support 208 | - Removed expect.js dependency from the test environment 209 | - Expanded usage documentation 210 | - Updated dependencies 211 | 212 | ##### Breaking Changes (see [MIGRATION.md](MIGRATION.md) for details): 213 | 214 | - The browser (UMD) version has moved from the `lib` directory to the `dist` directory. 215 | 216 | #### Version 2.2.0 _(2016-10-03)_ 217 | 218 | - Added fallback for unexpected HTTP responses (thanks @jellekralt) 219 | - Added handling for new HTTP 429 (Too Many Requests) rate-limiting responses 220 | - Improved tests 221 | - Switched code style from SemiStandard to Airbnb 222 | - Updated dependencies 223 | 224 | #### Version 2.1.0 _(2016-09-04)_ 225 | 226 | - Replaced **npmcdn.com** with **unpkg.com** in the documentation as the service is being renamed 227 | - Inherited support for `http_proxy` and `https_proxy` environment variables from Axios 0.14.0 228 | - Simplified build scripts 229 | - Refactored test environment 230 | - Updated dependencies 231 | 232 | #### Version 2.0.0 _(2016-08-07)_ 233 | 234 | ##### New: 235 | 236 | - Added browser support 237 | 238 | ##### Breaking Changes (see [MIGRATION.md](MIGRATION.md) for details): 239 | 240 | - Changed API methods to resolve to null instead of undefined when no data was found 241 | - Changed API methods to take a configuration object rather than optional, positional parameters 242 | 243 | #### Version 1.0.8 _(2016-08-06)_ 244 | 245 | - Updated description and example usage 246 | - Switched test coverage from istanbul to nyc 247 | - Improved cross-platform compatibility for development 248 | - Updated dependencies 249 | 250 | #### Version 1.0.7 _(2016-07-21)_ 251 | 252 | - Minor performance increase 253 | - Fixed API documentation for 'breaches' query 254 | - Updated dependencies 255 | 256 | #### Version 1.0.6 _(2016-06-28)_ 257 | 258 | - Increased visibility in npm search 259 | - Minor improvements to development environment 260 | 261 | #### Version 1.0.5 _(2016-04-22)_ 262 | 263 | - Removed temporary 'breach' hack as the API endpoint has been fixed 264 | - Updated dependencies 265 | 266 | #### Version 1.0.4 _(2016-04-12)_ 267 | 268 | - Changed temporary 'breach' hack to match author's intentions 269 | 270 | _The API author (Troy Hunt) indicated there is no hard format restrictions on a breach name, so 271 | the concept of an invalid breach name is not in play here. The API will respond with HTTP status 272 | 404 (not found) once the fix has been applied. This change mimics that behavior as opposed to 273 | responding with HTTP status 400 (bad request), which was my initial interpretation._ 274 | 275 | #### Version 1.0.3 _(2016-04-10)_ 276 | 277 | - Updated documentation 278 | 279 | #### Version 1.0.2 _(2016-04-10)_ 280 | 281 | - Shield clients from broken '[breach][singlebreach]' endpoint when querying for an invalid breach 282 | name 283 | 284 | _Currently, the endpoint responds with HTTP status 200 and "page not found" HTML in the body if an 285 | invalid breach name is queried (e.g. 'adobe.com', instead of the proper breach name, 'adobe'). 286 | Based on the response codes described in the API documentation, I believe it should respond with 287 | HTTP status 400 (bad request). Prior to this patch, it lead to a confusing one-off scenario for 288 | clients consuming this module. This change should provide a consistent experience by intercepting 289 | this specific case and throwing a "bad request" error instead of a `SyntaxError` from trying to 290 | parse HTML. I brought this API behavioral discrepancy to the API author's attention and he agreed 291 | it was broken and noted that a fix is incoming._ 292 | 293 | - Updated tests 294 | 295 | #### Version 1.0.1 _(2016-04-08)_ 296 | 297 | - Removed `preferGlobal` option from package.json 298 | 299 | #### Version 1.0.0 _(2016-04-08)_ 300 | 301 | - Initial release 302 | 303 | 304 | 305 | [hibp-pwnedpassword]: API.md#module_pwnedPassword 306 | [hibp-search]: API.md#module_search 307 | [haveibeenpwned]: https://haveibeenpwned.com 308 | [singlebreach]: https://haveibeenpwned.com/API/v2#SingleBreach 309 | [yarn]: https://yarnpkg.com 310 | [danieladams456]: https://github.com/danieladams456 311 | [23]: https://github.com/wKovacs64/hibp/pull/23 312 | [24]: https://github.com/wKovacs64/hibp/pull/24 313 | [renovate]: https://renovateapp.com/ 314 | [js-sha1]: https://github.com/emn178/js-sha1 315 | [jssha]: https://github.com/Caligatio/jsSHA 316 | [39]: https://github.com/wKovacs64/hibp/pull/39 317 | [40]: https://github.com/wKovacs64/hibp/pull/40 318 | [49]: https://github.com/wKovacs64/hibp/pull/49 319 | [3f33becf]: https://github.com/wKovacs64/hibp/commit/3f33becfa23b80abc45fbeaad6c8c9f85113d126 320 | [dominictarr/event-stream#116]: https://github.com/dominictarr/event-stream/issues/116 321 | [48d25282]: https://github.com/wKovacs64/hibp/commit/48d25282407d2b1d3cdfac51f311d018a6a16d25 322 | [52]: https://github.com/wKovacs64/hibp/pull/52 323 | [53]: https://github.com/wKovacs64/hibp/pull/53 324 | [56]: https://github.com/wKovacs64/hibp/pull/56 325 | [be01ad12]: https://github.com/wKovacs64/hibp/commit/be01ad1253b7ceb3c7f844049451a4e8e9e3a858 326 | [15e02f97]: https://github.com/wKovacs64/hibp/commit/15e02f970286a410a275fe3457f559050632e5bd 327 | [cd74e40d]: https://github.com/wKovacs64/hibp/commit/cd74e40de95143252ab99f5c070a84e54b1365a6 328 | [60]: https://github.com/wKovacs64/hibp/issues/60 329 | [63]: https://github.com/wKovacs64/hibp/pull/63 330 | [64]: https://github.com/wKovacs64/hibp/pull/64 331 | [8e5b4de7]: https://github.com/wKovacs64/hibp/commit/8e5b4de79d25d834e14b8917101b4e0209d52f14 332 | --------------------------------------------------------------------------------