347 lines
10 KiB
JavaScript
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;
|