diff --git a/packages/abuseipdb/api.test.ts b/packages/abuseipdb/api.test.ts new file mode 100644 index 000000000..337b43b10 --- /dev/null +++ b/packages/abuseipdb/api.test.ts @@ -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'); + }); +}); diff --git a/packages/abuseipdb/client.test.ts b/packages/abuseipdb/client.test.ts new file mode 100644 index 000000000..cdda5dec6 --- /dev/null +++ b/packages/abuseipdb/client.test.ts @@ -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; + +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(); + } + }); +}); diff --git a/packages/abuseipdb/client.ts b/packages/abuseipdb/client.ts new file mode 100644 index 000000000..d00a4ef76 --- /dev/null +++ b/packages/abuseipdb/client.ts @@ -0,0 +1,141 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +/** + * Error thrown for any non-2xx AbuseIPDB response. Preserves the HTTP status, + * response body, and rate-limit headers from the underlying `ApiError` so + * `error-handlers.ts` can inspect them without re-requesting. + */ +export class AbuseIPDBAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + /** + * The raw response body. Deliberately `unknown` — AbuseIPDB returns + * JSON:API-shaped errors (`{ errors: [{ detail, status, source }] }`) + * that don't map to a single known schema, so callers narrow it + * themselves (see error-handlers.ts). + */ + public readonly body?: unknown; + public readonly retryAfter?: number; + public readonly rateLimitReset?: number; + public readonly rateLimitRemaining?: number; + public readonly rateLimitLimit?: number; + + constructor( + message: string, + public readonly code?: number, + options?: { cause?: Error }, + ) { + super(message, options); + this.name = 'AbuseIPDBAPIError'; + + if (options?.cause instanceof ApiError) { + this.status = options.cause.status; + this.statusText = options.cause.statusText; + this.body = options.cause.body; + this.retryAfter = options.cause.retryAfter; + this.rateLimitReset = options.cause.rateLimitReset; + this.rateLimitRemaining = options.cause.rateLimitRemaining; + this.rateLimitLimit = options.cause.rateLimitLimit; + } + } +} + +// Matches only corsair's "no DEK on this account" error +// (packages/corsair/core/auth/key-manager.ts: `No DEK found for account +// (tenant: "...", integration: "...")`). No dedicated error class exists +// for this state, so message matching is the only handle available; kept +// narrow on purpose so it can't accidentally swallow an unrelated failure. +const NO_DEK_ERROR_PATTERN = /no dek found/i; + +/** + * Safely reads the stored API key from the account key manager. + * + * `ctx.keys.get_api_key()` throws (rather than returning null) when the + * account has no DEK at all — a fully valid state for accounts that only + * ever configure the key via plugin options and never touch the key + * manager, and must resolve to "no stored key" rather than abort the + * request. + * + * Anything else thrown (decryption failure, database error, ...) is a real + * operational problem, not an absent key, and must propagate. + */ +export async function tryGetStoredKey( + getter: () => Promise, +): Promise { + try { + const value = await getter(); + return value ?? undefined; + } catch (error) { + if (error instanceof Error && NO_DEK_ERROR_PATTERN.test(error.message)) { + return undefined; + } + throw error; + } +} + +/** + * AbuseIPDB API v2 base URL. All endpoints live under `/api/v2`. + */ +export const ABUSEIPDB_API_BASE = 'https://api.abuseipdb.com/api/v2'; + +/** + * Performs a request against the AbuseIPDB API v2. + * + * Auth: the API key is sent in the `Key` header (the recommended method — + * AbuseIPDB logs the query string, so the `key` query parameter is avoided). + * + * GET endpoints take query parameters; the REPORT endpoint expects + * `application/x-www-form-urlencoded` form fields, which are passed as + * `formBody` and serialized with `URLSearchParams`. + */ +export async function makeAbuseIPDBRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'DELETE'; + query?: Record; + formBody?: Record; + } = {}, +): Promise { + const { method = 'GET', query, formBody } = options; + + const config: OpenAPIConfig = { + BASE: ABUSEIPDB_API_BASE, + VERSION: '2.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + Key: apiKey, + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + query, + body: formBody + ? new URLSearchParams( + Object.entries(formBody) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, String(value)] as [string, string]), + ).toString() + : undefined, + mediaType: formBody ? 'application/x-www-form-urlencoded' : undefined, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + if (error instanceof ApiError) { + throw new AbuseIPDBAPIError(error.message, error.status, { + cause: error, + }); + } + if (error instanceof Error) { + throw new AbuseIPDBAPIError(error.message, undefined, { cause: error }); + } + throw new AbuseIPDBAPIError('Unknown error'); + } +} diff --git a/packages/abuseipdb/endpoints/blacklist.ts b/packages/abuseipdb/endpoints/blacklist.ts new file mode 100644 index 000000000..dd5c3aa36 --- /dev/null +++ b/packages/abuseipdb/endpoints/blacklist.ts @@ -0,0 +1,58 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { z } from 'zod'; +import { makeAbuseIPDBRequest } from '../client'; +import type { AbuseIPDBEndpoints } from '../index'; +import { GetBlacklistResponseSchema } from './types'; + +/** + * Download the blacklist of most-reported IPs, optionally filtered by + * confidence minimum, country, and IP version. + * + * API: GET /api/v2/blacklist + * Docs: https://docs.abuseipdb.com/#blacklist-endpoint + */ +export const get: AbuseIPDBEndpoints['getBlacklist'] = async (ctx, input) => { + if (!ctx.key) { + throw new AuthMissingError('abuseipdb', 'api_key'); + } + + const response = await makeAbuseIPDBRequest<{ + meta: { generatedAt: string }; + data: Array<{ + ipAddress: string; + abuseConfidenceScore: number; + lastReportedAt?: string | null; + countryCode?: string | null; + }>; + }>('blacklist', ctx.key, { + query: { + confidenceMinimum: input.confidenceMinimum, + limit: input.limit, + onlyCountries: input.onlyCountries?.join(','), + exceptCountries: input.exceptCountries?.join(','), + ipVersion: input.ipVersion, + }, + }); + + // The API wraps entries in `data` and the generation timestamp in `meta`. + // Validate the meta envelope and the whole flattened result so a missing + // `meta` surfaces as a schema error instead of a raw property-access + // crash on `response.meta.generatedAt`. + const generatedAt = z.string().parse(response.meta?.generatedAt); + const blacklistResult = GetBlacklistResponseSchema.parse({ + generatedAt, + entries: response.data, + }); + + await logEventFromContext( + ctx, + 'abuseipdb.blacklist.get', + { + confidenceMinimum: input.confidenceMinimum ?? 100, + limit: input.limit, + }, + 'completed', + ); + + return blacklistResult; +}; diff --git a/packages/abuseipdb/endpoints/check-block.ts b/packages/abuseipdb/endpoints/check-block.ts new file mode 100644 index 000000000..4dc948e7d --- /dev/null +++ b/packages/abuseipdb/endpoints/check-block.ts @@ -0,0 +1,38 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { makeAbuseIPDBRequest } from '../client'; +import type { AbuseIPDBEndpoints } from '../index'; +import type { CheckBlockResponse } from './types'; +import { CheckBlockResponseSchema } from './types'; + +/** + * Check a CIDR network block and list the addresses within it that have + * been reported to AbuseIPDB. + * + * API: GET /api/v2/check-block + * Docs: https://docs.abuseipdb.com/#check-block-endpoint + */ +export const check: AbuseIPDBEndpoints['checkBlock'] = async (ctx, input) => { + if (!ctx.key) { + throw new AuthMissingError('abuseipdb', 'api_key'); + } + + const response = await makeAbuseIPDBRequest<{ + data: CheckBlockResponse; + }>('check-block', ctx.key, { + query: { + network: input.network, + maxAgeInDays: input.maxAgeInDays, + }, + }); + + const blockResult = CheckBlockResponseSchema.parse(response.data); + + await logEventFromContext( + ctx, + 'abuseipdb.block.check', + { network: input.network }, + 'completed', + ); + + return blockResult; +}; diff --git a/packages/abuseipdb/endpoints/check.ts b/packages/abuseipdb/endpoints/check.ts new file mode 100644 index 000000000..c1bf86d21 --- /dev/null +++ b/packages/abuseipdb/endpoints/check.ts @@ -0,0 +1,65 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { makeAbuseIPDBRequest } from '../client'; +import type { AbuseIPDBEndpoints } from '../index'; +import type { CheckIpResponse } from './types'; +import { CheckIpResponseSchema } from './types'; + +/** + * Look up an IP address and get its abuse confidence score, country, ISP, + * usage type, and optionally recent reports (verbose). + * + * API: GET /api/v2/check + * Docs: https://docs.abuseipdb.com/#check-endpoint + */ +export const check: AbuseIPDBEndpoints['checkIp'] = async (ctx, input) => { + if (!ctx.key) { + throw new AuthMissingError('abuseipdb', 'api_key'); + } + + const response = await makeAbuseIPDBRequest<{ + data: CheckIpResponse; + }>('check', ctx.key, { + query: { + ipAddress: input.ipAddress, + maxAgeInDays: input.maxAgeInDays, + // AbuseIPDB's `verbose` is a flag-style param — official clients + // send it as an empty value (`-d verbose`), not `verbose=true`. + verbose: input.verbose ? '' : undefined, + }, + }); + + // The API wraps results in a `data` envelope; validate the inner shape at + // runtime before returning it. + const checkResult = CheckIpResponseSchema.parse(response.data); + + if (ctx.db.ipChecks) { + try { + await ctx.db.ipChecks.upsertByEntityId(checkResult.ipAddress, { + ipAddress: checkResult.ipAddress, + abuseConfidenceScore: checkResult.abuseConfidenceScore, + isPublic: checkResult.isPublic, + ipVersion: checkResult.ipVersion, + countryCode: checkResult.countryCode ?? null, + countryName: checkResult.countryName ?? null, + usageType: checkResult.usageType ?? null, + isp: checkResult.isp ?? null, + domain: checkResult.domain ?? null, + isTor: checkResult.isTor, + totalReports: checkResult.totalReports, + numDistinctUsers: checkResult.numDistinctUsers, + checkedAt: new Date(), + }); + } catch (error) { + console.warn('Failed to save IP check result to database:', error); + } + } + + await logEventFromContext( + ctx, + 'abuseipdb.check.ip', + { ipAddress: input.ipAddress }, + 'completed', + ); + + return checkResult; +}; diff --git a/packages/abuseipdb/endpoints/clear-address.ts b/packages/abuseipdb/endpoints/clear-address.ts new file mode 100644 index 000000000..b29819289 --- /dev/null +++ b/packages/abuseipdb/endpoints/clear-address.ts @@ -0,0 +1,38 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { makeAbuseIPDBRequest } from '../client'; +import type { AbuseIPDBEndpoints } from '../index'; +import type { ClearAddressResponse } from './types'; +import { ClearAddressResponseSchema } from './types'; + +/** + * Remove all reports for an IP address from your account, and return the + * number of reports that were deleted. + * + * API: DELETE /api/v2/clear-address + * Docs: https://docs.abuseipdb.com/#clear-address-endpoint + */ +export const clear: AbuseIPDBEndpoints['clearAddress'] = async (ctx, input) => { + if (!ctx.key) { + throw new AuthMissingError('abuseipdb', 'api_key'); + } + + const response = await makeAbuseIPDBRequest<{ + data: ClearAddressResponse; + }>('clear-address', ctx.key, { + method: 'DELETE', + query: { + ipAddress: input.ipAddress, + }, + }); + + const clearResult = ClearAddressResponseSchema.parse(response.data); + + await logEventFromContext( + ctx, + 'abuseipdb.address.clear', + { ipAddress: input.ipAddress }, + 'completed', + ); + + return clearResult; +}; diff --git a/packages/abuseipdb/endpoints/index.ts b/packages/abuseipdb/endpoints/index.ts new file mode 100644 index 000000000..112d3f648 --- /dev/null +++ b/packages/abuseipdb/endpoints/index.ts @@ -0,0 +1,32 @@ +import { get as blacklistGet } from './blacklist'; +import { check as ipCheck } from './check'; +import { check as blockCheck } from './check-block'; +import { clear as addressClear } from './clear-address'; +import { report as ipReport } from './report'; +import { list as reportsList } from './reports'; + +export const CheckIp = { + check: ipCheck, +}; + +export const Reports = { + list: reportsList, +}; + +export const Blacklist = { + get: blacklistGet, +}; + +export const ReportIp = { + report: ipReport, +}; + +export const CheckBlock = { + check: blockCheck, +}; + +export const ClearAddress = { + clear: addressClear, +}; + +export * from './types'; diff --git a/packages/abuseipdb/endpoints/output-validation.test.ts b/packages/abuseipdb/endpoints/output-validation.test.ts new file mode 100644 index 000000000..c2041ac86 --- /dev/null +++ b/packages/abuseipdb/endpoints/output-validation.test.ts @@ -0,0 +1,209 @@ +import { + CheckBlockResponseSchema, + CheckIpResponseSchema, + ClearAddressResponseSchema, + GetBlacklistResponseSchema, + GetReportsResponseSchema, + ReportIpResponseSchema, +} from './types'; + +// Endpoints call these schemas on the raw provider response at runtime +// (see the `.parse(response.data)` calls in endpoints/*.ts) — this proves +// that call has real teeth: a shape AbuseIPDB's API was never observed to +// return gets rejected instead of silently trusted. +describe('runtime output validation rejects malformed provider responses', () => { + it('accepts a real check response (the documented data envelope)', () => { + const real = { + data: { + ipAddress: '118.25.6.39', + isPublic: true, + ipVersion: 4, + isWhitelisted: false, + abuseConfidenceScore: 100, + countryCode: 'CN', + countryName: 'China', + usageType: 'Data Center/Web Hosting/Transit', + isp: 'Tencent Cloud Computing (Beijing) Co. Ltd.', + domain: 'tencent.com', + hostnames: ['118.25.6.39'], + isTor: false, + totalReports: 100, + numDistinctUsers: 87, + lastReportedAt: '2024-03-22T10:09:09+00:00', + }, + }; + + expect(() => CheckIpResponseSchema.parse(real.data)).not.toThrow(); + }); + + it('accepts a verbose check response with reports', () => { + const verbose = { + data: { + ipAddress: '118.25.6.39', + isPublic: true, + ipVersion: 4, + isWhitelisted: false, + abuseConfidenceScore: 100, + countryCode: 'CN', + countryName: 'China', + usageType: 'Data Center/Web Hosting/Transit', + isp: 'Tencent Cloud Computing (Beijing) Co. Ltd.', + domain: 'tencent.com', + hostnames: [], + isTor: false, + totalReports: 100, + numDistinctUsers: 87, + lastReportedAt: '2024-03-22T10:09:09+00:00', + reports: [ + { + reportedAt: '2024-03-22T10:09:09+00:00', + comment: 'SSH brute force', + categories: [18, 21], + reporterId: 12345, + reporterCountryCode: 'US', + reporterCountryName: 'United States', + }, + ], + }, + }; + + expect(() => CheckIpResponseSchema.parse(verbose.data)).not.toThrow(); + }); + + it('rejects a check response missing required fields', () => { + const malformed = { + ipAddress: '118.25.6.39', + // isPublic, abuseConfidenceScore, hostnames, ... missing — an + // error page or a differently-shaped response would look like this. + }; + + expect(() => CheckIpResponseSchema.parse(malformed)).toThrow(); + }); + + it('accepts a real reports (pagination) response', () => { + const real = { + data: { + total: 1, + page: 1, + count: 1, + perPage: 25, + lastPage: 1, + nextPageUrl: null, + previousPageUrl: null, + results: [ + { + reportedAt: '2024-03-22T10:09:09+00:00', + comment: null, + categories: [18, 21], + reporterId: 12345, + reporterCountryCode: 'US', + reporterCountryName: 'United States', + }, + ], + }, + }; + + expect(() => GetReportsResponseSchema.parse(real.data)).not.toThrow(); + }); + + it('rejects a reports response with the wrong shape', () => { + const wrongShape = { + data: { + reports: [{ comment: 'x', categories: [18] }], + }, + }; + + expect(() => GetReportsResponseSchema.parse(wrongShape.data)).toThrow(); + }); + + it('accepts the flattened blacklist shape the endpoint returns', () => { + const real = { + generatedAt: '2024-03-22T00:00:00+00:00', + entries: [ + { + ipAddress: '118.25.6.39', + abuseConfidenceScore: 100, + lastReportedAt: '2024-03-22T10:09:09+00:00', + countryCode: 'CN', + }, + ], + }; + + expect(() => GetBlacklistResponseSchema.parse(real)).not.toThrow(); + }); + + it('rejects a blacklist response missing the generation timestamp', () => { + const wrongShape = { + entries: [{ ipAddress: '118.25.6.39', abuseConfidenceScore: 100 }], + }; + + expect(() => GetBlacklistResponseSchema.parse(wrongShape)).toThrow(); + }); + + it('accepts a real report response', () => { + const real = { + data: { + ipAddress: '118.25.6.39', + abuseConfidenceScore: 100, + }, + }; + + expect(() => ReportIpResponseSchema.parse(real.data)).not.toThrow(); + }); + + it('rejects a report response missing the confidence score', () => { + const wrongShape = { + data: { ipAddress: '118.25.6.39' }, + }; + + expect(() => ReportIpResponseSchema.parse(wrongShape.data)).toThrow(); + }); + + it('accepts a real check-block response', () => { + const real = { + data: { + networkAddress: '118.25.6.39', + netmask: '255.255.255.0', + minAddress: '118.25.6.0', + maxAddress: '118.25.6.255', + numPossibleHosts: 256, + addressSpaceDesc: 'Private Use IPs', + reportedAddress: [ + { + ipAddress: '118.25.6.39', + numReports: 100, + mostRecentReport: '2024-03-22T10:09:09+00:00', + abuseConfidenceScore: 100, + countryCode: 'CN', + }, + ], + }, + }; + + expect(() => CheckBlockResponseSchema.parse(real.data)).not.toThrow(); + }); + + it('accepts a real clear-address response', () => { + const real = { + data: { numReportsDeleted: 4 }, + }; + + expect(() => ClearAddressResponseSchema.parse(real.data)).not.toThrow(); + }); + + it('rejects a clear-address response with the wrong field', () => { + const wrongShape = { + data: { deleted: 4 }, + }; + + expect(() => ClearAddressResponseSchema.parse(wrongShape.data)).toThrow(); + }); + + it('rejects a check-block response missing the reported addresses', () => { + const wrongShape = { + data: { networkAddress: '118.25.6.39', netmask: '255.255.255.0' }, + }; + + expect(() => CheckBlockResponseSchema.parse(wrongShape.data)).toThrow(); + }); +}); diff --git a/packages/abuseipdb/endpoints/report.ts b/packages/abuseipdb/endpoints/report.ts new file mode 100644 index 000000000..bc26e2610 --- /dev/null +++ b/packages/abuseipdb/endpoints/report.ts @@ -0,0 +1,48 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { makeAbuseIPDBRequest } from '../client'; +import type { AbuseIPDBEndpoints } from '../index'; +import type { ReportIpResponse } from './types'; +import { ReportIpResponseSchema } from './types'; + +/** + * Submit an abuse report for an IP address. + * + * The `categories` field accepts numeric category IDs (integers 1–30) — + * see https://www.abuseipdb.com/categories for the mapping. + * + * API: POST /api/v2/report + * Docs: https://docs.abuseipdb.com/#report-endpoint + */ +export const report: AbuseIPDBEndpoints['reportIp'] = async (ctx, input) => { + if (!ctx.key) { + throw new AuthMissingError('abuseipdb', 'api_key'); + } + + // POST /report expects `application/x-www-form-urlencoded` form fields, + // not JSON or query params — pass them as formBody. + const response = await makeAbuseIPDBRequest<{ + data: ReportIpResponse; + }>('report', ctx.key, { + method: 'POST', + formBody: { + ip: input.ip, + categories: input.categories.join(','), + comment: input.comment, + timestamp: input.timestamp, + }, + }); + + const reportResult = ReportIpResponseSchema.parse(response.data); + + await logEventFromContext( + ctx, + 'abuseipdb.report.ip', + { + ip: input.ip, + categories: input.categories.join(','), + }, + 'completed', + ); + + return reportResult; +}; diff --git a/packages/abuseipdb/endpoints/reports.ts b/packages/abuseipdb/endpoints/reports.ts new file mode 100644 index 000000000..6c8fd780d --- /dev/null +++ b/packages/abuseipdb/endpoints/reports.ts @@ -0,0 +1,39 @@ +import { AuthMissingError, logEventFromContext } from 'corsair/core'; +import { makeAbuseIPDBRequest } from '../client'; +import type { AbuseIPDBEndpoints } from '../index'; +import type { GetReportsResponse } from './types'; +import { GetReportsResponseSchema } from './types'; + +/** + * Get a paginated list of abuse reports filed against a single IP address. + * + * API: GET /api/v2/reports + * Docs: https://docs.abuseipdb.com/#reports-endpoint + */ +export const list: AbuseIPDBEndpoints['getReports'] = async (ctx, input) => { + if (!ctx.key) { + throw new AuthMissingError('abuseipdb', 'api_key'); + } + + const response = await makeAbuseIPDBRequest<{ + data: GetReportsResponse; + }>('reports', ctx.key, { + query: { + ipAddress: input.ipAddress, + maxAgeInDays: input.maxAgeInDays, + page: input.page, + perPage: input.perPage, + }, + }); + + const reportsResult = GetReportsResponseSchema.parse(response.data); + + await logEventFromContext( + ctx, + 'abuseipdb.reports.list', + { ipAddress: input.ipAddress, page: input.page ?? 1 }, + 'completed', + ); + + return reportsResult; +}; diff --git a/packages/abuseipdb/endpoints/types.ts b/packages/abuseipdb/endpoints/types.ts new file mode 100644 index 000000000..e4cf22225 --- /dev/null +++ b/packages/abuseipdb/endpoints/types.ts @@ -0,0 +1,323 @@ +import { z } from 'zod'; + +// Any IPv4 or IPv6 address. +const IpAddressSchema = z.union([z.ipv4(), z.ipv6()]); + +// ───────────────────────────────────────────────────────────────────────────── +// CHECK — GET /api/v2/check +// Look up an IP address and get its abuse confidence score, country, ISP, +// usage type, and optionally recent reports (verbose). +// Docs: https://docs.abuseipdb.com/#check-endpoint +// ───────────────────────────────────────────────────────────────────────────── + +export const CheckIpInputSchema = z.object({ + /** IPv4 or IPv6 address to look up, e.g. "118.25.6.39" */ + ipAddress: IpAddressSchema.describe('IPv4 or IPv6 address to check'), + /** Only consider reports from the last N days (1–365, default 30) */ + maxAgeInDays: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe('Only consider reports from the last N days (1–365)'), + /** Include the full reports array and country name in the response */ + verbose: z.boolean().optional().describe('Include the full reports array'), +}); + +export type CheckIpInput = z.infer; + +const CheckReportSchema = z + .object({ + reportedAt: z.string(), + comment: z.string().nullable().optional(), + categories: z.array(z.number()), + reporterId: z.number(), + reporterCountryCode: z.string().nullable().optional(), + reporterCountryName: z.string().nullable().optional(), + }) + .loose(); + +export const CheckIpResponseSchema = z + .object({ + ipAddress: z.string(), + isPublic: z.boolean(), + ipVersion: z.number(), + isWhitelisted: z.boolean().nullable(), + abuseConfidenceScore: z.number(), + countryCode: z.string().nullable().optional(), + countryName: z.string().nullable().optional(), + usageType: z.string().nullable().optional(), + isp: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + hostnames: z.array(z.string()), + isTor: z.boolean(), + totalReports: z.number(), + numDistinctUsers: z.number(), + lastReportedAt: z.string().nullable().optional(), + reports: z.array(CheckReportSchema).optional(), + }) + .loose(); + +export type CheckIpResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// REPORTS — GET /api/v2/reports +// Paginated list of reports filed against a single IP address. +// Docs: https://docs.abuseipdb.com/#reports-endpoint +// ───────────────────────────────────────────────────────────────────────────── + +export const GetReportsInputSchema = z.object({ + /** IPv4 or IPv6 address to fetch reports for */ + ipAddress: IpAddressSchema.describe( + 'IPv4 or IPv6 address to fetch reports for', + ), + /** Only consider reports from the last N days (1–365, default 30) */ + maxAgeInDays: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe('Only consider reports from the last N days (1–365)'), + /** Page number (starts at 1, default 1) */ + page: z + .number() + .int() + .min(1) + .optional() + .describe('Page number (starts at 1)'), + /** Reports per page (1–100, default 25) */ + perPage: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Reports per page (1–100)'), +}); + +export type GetReportsInput = z.infer; + +const ReportsItemSchema = CheckReportSchema; + +export const GetReportsResponseSchema = z + .object({ + total: z.number(), + page: z.number(), + count: z.number(), + perPage: z.number(), + lastPage: z.number(), + nextPageUrl: z.string().nullable().optional(), + previousPageUrl: z.string().nullable().optional(), + results: z.array(ReportsItemSchema), + }) + .loose(); + +export type GetReportsResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// BLACKLIST — GET /api/v2/blacklist +// Download the blacklist of most-reported IPs, optionally filtered by +// confidence minimum, country, and IP version. +// Docs: https://docs.abuseipdb.com/#blacklist-endpoint +// ───────────────────────────────────────────────────────────────────────────── + +export const GetBlacklistInputSchema = z.object({ + /** Minimum abuse confidence score (25–100, default 100) */ + confidenceMinimum: z + .number() + .int() + .min(25) + .max(100) + .optional() + .describe('Minimum abuse confidence score (25–100)'), + /** Maximum number of entries to return (1–500000, default 10000) */ + limit: z + .number() + .int() + .min(1) + .max(500000) + .optional() + .describe('Maximum number of entries to return'), + /** Only include IPs from these ISO 3166 alpha-2 country codes */ + onlyCountries: z + .array(z.string().regex(/^[A-Za-z]{2}$/)) + .optional() + .describe('Only include IPs from these ISO 3166 alpha-2 country codes'), + /** Exclude IPs from these ISO 3166 alpha-2 country codes */ + exceptCountries: z + .array(z.string().regex(/^[A-Za-z]{2}$/)) + .optional() + .describe('Exclude IPs from these ISO 3166 alpha-2 country codes'), + /** Restrict to a single IP version (4 or 6) */ + ipVersion: z + .union([z.literal(4), z.literal(6)]) + .optional() + .describe('Restrict to a single IP version (4 or 6)'), +}); + +export type GetBlacklistInput = z.infer; + +const BlacklistEntrySchema = z + .object({ + ipAddress: z.string(), + abuseConfidenceScore: z.number(), + lastReportedAt: z.string().nullable().optional(), + countryCode: z.string().nullable().optional(), + }) + .loose(); + +export const GetBlacklistResponseSchema = z.object({ + generatedAt: z.string(), + entries: z.array(BlacklistEntrySchema), +}); + +export type GetBlacklistResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// REPORT — POST /api/v2/report +// Submit an abuse report for an IP address. +// Docs: https://docs.abuseipdb.com/#report-endpoint +// ───────────────────────────────────────────────────────────────────────────── + +export const ReportIpInputSchema = z.object({ + /** IPv4 or IPv6 address being reported */ + ip: IpAddressSchema.describe('IPv4 or IPv6 address being reported'), + /** Abuse category IDs (integers 1–30, at least one required) */ + categories: z + .array(z.number().int().min(1).max(30)) + .min(1) + .describe('Abuse category IDs (integers 1–30)'), + /** Descriptive text of the attack (server logs, port numbers, etc.) */ + comment: z + .string() + .optional() + .describe('Descriptive text of the attack; avoid any PII'), + /** ISO 8601 datetime of the attack, defaults to now */ + timestamp: z + .string() + .optional() + .describe('ISO 8601 datetime of the attack, defaults to now'), +}); + +export type ReportIpInput = z.infer; + +export const ReportIpResponseSchema = z + .object({ + ipAddress: z.string(), + abuseConfidenceScore: z.number(), + }) + .loose(); + +export type ReportIpResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// CHECK-BLOCK — GET /api/v2/check-block +// Check a CIDR network block for reported addresses. +// Docs: https://docs.abuseipdb.com/#check-block-endpoint +// ───────────────────────────────────────────────────────────────────────────── + +export const CheckBlockInputSchema = z.object({ + /** CIDR notation network block, e.g. "127.0.0.1/24" or an IPv6 prefix */ + network: z + .union([z.cidrv4(), z.cidrv6()]) + .describe('CIDR notation network block, e.g. "127.0.0.1/24"'), + /** Only consider reports from the last N days (1–365, default 30) */ + maxAgeInDays: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe('Only consider reports from the last N days (1–365)'), +}); + +export type CheckBlockInput = z.infer; + +const ReportedAddressSchema = z + .object({ + ipAddress: z.string(), + numReports: z.number(), + mostRecentReport: z.string().nullable().optional(), + abuseConfidenceScore: z.number(), + countryCode: z.string().nullable().optional(), + }) + .loose(); + +export const CheckBlockResponseSchema = z + .object({ + networkAddress: z.string(), + netmask: z.string(), + minAddress: z.string(), + maxAddress: z.string(), + numPossibleHosts: z.number(), + addressSpaceDesc: z.string().nullable().optional(), + reportedAddress: z.array(ReportedAddressSchema), + }) + .loose(); + +export type CheckBlockResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// CLEAR-ADDRESS — DELETE /api/v2/clear-address +// Remove reports for an IP address from your account. +// Docs: https://docs.abuseipdb.com/#clear-address-endpoint +// ───────────────────────────────────────────────────────────────────────────── + +export const ClearAddressInputSchema = z.object({ + /** IPv4 or IPv6 address to clear reports for from your account */ + ipAddress: IpAddressSchema.describe( + 'IPv4 or IPv6 address to clear reports for', + ), +}); + +export type ClearAddressInput = z.infer; + +export const ClearAddressResponseSchema = z + .object({ + numReportsDeleted: z.number(), + }) + .loose(); + +export type ClearAddressResponse = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Endpoint Input / Output Maps +// ───────────────────────────────────────────────────────────────────────────── + +export type AbuseIPDBEndpointInputs = { + checkIp: CheckIpInput; + getReports: GetReportsInput; + getBlacklist: GetBlacklistInput; + reportIp: ReportIpInput; + checkBlock: CheckBlockInput; + clearAddress: ClearAddressInput; +}; + +export type AbuseIPDBEndpointOutputs = { + checkIp: CheckIpResponse; + getReports: GetReportsResponse; + getBlacklist: GetBlacklistResponse; + reportIp: ReportIpResponse; + checkBlock: CheckBlockResponse; + clearAddress: ClearAddressResponse; +}; + +export const AbuseIPDBEndpointInputSchemas = { + checkIp: CheckIpInputSchema, + getReports: GetReportsInputSchema, + getBlacklist: GetBlacklistInputSchema, + reportIp: ReportIpInputSchema, + checkBlock: CheckBlockInputSchema, + clearAddress: ClearAddressInputSchema, +} as const; + +export const AbuseIPDBEndpointOutputSchemas = { + checkIp: CheckIpResponseSchema, + getReports: GetReportsResponseSchema, + getBlacklist: GetBlacklistResponseSchema, + reportIp: ReportIpResponseSchema, + checkBlock: CheckBlockResponseSchema, + clearAddress: ClearAddressResponseSchema, +} as const; diff --git a/packages/abuseipdb/error-handlers.test.ts b/packages/abuseipdb/error-handlers.test.ts new file mode 100644 index 000000000..e280d756e --- /dev/null +++ b/packages/abuseipdb/error-handlers.test.ts @@ -0,0 +1,109 @@ +import { AbuseIPDBAPIError } from './client'; +import { errorHandlers } from './error-handlers'; + +function apiErrorWithBody( + status: number, + body: { + errors?: Array<{ detail?: string; status?: number; source?: unknown }>; + }, +): AbuseIPDBAPIError { + const error = new AbuseIPDBAPIError('placeholder', status); + Object.assign(error, { status, body }); + return error; +} + +function matchedHandlerName(error: Error): string { + const name = Object.keys(errorHandlers).find((key) => + errorHandlers[key as keyof typeof errorHandlers].match(error), + ); + if (!name) throw new Error('no handler matched'); + return name; +} + +describe('errorHandlers', () => { + it('classifies a 429 as RATE_LIMIT_ERROR', () => { + const error = apiErrorWithBody(429, {}); + expect(matchedHandlerName(error)).toBe('RATE_LIMIT_ERROR'); + }); + + it('exposes the Retry-After header for rate-limit errors', async () => { + const error = apiErrorWithBody(429, {}); + Object.assign(error, { retryAfter: 60_000 }); + + const handler = errorHandlers.RATE_LIMIT_ERROR?.handler as + | (( + error: Error, + context: { + pluginId: string; + operation: string; + input: Record; + originalError: Error; + }, + ) => Promise<{ + maxRetries?: number; + retryStrategy?: string; + headersRetryAfterMs?: number; + }>) + | undefined; + const result = await handler?.(error, { + pluginId: 'abuseipdb', + operation: 'check.ip', + input: { ipAddress: '1.1.1.1' }, + originalError: error, + }); + expect(result).toEqual({ + maxRetries: 3, + retryStrategy: 'exponential_backoff', + headersRetryAfterMs: 60_000, + }); + }); + + it('classifies a 401 as AUTH_ERROR', () => { + const error = apiErrorWithBody(401, { + errors: [{ detail: 'Invalid API key', status: 401 }], + }); + expect(matchedHandlerName(error)).toBe('AUTH_ERROR'); + }); + + it('classifies a 422 as VALIDATION_ERROR', () => { + const error = apiErrorWithBody(422, { + errors: [ + { + detail: 'ipAddress is a required field', + status: 422, + source: { parameter: 'ipAddress' }, + }, + ], + }); + expect(matchedHandlerName(error)).toBe('VALIDATION_ERROR'); + }); + + it('classifies a 402 as PAYMENT_REQUIRED_ERROR', () => { + const error = apiErrorWithBody(402, { + errors: [ + { + detail: 'This plan tier does not support blocks larger than /24', + status: 402, + }, + ], + }); + expect(matchedHandlerName(error)).toBe('PAYMENT_REQUIRED_ERROR'); + }); + + it('classifies a 5xx as SERVER_ERROR', () => { + const error = apiErrorWithBody(500, { + errors: [{ detail: 'Internal Server Error', status: 500 }], + }); + expect(matchedHandlerName(error)).toBe('SERVER_ERROR'); + }); + + it('falls through to DEFAULT for anything else', () => { + const error = apiErrorWithBody(418, {}); + expect(matchedHandlerName(error)).toBe('DEFAULT'); + }); + + it('treats a raw message about rate limiting as RATE_LIMIT_ERROR', () => { + const error = new AbuseIPDBAPIError('Rate limit exceeded', 429); + expect(matchedHandlerName(error)).toBe('RATE_LIMIT_ERROR'); + }); +}); diff --git a/packages/abuseipdb/error-handlers.ts b/packages/abuseipdb/error-handlers.ts new file mode 100644 index 000000000..c9c18c7ad --- /dev/null +++ b/packages/abuseipdb/error-handlers.ts @@ -0,0 +1,111 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import type { AbuseIPDBAPIError } from './client'; + +/** + * Helper to extract the HTTP status from an error. + * Works with AbuseIPDBAPIError (which copies status from ApiError) + * and any error that exposes a numeric `status` property. + */ +function getStatus(error: Error): number | undefined { + return (error as Partial).status; +} + +/** + * Helper to extract the Retry-After value (in ms) from an error. + */ +function getRetryAfter(error: Error): number | undefined { + return (error as Partial).retryAfter; +} + +/** + * Error handlers for the AbuseIPDB plugin. + * + * AbuseIPDB returns reliable HTTP status codes: + * - 401: invalid, missing, or unauthorized API key + * - 402: plan tier limit exceeded (e.g. check-block network too large) + * - 422: malformed/out-of-range parameter (e.g. maxAgeInDays > 365) + * - 429: daily per-endpoint rate limit exceeded (Retry-After header) + * - 5xx: internal server error + */ +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('429') || msg.includes('rate limit'); + }, + handler: async (error: Error) => { + return { + maxRetries: 3, + retryStrategy: 'exponential_backoff' as const, + headersRetryAfterMs: getRetryAfter(error), + }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 401) return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes('unauthorized') || + msg.includes('invalid api key') || + msg.includes('401') + ); + }, + handler: async () => { + console.warn( + '[ABUSEIPDB] Authentication failed — check that the API key is valid ' + + 'and active on your AbuseIPDB account.', + ); + return { maxRetries: 0 }; + }, + }, + PAYMENT_REQUIRED_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 402) return true; + const msg = error.message.toLowerCase(); + return msg.includes('402') || msg.includes('payment required'); + }, + handler: async () => { + console.warn( + '[ABUSEIPDB] Request rejected — the parameter exceeds your current ' + + 'plan tier (e.g. check-block network too large). Upgrade the plan ' + + 'or narrow the request.', + ); + return { maxRetries: 0 }; + }, + }, + VALIDATION_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 422) return true; + const msg = error.message.toLowerCase(); + return msg.includes('422') || msg.includes('unprocessable'); + }, + handler: async () => { + console.warn( + '[ABUSEIPDB] Request rejected — a parameter is missing, malformed, ' + + 'or out of range (see the error detail for the offending field).', + ); + return { maxRetries: 0 }; + }, + }, + SERVER_ERROR: { + match: (error: Error) => { + const status = getStatus(error); + if (status !== undefined && status >= 500) return true; + const msg = error.message.toLowerCase(); + return msg.includes('500') || msg.includes('internal server error'); + }, + handler: async () => ({ + maxRetries: 2, + retryStrategy: 'exponential_backoff' as const, + }), + }, + DEFAULT: { + match: () => true, + handler: async (error: Error) => { + console.error(`[ABUSEIPDB] Unhandled error: ${error.message}`); + return { maxRetries: 0 }; + }, + }, +} satisfies CorsairErrorHandler; diff --git a/packages/abuseipdb/index.ts b/packages/abuseipdb/index.ts new file mode 100644 index 000000000..154b4d1d9 --- /dev/null +++ b/packages/abuseipdb/index.ts @@ -0,0 +1,313 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { tryGetStoredKey } from './client'; +import { + Blacklist, + CheckBlock, + CheckIp, + ClearAddress, + ReportIp, + Reports, +} from './endpoints'; +import type { + AbuseIPDBEndpointInputs, + AbuseIPDBEndpointOutputs, +} from './endpoints/types'; +import { + AbuseIPDBEndpointInputSchemas, + AbuseIPDBEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AbuseIPDBSchema } from './schema'; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Options +// ───────────────────────────────────────────────────────────────────────────── + +export type AbuseIPDBPluginOptions = { + /** Authentication method. Only api_key is supported. */ + authType?: PickAuth<'api_key'>; + /** + * AbuseIPDB account API key (from the dashboard). Sent in the `Key` + * header on every request. + */ + key?: string; + /** Optional: lifecycle hooks for endpoints */ + hooks?: InternalAbuseIPDBPlugin['hooks']; + /** Optional: custom error handlers (merged with defaults) */ + errorHandlers?: CorsairErrorHandler; + /** + * Permission configuration for the AbuseIPDB plugin. The read-only + * endpoints (check, reports, blacklist, check-block) default to 'open'; + * the write endpoints (report, clear-address) default to 'allow'. + */ + permissions?: PluginPermissionsConfig; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Context & Type Helpers +// ───────────────────────────────────────────────────────────────────────────── + +export type AbuseIPDBContext = CorsairPluginContext< + typeof AbuseIPDBSchema, + AbuseIPDBPluginOptions, + undefined, + typeof abuseIPDBAuthConfig +>; + +export type AbuseIPDBKeyBuilderContext = KeyBuilderContext< + AbuseIPDBPluginOptions, + typeof abuseIPDBAuthConfig +>; + +export type AbuseIPDBBoundEndpoints = BindEndpoints< + typeof abuseIPDBEndpointsNested +>; + +type AbuseIPDBEndpoint = + CorsairEndpoint< + AbuseIPDBContext, + AbuseIPDBEndpointInputs[K], + AbuseIPDBEndpointOutputs[K] + >; + +export type AbuseIPDBEndpoints = { + checkIp: AbuseIPDBEndpoint<'checkIp'>; + getReports: AbuseIPDBEndpoint<'getReports'>; + getBlacklist: AbuseIPDBEndpoint<'getBlacklist'>; + reportIp: AbuseIPDBEndpoint<'reportIp'>; + checkBlock: AbuseIPDBEndpoint<'checkBlock'>; + clearAddress: AbuseIPDBEndpoint<'clearAddress'>; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint Tree +// ───────────────────────────────────────────────────────────────────────────── + +const abuseIPDBEndpointsNested = { + check: { + ip: CheckIp.check, + }, + reports: { + list: Reports.list, + }, + blacklist: { + get: Blacklist.get, + }, + report: { + ip: ReportIp.report, + }, + block: { + check: CheckBlock.check, + }, + address: { + clear: ClearAddress.clear, + }, +} as const; + +// No webhooks — AbuseIPDB is a pull-based API (no event delivery) +const abuseIPDBWebhooksNested = {} as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint Schemas (for get_schema / agent introspection) +// ───────────────────────────────────────────────────────────────────────────── + +export const abuseIPDBEndpointSchemas = { + 'check.ip': { + input: AbuseIPDBEndpointInputSchemas.checkIp, + output: AbuseIPDBEndpointOutputSchemas.checkIp, + }, + 'reports.list': { + input: AbuseIPDBEndpointInputSchemas.getReports, + output: AbuseIPDBEndpointOutputSchemas.getReports, + }, + 'blacklist.get': { + input: AbuseIPDBEndpointInputSchemas.getBlacklist, + output: AbuseIPDBEndpointOutputSchemas.getBlacklist, + }, + 'report.ip': { + input: AbuseIPDBEndpointInputSchemas.reportIp, + output: AbuseIPDBEndpointOutputSchemas.reportIp, + }, + 'block.check': { + input: AbuseIPDBEndpointInputSchemas.checkBlock, + output: AbuseIPDBEndpointOutputSchemas.checkBlock, + }, + 'address.clear': { + input: AbuseIPDBEndpointInputSchemas.clearAddress, + output: AbuseIPDBEndpointOutputSchemas.clearAddress, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof abuseIPDBEndpointsNested +>; + +// ───────────────────────────────────────────────────────────────────────────── +// Endpoint Meta (risk levels for permission system) +// ───────────────────────────────────────────────────────────────────────────── + +const abuseIPDBEndpointMeta = { + 'check.ip': { + riskLevel: 'read', + description: + 'Look up an IP address and get its abuse confidence score, country, ISP, usage type, and optionally recent reports', + }, + 'reports.list': { + riskLevel: 'read', + description: + 'Get a paginated list of abuse reports filed against a single IP address', + }, + 'blacklist.get': { + riskLevel: 'read', + description: + 'Download the blacklist of most-reported IPs, optionally filtered by confidence minimum, country, and IP version', + }, + 'report.ip': { + riskLevel: 'write', + description: + 'Submit an abuse report for an IP address with one or more abuse category IDs', + }, + 'block.check': { + riskLevel: 'read', + description: + 'Check a CIDR network block and list the reported addresses within it', + }, + 'address.clear': { + riskLevel: 'destructive', + description: + 'Remove all reports for an IP address from your account and return the number deleted', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof abuseIPDBEndpointsNested +>; + +// ───────────────────────────────────────────────────────────────────────────── +// Auth Configuration +// ───────────────────────────────────────────────────────────────────────────── + +const defaultAuthType = 'api_key' as const satisfies AuthTypes; + +export const abuseIPDBAuthConfig = { + api_key: { + account: [] as const, + }, +} as const satisfies PluginAuthConfig; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Types +// ───────────────────────────────────────────────────────────────────────────── + +export type BaseAbuseIPDBPlugin = + CorsairPlugin< + 'abuseipdb', + typeof AbuseIPDBSchema, + typeof abuseIPDBEndpointsNested, + typeof abuseIPDBWebhooksNested, + T, + typeof defaultAuthType, + typeof abuseIPDBAuthConfig + >; + +export type InternalAbuseIPDBPlugin = + BaseAbuseIPDBPlugin; + +export type ExternalAbuseIPDBPlugin = + BaseAbuseIPDBPlugin; + +// ───────────────────────────────────────────────────────────────────────────── +// Plugin Factory +// ───────────────────────────────────────────────────────────────────────────── + +export function abuseipdb( + incomingOptions: AbuseIPDBPluginOptions & + // Safe: T extends AbuseIPDBPluginOptions, so an empty object is a valid + // no-op default when no options are passed. TypeScript requires the cast + // because it cannot verify T = {}. + T = {} as AbuseIPDBPluginOptions & T, +): ExternalAbuseIPDBPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'abuseipdb', + authConfig: abuseIPDBAuthConfig, + schema: AbuseIPDBSchema, + options: options, + hooks: options.hooks, + webhookHooks: undefined, + endpoints: abuseIPDBEndpointsNested, + webhooks: abuseIPDBWebhooksNested, + endpointMeta: abuseIPDBEndpointMeta, + endpointSchemas: abuseIPDBEndpointSchemas, + // No webhooks — AbuseIPDB is a pull-based API + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AbuseIPDBKeyBuilderContext, source) => { + // Direct shared key from options takes priority. + if (source === 'endpoint' && options.key) { + return options.key; + } + + // Fall back to the key stored in the account key manager. A + // database-less/KEK-less setup only using `options.key` has no + // DEK, so tryGetStoredKey resolves that to "no stored key". + if (source === 'endpoint') { + const res = await tryGetStoredKey(() => ctx.keys?.get_api_key()); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalAbuseIPDBPlugin; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Type Exports +// ───────────────────────────────────────────────────────────────────────────── + +export type { + AbuseIPDBEndpointInputs, + AbuseIPDBEndpointOutputs, + CheckBlockInput, + CheckBlockResponse, + CheckIpInput, + CheckIpResponse, + ClearAddressInput, + ClearAddressResponse, + GetBlacklistInput, + GetBlacklistResponse, + GetReportsInput, + GetReportsResponse, + ReportIpInput, + ReportIpResponse, +} from './endpoints/types'; + +export { + CheckBlockInputSchema, + CheckBlockResponseSchema, + CheckIpInputSchema, + CheckIpResponseSchema, + ClearAddressInputSchema, + ClearAddressResponseSchema, + GetBlacklistInputSchema, + GetBlacklistResponseSchema, + GetReportsInputSchema, + GetReportsResponseSchema, + ReportIpInputSchema, + ReportIpResponseSchema, +} from './endpoints/types'; diff --git a/packages/abuseipdb/integration.test.ts b/packages/abuseipdb/integration.test.ts new file mode 100644 index 000000000..25f523159 --- /dev/null +++ b/packages/abuseipdb/integration.test.ts @@ -0,0 +1,59 @@ +import { createCorsair } from 'corsair/core'; +import { createIntegrationAndAccount, createTestDatabase } from 'corsair/tests'; +import { abuseipdb } from './index'; + +async function createAbuseIPDBClient(options: Parameters[0]) { + const testDb = createTestDatabase(); + await createIntegrationAndAccount(testDb.db, 'abuseipdb', 'default'); + + const corsair = createCorsair({ + plugins: [abuseipdb(options)], + database: testDb.db, + kek: process.env.CORSAIR_KEK ?? '0123456789abcdef0123456789abcdef', + }); + + return { corsair, testDb }; +} + +describe('AbuseIPDB plugin integration', () => { + it('fails fast with AuthMissingError when no key is configured anywhere', async () => { + const { corsair, testDb } = await createAbuseIPDBClient({}); + + try { + await expect( + corsair.abuseipdb.api.check.ip({ ipAddress: '118.25.6.39' }), + ).rejects.toThrow(/auth-missing/); + } finally { + testDb.cleanup(); + } + }); + + it('exposes the full nested endpoint tree', async () => { + const { corsair, testDb } = await createAbuseIPDBClient({ + key: 'test-key', + }); + + try { + const api = corsair.abuseipdb.api; + // check.ip / reports.list / blacklist.get / report.ip / + // block.check / address.clear + expect(api.check.ip).toBeDefined(); + expect(api.reports.list).toBeDefined(); + expect(api.blacklist.get).toBeDefined(); + expect(api.report.ip).toBeDefined(); + expect(api.block.check).toBeDefined(); + expect(api.address.clear).toBeDefined(); + } finally { + testDb.cleanup(); + } + }); + + it('loads the plugin factory with the correct id and auth type', () => { + const plugin = abuseipdb({}); + expect(plugin.id).toBe('abuseipdb'); + expect(plugin.authConfig).toHaveProperty('api_key'); + // Pull-based API — no webhooks. + expect(plugin.webhooks).toEqual({}); + expect(plugin.pluginWebhookMatcher).toBeUndefined(); + }); +}); diff --git a/packages/abuseipdb/jest.config.cjs b/packages/abuseipdb/jest.config.cjs new file mode 100644 index 000000000..b3454ca06 --- /dev/null +++ b/packages/abuseipdb/jest.config.cjs @@ -0,0 +1,59 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/db$': '/../corsair/db.ts', + '^corsair/orm$': '/../corsair/orm.ts', + '^corsair/http$': '/../corsair/http.ts', + '^corsair/setup$': '/../corsair/setup.ts', + '^corsair/tests$': '/../corsair/tests.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/abuseipdb/package.json b/packages/abuseipdb/package.json new file mode 100644 index 000000000..fc71de2ee --- /dev/null +++ b/packages/abuseipdb/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/abuseipdb", + "version": "0.1.0", + "description": "AbuseIPDB plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "abuseipdb", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/abuseipdb/schema.test.ts b/packages/abuseipdb/schema.test.ts new file mode 100644 index 000000000..7fb403193 --- /dev/null +++ b/packages/abuseipdb/schema.test.ts @@ -0,0 +1,20 @@ +import { AbuseIPDBSchema } from './schema'; + +describe('AbuseIPDB schema', () => { + it('declares a semver version', () => { + expect(AbuseIPDBSchema.version).toBeDefined(); + expect(AbuseIPDBSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof AbuseIPDBSchema.entities).toBe('object'); + expect(AbuseIPDBSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(AbuseIPDBSchema.entities))).toBe(true); + for (const entity of Object.values(AbuseIPDBSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/abuseipdb/schema/database.ts b/packages/abuseipdb/schema/database.ts new file mode 100644 index 000000000..0137f7de6 --- /dev/null +++ b/packages/abuseipdb/schema/database.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +/** + * Local storage record for an IP abuse lookup (the check endpoint). + * Captures the abuse confidence score and identifying details returned by + * the AbuseIPDB Check API. + */ +export const AbuseIPDBIpCheck = z.object({ + ipAddress: z.string(), + abuseConfidenceScore: z.number(), + isPublic: z.boolean(), + ipVersion: z.number(), + countryCode: z.string().nullable().optional(), + countryName: z.string().nullable().optional(), + usageType: z.string().nullable().optional(), + isp: z.string().nullable().optional(), + domain: z.string().nullable().optional(), + isTor: z.boolean(), + totalReports: z.number(), + numDistinctUsers: z.number(), + checkedAt: z.coerce.date().nullable().optional(), +}); + +/** + * Local storage record for an abuse report submission (the report endpoint). + */ +export const AbuseIPDBReport = z.object({ + ipAddress: z.string(), + abuseConfidenceScore: z.number(), + reportedAt: z.coerce.date().nullable().optional(), +}); + +/** + * Local storage record for a single blacklist entry (the blacklist endpoint). + */ +export const AbuseIPDBBlacklistEntry = z.object({ + ipAddress: z.string(), + abuseConfidenceScore: z.number(), + lastReportedAt: z.string().nullable().optional(), + countryCode: z.string().nullable().optional(), +}); + +export type AbuseIPDBIpCheck = z.infer; +export type AbuseIPDBReport = z.infer; +export type AbuseIPDBBlacklistEntry = z.infer; diff --git a/packages/abuseipdb/schema/index.ts b/packages/abuseipdb/schema/index.ts new file mode 100644 index 000000000..7e22125c6 --- /dev/null +++ b/packages/abuseipdb/schema/index.ts @@ -0,0 +1,14 @@ +import { + AbuseIPDBBlacklistEntry, + AbuseIPDBIpCheck, + AbuseIPDBReport, +} from './database'; + +export const AbuseIPDBSchema = { + version: '1.0.0', + entities: { + ipChecks: AbuseIPDBIpCheck, + reports: AbuseIPDBReport, + blacklistEntries: AbuseIPDBBlacklistEntry, + }, +} as const; diff --git a/packages/abuseipdb/tsconfig.json b/packages/abuseipdb/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/abuseipdb/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/abuseipdb/tsup.config.ts b/packages/abuseipdb/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/abuseipdb/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index d50380540..a6528ca07 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -14,6 +14,7 @@ export type AllErrors = export const BaseProviders = [ 'abstract', + 'abuseipdb', 'activetrail', 'addresszen', 'affinda', @@ -137,6 +138,7 @@ export const BaseProviders = [ export const ProviderDisplayNames = { abstract: 'Abstract', + abuseipdb: 'AbuseIPDB', activetrail: 'Active Trail', addresszen: 'Addresszen', affinda: 'Affinda', @@ -267,6 +269,7 @@ export function formatProviderDisplayName(plugin: string): string { export type AllProviders = | 'abstract' + | 'abuseipdb' | 'activetrail' | 'addresszen' | 'affinda' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c370ed0da..ed37ab2b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/abuseipdb: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/activetrail: devDependencies: '@types/jest':