/** * Docker Security Module * Provides image digest verification to ensure container images match expected digests * Protects against supply chain attacks and malicious image replacements */ const fs = require('fs'); const path = require('path'); const https = require('https'); const Docker = require('dockerode'); const platformPaths = require('../../platform-paths'); const { log } = require('../utils/logging'); const docker = new Docker(); const SECURITY_CONFIG_FILE = process.env.DOCKER_SECURITY_CONFIG || path.join(platformPaths.dataDir, 'docker-security-config.json'); const VERIFICATION_MODE = process.env.DOCKER_VERIFICATION_MODE || 'verify'; // strict | verify | permissive class DockerSecurity { constructor() { this.config = this.loadConfig(); this.mode = VERIFICATION_MODE; log.info('security', 'Docker security initialized', { mode: this.mode }); } /** * Load security configuration */ loadConfig() { try { if (fs.existsSync(SECURITY_CONFIG_FILE)) { const data = fs.readFileSync(SECURITY_CONFIG_FILE, 'utf8'); return JSON.parse(data); } } catch (error) { log.warn('security', 'Failed to load config', { error: error.message }); } // Default configuration return { trustedDigests: {}, verificationMode: VERIFICATION_MODE, allowUnverified: true, updateTrustedOnPull: true }; } /** * Save security configuration */ saveConfig() { try { fs.writeFileSync(SECURITY_CONFIG_FILE, JSON.stringify(this.config, null, 2)); } catch (error) { log.error('security', error, { operation: 'saveConfig' }); } } /** * Get image digest from Docker * @param {string} imageName - Full image name with tag (e.g., "nginx:latest") * @returns {Promise} Image digest (sha256:...) */ async getImageDigest(imageName) { try { const image = docker.getImage(imageName); const inspect = await image.inspect(); // RepoDigests contains the full image reference with digest // Example: ["nginx@sha256:abcd1234..."] if (inspect.RepoDigests && inspect.RepoDigests.length > 0) { const digestPart = inspect.RepoDigests[0].split('@')[1]; return digestPart; } // If no RepoDigest, use the local Image ID // This happens with locally built images or images pulled before digests were tracked return inspect.Id; } catch (error) { throw new Error(`Failed to get image digest: ${error.message}`); } } /** * Fetch manifest from Docker registry * @param {string} imageName - Image name (e.g., "nginx:latest") * @returns {Promise} Manifest data with digest */ async fetchRegistryManifest(imageName) { // Parse image name const parts = imageName.split('/'); let registry = 'registry-1.docker.io'; let repository = imageName; let tag = 'latest'; // Handle different image name formats if (imageName.includes(':')) { const tagSplit = imageName.split(':'); tag = tagSplit[tagSplit.length - 1]; repository = tagSplit.slice(0, -1).join(':'); } // Handle custom registries if (parts.length > 2 || (parts.length === 2 && parts[0].includes('.'))) { registry = parts[0]; repository = parts.slice(1).join('/').split(':')[0]; } else if (parts.length === 1) { // Official Docker Hub images need 'library/' prefix repository = `library/${repository.split(':')[0]}`; } else { repository = repository.split(':')[0]; } log.info('security', 'Fetching manifest', { registry, repository, tag }); return new Promise((resolve, reject) => { const isDockerHub = registry === 'registry-1.docker.io'; const tokenUrl = isDockerHub ? `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull` : null; const fetchManifest = (token) => { const options = { hostname: registry, path: `/v2/${repository}/manifests/${tag}`, method: 'GET', headers: { 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', } }; if (token) { options.headers['Authorization'] = `Bearer ${token}`; } const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 200) { try { const manifest = JSON.parse(data); const digest = res.headers['docker-content-digest']; resolve({ manifest, digest }); } catch (error) { reject(new Error(`Failed to parse manifest: ${error.message}`)); } } else { reject(new Error(`Registry returned status ${res.statusCode}: ${data}`)); } }); }); req.on('error', (error) => { reject(new Error(`Registry request failed: ${error.message}`)); }); req.end(); }; // Get auth token for Docker Hub if (isDockerHub) { https.get(tokenUrl, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const authData = JSON.parse(data); fetchManifest(authData.token); } catch (error) { reject(new Error(`Failed to get auth token: ${error.message}`)); } }); }).on('error', (error) => { reject(new Error(`Auth request failed: ${error.message}`)); }); } else { fetchManifest(null); } }); } /** * Verify image digest against trusted digests * @param {string} imageName - Image name with tag * @param {string} actualDigest - Actual digest from pulled image * @returns {Promise} Verification result */ async verifyImageDigest(imageName, actualDigest) { const baseImageName = imageName.split(':')[0]; const trustedDigest = this.config.trustedDigests[imageName] || this.config.trustedDigests[baseImageName]; const result = { verified: false, mode: this.mode, imageName, actualDigest, trustedDigest: trustedDigest || null, action: 'unknown' }; if (!trustedDigest) { // No trusted digest configured if (this.mode === 'strict') { result.verified = false; result.action = 'reject'; result.reason = 'No trusted digest configured (strict mode)'; } else { result.verified = true; result.action = 'accept'; result.reason = 'No trusted digest configured (permissive mode)'; if (this.config.updateTrustedOnPull) { this.config.trustedDigests[imageName] = actualDigest; this.saveConfig(); log.info('security', 'Added trusted digest', { imageName }); } } } else if (actualDigest === trustedDigest) { // Digest matches result.verified = true; result.action = 'accept'; result.reason = 'Digest matches trusted value'; } else { // Digest mismatch if (this.mode === 'strict') { result.verified = false; result.action = 'reject'; result.reason = 'Digest mismatch (strict mode)'; } else if (this.mode === 'verify') { result.verified = false; result.action = 'warn'; result.reason = 'Digest mismatch (verify mode - warning only)'; } else { result.verified = true; result.action = 'accept'; result.reason = 'Digest mismatch (permissive mode - accepted)'; } } return result; } /** * Verify an image after pulling * @param {string} imageName - Image name with tag * @returns {Promise} Verification result */ async verifyPulledImage(imageName) { log.info('security', 'Verifying image', { imageName }); try { const actualDigest = await this.getImageDigest(imageName); const result = await this.verifyImageDigest(imageName, actualDigest); if (result.action === 'reject') { log.error('security', 'Image REJECTED', { imageName, reason: result.reason }); throw new Error(`Image verification failed: ${result.reason}`); } else if (result.action === 'warn') { log.warn('security', 'Image WARNING', { imageName, reason: result.reason }); log.warn('security', 'Expected digest', { imageName, digest: result.trustedDigest }); log.warn('security', 'Actual digest', { imageName, digest: result.actualDigest }); } else { log.info('security', 'Image ACCEPTED', { imageName, reason: result.reason }); } return result; } catch (error) { log.error('security', error, { imageName, operation: 'verify' }); if (this.mode === 'strict') { throw error; } return { verified: false, mode: this.mode, imageName, action: this.mode === 'permissive' ? 'accept' : 'warn', error: error.message, reason: `Verification error (${this.mode} mode)` }; } } /** * Add or update trusted digest for an image * @param {string} imageName - Image name with tag * @param {string} digest - Trusted digest */ setTrustedDigest(imageName, digest) { this.config.trustedDigests[imageName] = digest; this.saveConfig(); log.info('security', 'Updated trusted digest', { imageName }); } /** * Remove trusted digest for an image * @param {string} imageName - Image name with tag */ removeTrustedDigest(imageName) { delete this.config.trustedDigests[imageName]; this.saveConfig(); log.info('security', 'Removed trusted digest', { imageName }); } /** * Get all trusted digests */ getTrustedDigests() { return { ...this.config.trustedDigests }; } /** * Set verification mode * @param {string} mode - strict | verify | permissive */ setMode(mode) { if (!['strict', 'verify', 'permissive'].includes(mode)) { throw new Error('Invalid mode. Must be: strict, verify, or permissive'); } this.mode = mode; this.config.verificationMode = mode; this.saveConfig(); log.info('security', 'Verification mode set', { mode }); } /** * Get security status */ getStatus() { return { mode: this.mode, trustedImagesCount: Object.keys(this.config.trustedDigests).length, configFile: SECURITY_CONFIG_FILE, updateTrustedOnPull: this.config.updateTrustedOnPull }; } } // Singleton instance const dockerSecurity = new DockerSecurity(); module.exports = dockerSecurity;