Files
dashcaddy/dashcaddy-api/src/security/docker-security.js
T
Krystie 503de258b8 [grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13.
These files were modified during the Aug 12 sprint but never committed.
2026-08-12 17:34:10 -07:00

349 lines
11 KiB
JavaScript

/**
* 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<string>} 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<object>} 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<object>} 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<object>} 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;