6 Commits

Author SHA1 Message Date
e31e7cca44 1.1.0
Some checks failed
Default (tags) / security (push) Successful in 55s
Default (tags) / test (push) Failing after 1m32s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-02-03 13:34:52 +01:00
a19638b476 feat(ClamAvService): Add ClamAV Manager with Docker container management capabilities. 2025-02-03 13:34:52 +01:00
f71219f0ca 1.0.4
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 1m18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-01-10 03:27:14 +01:00
e7e9cb30e7 fix(documentation): Removed redundant conclusion section in readme. 2025-01-10 03:27:13 +01:00
dbe0821429 1.0.3
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 1m20s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-01-10 03:26:22 +01:00
5500524ce7 fix(readme): Fix formatting errors in the README file for consistent Markdown syntax. 2025-01-10 03:26:22 +01:00
12 changed files with 525 additions and 44 deletions

View File

@ -1,5 +1,23 @@
# Changelog
## 2025-02-03 - 1.1.0 - feat(ClamAvService)
Add ClamAV Manager with Docker container management capabilities.
- Introduced ClamAVManager class to manage ClamAV Docker containers.
- Implemented startContainer and stopContainer methods in ClamAVManager.
- Integrated ClamAVManager into ClamAvService for managing container lifecycle.
- Added ClamAVManager test setups and helpers in test suite.
## 2025-01-10 - 1.0.4 - fix(documentation)
Removed redundant conclusion section in readme.
- Removed the conclusion section from the README file for conciseness.
## 2025-01-10 - 1.0.3 - fix(readme)
Fix formatting errors in the README file for consistent Markdown syntax.
- Removed stray Markdown syntax in README file.
## 2025-01-10 - 1.0.2 - fix(documentation)
Updated README and package metadata to reflect antivirus scanning capabilities and usage instructions.

19
license Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2025 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartantivirus",
"version": "1.0.2",
"version": "1.1.0",
"private": false,
"description": "A Node.js package for integrating antivirus scanning capabilities using ClamAV, allowing in-memory file and data scanning.",
"main": "dist_ts/index.js",

View File

@ -1,4 +1,3 @@
```markdown
# @push.rocks/smartantivirus
A package for performing antivirus testing, especially suitable for use with ClamAV.
@ -119,9 +118,3 @@ The tests include creating and utilizing a `ClamAvService` instance and attempts
Beyond scanning strings and buffers, you can implement additional advanced use cases based on your specific application needs, such as integrating into web services or automating file scans in cloud environments. Consider building upon provided functionalities and adapting them to meet the requirements of your application architecture.
With the help of Node.js worker threads or external task queues like RabbitMQ, you can distribute scanning tasks efficiently within high-traffic environments.
### Conclusion
`@push.rocks/smartantivirus` provides a simple yet effective way to incorporate antivirus checks into your Node.js applications, leveraging the robust ClamAV engine. With features like in-memory scanning and connection verification, you can seamlessly ensure your data security solutions are integrated into your application lifecycle.
```
undefined

View File

@ -0,0 +1,90 @@
import { ClamAVManager } from '../../ts/classes.clamav.manager.js';
import { execAsync } from '../../ts/plugins.js';
let clamManager: ClamAVManager | null = null;
let isCleaningUp = false;
export async function getManager(): Promise<ClamAVManager> {
if (!clamManager) {
throw new Error('ClamAV manager not initialized');
}
return clamManager;
}
export async function setupClamAV(): Promise<ClamAVManager> {
console.log('[Helper] Setting up ClamAV...');
// First cleanup any existing containers
await forceCleanupContainer();
if (!clamManager) {
console.log('[Helper] Creating new ClamAV manager instance');
clamManager = new ClamAVManager();
await clamManager.startContainer();
console.log('[Helper] ClamAV manager initialized');
} else {
console.log('[Helper] Using existing ClamAV manager instance');
}
return clamManager;
}
export async function cleanupClamAV(): Promise<void> {
if (isCleaningUp) {
console.log('[Helper] Cleanup already in progress, skipping');
return;
}
isCleaningUp = true;
console.log('[Helper] Cleaning up ClamAV...');
try {
if (clamManager) {
await clamManager.stopContainer();
console.log('[Helper] ClamAV container stopped');
}
await forceCleanupContainer();
} catch (error) {
console.error('[Helper] Error during cleanup:', error);
throw error;
} finally {
clamManager = null;
isCleaningUp = false;
}
}
async function forceCleanupContainer(): Promise<void> {
try {
// Stop any existing container
await execAsync('docker stop clamav-daemon').catch(() => {});
// Remove any existing container
await execAsync('docker rm -f clamav-daemon').catch(() => {});
console.log('[Helper] Forced cleanup of existing containers complete');
} catch (error) {
// Ignore errors as the container might not exist
}
}
// Handle interrupts
process.on('SIGINT', async () => {
console.log('\n[Helper] Received SIGINT. Cleaning up...');
try {
await cleanupClamAV();
process.exit(0);
} catch (err) {
console.error('[Helper] Error during cleanup:', err);
process.exit(1);
}
});
// Ensure cleanup on process exit
process.on('exit', () => {
if (clamManager && !isCleaningUp) {
console.log('[Helper] Process exit detected, attempting cleanup');
// We can't use async functions in exit handler, so we do our best
try {
execAsync('docker stop clamav-daemon').catch(() => {});
execAsync('docker rm -f clamav-daemon').catch(() => {});
} catch {}
}
});

