How to use pompelmi in a NestJS application

NestJS is a TypeScript-first framework built on top of Express or Fastify. The idiomatic way to add antivirus scanning is to write a NestJS interceptor that inspects uploaded files before the controller method runs. This guide builds the provider, interceptor, and module from scratch without relying on any third-party NestJS wrapper.

pompelmi scans files on disk. NestJS's FileInterceptor from @nestjs/platform-express uses Multer under the hood, so the file will be on disk at file.path by the time your interceptor runs.

Install

npm install pompelmi @nestjs/platform-express @nestjs/common @nestjs/core
npm install --save-dev @types/multer

Scan provider

Encapsulate the pompelmi call in an injectable service. This makes it easy to mock in unit tests and swap out the underlying scanner without touching controller code.

// scan/scan.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { scan, Verdict } from 'pompelmi';

export type ScanVerdict = typeof Verdict.Clean
                        | typeof Verdict.Malicious
                        | typeof Verdict.ScanError;

@Injectable()
export class ScanService {
  /**
   * Scan an absolute file path with ClamAV via pompelmi.
   * Returns the Verdict Symbol, or throws InternalServerErrorException
   * if clamscan is unavailable.
   */
  async scanFile(filePath: string): Promise<ScanVerdict> {
    try {
      return await scan(filePath) as ScanVerdict;
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : String(err);
      throw new InternalServerErrorException('ClamAV scan failed: ' + message);
    }
  }
}

Antivirus interceptor

The interceptor runs after FileInterceptor has written the upload to disk. It calls ScanService, deletes the file on rejection, and throws the appropriate HTTP exception.

// scan/scan.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  BadRequestException,
  UnprocessableEntityException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Verdict } from 'pompelmi';
import * as fs from 'fs';
import { ScanService } from './scan.service';

@Injectable()
export class AntivirusInterceptor implements NestInterceptor {
  constructor(private readonly scanService: ScanService) {}

  async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
    const request = ctx.switchToHttp().getRequest();
    const file: Express.Multer.File | undefined = request.file;

    if (!file) {
      // No file in this request — skip scanning
      return next.handle();
    }

    const verdict = await this.scanService.scanFile(file.path);

    if (verdict === Verdict.Malicious) {
      this.deleteFile(file.path);
      throw new BadRequestException('Malware detected. Upload rejected.');
    }

    if (verdict === Verdict.ScanError) {
      this.deleteFile(file.path);
      throw new UnprocessableEntityException(
        'Scan could not complete. Upload rejected as a precaution.'
      );
    }

    // Verdict.Clean — continue to controller
    return next.handle();
  }

  private deleteFile(filePath: string): void {
    try {
      fs.unlinkSync(filePath);
    } catch (_) {
      // Best-effort deletion
    }
  }
}

Scan module

Bundle the service and interceptor into a module so they can be imported into any feature module that needs antivirus scanning.

// scan/scan.module.ts
import { Module } from '@nestjs/common';
import { ScanService } from './scan.service';
import { AntivirusInterceptor } from './scan.interceptor';

@Module({
  providers: [ScanService, AntivirusInterceptor],
  exports:   [ScanService, AntivirusInterceptor],
})
export class ScanModule {}

Controller

Import ScanModule in the feature module, then apply AntivirusInterceptor after FileInterceptor on the upload route.

// upload/upload.controller.ts
import {
  Controller,
  Post,
  UseInterceptors,
  UploadedFile,
  HttpCode,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import * as path from 'path';
import { AntivirusInterceptor } from '../scan/scan.interceptor';

@Controller('upload')
export class UploadController {
  @Post()
  @HttpCode(200)
  @UseInterceptors(
    // 1. FileInterceptor writes the file to disk
    FileInterceptor('file', {
      storage: diskStorage({
        destination: '/tmp/uploads',
        filename: (_req, file, cb) => {
          const unique = Date.now() + '-' + Math.round(Math.random() * 1e9);
          cb(null, unique + path.extname(file.originalname));
        },
      }),
    }),
    // 2. AntivirusInterceptor scans the saved file
    AntivirusInterceptor,
  )
  handleUpload(@UploadedFile() file: Express.Multer.File) {
    // At this point the file is guaranteed Clean
    return { status: 'ok', file: file.filename };
  }
}

Register the modules in your application root:

// app.module.ts
import { Module } from '@nestjs/common';
import { ScanModule } from './scan/scan.module';
import { UploadModule } from './upload/upload.module';

@Module({
  imports: [ScanModule, UploadModule],
})
export class AppModule {}
// upload/upload.module.ts
import { Module } from '@nestjs/common';
import { ScanModule } from '../scan/scan.module';
import { UploadController } from './upload.controller';

@Module({
  imports:     [ScanModule],
  controllers: [UploadController],
})
export class UploadModule {}

Unit testing the interceptor

Mock ScanService to test the interceptor in isolation from ClamAV.

// scan/scan.interceptor.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';
import { Verdict } from 'pompelmi';
import { AntivirusInterceptor } from './scan.interceptor';
import { ScanService } from './scan.service';

describe('AntivirusInterceptor', () => {
  let interceptor: AntivirusInterceptor;
  let scanService: jest.Mocked<ScanService>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AntivirusInterceptor,
        {
          provide: ScanService,
          useValue: { scanFile: jest.fn() },
        },
      ],
    }).compile();

    interceptor = module.get(AntivirusInterceptor);
    scanService  = module.get(ScanService);
  });

  function mockContext(filePath: string | undefined) {
    return {
      switchToHttp: () => ({
        getRequest: () => ({ file: filePath ? { path: filePath } : undefined }),
      }),
    } as unknown as ExecutionContext;
  }

  const next: CallHandler = { handle: () => of('ok') };

  it('calls next when file is clean', async () => {
    scanService.scanFile.mockResolvedValue(Verdict.Clean);
    const result = await interceptor.intercept(mockContext('/tmp/file.pdf'), next);
    expect(result).toBeDefined();
  });

  it('throws BadRequestException for malicious file', async () => {
    scanService.scanFile.mockResolvedValue(Verdict.Malicious);
    await expect(interceptor.intercept(mockContext('/tmp/evil.exe'), next))
      .rejects.toBeInstanceOf(BadRequestException);
  });

  it('skips scan when no file is present', async () => {
    const result = await interceptor.intercept(mockContext(undefined), next);
    expect(scanService.scanFile).not.toHaveBeenCalled();
    expect(result).toBeDefined();
  });
});