Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
346
dashcaddy-api/docker-security.js
Normal file
346
dashcaddy-api/docker-security.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user