View File

@ -0,0 +1,55 @@
import { expect, tap } from '../ts/plugins.js';
import { type ClamAVLogEvent, ClamAVManager } from '../ts/classes.clamav.manager.js';
import { setupClamAV, cleanupClamAV, getManager } from './helpers/clamav.helper.js';
let manager: ClamAVManager;
tap.test('setup', async () => {
manager = await setupClamAV();
expect(manager).toBeTruthy();
});
tap.test('should have initialized container and receive logs', async () => {
let logReceived = false;
// Add event listener for logs
manager.on('log', (event: ClamAVLogEvent) => {
console.log(`[Test] Received log event: ${event.type} - ${event.message}`);
logReceived = true;
});
// Wait for logs
const maxWaitTime = 5000;
const startTime = Date.now();
while (!logReceived && Date.now() - startTime < maxWaitTime) {
await new Promise(resolve => setTimeout(resolve, 100));
}
expect(logReceived).toBeTruthy('No logs received within timeout period');
// Verify container is running by checking if we can get database info
try {
const dbInfo = await manager.getDatabaseInfo();
expect(dbInfo).toBeTruthy('Container should be running and able to get database info');
} catch (error) {
console.error('Error getting database info:', error);
expect.fail('Failed to get database info - container may not be fully initialized');
}
});
tap.test('should get database info', async () => {
const dbInfo = await manager.getDatabaseInfo();
console.log('Database Info:', dbInfo);
expect(dbInfo).toBeTruthy();
});
tap.test('should update database', async () => {
await manager.updateDatabase();
});
tap.test('cleanup', async () => {
await cleanupClamAV();
});
tap.start();

View File

