Skip to content

Commit 655b473

Browse files
Implement stealerLogsByEmail module (#541)
* feat: Add stealerLogsByEmail module Co-authored-by: justin.r.hall <justin.r.hall@gmail.com> * Human tweaks --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 825fdaf commit 655b473

File tree

8 files changed

+268
-3
lines changed

8 files changed

+268
-3
lines changed

.bundlewatch.config.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,16 @@
4747
"maxSize": "1.4 kB"
4848
},
4949
{
50-
"path": "dist/esm/subscription-status.js",
50+
"path": "dist/esm/stealer-logs-by-email.js",
5151
"maxSize": "1 kB"
5252
},
5353
{
5454
"path": "dist/esm/subscribed-domains.js",
5555
"maxSize": "1.1 kB"
56+
},
57+
{
58+
"path": "dist/esm/subscription-status.js",
59+
"maxSize": "1 kB"
5660
}
5761
]
5862
}
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 `stealerLogsByEmail` module.

API.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ 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="#stealerLogsByEmail">stealerLogsByEmail(emailAddress, [options])</a> ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> | <code>Promise.&lt;null&gt;</code></dt>
73+
<dd><p>Fetches all stealer log domains for an email address.</p>
74+
<p>Returns an array of domains for which stealer logs contain entries for the
75+
supplied email address.</p>
76+
<p>🔑 <code>haveibeenpwned.com</code> requires an API key from
77+
<a href="https://haveibeenpwned.com/API/Key">https://haveibeenpwned.com/API/Key</a> for the <code>stealerlogsbyemail</code> endpoint. The
78+
<code>apiKey</code> option here is not explicitly required, but direct requests made
79+
without it will fail (unless you specify a <code>baseUrl</code> to a proxy that inserts
80+
a valid API key on your behalf).</p>
81+
</dd>
7282
<dt><a href="#subscribedDomains">subscribedDomains([options])</a> ⇒ <code><a href="#subscribeddomain--object">Promise.&lt;Array.&lt;SubscribedDomain&gt;&gt;</a></code></dt>
7383
<dd><p>Fetches all subscribed domains for your HIBP account.</p>
7484
<p>Returns domains that have been successfully added to the Domain Search dashboard
@@ -575,6 +585,62 @@ try {
575585
// ...
576586
}
577587
```
588+
<a name="stealerLogsByEmail"></a>
589+
590+
## stealerLogsByEmail(emailAddress, [options]) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> \| <code>Promise.&lt;null&gt;</code>
591+
Fetches all stealer log domains for an email address.
592+
593+
Returns an array of domains for which stealer logs contain entries for the
594+
supplied email address.
595+
596+
🔑 `haveibeenpwned.com` requires an API key from
597+
https://haveibeenpwned.com/API/Key for the `stealerlogsbyemail` endpoint. The
598+
`apiKey` option here is not explicitly required, but direct requests made
599+
without it will fail (unless you specify a `baseUrl` to a proxy that inserts
600+
a valid API key on your behalf).
601+
602+
**Kind**: global function
603+
**Returns**: <code>Promise.&lt;Array.&lt;string&gt;&gt;</code> \| <code>Promise.&lt;null&gt;</code> - a Promise which resolves to an
604+
array of domain strings (or null if none were found), or rejects with an
605+
Error
606+
607+
| Param | Type | Description |
608+
| --- | --- | --- |
609+
| emailAddress | <code>string</code> | the email address to query |
610+
| [options] | <code>object</code> | a configuration object |
611+
| [options.apiKey] | <code>string</code> | an API key from https://haveibeenpwned.com/API/Key (default: undefined) |
612+
| [options.baseUrl] | <code>string</code> | a custom base URL for the haveibeenpwned.com API endpoints (default: `https://haveibeenpwned.com/api/v3`) |
613+
| [options.timeoutMs] | <code>number</code> | timeout for the request in milliseconds (default: none) |
614+
| [options.userAgent] | <code>string</code> | a custom string to send as the User-Agent field in the request headers (default: `hibp <version>`) |
615+
616+
**Example**
617+
```js
618+
try {
619+
const data = await stealerLogsByEmail("foo@bar.com", { apiKey: "my-api-key" });
620+
if (data) {
621+
// ...
622+
} else {
623+
// ...
624+
}
625+
} catch (err) {
626+
// ...
627+
}
628+
```
629+
**Example**
630+
```js
631+
try {
632+
const data = await stealerLogsByEmail("foo@bar.com", {
633+
baseUrl: "https://my-hibp-proxy:8080",
634+
});
635+
if (data) {
636+
// ...
637+
} else {
638+
// ...
639+
}
640+
} catch (err) {
641+
// ...
642+
}
643+
```
578644
<a name="subscribedDomains"></a>
579645

