/** * 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 docker = new Docker(); const SECURITY_CONFIG_FILE = process.env.DOCKER_SECURITY_CONFIG || path.join(__dirname, '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; console.log(`[DockerSecurity] Initialized in ${this.mode} 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) { console.warn(`[DockerSecurity] Failed to load config: ${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) { console.error(`[DockerSecurity] Failed to save config: ${error.message}`); } } /** * 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]; } console.log(`[DockerSecurity] Fetching manifest for ${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(); console.log(`[DockerSecurity] Added trusted digest for ${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) { console.log(`[DockerSecurity] Verifying image: ${imageName}`); try { const actualDigest = await this.getImageDigest(imageName); const result = await this.verifyImageDigest(imageName, actualDigest); if (result.action === 'reject') { console.error(`[DockerSecurity] REJECTED: ${result.reason}`); throw new Error(`Image verification failed: ${result.reason}`); } else if (result.action === 'warn') { console.warn(`[DockerSecurity] WARNING: ${result.reason}`); console.warn(`[DockerSecurity] Expected: ${result.trustedDigest}`); console.warn(`[DockerSecurity] Actual: ${result.actualDigest}`); } else { console.log(`[DockerSecurity] ACCEPTED: ${result.reason}`); } return result; } catch (error) { console.error(`[DockerSecurity] Verification error: ${error.message}`); 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(); console.log(`[DockerSecurity] Updated trusted digest for ${imageName}`); } /** * Remove trusted digest for an image * @param {string} imageName - Image name with tag */ removeTrustedDigest(imageName) { delete this.config.trustedDigests[imageName]; this.saveConfig(); console.log(`[DockerSecurity] Removed trusted digest for ${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(); console.log(`[DockerSecurity] Verification mode set to: ${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;