npx skills add mukul975/Anthropic-Cybersecurity-SkillsMITRE ATT&CK
When to Use
- When building production container images that need minimal attack surface
- When compliance requires CIS Docker Benchmark adherence for container configurations
- When reducing image size to minimize vulnerability exposure from unused packages
- When implementing defense-in-depth for containerized workloads
- When migrating from fat base images to distroless or minimal images
Do not use for runtime container security monitoring (use Falco), for host-level Docker daemon hardening (use CIS Docker Benchmark host checks), or for container orchestration security (use Kubernetes security scanning).
Prerequisites
- Docker or BuildKit for multi-stage builds
- Base image options: distroless, Alpine, slim, or scratch
- Container scanning tool (Trivy) for validation
- CIS Docker Benchmark reference
Workflow
Step 1: Use Multi-Stage Builds to Minimize Image Size
# Build stage with all dependencies
FROM python:3.12-bookworm AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
COPY src/ ./src/
RUN python -m compileall src/
# Production stage with minimal base
FROM python:3.12-slim-bookworm AS production
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 && \
rm -rf /var/lib/apt/lists/* && \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false
COPY --from=builder /install /usr/local
COPY --from=builder /build/src /app/src
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
RUN chown -R appuser:appuser /app
USER appuser
WORKDIR /app
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1
EXPOSE 8080
ENTRYPOINT ["python", "-m", "src.main"]Step 2: Use Distroless Base Images
# Go application with distroless
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /server .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]Step 3: Remove Unnecessary Components
# Hardened image checklist
FROM ubuntu:24.04 AS base
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 && \
# Remove package manager to prevent runtime package installation
apt-get purge -y --auto-remove apt dpkg && \
rm -rf /var/lib/apt/lists/* \
/var/cache/apt/* \
/tmp/* \
/var/tmp/* \
/usr/share/doc/* \
/usr/share/man/* \
/usr/share/info/* \
/root/.cache
# Remove shells if not needed
RUN rm -f /bin/sh /bin/bash /usr/bin/sh 2>/dev/null || true
# Remove setuid/setgid binaries
RUN find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || trueStep 4: Configure Read-Only Filesystem
# Kubernetes deployment with read-only root filesystem
apiVersion: apps/v1
kind: Deployment
metadata:
name: hardened-app
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: app:hardened
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir:
sizeLimit: 100Mi
- name: cache
emptyDir:
sizeLimit: 50MiStep 5: Pin Base Image by Digest
# Pin to exact image digest for reproducibility
FROM python:3.12-slim-bookworm@sha256:abcdef1234567890 AS production
# This ensures the exact same base image is used every timeStep 6: Validate Hardening with Automated Scanning
# Scan hardened image with Trivy
trivy image --severity HIGH,CRITICAL hardened-app:latest
# Check CIS Docker Benchmark compliance
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/docker-bench-security
# Verify no root processes
docker run --rm hardened-app:latest whoami
# Expected: appuser (NOT root)
# Verify read-only filesystem
docker run --rm hardened-app:latest touch /test 2>&1
# Expected: Read-only file system errorKey Concepts
| Term | Definition |
|---|---|
| Multi-Stage Build | Docker build technique using multiple FROM stages to separate build and runtime, reducing final image size |
| Distroless | Google-maintained minimal container images containing only the application and runtime dependencies |
| Non-Root User | Running container processes as unprivileged user to limit impact of container escape exploits |
| Read-Only Root | Mounting the container root filesystem as read-only to prevent runtime modification |
| Image Digest | SHA256 hash uniquely identifying an exact image version, more precise than mutable tags |
| Scratch Image | Empty Docker base image used for statically compiled binaries requiring no OS |
| Security Context | Kubernetes pod/container-level security settings controlling privileges, filesystem, and capabilities |
Tools & Systems
- Docker BuildKit: Advanced Docker build engine supporting multi-stage builds and build secrets
- Distroless Images: Google's minimal container base images (static, base, java, python, nodejs)
- docker-bench-security: Script checking CIS Docker Benchmark compliance
- Trivy: Container image vulnerability and misconfiguration scanner
- Hadolint: Dockerfile linter enforcing best practices
Common Scenarios
Scenario: Reducing a 1.2GB Python Image to Under 150MB
Context: A data science team uses python:3.12 as base image (1.2GB) with scientific computing packages. The image has 200+ known CVEs from unnecessary system packages.
Approach:
- Switch to
python:3.12-slim-bookwormas base (150MB) and install only required system libraries - Use multi-stage build: compile C extensions in builder stage, copy wheels to production
- Pin numpy, pandas, and scipy to pre-built wheels to avoid build dependencies in production
- Remove pip, setuptools, and wheel from the final image
- Create non-root user and set filesystem permissions
- Validate with Trivy: expect CVE count to drop from 200+ to under 20
Pitfalls: Some Python packages require shared libraries at runtime (libgomp, libstdc++). Test the application thoroughly after removing system packages. Alpine-based images use musl libc which can cause compatibility issues with numpy and pandas.
Output Format
Container Image Hardening Report
==================================
Image: app:hardened
Base: python:3.12-slim-bookworm
Date: 2026-02-23
SIZE COMPARISON:
Before hardening: 1,247 MB (python:3.12)
After hardening: 143 MB (python:3.12-slim + multi-stage)
Reduction: 88.5%
SECURITY CHECKS:
[PASS] Non-root user configured (appuser:1000)
[PASS] HEALTHCHECK instruction present
[PASS] No setuid/setgid binaries found
[PASS] Package manager removed
[PASS] Base image pinned by digest
[PASS] No shell access (/bin/sh removed)
[WARN] /tmp writable (emptyDir mounted)
VULNERABILITY COMPARISON:
Before: 234 CVEs (12 Critical, 45 High)
After: 18 CVEs (0 Critical, 3 High)
Reduction: 92.3%References and resources
Everything below is rendered for inspection. Script files are read-only and never run.
References 3
api-reference.md5.1 KB
API Reference: Container Image Hardening Audit
Libraries Used
| Library | Purpose |
|---|---|
subprocess |
Execute Trivy, Docker, and hadolint CLI commands |
json |
Parse vulnerability scan and inspection results |
re |
Analyze Dockerfile instructions |
pathlib |
Handle Dockerfile and image paths |
Installation
# Trivy vulnerability scanner
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Hadolint Dockerfile linter
wget -O /usr/local/bin/hadolint https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64
chmod +x /usr/local/bin/hadolint
# Docker CLI (already installed in most environments)Trivy Image Scanning
Scan Image for Vulnerabilities
import subprocess
import json
def scan_image(image_name, severity="CRITICAL,HIGH"):
cmd = [
"trivy", "image",
"--format", "json",
"--severity", severity,
"--exit-code", "0",
image_name,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
return json.loads(result.stdout) if result.stdout else {}Scan for Secrets in Image
def scan_secrets(image_name):
cmd = [
"trivy", "image",
"--format", "json",
"--scanners", "secret",
image_name,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
return json.loads(result.stdout) if result.stdout else {}Scan for Misconfigurations
def scan_misconfig(image_name):
cmd = [
"trivy", "image",
"--format", "json",
"--scanners", "misconfig",
image_name,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
return json.loads(result.stdout) if result.stdout else {}Docker Image Inspection
Inspect Image Metadata
def inspect_image(image_name):
cmd = ["docker", "inspect", image_name]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
data = json.loads(result.stdout)[0]
config = data.get("Config", {})
return {
"image": image_name,
"user": config.get("User", "root"),
"exposed_ports": list(config.get("ExposedPorts", {}).keys()),
"env_vars": config.get("Env", []),
"entrypoint": config.get("Entrypoint"),
"cmd": config.get("Cmd"),
"labels": config.get("Labels", {}),
"layers": len(data.get("RootFS", {}).get("Layers", [])),
"size_mb": round(data.get("Size", 0) / 1048576, 1),
}Check for Root User
def check_non_root(image_name):
inspection = inspect_image(image_name)
user = inspection["user"]
return {
"image": image_name,
"runs_as_root": user in ("", "root", "0"),
"user": user or "root (default)",
"severity": "high" if user in ("", "root", "0") else "pass",
}Hadolint Dockerfile Linting
def lint_dockerfile(dockerfile_path):
cmd = [
"hadolint",
"--format", "json",
str(dockerfile_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
findings = json.loads(result.stdout) if result.stdout else []
return [
{
"line": f["line"],
"code": f["code"],
"level": f["level"],
"message": f["message"],
}
for f in findings
]Hardening Checks
Common Dockerfile Issues
def audit_dockerfile(dockerfile_path):
findings = []
with open(dockerfile_path) as f:
lines = f.readlines()
has_user = False
has_healthcheck = False
for i, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith("USER") and stripped.split()[-1] not in ("root", "0"):
has_user = True
if stripped.startswith("HEALTHCHECK"):
has_healthcheck = True
if stripped.startswith("FROM") and ":latest" in stripped:
findings.append({
"line": i, "severity": "medium",
"issue": "Using :latest tag — pin specific version",
})
if "ADD" in stripped and ("http://" in stripped or "https://" in stripped):
findings.append({
"line": i, "severity": "high",
"issue": "ADD with remote URL — use COPY + curl for verification",
})
if not has_user:
findings.append({"line": 0, "severity": "high", "issue": "No USER instruction — runs as root"})
if not has_healthcheck:
findings.append({"line": 0, "severity": "low", "issue": "No HEALTHCHECK instruction"})
return findingsOutput Format
{
"image": "myapp:v1.2.3",
"vulnerabilities": {
"critical": 2,
"high": 8,
"medium": 15,
"low": 23
},
"runs_as_root": false,
"size_mb": 142.5,
"layers": 12,
"dockerfile_issues": 3,
"secrets_found": 0,
"findings": [
{
"type": "vulnerability",
"package": "openssl",
"installed": "3.0.2",
"fixed": "3.0.13",
"severity": "CRITICAL",
"cve": "CVE-2024-0727"
}
]
}standards.md0.9 KB
Standards Reference: Container Image Hardening
CIS Docker Benchmark v1.6.0 - Image Controls
- 4.1: Create a user for the container
- 4.2: Use trusted base images
- 4.3: Do not install unnecessary packages
- 4.4: Scan and rebuild images for security patches
- 4.6: Add HEALTHCHECK instruction
- 4.7: Do not use update instructions alone
- 4.9: Use COPY instead of ADD
- 4.10: Do not store secrets in Dockerfiles
- 4.11: Install verified packages only
NIST SP 800-190 Application Container Security Guide
Image Hardening (Section 4.1)
- Use minimal base images to reduce attack surface
- Remove unnecessary tools, shells, and package managers
- Scan images for vulnerabilities before deployment
- Sign images for integrity verification
OWASP Docker Security Cheat Sheet
- Use multi-stage builds
- Run as non-root user
- Use read-only root filesystem
- Pin base images to digests
- Drop all Linux capabilities
- Enable seccomp profile
workflows.md1.8 KB
Workflow Reference: Container Image Hardening
Hardening Pipeline
Base Image Selection
│
▼
┌──────────────────┐
│ Multi-Stage Build │
│ (builder + prod) │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ Remove Packages │
│ + Set User/Perms │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ Trivy Scan │
│ + Hadolint │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ CIS Benchmark │
│ Validation │
└──────────────────┘Base Image Selection Guide
| Base Image | Size | Use Case | Packages |
|---|---|---|---|
| scratch | 0 MB | Static Go/Rust binaries | None |
| distroless/static | 2 MB | Static binaries + CA certs | ca-certificates |
| distroless/base | 20 MB | Dynamic binaries | glibc, libssl |
| alpine:3.19 | 7 MB | General minimal | musl, busybox |
| debian:bookworm-slim | 80 MB | Debian ecosystem | apt, glibc |
| ubuntu:24.04 | 78 MB | Ubuntu ecosystem | apt, glibc |
Dockerfile Hardening Checklist
- Multi-stage build separates build and runtime
- Minimal base image selected
- Non-root USER instruction present
- HEALTHCHECK instruction defined
- Base image pinned by digest
- COPY used instead of ADD
- No secrets in ENV or ARG
- Package cache cleaned (rm -rf /var/lib/apt/lists/*)
- Unnecessary packages removed
- setuid/setgid bits cleared
- Shell removed (if not needed)
Scripts 2
agent.py11.3 KB
#!/usr/bin/env python3
"""Container image hardening audit agent.
Audits Docker container images for security hardening best practices
using Trivy for vulnerability scanning, Dockle for CIS Docker Benchmark
compliance, and Dockerfile analysis for security anti-patterns.
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
def find_binary(name):
"""Locate a binary on the system PATH."""
for ext in ["", ".exe"]:
for directory in os.environ.get("PATH", "").split(os.pathsep):
full_path = os.path.join(directory, name + ext)
if os.path.isfile(full_path):
return full_path
return None
def run_trivy_image_scan(image, severity="CRITICAL,HIGH", ignore_unfixed=True):
"""Scan a container image with Trivy for vulnerabilities."""
trivy_bin = find_binary("trivy")
if not trivy_bin:
print("[!] trivy not found. Install: https://github.com/aquasecurity/trivy",
file=sys.stderr)
return None
cmd = [trivy_bin, "image", "--format", "json", "--severity", severity]
if ignore_unfixed:
cmd.append("--ignore-unfixed")
cmd.append(image)
print(f"[*] Running Trivy scan: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode != 0 and not result.stdout:
print(f"[!] Trivy error: {result.stderr[:300]}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return None
def run_dockle_scan(image):
"""Scan a container image with Dockle for CIS benchmark compliance."""
dockle_bin = find_binary("dockle")
if not dockle_bin:
print("[*] dockle not found, skipping CIS benchmark check", file=sys.stderr)
return None
cmd = [dockle_bin, "--format", "json", image]
print(f"[*] Running Dockle scan: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return None
def analyze_dockerfile(dockerfile_path):
"""Analyze a Dockerfile for security anti-patterns."""
findings = []
if not os.path.isfile(dockerfile_path):
return findings
with open(dockerfile_path, "r") as f:
lines = f.readlines()
runs_as_root = True
has_healthcheck = False
uses_latest_tag = False
for i, line in enumerate(lines, 1):
stripped = line.strip()
upper = stripped.upper()
if upper.startswith("FROM") and ":latest" in stripped:
uses_latest_tag = True
findings.append({
"check": "FROM uses :latest tag",
"line": i,
"severity": "HIGH",
"description": "Pin image to a specific version for reproducibility and security",
"content": stripped,
})
if upper.startswith("FROM") and "scratch" not in stripped.lower():
base = stripped.split()[-1] if stripped.split() else ""
if "alpine" not in base.lower() and "distroless" not in base.lower():
findings.append({
"check": "Non-minimal base image",
"line": i,
"severity": "MEDIUM",
"description": "Consider using Alpine or distroless base for smaller attack surface",
"content": stripped,
})
if upper.startswith("USER") and stripped.split()[-1] not in ("root", "0"):
runs_as_root = False
if upper.startswith("HEALTHCHECK"):
has_healthcheck = True
if upper.startswith("RUN") and ("chmod 777" in stripped or "chmod -R 777" in stripped):
findings.append({
"check": "Overly permissive chmod 777",
"line": i,
"severity": "HIGH",
"description": "chmod 777 grants world-writable permissions; use specific permissions",
"content": stripped,
})
if upper.startswith("RUN") and "curl" in stripped and "| sh" in stripped:
findings.append({
"check": "Pipe to shell from curl",
"line": i,
"severity": "CRITICAL",
"description": "Piping curl output to shell is risky; download, verify, then execute",
"content": stripped,
})
if upper.startswith("ENV") and any(kw in upper for kw in ["PASSWORD", "SECRET", "TOKEN", "API_KEY"]):
findings.append({
"check": "Secrets in ENV instruction",
"line": i,
"severity": "CRITICAL",
"description": "Never embed secrets in Dockerfile; use build args or secrets mount",
"content": stripped,
})
if upper.startswith("ADD") and not stripped.endswith(".tar.gz"):
findings.append({
"check": "ADD instead of COPY",
"line": i,
"severity": "LOW",
"description": "Use COPY unless you need ADD's auto-extraction; COPY is more explicit",
"content": stripped,
})
if upper.startswith("EXPOSE") and any(p in stripped for p in ["22", "23", "3389"]):
findings.append({
"check": "Exposed management port",
"line": i,
"severity": "HIGH",
"description": "SSH/Telnet/RDP ports should not be exposed in containers",
"content": stripped,
})
if runs_as_root:
findings.append({
"check": "Container runs as root",
"line": 0,
"severity": "HIGH",
"description": "Add a USER instruction to run as non-root for least privilege",
})
if not has_healthcheck:
findings.append({
"check": "Missing HEALTHCHECK",
"line": 0,
"severity": "LOW",
"description": "Add HEALTHCHECK to enable container health monitoring",
})
return findings
def extract_trivy_findings(trivy_data):
"""Extract vulnerability findings from Trivy JSON output."""
findings = []
if not trivy_data:
return findings
results = trivy_data.get("Results", [])
for result in results:
target = result.get("Target", "")
for vuln in result.get("Vulnerabilities", []):
findings.append({
"source": "trivy",
"target": target,
"vulnerability_id": vuln.get("VulnerabilityID", ""),
"pkg_name": vuln.get("PkgName", ""),
"installed_version": vuln.get("InstalledVersion", ""),
"fixed_version": vuln.get("FixedVersion", ""),
"severity": vuln.get("Severity", "UNKNOWN"),
"title": vuln.get("Title", ""),
"description": vuln.get("Description", "")[:200],
})
return findings
def extract_dockle_findings(dockle_data):
"""Extract CIS benchmark findings from Dockle JSON output."""
findings = []
if not dockle_data:
return findings
for detail in dockle_data.get("details", []):
severity_map = {"FATAL": "CRITICAL", "WARN": "HIGH", "INFO": "MEDIUM", "SKIP": "LOW"}
findings.append({
"source": "dockle",
"code": detail.get("code", ""),
"title": detail.get("title", ""),
"level": detail.get("level", "INFO"),
"severity": severity_map.get(detail.get("level", "INFO"), "MEDIUM"),
"alerts": detail.get("alerts", []),
})
return findings
def format_summary(image, trivy_findings, dockle_findings, dockerfile_findings):
"""Print combined audit summary."""
all_findings = trivy_findings + dockle_findings + dockerfile_findings
print(f"\n{'='*60}")
print(f" Container Image Hardening Audit")
print(f"{'='*60}")
print(f" Image : {image}")
print(f" Vulnerabilities: {len(trivy_findings)} (Trivy)")
print(f" CIS Benchmark : {len(dockle_findings)} (Dockle)")
print(f" Dockerfile : {len(dockerfile_findings)}")
print(f" Total Findings : {len(all_findings)}")
severity_counts = {}
for f in all_findings:
sev = f.get("severity", "UNKNOWN")
severity_counts[sev] = severity_counts.get(sev, 0) + 1
print(f"\n By Severity:")
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]:
count = severity_counts.get(sev, 0)
if count > 0:
print(f" {sev:10s}: {count}")
if trivy_findings:
print(f"\n Top Vulnerabilities:")
for f in trivy_findings[:10]:
print(f" {f['vulnerability_id']:16s} | {f['severity']:8s} | "
f"{f['pkg_name']}:{f['installed_version']} -> {f.get('fixed_version', 'N/A')}")
if dockerfile_findings:
print(f"\n Dockerfile Issues:")
for f in dockerfile_findings:
print(f" [{f['severity']:8s}] Line {f.get('line', 0):3d}: {f['check']}")
return severity_counts
def main():
parser = argparse.ArgumentParser(
description="Container image hardening audit agent"
)
parser.add_argument("--image", required=True, help="Container image to scan (e.g., nginx:1.25)")
parser.add_argument("--dockerfile", help="Path to Dockerfile for static analysis")
parser.add_argument("--severity", default="CRITICAL,HIGH",
help="Trivy severity filter (default: CRITICAL,HIGH)")
parser.add_argument("--skip-trivy", action="store_true", help="Skip Trivy scan")
parser.add_argument("--skip-dockle", action="store_true", help="Skip Dockle scan")
parser.add_argument("--output", "-o", help="Output JSON report path")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
trivy_findings = []
dockle_findings = []
dockerfile_findings = []
if not args.skip_trivy:
trivy_data = run_trivy_image_scan(args.image, args.severity)
trivy_findings = extract_trivy_findings(trivy_data)
if not args.skip_dockle:
dockle_data = run_dockle_scan(args.image)
dockle_findings = extract_dockle_findings(dockle_data)
if args.dockerfile:
dockerfile_findings = analyze_dockerfile(args.dockerfile)
severity_counts = format_summary(
args.image, trivy_findings, dockle_findings, dockerfile_findings
)
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"tool": "Container Hardening Audit",
"image": args.image,
"severity_counts": severity_counts,
"trivy_findings": trivy_findings,
"dockle_findings": dockle_findings,
"dockerfile_findings": dockerfile_findings,
"total_findings": len(trivy_findings) + len(dockle_findings) + len(dockerfile_findings),
"risk_level": (
"CRITICAL" if severity_counts.get("CRITICAL", 0) > 0
else "HIGH" if severity_counts.get("HIGH", 0) > 0
else "MEDIUM" if severity_counts.get("MEDIUM", 0) > 0
else "LOW"
),
}
if args.output:
with open(args.output, "w") as f:
json.dump(report, f, indent=2)
print(f"\n[+] Report saved to {args.output}")
elif args.verbose:
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
process.py6.9 KB
#!/usr/bin/env python3
"""
Container Image Hardening Validation Script
Validates container images against hardening best practices,
checks for non-root user, minimal packages, and CIS compliance.
Usage:
python process.py --image myapp:latest --output hardening-report.json
"""
import argparse
import json
import os
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class HardeningCheck:
check_id: str
name: str
passed: bool
details: str
severity: str
def run_docker_inspect(image: str) -> dict:
"""Inspect a Docker image and return configuration."""
cmd = ["docker", "inspect", image]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if proc.returncode == 0:
data = json.loads(proc.stdout)
return data[0] if data else {}
return {"error": proc.stderr}
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError) as e:
return {"error": str(e)}
def check_non_root_user(config: dict) -> HardeningCheck:
"""Check if image runs as non-root user."""
user = config.get("Config", {}).get("User", "")
if user and user != "0" and user != "root":
return HardeningCheck("CIS-4.1", "Non-root user configured", True,
f"User: {user}", "HIGH")
return HardeningCheck("CIS-4.1", "Non-root user configured", False,
"Image runs as root. Add USER instruction.", "HIGH")
def check_healthcheck(config: dict) -> HardeningCheck:
"""Check if HEALTHCHECK is defined."""
healthcheck = config.get("Config", {}).get("Healthcheck")
if healthcheck and healthcheck.get("Test"):
return HardeningCheck("CIS-4.6", "HEALTHCHECK defined", True,
f"Test: {healthcheck['Test']}", "MEDIUM")
return HardeningCheck("CIS-4.6", "HEALTHCHECK defined", False,
"No HEALTHCHECK instruction found.", "MEDIUM")
def check_exposed_ports(config: dict) -> HardeningCheck:
"""Check for unnecessary exposed ports."""
ports = config.get("Config", {}).get("ExposedPorts", {})
port_list = list(ports.keys()) if ports else []
if len(port_list) <= 2:
return HardeningCheck("HARD-1", "Minimal ports exposed", True,
f"Ports: {port_list}", "LOW")
return HardeningCheck("HARD-1", "Minimal ports exposed", False,
f"Many ports exposed: {port_list}. Minimize exposed ports.", "LOW")
def check_image_size(image: str) -> HardeningCheck:
"""Check image size against recommended limits."""
cmd = ["docker", "image", "inspect", image, "--format", "{{.Size}}"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
size_bytes = int(proc.stdout.strip())
size_mb = size_bytes / (1024 * 1024)
if size_mb < 200:
return HardeningCheck("HARD-2", "Image size within limits", True,
f"Size: {size_mb:.0f} MB (< 200 MB)", "MEDIUM")
elif size_mb < 500:
return HardeningCheck("HARD-2", "Image size within limits", False,
f"Size: {size_mb:.0f} MB (> 200 MB, consider optimizing)", "MEDIUM")
else:
return HardeningCheck("HARD-2", "Image size within limits", False,
f"Size: {size_mb:.0f} MB (> 500 MB, use multi-stage build)", "HIGH")
except (subprocess.TimeoutExpired, ValueError):
return HardeningCheck("HARD-2", "Image size within limits", False,
"Could not determine image size", "LOW")
def check_env_secrets(config: dict) -> HardeningCheck:
"""Check for potential secrets in environment variables."""
env_vars = config.get("Config", {}).get("Env", [])
secret_keywords = ["password", "secret", "key", "token", "credential", "api_key"]
suspicious = []
for env in env_vars:
name = env.split("=")[0].lower()
if any(kw in name for kw in secret_keywords):
suspicious.append(env.split("=")[0])
if not suspicious:
return HardeningCheck("CIS-4.10", "No secrets in ENV", True,
"No suspicious environment variables found", "HIGH")
return HardeningCheck("CIS-4.10", "No secrets in ENV", False,
f"Suspicious ENV vars: {suspicious}. Use secrets manager.", "HIGH")
def check_shell_available(image: str) -> HardeningCheck:
"""Check if shell is available in the image."""
cmd = ["docker", "run", "--rm", "--entrypoint", "", image, "sh", "-c", "echo shell_available"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if "shell_available" in proc.stdout:
return HardeningCheck("HARD-3", "Shell removed from image", False,
"Shell (/bin/sh) is available. Consider removing for production.", "LOW")
return HardeningCheck("HARD-3", "Shell removed from image", True,
"No shell available in image", "LOW")
except (subprocess.TimeoutExpired, FileNotFoundError):
return HardeningCheck("HARD-3", "Shell removed from image", True,
"Could not test shell access (likely unavailable)", "LOW")
def main():
parser = argparse.ArgumentParser(description="Container Image Hardening Validation")
parser.add_argument("--image", required=True, help="Docker image to validate")
parser.add_argument("--output", default="hardening-report.json")
parser.add_argument("--fail-on-findings", action="store_true")
args = parser.parse_args()
print(f"[*] Validating hardening: {args.image}")
config = run_docker_inspect(args.image)
if "error" in config:
print(f"[ERROR] {config['error']}")
sys.exit(2)
checks = [
check_non_root_user(config),
check_healthcheck(config),
check_exposed_ports(config),
check_image_size(args.image),
check_env_secrets(config),
check_shell_available(args.image),
]
passed = sum(1 for c in checks if c.passed)
failed = sum(1 for c in checks if not c.passed)
report = {
"metadata": {"image": args.image, "date": datetime.now(timezone.utc).isoformat()},
"summary": {"total": len(checks), "passed": passed, "failed": failed},
"checks": [
{"id": c.check_id, "name": c.name, "passed": c.passed,
"details": c.details, "severity": c.severity}
for c in checks
]
}
output_path = os.path.abspath(args.output)
with open(output_path, "w") as f:
json.dump(report, f, indent=2)
for c in checks:
status = "PASS" if c.passed else "FAIL"
print(f" [{status}] {c.check_id}: {c.name} - {c.details}")
print(f"\n[*] {passed}/{len(checks)} checks passed")
if args.fail_on_findings and failed > 0:
sys.exit(1)
if __name__ == "__main__":
main()