cloudrun-malware-scanner/server.ts (160 lines of code) (raw):

/* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as process from 'node:process'; import * as ClamdClient from 'clamdjs'; import * as express from 'express'; import {Storage} from '@google-cloud/storage'; import {GoogleAuth} from 'google-auth-library'; import {logger} from './logger'; import { name as packageName, version as packageVersion, description as packageDescription, } from './package.json'; import * as metrics from './metrics'; import {Scanner, StorageObjectData} from './scanner'; import {promisify} from 'node:util'; import {execFile, ExecFileException} from 'node:child_process'; import {setTimeout} from 'timers/promises'; import {readAndVerifyConfig, Config} from './config'; const execFilePromise = promisify(execFile); /** Encapsulates the HTTP server and its methods */ class Server { app: express.Application; constructor( private readonly scanner: Scanner, private readonly config: Config, private readonly port: number, ) { this.config = config; this.port = port; this.app = express(); this.app.use(express.json()); this.app.get('/', (_, res) => this.versionInfo(res)); this.app.post('/', (req, res) => this.handlePost(req, res)); this.app.get('/ready', (_, res) => this.healthCheck(res)); } /** * Trivial handler for get requests which returns the clam version. * * Use: * curl -D - -H "Authorization: Bearer $(gcloud auth print-identity-token)" CLOUD_RUN_APP_URL */ async versionInfo(res: express.Response): Promise<void> { res .status(200) .type('text/plain') .send( `${packageName} version ${packageVersion}\nUsing Clam AV version: ${await this.scanner.getClamVersion()}\n\n${packageDescription}\n\n`, ); } /** * Health check from cloud run. * Verifies that clamd is running. */ async healthCheck(res: express.Response): Promise<void> { try { await this.scanner.pingClamD(); res.status(200).json({message: 'Health Check Suceeded'}); } catch (e) { logger.fatal(e, `Health check failed to contact clamd: ${e as Error}`); res.status(500).json({message: 'Health Check Failed', status: 'error'}); } } /** * Route that is invoked by Cloud Run when a malware scan is requested * for a document uploaded to GCS. */ async handlePost(req: express.Request, res: express.Response): Promise<void> { try { // eslint-disable-next-line const objectKind = req.body?.kind as string | null; switch (objectKind) { case 'storage#object': res.json( await this.scanner.handleGcsObject(req.body as StorageObjectData), ); break; case 'schedule#cvd_update': res.json(await handleCvdUpdate(this.config)); break; default: logger.error( {payload: req.body as unknown}, `Error processing request: object kind: ${objectKind} is not supported`, ); res.status(400).json({message: 'invalid request', status: 'error'}); break; } } catch (e) { logger.error( {err: e, payload: req.body as unknown}, `Failure when processing request: ${e as Error}`, ); res.status(500).json({message: e, status: 'error'}); } } start(): void { this.app.listen(this.port, () => { logger.info( `${packageName} version ${packageVersion} started on port ${this.port}`, ); }); } } /** * Triggers a update check on the CVD Mirror GCS bucket. */ async function handleCvdUpdate(config: Config): Promise<{ status: string; updated: boolean; }> { try { logger.info('Starting CVD Mirror update'); const result = await execFilePromise('./updateCvdMirror.sh', [ config.ClamCvdMirrorBucket, ]); logger.info('CVD Mirror update check complete. output:\n' + result.stdout); // look for updated versions in output by looking for // "updated (version: " from freshclam output. const newVersions = result.stdout .split('\n') // Look for lines beginning with Downloaded .filter((line) => line.indexOf('Downloaded') >= 0); for (const version of newVersions) { logger.info(`CVD Mirror updated: ${version}`); } const isUpdated = newVersions.length > 0; metrics.writeCvdMirrorUpdated(true, isUpdated); return { status: 'CvdUpdateComplete', updated: isUpdated, }; } catch (e) { const err = e as ExecFileException; logger.error( {err}, `Failure when running ./updateCvdMirror.sh: ${err.message}\nstdout: ${err.stdout}\nstderr: \n${err.stderr}`, ); metrics.writeCvdMirrorUpdated(false, false); throw err as Error; } } /** * Wait up to 5 mins for ClamD to respond */ async function waitForClamD(scanner: Scanner): Promise<void> { const timeoutMins = 10; const endTime = Date.now() + timeoutMins * 60 * 1000; logger.info('Waiting for Clamd'); while (Date.now() < endTime) { try { const version = await scanner.getClamVersion(); logger.info(`Clamd started with version ${version}`); return; } catch (e) { logger.warn(`Waiting for clamd to start: ${e as Error}`); } await setTimeout(10000); } logger.fatal(`Clamd not started after ${timeoutMins} mins`); process.exit(1); } /** * Perform async setup and start the app. * * @async */ async function run(): Promise<void> { let projectId = process.env.PROJECT_ID; if (!projectId) { // Metrics needs project ID, so get it from GoogleAuth projectId = await new GoogleAuth().getProjectId(); } metrics.init(projectId); const storage = new Storage({ userAgent: `cloud-solutions/${packageName}-usage-v${packageVersion}`, }); let configFile; if (process.argv.length >= 3) { configFile = process.argv[2]; } else { configFile = './config.json'; } const config = await readAndVerifyConfig(configFile, storage); const scanner = new Scanner(config, ClamdClient, storage, metrics); await waitForClamD(scanner); new Server(scanner, config, parseInt(process.env.PORT || '8080')).start(); } // Start the service, exiting on error. run().catch((e) => { logger.fatal(e); logger.fatal('Exiting'); process.exit(1); });