Skip to content

Commit 6c745c0

Browse files
committed
feat(worker): implement AbuseIPDB component
- Added AbuseIPDB API wrapper component - Added unit and integration tests - Registered component in registry Closes #37 Signed-off-by: betterclever <[email protected]>
1 parent 4171703 commit 6c745c0

File tree

4 files changed

+352
-0
lines changed

4 files changed

+352
-0
lines changed

worker/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import './security/atlassian-offboarding';
5555
import './security/trufflehog';
5656
import './security/terminal-demo';
5757
import './security/virustotal';
58+
import './security/abusedb';
5859

5960
// GitHub components
6061
import './github/connection-provider';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Optional integration test for AbuseIPDB component.
3+
* Requires a valid AbuseIPDB API key.
4+
* Enable by setting RUN_ABUSEDB_TESTS=1 and providing ABUSEIPDB_API_KEY.
5+
*/
6+
import { describe, expect, test, beforeEach } from 'bun:test';
7+
import { componentRegistry, createExecutionContext, type ExecutionContext } from '@shipsec/component-sdk';
8+
import type { definition } from '../abusedb';
9+
import '../../index'; // Ensure registry is populated
10+
11+
const shouldRunIntegration =
12+
process.env.RUN_ABUSEDB_TESTS === '1' && !!process.env.ABUSEIPDB_API_KEY;
13+
14+
(shouldRunIntegration ? describe : describe.skip)('AbuseIPDB Integration', () => {
15+
let context: ExecutionContext;
16+
17+
beforeEach(async () => {
18+
context = createExecutionContext({
19+
runId: 'test-run',
20+
componentRef: 'abusedb-integration-test',
21+
});
22+
});
23+
24+
test('checks a known IP address', async () => {
25+
const component = componentRegistry.get('security.abuseipdb.check');
26+
expect(component).toBeDefined();
27+
28+
// 127.0.0.1 is usually reserved/private so result might be specific,
29+
// but 1.1.1.1 is Cloudflare and should exist.
30+
const ipToCheck = '1.1.1.1';
31+
32+
const params = {
33+
ipAddress: ipToCheck,
34+
apiKey: process.env.ABUSEIPDB_API_KEY!,
35+
maxAgeInDays: 90,
36+
verbose: true
37+
};
38+
39+
const result = await component!.execute(params, context);
40+
const output = result as any;
41+
42+
expect(output.ipAddress).toBe(ipToCheck);
43+
expect(typeof output.abuseConfidenceScore).toBe('number');
44+
expect(output.full_report).toBeDefined();
45+
});
46+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, it, expect, beforeAll, afterEach, vi } from 'bun:test';
2+
import * as sdk from '@shipsec/component-sdk';
3+
import { componentRegistry } from '../../index';
4+
import { definition } from '../abusedb';
5+
6+
describe('abusedb component', () => {
7+
beforeAll(async () => {
8+
// Ensure registry is populated
9+
await import('../../index');
10+
});
11+
12+
afterEach(() => {
13+
vi.restoreAllMocks();
14+
});
15+
16+
it('should be registered with correct metadata', () => {
17+
const component = componentRegistry.get('security.abuseipdb.check');
18+
expect(component).toBeDefined();
19+
expect(component!.label).toBe('AbuseIPDB Check');
20+
expect(component!.category).toBe('security');
21+
});
22+
23+
it('should execute successfully with valid input', async () => {
24+
const component = componentRegistry.get('security.abuseipdb.check');
25+
if (!component) throw new Error('Component not registered');
26+
27+
const context = sdk.createExecutionContext({
28+
runId: 'test-run',
29+
componentRef: 'abusedb-test',
30+
});
31+
32+
const params = {
33+
ipAddress: '127.0.0.1',
34+
apiKey: 'test-key',
35+
maxAgeInDays: 90,
36+
verbose: false
37+
};
38+
39+
const mockResponse = {
40+
data: {
41+
ipAddress: '127.0.0.1',
42+
isPublic: true,
43+
ipVersion: 4,
44+
isWhitelisted: false,
45+
abuseConfidenceScore: 100,
46+
countryCode: 'US',
47+
usageType: 'Data Center',
48+
isp: 'Test ISP',
49+
domain: 'example.com',
50+
totalReports: 10,
51+
numDistinctUsers: 5,
52+
lastReportedAt: '2023-01-01T00:00:00Z'
53+
}
54+
};
55+
56+
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify(mockResponse), {
57+
status: 200,
58+
headers: { 'Content-Type': 'application/json' }
59+
}));
60+
61+
const result = await component.execute(params, context);
62+
63+
expect(fetchSpy).toHaveBeenCalled();
64+
const callArgs = fetchSpy.mock.calls[0];
65+
expect(callArgs[0]).toContain('https://api.abuseipdb.com/api/v2/check');
66+
expect(callArgs[0]).toContain('ipAddress=127.0.0.1');
67+
68+
// Type assertion or check specific fields
69+
const output = result as any;
70+
expect(output.ipAddress).toBe('127.0.0.1');
71+
expect(output.abuseConfidenceScore).toBe(100);
72+
expect(output.isp).toBe('Test ISP');
73+
expect(output.full_report).toEqual(mockResponse);
74+
});
75+
76+
it('should handle 404', async () => {
77+
const component = componentRegistry.get('security.abuseipdb.check');
78+
if (!component) throw new Error('Component not registered');
79+
80+
const context = sdk.createExecutionContext({
81+
runId: 'test-run',
82+
componentRef: 'abusedb-test',
83+
});
84+
85+
const params = {
86+
ipAddress: '0.0.0.0',
87+
apiKey: 'test-key',
88+
maxAgeInDays: 90,
89+
verbose: false
90+
};
91+
92+
vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, {
93+
status: 404,
94+
}));
95+
96+
const result = await component.execute(params, context);
97+
const output = result as any;
98+
expect(output.abuseConfidenceScore).toBe(0);
99+
expect(output.full_report.error).toBe('Not Found');
100+
});
101+
102+
it('should throw error on failure', async () => {
103+
const component = componentRegistry.get('security.abuseipdb.check');
104+
if (!component) throw new Error('Component not registered');
105+
106+
const context = sdk.createExecutionContext({
107+
runId: 'test-run',
108+
componentRef: 'abusedb-test',
109+
});
110+
111+
const params = {
112+
ipAddress: '1.1.1.1',
113+
apiKey: 'test-key',
114+
maxAgeInDays: 90,
115+
verbose: false
116+
};
117+
118+
vi.spyOn(global, 'fetch').mockResolvedValue(new Response('Unauthorized', {
119+
status: 401,
120+
statusText: 'Unauthorized'
121+
}));
122+
123+
await expect(component.execute(params, context)).rejects.toThrow();
124+
});
125+
});
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { z } from 'zod';
2+
import {
3+
componentRegistry,
4+
ComponentDefinition,
5+
port,
6+
ValidationError,
7+
ConfigurationError,
8+
fromHttpResponse,
9+
ComponentRetryPolicy,
10+
} from '@shipsec/component-sdk';
11+
12+
const inputSchema = z.object({
13+
ipAddress: z.string().describe('The IPv4 or IPv6 address you want to check.'),
14+
maxAgeInDays: z.number().default(90).describe('Max age in days for reports to be included (default: 90).'),
15+
verbose: z.boolean().default(false).describe('Include verbose information.'),
16+
apiKey: z.string().describe('Your AbuseIPDB API Key.'),
17+
});
18+
19+
const outputSchema = z.object({
20+
ipAddress: z.string().describe('The IP address that was checked.'),
21+
isPublic: z.boolean().optional(),
22+
ipVersion: z.number().optional(),
23+
isWhitelisted: z.boolean().optional(),
24+
abuseConfidenceScore: z.number().describe('The confidence score (0-100).'),
25+
countryCode: z.string().optional(),
26+
usageType: z.string().optional(),
27+
isp: z.string().optional(),
28+
domain: z.string().optional(),
29+
hostnames: z.array(z.string()).optional(),
30+
totalReports: z.number().optional(),
31+
numDistinctUsers: z.number().optional(),
32+
lastReportedAt: z.string().optional(),
33+
reports: z.array(z.record(z.string(), z.any())).optional(),
34+
full_report: z.record(z.string(), z.any()).describe('The full raw JSON response.'),
35+
});
36+
37+
type Input = z.infer<typeof inputSchema>;
38+
type Output = z.infer<typeof outputSchema>;
39+
40+
const abuseIPDBRetryPolicy: ComponentRetryPolicy = {
41+
maxAttempts: 4,
42+
initialIntervalSeconds: 2,
43+
maximumIntervalSeconds: 120,
44+
backoffCoefficient: 2.0,
45+
nonRetryableErrorTypes: [
46+
'AuthenticationError',
47+
'ValidationError',
48+
'ConfigurationError',
49+
],
50+
};
51+
52+
const definition: ComponentDefinition<Input, Output> = {
53+
id: 'security.abuseipdb.check',
54+
label: 'AbuseIPDB Check',
55+
category: 'security',
56+
runner: { kind: 'inline' },
57+
retryPolicy: abuseIPDBRetryPolicy,
58+
inputSchema,
59+
outputSchema,
60+
docs: 'Check the reputation of an IP address using the AbuseIPDB API.',
61+
metadata: {
62+
slug: 'abuseipdb-check',
63+
version: '1.0.0',
64+
type: 'scan',
65+
category: 'security',
66+
description: 'Get threat intelligence reports for an IP from AbuseIPDB.',
67+
icon: 'Shield',
68+
author: { name: 'ShipSecAI', type: 'shipsecai' },
69+
isLatest: true,
70+
deprecated: false,
71+
inputs: [
72+
{ id: 'ipAddress', label: 'IP Address', dataType: port.text(), required: true },
73+
{ id: 'apiKey', label: 'API Key', dataType: port.secret(), required: true },
74+
{ id: 'maxAgeInDays', label: 'Max Age (Days)', dataType: port.number(), required: false },
75+
{ id: 'verbose', label: 'Verbose', dataType: port.boolean(), required: false },
76+
],
77+
outputs: [
78+
{ id: 'abuseConfidenceScore', label: 'Confidence Score', dataType: port.number() },
79+
{ id: 'isWhitelisted', label: 'Whitelisted', dataType: port.boolean() },
80+
{ id: 'countryCode', label: 'Country', dataType: port.text() },
81+
{ id: 'isp', label: 'ISP', dataType: port.text() },
82+
{ id: 'totalReports', label: 'Total Reports', dataType: port.number() },
83+
{ id: 'full_report', label: 'Full Report', dataType: port.json() },
84+
],
85+
},
86+
resolvePorts(params) {
87+
return {
88+
inputs: [
89+
{ id: 'ipAddress', label: 'IP Address', dataType: port.text(), required: true },
90+
{ id: 'apiKey', label: 'API Key', dataType: port.secret(), required: true },
91+
{ id: 'maxAgeInDays', label: 'Max Age (Days)', dataType: port.number(), required: false },
92+
{ id: 'verbose', label: 'Verbose', dataType: port.boolean(), required: false },
93+
],
94+
outputs: [
95+
{ id: 'abuseConfidenceScore', label: 'Confidence Score', dataType: port.number() },
96+
{ id: 'isWhitelisted', label: 'Whitelisted', dataType: port.boolean() },
97+
{ id: 'countryCode', label: 'Country', dataType: port.text() },
98+
{ id: 'isp', label: 'ISP', dataType: port.text() },
99+
{ id: 'totalReports', label: 'Total Reports', dataType: port.number() },
100+
{ id: 'full_report', label: 'Full Report', dataType: port.json() },
101+
]
102+
};
103+
},
104+
async execute(params, context) {
105+
const { ipAddress, apiKey, maxAgeInDays, verbose } = params;
106+
107+
if (!ipAddress) {
108+
throw new ValidationError('IP Address is required', {
109+
fieldErrors: { ipAddress: ['IP Address is required'] },
110+
});
111+
}
112+
if (!apiKey) {
113+
throw new ConfigurationError('AbuseIPDB API Key is required', {
114+
configKey: 'apiKey',
115+
});
116+
}
117+
118+
const endpoint = 'https://api.abuseipdb.com/api/v2/check';
119+
const queryParams = new URLSearchParams({
120+
ipAddress,
121+
maxAgeInDays: String(maxAgeInDays),
122+
});
123+
if (verbose) {
124+
queryParams.append('verbose', 'true');
125+
}
126+
127+
const url = `${endpoint}?${queryParams.toString()}`;
128+
129+
context.logger.info(`[AbuseIPDB] Checking IP: ${ipAddress}`);
130+
131+
const response = await fetch(url, {
132+
method: 'GET',
133+
headers: {
134+
'Key': apiKey,
135+
'Accept': 'application/json'
136+
}
137+
});
138+
139+
if (response.status === 404) {
140+
context.logger.warn(`[AbuseIPDB] IP not found: ${ipAddress}`);
141+
return {
142+
ipAddress,
143+
abuseConfidenceScore: 0,
144+
full_report: { error: 'Not Found' }
145+
};
146+
}
147+
148+
if (!response.ok) {
149+
const text = await response.text();
150+
throw fromHttpResponse(response, text);
151+
}
152+
153+
const data = await response.json() as any;
154+
const info = data.data || {};
155+
156+
context.logger.info(`[AbuseIPDB] Score for ${ipAddress}: ${info.abuseConfidenceScore}`);
157+
158+
return {
159+
ipAddress: info.ipAddress,
160+
isPublic: info.isPublic,
161+
ipVersion: info.ipVersion,
162+
isWhitelisted: info.isWhitelisted,
163+
abuseConfidenceScore: info.abuseConfidenceScore,
164+
countryCode: info.countryCode,
165+
usageType: info.usageType,
166+
isp: info.isp,
167+
domain: info.domain,
168+
hostnames: info.hostnames,
169+
totalReports: info.totalReports,
170+
numDistinctUsers: info.numDistinctUsers,
171+
lastReportedAt: info.lastReportedAt,
172+
reports: info.reports, // Only present if verbose is true
173+
full_report: data,
174+
};
175+
},
176+
};
177+
178+
componentRegistry.register(definition);
179+
180+
export { definition };

0 commit comments

Comments
 (0)