@ -1,35 +1,40 @@
import { expect, expectAsync, tap } from '@push.rocks/tapbundle';
import { expect, tap } from '../ts/plugins.js';
import * as smartantivirus from '../ts/index.js';
import { setupClamAV, cleanupClamAV } from './helpers/clamav.helper.js';
const EICAR_TEST_STRING = 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*';
let clamService: smartantivirus.ClamAvService;
tap.test('should create a ClamAvService instance', async () => {
clamService = new smartantivirus.ClamAvService();
expect(clamService).toBeDefined();
tap.test('setup', async () => {
await setupClamAV();
});
tap.test('should scan a string', async () => {
const scanResult = await clamService.scanString('X5O!P%@AP[4\PZX54(P^)7CC)7}' + '$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*');
tap.test('should create a ClamAvService instance and initialize ClamAV', async () => {
clamService = new smartantivirus.ClamAvService();
expect(clamService).toBeTruthy();
// The manager will start the container and wait for initialization
await clamService.verifyConnection();
});
tap.test('should detect EICAR test string', async () => {
const scanResult = await clamService.scanString(EICAR_TEST_STRING);
console.log('Scan Result:', scanResult);
// expect(scanResult).toEqual({ isInfected: true, reason: 'FOUND' });
expect(scanResult.isInfected).toEqual(true);
expect(scanResult.reason).toBeTruthy();
});
tap.test('should not detect clean string', async () => {
const scanResult = await clamService.scanString('This is a clean string with no virus signature');
console.log('Clean Scan Result:', scanResult);
expect(scanResult.isInfected).toEqual(false);
expect(scanResult.reason).toBeUndefined();
});
tap.test('cleanup', async () => {
await cleanupClamAV();
});
tap.start();
/* (async () => {
try {
await clamService.updateVirusDefinitions(); // Step 2: Update definitions
await clamService.startClamDaemon(); // Step 3: Start daemon
const scanResult = await clamService.scanString('EICAR test string...');
console.log('Scan Result:', scanResult);
} catch (error) {
console.error('Error:', error);
}
})(); */

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartantivirus',
version: '1.0.2',
version: '1.1.0',
description: 'A Node.js package for integrating antivirus scanning capabilities using ClamAV, allowing in-memory file and data scanning.'
}

View File

@ -0,0 +1,274 @@
import { exec, spawn, net, promisify, EventEmitter, execAsync } from './plugins.js';
export interface ClamAVLogEvent {
timestamp: string;
message: string;
type: 'update' | 'scan' | 'system' | 'error';
}
export class ClamAVManager extends EventEmitter {
private containerId: string | null = null;
private containerName = 'clamav-daemon';
private imageTag = 'clamav/clamav:latest';
private port = 3310;
constructor() {
super();
}
/**
* Start the ClamAV container if it's not already running
*/
public async startContainer(): Promise<void> {
try {
console.log('[ClamAV] Starting container initialization...');
// Check if container is already running
const { stdout: psOutput } = await execAsync('docker ps --filter name=' + this.containerName);
if (psOutput.includes(this.containerName)) {
console.log('[ClamAV] Container is already running');
this.containerId = (await execAsync(`docker ps -q --filter name=${this.containerName}`)).stdout.trim();
console.log('[ClamAV] Container ID:', this.containerId);
this.attachLogWatcher();
await this.waitForInitialization();
return;
}
// Check if container exists but is stopped
const { stdout: psaOutput } = await execAsync('docker ps -a --filter name=' + this.containerName);
if (psaOutput.includes(this.containerName)) {
console.log('[ClamAV] Found stopped container, starting it...');
await execAsync(`docker start ${this.containerName}`);
this.containerId = (await execAsync(`docker ps -q --filter name=${this.containerName}`)).stdout.trim();
console.log('[ClamAV] Started existing container, ID:', this.containerId);
} else {
// Create and start new container
console.log('[ClamAV] Creating new container...');
const { stdout } = await execAsync(
`docker run -d --name ${this.containerName} -p ${this.port}:3310 ${this.imageTag}`
);
this.containerId = stdout.trim();
console.log('[ClamAV] Created new container, ID:', this.containerId);
}
this.attachLogWatcher();
console.log('[ClamAV] Waiting for initialization...');
await this.waitForInitialization();
console.log('[ClamAV] Container successfully initialized');
} catch (error) {
console.error('[ClamAV] Error starting container:', error);
throw error;
}
}
/**
* Stop the ClamAV container
*/
public async stopContainer(): Promise<void> {
if (!this.containerId) {
console.log('No ClamAV container is running');
return;
}
try {
await execAsync(`docker stop ${this.containerId}`);
console.log('Stopped ClamAV container');
} catch (error) {
console.error('Error stopping ClamAV container:', error);
throw error;
}
}
/**
* Manually trigger a database update
*/
public async updateDatabase(): Promise<void> {
if (!this.containerId) {
throw new Error('ClamAV container is not running');
}
try {
// First check if freshclam is already running
const { stdout: psOutput } = await execAsync(`docker exec ${this.containerId} ps aux | grep freshclam`);
if (psOutput.includes('/usr/local/sbin/freshclam -d')) {
console.log('Freshclam daemon is already running');
// Wait a bit to ensure database is updated
await new Promise(resolve => setTimeout(resolve, 2000));
return;
}
// If not running as daemon, try to update manually
const { stdout, stderr } = await execAsync(`docker exec ${this.containerId} freshclam --no-warnings`);
console.log('Database update output:', stdout);
if (stderr) {
console.error('Database update errors:', stderr);
}
} catch (error) {
// Check if the error is due to freshclam already running
if (error.stderr?.includes('ERROR: Problem with internal logger') ||
error.stdout?.includes('Resource temporarily unavailable')) {
console.log('Freshclam is already running, skipping manual update');
return;
}
console.error('Error updating ClamAV database:', error);
throw error;
}
}
/**
* Get the current database version information
*/
public async getDatabaseInfo(): Promise<string> {
if (!this.containerId) {
throw new Error('ClamAV container is not running');
}
try {
// Try both .cld and .cvd files since ClamAV can use either format
try {
const { stdout } = await execAsync(`docker exec ${this.containerId} sigtool --info /var/lib/clamav/daily.cld`);
return stdout;
} catch {
const { stdout } = await execAsync(`docker exec ${this.containerId} sigtool --info /var/lib/clamav/daily.cvd`);
return stdout;
}
} catch (error) {
console.error('Error getting database info:', error);
throw error;
}
}
/**
* Watch container logs and emit events for different types of log messages
*/
private attachLogWatcher(): void {
if (!this.containerId) return;
const logProcess = spawn('docker', ['logs', '-f', this.containerId]);
logProcess.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
lines.forEach(line => {
if (!line.trim()) return;
const event: ClamAVLogEvent = {
timestamp: new Date().toISOString(),
message: line,
type: this.determineLogType(line)
};
this.emit('log', event);
console.log(`[ClamAV ${event.type}] ${event.message}`);
});
});
logProcess.stderr.on('data', (data) => {
const event: ClamAVLogEvent = {
timestamp: new Date().toISOString(),
message: data.toString(),
type: 'error'
};
this.emit('log', event);
console.error(`[ClamAV error] ${event.message}`);
});
logProcess.on('error', (error) => {
console.error('Error in log watcher:', error);
});
}
/**
* Determine the type of log message
*/
private determineLogType(logMessage: string): ClamAVLogEvent['type'] {
const lowerMessage = logMessage.toLowerCase();
if (lowerMessage.includes('update') || lowerMessage.includes('freshclam')) {
return 'update';
} else if (lowerMessage.includes('scan') || lowerMessage.includes('found')) {
return 'scan';
} else if (lowerMessage.includes('error') || lowerMessage.includes('warning')) {
return 'error';
}
return 'system';
}
/**
* Wait for ClamAV to initialize by checking both logs and service readiness
*/
private async waitForInitialization(): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.containerId) {
reject(new Error('Container ID not set'));
return;
}
let timeout: NodeJS.Timeout;
let checkCount = 0;
const maxChecks = 60; // Check for 60 seconds
const startTime = Date.now();
// Check service readiness
const checkService = async () => {
try {
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
console.log(`[ClamAV] Checking service readiness (attempt ${checkCount + 1}, ${elapsedTime}s elapsed)...`);
// First check if the service is accepting connections
const client = new net.Socket();
await new Promise<void>((resolveConn, rejectConn) => {
const connectTimeout = setTimeout(() => {
client.destroy();
rejectConn(new Error('Connection timeout'));
}, 1000);
client.connect(this.port, 'localhost', () => {
clearTimeout(connectTimeout);
client.end();
resolveConn();
});
client.on('error', (err) => {
clearTimeout(connectTimeout);
rejectConn(err);
});
});
// Verify the service is responding to commands
const { stdout } = await execAsync(`echo PING | nc localhost ${this.port}`);
if (!stdout.includes('PONG')) {
throw new Error('Service not responding to commands');
}
// If we can connect and get a PONG, the service is ready
console.log('[ClamAV] Service is accepting connections and responding to commands');
cleanup();
resolve();
} catch (error) {
// Service not ready yet, will retry
if (checkCount >= maxChecks) {
cleanup();
reject(new Error(`ClamAV initialization timed out after ${maxChecks} seconds. Last error: ${error.message}`));
return;
}
checkCount++;
}
};
const cleanup = () => {
clearTimeout(timeout);
clearInterval(serviceCheck);
};
const serviceCheck = setInterval(checkService, 1000);
timeout = setTimeout(() => {
cleanup();
reject(new Error('ClamAV initialization timed out after 60 seconds'));
}, 60000);
// Start initial service check
checkService();
});
}
}

