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);
});