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
4 changes: 3 additions & 1 deletion server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ DB_USER=
DB_PASSWORD=
DB_NAME=
PORT=3000

SSL_MODE=require
VIRUSTOTAL_API_KEY=
VIRUSTOTAL_BASE_URL=https://www.virustotal.com/api/v3
4 changes: 2 additions & 2 deletions server/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 135 additions & 0 deletions server/src/constants/virustotal.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { ThreatIntelProvider, IocType } from "./provider.interface";

// 1 : virustotal response------------------------------------
interface VirusTotalResponse {
data: {
attributes: {
last_analysis_stats: {
malicious: number;
suspicious: number;
undetected: number;
harmless: number;
timeout: number;
};
reputation?: number;
tags?: string[];
};
};
}

// 2 : normalized response------------------------------------------------------------
interface NormalizedResponse {
provider_name: string;
verdict: "benign" | "suspicious" | "malicious";
score: number;
tags?: string[];
confidence?: number;
summary?: string;
}

class VirusTotalProvider implements ThreatIntelProvider<NormalizedResponse> {
readonly name = "virustotal";
readonly supportedIocTypes: ReadonlyArray<IocType> = [
"ip",
"domain",
"url",
"hash",
];

private apiKey = process.env.VIRUSTOTAL_API_KEY;
private baseUrl =
process.env.VIRUSTOTAL_BASE_URL || "https://www.virustotal.com/api/v3";

async query(ioc: string, type: IocType): Promise<NormalizedResponse> {
if (!this.apiKey) {
return this.fail("VirusTotal API key not configured");
}

try {
const endpoint = this.getEndpoint(ioc, type);
if (!endpoint) {
return this.fail("Unsupported IOC type");
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
"x-apikey": this.apiKey,
accept: "application/json",
},
});

if (response.status === 429) {
return this.fail("Rate limit exceeded");
}

if (!response.ok) {
return this.fail(`API error: ${response.status}`);
}
const data = (await response.json()) as VirusTotalResponse;
return this.normalizeResponse(data);
} catch {
return this.fail("VirusTotal query failed");
}
}
//----get endpoint based on ioc type-------------------------------------------------------
private getEndpoint(ioc: string, type: IocType): string | null {
switch (type) {
case "ip":
return `/ip_addresses/${ioc}`;
case "domain":
return `/domains/${ioc}`;
case "hash":
return `/files/${ioc}`;
case "url":
return `/urls/${this.encodeUrl(ioc)}`;
default:
return null;
}
}

private encodeUrl(url: string): string {
return Buffer.from(url).toString("base64url");
}

//----normalize response---------------------------------------------------------------------
private normalizeResponse(data: VirusTotalResponse): NormalizedResponse {
const stats = data.data?.attributes?.last_analysis_stats;
if (!stats) {
return this.fail("Incomplete VirusTotal response");
}
const total =
stats.malicious +
stats.suspicious +
stats.undetected +
stats.harmless +
stats.timeout;
const maliciousCount = stats.malicious;
const suspiciousCount = stats.suspicious;

let verdict: NormalizedResponse["verdict"] = "benign";
if (maliciousCount > 0) verdict = "malicious";
else if (suspiciousCount > 0) verdict = "suspicious";

const score = total > 0 ? Math.round((maliciousCount / total) * 100) : 0;
return {
provider_name: this.name,
verdict,
score,
tags: data.data.attributes.tags,
confidence: Math.min(100, total),
summary: `Malicious: ${maliciousCount}, Suspicious: ${suspiciousCount}, Harmless: ${stats.harmless}, Undetected: ${stats.undetected}, Timeout: ${stats.timeout}`,
};
}

//FAILURE handler--------------------------------------------------------------------------------------------
private fail(reason: string): NormalizedResponse {
return {
provider_name: this.name,
verdict: "benign",
score: 0,
confidence: 0,
summary: reason,
};
}
}

export { VirusTotalProvider };
38 changes: 19 additions & 19 deletions server/src/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import { Pool } from 'pg';
import { Pool } from "pg";

const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || "5432"),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
ssl:
process.env.SSL_MODE === "require" ? { rejectUnauthorized: false } : false,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});

async function testConnection(): Promise<void> {
try {
const client = await pool.connect();
console.log('PostgreSQL connected successfully');
client.release();
} catch (error) {
console.error('PostgreSQL connection failed:', error);
throw error;
}
try {
const client = await pool.connect();
console.log("PostgreSQL connected successfully");
client.release();
} catch (error) {
console.error("PostgreSQL connection failed:", error);
throw error;
}
}

export default pool;
export { testConnection };