diff --git a/.gitignore b/.gitignore index 6e4403f..f6d988c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/ .env .env.local .env.*.local +logs/ \ No newline at end of file diff --git a/README.md b/README.md index a2e315c..67365af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # VirusTotal MCP Server -A Model Context Protocol (MCP) server for querying the [VirusTotal API](https://www.virustotal.com/). This server provides tools for scanning URLs, analyzing file hashes, and retrieving IP address reports. It is designed to integrate seamlessly with MCP-compatible applications like [Claude Desktop](https://claude.ai). +A Model Context Protocol (MCP) server for querying the [VirusTotal API](https://www.virustotal.com/). This server provides comprehensive security analysis tools with automatic relationship data fetching. It integrates seamlessly with MCP-compatible applications like [Claude Desktop](https://claude.ai). ## Quick Start (Recommended) @@ -58,59 +58,83 @@ npm run build ## Features -- **URL Scanning**: Submit and analyze URLs for potential security threats -- **File Hash Analysis**: Get detailed analysis results for file hashes -- **IP Reports**: Retrieve comprehensive security analysis reports for IP addresses -- **Relationship Analysis**: Get related objects for URLs, files, and IP addresses +- **Comprehensive Analysis Reports**: Each analysis tool automatically fetches relevant relationship data along with the basic report, providing a complete security overview in a single request +- **URL Analysis**: Security reports with automatic fetching of contacted domains, downloaded files, and threat actors +- **File Analysis**: Detailed analysis of file hashes including behaviors, dropped files, and network connections +- **IP Analysis**: Security reports with historical data, resolutions, and related threats +- **Domain Analysis**: DNS information, WHOIS data, SSL certificates, and subdomains +- **Detailed Relationship Analysis**: Dedicated tools for querying specific types of relationships with pagination support +- **Rich Formatting**: Clear categorization and presentation of analysis results and relationship data ## Tools -### 1. URL Scan Tool -- Name: `scan_url` -- Description: Scan a URL for potential security threats +### Report Tools (with Automatic Relationship Fetching) + +### 1. URL Report Tool +- Name: `get_url_report` +- Description: Get a comprehensive URL analysis report including security scan results and key relationships (communicating files, contacted domains/IPs, downloaded files, redirects, threat actors) +- Parameters: + * `url` (required): The URL to analyze + +### 2. File Report Tool +- Name: `get_file_report` +- Description: Get a comprehensive file analysis report using its hash (MD5/SHA-1/SHA-256). Includes detection results, file properties, and key relationships (behaviors, dropped files, network connections, embedded content, threat actors) - Parameters: - * `url` (required): The URL to scan + * `hash` (required): MD5, SHA-1 or SHA-256 hash of the file + +### 3. IP Report Tool +- Name: `get_ip_report` +- Description: Get a comprehensive IP address analysis report including geolocation, reputation data, and key relationships (communicating files, historical certificates/WHOIS, resolutions) +- Parameters: + * `ip` (required): IP address to analyze + +### 4. Domain Report Tool +- Name: `get_domain_report` +- Description: Get a comprehensive domain analysis report including DNS records, WHOIS data, and key relationships (SSL certificates, subdomains, historical data) +- Parameters: + * `domain` (required): Domain name to analyze + * `relationships` (optional): Array of specific relationships to include in the report + +### Relationship Tools (for Detailed Analysis) -### 2. URL Relationship Tool +### 1. URL Relationship Tool - Name: `get_url_relationship` -- Description: Get related objects for a URL (e.g., downloaded files, contacted domains) +- Description: Query a specific relationship type for a URL with pagination support. Choose from 17 relationship types including analyses, communicating files, contacted domains/IPs, downloaded files, graphs, referrers, redirects, and threat actors - Parameters: * `url` (required): The URL to get relationships for * `relationship` (required): Type of relationship to query - Available relationships: analyses, comments, communicating_files, contacted_domains, contacted_ips, downloaded_files, graphs, last_serving_ip_address, network_location, referrer_files, referrer_urls, redirecting_urls, redirects_to, related_comments, related_references, related_threat_actors, submissions - * `limit` (optional, default: 10): Maximum number of related objects to retrieve + * `limit` (optional, default: 10): Maximum number of related objects to retrieve (1-40) * `cursor` (optional): Continuation cursor for pagination -### 3. File Hash Analysis Tool -- Name: `scan_file_hash` -- Description: Get analysis results for a file hash -- Parameters: - * `hash` (required): MD5, SHA-1 or SHA-256 hash of the file - -### 4. File Relationship Tool +### 2. File Relationship Tool - Name: `get_file_relationship` -- Description: Get related objects for a file (e.g., dropped files, contacted domains) +- Description: Query a specific relationship type for a file with pagination support. Choose from 41 relationship types including behaviors, network connections, dropped files, embedded content, execution chains, and threat actors - Parameters: * `hash` (required): MD5, SHA-1 or SHA-256 hash of the file * `relationship` (required): Type of relationship to query - Available relationships: analyses, behaviours, bundled_files, carbonblack_children, carbonblack_parents, ciphered_bundled_files, ciphered_parents, clues, collections, comments, compressed_parents, contacted_domains, contacted_ips, contacted_urls, dropped_files, email_attachments, email_parents, embedded_domains, embedded_ips, embedded_urls, execution_parents, graphs, itw_domains, itw_ips, itw_urls, memory_pattern_domains, memory_pattern_ips, memory_pattern_urls, overlay_children, overlay_parents, pcap_children, pcap_parents, pe_resource_children, pe_resource_parents, related_references, related_threat_actors, similar_files, submissions, screenshots, urls_for_embedded_js, votes - * `limit` (optional, default: 10): Maximum number of related objects to retrieve + * `limit` (optional, default: 10): Maximum number of related objects to retrieve (1-40) * `cursor` (optional): Continuation cursor for pagination -### 5. IP Report Tool -- Name: `get_ip_report` -- Description: Get security analysis report for an IP address -- Parameters: - * `ip` (required): IP address to analyze - -### 6. IP Relationship Tool +### 3. IP Relationship Tool - Name: `get_ip_relationship` -- Description: Get related objects for an IP address (e.g., downloaded files, resolutions) +- Description: Query a specific relationship type for an IP address with pagination support. Choose from 12 relationship types including communicating files, historical SSL certificates, WHOIS records, resolutions, and threat actors - Parameters: * `ip` (required): IP address to analyze * `relationship` (required): Type of relationship to query - Available relationships: comments, communicating_files, downloaded_files, graphs, historical_ssl_certificates, historical_whois, related_comments, related_references, related_threat_actors, referrer_files, resolutions, urls - * `limit` (optional, default: 10): Maximum number of related objects to retrieve + * `limit` (optional, default: 10): Maximum number of related objects to retrieve (1-40) + * `cursor` (optional): Continuation cursor for pagination + +### 4. Domain Relationship Tool +- Name: `get_domain_relationship` +- Description: Query a specific relationship type for a domain with pagination support. Choose from 21 relationship types including SSL certificates, subdomains, historical data, and DNS records +- Parameters: + * `domain` (required): Domain name to analyze + * `relationship` (required): Type of relationship to query + - Available relationships: caa_records, cname_records, comments, communicating_files, downloaded_files, historical_ssl_certificates, historical_whois, immediate_parent, mx_records, ns_records, parent, referrer_files, related_comments, related_references, related_threat_actors, resolutions, soa_records, siblings, subdomains, urls, user_votes + * `limit` (optional, default: 10): Maximum number of related objects to retrieve (1-40) * `cursor` (optional): Continuation cursor for pagination ## Requirements @@ -166,6 +190,7 @@ The server includes comprehensive error handling for: - v1.1.0: Added relationship analysis tools for URLs, files, and IP addresses - v1.2.0: Added improved error handling and logging - v1.3.0: Added pagination support for relationship queries +- v1.4.0: Added automatic relationship fetching in report tools and domain analysis support ## Contributing diff --git a/src/formatters/domain.ts b/src/formatters/domain.ts new file mode 100644 index 0000000..eabeb83 --- /dev/null +++ b/src/formatters/domain.ts @@ -0,0 +1,160 @@ +// src/formatters/domain.ts + +import { FormattedResult } from './types.js'; +import { formatDateTime, formatDetectionResults } from './utils.js'; +import { logToFile } from '../utils/logging.js'; +import { DomainData, RelationshipData } from '../types/virustotal.js'; + +export function formatDomainResults(data: DomainData): FormattedResult { + try { + const attributes = data?.attributes || {}; + const stats = attributes?.last_analysis_stats || {}; + const categories = attributes?.categories || {}; + const ranks = attributes?.popularity_ranks || {}; + const whois = attributes?.whois || ''; + const dnsRecords = attributes?.last_dns_records || []; + const threatSeverity = attributes?.threat_severity; + const votes = attributes?.total_votes; + + let outputArray = [ + `šŸŒ Domain Analysis Results`, + `Domain: ${data?.id || 'Unknown Domain'}`, + `šŸ“… Last Analysis Date: ${attributes.last_analysis_date ? formatDateTime(attributes.last_analysis_date) : 'N/A'}`, + `šŸ“Š Reputation Score: ${attributes?.reputation ?? 'N/A'}`, + "", + "Analysis Statistics:", + formatDetectionResults(stats), + ]; + + // Add threat severity if available + if (threatSeverity && threatSeverity.threat_severity_level) { + const severityData = threatSeverity.threat_severity_data; + outputArray.push( + "", + "Threat Severity:", + `Level: ${threatSeverity.threat_severity_level}`, + `Description: ${threatSeverity.level_description || 'N/A'}`, + ...(severityData ? [ + `Detections: ${severityData.num_detections}`, + `Bad Collection: ${severityData.belongs_to_bad_collection ? 'Yes' : 'No'}` + ] : []) + ); + } + + // Add categories if available + if (Object.keys(categories).length > 0) { + outputArray = [ + ...outputArray, + "", + "Categories:", + ...Object.entries(categories).map(([service, category]) => + `ā€¢ ${service}: ${category}` + ) + ]; + } + + // Add DNS records if available + if (dnsRecords.length > 0) { + outputArray = [ + ...outputArray, + "", + "Latest DNS Records:", + ...dnsRecords.map((record) => + `ā€¢ ${record.type}: ${record.value} (TTL: ${record.ttl})` + ) + ]; + } + + // Add popularity rankings if available + if (Object.keys(ranks).length > 0) { + outputArray = [ + ...outputArray, + "", + "Popularity Rankings:", + ...Object.entries(ranks).map(([service, data]) => + `ā€¢ ${service}: Rank ${data.rank || 'N/A'}` + ) + ]; + } + + // Add key WHOIS information + if (whois) { + const whoisLines = whois.split('\n'); + const keyFields = [ + 'Registrar:', + 'Creation Date:', + 'Registry Expiry Date:', + 'Updated Date:', + 'Registrant Organization:', + 'Admin Organization:' + ]; + + const relevantWhois = whoisLines + .filter((line: string) => keyFields.some(field => line.startsWith(field))) + .filter((item: string, index: number, self: string[]) => + self.indexOf(item) === index + ); // Remove duplicates + + if (relevantWhois.length > 0) { + outputArray = [ + ...outputArray, + "", + "WHOIS Information:", + ...relevantWhois.map((line: string) => `ā€¢ ${line.trim()}`) + ]; + } + } + + // Add creation and modification dates + if (attributes.creation_date) { + outputArray.push(`\nCreation Date: ${formatDateTime(attributes.creation_date)}`); + } + if (attributes.last_modification_date) { + outputArray.push(`Last Modified: ${formatDateTime(attributes.last_modification_date)}`); + } + + // Add total votes if available + if (votes) { + outputArray.push( + "", + "Community Votes:", + `ā€¢ Harmless: ${votes.harmless || 0}`, + `ā€¢ Malicious: ${votes.malicious || 0}` + ); + } + + // Format relationships if available + if (data.relationships) { + outputArray.push('\nšŸ”— Relationships:'); + + for (const [relType, relData] of Object.entries(data.relationships)) { + const typedRelData = relData as RelationshipData; + const count = typedRelData.meta?.count || + (Array.isArray(typedRelData.data) ? typedRelData.data.length : 1); + + outputArray.push(`\n${relType} (${count} items):`); + + if (Array.isArray(typedRelData.data)) { + typedRelData.data.forEach(item => { + if (item.formattedOutput) { + outputArray.push(item.formattedOutput); + } + }); + } else if (typedRelData.data && 'formattedOutput' in typedRelData.data && typedRelData.data.formattedOutput) { + outputArray.push(typedRelData.data.formattedOutput); + } + } + } + + return { + type: "text", + text: outputArray.join('\n') + }; + } catch (error) { + logToFile(`Error formatting domain results: ${error}`); + return { + type: "text", + text: "Error formatting domain results" + }; + } +} diff --git a/src/formatters/file.ts b/src/formatters/file.ts new file mode 100644 index 0000000..58cf874 --- /dev/null +++ b/src/formatters/file.ts @@ -0,0 +1,381 @@ +// src/formatters/file.ts +import { FormattedResult } from './types.js'; +import { formatDateTime, formatDetectionResults } from './utils.js'; +import { logToFile } from '../utils/logging.js'; +import { RelationshipData } from '../types/virustotal.js'; + +interface SandboxVerdict { + category: string; + confidence: number; + malware_classification?: string[]; + malware_names?: string[]; + sandbox_name: string; +} + +interface CrowdsourcedIdsResult { + alert_severity: string; + rule_category: string; + rule_id: string; + rule_msg: string; + rule_source: string; + alert_context?: Array<{ + proto?: string; + src_ip?: string; + src_port?: number; + dest_ip?: string; // Add this + dest_port?: number; // Add this + hostname?: string; // Add this + url?: string; // Add this + }>; +} + +interface YaraResult { + description: string; + match_in_subfile: boolean; + rule_name: string; + ruleset_id: string; + ruleset_name: string; + source: string; +} + +interface SigmaResult { + rule_title: string; + rule_source: string; + rule_level: string; + rule_description: string; + rule_author: string; + rule_id: string; + match_context?: Array<{ + values: Record; + }>; +} + +interface FileData { + attributes?: any; + relationships?: Record; +} + +interface AnalysisStats { + harmless: number; + malicious: number; + suspicious: number; + undetected: number; + timeout: number; +} + +function formatRelationshipData(relType: string, item: any): string { + const attrs = item.attributes || {}; + + switch (relType) { + case 'behaviours': + const activities = [ + ...(attrs.processes_created || []), + ...(attrs.command_executions || []), + ...(attrs.activities_started || []) + ].filter(Boolean); + + const files = [ + ...(attrs.files_opened || []), + ...(attrs.files_written || []) + ].filter(Boolean); + + const registry = [ + ...(attrs.registry_keys_opened || []), + ...(attrs.registry_keys_set?.map((reg: any) => `${reg.key}: ${reg.value}`) || []), + ...(attrs.registry_keys_deleted || []) + ].filter(Boolean); + + const network = attrs.ids_results?.map((result: any) => { + const ctx = result.alert_context || {}; + return `${result.rule_msg} (${ctx.protocol || 'unknown'} ${ctx.src_ip || ''}:${ctx.src_port || ''} -> ${ctx.dest_ip || ''}:${ctx.dest_port || ''})`; + }) || []; + + return ` ā€¢ Sandbox: ${attrs.sandbox_name || 'Unknown'} + Activities (${activities.length}):${activities.length ? '\n - ' + activities.slice(0, 5).join('\n - ') + (activities.length > 5 ? '\n ... and more' : '') : ' None'} + Files (${files.length}):${files.length ? '\n - ' + files.slice(0, 5).join('\n - ') + (files.length > 5 ? '\n ... and more' : '') : ' None'} + Registry (${registry.length}):${registry.length ? '\n - ' + registry.slice(0, 5).join('\n - ') + (registry.length > 5 ? '\n ... and more' : '') : ' None'} + Network (${network.length}):${network.length ? '\n - ' + network.slice(0, 5).join('\n - ') + (network.length > 5 ? '\n ... and more' : '') : ' None'} + Verdicts: ${attrs.verdicts?.join(', ') || 'None'}`; + + case 'contacted_domains': + case 'embedded_domains': + case 'itw_domains': + return ` ā€¢ ${attrs.id || item.id} + Categories: ${Object.entries(attrs.categories || {}).map(([k, v]) => `${k}: ${v}`).join(', ') || 'None'} + Last Analysis Stats: ${attrs.last_analysis_stats ? + `šŸ”“ ${attrs.last_analysis_stats.malicious} malicious, āœ… ${attrs.last_analysis_stats.harmless} harmless` : + 'Unknown'}`; + + case 'contacted_ips': + case 'embedded_ips': + case 'itw_ips': + return ` ā€¢ ${attrs.ip_address || item.id} + Country: ${attrs.country || 'Unknown'} + AS Owner: ${attrs.as_owner || 'Unknown'} + Last Analysis Stats: ${attrs.last_analysis_stats ? + `šŸ”“ ${attrs.last_analysis_stats.malicious} malicious, āœ… ${attrs.last_analysis_stats.harmless} harmless` : + 'Unknown'}`; + + case 'contacted_urls': + case 'embedded_urls': + case 'itw_urls': + return ` ā€¢ ${attrs.url || item.id} + Last Analysis: ${attrs.last_analysis_date ? formatDateTime(attrs.last_analysis_date) : 'Unknown'} + Reputation: ${attrs.reputation ?? 'Unknown'}`; + + case 'dropped_files': + return ` ā€¢ ${attrs.meaningful_name || attrs.names?.[0] || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + Size: ${attrs.size ? formatFileSize(attrs.size) : 'Unknown'} + First Seen: ${attrs.first_submission_date ? formatDateTime(attrs.first_submission_date) : 'Unknown'} + Detection Stats: ${attrs.last_analysis_stats ? + `šŸ”“ ${attrs.last_analysis_stats.malicious} malicious, āœ… ${attrs.last_analysis_stats.harmless} harmless` : + 'Unknown'}`; + + case 'similar_files': + return ` ā€¢ ${attrs.meaningful_name || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + Size: ${attrs.size ? formatFileSize(attrs.size) : 'Unknown'} + First Seen: ${attrs.first_submission_date ? formatDateTime(attrs.first_submission_date) : 'Unknown'}`; + + case 'execution_parents': + const stats = attrs.last_analysis_stats as AnalysisStats; + const totalDetections = stats ? + Object.values(stats).reduce((a: number, b: number) => a + b, 0) : 0; + return ` ā€¢ ${attrs.meaningful_name || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + First Seen: ${attrs.first_submission_date ? formatDateTime(attrs.first_submission_date) : 'Unknown'} + Detection Ratio: ${stats ? `${stats.malicious}/${totalDetections}` : 'Unknown'}`; + + case 'related_threat_actors': + return ` ā€¢ ${attrs.name || item.id} + Description: ${attrs.description || 'No description available'}`; + + default: + return ` ā€¢ ${item.id}`; + } +} + +export function formatFileResults(data: FileData): FormattedResult { + try { + const attributes = data?.attributes || {}; + const stats = attributes?.last_analysis_stats || {}; + const results = attributes?.last_analysis_results || {}; + const sandboxResults = attributes?.sandbox_verdicts || {}; + const sigmaResults = attributes?.sigma_analysis_results || []; + const sigmaStats = attributes?.sigma_analysis_stats || {}; + const crowdsourcedIds = attributes?.crowdsourced_ids_results || []; + const crowdsourcedIdsStats = attributes?.crowdsourced_ids_stats || {}; + const yaraResults = attributes?.crowdsourced_yara_results || []; + + let outputArray = [ + `šŸ“ File Analysis Results`, + `šŸ”‘ Hashes:`, + `ā€¢ SHA-256: ${attributes?.sha256 || 'Unknown'}`, + `ā€¢ SHA-1: ${attributes?.sha1 || 'Unknown'}`, + `ā€¢ MD5: ${attributes?.md5 || 'Unknown'}`, + attributes?.vhash ? `ā€¢ VHash: ${attributes.vhash}` : null, + ``, + `šŸ“„ File Information:`, + `ā€¢ Name: ${attributes?.meaningful_name || attributes?.names?.[0] || 'Unknown'}`, + `ā€¢ Type: ${attributes?.type_description || 'Unknown'}`, + `ā€¢ Size: ${attributes?.size ? formatFileSize(attributes.size) : 'Unknown'}`, + `ā€¢ First Seen: ${formatDateTime(attributes?.first_submission_date)}`, + `ā€¢ Last Modified: ${formatDateTime(attributes?.last_modification_date)}`, + `ā€¢ Times Submitted: ${attributes?.times_submitted || 0}`, + `ā€¢ Unique Sources: ${attributes?.unique_sources || 0}`, + ``, + `šŸ“Š Analysis Statistics:`, + formatDetectionResults(stats), + ].filter(Boolean); + + // Add reputation and votes + const reputation = attributes?.reputation ?? 'N/A'; + const votes = attributes?.total_votes || {}; + outputArray.push( + ``, + `šŸ‘„ Community Feedback:`, + `ā€¢ Reputation Score: ${reputation}`, + `ā€¢ Harmless Votes: ${votes.harmless || 0}`, + `ā€¢ Malicious Votes: ${votes.malicious || 0}` + ); + + // Add capabilities if available + if (attributes?.capabilities_tags?.length > 0) { + outputArray.push( + ``, + `āš” Capabilities:`, + ...attributes.capabilities_tags.map((tag: string) => `ā€¢ ${formatCapabilityTag(tag)}`) + ); + } + + // Add tags if available + if (attributes?.tags?.length > 0) { + outputArray.push( + ``, + `šŸ·ļø Tags:`, + ...attributes.tags.map((tag: string) => `ā€¢ ${tag}`) + ); + } + + // Add sandbox verdicts if available + if (Object.keys(sandboxResults).length > 0) { + outputArray.push( + ``, + `šŸ”¬ Sandbox Analysis Results:` + ); + for (const [sandbox, verdict] of Object.entries(sandboxResults)) { + const v = verdict as SandboxVerdict; + outputArray.push( + `${sandbox}:`, + `ā€¢ Category: ${v.category}`, + `ā€¢ Confidence: ${v.confidence}%`, + ...(v.malware_classification ? [`ā€¢ Classification: ${v.malware_classification.join(', ')}`] : []), + ...(v.malware_names ? [`ā€¢ Identified as: ${v.malware_names.join(', ')}`] : []), + `` + ); + } + } + + // Add Sigma analysis results if available + if (sigmaResults.length > 0 || Object.keys(sigmaStats).length > 0) { + outputArray.push( + ``, + `šŸŽÆ Sigma Analysis:`, + `Statistics:`, + `ā€¢ Critical: ${sigmaStats.critical || 0}`, + `ā€¢ High: ${sigmaStats.high || 0}`, + `ā€¢ Medium: ${sigmaStats.medium || 0}`, + `ā€¢ Low: ${sigmaStats.low || 0}`, + `` + ); + + if (sigmaResults.length > 0) { + outputArray.push(`Detected Rules:`); + for (const result of sigmaResults) { + const r = result as SigmaResult; + outputArray.push( + `${r.rule_title}:`, + `ā€¢ Level: ${r.rule_level}`, + `ā€¢ Source: ${r.rule_source}`, + `ā€¢ Description: ${r.rule_description}`, + `ā€¢ Author: ${r.rule_author}`, + `` + ); + } + } + } + + // Add crowdsourced IDS results if available + if (crowdsourcedIds.length > 0) { + outputArray.push( + ``, + `šŸ›”ļø Intrusion Detection Results:`, + `Statistics:`, + `ā€¢ High: ${crowdsourcedIdsStats.high || 0}`, + `ā€¢ Medium: ${crowdsourcedIdsStats.medium || 0}`, + `ā€¢ Low: ${crowdsourcedIdsStats.low || 0}`, + `ā€¢ Info: ${crowdsourcedIdsStats.info || 0}`, + `` + ); + + outputArray.push(`Alerts:`); + for (const alert of crowdsourcedIds) { + const a = alert as CrowdsourcedIdsResult; + outputArray.push( + `ā€¢ ${a.rule_msg}`, + ` - Severity: ${a.alert_severity}`, + ` - Category: ${a.rule_category}`, + ` - Source: ${a.rule_source}`, + `` + ); + } + } + + // Add YARA results if available + if (yaraResults.length > 0) { + outputArray.push( + ``, + `šŸ” YARA Detections:` + ); + for (const yara of yaraResults) { + const y = yara as YaraResult; + outputArray.push( + `ā€¢ Rule: ${y.rule_name}`, + ` - Description: ${y.description}`, + ` - Ruleset: ${y.ruleset_name}`, + ` - Source: ${y.source}`, + `` + ); + } + } + + // Add popular engine results + const popularEngines = Object.entries(results) + .filter(([engine]) => ['Microsoft', 'Kaspersky', 'Symantec', 'McAfee'].includes(engine)); + + if (popularEngines.length > 0) { + outputArray.push( + ``, + `šŸ”° Popular Engines Results:`, + ...popularEngines.map(([engine, result]: [string, any]) => + `ā€¢ ${engine}: ${result?.result || 'Clean'} ${ + result?.category === 'malicious' ? 'šŸ”“' : + result?.category === 'suspicious' ? 'āš ļø' : 'āœ…' + }` + ) + ); + } + + // Format relationships if available + if (data.relationships) { + outputArray.push('\nšŸ”— Relationships:'); + + for (const [relType, relData] of Object.entries(data.relationships)) { + const count = relData.meta?.count || (Array.isArray(relData.data) ? relData.data.length : 1); + + outputArray.push(`\n${relType} (${count} items):`); + + if (Array.isArray(relData.data)) { + relData.data.forEach(item => { + outputArray.push(formatRelationshipData(relType, item)); + }); + } else if (relData.data) { + outputArray.push(formatRelationshipData(relType, relData.data)); + } + } + } + + return { + type: "text", + text: outputArray.filter(Boolean).join('\n') + }; + } catch (error) { + logToFile(`Error formatting file results: ${error}`); + return { + type: "text", + text: "Error formatting file results" + }; + } +} + +function formatFileSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} + +function formatCapabilityTag(tag: string): string { + // Convert snake_case to Title Case and replace underscores with spaces + return tag + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts new file mode 100644 index 0000000..6d28a7f --- /dev/null +++ b/src/formatters/index.ts @@ -0,0 +1,9 @@ +// src/formatters/index.ts + +export { FormattedResult } from './types.js'; +export { formatDateTime, formatPercentage, formatDetectionResults } from './utils.js'; +export { formatUrlScanResults, UrlData } from './url.js'; +export { formatFileResults } from './file.js'; +export { formatIpResults } from './ip.js'; +export { formatDomainResults } from './domain.js'; +export { formatRelationshipResults } from './relationship.js'; diff --git a/src/formatters/ip.ts b/src/formatters/ip.ts new file mode 100644 index 0000000..d5eb7c6 --- /dev/null +++ b/src/formatters/ip.ts @@ -0,0 +1,262 @@ +// src/formatters/ip.ts + +import { FormattedResult } from './types.js'; +import { formatDateTime, formatDetectionResults } from './utils.js'; +import { logToFile } from '../utils/logging.js'; +import { RelationshipData } from '../types/virustotal.js'; + +interface Certificate { + issuer: { + C?: string; + CN?: string; + O?: string; + }; + subject: { + CN?: string; + }; + validity: { + not_after: string; + not_before: string; + }; + version: string; + serial_number: string; + thumbprint: string; + thumbprint_sha256: string; + extensions?: { + CA?: boolean; + subject_alternative_name?: string[]; + certificate_policies?: string[]; + extended_key_usage?: string[]; + key_usage?: string[]; + }; +} + +interface IpAttributes { + as_owner?: string; + asn?: number; + continent?: string; + country?: string; + network?: string; + regional_internet_registry?: string; + jarm?: string; + reputation?: number; + last_analysis_stats?: { + harmless: number; + malicious: number; + suspicious: number; + timeout: number; + undetected: number; + }; + last_analysis_results?: Record; + last_https_certificate?: Certificate; + last_https_certificate_date?: number; + whois?: string; + whois_date?: number; + tags?: string[]; + total_votes?: { + harmless: number; + malicious: number; + }; +} + +interface IpData { + id?: string; + ip?: string; + attributes?: IpAttributes; + relationships?: Record; +} + +function formatRelationshipData(relType: string, item: any): string { + const attrs = item.attributes || {}; + + switch (relType) { + case 'communicating_files': + case 'downloaded_files': + return ` ā€¢ ${attrs.meaningful_name || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + First Seen: ${attrs.first_submission_date ? formatDateTime(attrs.first_submission_date) : 'Unknown'}`; + + case 'historical_ssl_certificates': + const certInfo = []; + if (attrs.subject?.CN) certInfo.push(`Subject: ${attrs.subject.CN}`); + if (attrs.issuer?.CN) certInfo.push(`Issuer: ${attrs.issuer.CN}`); + if (attrs.validity?.not_before) certInfo.push(`Valid From: ${formatDateTime(new Date(attrs.validity.not_before).getTime() / 1000)}`); + if (attrs.validity?.not_after) certInfo.push(`Valid Until: ${formatDateTime(new Date(attrs.validity.not_after).getTime() / 1000)}`); + if (attrs.serial_number) certInfo.push(`Serial: ${attrs.serial_number}`); + return ` ā€¢ SSL Certificate${certInfo.length ? '\n ' + certInfo.join('\n ') : ''}`; + + case 'resolutions': + return ` ā€¢ Host: ${attrs.host_name || 'Unknown'} + Last Resolved: ${attrs.date ? formatDateTime(Number(attrs.date)) : 'Unknown'}`; + + case 'related_threat_actors': + return ` ā€¢ ${attrs.name || item.id} + Description: ${attrs.description || 'No description available'}`; + + case 'urls': + return ` ā€¢ ${attrs.url || item.id} + Last Analysis: ${attrs.last_analysis_date ? formatDateTime(attrs.last_analysis_date) : 'Unknown'} + Reputation: ${attrs.reputation ?? 'Unknown'}`; + + default: + return ` ā€¢ ${item.id}`; + } +} + +export function formatIpResults(data: IpData): FormattedResult { + try { + const attributes = data?.attributes || {}; + const stats = attributes?.last_analysis_stats || {}; + const votes = attributes?.total_votes || { harmless: 0, malicious: 0 }; + const tags = attributes?.tags || []; + + let outputArray = [ + `šŸŒ IP Address Analysis`, + `IP: ${data?.id || data?.ip || 'Unknown IP'}`, + ``, + `šŸ“ Network Information:`, + `ā€¢ AS Owner: ${attributes?.as_owner || 'Unknown'}`, + attributes?.asn ? `ā€¢ ASN: ${attributes.asn}` : null, + `ā€¢ Network: ${attributes?.network || 'Unknown'}`, + `ā€¢ Country: ${attributes?.country || 'Unknown'}`, + `ā€¢ Continent: ${attributes?.continent || 'Unknown'}`, + `ā€¢ Registry: ${attributes?.regional_internet_registry || 'Unknown'}`, + ``, + `šŸ“Š Analysis Statistics:`, + formatDetectionResults(stats), + ].filter(Boolean); + + // Add reputation and votes + const reputation = attributes?.reputation ?? 'N/A'; + outputArray.push( + ``, + `šŸ‘„ Community Feedback:`, + `ā€¢ Reputation Score: ${reputation}`, + `ā€¢ Harmless Votes: ${votes.harmless}`, + `ā€¢ Malicious Votes: ${votes.malicious}` + ); + + // Add JARM hash if available + if (attributes?.jarm) { + outputArray.push( + ``, + `šŸ”’ JARM Hash:`, + attributes.jarm + ); + } + + // Add HTTPS certificate information if available + if (attributes?.last_https_certificate) { + const cert = attributes.last_https_certificate; + outputArray.push( + ``, + `šŸ“œ SSL Certificate:`, + `ā€¢ Subject: ${cert.subject?.CN || 'Unknown'}`, + `ā€¢ Issuer: ${[cert.issuer?.O, cert.issuer?.CN].filter(Boolean).join(' - ')}`, + `ā€¢ Valid From: ${formatDateTime(new Date(cert.validity.not_before).getTime() / 1000)}`, + `ā€¢ Valid Until: ${formatDateTime(new Date(cert.validity.not_after).getTime() / 1000)}`, + `ā€¢ Serial Number: ${cert.serial_number}`, + `ā€¢ Version: ${cert.version}`, + `ā€¢ SHA-256 Fingerprint: ${cert.thumbprint_sha256}` + ); + + // Add certificate extensions if available + if (cert.extensions) { + if (cert.extensions.subject_alternative_name?.length) { + outputArray.push( + `ā€¢ Alternative Names:`, + ...cert.extensions.subject_alternative_name.map((name: string) => ` - ${name}`) + ); + } + if (cert.extensions.certificate_policies?.length) { + outputArray.push( + `ā€¢ Certificate Policies:`, + ...cert.extensions.certificate_policies.map((policy: string) => ` - ${policy}`) + ); + } + if (cert.extensions.extended_key_usage?.length) { + outputArray.push( + `ā€¢ Extended Key Usage:`, + ...cert.extensions.extended_key_usage.map((usage: string) => ` - ${usage}`) + ); + } + } + } + + // Add tags if available + if (tags.length > 0) { + outputArray.push( + ``, + `šŸ·ļø Tags:`, + ...tags.map((tag: string) => `ā€¢ ${tag}`) + ); + } + + // Add key WHOIS information if available + if (attributes?.whois) { + const whoisLines = attributes.whois.split('\n'); + const keyFields = [ + 'Organization:', + 'OrgName:', + 'Country:', + 'City:', + 'Address:', + 'RegDate:', + 'NetName:', + 'NetType:', + 'Comment:' + ]; + + const relevantWhois = whoisLines + .filter((line: string) => keyFields.some(field => line.trim().startsWith(field))) + .filter((item: string, index: number, self: string[]) => + self.indexOf(item) === index + ); // Remove duplicates + + if (relevantWhois.length > 0) { + outputArray.push( + ``, + `šŸ“‹ WHOIS Information:`, + ...relevantWhois.map((line: string) => `ā€¢ ${line.trim()}`), + attributes.whois_date ? + `\nLast Updated: ${formatDateTime(attributes.whois_date)}` : null + ); + } + } + + // Format relationships if available + if (data.relationships) { + outputArray.push('\nšŸ”— Relationships:'); + + for (const [relType, relData] of Object.entries(data.relationships)) { + const count = relData.meta?.count || (Array.isArray(relData.data) ? relData.data.length : 1); + + outputArray.push(`\n${relType} (${count} items):`); + + if (Array.isArray(relData.data)) { + relData.data.forEach(item => { + outputArray.push(formatRelationshipData(relType, item)); + }); + } else if (relData.data) { + outputArray.push(formatRelationshipData(relType, relData.data)); + } + } + } + + return { + type: "text", + text: outputArray.filter(Boolean).join('\n') + }; + } catch (error) { + logToFile(`Error formatting IP results: ${error}`); + return { + type: "text", + text: "Error formatting IP results" + }; + } +} diff --git a/src/formatters/relationship.ts b/src/formatters/relationship.ts new file mode 100644 index 0000000..aee9b4c --- /dev/null +++ b/src/formatters/relationship.ts @@ -0,0 +1,66 @@ +// src/formatters/relationship.ts + +import { FormattedResult } from './types.js'; +import { logToFile } from '../utils/logging.js'; + +export function formatRelationshipResults(data: any, type: 'url' | 'file' | 'ip' | 'domain'): FormattedResult { + try { + const relationshipData = data?.data || []; + const meta = data?.meta || {}; + + const typeEmoji = { + url: 'šŸ”—', + file: 'šŸ“', + ip: 'šŸŒ', + domain: 'šŸŒ' + }[type]; + + let outputArray = [ + `${typeEmoji} ${type.toUpperCase()} Relationship Results`, + `Type: ${data?.relationship || 'Unknown'}`, + `Total Results: ${meta?.count || relationshipData.length}`, + "", + "Related Items:", + ...relationshipData.map((item: any) => { + try { + switch(type) { + case 'url': + return `ā€¢ ${item?.attributes?.url || 'Unknown URL'}`; + case 'file': + return `ā€¢ ${item?.attributes?.meaningful_name || item?.id || 'Unknown File'} (${item?.attributes?.type || 'Unknown Type'})`; + case 'ip': + return `ā€¢ ${item?.attributes?.ip_address || item?.id || 'Unknown IP'}`; + case 'domain': + // Handle different domain relationship types + if (item?.attributes?.hostname) return `ā€¢ ${item.attributes.hostname}`; + if (item?.attributes?.value) return `ā€¢ ${item.attributes.value}`; + if (item?.attributes?.domain) return `ā€¢ ${item.attributes.domain}`; + if (item?.attributes?.ip_address) return `ā€¢ ${item.attributes.ip_address}`; + if (item?.attributes?.date) return `ā€¢ Record from ${item.attributes.date}`; + return `ā€¢ ${item?.id || 'Unknown Domain Item'}`; + default: + return `ā€¢ ${item?.id || 'Unknown Item'}`; + } + } catch (error) { + logToFile(`Error formatting relationship item: ${error}`); + return 'ā€¢ Error formatting item'; + } + }) + ]; + + if (meta?.cursor) { + outputArray.push('\nšŸ“„ More results available. Use cursor: ' + meta.cursor); + } + + return { + type: "text", + text: outputArray.join('\n') + }; + } catch (error) { + logToFile(`Error formatting relationship results: ${error}`); + return { + type: "text", + text: "Error formatting relationship results" + }; + } +} diff --git a/src/formatters/types.ts b/src/formatters/types.ts new file mode 100644 index 0000000..c2d8fc5 --- /dev/null +++ b/src/formatters/types.ts @@ -0,0 +1,6 @@ +// src/formatters/types.ts + +export interface FormattedResult { + type: "text"; + text: string; + } diff --git a/src/formatters/url.ts b/src/formatters/url.ts new file mode 100644 index 0000000..e4954c8 --- /dev/null +++ b/src/formatters/url.ts @@ -0,0 +1,324 @@ +// src/formatters/url.ts + +import { FormattedResult } from './types.js'; +import { formatDateTime, formatDetectionResults } from './utils.js'; +import { logToFile } from '../utils/logging.js'; +import { RelationshipData } from '../types/virustotal.js'; + +interface TrackerInstance { + id: string; + timestamp: number; + url: string; +} + +export interface UrlAttributes { + url?: string; + last_final_url?: string; + title?: string; + categories?: Record; + first_submission_date?: number; + last_analysis_date?: number; + last_modification_date?: number; + times_submitted?: number; + last_http_response_code?: number; + last_http_response_content_length?: number; + last_http_response_content_sha256?: string; + last_http_response_cookies?: Record; + last_http_response_headers?: Record; + reputation?: number; + html_meta?: Record; + redirection_chain?: string[]; + outgoing_links?: string[]; + trackers?: Record; + targeted_brand?: Record; + tags?: string[]; + total_votes?: { + harmless: number; + malicious: number; + }; + favicon?: { + dhash: string; + raw_md5: string; + }; + last_analysis_stats?: { + harmless: number; + malicious: number; + suspicious: number; + timeout: number; + undetected: number; + }; + last_analysis_results?: Record; +} + +export interface UrlData { + id?: string; + url?: string; + attributes?: UrlAttributes; + scan_id?: string; + scan_date?: string; + relationships?: Record; +} + +function formatRelationshipData(relType: string, item: any): string { + const attrs = item.attributes || {}; + + switch (relType) { + case 'communicating_files': + case 'downloaded_files': + return ` ā€¢ ${attrs.meaningful_name || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + First Seen: ${attrs.first_submission_date ? formatDateTime(attrs.first_submission_date) : 'Unknown'}`; + + case 'contacted_domains': + return ` ā€¢ ${attrs.id || item.id} + Last DNS Resolution: ${attrs.last_dns_records_date ? formatDateTime(attrs.last_dns_records_date) : 'Unknown'} + Categories: ${Object.entries(attrs.categories || {}).map(([k, v]) => `${k}: ${v}`).join(', ') || 'None'}`; + + case 'contacted_ips': + return ` ā€¢ ${attrs.ip_address || item.id} + Country: ${attrs.country || 'Unknown'} + AS Owner: ${attrs.as_owner || 'Unknown'} + Last Analysis Stats: ${attrs.last_analysis_stats ? + `šŸ”“ ${attrs.last_analysis_stats.malicious} malicious, āœ… ${attrs.last_analysis_stats.harmless} harmless` : + 'Unknown'}`; + + case 'redirects_to': + case 'redirecting_urls': + return ` ā€¢ ${attrs.url || item.id} + Last Analysis: ${attrs.last_analysis_date ? formatDateTime(attrs.last_analysis_date) : 'Unknown'} + Reputation: ${attrs.reputation ?? 'Unknown'}`; + + case 'related_threat_actors': + return ` ā€¢ ${attrs.name || item.id} + Description: ${attrs.description || 'No description available'}`; + + default: + return ` ā€¢ ${item.id}`; + } +} + +export function formatUrlScanResults(data: UrlData): FormattedResult { + try { + const attributes = data?.attributes || {}; + const stats = attributes?.last_analysis_stats || {}; + const votes = attributes?.total_votes || { harmless: 0, malicious: 0 }; + const tags = attributes?.tags || []; + const redirectionChain = attributes?.redirection_chain || []; + const outgoingLinks = attributes?.outgoing_links || []; + + let outputArray = [ + `šŸ” URL Analysis Results`, + ``, + `šŸŒ URL Information:`, + `ā€¢ URL: ${attributes?.url || data?.url || data?.id || 'Unknown URL'}`, + attributes?.last_final_url && attributes.last_final_url !== attributes.url ? + `ā€¢ Final URL: ${attributes.last_final_url}` : null, + attributes?.title ? `ā€¢ Page Title: ${attributes.title}` : null, + `ā€¢ First Seen: ${attributes.first_submission_date ? formatDateTime(attributes.first_submission_date) : 'N/A'}`, + `ā€¢ Last Analyzed: ${attributes.last_analysis_date ? formatDateTime(attributes.last_analysis_date) : 'N/A'}`, + `ā€¢ Times Submitted: ${attributes?.times_submitted || 0}`, + ``, + `šŸ“Š Analysis Statistics:`, + formatDetectionResults(stats), + ].filter(Boolean); + + // Add reputation and votes + const reputation = attributes?.reputation ?? 'N/A'; + outputArray.push( + ``, + `šŸ‘„ Community Feedback:`, + `ā€¢ Reputation Score: ${reputation}`, + `ā€¢ Harmless Votes: ${votes.harmless}`, + `ā€¢ Malicious Votes: ${votes.malicious}` + ); + + // Add HTTP response details + if (attributes?.last_http_response_code) { + outputArray.push( + ``, + `šŸŒ HTTP Response:`, + `ā€¢ Status Code: ${attributes.last_http_response_code}`, + `ā€¢ Content Length: ${formatSize(attributes.last_http_response_content_length || 0)}`, + attributes.last_http_response_content_sha256 ? + `ā€¢ Content SHA-256: ${attributes.last_http_response_content_sha256}` : null + ); + } + + // Add categories if available + if (attributes?.categories && Object.keys(attributes.categories).length > 0) { + outputArray.push( + ``, + `šŸ·ļø Categories:`, + ...Object.entries(attributes.categories).map(([service, category]) => + `ā€¢ ${service}: ${category}` + ) + ); + } + + // Add redirection chain if available + if (redirectionChain.length > 0) { + outputArray.push( + ``, + `ā†Ŗļø Redirection Chain:`, + ...redirectionChain.map((url: string, index: number) => + `${index + 1}. ${url}` + ) + ); + } + + // Add outgoing links if available + if (outgoingLinks.length > 0) { + outputArray.push( + ``, + `šŸ”— Outgoing Links:`, + ...outgoingLinks.slice(0, 5).map((url: string) => `ā€¢ ${url}`), + outgoingLinks.length > 5 ? + `... and ${outgoingLinks.length - 5} more` : null + ); + } + + // Add trackers if available + if (attributes?.trackers && Object.keys(attributes.trackers).length > 0) { + outputArray.push( + ``, + `šŸ“” Trackers:` + ); + for (const [tracker, instances] of Object.entries(attributes.trackers)) { + if (Array.isArray(instances)) { + outputArray.push( + `${tracker}:`, + ...instances.map((instance: TrackerInstance) => + `ā€¢ ID: ${instance.id}${instance.url ? `\n URL: ${instance.url}` : ''}` + ) + ); + } + } + } + + // Add targeted brand if available + if (attributes?.targeted_brand && Object.keys(attributes.targeted_brand).length > 0) { + outputArray.push( + ``, + `šŸŽÆ Targeted Brands:`, + ...Object.entries(attributes.targeted_brand).map(([source, brand]) => + `ā€¢ ${source}: ${brand}` + ) + ); + } + + // Add meta information if available + if (attributes?.html_meta && Object.keys(attributes.html_meta).length > 0) { + const relevantMeta = ['description', 'keywords', 'author']; + const metaEntries = Object.entries(attributes.html_meta) + .filter(([key]) => relevantMeta.includes(key)); + + if (metaEntries.length > 0) { + outputArray.push( + ``, + `šŸ“ Meta Information:` + ); + for (const [key, values] of metaEntries) { + if (Array.isArray(values) && values.length > 0) { + outputArray.push(`ā€¢ ${key}: ${values[0]}`); + } + } + } + } + + // Add favicon information if available + if (attributes?.favicon) { + outputArray.push( + ``, + `šŸ–¼ļø Favicon:`, + `ā€¢ Hash: ${attributes.favicon.dhash}`, + `ā€¢ MD5: ${attributes.favicon.raw_md5}` + ); + } + + // Add tags if available + if (tags.length > 0) { + outputArray.push( + ``, + `šŸ·ļø Tags:`, + ...tags.map((tag: string) => `ā€¢ ${tag}`) + ); + } + + // Add HTTP response headers if available + if (attributes?.last_http_response_headers && + Object.keys(attributes.last_http_response_headers).length > 0) { + const importantHeaders = ['server', 'content-type', 'x-powered-by', 'x-frame-options', 'x-xss-protection']; + const relevantHeaders = Object.entries(attributes.last_http_response_headers) + .filter(([key]) => importantHeaders.includes(key.toLowerCase())); + + if (relevantHeaders.length > 0) { + outputArray.push( + ``, + `šŸ“‹ Important HTTP Headers:` + ); + for (const [key, value] of relevantHeaders) { + outputArray.push(`ā€¢ ${key}: ${value}`); + } + } + } + + // Add HTTP response cookies if available + if (attributes?.last_http_response_cookies && + Object.keys(attributes.last_http_response_cookies).length > 0) { + outputArray.push( + ``, + `šŸŖ Cookies:`, + ...Object.entries(attributes.last_http_response_cookies) + .map(([name, value]) => `ā€¢ ${name}: ${value}`) + ); + } + + // Format relationships if available + if (data.relationships) { + outputArray.push('\nšŸ”— Relationships:'); + + for (const [relType, relData] of Object.entries(data.relationships)) { + const count = relData.meta?.count || (Array.isArray(relData.data) ? relData.data.length : 1); + + outputArray.push(`\n${relType} (${count} items):`); + + if (Array.isArray(relData.data)) { + relData.data.forEach(item => { + outputArray.push(formatRelationshipData(relType, item)); + }); + } else if (relData.data) { + outputArray.push(formatRelationshipData(relType, relData.data)); + } + } + } + + return { + type: "text", + text: outputArray.filter(Boolean).join('\n') + }; + } catch (error) { + logToFile(`Error formatting URL scan results: ${error}`); + return { + type: "text", + text: "Error formatting URL scan results" + }; + } +} + +function formatSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} diff --git a/src/formatters/utils.ts b/src/formatters/utils.ts new file mode 100644 index 0000000..80eed60 --- /dev/null +++ b/src/formatters/utils.ts @@ -0,0 +1,52 @@ +// src/formatters/utils.ts + +import { logToFile } from '../utils/logging.js'; + +export function formatDateTime(timestamp: number | string | null): string { + try { + if (!timestamp) return 'N/A'; + const date = typeof timestamp === 'number' + ? new Date(timestamp * 1000) + : new Date(timestamp); + return date.toLocaleString(); + } catch (error) { + logToFile(`Error formatting datetime: ${error}`); + return 'Invalid Date'; + } +} + +export function formatPercentage(num: number, total: number): string { + try { + if (total === 0) return '0.0%'; + return `${((num / total) * 100).toFixed(1)}%`; + } catch (error) { + logToFile(`Error formatting percentage: ${error}`); + return '0.0%'; + } +} + +export function formatDetectionResults(results: any): string { + try { + const malicious = results?.malicious || 0; + const suspicious = results?.suspicious || 0; + const harmless = results?.harmless || 0; + const undetected = results?.undetected || 0; + const total = malicious + suspicious + harmless + undetected; + + if (total === 0) { + return "No detection results available"; + } + + return [ + "Detection Results:", + `šŸ”“ Malicious: ${malicious} (${formatPercentage(malicious, total)})`, + `āš ļø Suspicious: ${suspicious} (${formatPercentage(suspicious, total)})`, + `āœ… Clean: ${harmless} (${formatPercentage(harmless, total)})`, + `āšŖ Undetected: ${undetected} (${formatPercentage(undetected, total)})`, + `šŸ“Š Total Scans: ${total}`, + ].join('\n'); + } catch (error) { + logToFile(`Error formatting detection results: ${error}`); + return "Error formatting detection results"; + } +} \ No newline at end of file diff --git a/src/handlers/domain.ts b/src/handlers/domain.ts new file mode 100644 index 0000000..d971139 --- /dev/null +++ b/src/handlers/domain.ts @@ -0,0 +1,158 @@ +import { AxiosInstance } from 'axios'; +import { queryVirusTotal } from '../utils/api.js'; +import { formatDomainResults } from '../formatters/index.js'; +import { GetDomainReportArgsSchema } from '../schemas/index.js'; +import { logToFile } from '../utils/logging.js'; +import { RelationshipItem, RelationshipData, DomainResponse } from '../types/virustotal.js'; + +// Default relationships to fetch if none specified +const DEFAULT_RELATIONSHIPS = [ + 'historical_whois', + 'historical_ssl_certificates', + 'resolutions', + 'communicating_files', + 'downloaded_files', + 'referrer_files' +] as const; + +function formatDate(dateStr: string | number): string { + try { + if (typeof dateStr === 'number') { + return new Date(dateStr * 1000).toLocaleDateString(); + } + return new Date(dateStr).toLocaleDateString(); + } catch { + return 'Unknown'; + } +} + +function formatRelationshipData(relType: string, item: RelationshipItem): string { + const attrs = item.attributes || {}; + + switch (relType) { + case 'resolutions': + return ` ā€¢ IP: ${attrs.ip_address} (${attrs.date ? new Date(Number(attrs.date) * 1000).toLocaleDateString() : 'Unknown'}) + Host: ${attrs.host_name || 'Unknown'} + Analysis Stats: + - IP: šŸ”“ ${attrs.ip_address_last_analysis_stats?.malicious || 0} malicious, āœ… ${attrs.ip_address_last_analysis_stats?.harmless || 0} harmless + - Host: šŸ”“ ${attrs.host_name_last_analysis_stats?.malicious || 0} malicious, āœ… ${attrs.host_name_last_analysis_stats?.harmless || 0} harmless`; + + case 'communicating_files': + return ` ā€¢ ${attrs.meaningful_name || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + First Seen: ${attrs.first_submission_date ? new Date(attrs.first_submission_date * 1000).toLocaleDateString() : 'Unknown'}`; + + case 'downloaded_files': + return ` ā€¢ ${attrs.meaningful_name || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + First Seen: ${attrs.first_submission_date ? new Date(attrs.first_submission_date * 1000).toLocaleDateString() : 'Unknown'}`; + + case 'urls': + return ` ā€¢ ${attrs.url || item.id} + Last Analysis: ${attrs.last_analysis_date ? new Date(attrs.last_analysis_date * 1000).toLocaleDateString() : 'Unknown'} + Reputation: ${attrs.reputation ?? 'Unknown'}`; + + case 'historical_whois': + const whoisMap = attrs.whois_map || {}; + const whoisInfo = []; + if (whoisMap['Registrar']) whoisInfo.push(`Registrar: ${whoisMap['Registrar']}`); + if (whoisMap['Creation Date']) whoisInfo.push(`Created: ${formatDate(whoisMap['Creation Date'])}`); + if (whoisMap['Registry Expiry Date']) whoisInfo.push(`Expires: ${formatDate(whoisMap['Registry Expiry Date'])}`); + if (whoisMap['Updated Date']) whoisInfo.push(`Updated: ${formatDate(whoisMap['Updated Date'])}`); + if (whoisMap['Registrant Organization']) whoisInfo.push(`Organization: ${whoisMap['Registrant Organization']}`); + if (attrs.registrar_name) whoisInfo.push(`Registrar: ${attrs.registrar_name}`); + return ` ā€¢ WHOIS Record from ${formatDate(attrs.last_updated || '')}${whoisInfo.length ? '\n ' + whoisInfo.join('\n ') : ''}`; + + case 'historical_ssl_certificates': + const certInfo = []; + if (attrs.subject?.CN) certInfo.push(`Subject: ${attrs.subject.CN}`); + if (attrs.issuer?.CN) certInfo.push(`Issuer: ${attrs.issuer.CN}`); + if (attrs.validity?.not_before) certInfo.push(`Valid From: ${formatDate(attrs.validity.not_before)}`); + if (attrs.validity?.not_after) certInfo.push(`Valid Until: ${formatDate(attrs.validity.not_after)}`); + if (attrs.serial_number) certInfo.push(`Serial: ${attrs.serial_number}`); + const altNames = attrs.extensions?.subject_alternative_name; + if (altNames && altNames.length) certInfo.push(`Alt Names: ${altNames.join(', ')}`); + return ` ā€¢ SSL Certificate${certInfo.length ? '\n ' + certInfo.join('\n ') : ''}`; + + case 'referrer_files': + const stats = attrs.last_analysis_stats || {}; + const totalDetections = (Object.values(stats) as number[]).reduce((a, b) => a + b, 0); + return ` ā€¢ ${attrs.meaningful_name || item.id} + Type: ${attrs.type_description || attrs.type || 'Unknown'} + Detection Ratio: ${attrs.last_analysis_stats ? + `${attrs.last_analysis_stats.malicious}/${totalDetections}` : + 'Unknown'}`; + + default: + if (attrs.hostname) return ` ā€¢ ${attrs.hostname}`; + if (attrs.ip_address) return ` ā€¢ ${attrs.ip_address}`; + if (attrs.url) return ` ā€¢ ${attrs.url}`; + if (attrs.value) return ` ā€¢ ${attrs.value}`; + return ` ā€¢ ${item.id}`; + } +} + +export async function handleGetDomainReport(axiosInstance: AxiosInstance, args: unknown) { + const parsedArgs = GetDomainReportArgsSchema.safeParse(args); + if (!parsedArgs.success) { + throw new Error("Invalid domain format"); + } + + const { domain, relationships = DEFAULT_RELATIONSHIPS } = parsedArgs.data; + + // First get the basic domain report + logToFile('Getting domain report...'); + const basicReport = await queryVirusTotal( + axiosInstance, + `/domains/${domain}`, + 'get' + ) as DomainResponse; + + // Then get full data for specified relationships + const relationshipData: Record = {}; + + for (const relType of relationships) { + logToFile(`Getting full data for ${relType}...`); + try { + const response = await queryVirusTotal( + axiosInstance, + `/domains/${domain}/${relType}`, + 'get' + ); + + // Format the relationship data + if (Array.isArray(response.data)) { + relationshipData[relType] = { + data: response.data.map((item: RelationshipItem) => ({ + ...item, + formattedOutput: formatRelationshipData(relType, item) + })), + meta: response.meta + }; + } else if (response.data) { + relationshipData[relType] = { + data: { + ...response.data, + formattedOutput: formatRelationshipData(relType, response.data) + }, + meta: response.meta + }; + } + } catch (error) { + logToFile(`Error fetching ${relType} data: ${error}`); + // Continue with other relationships even if one fails + } + } + + // Combine the basic report with detailed relationships + const combinedData = { + ...basicReport.data, + relationships: relationshipData + }; + + return { + content: [ + formatDomainResults(combinedData) + ], + }; +} diff --git a/src/handlers/file.ts b/src/handlers/file.ts new file mode 100644 index 0000000..d286947 --- /dev/null +++ b/src/handlers/file.ts @@ -0,0 +1,114 @@ +import { AxiosInstance } from 'axios'; +import { queryVirusTotal } from '../utils/api.js'; +import { formatFileResults } from '../formatters/index.js'; +import { GetFileReportArgsSchema, GetFileRelationshipArgsSchema } from '../schemas/index.js'; +import { logToFile } from '../utils/logging.js'; +import { RelationshipData } from '../types/virustotal.js'; + +// Default relationships to fetch +const DEFAULT_RELATIONSHIPS = [ + 'behaviours', + 'contacted_domains', + 'contacted_ips', + 'contacted_urls', + 'dropped_files', + 'execution_parents', + 'embedded_domains', + 'embedded_ips', + 'embedded_urls', + 'itw_domains', + 'itw_ips', + 'itw_urls', + 'related_threat_actors', + 'similar_files' +] as const; + +export async function handleGetFileReport(axiosInstance: AxiosInstance, args: unknown) { + const parsedArgs = GetFileReportArgsSchema.safeParse(args); + if (!parsedArgs.success) { + throw new Error("Invalid hash format. Must be MD5, SHA-1, or SHA-256"); + } + + const hash = parsedArgs.data.hash; + + // First get the basic file report + logToFile('Getting file report...'); + const basicReport = await queryVirusTotal( + axiosInstance, + `/files/${hash}` + ); + + // Then get full data for specified relationships + const relationshipData: Record = {}; + + for (const relType of DEFAULT_RELATIONSHIPS) { + logToFile(`Getting full data for ${relType}...`); + try { + const response = await queryVirusTotal( + axiosInstance, + `/files/${hash}/${relType}`, + 'get' + ); + + // Format the relationship data + if (Array.isArray(response.data)) { + relationshipData[relType] = { + data: response.data, + meta: response.meta + }; + } else if (response.data) { + relationshipData[relType] = { + data: response.data, + meta: response.meta + }; + } + } catch (error) { + logToFile(`Error fetching ${relType} data: ${error}`); + // Continue with other relationships even if one fails + } + } + + // Combine the basic report with relationships + const combinedData = { + ...basicReport.data, + relationships: relationshipData + }; + + return { + content: [ + formatFileResults(combinedData) + ], + }; +} + +export async function handleGetFileRelationship(axiosInstance: AxiosInstance, args: unknown) { + const parsedArgs = GetFileRelationshipArgsSchema.safeParse(args); + if (!parsedArgs.success) { + throw new Error("Invalid arguments for file relationship query"); + } + + const { hash, relationship, limit, cursor } = parsedArgs.data; + + const params: Record = { limit }; + if (cursor) params.cursor = cursor; + + const result = await queryVirusTotal( + axiosInstance, + `/files/${hash}/${relationship}`, + 'get' + ); + + return { + content: [ + formatFileResults({ + ...result.data, + relationships: { + [relationship]: { + data: result.data, + meta: result.meta + } + } + }) + ], + }; +} diff --git a/src/handlers/index.ts b/src/handlers/index.ts new file mode 100644 index 0000000..84698b8 --- /dev/null +++ b/src/handlers/index.ts @@ -0,0 +1,4 @@ +export * from './url.js'; +export * from './file.js'; +export * from './ip.js'; +export * from './domain.js'; diff --git a/src/handlers/ip.ts b/src/handlers/ip.ts new file mode 100644 index 0000000..39e09df --- /dev/null +++ b/src/handlers/ip.ts @@ -0,0 +1,107 @@ +import { AxiosInstance } from 'axios'; +import { queryVirusTotal } from '../utils/api.js'; +import { formatIpResults } from '../formatters/index.js'; +import { GetIpReportArgsSchema, GetIpRelationshipArgsSchema } from '../schemas/index.js'; +import { logToFile } from '../utils/logging.js'; +import { RelationshipData } from '../types/virustotal.js'; + +// Default relationships to fetch +const DEFAULT_RELATIONSHIPS = [ + 'communicating_files', + 'downloaded_files', + 'historical_ssl_certificates', + 'resolutions', + 'related_threat_actors', + 'urls' +] as const; + +export async function handleGetIpReport(axiosInstance: AxiosInstance, args: unknown) { + const parsedArgs = GetIpReportArgsSchema.safeParse(args); + if (!parsedArgs.success) { + throw new Error("Invalid IP address format"); + } + + const ip = parsedArgs.data.ip; + + // First get the basic IP report + logToFile('Getting IP report...'); + const basicReport = await queryVirusTotal( + axiosInstance, + `/ip_addresses/${ip}` + ); + + // Then get full data for specified relationships + const relationshipData: Record = {}; + + for (const relType of DEFAULT_RELATIONSHIPS) { + logToFile(`Getting full data for ${relType}...`); + try { + const response = await queryVirusTotal( + axiosInstance, + `/ip_addresses/${ip}/${relType}`, + 'get' + ); + + // Format the relationship data + if (Array.isArray(response.data)) { + relationshipData[relType] = { + data: response.data, + meta: response.meta + }; + } else if (response.data) { + relationshipData[relType] = { + data: response.data, + meta: response.meta + }; + } + } catch (error) { + logToFile(`Error fetching ${relType} data: ${error}`); + // Continue with other relationships even if one fails + } + } + + // Combine the basic report with detailed relationships + const combinedData = { + ...basicReport.data, + relationships: relationshipData + }; + + return { + content: [ + formatIpResults(combinedData) + ], + }; +} + +export async function handleGetIpRelationship(axiosInstance: AxiosInstance, args: unknown) { + const parsedArgs = GetIpRelationshipArgsSchema.safeParse(args); + if (!parsedArgs.success) { + throw new Error("Invalid arguments for IP relationship query"); + } + + const { ip, relationship, limit, cursor } = parsedArgs.data; + + const params: Record = { limit }; + if (cursor) params.cursor = cursor; + + const result = await queryVirusTotal( + axiosInstance, + `/ip_addresses/${ip}/${relationship}`, + 'get' + ); + + return { + content: [ + formatIpResults({ + id: ip, + attributes: result.data.attributes, + relationships: { + [relationship]: { + data: result.data, + meta: result.meta + } + } + }) + ], + }; +} diff --git a/src/handlers/url.ts b/src/handlers/url.ts new file mode 100644 index 0000000..8d8d23f --- /dev/null +++ b/src/handlers/url.ts @@ -0,0 +1,134 @@ +import { AxiosInstance } from 'axios'; +import { queryVirusTotal, encodeUrlForVt } from '../utils/api.js'; +import { formatUrlScanResults } from '../formatters/index.js'; +import { GetUrlReportArgsSchema, GetUrlRelationshipArgsSchema } from '../schemas/index.js'; +import { logToFile } from '../utils/logging.js'; +import { RelationshipData } from '../types/virustotal.js'; + +// Default relationships to fetch +const DEFAULT_RELATIONSHIPS = [ + 'communicating_files', + 'contacted_domains', + 'contacted_ips', + 'downloaded_files', + 'redirects_to', + 'redirecting_urls', + 'related_threat_actors' +] as const; + +export async function handleGetUrlReport(axiosInstance: AxiosInstance, args: unknown) { + const parsedArgs = GetUrlReportArgsSchema.safeParse(args); + if (!parsedArgs.success) { + throw new Error("Invalid URL format"); + } + + const url = parsedArgs.data.url; + const encodedUrl = encodeUrlForVt(url); + + // First submit URL for scanning + logToFile(`Scanning URL: ${url}`); + const scanResponse = await queryVirusTotal( + axiosInstance, + '/urls', + 'post', + new URLSearchParams({ url }) + ); + + const analysisId = scanResponse.data.id; + logToFile(`Analysis ID: ${analysisId}`); + + // Wait for analysis to complete + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Get analysis results + const analysisResponse = await queryVirusTotal( + axiosInstance, + `/analyses/${analysisId}` + ); + + // Then get full data for specified relationships + const relationshipData: Record = {}; + + for (const relType of DEFAULT_RELATIONSHIPS) { + logToFile(`Fetching ${relType}`); + try { + const response = await queryVirusTotal( + axiosInstance, + `/urls/${encodedUrl}/${relType}`, + 'get' + ); + + // Only log relationship metadata + logToFile(`${relType} count: ${ + Array.isArray(response.data) ? response.data.length : + response.data ? '1' : '0' + }`); + + // Format the relationship data + if (Array.isArray(response.data)) { + relationshipData[relType] = { + data: response.data, + meta: response.meta + }; + } else if (response.data) { + relationshipData[relType] = { + data: response.data, + meta: response.meta + }; + } + } catch (error) { + logToFile(`Failed to fetch ${relType}`); + // Continue with other relationships even if one fails + } + } + + // Combine the analysis results with relationships + const combinedData = { + id: analysisId, + url: url, + attributes: analysisResponse.data.attributes, + scan_date: new Date().toISOString(), + relationships: relationshipData + }; + + return { + content: [ + formatUrlScanResults(combinedData) + ], + }; +} + +export async function handleGetUrlRelationship(axiosInstance: AxiosInstance, args: unknown) { + const parsedArgs = GetUrlRelationshipArgsSchema.safeParse(args); + if (!parsedArgs.success) { + throw new Error("Invalid arguments for URL relationship query"); + } + + const { url, relationship, limit, cursor } = parsedArgs.data; + const encodedUrl = encodeUrlForVt(url); + + const params: Record = { limit }; + if (cursor) params.cursor = cursor; + + logToFile(`Fetching ${relationship} for URL: ${url}`); + const result = await queryVirusTotal( + axiosInstance, + `/urls/${encodedUrl}/${relationship}`, + 'get' + ); + + return { + content: [ + formatUrlScanResults({ + url: url, + attributes: result.data.attributes, + relationships: { + [relationship]: { + data: result.data, + meta: result.meta + } + } + }) + ], + }; +} diff --git a/src/index.ts b/src/index.ts index 1a4da1a..f226a73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node + import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { @@ -6,153 +7,37 @@ import { ListToolsRequestSchema, InitializeRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import axios, { AxiosInstance, AxiosError } from 'axios'; +import axios from 'axios'; import dotenv from "dotenv"; -import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; -import fs from "fs"; -import path from "path"; -import os from "os"; +import { logToFile } from './utils/logging.js'; +import { + GetUrlReportArgsSchema, + GetUrlRelationshipArgsSchema, + GetFileReportArgsSchema, + GetFileRelationshipArgsSchema, + GetIpReportArgsSchema, + GetIpRelationshipArgsSchema, + GetDomainReportArgsSchema, +} from './schemas/index.js'; +import { + handleGetUrlReport, + handleGetUrlRelationship, + handleGetFileReport, + handleGetFileRelationship, + handleGetIpReport, + handleGetIpRelationship, + handleGetDomainReport, +} from './handlers/index.js'; dotenv.config(); -const logFilePath = path.join(os.tmpdir(), "mcp-virustotal-server.log"); const API_KEY = process.env.VIRUSTOTAL_API_KEY; -// Debug logging for API key -logToFile(`API Key status: ${API_KEY ? 'Present' : 'Missing'}`); -if (API_KEY) { - logToFile(`API Key length: ${API_KEY.length}`); - logToFile(`API Key preview: ${API_KEY.substring(0, 4)}...${API_KEY.substring(API_KEY.length - 4)}`); -} - if (!API_KEY) { throw new Error("VIRUSTOTAL_API_KEY environment variable is required"); } -// Logging Helper Function -function logToFile(message: string) { - try { - const timestamp = new Date().toISOString(); - const formattedMessage = `[${timestamp}] ${message}\n`; - fs.appendFileSync(logFilePath, formattedMessage, "utf8"); - console.error(formattedMessage.trim()); - } catch (error) { - console.error(`Failed to write to log file: ${error}`); - } -} - -// Common Schema for Pagination -const PaginationSchema = z.object({ - limit: z.number().min(1).max(40).optional().default(10), - cursor: z.string().optional(), -}); - -// Tool Schemas -const ScanUrlArgsSchema = z.object({ - url: z.string().url("Must be a valid URL").describe("The URL to scan"), -}); - -const GetUrlRelationshipArgsSchema = z.object({ - url: z.string().url("Must be a valid URL").describe("The URL to get relationships for"), - relationship: z.enum([ - "analyses", "comments", "communicating_files", "contacted_domains", - "contacted_ips", "downloaded_files", "graphs", "last_serving_ip_address", - "network_location", "referrer_files", "referrer_urls", "redirecting_urls", - "redirects_to", "related_comments", "related_references", "related_threat_actors", - "submissions" - ]).describe("Type of relationship to query"), -}).merge(PaginationSchema); - -const ScanFileHashArgsSchema = z.object({ - hash: z - .string() - .regex(/^[a-fA-F0-9]{32,64}$/, "Must be a valid MD5, SHA-1, or SHA-256 hash") - .describe("MD5, SHA-1 or SHA-256 hash of the file"), -}); - -const GetFileRelationshipArgsSchema = z.object({ - hash: z - .string() - .regex(/^[a-fA-F0-9]{32,64}$/, "Must be a valid MD5, SHA-1, or SHA-256 hash") - .describe("MD5, SHA-1 or SHA-256 hash of the file"), - relationship: z.enum([ - "analyses", "behaviours", "bundled_files", "carbonblack_children", - "carbonblack_parents", "ciphered_bundled_files", "ciphered_parents", - "clues", "collections", "comments", "compressed_parents", "contacted_domains", - "contacted_ips", "contacted_urls", "dropped_files", "email_attachments", - "email_parents", "embedded_domains", "embedded_ips", "embedded_urls", - "execution_parents", "graphs", "itw_domains", "itw_ips", "itw_urls", - "memory_pattern_domains", "memory_pattern_ips", "memory_pattern_urls", - "overlay_children", "overlay_parents", "pcap_children", "pcap_parents", - "pe_resource_children", "pe_resource_parents", "related_references", - "related_threat_actors", "similar_files", "submissions", "screenshots", - "urls_for_embedded_js", "votes" - ]).describe("Type of relationship to query"), -}).merge(PaginationSchema); - -const GetIpReportArgsSchema = z.object({ - ip: z - .string() - .ip("Must be a valid IP address") - .describe("IP address to analyze"), -}); - -const GetIpRelationshipArgsSchema = z.object({ - ip: z - .string() - .ip("Must be a valid IP address") - .describe("IP address to analyze"), - relationship: z.enum([ - "comments", "communicating_files", "downloaded_files", "graphs", - "historical_ssl_certificates", "historical_whois", "related_comments", - "related_references", "related_threat_actors", "referrer_files", - "resolutions", "urls" - ]).describe("Type of relationship to query"), -}).merge(PaginationSchema); - -interface VirusTotalErrorResponse { - error?: { - message?: string; - }; -} - -// Helper Function to Query VirusTotal API -async function queryVirusTotal(axiosInstance: AxiosInstance, endpoint: string, method: 'get' | 'post' = 'get', data?: any) { - try { - // Log request details (excluding full API key) - logToFile(`Making ${method.toUpperCase()} request to: ${endpoint}`); - logToFile(`Request headers: ${JSON.stringify({ - ...axiosInstance.defaults.headers, - 'x-apikey': '[REDACTED]' - }, null, 2)}`); - - const response = method === 'get' - ? await axiosInstance.get(endpoint) - : await axiosInstance.post(endpoint, data); - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - // Log error details - logToFile(`API Error: ${JSON.stringify({ - status: axiosError.response?.status, - statusText: axiosError.response?.statusText, - data: axiosError.response?.data - }, null, 2)}`); - throw new Error(`VirusTotal API error: ${ - axiosError.response?.data?.error?.message || axiosError.message - }`); - } - throw error; - } -} - -// Helper function to encode URL for VirusTotal API -function encodeUrlForVt(url: string): string { - return Buffer.from(url).toString('base64url'); -} - // Server Setup const server = new Server( { @@ -182,8 +67,19 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => { name: "virustotal-mcp", version: "1.0.0", }, - instructions: - "This server provides tools for scanning and analyzing URLs, files, and IP addresses using the VirusTotal API.", + instructions: `VirusTotal Analysis Server + +This server provides comprehensive security analysis tools using the VirusTotal API. Each analysis tool automatically fetches relevant relationship data (e.g., contacted domains, downloaded files) along with the basic report. + +For more detailed relationship analysis, dedicated relationship tools are available to query specific types of relationships with pagination support. + +Available Analysis Types: +- URLs: Security reports and relationships like contacted domains +- Files: Analysis results and relationships like dropped files +- IPs: Security reports and relationships like historical data +- Domains: DNS information and relationships like subdomains + +All tools return formatted results with clear categorization and relationship data.`, }; }); @@ -191,35 +87,40 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => { server.setRequestHandler(ListToolsRequestSchema, async () => { const tools = [ { - name: "scan_url", - description: "Scan a URL for potential security threats", - inputSchema: zodToJsonSchema(ScanUrlArgsSchema), + name: "get_url_report", + description: "Get a comprehensive URL analysis report including security scan results and key relationships (communicating files, contacted domains/IPs, downloaded files, redirects, threat actors). Returns both the basic security analysis and automatically fetched relationship data.", + inputSchema: zodToJsonSchema(GetUrlReportArgsSchema), }, { name: "get_url_relationship", - description: "Get related objects for a URL (e.g., downloaded files, contacted domains)", + description: "Query a specific relationship type for a URL with pagination support. Choose from 17 relationship types including analyses, communicating files, contacted domains/IPs, downloaded files, graphs, referrers, redirects, and threat actors. Useful for detailed investigation of specific relationship types.", inputSchema: zodToJsonSchema(GetUrlRelationshipArgsSchema), }, { - name: "scan_file_hash", - description: "Get analysis results for a file hash", - inputSchema: zodToJsonSchema(ScanFileHashArgsSchema), + name: "get_file_report", + description: "Get a comprehensive file analysis report using its hash (MD5/SHA-1/SHA-256). Includes detection results, file properties, and key relationships (behaviors, dropped files, network connections, embedded content, threat actors). Returns both the basic analysis and automatically fetched relationship data.", + inputSchema: zodToJsonSchema(GetFileReportArgsSchema), }, { name: "get_file_relationship", - description: "Get related objects for a file (e.g., dropped files, contacted domains)", + description: "Query a specific relationship type for a file with pagination support. Choose from 41 relationship types including behaviors, network connections, dropped files, embedded content, execution chains, and threat actors. Useful for detailed investigation of specific relationship types.", inputSchema: zodToJsonSchema(GetFileRelationshipArgsSchema), }, { name: "get_ip_report", - description: "Get security analysis report for an IP address", + description: "Get a comprehensive IP address analysis report including geolocation, reputation data, and key relationships (communicating files, historical certificates/WHOIS, resolutions). Returns both the basic analysis and automatically fetched relationship data.", inputSchema: zodToJsonSchema(GetIpReportArgsSchema), }, { name: "get_ip_relationship", - description: "Get related objects for an IP address (e.g., downloaded files, resolutions)", + description: "Query a specific relationship type for an IP address with pagination support. Choose from 12 relationship types including communicating files, historical SSL certificates, WHOIS records, resolutions, and threat actors. Useful for detailed investigation of specific relationship types.", inputSchema: zodToJsonSchema(GetIpRelationshipArgsSchema), }, + { + name: "get_domain_report", + description: "Get a comprehensive domain analysis report including DNS records, WHOIS data, and key relationships (SSL certificates, subdomains, historical data). Optionally specify which relationships to include in the report. Returns both the basic analysis and relationship data.", + inputSchema: zodToJsonSchema(GetDomainReportArgsSchema), + } ]; logToFile("Registered tools."); @@ -242,208 +143,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { - case "scan_url": { - const parsedArgs = ScanUrlArgsSchema.safeParse(args); - if (!parsedArgs.success) { - throw new Error("Invalid URL format"); - } - - logToFile(`Scanning URL: ${parsedArgs.data.url}`); - - const scanResponse = await queryVirusTotal( - axiosInstance, - '/urls', - 'post', - new URLSearchParams({ url: parsedArgs.data.url }) - ); - - const analysisId = scanResponse.data.id; - await new Promise(resolve => setTimeout(resolve, 3000)); - - const analysisResponse = await queryVirusTotal( - axiosInstance, - `/analyses/${analysisId}` - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - scan_id: analysisId, - url: parsedArgs.data.url, - scan_date: new Date().toISOString(), - results: analysisResponse.data.attributes, - stats: analysisResponse.data.attributes.stats, - }, null, 2), - }, - ], - }; - } - - case "get_url_relationship": { - const parsedArgs = GetUrlRelationshipArgsSchema.safeParse(args); - if (!parsedArgs.success) { - throw new Error("Invalid arguments for URL relationship query"); - } - - const { url, relationship, limit, cursor } = parsedArgs.data; - const encodedUrl = encodeUrlForVt(url); - - logToFile(`Getting ${relationship} for URL: ${url}`); - - const params: Record = { limit }; - if (cursor) params.cursor = cursor; - - const result = await queryVirusTotal( - axiosInstance, - `/urls/${encodedUrl}/${relationship}`, - 'get' - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - url, - relationship, - data: result.data, - meta: result.meta, - }, null, 2), - }, - ], - }; - } - - case "scan_file_hash": { - const parsedArgs = ScanFileHashArgsSchema.safeParse(args); - if (!parsedArgs.success) { - throw new Error("Invalid hash format. Must be MD5, SHA-1, or SHA-256"); - } - - logToFile(`Looking up file hash: ${parsedArgs.data.hash}`); - - const result = await queryVirusTotal( - axiosInstance, - `/files/${parsedArgs.data.hash}` - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - hash: parsedArgs.data.hash, - scan_date: result.data.attributes.last_analysis_date, - reputation: result.data.attributes.reputation, - total_votes: result.data.attributes.total_votes, - stats: result.data.attributes.last_analysis_stats, - results: result.data.attributes.last_analysis_results, - }, null, 2), - }, - ], - }; - } - - case "get_file_relationship": { - const parsedArgs = GetFileRelationshipArgsSchema.safeParse(args); - if (!parsedArgs.success) { - throw new Error("Invalid arguments for file relationship query"); - } - - const { hash, relationship, limit, cursor } = parsedArgs.data; - - logToFile(`Getting ${relationship} for file hash: ${hash}`); - - const params: Record = { limit }; - if (cursor) params.cursor = cursor; - - const result = await queryVirusTotal( - axiosInstance, - `/files/${hash}/${relationship}`, - 'get' - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - hash, - relationship, - data: result.data, - meta: result.meta, - }, null, 2), - }, - ], - }; - } + case "get_url_report": + return await handleGetUrlReport(axiosInstance, args); - case "get_ip_report": { - const parsedArgs = GetIpReportArgsSchema.safeParse(args); - if (!parsedArgs.success) { - throw new Error("Invalid IP address format"); - } + case "get_url_relationship": + return await handleGetUrlRelationship(axiosInstance, args); - logToFile(`Getting IP report for: ${parsedArgs.data.ip}`); - - const result = await queryVirusTotal( - axiosInstance, - `/ip_addresses/${parsedArgs.data.ip}` - ); + case "get_file_report": + return await handleGetFileReport(axiosInstance, args); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - ip: parsedArgs.data.ip, - as_owner: result.data.attributes.as_owner, - country: result.data.attributes.country, - reputation: result.data.attributes.reputation, - total_votes: result.data.attributes.total_votes, - last_analysis_stats: result.data.attributes.last_analysis_stats, - last_analysis_results: result.data.attributes.last_analysis_results, - }, null, 2), - }, - ], - }; - } + case "get_file_relationship": + return await handleGetFileRelationship(axiosInstance, args); - case "get_ip_relationship": { - const parsedArgs = GetIpRelationshipArgsSchema.safeParse(args); - if (!parsedArgs.success) { - throw new Error("Invalid arguments for IP relationship query"); - } + case "get_ip_report": + return await handleGetIpReport(axiosInstance, args); - const { ip, relationship, limit, cursor } = parsedArgs.data; - - logToFile(`Getting ${relationship} for IP: ${ip}`); - - const params: Record = { limit }; - if (cursor) params.cursor = cursor; - - const result = await queryVirusTotal( - axiosInstance, - `/ip_addresses/${ip}/${relationship}`, - 'get' - ); + case "get_ip_relationship": + return await handleGetIpRelationship(axiosInstance, args); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - ip, - relationship, - data: result.data, - meta: result.meta, - }, null, 2), - }, - ], - }; - } + case "get_domain_report": + return await handleGetDomainReport(axiosInstance, args); default: throw new Error(`Unknown tool: ${name}`); diff --git a/src/schemas/index.ts b/src/schemas/index.ts new file mode 100644 index 0000000..5b44bb8 --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; + +// Common Schema for Pagination +export const PaginationSchema = z.object({ + limit: z.number().min(1).max(40).optional().default(10), + cursor: z.string().optional(), +}); + +// Tool Schemas +export const GetUrlReportArgsSchema = z.object({ + url: z.string().url("Must be a valid URL").describe("The URL to analyze"), +}); + +export const GetUrlRelationshipArgsSchema = z.object({ + url: z.string().url("Must be a valid URL").describe("The URL to get relationships for"), + relationship: z.enum([ + "analyses", "comments", "communicating_files", "contacted_domains", + "contacted_ips", "downloaded_files", "graphs", "last_serving_ip_address", + "network_location", "referrer_files", "referrer_urls", "redirecting_urls", + "redirects_to", "related_comments", "related_references", "related_threat_actors", + "submissions" + ]).describe("Type of relationship to query"), +}).merge(PaginationSchema); + +export const GetFileReportArgsSchema = z.object({ + hash: z + .string() + .regex(/^[a-fA-F0-9]{32,64}$/, "Must be a valid MD5, SHA-1, or SHA-256 hash") + .describe("MD5, SHA-1 or SHA-256 hash of the file"), +}); + +export const GetFileRelationshipArgsSchema = z.object({ + hash: z + .string() + .regex(/^[a-fA-F0-9]{32,64}$/, "Must be a valid MD5, SHA-1, or SHA-256 hash") + .describe("MD5, SHA-1 or SHA-256 hash of the file"), + relationship: z.enum([ + "analyses", "behaviours", "bundled_files", "carbonblack_children", + "carbonblack_parents", "ciphered_bundled_files", "ciphered_parents", + "clues", "collections", "comments", "compressed_parents", "contacted_domains", + "contacted_ips", "contacted_urls", "dropped_files", "email_attachments", + "email_parents", "embedded_domains", "embedded_ips", "embedded_urls", + "execution_parents", "graphs", "itw_domains", "itw_ips", "itw_urls", + "memory_pattern_domains", "memory_pattern_ips", "memory_pattern_urls", + "overlay_children", "overlay_parents", "pcap_children", "pcap_parents", + "pe_resource_children", "pe_resource_parents", "related_references", + "related_threat_actors", "similar_files", "submissions", "screenshots", + "urls_for_embedded_js", "votes" + ]).describe("Type of relationship to query"), +}).merge(PaginationSchema); + +export const GetIpReportArgsSchema = z.object({ + ip: z + .string() + .ip("Must be a valid IP address") + .describe("IP address to analyze"), +}); + +export const GetIpRelationshipArgsSchema = z.object({ + ip: z + .string() + .ip("Must be a valid IP address") + .describe("IP address to analyze"), + relationship: z.enum([ + "comments", "communicating_files", "downloaded_files", "graphs", + "historical_ssl_certificates", "historical_whois", "related_comments", + "related_references", "related_threat_actors", "referrer_files", + "resolutions", "urls" + ]).describe("Type of relationship to query"), +}).merge(PaginationSchema); + +// Define available domain relationships +export const DomainRelationships = [ + "caa_records", + "cname_records", + "comments", + "communicating_files", + "downloaded_files", + "historical_ssl_certificates", + "historical_whois", + "immediate_parent", + "mx_records", + "ns_records", + "parent", + "referrer_files", + "related_comments", + "related_references", + "related_threat_actors", + "resolutions", + "soa_records", + "siblings", + "subdomains", + "urls", + "user_votes" +] as const; + +export const GetDomainReportArgsSchema = z.object({ + domain: z + .string() + .regex(/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, "Must be a valid domain name") + .describe("Domain name to analyze"), + relationships: z.array(z.enum(DomainRelationships)) + .optional() + .describe("Optional array of relationships to include in the report"), +}); + +export const GetDomainRelationshipArgsSchema = z.object({ + domain: z + .string() + .regex(/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, "Must be a valid domain name") + .describe("Domain name to analyze"), + relationship: z.enum(DomainRelationships).describe("Type of relationship to query"), +}).merge(PaginationSchema); diff --git a/src/types/virustotal.ts b/src/types/virustotal.ts new file mode 100644 index 0000000..b70c520 --- /dev/null +++ b/src/types/virustotal.ts @@ -0,0 +1,109 @@ +// Common types for VirusTotal API responses + +export interface AnalysisStats { + malicious: number; + suspicious: number; + undetected: number; + harmless: number; + timeout: number; +} + +export interface RelationshipItem { + type: string; + id: string; + attributes?: { + hostname?: string; + ip_address?: string; + url?: string; + name?: string; + value?: string; + date?: string | number; + resolver?: string; + host_name?: string; + host_name_last_analysis_stats?: AnalysisStats; + ip_address_last_analysis_stats?: AnalysisStats; + type_description?: string; + type?: string; + first_submission_date?: number; + last_analysis_date?: number; + reputation?: number; + meaningful_name?: string; + [key: string]: any; // Allow for additional attributes + }; + context_attributes?: { + url?: string; + timestamp?: number; + }; + error?: { + code: string; + message: string; + }; + formattedOutput?: string; +} + +export interface RelationshipData { + data: RelationshipItem | RelationshipItem[]; + meta?: { + count?: number; + }; + links?: { + self?: string; + related?: string; + next?: string; + }; +} + +// Base interface for all VirusTotal responses +export interface BaseAttributes { + last_analysis_date?: number; + last_analysis_stats?: AnalysisStats; + reputation?: number; + total_votes?: { + harmless: number; + malicious: number; + }; +} + +// Domain-specific attributes +export interface DomainAttributes extends BaseAttributes { + categories?: Record; + last_dns_records?: Array<{ + type: string; + value: string; + ttl: number; + }>; + threat_severity?: { + threat_severity_level: string; + level_description?: string; + threat_severity_data?: { + num_detections: number; + belongs_to_bad_collection: boolean; + }; + last_analysis_date?: string; + }; + popularity_ranks?: Record; + whois?: string; + creation_date?: number; + last_modification_date?: number; +} + +// Domain data structure +export interface DomainData { + type?: string; + id?: string; + attributes?: DomainAttributes; + relationships?: Record; +} + +// Generic response structure +export interface VirusTotalResponse { + data: { + type?: string; + id?: string; + attributes?: T; + relationships?: Record; + }; +} + +// Type alias for domain response +export type DomainResponse = VirusTotalResponse; diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..998b0e7 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,99 @@ +import { AxiosInstance, AxiosError } from 'axios'; +import axios from 'axios'; +import { logToFile } from './logging.js'; + +export interface VirusTotalErrorResponse { + error?: { + message?: string; + }; +} + +// Helper Function to Query VirusTotal API +export async function queryVirusTotal( + axiosInstance: AxiosInstance, + endpoint: string, + method: 'get' | 'post' = 'get', + data?: any, + params?: Record +) { + if (!endpoint) { + throw new Error('Endpoint is required'); + } + try { + // Log minimal request details + logToFile(`${method.toUpperCase()} ${endpoint}`); + + if (params) { + // Log only param keys, not values + logToFile(`Request params: ${Object.keys(params).join(', ')}`); + } + + const response = method === 'get' + ? await axiosInstance.get(endpoint, { params }) + : await axiosInstance.post(endpoint, data, { params }); + + // Log minimal response info + logToFile(`Response status: ${response.status}`); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + // Log only essential error info + logToFile(`API Error: ${axiosError.response?.status} - ${ + axiosError.response?.data?.error?.message || axiosError.message + }`); + throw new Error(`VirusTotal API error: ${ + axiosError.response?.data?.error?.message || axiosError.message + }`); + } + throw error; + } +} + +// Helper function to encode URL for VirusTotal API +export function encodeUrlForVt(url: string): string { + return Buffer.from(url).toString('base64url'); +} + +// Helper function to get all available relationships for each type +export const RELATIONSHIPS = { + url: [ + "analyses", "comments", "communicating_files", "contacted_domains", + "contacted_ips", "downloaded_files", "graphs", "last_serving_ip_address", + "network_location", "referrer_files", "referrer_urls", "redirecting_urls", + "redirects_to", "related_comments", "related_references", "related_threat_actors", + "submissions" + ], + file: [ + "analyses", "behaviours", "bundled_files", "carbonblack_children", + "carbonblack_parents", "ciphered_bundled_files", "ciphered_parents", + "clues", "collections", "comments", "compressed_parents", "contacted_domains", + "contacted_ips", "contacted_urls", "dropped_files", "email_attachments", + "email_parents", "embedded_domains", "embedded_ips", "embedded_urls", + "execution_parents", "graphs", "itw_domains", "itw_ips", "itw_urls", + "memory_pattern_domains", "memory_pattern_ips", "memory_pattern_urls", + "overlay_children", "overlay_parents", "pcap_children", "pcap_parents", + "pe_resource_children", "pe_resource_parents", "related_references", + "related_threat_actors", "similar_files", "submissions", "screenshots", + "urls_for_embedded_js", "votes" + ], + ip: [ + "comments", "communicating_files", "downloaded_files", "graphs", + "historical_ssl_certificates", "historical_whois", "related_comments", + "related_references", "related_threat_actors", "referrer_files", + "resolutions", "urls" + ], + domain: [ + "caa_records", "cname_records", "comments", "communicating_files", + "downloaded_files", "graphs", "historical_ssl_certificates", "historical_whois", + "immediate_parent", "mx_records", "ns_records", "parent", "referrer_files", + "related_comments", "related_references", "related_threat_actors", + "resolutions", "soa_records", "siblings", "subdomains", "urls", "user_votes" + ] +} as const; + +// Helper function to get relationships query parameter +export function getRelationshipsParam(type: keyof typeof RELATIONSHIPS): string { + return RELATIONSHIPS[type].join(','); +} diff --git a/src/utils/logging.ts b/src/utils/logging.ts new file mode 100644 index 0000000..7178aee --- /dev/null +++ b/src/utils/logging.ts @@ -0,0 +1,27 @@ +// utils/logging.ts + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from 'url'; + +// Get the directory name of the current module +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Create logs directory in project root +const logsDir = path.join(__dirname, '..', '..', 'logs'); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir); +} + +const logFilePath = path.join(logsDir, "mcp-virustotal-server.log"); + +export function logToFile(message: string) { + try { + const timestamp = new Date().toISOString(); + const formattedMessage = `[${timestamp}] ${message}\n`; + fs.appendFileSync(logFilePath, formattedMessage, "utf8"); + console.error(formattedMessage.trim()); + } catch (error) { + console.error(`Failed to write to log file: ${error}`); + } +} diff --git a/tsconfig.json b/tsconfig.json index 9fd6ac4..dea9877 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,4 +12,4 @@ }, "include": ["src/**/*"], "exclude": ["node_modules"] -} +} \ No newline at end of file