Skip to content

Commit 70db0e1

Browse files
Implement stealerLogsByEmailDomain module (#543)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 682cff7 commit 70db0e1

File tree

9 files changed

+270
-2
lines changed

9 files changed

+270
-2
lines changed

.bundlewatch.config.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@
5050
"path": "dist/esm/stealer-logs-by-email.js",
5151
"maxSize": "1 kB"
5252
},
53+
{
54+
"path": "dist/esm/stealer-logs-by-email-domain.js",
55+
"maxSize": "1.2 kB"
56+
},
5357
{
5458
"path": "dist/esm/stealer-logs-by-website-domain.js",
55-
"maxSize": "1.1 kB"
59+
"maxSize": "1 kB"
5660
},
5761
{
5862
"path": "dist/esm/subscribed-domains.js",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hibp': minor
3+
---
4+
5+
Add `stealerLogsByEmailDomain` module.

API.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ convenience method is designed to mimic.</p>
6969
required, but direct requests made without it will fail (unless you specify a
7070
<code>baseUrl</code> to a proxy that inserts a valid API key on your behalf).</p>
7171
</dd>
72+
<dt><a href="#stealerLogsByEmailDomain">stealerLogsByEmailDomain(emailDomain, [options])</a> ⇒ <code><a href="#StealerLogDomainsByEmailAlias">Promise.&lt;StealerLogDomainsByEmailAlias&gt;</a></code> | <code>Promise.&lt;null&gt;</code></dt>
73+
<dd><p>Fetches all stealer log email aliases for an email domain.</p>
74+
<p>The result maps email aliases (the local-part before the &#39;@&#39;) to an array of
75+
email domains found in stealer logs. For example, querying <code>example.com</code>
76+
could return an object like <code>{ &quot;andy&quot;: [&quot;netflix.com&quot;], &quot;jane&quot;: [&quot;netflix.com&quot;, &quot;spotify.com&quot;] }</code>, corresponding to <code>andy@example.com</code> and <code>jane@example.com</code>.</p>
77+
<p>🔑 <code>haveibeenpwned.com</code> requires an API key from
78+
<a href="https://haveibeenpwned.com/API/Key">https://haveibeenpwned.com/API/Key</a> for the <code>stealerlogsbyemaildomain</code> endpoint.
79+
The <code>apiKey</code> option here is not explicitly required, but direct requests made
80+
without it will fail (unless you specify a <code>baseUrl</code> to a proxy that inserts
81+
a valid API key on your behalf).</p>
82+
</dd>
7283
<dt><a href="#stealerLogsByEmail">stealerLogsByEmail(emailAddress, [options])</a> ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> | <code>Promise.&lt;null&gt;</code></dt>
7384
<dd><p>Fetches all stealer log domains for an email address.</p>
7485
<p>Returns an array of domains for which stealer logs contain entries for the
@@ -130,6 +141,11 @@ hash prefix) to how many times it occurred in the Pwned Passwords repository.</p
130141
<dt><a href="#SearchResults">SearchResults</a> : <code>object</code></dt>
131142
<dd><p>An object representing search results.</p>
132143
</dd>
144+
<dt><a href="#StealerLogDomainsByEmailAlias">StealerLogDomainsByEmailAlias</a> : <code>Object.&lt;string, Array.&lt;string&gt;&gt;</code></dt>
145+
<dd><p>An object mapping an email alias (local-part before the &#39;@&#39;) to the list of
146+
email domains that alias has appeared in within stealer logs for the specified
147+
email domain.</p>
148+
</dd>
133149
<dt><a href="#SubscribedDomain">SubscribedDomain</a> : <code>object</code></dt>
134150
<dd><p>An object representing a subscribed domain.</p>
135151
</dd>
@@ -595,6 +611,49 @@ try {
595611
// ...
596612
}
597613
```
614+
<a name="stealerLogsByEmailDomain"></a>
615+
616+
## stealerLogsByEmailDomain(emailDomain, [options]) ⇒ [<code>Promise.&lt;StealerLogDomainsByEmailAlias&gt;</code>](#StealerLogDomainsByEmailAlias) \| <code>Promise.&lt;null&gt;</code>
617+
Fetches all stealer log email aliases for an email domain.
618+
619+
The result maps email aliases (the local-part before the '@') to an array of
620+
email domains found in stealer logs. For example, querying `example.com`
621+
could return an object like `{ "andy": ["netflix.com"], "jane": ["netflix.com",
622+
"spotify.com"] }`, corresponding to `andy@example.com` and `jane@example.com`.
623+
624+
🔑 `haveibeenpwned.com` requires an API key from
625+
https://haveibeenpwned.com/API/Key for the `stealerlogsbyemaildomain` endpoint.
626+
The `apiKey` option here is not explicitly required, but direct requests made
627+
without it will fail (unless you specify a `baseUrl` to a proxy that inserts
628+
a valid API key on your behalf).
629+
630+
**Kind**: global function
631+
**Returns**: [<code>Promise.&lt;StealerLogDomainsByEmailAlias&gt;</code>](#StealerLogDomainsByEmailAlias) \| <code>Promise.&lt;null&gt;</code> - a Promise
632+
which resolves to an object mapping aliases to stealer log email domain arrays
633+
(or null if no results were found), or rejects with an Error
634+
635+
| Param | Type | Description |
636+
| --- | --- | --- |
637+
| emailDomain | <code>string</code> | the email domain to query (e.g., "example.com") |
638+
| [options] | <code>object</code> | a configuration object |
639+
| [options.apiKey] | <code>string</code> | an API key from https://haveibeenpwned.com/API/Key (default: undefined) |
640+
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
641+
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
642+
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |
643+
644+
**Example**
645+
```js
646+
try {
647+
const data = await stealerLogsByEmailDomain("example.com", { apiKey: "my-api-key" });
648+
if (data) {
649+
// { "andy": ["netflix.com"], "jane": ["netflix.com", "spotify.com"] }
650+
} else {
651+
// no results
652+
}
653+
} catch (err) {
654+
// ...
655+
}
656+
```
598657
<a name="stealerLogsByEmail"></a>
599658

600659
## stealerLogsByEmail(emailAddress, [options]) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> \| <code>Promise.&lt;null&gt;</code>
@@ -868,6 +927,14 @@ An object representing search results.
868927
| breaches | [<code>Array.&lt;Breach&gt;</code>](#breach--object) \| <code>null</code> |
869928
| pastes | [<code>Array.&lt;Paste&gt;</code>](#Paste) \| <code>null</code> |
870929

930+
<a name="StealerLogDomainsByEmailAlias"></a>
931+
932+
## StealerLogDomainsByEmailAlias : <code>Object.&lt;string, Array.&lt;string&gt;&gt;</code>
933+
An object mapping an email alias (local-part before the '@') to the list of
934+
email domains that alias has appeared in within stealer logs for the specified
935+
email domain.
936+
937+
**Kind**: global typedef
871938
<a name="SubscribedDomain"></a>
872939

873940
## SubscribedDomain : <code>object</code>

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ browser.
4040
- Get a single breach event
4141
- Get all breaches for an account 🔑
4242
- Get all breached email addresses for a domain 🔑
43+
- Get all stealer log email aliases for an email domain 🔑
4344
- Get all breach events in the system
4445
- Get all data classes
4546
- Get all pastes for an account 🔑
@@ -75,6 +76,7 @@ The following modules are available:
7576
- [pwnedPasswordRange](API.md#pwnedpasswordrange)
7677
- [search](API.md#search)
7778
- [stealerLogsByEmail](API.md#stealerlogsbyemail)
79+
- [stealerLogsByEmailDomain](API.md#stealerlogsbyemaildomain)
7880
- [stealerLogsByWebsiteDomain](API.md#stealerlogsbywebsitedomain)
7981
- [subscribedDomains](API.md#subscribeddomains)
8082
- [subscriptionStatus](API.md#subscriptionstatus)

src/__tests__/hibp.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ describe('hibp', () => {
1717
"pwnedPasswordRange": [Function],
1818
"search": [Function],
1919
"stealerLogsByEmail": [Function],
20+
"stealerLogsByEmailDomain": [Function],
2021
"stealerLogsByWebsiteDomain": [Function],
2122
"subscribedDomains": [Function],
2223
"subscriptionStatus": [Function],
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
});

src/api/haveibeenpwned/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export interface SubscribedDomain {
4949

5050
export type BreachedDomainsByEmailAlias = Record<string, string[]>;
5151

52+
export type StealerLogDomainsByEmailAlias = Record<string, string[]>;
53+
5254
//
5355
// Internal convenience types
5456
//
@@ -63,7 +65,8 @@ export type ApiData =
6365
| Breach[] // breachedaccount, breaches
6466
| BreachedDomainsByEmailAlias // breacheddomain
6567
| Paste[] // pasteaccount
66-
| string[] // dataclasses
68+
| string[] // dataclasses, stealerlogsbyemail, stealerlogsbywebsitedomain
69+
| StealerLogDomainsByEmailAlias // stealerlogsbyemaildomain
6770
| SubscriptionStatus // subscription/status
6871
| SubscribedDomain[] // subscribeddomains
6972
| null; // most endpoints can return an empty response (404, but not an error)

src/hibp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { pwnedPassword } from './pwned-password.js';
1010
import { pwnedPasswordRange } from './pwned-password-range.js';
1111
import { search } from './search.js';
1212
import { stealerLogsByEmail } from './stealer-logs-by-email.js';
13+
import { stealerLogsByEmailDomain } from './stealer-logs-by-email-domain.js';
1314
import { stealerLogsByWebsiteDomain } from './stealer-logs-by-website-domain.js';
1415
import { subscribedDomains } from './subscribed-domains.js';
1516
import { subscriptionStatus } from './subscription-status.js';
@@ -37,6 +38,7 @@ export {
3738
pwnedPasswordRange,
3839
search,
3940
stealerLogsByEmail,
41+
stealerLogsByEmailDomain,
4042
stealerLogsByWebsiteDomain,
4143
subscribedDomains,
4244
subscriptionStatus,
@@ -56,6 +58,7 @@ export interface HIBP {
5658
pwnedPasswordRange: typeof pwnedPasswordRange;
5759
search: typeof search;
5860
stealerLogsByEmail: typeof stealerLogsByEmail;
61+
stealerLogsByEmailDomain: typeof stealerLogsByEmailDomain;
5962
stealerLogsByWebsiteDomain: typeof stealerLogsByWebsiteDomain;
6063
subscribedDomains: typeof subscribedDomains;
6164
subscriptionStatus: typeof subscriptionStatus;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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.<string, string[]>} 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 {string} [options.userAgent] a custom string to send as the User-Agent
36+
* field in the request headers (default: `hibp <version>`)
37+
* @returns {(Promise<StealerLogDomainsByEmailAlias> | Promise<null>)} a Promise
38+
* which resolves to an object mapping aliases to stealer log email domain arrays
39+
* (or null if no results were found), or rejects with an Error
40+
* @example
41+
* try {
42+
* const data = await stealerLogsByEmailDomain("example.com", { apiKey: "my-api-key" });
43+
* if (data) {
44+
* // { "andy": ["netflix.com"], "jane": ["netflix.com", "spotify.com"] }
45+
* } else {
46+
* // no results
47+
* }
48+
* } catch (err) {
49+
* // ...
50+
* }
51+
*/
52+
export function stealerLogsByEmailDomain(
53+
emailDomain: 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+
* a custom string to send as the User-Agent field in the request headers
70+
* (default: `hibp <version>`)
71+
*/
72+
userAgent?: string;
73+
} = {},
74+
): Promise<StealerLogDomainsByEmailAlias | null> {
75+
const { apiKey, baseUrl, timeoutMs, userAgent } = options;
76+
const endpoint = `/stealerlogsbyemaildomain/${encodeURIComponent(emailDomain)}`;
77+
78+
return fetchFromApi(endpoint, {
79+
apiKey,
80+
baseUrl,
81+
timeoutMs,
82+
userAgent,
83+
}) as Promise<StealerLogDomainsByEmailAlias | null>;
84+
}

0 commit comments

Comments
 (0)