580646
## subscribedDomains([options]) ⇒ <code><a href="#subscribeddomain--object">Promise.&lt;Array.&lt;SubscribedDomain&gt;&gt;</a></code>

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,16 @@ The following modules are available:
6666

6767
- [breach](API.md#breach)
6868
- [breachedAccount](API.md#breachedaccount)
69-
- [breaches](API.md#breaches)
7069
- [breachedDomain](API.md#breacheddomain)
70+
- [breaches](API.md#breaches)
7171
- [dataClasses](API.md#dataclasses)
7272
- [latestBreach](API.md#latestbreach)
7373
- [pasteAccount](API.md#pasteaccount)
74-
- [subscribedDomains](API.md#subscribeddomains)
7574
- [pwnedPassword](API.md#pwnedpassword)
7675
- [pwnedPasswordRange](API.md#pwnedpasswordrange)
7776
- [search](API.md#search)
77+
- [stealerLogsByEmail](API.md#stealerlogsbyemail)
78+
- [subscribedDomains](API.md#subscribeddomains)
7879
- [subscriptionStatus](API.md#subscriptionstatus)
7980

8081
Please see the [API reference](API.md) for more detailed usage information and examples.

src/__tests__/hibp.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('hibp', () => {
1616
"pwnedPassword": [Function],
1717
"pwnedPasswordRange": [Function],
1818
"search": [Function],
19+
"stealerLogsByEmail": [Function],
1920
"subscribedDomains": [Function],
2021
"subscriptionStatus": [Function],
2122
}
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 { 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+
});

src/hibp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { pasteAccount } from './paste-account.js';
99
import { pwnedPassword } from './pwned-password.js';
1010
import { pwnedPasswordRange } from './pwned-password-range.js';
1111
import { search } from './search.js';
12+
import { stealerLogsByEmail } from './stealer-logs-by-email.js';
1213
import { subscribedDomains } from './subscribed-domains.js';
1314
import { subscriptionStatus } from './subscription-status.js';
1415

@@ -34,6 +35,7 @@ export {
3435
pwnedPassword,
3536
pwnedPasswordRange,
3637
search,
38+
stealerLogsByEmail,
3739
subscribedDomains,
3840
subscriptionStatus,
3941
RateLimitError,
@@ -51,6 +53,7 @@ export interface HIBP {
5153
pwnedPassword: typeof pwnedPassword;
5254
pwnedPasswordRange: typeof pwnedPasswordRange;
5355
search: typeof search;
56+
stealerLogsByEmail: typeof stealerLogsByEmail;
5457
subscribedDomains: typeof subscribedDomains;
5558
subscriptionStatus: typeof subscriptionStatus;
5659
RateLimitError: typeof RateLimitError;

src/stealer-logs-by-email.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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 {string} [options.userAgent] a custom string to send as the User-Agent
25+
* field in the request headers (default: `hibp <version>`)
26+
* @returns {(Promise<string[]> | Promise<null>)} a Promise which resolves to an
27+
* array of domain strings (or null if none were found), or rejects with an
28+
* Error
29+
* @example
30+
* try {
31+
* const data = await stealerLogsByEmail("foo@bar.com", { apiKey: "my-api-key" });
32+
* if (data) {
33+
* // ...
34+
* } else {
35+
* // ...
36+
* }
37+
* } catch (err) {
38+
* // ...
39+
* }
40+
* @example
41+
* try {
42+
* const data = await stealerLogsByEmail("foo@bar.com", {
43+
* baseUrl: "https://my-hibp-proxy:8080",
44+
* });
45+
* if (data) {
46+
* // ...
47+
* } else {
48+
* // ...
49+
* }
50+
* } catch (err) {
51+
* // ...
52+
* }
53+
*/
54+
export function stealerLogsByEmail(
55+
emailAddress: string,
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+
* a custom string to send as the User-Agent field in the request headers
72+
* (default: `hibp <version>`)
73+
*/
74+
userAgent?: string;
75+
} = {},
76+
): Promise<string[] | null> {
77+
const { apiKey, baseUrl, timeoutMs, userAgent } = options;
78+
const endpoint = `/stealerlogsbyemail/${encodeURIComponent(emailAddress)}`;
79+
80+
return fetchFromApi(endpoint, {
81+
apiKey,
82+
baseUrl,
83+
timeoutMs,
84+
userAgent,
85+
}) as Promise<string[] | null>;
86+
}

0 commit comments

Comments
 (0)