Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions worker/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import './security/shuffledns-massdns';
import './security/atlassian-offboarding';
import './security/trufflehog';
import './security/terminal-demo';
import './security/virustotal';

// GitHub components
import './github/connection-provider';
Expand Down
157 changes: 157 additions & 0 deletions worker/src/components/security/virustotal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { z } from 'zod';
import { componentRegistry, ComponentDefinition, port } from '@shipsec/component-sdk';

const inputSchema = z.object({
indicator: z.string().describe('The IP, Domain, File Hash, or URL to inspect.'),
type: z.enum(['ip', 'domain', 'file', 'url']).default('ip').describe('The type of indicator.'),
apiKey: z.string().describe('Your VirusTotal API Key.'),
});

const outputSchema = z.object({
malicious: z.number().describe('Number of engines flagging this as malicious.'),
suspicious: z.number().describe('Number of engines flagging this as suspicious.'),
harmless: z.number().describe('Number of engines flagging this as harmless.'),
tags: z.array(z.string()).optional(),
reputation: z.number().optional(),
full_report: z.record(z.string(), z.any()).describe('The full raw JSON response from VirusTotal.'),
});

type Input = z.infer<typeof inputSchema>;
type Output = z.infer<typeof outputSchema>;

const definition: ComponentDefinition<Input, Output> = {
id: 'security.virustotal.lookup',
label: 'VirusTotal Lookup',
category: 'security',
runner: { kind: 'inline' },
inputSchema,
outputSchema,
docs: 'Check the reputation of an IP, Domain, File Hash, or URL using the VirusTotal v3 API.',
metadata: {
slug: 'virustotal-lookup',
version: '1.0.0',
type: 'scan',
category: 'security',
description: 'Get threat intelligence reports for IOCs from VirusTotal.',
icon: 'Shield', // We can update this if there's a better one, or generic Shield
author: { name: 'ShipSecAI', type: 'shipsecai' },
isLatest: true,
deprecated: false,
inputs: [
{ id: 'indicator', label: 'Indicator', dataType: port.text(), required: true },
{ id: 'apiKey', label: 'API Key', dataType: port.secret(), required: true },
],
outputs: [
{ id: 'malicious', label: 'Malicious Count', dataType: port.number() },
{ id: 'full_report', label: 'Full Report', dataType: port.json() },
],
parameters: [
{
id: 'type',
label: 'Indicator Type',
type: 'select',
default: 'ip',
options: [
{ label: 'IP Address', value: 'ip' },
{ label: 'Domain', value: 'domain' },
{ label: 'File Hash (MD5/SHA1/SHA256)', value: 'file' },
{ label: 'URL', value: 'url' },
],
},
],
},
resolvePorts(params) {
return {
inputs: [
{ id: 'indicator', label: 'Indicator', dataType: port.text(), required: true },
{ id: 'apiKey', label: 'API Key', dataType: port.secret(), required: true }
]
};
},
async execute(params, context) {
const { indicator, type, apiKey } = params;

if (!indicator) throw new Error('Indicator is required');
if (!apiKey) throw new Error('VirusTotal API Key is required');

let endpoint = '';

// API v3 Base URL
const baseUrl = 'https://www.virustotal.com/api/v3';

// Construct endpoint based on type
switch (type) {
case 'ip':
endpoint = `${baseUrl}/ip_addresses/${indicator}`;
break;
case 'domain':
endpoint = `${baseUrl}/domains/${indicator}`;
break;
case 'file':
endpoint = `${baseUrl}/files/${indicator}`;
break;
case 'url':
// URL endpoints usually require the URL to be base64 encoded without padding
const b64Url = Buffer.from(indicator).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
endpoint = `${baseUrl}/urls/${b64Url}`;
break;
}

context.logger.info(`[VirusTotal] Checking ${type}: ${indicator}`);

// If type is URL, we might need to "scan" it first if it hasn't been seen,
// but typically "lookup" implies retrieving existing info.
// The GET endpoint retrieves the last analysis.

const response = await fetch(endpoint, {
method: 'GET',
headers: {
'x-apikey': apiKey,
'Accept': 'application/json'
}
});

if (response.status === 404) {
context.logger.warn(`[VirusTotal] Indicator not found: ${indicator}`);
// Return neutral/zero stats if not found, or maybe just the error?
// Usually "not found" fits the schema if we return zeros.
return {
malicious: 0,
suspicious: 0,
harmless: 0,
tags: [],
full_report: { error: 'Not Found in VirusTotal' }
};
}

if (!response.ok) {
const text = await response.text();
throw new Error(`VirusTotal API failed (${response.status}): ${text}`);
}

const data = await response.json() as any;
const attrs = data.data?.attributes || {};
const stats = attrs.last_analysis_stats || {};

const malicious = stats.malicious || 0;
const suspicious = stats.suspicious || 0;
const harmless = stats.harmless || 0;
const tags = attrs.tags || [];
const reputation = attrs.reputation || 0;

context.logger.info(`[VirusTotal] Results for ${indicator}: ${malicious} malicious, ${suspicious} suspicious.`);

return {
malicious,
suspicious,
harmless,
tags,
reputation,
full_report: data,
};
},
};

componentRegistry.register(definition);

export { definition };