-
Notifications
You must be signed in to change notification settings - Fork 287
feat(abuseipdb): add AbuseIPDB integration plugin #769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MauryaQbit
wants to merge
2
commits into
corsairdev:main
Choose a base branch
from
MauryaQbit:feat/abuseipdb
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,131
−0
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import 'dotenv/config'; | ||
| import { makeAbuseIPDBRequest } from './client'; | ||
| import type { | ||
| CheckBlockResponse, | ||
| CheckIpResponse, | ||
| ClearAddressResponse, | ||
| GetReportsResponse, | ||
| ReportIpResponse, | ||
| } from './endpoints/types'; | ||
| import { AbuseIPDBEndpointOutputSchemas } from './endpoints/types'; | ||
|
|
||
| // Live API tests — skipped unless ABUSEIPDB_API_KEY is set in the | ||
| // environment (see the root .env.example). They hit the real AbuseIPDB API | ||
| // and prove the endpoint output schemas accept the shapes the provider | ||
| // actually returns. | ||
| const ABUSEIPDB_API_KEY = process.env.ABUSEIPDB_API_KEY; | ||
|
|
||
| // The write tests (report, clear-address) mutate a real AbuseIPDB account — | ||
| // `clear-address` deletes every report filed against the test IP — so they | ||
| // additionally require ABUSEIPDB_WRITE_ENABLED=true. Read-only tests only | ||
| // need the API key. | ||
| const ABUSEIPDB_WRITE_ENABLED = process.env.ABUSEIPDB_WRITE_ENABLED === 'true'; | ||
|
|
||
| // A public, stable IP frequently reported to AbuseIPDB (a Tencent Cloud | ||
| // datacenter address) used as the shared fixture across tests. | ||
| const TEST_IP = '118.25.6.39'; | ||
|
|
||
| const describeOrSkip = ABUSEIPDB_API_KEY ? describe : describe.skip; | ||
|
|
||
| describeOrSkip('AbuseIPDB API Type Tests', () => { | ||
| it('check returns correct type', async () => { | ||
| const response = await makeAbuseIPDBRequest<{ data: CheckIpResponse }>( | ||
| 'check', | ||
| ABUSEIPDB_API_KEY!, | ||
| { query: { ipAddress: TEST_IP } }, | ||
| ); | ||
|
|
||
| const parsed = AbuseIPDBEndpointOutputSchemas.checkIp.parse(response.data); | ||
| expect(parsed.ipAddress).toBe(TEST_IP); | ||
| expect(typeof parsed.abuseConfidenceScore).toBe('number'); | ||
| }); | ||
|
|
||
| it('check returns correct type with verbose reports', async () => { | ||
| const response = await makeAbuseIPDBRequest<{ data: CheckIpResponse }>( | ||
| 'check', | ||
| ABUSEIPDB_API_KEY!, | ||
| { query: { ipAddress: TEST_IP, verbose: true } }, | ||
| ); | ||
|
|
||
| const parsed = AbuseIPDBEndpointOutputSchemas.checkIp.parse(response.data); | ||
| expect(parsed.ipAddress).toBe(TEST_IP); | ||
| expect(Array.isArray(parsed.reports)).toBe(true); | ||
| }); | ||
|
|
||
| it('reports returns correct pagination shape', async () => { | ||
| const response = await makeAbuseIPDBRequest<{ | ||
| data: GetReportsResponse; | ||
| }>('reports', ABUSEIPDB_API_KEY!, { | ||
| query: { ipAddress: TEST_IP }, | ||
| }); | ||
|
|
||
| const parsed = AbuseIPDBEndpointOutputSchemas.getReports.parse( | ||
| response.data, | ||
| ); | ||
| expect(Array.isArray(parsed.results)).toBe(true); | ||
| }); | ||
|
|
||
| it('blacklist returns correct type', async () => { | ||
| const response = await makeAbuseIPDBRequest<{ | ||
| meta: { generatedAt: string }; | ||
| data: Array<{ | ||
| ipAddress: string; | ||
| abuseConfidenceScore: number; | ||
| lastReportedAt?: string | null; | ||
| countryCode?: string | null; | ||
| }>; | ||
| }>('blacklist', ABUSEIPDB_API_KEY!, { | ||
| query: { confidenceMinimum: 90, limit: 5 }, | ||
| }); | ||
|
|
||
| const parsed = AbuseIPDBEndpointOutputSchemas.getBlacklist.parse({ | ||
| generatedAt: response.meta.generatedAt, | ||
| entries: response.data, | ||
| }); | ||
| expect(parsed.entries.length).toBeGreaterThan(0); | ||
| }); | ||
|
|
||
| it('check-block returns correct type', async () => { | ||
| const response = await makeAbuseIPDBRequest<{ data: CheckBlockResponse }>( | ||
| 'check-block', | ||
| ABUSEIPDB_API_KEY!, | ||
| { query: { network: `${TEST_IP}/24` } }, | ||
| ); | ||
|
|
||
| const parsed = AbuseIPDBEndpointOutputSchemas.checkBlock.parse( | ||
| response.data, | ||
| ); | ||
| expect(parsed.networkAddress).toBeTruthy(); | ||
| expect(Array.isArray(parsed.reportedAddress)).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| // Write operations mutate a real AbuseIPDB account (clear-address deletes | ||
| // every report filed against the IP), so they only run when explicitly | ||
| // opted in via ABUSEIPDB_WRITE_ENABLED=true. | ||
| const describeWriteOrSkip = ABUSEIPDB_WRITE_ENABLED ? describe : describe.skip; | ||
|
|
||
| describeWriteOrSkip('AbuseIPDB API write tests', () => { | ||
| it('report accepts a report for a well-known test IP', async () => { | ||
| const response = await makeAbuseIPDBRequest<{ data: ReportIpResponse }>( | ||
| 'report', | ||
| ABUSEIPDB_API_KEY!, | ||
| { | ||
| method: 'POST', | ||
| formBody: { | ||
| ip: TEST_IP, | ||
| categories: '18,21', | ||
| comment: 'Automated test report from the Corsair plugin test suite', | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| const parsed = AbuseIPDBEndpointOutputSchemas.reportIp.parse(response.data); | ||
| expect(parsed.ipAddress).toBe(TEST_IP); | ||
| }); | ||
|
|
||
| it('clear-address deletes all reports for the IP and returns the count', async () => { | ||
| const response = await makeAbuseIPDBRequest<{ | ||
| data: ClearAddressResponse; | ||
| }>('clear-address', ABUSEIPDB_API_KEY!, { | ||
| method: 'DELETE', | ||
| query: { ipAddress: TEST_IP }, | ||
| }); | ||
|
|
||
| const parsed = AbuseIPDBEndpointOutputSchemas.clearAddress.parse( | ||
| response.data, | ||
| ); | ||
| expect(typeof parsed.numReportsDeleted).toBe('number'); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; | ||
| import { ApiError, request } from 'corsair/http'; | ||
| import { | ||
| ABUSEIPDB_API_BASE, | ||
| AbuseIPDBAPIError, | ||
| makeAbuseIPDBRequest, | ||
| } from './client'; | ||
|
|
||
| jest.mock('corsair/http', () => { | ||
| const actual = jest.requireActual('corsair/http'); | ||
| return { ...actual, request: jest.fn() }; | ||
| }); | ||
|
|
||
| const mockRequest = request as jest.MockedFunction<typeof request>; | ||
|
|
||
| function lastCall(): [OpenAPIConfig, ApiRequestOptions] { | ||
| const call = mockRequest.mock.calls.at(-1); | ||
| if (!call) throw new Error('request() was never called'); | ||
| return call as unknown as [OpenAPIConfig, ApiRequestOptions]; | ||
| } | ||
|
|
||
| function apiError(status: number, retryAfter?: number): ApiError { | ||
| return new ApiError( | ||
| { method: 'GET', url: 'check' }, | ||
| { | ||
| url: `${ABUSEIPDB_API_BASE}/check`, | ||
| ok: false, | ||
| status, | ||
| statusText: 'Error', | ||
| body: { errors: [{ detail: 'failed', status }] }, | ||
| }, | ||
| 'AbuseIPDB request failed', | ||
| { retryAfter }, | ||
| ); | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| mockRequest.mockReset(); | ||
| }); | ||
|
|
||
| describe('makeAbuseIPDBRequest', () => { | ||
| it('sends the API key in the Key header and never as a bearer token', async () => { | ||
| mockRequest.mockResolvedValue({ data: {} }); | ||
|
|
||
| await makeAbuseIPDBRequest('check', 'secret-key', { | ||
| query: { ipAddress: '118.25.6.39' }, | ||
| }); | ||
|
|
||
| const [config] = lastCall(); | ||
| expect(config.BASE).toBe(ABUSEIPDB_API_BASE); | ||
| expect(config.HEADERS).toMatchObject({ Key: 'secret-key' }); | ||
| // AbuseIPDB authenticates via the Key header; the `key` query | ||
| // parameter is also supported but gets logged by the provider, so | ||
| // it is deliberately avoided. | ||
| expect(config.TOKEN).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('issues a GET with the endpoint path and query parameters', async () => { | ||
| mockRequest.mockResolvedValue({ data: {} }); | ||
|
|
||
| await makeAbuseIPDBRequest('check', 'k', { | ||
| query: { ipAddress: '118.25.6.39', verbose: true }, | ||
| }); | ||
|
|
||
| const [, options] = lastCall(); | ||
| expect(options.method).toBe('GET'); | ||
| expect(options.url).toBe('check'); | ||
| expect(options.query).toEqual({ | ||
| ipAddress: '118.25.6.39', | ||
| verbose: true, | ||
| }); | ||
| }); | ||
|
|
||
| it('returns the parsed body on success', async () => { | ||
| mockRequest.mockResolvedValue({ data: { isPublic: true } }); | ||
|
|
||
| const result = await makeAbuseIPDBRequest('check', 'k', { | ||
| query: { ipAddress: '118.25.6.39' }, | ||
| }); | ||
|
|
||
| expect(result).toEqual({ data: { isPublic: true } }); | ||
| }); | ||
|
|
||
| it('POSTs a form-urlencoded body for the report endpoint', async () => { | ||
| mockRequest.mockResolvedValue({ data: {} }); | ||
|
|
||
| await makeAbuseIPDBRequest('report', 'k', { | ||
| method: 'POST', | ||
| formBody: { | ||
| ip: '118.25.6.39', | ||
| categories: '18,21', | ||
| comment: 'SSH brute force', | ||
| }, | ||
| }); | ||
|
|
||
| const [, options] = lastCall(); | ||
| expect(options.method).toBe('POST'); | ||
| expect(options.mediaType).toBe('application/x-www-form-urlencoded'); | ||
| expect(options.body).toBe( | ||
| 'ip=118.25.6.39&categories=18%2C21&comment=SSH+brute+force', | ||
| ); | ||
| }); | ||
|
|
||
| it('skips undefined form fields when serializing', async () => { | ||
| mockRequest.mockResolvedValue({ data: {} }); | ||
|
|
||
| await makeAbuseIPDBRequest('report', 'k', { | ||
| method: 'POST', | ||
| formBody: { | ||
| ip: '118.25.6.39', | ||
| categories: '18', | ||
| comment: undefined, | ||
| timestamp: undefined, | ||
| }, | ||
| }); | ||
|
|
||
| const [, options] = lastCall(); | ||
| expect(options.body).toBe('ip=118.25.6.39&categories=18'); | ||
| }); | ||
|
|
||
| it('supports DELETE requests with query parameters', async () => { | ||
| mockRequest.mockResolvedValue({ data: {} }); | ||
|
|
||
| await makeAbuseIPDBRequest('clear-address', 'k', { | ||
| method: 'DELETE', | ||
| query: { ipAddress: '118.25.6.39' }, | ||
| }); | ||
|
|
||
| const [, options] = lastCall(); | ||
| expect(options.method).toBe('DELETE'); | ||
| expect(options.query).toEqual({ ipAddress: '118.25.6.39' }); | ||
| }); | ||
|
|
||
| it('wraps an ApiError in AbuseIPDBAPIError, preserving status and cause', async () => { | ||
| const original = apiError(429, 1500); | ||
| mockRequest.mockRejectedValue(original); | ||
|
|
||
| try { | ||
| await makeAbuseIPDBRequest('check', 'k'); | ||
| throw new Error('expected makeAbuseIPDBRequest to throw'); | ||
| } catch (error) { | ||
| const abuseError = error as AbuseIPDBAPIError; | ||
| expect(abuseError).toBeInstanceOf(AbuseIPDBAPIError); | ||
| expect(abuseError.status).toBe(429); | ||
| expect(abuseError.code).toBe(429); | ||
| expect(abuseError.retryAfter).toBe(1500); | ||
| expect(abuseError.cause).toBe(original); | ||
| } | ||
| }); | ||
|
|
||
| it('wraps a non-ApiError failure without inventing a status', async () => { | ||
| mockRequest.mockRejectedValue(new Error('socket hang up')); | ||
|
|
||
| try { | ||
| await makeAbuseIPDBRequest('check', 'k'); | ||
| throw new Error('expected makeAbuseIPDBRequest to throw'); | ||
| } catch (error) { | ||
| const abuseError = error as AbuseIPDBAPIError; | ||
| expect(abuseError).toBeInstanceOf(AbuseIPDBAPIError); | ||
| expect(abuseError.message).toBe('socket hang up'); | ||
| expect(abuseError.status).toBeUndefined(); | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Gate the write tests behind a separate opt-in variable.
These two tests mutate production state on a shared, public reputation service:
118.25.6.39under the key owner's account. The report contributes to the public confidence score for a third-party address.clear-address, which deletes all reports the account holds for that IP, not only the report created above. If the key owner has legitimate prior reports for that IP, this test destroys them.Presence of
ABUSEIPDB_API_KEYalone is not enough consent for destructive writes. Require a second explicit variable, and note the data-loss behavior in the test name.🛡️ Proposed gating
Then move the
reportandclear-addresstests into adescribeWriteOrSkipblock.🤖 Prompt for AI Agents