Security · python · 21 copies
Container & Kubernetes Security Scanner
Security audit for Docker images and Kubernetes clusters. For Docker: scans image layers for CVEs via Trivy subprocess, detects secrets baked into image layers (AWS keys, private keys, passwords), identifies containers running as root or with privileged flags, and validates Dockerfile best practices. For Kubernetes: audits RBAC (ServiceAccounts with cluster-admin, wildcard verbs, Secrets access), PodSecurityContext misconfigurations, containers without resource limits, privileged pods, hostPath mounts to Docker socket, missing NetworkPolicies, and externally-exposed services including the Kubernetes Dashboard.
- Linux
- Windows
- macOS
pip install "docker>=6.1" "kubernetes>=28.1" "requests>=2.31" "colorama>=0.4.6"#!/usr/bin/env python3
"""
Container & Kubernetes Security Scanner
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/container-security-scanner
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "docker>=6.1" "kubernetes>=28.1" "requests>=2.31" "colorama>=0.4.6"
"""
import argparse
import base64
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
try:
from colorama import Fore, Style, init as colorama_init
colorama_init(autoreset=True)
except ImportError:
class Fore:
RED = GREEN = YELLOW = CYAN = WHITE = MAGENTA = ''
class Style:
RESET_ALL = BRIGHT = ''
FINDINGS = []
def finding(severity, component, resource, issue, detail='', fix=''):
FINDINGS.append({'severity': severity, 'component': component, 'resource': resource,
'issue': issue, 'detail': detail, 'fix': fix})
color = {'CRITICAL': Fore.RED, 'HIGH': Fore.YELLOW, 'MEDIUM': Fore.CYAN, 'LOW': Fore.WHITE}.get(severity, Fore.WHITE)
mark = '\u2717' if severity in ('CRITICAL', 'HIGH') else '\u26a0'
print(f" {color}{mark} {severity:<10}{Style.RESET_ALL} {resource}")
if detail:
print(f" {Fore.WHITE}{detail}{Style.RESET_ALL}")
if fix:
print(f" {Fore.CYAN}Fix: {fix}{Style.RESET_ALL}")
def ok(resource, note=''):
print(f" {Fore.GREEN}\u2713{Style.RESET_ALL} {resource} {note}")
# ─── DOCKER ──────────────────────────────────────────────────────────────────
# Secret patterns for layer scanning
SECRET_PATTERNS = [
(r'(?i)(aws_secret_access_key|aws_access_key_id)\s*[=:]\s*[A-Za-z0-9/+]{20,}', 'AWS credential'),
(r'(?i)(password|passwd|pwd)\s*[=:]\s*[^\s\n]{8,}', 'Password'),
(r'-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----', 'Private key'),
(r'(?i)(api_key|apikey|api-key)\s*[=:]\s*[A-Za-z0-9_\-]{20,}', 'API key'),
(r'eyJ[A-Za-z0-9_\-]{20,}\.eyJ', 'JWT token'),
(r'(?i)(secret_key|secret)\s*[=:]\s*[A-Za-z0-9_\-]{16,}', 'Secret key'),
(r'AKIA[0-9A-Z]{16}', 'AWS Access Key ID'),
(r'ghp_[A-Za-z0-9]{36}', 'GitHub Personal Access Token'),
(r'(?i)database_url\s*[=:]\s*\w+://\S+:\S+@', 'Database URL with credentials'),
]
def scan_image_layers(image_name: str):
print(f"\n{Fore.CYAN}[*] DOCKER IMAGE: {image_name} — CVE Scan{Style.RESET_ALL}")
# Use trivy if available
trivy_path = subprocess.run(['which', 'trivy'], capture_output=True, text=True).stdout.strip()
if not trivy_path:
trivy_path = subprocess.run(['where', 'trivy'], capture_output=True, text=True).stdout.strip().split('\n')[0]
if trivy_path:
try:
result = subprocess.run(
['trivy', 'image', '--format', 'json', '--severity', 'CRITICAL,HIGH,MEDIUM', image_name],
capture_output=True, text=True, timeout=120
)
data = json.loads(result.stdout)
total_crit = total_high = 0
for target in data.get('Results', []):
for vuln in target.get('Vulnerabilities', []) or []:
cve = vuln.get('VulnerabilityID', '')
pkg = vuln.get('PkgName', '')
severity = vuln.get('Severity', '')
installed = vuln.get('InstalledVersion', '')
fixed = vuln.get('FixedVersion', '')
title = vuln.get('Title', '')[:60]
if severity == 'CRITICAL':
total_crit += 1
finding('CRITICAL', 'CVE', f'{cve} in {pkg} {installed}',
title,
f'CVSS: {vuln.get("CVSS", {}).get("nvd", {}).get("V3Score", "N/A")}',
f'Update {pkg} to {fixed}' if fixed else f'Update base image')
elif severity == 'HIGH' and total_high < 5:
total_high += 1
finding('HIGH', 'CVE', f'{cve} in {pkg} {installed}', title,
'', f'Update {pkg} to {fixed}' if fixed else 'Update base image')
if total_crit == 0 and total_high == 0:
ok(image_name, 'No CRITICAL/HIGH CVEs found ✓')
else:
print(f" {Fore.RED}Total: {total_crit} CRITICAL, {total_high} HIGH CVEs{Style.RESET_ALL}")
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as e:
print(f" {Fore.YELLOW}Trivy scan error: {e}{Style.RESET_ALL}")
else:
print(f" {Fore.YELLOW}Trivy not found — install with: brew install trivy (macOS) or apt install trivy (Ubuntu){Style.RESET_ALL}")
print(f" Skipping CVE scan — checking image metadata only")
def find_secrets_in_layers(image_name: str):
print(f"\n{Fore.CYAN}[*] SECRET SCAN — image layers{Style.RESET_ALL}")
try:
import docker
client = docker.from_env()
try:
image = client.images.get(image_name)
except docker.errors.ImageNotFound:
print(f" Pulling {image_name}...")
client.images.pull(image_name)
image = client.images.get(image_name)
history = client.api.history(image_name)
for i, layer in enumerate(history):
cmd = layer.get('CreatedBy', '')
for pattern, secret_type in SECRET_PATTERNS:
m = re.search(pattern, cmd)
if m:
redacted = m.group(0)[:20] + '****'
finding('HIGH', 'Secret in Layer', f'Layer {i+1}',
f'{secret_type} found in build command',
f'Value (redacted): {redacted}\n Secrets in layers are permanent — even if removed in later layers, they exist in the layer history',
'Use --secret flag in docker build, or inject at runtime via env vars / secrets manager')
except ImportError:
print(f" {Fore.YELLOW}docker SDK not installed — pip install docker{Style.RESET_ALL}")
except Exception as e:
print(f" {Fore.YELLOW}ERROR scanning layers: {e}{Style.RESET_ALL}")
def check_running_containers():
print(f"\n{Fore.CYAN}[*] RUNNING CONTAINERS{Style.RESET_ALL}")
try:
import docker
client = docker.from_env()
containers = client.containers.list()
print(f" {len(containers)} running containers")
for c in containers:
name = c.name
attrs = c.attrs
config = attrs.get('Config', {})
host_config = attrs.get('HostConfig', {})
# Privileged
if host_config.get('Privileged', False):
finding('CRITICAL', 'Container', name,
'Privileged container — full host kernel access',
'A privileged container can escape to the host node and compromise the entire cluster',
'Remove --privileged flag; use specific capabilities instead: --cap-add NET_ADMIN')
# Root user
user = config.get('User', '')
if not user or user == '0' or user == 'root':
finding('HIGH', 'Container', name, 'Running as root (UID 0)',
'If exploited, attacker has root in container which often maps to root on host',
'Add USER nonroot in Dockerfile, or use securityContext.runAsNonRoot: true')
# Dangerous volume mounts
mounts = attrs.get('Mounts', [])
for mount in mounts:
src = mount.get('Source', '')
if src in ('/var/run/docker.sock', '/run/docker.sock'):
finding('CRITICAL', 'Volume Mount', name,
'Docker socket mounted into container',
'App inside container can control Docker daemon — trivial container escape to host',
'Remove docker.sock mount; use Docker API proxy (e.g. Tecnativa/docker-socket-proxy) if needed')
elif src == '/':
finding('CRITICAL', 'Volume Mount', name,
'Root filesystem mounted into container',
'Container has read/write access to the entire host filesystem',
'Remove host root mount immediately')
# Exposed ports
ports = c.ports
if ports:
open_ports = [p for p, bindings in ports.items() if bindings]
if open_ports:
ok(name, f'Exposed ports: {', '.join(open_ports[:5])}')
except ImportError:
print(f" {Fore.YELLOW}docker SDK not installed — pip install docker{Style.RESET_ALL}")
except Exception as e:
print(f" {Fore.YELLOW}ERROR: {e}{Style.RESET_ALL}")
def check_dockerfile_best_practices(dockerfile_path: str):
print(f"\n{Fore.CYAN}[*] DOCKERFILE BEST PRACTICES — {dockerfile_path}{Style.RESET_ALL}")
try:
with open(dockerfile_path, 'r') as fh:
content = fh.read()
lines = content.splitlines()
except Exception as e:
print(f" ERROR reading Dockerfile: {e}"); return
# USER instruction
user_lines = [l for l in lines if re.match(r'^USER\s+', l, re.IGNORECASE)]
if not user_lines:
finding('HIGH', 'Dockerfile', dockerfile_path, 'No USER instruction — runs as root',
'Container will run as root by default if no USER is set',
'Add: USER nonroot (after creating the user in an earlier RUN step)')
else:
users = [l.split()[1] for l in user_lines if len(l.split()) > 1]
if any(u in ('root', '0') for u in users):
finding('HIGH', 'Dockerfile', dockerfile_path, 'USER root explicitly set', '', 'Change USER to a non-root user')
else:
ok(dockerfile_path, f'USER set: {users[-1]} ✓')
# latest tag
from_lines = [l for l in lines if re.match(r'^FROM\s+', l, re.IGNORECASE)]
for fl in from_lines:
if ':latest' in fl or (len(fl.split()) >= 2 and ':' not in fl.split()[1]):
finding('MEDIUM', 'Dockerfile', dockerfile_path,
f'Unpinned base image: {fl.strip()}',
'Using :latest or no tag means the base image can change unexpectedly on rebuild',
'Pin to a specific digest: FROM ubuntu:22.04@sha256:<digest>')
# ADD with URL
for line in lines:
if re.match(r'^ADD\s+https?://', line, re.IGNORECASE):
finding('MEDIUM', 'Dockerfile', dockerfile_path,
'ADD with remote URL — use curl + verify checksum instead',
'ADD with URLs does not verify integrity; the remote file could be tampered',
'Replace with: RUN curl -sSL <url> -o file && echo "<sha256> file" | sha256sum -c')
# Secrets in ENV
for line in lines:
if re.match(r'^ENV\s+', line, re.IGNORECASE):
for pattern, secret_type in SECRET_PATTERNS[:3]:
if re.search(pattern, line, re.IGNORECASE):
finding('HIGH', 'Dockerfile', dockerfile_path,
f'Secret in ENV instruction: {secret_type}',
'ENV values are baked into every layer and visible in docker inspect',
'Inject secrets at runtime via --env-file or secrets manager; never in Dockerfile')
# HEALTHCHECK
if not any(re.match(r'^HEALTHCHECK\s+', l, re.IGNORECASE) for l in lines):
finding('LOW', 'Dockerfile', dockerfile_path, 'No HEALTHCHECK defined',
'Without HEALTHCHECK, Docker cannot detect if the app is unhealthy',
'Add HEALTHCHECK CMD curl -f http://localhost/ || exit 1')
else:
ok(dockerfile_path, 'HEALTHCHECK defined ✓')
# ─── KUBERNETES ──────────────────────────────────────────────────────────────
def get_k8s_client(kubeconfig: str):
try:
from kubernetes import client as k8s_client, config as k8s_config
if kubeconfig:
k8s_config.load_kube_config(config_file=kubeconfig)
else:
try:
k8s_config.load_incluster_config()
except Exception:
k8s_config.load_kube_config()
return k8s_client
except ImportError:
print(f"{Fore.RED}ERROR: kubernetes SDK not installed — pip install kubernetes{Style.RESET_ALL}")
return None
except Exception as e:
print(f"{Fore.RED}ERROR connecting to Kubernetes: {e}{Style.RESET_ALL}")
return None
def audit_rbac(k8s_client, namespace: str):
print(f"\n{Fore.CYAN}[*] RBAC AUDIT{Style.RESET_ALL}")
rbac_api = k8s_client.RbacAuthorizationV1Api()
# ClusterRoleBindings with cluster-admin
try:
crbs = rbac_api.list_cluster_role_binding()
for crb in crbs.items:
if crb.role_ref.name == 'cluster-admin':
subjects = crb.subjects or []
for subj in subjects:
finding('HIGH', 'RBAC', f'{crb.metadata.name}',
f'{subj.kind} "{subj.name}" has cluster-admin ClusterRoleBinding',
'cluster-admin grants read/write on ALL resources in the entire cluster — massive blast radius if compromised',
f'Create a scoped Role with only the permissions this workload actually needs')
except Exception as e:
print(f" ERROR listing ClusterRoleBindings: {e}")
# RoleBindings with access to Secrets
try:
ns_list = [namespace] if namespace != 'all' else \
[ns.metadata.name for ns in k8s_client.CoreV1Api().list_namespace().items]
# One apiserver call per namespace, then one per RoleBinding. On a
# cluster with 30 namespaces and 10 bindings each that is 330 calls, so
# they are issued concurrently. The workers return data only - findings
# are emitted below in namespace order so the report is deterministic.
def list_bindings(ns):
return ns, rbac_api.list_namespaced_role_binding(ns).items
bindings_by_ns = {}
with ThreadPoolExecutor(max_workers=min(16, max(1, len(ns_list)))) as pool:
for fut in as_completed([pool.submit(list_bindings, n) for n in ns_list]):
try:
ns, items = fut.result()
bindings_by_ns[ns] = items
except Exception:
pass # namespace not listable; reported in the count below
pairs = [(ns, rb) for ns in ns_list for rb in bindings_by_ns.get(ns, [])]
def read_role(pair):
ns, rb = pair
role = rbac_api.read_namespaced_role(rb.role_ref.name, ns)
return ns, rb, role
roles = {}
unreadable = 0
if pairs:
with ThreadPoolExecutor(max_workers=min(24, len(pairs))) as pool:
for fut in as_completed([pool.submit(read_role, p) for p in pairs]):
try:
ns, rb, role = fut.result()
roles[(ns, rb.metadata.name)] = (rb, role)
except Exception:
unreadable += 1
# Emit in namespace order, then binding order within each namespace.
for ns in ns_list:
for rb in bindings_by_ns.get(ns, []):
entry = roles.get((ns, rb.metadata.name))
if not entry:
continue
rb_obj, role = entry
for rule in (role.rules or []):
resources = rule.resources or []
verbs = rule.verbs or []
if 'secrets' in resources and any(v in verbs for v in ['get', 'list', '*']):
subjects = rb_obj.subjects or []
subject_names = ', '.join(f'{s.kind}/{s.name}' for s in subjects)
finding('HIGH', 'RBAC', f'{rb_obj.metadata.name} ({ns})',
f'Can read Secrets — subjects: {subject_names}',
'Kubernetes Secrets include DB passwords, API keys, TLS certs — scope access carefully',
f'Restrict Secrets access to only the ServiceAccount that needs it')
break
if unreadable:
# A role that could not be read is NOT a role without Secrets
# access. Say so rather than letting the gap look like a pass.
print(f" {Fore.YELLOW}{unreadable} Role(s) could not be read — "
f"those bindings were not checked. Grant the audit account "
f"get/list on roles to cover them.{Style.RESET_ALL}")
except Exception as e:
print(f" ERROR checking RoleBindings: {e}")
def audit_pod_security(k8s_client, namespace: str):
print(f"\n{Fore.CYAN}[*] POD SECURITY{Style.RESET_ALL}")
core_api = k8s_client.CoreV1Api()
try:
pods = core_api.list_pod_for_all_namespaces().items if namespace == 'all' \
else core_api.list_namespaced_pod(namespace).items
except Exception as e:
print(f" ERROR listing pods: {e}"); return
root_count = 0
total = len(pods)
for pod in pods:
name = pod.metadata.name
ns = pod.metadata.namespace
spec = pod.spec
# hostPath docker.sock
for vol in (spec.volumes or []):
if vol.host_path and vol.host_path.path in ('/var/run/docker.sock', '/run/docker.sock'):
finding('HIGH', 'Pod Security', f'{ns}/{name}',
f'hostPath mount: {vol.host_path.path}',
'Docker socket mount enables container escape — app can control Docker daemon',
'Remove hostPath docker.sock; use Docker API proxy if CI/CD requires it')
# hostPID / hostNetwork / hostIPC
for attr, label in [('host_pid', 'hostPID'), ('host_network', 'hostNetwork'), ('host_ipc', 'hostIPC')]:
if getattr(spec, attr, False):
finding('HIGH', 'Pod Security', f'{ns}/{name}',
f'{label}: true — host namespace sharing',
f'{label}=true allows the container to see and interact with host-level processes/network',
f'Remove {label}: true from pod spec unless absolutely required')
# Per-container checks
for container in (spec.containers or []):
sc = container.security_context
if sc:
if sc.privileged:
finding('CRITICAL', 'Pod Security', f'{ns}/{name}/{container.name}',
'privileged: true — full host kernel access',
'Privileged containers have unrestricted access to host devices and kernel — trivial escape',
'Remove privileged: true; use specific capabilities (securityContext.capabilities.add)')
if sc.run_as_user == 0 or (sc.run_as_non_root is False):
root_count += 1
else:
# No securityContext = defaults vary, warn
finding('MEDIUM', 'Pod Security', f'{ns}/{name}/{container.name}',
'No securityContext defined',
'Without securityContext, container may run as root with default capabilities',
'Add securityContext: { runAsNonRoot: true, readOnlyRootFilesystem: true }')
if root_count:
print(f" {Fore.YELLOW}{root_count}/{total} containers running as root (UID 0){Style.RESET_ALL}")
print(f" {Fore.WHITE}Fix: Add securityContext.runAsNonRoot: true to pod specs{Style.RESET_ALL}")
def audit_network_policies(k8s_client, namespace: str):
print(f"\n{Fore.CYAN}[*] NETWORK POLICIES{Style.RESET_ALL}")
networking_api = k8s_client.NetworkingV1Api()
core_api = k8s_client.CoreV1Api()
try:
ns_list = [namespace] if namespace != 'all' else \
[ns.metadata.name for ns in core_api.list_namespace().items]
# One call per namespace, issued concurrently; emitted in order below.
targets = [n for n in ns_list if not n.startswith('kube-')]
def list_policies(ns):
return ns, networking_api.list_namespaced_network_policy(ns).items
pol_by_ns = {}
unlistable = 0
if targets:
with ThreadPoolExecutor(max_workers=min(16, len(targets))) as pool:
# Map the future back to its namespace, so a failure can be
# attributed - the exception itself does not carry it.
futs = {pool.submit(list_policies, n): n for n in targets}
for fut in as_completed(futs):
try:
ns, items = fut.result()
pol_by_ns[ns] = items
except Exception:
unlistable += 1
for ns in targets:
if ns not in pol_by_ns:
continue
policies = pol_by_ns[ns]
if not policies:
finding('HIGH', 'Network Policy', ns,
'No NetworkPolicies — all pods can communicate freely',
'Without NetworkPolicies, a compromised pod can reach every other pod in the cluster',
f'Add default-deny NetworkPolicy, then allow only required paths: kubectl apply -f default-deny.yaml -n {ns}')
else:
ok(ns, f'{len(policies)} NetworkPolicy/ies ✓')
if unlistable:
print(f" {Fore.YELLOW}{unlistable} namespace(s) could not be listed — "
f"not checked for NetworkPolicies.{Style.RESET_ALL}")
except Exception as e:
print(f" ERROR checking NetworkPolicies: {e}")
def audit_resource_limits(k8s_client, namespace: str):
print(f"\n{Fore.CYAN}[*] RESOURCE LIMITS{Style.RESET_ALL}")
core_api = k8s_client.CoreV1Api()
try:
pods = core_api.list_pod_for_all_namespaces().items if namespace == 'all' \
else core_api.list_namespaced_pod(namespace).items
no_limits = []
for pod in pods:
for container in (pod.spec.containers or []):
resources = container.resources
if not resources or not resources.limits:
no_limits.append(f"{pod.metadata.namespace}/{pod.metadata.name}/{container.name}")
if no_limits:
finding('MEDIUM', 'Resource Limits', f'{len(no_limits)} containers',
f'{len(no_limits)}/{sum(len(p.spec.containers or []) for p in pods)} containers without CPU/memory limits',
'Unlimited containers can exhaust node resources and starve other workloads (denial of service)',
'Add resources.limits.cpu and resources.limits.memory to all container specs')
for name in no_limits[:5]:
print(f" {Fore.YELLOW}{name}{Style.RESET_ALL}")
else:
ok('All containers', 'Resource limits defined ✓')
except Exception as e:
print(f" ERROR checking resource limits: {e}")
def check_exposed_services(k8s_client):
print(f"\n{Fore.CYAN}[*] EXPOSED SERVICES{Style.RESET_ALL}")
core_api = k8s_client.CoreV1Api()
try:
services = core_api.list_service_for_all_namespaces().items
for svc in services:
svc_type = svc.spec.type
name = f"{svc.metadata.namespace}/{svc.metadata.name}"
if svc_type == 'NodePort':
ports = [str(p.node_port) for p in (svc.spec.ports or []) if p.node_port]
finding('MEDIUM', 'Exposed Service', name,
f'NodePort service — ports {', '.join(ports)} exposed on every node',
'NodePort services are reachable on all node IPs — avoid in production',
'Use Ingress + ClusterIP instead; restrict NodePort with firewall rules if required')
elif svc_type == 'LoadBalancer':
lb_ip = (svc.status.load_balancer.ingress or [{}])[0].get('ip', 'pending') if svc.status.load_balancer else 'pending'
# Check for Kubernetes Dashboard
if 'dashboard' in name.lower():
finding('HIGH', 'Exposed Service', name,
f'Kubernetes Dashboard exposed via LoadBalancer ({lb_ip})',
'Exposed Dashboard has been exploited in multiple supply chain attacks',
'Restrict Dashboard to localhost only; use kubectl port-forward instead of exposing it')
else:
ok(name, f'LoadBalancer ({lb_ip}) — review if intentional')
except Exception as e:
print(f" ERROR checking services: {e}")
# ─── MAIN ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='Container & Kubernetes Security Scanner')
parser.add_argument('--mode', required=True, choices=['docker', 'kubernetes', 'both'])
parser.add_argument('--image', default='', help='Docker image to scan')
parser.add_argument('--namespace', default='all', help='Kubernetes namespace (default: all)')
parser.add_argument('--kubeconfig', default='', help='Path to kubeconfig')
parser.add_argument('--output', default='', help='Output JSON report path')
args = parser.parse_args()
print(f"{Fore.CYAN}[*] Container & Kubernetes Security Scanner{Style.RESET_ALL}")
print(f"{Fore.CYAN}[*] Mode: {args.mode.upper()} | Namespace: {args.namespace}{Style.RESET_ALL}")
if args.mode in ('docker', 'both'):
if not args.image:
print(f"{Fore.YELLOW}WARNING: --image not provided, skipping image scan. Checking running containers only.{Style.RESET_ALL}")
else:
scan_image_layers(args.image)
find_secrets_in_layers(args.image)
check_running_containers()
if args.mode in ('kubernetes', 'both'):
k8s = get_k8s_client(args.kubeconfig)
if k8s:
audit_rbac(k8s, args.namespace)
audit_pod_security(k8s, args.namespace)
audit_network_policies(k8s, args.namespace)
audit_resource_limits(k8s, args.namespace)
check_exposed_services(k8s)
counts = {s: sum(1 for f in FINDINGS if f['severity'] == s) for s in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']}
print(f"\n{'─'*70}")
print(f"{Fore.RED}{counts['CRITICAL']} CRITICAL{Style.RESET_ALL} "
f"{Fore.YELLOW}{counts['HIGH']} HIGH{Style.RESET_ALL} "
f"{Fore.CYAN}{counts['MEDIUM']} MEDIUM{Style.RESET_ALL} "
f"{counts['LOW']} LOW")
output = args.output or f"container_scan_{datetime.now().strftime('%Y-%m-%d')}.json"
report = {'generated': datetime.now(timezone.utc).isoformat(), 'mode': args.mode,
'summary': counts, 'findings': FINDINGS}
with open(output, 'w') as fh:
json.dump(report, fh, indent=2)
print(f"{Fore.GREEN}Report saved: {output}{Style.RESET_ALL}")
return 2 if counts['CRITICAL'] else (1 if counts['HIGH'] or counts['MEDIUM'] else 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--modeselectrequireddocker (scan an image), kubernetes (audit a cluster), or both.
--imagetextThe Docker image to scan, e.g. myapp:1.4. Required for docker mode.
--namespacetextKubernetes namespace to audit. Default: all.
--kubeconfigtextPath to a kubeconfig file. Omit to use the default context.
--outputtextJSON report path.
What This Solves
Container security fails in two places and people usually only look at one of them. The image. Base image CVEs, and secrets baked into a layer. A secret `rm`-ed in a later layer is still in the earlier one - exactly like deleting a file in a later git commit. Anyone who can pull the image can read it. The runtime. Containers running as root, `privileged: true`, hostPath mounts, missing resource limits, service accounts with far more RBAC than they need. A perfectly patched image running privileged on the host network is not a secure container. This audits both. For Docker it scans image layers for CVEs through Trivy, looks for secrets in the layers, and checks how the container runs. For Kubernetes it audits workloads and RBAC across a namespace or the whole cluster.
Is It Safe to Run
Yes, and it is read-only in both modes. Docker mode Inspects an image and running containers. It does not pull anything it was not asked about, does not run the image, and changes nothing. Kubernetes mode Lists workloads, pod specs and RBAC objects through the API. It is `get` and `list` only - it creates, patches and deletes nothing. Three things worth knowing before the first run: It needs credentials that can see a lot. A cluster audit reads RBAC and every namespace's workloads. The account you use is necessarily broad, so use a read-only one - a ClusterRole with `get`/`list`/`watch` and nothing else. Do not reach for cluster-admin because it is convenient. Trivy is an external dependency and it is optional. The script shells out to `trivy` for the CVE scan. **If Trivy is not installed, the CVE portion silently produces nothing while the rest of the audit runs.** An image report with no CVEs may mean a clean image or may mean no Trivy - check that it is on your PATH before believing a clean result. Trivy downloads a vulnerability database. On first use, from the internet. On an air-gapped host that needs arranging separately. On your own machine it writes the JSON report under --output.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "docker>=6.1" "kubernetes>=28.1" "requests>=2.31" \ "colorama>=0.4.6" 3. Trivy, for the CVE scan. Without it the rest still works and the CVE section is empty: trivy --version 4. Access. Docker mode needs access to the Docker socket. Kubernetes mode needs a working kubeconfig - confirm with `kubectl get nodes` before blaming the script.
Your First Run
Docker, one image: python container_scanner.py --mode docker --image myapp:1.4 Kubernetes, one namespace rather than the whole cluster: python container_scanner.py --mode kubernetes --namespace staging Start with a namespace. A whole-cluster audit on a busy cluster produces a lot of output, and reading it is much easier once you know what a normal namespace looks like in your environment. Both, with a report: python container_scanner.py --mode both --image myapp:1.4 \ --namespace staging --output audit-$(date +%F).json
What the Output Means
Findings are graded CRITICAL, HIGH, MEDIUM or LOW. The image findings: CVEs from Trivy Per package, with severity. The same caveat as every version-based scanner applies: a distribution that backports fixes keeps the old version number, so confirm against the base image's own security tracker before reporting a long list. Secrets in image layers AWS keys, private keys, passwords found in a layer. **These are not removable by deleting the file in a later layer.** The layer is still in the image and anyone who can pull it can extract the secret. The fix is a rebuild with a multi-stage build or build secrets, plus rotating the credential. The runtime findings: Running as root The default, and the single easiest thing to fix. `runAsNonRoot` plus a numeric `runAsUser`. privileged: true Effectively root on the host. There are legitimate uses - storage and network plugins - and they are rare. Any application workload with this set is a finding. hostPath mounts, host network, host PID Each one punctures the boundary between the container and the node. Missing resource limits Not a breach, but it is how one container takes a node down. Over-broad RBAC A service account with `cluster-admin`, or with wildcard verbs on secrets. This is the finding that converts a single compromised pod into a compromised cluster, and it is the one most worth reading carefully. What it cannot tell you: whether a finding is accepted risk in your environment. A CNI plugin needs privileged mode. The script reports it; you decide.
Exit Codes for Cron and CI
0 nothing above low severity 1 high or medium findings 2 critical findings 130 you pressed Ctrl-C As an image gate in a build pipeline: python container_scanner.py --mode docker --image $IMAGE:$TAG \ --output scan.json test $? -lt 2 Gating the build on 2 and merely reporting 1 is the usable setting. A pipeline that fails on every medium CVE in a base image gets disabled within a month, which is worse than one that catches the critical ones.
Every Flag
--mode MODE REQUIRED. docker, kubernetes, or both. --image NAME:TAG The image to scan. Required for docker mode. --namespace NAME Kubernetes namespace. Default: all. Start with one. --kubeconfig PATH Path to a kubeconfig. Omit to use the default context - check it with `kubectl config current-context` first. --output PATH JSON report path.
When Something Fails
ModuleNotFoundError `pip install "docker>=6.1" "kubernetes>=28.1" "requests>=2.31" "colorama>=0.4.6"`. No CVEs reported on an image you know has them Trivy is probably not installed or not on PATH. Check with `trivy --version`. This is the failure mode to watch for, because a missing scanner looks exactly like a clean image. Trivy fails or times out on first use It is downloading its vulnerability database. Let it finish once; subsequent runs use the cache. On an air-gapped host, populate the database separately. "permission denied" on the Docker socket Your user is not in the docker group, or the socket is not where the library expects. Note that adding yourself to the docker group is effectively granting root on that host - a real decision, not a formality. Kubernetes mode cannot connect Confirm with `kubectl get nodes` using the same kubeconfig and context. If kubectl works and this does not, point --kubeconfig at the file explicitly. "forbidden" on RBAC or on some namespaces The account cannot read those objects, so those checks produce nothing. **An absent finding is not a clean result** - either widen the read-only role or note which namespaces were not audited. Hundreds of findings on a whole-cluster run Expected the first time. Start with one namespace, establish what your normal looks like, then widen. A privileged container you know is required Storage and network plugins legitimately need it. Record it as accepted risk with a reason rather than leaving it in every report forever - a report with permanent known findings is a report nobody reads.