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