View File

@ -1,25 +1,35 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { exec } from 'child_process';
import net from 'net';
import { promisify } from 'util';
const execAsync = promisify(exec);
import { net } from './plugins.js';
import { ClamAVManager } from './classes.clamav.manager.js';
export class ClamAvService {
private host: string;
private port: number;
private manager: ClamAVManager;
constructor(host: string = '127.0.0.1', port: number = 3310) {
this.host = host;
this.port = port;
this.manager = new ClamAVManager();
// Listen to ClamAV logs
this.manager.on('log', (event) => {
if (event.type === 'scan') {
console.log(`[ClamAV Scan] ${event.message}`);
}
});
}
private async ensureContainerStarted(): Promise<void> {
await this.manager.startContainer();
}
/**
* Scans an in-memory Buffer using ClamAV daemon's INSTREAM command.
*/
public async scanBuffer(buffer: Buffer): Promise<{ isInfected: boolean; reason?: string }> {
await this.ensureContainerStarted();
return new Promise((resolve, reject) => {
const client = new net.Socket();
@ -85,6 +95,7 @@ export class ClamAvService {
* Verifies the ClamAV daemon is reachable.
*/
public async verifyConnection(): Promise<boolean> {
await this.ensureContainerStarted();
return new Promise((resolve, reject) => {
const client = new net.Socket();

View File

@ -1 +1,2 @@
export * from './classes.smartantivirus.js';
export * from './classes.smartantivirus.js';
export * from './classes.clamav.manager.js';

View File

@ -1,24 +1,39 @@
// node native scope
// Node.js built-in modules
import * as fs from 'fs';
import * as path from 'path';
import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import { EventEmitter } from 'events';
import net from 'net';
export {
fs,
path,
}
exec,
spawn,
promisify,
EventEmitter,
net
};
// @push.rocks scope
import * as smartpath from '@push.rocks/smartpath';
import * as smartfile from '@push.rocks/smartfile';
import { expect, tap } from '@push.rocks/tapbundle';
export {
smartpath,
smartfile,
}
expect,
tap
};
// third party scope
// Third party scope
import axios from 'axios';
export {
axios,
}
axios
};
// Common utilities
export const execAsync = promisify(exec);