Skip to content

Commit f60cc29

Browse files
authored
Merge pull request #184 from ShipSecAI/betterclever/25b2026/d1feat4
feat(security): implement valid VirusTotal lookup
2 parents 729278f + 6882148 commit f60cc29

File tree

2 files changed

+158
-0
lines changed

2 files changed

+158
-0
lines changed

worker/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import './security/shuffledns-massdns';
4848
import './security/atlassian-offboarding';
4949
import './security/trufflehog';
5050
import './security/terminal-demo';
51+
import './security/virustotal';
5152

5253
// GitHub components
5354
import './github/connection-provider';
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { z } from 'zod';
2+
import { componentRegistry, ComponentDefinition, port } from '@shipsec/component-sdk';
3+
4+
const inputSchema = z.object({
5+
indicator: z.string().describe('The IP, Domain, File Hash, or URL to inspect.'),
6+
type: z.enum(['ip', 'domain', 'file', 'url']).default('ip').describe('The type of indicator.'),
7+
apiKey: z.string().describe('Your VirusTotal API Key.'),
8+
});
9+
10+
const outputSchema = z.object({
11+
malicious: z.number().describe('Number of engines flagging this as malicious.'),
12+
suspicious: z.number().describe('Number of engines flagging this as suspicious.'),
13+
harmless: z.number().describe('Number of engines flagging this as harmless.'),
14+
tags: z.array(z.string()).optional(),
15+
reputation: z.number().optional(),
16+
full_report: z.record(z.string(), z.any()).describe('The full raw JSON response from VirusTotal.'),
17+
});
18+
19+
type Input = z.infer<typeof inputSchema>;
20+
type Output = z.infer<typeof outputSchema>;
21+
22+
const definition: ComponentDefinition<Input, Output> = {
23+
id: 'security.virustotal.lookup',
24+
label: 'VirusTotal Lookup',
25+
category: 'security',
26+
runner: { kind: 'inline' },
27+
inputSchema,
28+
outputSchema,
29+
docs: 'Check the reputation of an IP, Domain, File Hash, or URL using the VirusTotal v3 API.',
30+
metadata: {
31+
slug: 'virustotal-lookup',
32+
version: '1.0.0',
33+
type: 'scan',
34+
category: 'security',
35+
description: 'Get threat intelligence reports for IOCs from VirusTotal.',
36+
icon: 'Shield', // We can update this if there's a better one, or generic Shield
37+
author: { name: 'ShipSecAI', type: 'shipsecai' },
38+
isLatest: true,
39+
deprecated: false,
40+
inputs: [
41+
{ id: 'indicator', label: 'Indicator', dataType: port.text(), required: true },
42+
{ id: 'apiKey', label: 'API Key', dataType: port.secret(), required: true },
43+
],
44+
outputs: [
45+
{ id: 'malicious', label: 'Malicious Count', dataType: port.number() },
46+
{ id: 'full_report', label: 'Full Report', dataType: port.json() },
47+
],
48+
parameters: [
49+
{
50+
id: 'type',
51+
label: 'Indicator Type',
52+
type: 'select',
53+
default: 'ip',
54+
options: [
55+
{ label: 'IP Address', value: 'ip' },
56+
{ label: 'Domain', value: 'domain' },
57+
{ label: 'File Hash (MD5/SHA1/SHA256)', value: 'file' },
58+
{ label: 'URL', value: 'url' },
59+
],
60+
},
61+
],
62+
},
63+
resolvePorts(params) {
64+
return {
65+
inputs: [
66+
{ id: 'indicator', label: 'Indicator', dataType: port.text(), required: true },
67+
{ id: 'apiKey', label: 'API Key', dataType: port.secret(), required: true }
68+
]
69+
};
70+
},
71+
async execute(params, context) {
72+
const { indicator, type, apiKey } = params;
73+
74+
if (!indicator) throw new Error('Indicator is required');
75+
if (!apiKey) throw new Error('VirusTotal API Key is required');
76+
77+
let endpoint = '';
78+
79+
// API v3 Base URL
80+
const baseUrl = 'https://www.virustotal.com/api/v3';
81+
82+
// Construct endpoint based on type
83+
switch (type) {
84+
case 'ip':
85+
endpoint = `${baseUrl}/ip_addresses/${indicator}`;
86+
break;
87+
case 'domain':
88+
endpoint = `${baseUrl}/domains/${indicator}`;
89+
break;
90+
case 'file':
91+
endpoint = `${baseUrl}/files/${indicator}`;
92+
break;
93+
case 'url':
94+
// URL endpoints usually require the URL to be base64 encoded without padding
95+
const b64Url = Buffer.from(indicator).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
96+
endpoint = `${baseUrl}/urls/${b64Url}`;
97+
break;
98+
}
99+
100+
context.logger.info(`[VirusTotal] Checking ${type}: ${indicator}`);
101+
102+
// If type is URL, we might need to "scan" it first if it hasn't been seen,
103+
// but typically "lookup" implies retrieving existing info.
104+
// The GET endpoint retrieves the last analysis.
105+
106+
const response = await fetch(endpoint, {
107+
method: 'GET',
108+
headers: {
109+
'x-apikey': apiKey,
110+
'Accept': 'application/json'
111+
}
112+
});
113+
114+
if (response.status === 404) {
115+
context.logger.warn(`[VirusTotal] Indicator not found: ${indicator}`);
116+
// Return neutral/zero stats if not found, or maybe just the error?
117+
// Usually "not found" fits the schema if we return zeros.
118+
return {
119+
malicious: 0,
120+
suspicious: 0,
121+
harmless: 0,
122+
tags: [],
123+
full_report: { error: 'Not Found in VirusTotal' }
124+
};
125+
}
126+
127+
if (!response.ok) {
128+
const text = await response.text();
129+
throw new Error(`VirusTotal API failed (${response.status}): ${text}`);
130+
}
131+
132+
const data = await response.json() as any;
133+
const attrs = data.data?.attributes || {};
134+
const stats = attrs.last_analysis_stats || {};
135+
136+
const malicious = stats.malicious || 0;
137+
const suspicious = stats.suspicious || 0;
138+
const harmless = stats.harmless || 0;
139+
const tags = attrs.tags || [];
140+
const reputation = attrs.reputation || 0;
141+
142+
context.logger.info(`[VirusTotal] Results for ${indicator}: ${malicious} malicious, ${suspicious} suspicious.`);
143+
144+
return {
145+
malicious,
146+
suspicious,
147+
harmless,
148+
tags,
149+
reputation,
150+
full_report: data,
151+
};
152+
},
153+
};
154+
155+
componentRegistry.register(definition);
156+
157+
export { definition };

0 commit comments

Comments
 (0)