From 8ba32cd73e1fa03ae3c56690edfdf23be06e0427 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 25 Mar 2026 10:39:48 +0200 Subject: [PATCH 1/6] add image scan for vulnerabilities after pull/build --- src/@types/C2D/C2D.ts | 6 + src/components/c2d/compute_engine_docker.ts | 374 +++++++++++++++----- src/components/c2d/compute_engines.ts | 47 ++- src/utils/config/builder.ts | 2 +- 4 files changed, 317 insertions(+), 112 deletions(-) diff --git a/src/@types/C2D/C2D.ts b/src/@types/C2D/C2D.ts index 14ecc752a..e4de09594 100644 --- a/src/@types/C2D/C2D.ts +++ b/src/@types/C2D/C2D.ts @@ -151,6 +151,8 @@ export interface C2DDockerConfig { imageRetentionDays?: number // Default: 7 days imageCleanupInterval?: number // Default: 86400 seconds (24 hours) paymentClaimInterval?: number // Default: 3600 seconds (1 hours) + scanImages?: boolean + scanImageDBUpdateInterval?: number // Default: 12 hours } export type ComputeResultType = @@ -292,6 +294,8 @@ export enum C2DStatusNumber { // eslint-disable-next-line no-unused-vars BuildImageFailed = 13, // eslint-disable-next-line no-unused-vars + VulnerableImage = 14, + // eslint-disable-next-line no-unused-vars ConfiguringVolumes = 20, // eslint-disable-next-line no-unused-vars VolumeCreationFailed = 21, @@ -340,6 +344,8 @@ export enum C2DStatusText { // eslint-disable-next-line no-unused-vars BuildImageFailed = 'Building algorithm image failed', // eslint-disable-next-line no-unused-vars + VulnerableImage = 'Image has vulnerabilities', + // eslint-disable-next-line no-unused-vars ConfiguringVolumes = 'Configuring volumes', // eslint-disable-next-line no-unused-vars VolumeCreationFailed = 'Volume creation failed', diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index aec1d59bf..5b63de6ef 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -1,6 +1,7 @@ /* eslint-disable security/detect-non-literal-fs-filename */ -import { Readable } from 'stream' +import { Readable, PassThrough } from 'stream' import os from 'os' +import path from 'path' import { C2DStatusNumber, C2DStatusText, @@ -55,6 +56,8 @@ import { dockerRegistrysAuth, dockerRegistryAuth } from '../../@types/OceanNode. import { EncryptMethod } from '../../@types/fileObject.js' import { ZeroAddress } from 'ethers' +const trivyImage = 'aquasec/trivy:0.69.3' // Use pinned versions for safety + export class C2DEngineDocker extends C2DEngine { private envs: ComputeEnvironment[] = [] @@ -65,10 +68,14 @@ export class C2DEngineDocker extends C2DEngine { private isInternalLoopRunning: boolean = false private imageCleanupTimer: NodeJS.Timeout | null = null private paymentClaimTimer: NodeJS.Timeout | null = null + private scanDBUpdateTimer: NodeJS.Timeout | null = null private static DEFAULT_DOCKER_REGISTRY = 'https://registry-1.docker.io' private retentionDays: number private cleanupInterval: number private paymentClaimInterval: number + private scanImages: boolean + private scanImageDBUpdateInterval: number + private trivyCachePath: string public constructor( clusterConfig: C2DClusterInfo, db: C2DDatabase, @@ -87,8 +94,10 @@ export class C2DEngineDocker extends C2DEngine { } } this.retentionDays = clusterConfig.connection.imageRetentionDays || 7 - this.cleanupInterval = clusterConfig.connection.imageCleanupInterval || 86400 // 24 hours + this.cleanupInterval = clusterConfig.connection.imageCleanupInterval this.paymentClaimInterval = clusterConfig.connection.paymentClaimInterval || 3600 // 1 hour + this.scanImages = clusterConfig.connection.scanImages || false // default is not to scan images for now, until it's prod ready + this.scanImageDBUpdateInterval = clusterConfig.connection.scanImageDBUpdateInterval if ( clusterConfig.connection.protocol && clusterConfig.connection.host && @@ -104,18 +113,30 @@ export class C2DEngineDocker extends C2DEngine { CORE_LOGGER.error('Could not create Docker container: ' + e.message) } } - // TO DO C2D - create envs + // trivy cache is the same for all engines + this.trivyCachePath = path.join( + process.cwd(), + this.getC2DConfig().tempFolder, + 'trivy_cache' + ) try { - if (!existsSync(clusterConfig.tempFolder)) - mkdirSync(clusterConfig.tempFolder, { recursive: true }) + if (!existsSync(this.getStoragePath())) + mkdirSync(this.getStoragePath(), { recursive: true }) + if (!existsSync(this.trivyCachePath)) + mkdirSync(this.trivyCachePath, { recursive: true }) } catch (e) { CORE_LOGGER.error( 'Could not create Docker container temporary folders: ' + e.message ) } + // envs are build on start function } + public getStoragePath(): string { + return this.getC2DConfig().tempFolder + this.getC2DConfig().hash + } + public override async start() { // let's build the env. Swarm and k8 will build multiple envs, based on arhitecture const config = await getConfiguration() @@ -305,10 +326,86 @@ export class C2DEngineDocker extends C2DEngine { if (!this.cronTimer) { this.setNewTimer() } + this.startCrons() + } + + public startCrons() { + if (!this.docker) { + CORE_LOGGER.debug('Docker not available, skipping crons') + return + } + // Start image cleanup timer - this.startImageCleanupTimer() - // Start claim timer - this.startPaymentTimer() + if (this.cleanupInterval) { + if (this.imageCleanupTimer) { + return // Already running + } + // Run initial cleanup after a short delay + setTimeout(() => { + this.cleanupOldImages().catch((e) => { + CORE_LOGGER.error(`Initial image cleanup failed: ${e.message}`) + }) + }, 60000) // Wait 1 minute after start + + // Set up periodic cleanup + this.imageCleanupTimer = setInterval(() => { + this.cleanupOldImages().catch((e) => { + CORE_LOGGER.error(`Periodic image cleanup failed: ${e.message}`) + }) + }, this.cleanupInterval * 1000) + + CORE_LOGGER.info( + `Image cleanup timer started (interval: ${this.cleanupInterval / 60} minutes)` + ) + } + // start payments cron + if (this.paymentClaimInterval) { + if (this.paymentClaimTimer) { + return // Already running + } + + // Run initial cleanup after a short delay + setTimeout(() => { + this.claimPayments().catch((e) => { + CORE_LOGGER.error(`Initial payments claim failed: ${e.message}`) + }) + }, 60000) // Wait 1 minute after start + + // Set up periodic cleanup + this.paymentClaimTimer = setInterval(() => { + this.claimPayments().catch((e) => { + CORE_LOGGER.error(`Periodic payments claim failed: ${e.message}`) + }) + }, this.paymentClaimInterval * 1000) + + CORE_LOGGER.info( + `Payments claim timer started (interval: ${this.paymentClaimInterval / 60} minutes)` + ) + } + // scan db updater cron + if (this.scanImageDBUpdateInterval) { + if (this.scanDBUpdateTimer) { + return // Already running + } + + // Run initial db cache + setTimeout(() => { + this.scanDBUpdate().catch((e) => { + CORE_LOGGER.error(`scan DB Update Initial failed: ${e.message}`) + }) + }, 30000) // Wait 30 seconds + + // Set up periodic cleanup + this.scanDBUpdateTimer = setInterval(() => { + this.scanDBUpdate().catch((e) => { + CORE_LOGGER.error(`Periodic scan DB update failed: ${e.message}`) + }) + }, this.scanImageDBUpdateInterval * 1000) + + CORE_LOGGER.info( + `scan DB update timer started (interval: ${this.scanImageDBUpdateInterval / 60} minutes)` + ) + } } public override stop(): Promise { @@ -715,59 +812,6 @@ export class C2DEngineDocker extends C2DEngine { } } - private startImageCleanupTimer(): void { - if (this.imageCleanupTimer) { - return // Already running - } - - if (!this.docker) { - CORE_LOGGER.debug('Docker not available, skipping image cleanup timer') - return - } - - // Run initial cleanup after a short delay - setTimeout(() => { - this.cleanupOldImages().catch((e) => { - CORE_LOGGER.error(`Initial image cleanup failed: ${e.message}`) - }) - }, 60000) // Wait 1 minute after start - - // Set up periodic cleanup - this.imageCleanupTimer = setInterval(() => { - this.cleanupOldImages().catch((e) => { - CORE_LOGGER.error(`Periodic image cleanup failed: ${e.message}`) - }) - }, this.cleanupInterval * 1000) - - CORE_LOGGER.info( - `Image cleanup timer started (interval: ${this.cleanupInterval / 60} minutes)` - ) - } - - private startPaymentTimer(): void { - if (this.paymentClaimTimer) { - return // Already running - } - - // Run initial cleanup after a short delay - setTimeout(() => { - this.claimPayments().catch((e) => { - CORE_LOGGER.error(`Initial payments claim failed: ${e.message}`) - }) - }, 60000) // Wait 1 minute after start - - // Set up periodic cleanup - this.paymentClaimTimer = setInterval(() => { - this.claimPayments().catch((e) => { - CORE_LOGGER.error(`Periodic payments claim failed: ${e.message}`) - }) - }, this.paymentClaimInterval * 1000) - - CORE_LOGGER.info( - `Payments claim timer started (interval: ${this.paymentClaimInterval / 60} minutes)` - ) - } - // eslint-disable-next-line require-await public override async getComputeEnvironments( chainId?: number @@ -1210,7 +1254,7 @@ export class C2DEngineDocker extends C2DEngine { let index = 0 try { const logStat = statSync( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/logs/image.log' + this.getStoragePath() + '/' + jobId + '/data/logs/image.log' ) if (logStat) { res.push({ @@ -1224,7 +1268,7 @@ export class C2DEngineDocker extends C2DEngine { } catch (e) {} try { const logStat = statSync( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/logs/configuration.log' + this.getStoragePath() + '/' + jobId + '/data/logs/configuration.log' ) if (logStat) { res.push({ @@ -1238,7 +1282,7 @@ export class C2DEngineDocker extends C2DEngine { } catch (e) {} try { const logStat = statSync( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/logs/algorithm.log' + this.getStoragePath() + '/' + jobId + '/data/logs/algorithm.log' ) if (logStat) { res.push({ @@ -1255,7 +1299,7 @@ export class C2DEngineDocker extends C2DEngine { const jobDb = await this.db.getJob(jobId) if (jobDb.length < 1 || !jobDb[0].output) { const outputStat = statSync( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/outputs/outputs.tar' + this.getStoragePath() + '/' + jobId + '/data/outputs/outputs.tar' ) if (outputStat) { res.push({ @@ -1270,7 +1314,7 @@ export class C2DEngineDocker extends C2DEngine { } catch (e) {} try { const logStat = statSync( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/logs/publish.log' + this.getStoragePath() + '/' + jobId + '/data/logs/publish.log' ) if (logStat) { res.push({ @@ -1332,7 +1376,7 @@ export class C2DEngineDocker extends C2DEngine { if (i.type === 'algorithmLog') { return { stream: createReadStream( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/logs/algorithm.log' + this.getStoragePath() + '/' + jobId + '/data/logs/algorithm.log' ), headers: { 'Content-Type': 'text/plain' @@ -1342,10 +1386,7 @@ export class C2DEngineDocker extends C2DEngine { if (i.type === 'configurationLog') { return { stream: createReadStream( - this.getC2DConfig().tempFolder + - '/' + - jobId + - '/data/logs/configuration.log' + this.getStoragePath() + '/' + jobId + '/data/logs/configuration.log' ), headers: { 'Content-Type': 'text/plain' @@ -1355,7 +1396,7 @@ export class C2DEngineDocker extends C2DEngine { if (i.type === 'publishLog') { return { stream: createReadStream( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/logs/publish.log' + this.getStoragePath() + '/' + jobId + '/data/logs/publish.log' ), headers: { 'Content-Type': 'text/plain' @@ -1365,7 +1406,7 @@ export class C2DEngineDocker extends C2DEngine { if (i.type === 'imageLog') { return { stream: createReadStream( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/logs/image.log' + this.getStoragePath() + '/' + jobId + '/data/logs/image.log' ), headers: { 'Content-Type': 'text/plain' @@ -1375,7 +1416,7 @@ export class C2DEngineDocker extends C2DEngine { if (i.type === 'output') { return { stream: createReadStream( - this.getC2DConfig().tempFolder + '/' + jobId + '/data/outputs/outputs.tar', + this.getStoragePath() + '/' + jobId + '/data/outputs/outputs.tar', offset > 0 ? { start: offset } : undefined ), headers: { @@ -1590,6 +1631,26 @@ export class C2DEngineDocker extends C2DEngine { } if (job.status === C2DStatusNumber.ConfiguringVolumes) { + // now that we have the image ready, check it for vulnerabilities + if (this.getC2DConfig().connection?.scanImages) { + const check = await this.checkImageVulnerability(job.containerImage) + const imageLogFile = + this.getStoragePath() + '/' + job.jobId + '/data/logs/image.log' + const logText = + `Image scanned for vulnerabilities\nVulnerable:${check.vulnerable}\nSummary:` + + JSON.stringify(check.summary, null, 2) + CORE_LOGGER.error(logText) + appendFileSync(imageLogFile, logText) + if (check.vulnerable) { + job.status = C2DStatusNumber.VulnerableImage + job.statusText = C2DStatusText.VulnerableImage + job.isRunning = false + job.dateFinished = String(Date.now() / 1000) + await this.db.updateJob(job) + await this.cleanupJob(job) + return + } + } // create the volume & create container // TO DO C2D: Choose driver & size // get env info @@ -1763,10 +1824,7 @@ export class C2DEngineDocker extends C2DEngine { job.algoStopTimestamp = String(Date.now() / 1000) try { const algoLogFile = - this.getC2DConfig().tempFolder + - '/' + - job.jobId + - '/data/logs/algorithm.log' + this.getStoragePath() + '/' + job.jobId + '/data/logs/algorithm.log' writeFileSync(algoLogFile, String(e.message)) } catch (e) { CORE_LOGGER.error('Failed to write algorithm log file: ' + e.message) @@ -1838,7 +1896,7 @@ export class C2DEngineDocker extends C2DEngine { job.dateFinished = String(Date.now() / 1000) try { const algoLogFile = - this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/logs/algorithm.log' + this.getStoragePath() + '/' + job.jobId + '/data/logs/algorithm.log' writeFileSync(algoLogFile, String(e.message)) } catch (e) { CORE_LOGGER.error('Failed to write algorithm log file: ' + e.message) @@ -1856,7 +1914,7 @@ export class C2DEngineDocker extends C2DEngine { job.terminationDetails.exitCode = null } const outputsArchivePath = - this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/outputs/outputs.tar' + this.getStoragePath() + '/' + job.jobId + '/data/outputs/outputs.tar' try { if (container) { @@ -1936,7 +1994,7 @@ export class C2DEngineDocker extends C2DEngine { if (container) { if (job.status !== C2DStatusNumber.AlgorithmFailed) { writeFileSync( - this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/logs/algorithm.log', + this.getStoragePath() + '/' + job.jobId + '/data/logs/algorithm.log', await container.logs({ stdout: true, stderr: true, @@ -1963,33 +2021,32 @@ export class C2DEngineDocker extends C2DEngine { } try { // remove folders - rmSync(this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/inputs', { + rmSync(this.getStoragePath() + '/' + job.jobId + '/data/inputs', { recursive: true, force: true }) } catch (e) { console.error( - `Could not delete inputs from path ${this.getC2DConfig().tempFolder} for job ID ${ + `Could not delete inputs from path ${this.getStoragePath()} for job ID ${ job.jobId }! ` + e.message ) } try { - rmSync(this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/transformations', { + rmSync(this.getStoragePath() + '/' + job.jobId + '/data/transformations', { recursive: true, force: true }) } catch (e) { console.error( - `Could not delete algorithms from path ${ - this.getC2DConfig().tempFolder - } for job ID ${job.jobId}! ` + e.message + `Could not delete algorithms from path ${this.getStoragePath()} for job ID ${job.jobId}! ` + + e.message ) } } private deleteOutputFolder(job: DBComputeJob) { - rmSync(this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/outputs/', { + rmSync(this.getStoragePath() + '/' + job.jobId + '/data/outputs/', { recursive: true, force: true }) @@ -2124,8 +2181,7 @@ export class C2DEngineDocker extends C2DEngine { private async pullImage(originaljob: DBComputeJob) { const job = JSON.parse(JSON.stringify(originaljob)) as DBComputeJob - const imageLogFile = - this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/logs/image.log' + const imageLogFile = this.getStoragePath() + '/' + job.jobId + '/data/logs/image.log' try { // Get registry auth for the image const { registry } = this.parseImage(job.containerImage) @@ -2226,8 +2282,7 @@ export class C2DEngineDocker extends C2DEngine { additionalDockerFiles: { [key: string]: any } ) { const job = JSON.parse(JSON.stringify(originaljob)) as DBComputeJob - const imageLogFile = - this.getC2DConfig().tempFolder + '/' + job.jobId + '/data/logs/image.log' + const imageLogFile = this.getStoragePath() + '/' + job.jobId + '/data/logs/image.log' try { const pack = tarStream.pack() @@ -2314,7 +2369,7 @@ export class C2DEngineDocker extends C2DEngine { status: C2DStatusNumber.RunningAlgorithm, statusText: C2DStatusText.RunningAlgorithm } - const jobFolderPath = this.getC2DConfig().tempFolder + '/' + job.jobId + const jobFolderPath = this.getStoragePath() + '/' + job.jobId const fullAlgoPath = jobFolderPath + '/data/transformations/algorithm' const configLogPath = jobFolderPath + '/data/logs/configuration.log' @@ -2324,10 +2379,7 @@ export class C2DEngineDocker extends C2DEngine { "Writing algocustom data to '/data/inputs/algoCustomData.json'\n" ) const customdataPath = - this.getC2DConfig().tempFolder + - '/' + - job.jobId + - '/data/inputs/algoCustomData.json' + this.getStoragePath() + '/' + job.jobId + '/data/inputs/algoCustomData.json' writeFileSync(customdataPath, JSON.stringify(job.algorithm.algocustomdata ?? {})) let storage = null @@ -2628,7 +2680,7 @@ export class C2DEngineDocker extends C2DEngine { private makeJobFolders(job: DBComputeJob): boolean { try { - const baseFolder = this.getC2DConfig().tempFolder + '/' + job.jobId + const baseFolder = this.getStoragePath() + '/' + job.jobId const dirs = [ baseFolder, baseFolder + '/data', @@ -2676,6 +2728,132 @@ export class C2DEngineDocker extends C2DEngine { } return false } + + private async checkscanDBImage(): Promise { + // 1. Pull the image if it's missing locally + try { + await this.docker.getImage(trivyImage).inspect() + return true + } catch (error) { + if (error.statusCode === 404) { + CORE_LOGGER.info(`Trivy not found. Pulling ${trivyImage}...`) + const stream = await this.docker.pull(trivyImage) + + // We must wrap the pull stream in a promise to wait for completion + await new Promise((resolve, reject) => { + this.docker.modem.followProgress(stream, (err, res) => + err ? reject(err) : resolve(res) + ) + }) + + CORE_LOGGER.info('Pull complete.') + return true + } else { + CORE_LOGGER.error(`Unabe to pull ${trivyImage}: ${error.message}`) + return true + } + } + } + + private async scanDBUpdate(): Promise { + CORE_LOGGER.info('Starting Trivy database refresh cron') + const hasImage = await this.checkscanDBImage() + if (!hasImage) { + // we cannot update without image + return + } + const updater = await this.docker.createContainer({ + Image: trivyImage, + Cmd: ['image', '--download-db-only'], // Only refreshes the cache + HostConfig: { + Binds: [`${this.trivyCachePath}:/root/.cache/trivy`] + } + }) + + await updater.start() + await updater.wait() + await updater.remove() + CORE_LOGGER.info('Trivy database refreshed.') + } + + private async scanImage(imageName: string) { + const hasImage = await this.checkscanDBImage() + if (!hasImage) { + // we cannot update without image + return + } + const container = await this.docker.createContainer({ + Image: trivyImage, + Cmd: [ + 'image', + '--format', + 'json', + '--quiet', + // '--skip-db-update', // Optional: Use this if you want to update via a separate cron job + imageName + ], + HostConfig: { + Binds: [ + '/var/run/docker.sock:/var/run/docker.sock', // To see local images + `${this.trivyCachePath}:/root/.cache/trivy` // THE CACHE BIND + ] + } + }) + + await container.start() + + // Capture the output stream + const logStream = new PassThrough() + const logs = await container.logs({ follow: true, stdout: true, stderr: true }) + + // Demux Docker's multiplexed stream (removes binary headers) + container.modem.demuxStream(logs, logStream, process.stderr) + + let rawData = '' + logStream.on('data', (chunk) => { + rawData += chunk + }) + + await container.wait() + await container.remove() + + try { + return JSON.parse(rawData) + } catch (e) { + CORE_LOGGER.error('Failed to parse Trivy output: ' + e.message) + return null + } + } + + private async checkImageVulnerability(imageName: string) { + const report = await this.scanImage(imageName) + if (!report) { + // + return { vulnerable: false, summary: 'failed to scan' } + } + // Results is an array (one entry per OS package manager / language) + const allVulnerabilities = report.Results.flatMap((r: any) => r.Vulnerabilities || []) + + const summary = { + total: allVulnerabilities.length, + critical: allVulnerabilities.filter((v: any) => v.Severity === 'CRITICAL').length, + high: allVulnerabilities.filter((v: any) => v.Severity === 'HIGH').length, + list: allVulnerabilities.slice(0, 5).map((v: any) => ({ + id: v.VulnerabilityID, + package: v.PkgName, + title: v.Title || 'No description' + })) + } + + if (summary.critical > 0) { + return { + vulnerable: true, + summary + } + } + + return { vulnerable: false, summary } + } } // this uses the docker engine, but exposes only one env, the free one diff --git a/src/components/c2d/compute_engines.ts b/src/components/c2d/compute_engines.ts index 26ad035f9..fa98fd7c5 100644 --- a/src/components/c2d/compute_engines.ts +++ b/src/components/c2d/compute_engines.ts @@ -1,4 +1,8 @@ -import { C2DClusterType, ComputeEnvironment } from '../../@types/C2D/C2D.js' +import { + C2DClusterInfo, + C2DClusterType, + ComputeEnvironment +} from '../../@types/C2D/C2D.js' import { C2DEngine } from './compute_engine_base.js' import { C2DEngineDocker } from './compute_engine_docker.js' import { OceanNodeConfig } from '../../@types/OceanNode.js' @@ -14,23 +18,40 @@ export class C2DEngines { escrow: Escrow, keyManager: KeyManager ) { - // let's see what engines do we have and initialize them one by one - // for docker, we need to add the "free" - - // TO DO - check if we have multiple config.c2dClusters with the same host - // if yes, do not create multiple engines + const crons = { + imageCleanup: false, + scanDBUpdate: false + } if (config && config.c2dClusters) { this.engines = [] for (const cluster of config.c2dClusters) { if (cluster.type === C2DClusterType.DOCKER) { + const cfg = JSON.parse(JSON.stringify(cluster)) as C2DClusterInfo + // make sure that crons are running only on one docker engine + if (crons.imageCleanup) { + // already running, set cron to null for this engine + cfg.connection.imageCleanupInterval = null + } else { + // not running yet, set the defaults + cfg.connection.imageCleanupInterval = + cfg.connection.imageCleanupInterval || 86400 // 24 hours + crons.imageCleanup = true + } + if (crons.scanDBUpdate) { + cfg.connection.scanImageDBUpdateInterval = null + } else { + if (cfg.connection.scanImages) { + // set the defaults + cfg.connection.scanImageDBUpdateInterval = + cfg.connection.scanImageDBUpdateInterval || 43200 // 12 hours + crons.scanDBUpdate = true + } else { + // image scanning disabled for this engine + cfg.connection.scanImageDBUpdateInterval = null + } + } this.engines.push( - new C2DEngineDocker( - cluster, - db, - escrow, - keyManager, - config.dockerRegistrysAuth - ) + new C2DEngineDocker(cfg, db, escrow, keyManager, config.dockerRegistrysAuth) ) } } diff --git a/src/utils/config/builder.ts b/src/utils/config/builder.ts index 23a0f5218..7a14e8cee 100644 --- a/src/utils/config/builder.ts +++ b/src/utils/config/builder.ts @@ -159,7 +159,7 @@ export function buildC2DClusters( connection: dockerC2d, hash, type: C2DClusterType.DOCKER, - tempFolder: './c2d_storage/' + hash + tempFolder: './c2d_storage/' // this is the base folder, each engine creates it's own subfolder }) count += 1 } From 0483f8f8fd1530c88596b6d0fa5755a9a88b44a9 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 25 Mar 2026 10:42:41 +0200 Subject: [PATCH 2/6] add severity in vulnerability list --- src/components/c2d/compute_engine_docker.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index 5b63de6ef..416670dce 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -2839,6 +2839,7 @@ export class C2DEngineDocker extends C2DEngine { critical: allVulnerabilities.filter((v: any) => v.Severity === 'CRITICAL').length, high: allVulnerabilities.filter((v: any) => v.Severity === 'HIGH').length, list: allVulnerabilities.slice(0, 5).map((v: any) => ({ + severity: v.Severity, id: v.VulnerabilityID, package: v.PkgName, title: v.Title || 'No description' From 01a9d402496a412bc845d290d6841c8a3a66d72e Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 25 Mar 2026 10:49:28 +0200 Subject: [PATCH 3/6] remove unneeded test --- src/test/integration/compute.test.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/test/integration/compute.test.ts b/src/test/integration/compute.test.ts index d80e76291..4de6379bc 100644 --- a/src/test/integration/compute.test.ts +++ b/src/test/integration/compute.test.ts @@ -3167,19 +3167,5 @@ describe('Compute Access Restrictions', () => { ) } }) - - it('should start payment claim timer on engine start', function () { - // Verify timer methods exist - // Timer might be null if not started yet, or a NodeJS.Timeout if started - // We can't easily test the timer directly, but we can verify the method exists - assert( - typeof (dockerEngine as any).startPaymentTimer === 'function', - 'startPaymentTimer method should exist' - ) - assert( - typeof (dockerEngine as any).claimPayments === 'function', - 'claimPayments method should exist' - ) - }) }) }) From 4bc4b2f599dc6ba28ba98c26d8ae18f5ccbb9b57 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 25 Mar 2026 10:51:15 +0200 Subject: [PATCH 4/6] update docs --- docs/env.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/env.md b/docs/env.md index e07f41b82..013572344 100644 --- a/docs/env.md +++ b/docs/env.md @@ -136,6 +136,7 @@ The `DOCKER_COMPUTE_ENVIRONMENTS` environment variable should be a JSON array of [ { "socketPath": "/var/run/docker.sock", + "scanImages": true, "imageRetentionDays": 7, "imageCleanupInterval": 86400, "resources": [ @@ -194,6 +195,7 @@ The `DOCKER_COMPUTE_ENVIRONMENTS` environment variable should be a JSON array of #### Configuration Options - **socketPath**: Path to the Docker socket (e.g., docker.sock). +- **scanImages**: If the docker images should be scan for vulnerabilities using trivy. If yes and critical vulnerabilities are found, then C2D job is refused - **imageRetentionDays** - how long docker images are kept, in days. Default: 7 - **imageCleanupInterval** - how often to run cleanup for docker images, in seconds. Min: 3600 (1hour), Default: 86400 (24 hours) - **paymentClaimInterval** - how often to run payment claiming, in seconds. Default: 3600 (1 hour) From 4295858d324ee775790da53f01e8b426ef694a33 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 25 Mar 2026 10:52:41 +0200 Subject: [PATCH 5/6] fix spelling --- src/components/c2d/compute_engine_docker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index 416670dce..2b2ad9a4b 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -2749,7 +2749,7 @@ export class C2DEngineDocker extends C2DEngine { CORE_LOGGER.info('Pull complete.') return true } else { - CORE_LOGGER.error(`Unabe to pull ${trivyImage}: ${error.message}`) + CORE_LOGGER.error(`Unable to pull ${trivyImage}: ${error.message}`) return true } } From 12354d3141e00c558cc2ba479e35ab5a10fc5e24 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 25 Mar 2026 11:35:54 +0200 Subject: [PATCH 6/6] fix to work with high volume outputs from trivy --- src/components/c2d/compute_engine_docker.ts | 116 +++++++++++++++++--- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/src/components/c2d/compute_engine_docker.ts b/src/components/c2d/compute_engine_docker.ts index 2b2ad9a4b..b6351ba0c 100755 --- a/src/components/c2d/compute_engine_docker.ts +++ b/src/components/c2d/compute_engine_docker.ts @@ -1639,7 +1639,7 @@ export class C2DEngineDocker extends C2DEngine { const logText = `Image scanned for vulnerabilities\nVulnerable:${check.vulnerable}\nSummary:` + JSON.stringify(check.summary, null, 2) - CORE_LOGGER.error(logText) + CORE_LOGGER.debug(logText) appendFileSync(imageLogFile, logText) if (check.vulnerable) { job.status = C2DStatusNumber.VulnerableImage @@ -2777,11 +2777,13 @@ export class C2DEngineDocker extends C2DEngine { } private async scanImage(imageName: string) { + if (!imageName || !imageName.trim()) return null const hasImage = await this.checkscanDBImage() if (!hasImage) { // we cannot update without image return } + CORE_LOGGER.debug(`Starting vulnerability check for ${imageName}`) const container = await this.docker.createContainer({ Image: trivyImage, Cmd: [ @@ -2789,7 +2791,10 @@ export class C2DEngineDocker extends C2DEngine { '--format', 'json', '--quiet', - // '--skip-db-update', // Optional: Use this if you want to update via a separate cron job + '--no-progress', + '--skip-db-update', + '--severity', + 'CRITICAL,HIGH', imageName ], HostConfig: { @@ -2802,23 +2807,67 @@ export class C2DEngineDocker extends C2DEngine { await container.start() - // Capture the output stream - const logStream = new PassThrough() - const logs = await container.logs({ follow: true, stdout: true, stderr: true }) + // Wait for completion, then parse from *demuxed stdout* to avoid corrupt JSON + // due to Docker multiplexed log framing. + const logsStream = await container.logs({ + follow: true, + stdout: true, + stderr: true + }) + + const outStream = new PassThrough() + const errStream = new PassThrough() + outStream.resume() + errStream.resume() + + const rawChunks: Buffer[] = [] + outStream.on('data', (chunk) => { + rawChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + }) - // Demux Docker's multiplexed stream (removes binary headers) - container.modem.demuxStream(logs, logStream, process.stderr) + container.modem.demuxStream(logsStream, outStream, errStream) - let rawData = '' - logStream.on('data', (chunk) => { - rawData += chunk + const logsDrained = new Promise((resolve, reject) => { + const done = () => resolve() + logsStream.once('end', done) + logsStream.once('close', done) + logsStream.once('error', reject) }) await container.wait() + // Wait for the docker log stream to finish producing data. + await logsDrained + await container.remove() + CORE_LOGGER.debug(`Vulnerability check for ${imageName} finished`) try { - return JSON.parse(rawData) + const rawData = Buffer.concat(rawChunks).toString('utf8') + // Trivy's `--format json` output is a JSON object (it includes `SchemaVersion`). + // Prefer extracting the JSON object only; do not attempt array parsing since + // Trivy help/usage output may include `[` tokens (e.g. "[flags]") that are not JSON. + const firstBrace = rawData.indexOf('{') + const lastBrace = rawData.lastIndexOf('}') + + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + const jsonText = rawData.slice(firstBrace, lastBrace + 1).trim() + if (!jsonText.includes('"SchemaVersion"')) { + CORE_LOGGER.error( + 'Trivy output did not contain SchemaVersion in extracted JSON. Truncated output: ' + + rawData.slice(0, 500) + ) + return null + } + return JSON.parse(jsonText) + } + + CORE_LOGGER.error( + `Failed to locate JSON in Trivy output. Truncated output: ${rawData.slice( + 0, + 1000 + )}` + ) + return null } catch (e) { CORE_LOGGER.error('Failed to parse Trivy output: ' + e.message) return null @@ -2834,16 +2883,49 @@ export class C2DEngineDocker extends C2DEngine { // Results is an array (one entry per OS package manager / language) const allVulnerabilities = report.Results.flatMap((r: any) => r.Vulnerabilities || []) + const severityRank = (sev: string) => { + switch (sev) { + case 'CRITICAL': + return 3 + case 'HIGH': + return 2 + default: + return 1 + } + } + const summary = { total: allVulnerabilities.length, critical: allVulnerabilities.filter((v: any) => v.Severity === 'CRITICAL').length, high: allVulnerabilities.filter((v: any) => v.Severity === 'HIGH').length, - list: allVulnerabilities.slice(0, 5).map((v: any) => ({ - severity: v.Severity, - id: v.VulnerabilityID, - package: v.PkgName, - title: v.Title || 'No description' - })) + list: (() => { + // Present the most important vulnerabilities first. + const sorted = [...allVulnerabilities].sort((a: any, b: any) => { + const diff = severityRank(b.Severity) - severityRank(a.Severity) + if (diff !== 0) return diff + return String(a.VulnerabilityID || '').localeCompare( + String(b.VulnerabilityID || '') + ) + }) + + const list: Array<{ + severity: string + id: string + package: string + title: string + }> = [] + + for (const v of sorted) { + list.push({ + severity: v.Severity, + id: v.VulnerabilityID, + package: v.PkgName, + title: v.Title || 'No description' + }) + } + + return list + })() } if (summary.critical > 0) {