Files
dashcaddy/dashcaddy-api/docker-security.js
Sami f61e85d9a7 Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
2026-03-05 02:26:12 -08:00

347 lines
10 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 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<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];
}
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<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();
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<object>} 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;