cloudrun-malware-scanner/config.ts (221 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 {logger} from './logger'; import {readFileSync} from 'node:fs'; import {Storage} from '@google-cloud/storage'; /** * Configuration object. * * Values are read from the JSON configuration file. * See {@link readAndVerifyConfig}. */ export type BucketDefs = { unscanned: string; clean: string; quarantined: string; }; export type Config = { buckets: Array<BucketDefs>; ClamCvdMirrorBucket: string; fileExclusionPatterns?: Array<string | Array<string>>; fileExclusionRegexps: Array<RegExp>; ignoreZeroLengthFiles: boolean; comments?: string | string[]; quarantine: { encryptedFiles: boolean; fileExtensionAllowList: string[]; fileExtensionDenyList: string[]; }; }; const BUCKET_TYPES = ['unscanned', 'clean', 'quarantined'] as Array< keyof BucketDefs >; /** * Read configuration from JSON configuration file, parse, verify * and return a Config object */ export async function readAndVerifyConfig( configFile: string, storage: Storage, ): Promise<Config> { logger.info(`Using configuration file: ${configFile}`); let configText; try { configText = readFileSync(configFile, {encoding: 'utf-8'}); } catch (e) { logger.fatal( e, `Unable to read JSON file from ${configFile}: ${e as Error}`, ); throw e; } try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const config = JSON.parse(configText); if (typeof config !== 'object') { throw new Error('config must be an object'); } return await validateConfig(config, storage); } catch (e) { logger.fatal(e, `Failed parsing config file: ${configFile}: ${e as Error}`); throw e; } } // Allow any for this function, as it is validating a parsed object. /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /** * Validate and freeze the Config object. */ async function validateConfig(config: any, storage: Storage): Promise<Config> { delete config.comments; if (config.buckets == null || config.buckets.length === 0) { logger.fatal(`No buckets configured for scanning`); throw new Error('No buckets configured'); } logger.info('BUCKET_CONFIG: ' + JSON.stringify(config, null, 2)); // Check buckets are specified and exist. let success = true; for (let x = 0; x < config.buckets.length; x++) { const bucketDefs = config.buckets[x] as BucketDefs; for (const bucketType of BUCKET_TYPES) { if ( !(await checkBucketExists( bucketDefs[bucketType], `config.buckets[${x.toString()}].${bucketType}`, storage, )) ) { success = false; } } if ( bucketDefs.unscanned === bucketDefs.clean || bucketDefs.unscanned === bucketDefs.quarantined || bucketDefs.clean === bucketDefs.quarantined ) { logger.fatal( `Config Error: buckets[${x.toString()}]: bucket names are not unique`, ); success = false; } } if ( !(await checkBucketExists( config.ClamCvdMirrorBucket as string, 'ClamCvdMirrorBucket', storage, )) ) { success = false; } // Validate ignoreZeroLengthFiles if (config.ignoreZeroLengthFiles == null) { config.ignoreZeroLengthFiles = false; } else if (typeof config.ignoreZeroLengthFiles !== 'boolean') { logger.fatal( `Config Error: ignoreZeroLengthFiles must be true or false: ${JSON.stringify(config.ignoreZeroLengthFiles)}`, ); success = false; } // Validate fileExclusionPatterns[] and convert to fileExclusionRegexps[] config.fileExclusionRegexps = []; if (config.fileExclusionPatterns != null) { if (!(config.fileExclusionPatterns instanceof Array)) { logger.fatal( `Config Error: fileExclusionPatterns must be an array of Strings`, ); success = false; } else { // config.fileExclusionPatterns is an array, check each value and // convert to a regexp in fileExclusionRegexps[] for (let i = 0; i < config.fileExclusionPatterns.length; i++) { let pattern: string | undefined; let flags: string | undefined; // Each element can either be a simple pattern: // "^.*\\.tmp$" // or an array with pattern and flags, eg for case-insensive matching: // [ "^.*\\tmp$", "i" ] const element = config.fileExclusionPatterns[i] as | string | Array<string>; if (typeof element === 'string') { // validate regex as simple string pattern = element; } else if ( Array.isArray(element) && element.length <= 2 && element.length >= 1 && typeof element[0] === 'string' ) { // validate regex as [pattern, flags] pattern = element[0]; flags = element[1]; } else { pattern = undefined; } if (pattern == null) { logger.fatal( `Config Error: fileExclusionPatterns[${i}] must be either a string or an array of 2 strings: ${JSON.stringify(config.fileExclusionPatterns[i])}`, ); success = false; } else { try { config.fileExclusionRegexps[i] = new RegExp(pattern, flags); } catch (e) { logger.fatal( e, `Config Error: fileExclusionPatterns[${i}]: Regexp compile failed for ${JSON.stringify(config.fileExclusionPatterns[i])}: ${e as Error}`, ); success = false; } } } } } delete config.fileExclusionPatterns; // Validate quarantine.fileExtensionAllowList[] config.quarantine = config.quarantine ?? {}; try { config.quarantine.fileExtensionAllowList = validateFileExtensionList( config.quarantine.fileExtensionAllowList, ); } catch (e) { logger.fatal( e, `Config Error: quarantine.fileExtensionAllowList[]: ${(e as Error).message}`, ); success = false; } // Validate quarantine.fileExtensionDenyList[] try { config.quarantine.fileExtensionDenyList = validateFileExtensionList( config.quarantine.fileExtensionDenyList, ); } catch (e) { logger.fatal( e, `Config Error: quarantine.fileExtensionDenyList[]: ${(e as Error).message}`, ); success = false; } if (!success) { throw new Error('Invalid configuration'); } return Object.freeze(config as Config); } /* eslint-enable */ /** * Validates that extensionList is a list of Strings, and normalizes extensions * to lowercase prefixed by '.' - except for the empty string, which is returned * as-is. */ function validateFileExtensionList(extensionList: unknown): string[] { if (extensionList == null) { return []; } if (!(extensionList instanceof Array)) { throw new Error('value must be an array of strings'); } const normalizedExtensionList: string[] = []; for (let i = 0; i < extensionList.length; i++) { const ext = extensionList[i] as unknown; if (typeof ext !== 'string') { throw new Error(`value [${i}] must be a string`); } if (ext.length > 0 && ext[0] !== '.') { normalizedExtensionList.push('.' + ext.toLocaleLowerCase()); } else { normalizedExtensionList.push(ext.toLocaleLowerCase()); } } return normalizedExtensionList; } /** * Check that given bucket exists. Returns true on success */ async function checkBucketExists( bucketName: string, configName: string, storage: Storage, ): Promise<boolean> { if (!bucketName) { logger.fatal(`Config Error: no "${configName}" bucket defined`); return false; } // Check for bucket existence by listing files in bucket, will throw // an exception if the bucket is not readable. // This is used in place of Bucket.exists() to avoid the need for // Project/viewer permission. try { await storage .bucket(bucketName) .getFiles({maxResults: 1, prefix: 'zzz', autoPaginate: false}); return true; } catch (e) { logger.fatal( `Error in config: cannot view files in "${configName}" : ${bucketName} : ${e as Error}`, ); logger.debug({err: e}); return false; } } export const TEST_ONLY = { checkBucketExists, validateConfig, };