Files
lxc-enroll/docker-security.sh
T

760 lines
35 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# Standalone Debian/Ubuntu Docker host setup. No credentials/site addresses.
set -Eeuo pipefail
set +x
umask 077
export PATH=/usr/sbin:/usr/bin:/sbin:/bin
cd /
fail() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
say() { printf '%s\n' "$*"; }
UI_TOOL=''; UI_HEIGHT=20; UI_WIDTH=76
init_ui() {
local dimensions rows=24 columns=80
UI_TOOL=''
if command -v whiptail >/dev/null 2>&1; then UI_TOOL=whiptail
elif command -v dialog >/dev/null 2>&1; then UI_TOOL=dialog; fi
dimensions=$(stty size <&3 2>/dev/null) || dimensions=''
if [[ $dimensions =~ ^([0-9]+)[[:space:]]+([0-9]+)$ ]]; then
rows=${BASH_REMATCH[1]}; columns=${BASH_REMATCH[2]}
fi
if [[ -z $UI_TOOL || ${TERM:-dumb} == dumb || $rows -lt 16 || $columns -lt 60 ]]; then
UI_TOOL=''
say 'Dialogoberflaeche nicht verfuegbar (whiptail/dialog oder ausreichend grosses Terminal fehlt); Texteingabe verwenden.'
return 0
fi
UI_HEIGHT=$((rows-4)); UI_WIDTH=$((columns-4))
if [[ $UI_HEIGHT -gt 30 ]]; then UI_HEIGHT=30; fi
if [[ $UI_WIDTH -gt 110 ]]; then UI_WIDTH=110; fi
}
ui_capture() {
# The UI uses the controlling terminal, even if this script's stdin is a pipe.
# Secrets/selections are captured on a separate descriptor, never stdout/logs.
REPLY=$("$UI_TOOL" --output-fd 4 "$@" 4>&1 1>&3 2>&3 <&3)
}
ask() {
if [[ -n $UI_TOOL ]]; then
ui_capture --title 'Docker Security - Eingabe' --ok-button 'Weiter' --cancel-button 'Abbrechen' \
--inputbox "$1" "$UI_HEIGHT" "$UI_WIDTH" "${2:-}" || fail 'Eingabe abgebrochen oder Dialog fehlgeschlagen.'
else
printf '%s ' "$1" >&2
IFS= read -r REPLY <&3 || fail 'Eingabe abgebrochen.'
fi
}
secret() {
if [[ -n $UI_TOOL ]]; then
ui_capture --title 'Docker Security - Verdeckte Eingabe' --ok-button 'Weiter' --cancel-button 'Abbrechen' \
--passwordbox "$1" "$UI_HEIGHT" "$UI_WIDTH" || fail 'Eingabe abgebrochen oder Dialog fehlgeschlagen.'
return 0
fi
printf '%s ' "$1" >&2
IFS= read -r -s REPLY <&3 || fail 'Eingabe abgebrochen.'
printf '\n' >&2
}
installed_label() { if [[ $1 == 1 ]]; then printf 'vorhanden'; else printf 'fehlt'; fi; }
choice_hint() { if [[ $1 == 1 ]]; then printf '[J/n]'; else printf '[j/N]'; fi; }
yesno_label() { if [[ $1 == 1 ]]; then printf 'Ja'; else printf 'Nein'; fi; }
yes_no() {
local prompt=$1 default=$2 rc
local -a options=()
if [[ -n ${UI_TOOL:-} ]]; then
[[ $default != 0 ]] || options+=(--defaultno)
# Keep keyboard hints only in text mode; buttons show Ja/Nein in the UI.
prompt=${prompt% \[J/n\]}; prompt=${prompt% \[j/N\]}
if ui_capture --title 'Docker Security - Auswahl' --yes-button 'Ja' --no-button 'Nein' \
"${options[@]}" --yesno "$prompt" "$UI_HEIGHT" "$UI_WIDTH"; then REPLY=1
else
rc=$?
case "$rc" in 1) REPLY=0;; *) fail 'Auswahl abgebrochen oder Dialog fehlgeschlagen.';; esac
fi
return 0
fi
ask "$prompt"
case "$REPLY" in '' ) REPLY=$default;; 1|j|J|ja|Ja|y|Y|yes|Yes) REPLY=1;; 0|n|N|nein|Nein|no|No) REPLY=0;; *) fail 'Bitte j oder n eingeben (auch 1/0 moeglich).';; esac
}
MODE=host; CHECK=0; TMP=''
BASE=/opt/wazuh-docker-security
STATE=/var/lib/wazuh-docker-security
CONF=/etc/wazuh-docker-security
LOGDIR=/var/log/wazuh-docker-security
WAZUH_CONFIG=/var/ossec/etc/ossec.conf
TRIVY_RELEASE=0.75.0
while [[ $# -gt 0 ]]; do
case "$1" in
--manager-rules) MODE=manager; shift;;
--check) CHECK=1; shift;;
--help|-h)
cat <<'HELP'
Docker-Host (LXC/VM/Server, Debian/Ubuntu mit systemd):
bash docker-security.sh
bash docker-security.sh --check
Einmal auf dem Wazuh-Manager:
bash docker-security.sh --manager-rules
Root erforderlich. Docker und ein nativer Wazuh-Agent muessen schon vorhanden
sein. Keine Docker-/Wazuh-Installation oder Registrierung; keine Container-
Updates/Neustarts. Dialoge oder Texteingabe; INSTALL bestaetigt Aenderungen.
HELP
exit 0;;
*) fail "Unbekannte Option: $1";;
esac
done
cleanup() {
local rc=$?
if [[ $rc != 0 && ${MODE:-host} == host && ${CONFIG_COMMITTED:-0} == 0 ]]; then
if [[ ${LISTENER_CHANGED:-0} == 1 && -f ${BACKUP:-}/DockerListener ]]; then
cp -a "$BACKUP/DockerListener" /var/ossec/wodles/docker/DockerListener || true
fi
if [[ ${CONFIG_CHANGED:-0} == 1 && -f ${BACKUP:-}/ossec.conf ]]; then
cp -a "$BACKUP/ossec.conf" "$WAZUH_CONFIG" || true
fi
fi
[[ -z $TMP ]] || rm -rf -- "$TMP"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
[[ $EUID == 0 ]] || fail 'Als root auf dem Docker-Host bzw. Wazuh-Manager starten.'
for tool in python3 systemctl; do command -v "$tool" >/dev/null || fail "$tool fehlt."; done
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,9) else 1)' || fail 'Python 3.9 oder neuer erforderlich.'
[[ -d /run/systemd/system ]] || fail 'Native systemd-Installation erforderlich.'
DISTRO=$( . /etc/os-release; printf '%s' "$ID")
[[ $DISTRO =~ ^(debian|ubuntu)$ ]] || fail 'Unterstuetzt werden Debian und Ubuntu mit APT.'
[[ -f $WAZUH_CONFIG && ! -L $WAZUH_CONFIG ]] || fail 'Native Wazuh-Konfiguration fehlt oder ist ein Symlink. Zuerst Wazuh einrichten.'
getent group wazuh >/dev/null || fail 'Wazuh-Gruppe fehlt.'
if [[ $MODE == manager ]]; then
[[ -x /var/ossec/bin/wazuh-analysisd ]] || fail 'Diesen Modus direkt auf dem Wazuh-Manager ausfuehren.'
WAZUH_SERVICE=wazuh-manager
else
for tool in docker timeout dpkg-query apt-get; do command -v "$tool" >/dev/null || fail "$tool fehlt; zuerst Docker/Wazuh einrichten."; done
[[ -S /var/run/docker.sock ]] || fail 'Lokaler rootful Docker-Socket fehlt. Rootless/Remote/Podman sind nicht enthalten.'
timeout 15 docker --host unix:///var/run/docker.sock info >/dev/null || fail 'Lokaler Docker-Daemon nicht erreichbar.'
[[ -x /var/ossec/bin/wazuh-agentd && ! -x /var/ossec/bin/wazuh-analysisd ]] || fail 'Host-Modus braucht einen nativen Wazuh-Agent. Manager-Modus: --manager-rules.'
WAZUH_SERVICE=wazuh-agent
systemctl is-active --quiet wazuh-agent || fail 'Wazuh-Agent ist nicht aktiv; zuerst reparieren.'
grep -q "^status='connected'" /var/ossec/var/run/wazuh-agentd.state || fail 'Wazuh-Agent noch nicht verbunden; zuerst Enrollment/Netzwerk reparieren.'
fi
if [[ $CHECK == 1 ]]; then
say "Voraussetzungen fuer $MODE erfuellt. Keine Aenderungen vorgenommen."
exit 0
fi
exec 3<>/dev/tty || fail 'Bitte mit einem interaktiven Terminal starten.'
init_ui
LISTENER=1; SCANS=1; HOURS=24; SCOPE=running; FIRST_SCAN=1
if [[ $MODE == host ]]; then
yes_no 'Docker-Ereignisse ueber den Wazuh Docker Listener sammeln? [J/n]' 1; LISTENER=$REPLY
yes_no 'Docker-Images mit Trivy regelmaessig auf Schwachstellen pruefen? [J/n]' 1; SCANS=$REPLY
[[ $LISTENER == 1 || $SCANS == 1 ]] || { say 'Nichts ausgewaehlt; abgebrochen.'; exit 0; }
if [[ $LISTENER == 1 ]]; then
[[ -f /var/ossec/wodles/docker/DockerListener && ! -L /var/ossec/wodles/docker/DockerListener ]] || fail 'Offizieller DockerListener fehlt oder ist ein Symlink.'
head -n 1 /var/ossec/wodles/docker/DockerListener | grep -q '^#!.*python' || fail 'Unerwarteter DockerListener-Wrapper; bitte vor Aenderung pruefen. Scanner-Modus kann separat verwendet werden.'
fi
if [[ $SCANS == 1 ]]; then
for path in "$BASE" "$STATE" "$CONF" "$LOGDIR" /etc/systemd/system/wazuh-docker-security.service /etc/systemd/system/wazuh-docker-security.timer /etc/logrotate.d/wazuh-docker-security; do
[[ ! -L $path ]] || fail "Unerwarteter Symlink: $path"
done
for path in /etc/systemd/system/wazuh-docker-security.service /etc/systemd/system/wazuh-docker-security.timer /etc/logrotate.d/wazuh-docker-security; do
if [[ -e $path ]]; then grep -q '^# Managed by wazuh-docker-security' "$path" || fail "Fremde Datei vorhanden: $path"; fi
done
ask 'Abstand zwischen Image-Scans in Stunden (1-168):' 24; HOURS=${REPLY:-24}
[[ $HOURS =~ ^[1-9][0-9]{0,2}$ && $HOURS -le 168 ]] || fail 'Bitte 1 bis 168 Stunden eingeben.'
yes_no 'Auch Images gestoppter Container pruefen? [j/N]' 0
[[ $REPLY == 0 ]] || SCOPE=all-containers
yes_no 'Nach Einrichtung sofort einen ersten Scan im Hintergrund starten? [J/n]' 1; FIRST_SCAN=$REPLY
fi
fi
if [[ $MODE == manager ]]; then
ask 'Auf diesem Manager werden die JSON-Regeln 110800-110806 ergaenzt und Wazuh neu gestartet. Bestehende Regeln/Discord-Einstellungen bleiben erhalten. Bitte INSTALL eingeben:'
else
ask "Auf diesem Docker-Host einrichten: Listener=$(yesno_label "$LISTENER"), Image-Scans=$(yesno_label "$SCANS"), Abstand=${HOURS}h, Umfang=$SCOPE. Der Wazuh-Agent wird neu gestartet; Docker-Container bleiben in Betrieb. Bitte INSTALL eingeben:"
fi
[[ $REPLY == INSTALL ]] || { say 'Abgebrochen.'; exit 0; }
command -v flock >/dev/null || fail 'flock fehlt.'
exec 8>/run/wazuh-docker-security-install.lock
flock -n 8 || fail 'Ein anderer Installationslauf ist aktiv.'
TMP=$(mktemp -d /run/wazuh-docker-security.XXXXXXXX)
CONFIG_COMMITTED=0; CONFIG_CHANGED=0; LISTENER_CHANGED=0
cat >"$TMP/configure.py" <<'CONFIGURE_PY'
#!/usr/bin/env python3
"""Conservative XML edits preserving unrelated Wazuh configuration/comments."""
from pathlib import Path
import re
import sys
from xml.dom import minidom
LOG = '/var/log/wazuh-docker-security/events.jsonl'
def parse_fragment(text):
if '<!DOCTYPE' in text.upper() or '<!ENTITY' in text.upper():
raise ValueError('XML entities/DOCTYPE are unsupported')
text = re.sub(r'^\s*<\?xml[^?]*\?>', '', text, count=1)
return minidom.parseString('<configuration_fragments>' + text + '</configuration_fragments>')
def element_children(parent, tag):
return [n for n in parent.childNodes if n.nodeType == n.ELEMENT_NODE and n.tagName == tag]
def value(node):
return ''.join(n.data for n in node.childNodes if n.nodeType == n.TEXT_NODE).strip()
def set_child(document, parent, tag, text):
nodes = element_children(parent, tag)
if len(nodes) > 1:
raise ValueError('ambiguous duplicate ' + tag)
node = nodes[0] if nodes else document.createElement(tag)
if not nodes:
parent.appendChild(node)
for child in list(node.childNodes):
node.removeChild(child)
node.appendChild(document.createTextNode(text))
def configure(text, listener, scans):
doc = parse_fragment(text)
roots = element_children(doc.documentElement, 'ossec_config')
if not roots:
raise ValueError('ossec_config missing')
if listener:
matches = [n for root in roots for n in element_children(root, 'wodle')
if n.getAttribute('name') == 'docker-listener']
if len(matches) > 1:
raise ValueError('multiple docker-listener blocks; resolve manually')
node = matches[0] if matches else doc.createElement('wodle')
if not matches:
node.setAttribute('name', 'docker-listener')
roots[0].appendChild(node)
set_child(doc, node, 'disabled', 'no')
if scans:
matches = [n for root in roots for n in element_children(root, 'localfile')
if any(value(x) == LOG for x in element_children(n, 'location'))]
if len(matches) > 1:
raise ValueError('multiple managed scan log blocks')
node = matches[0] if matches else doc.createElement('localfile')
if not matches:
roots[0].appendChild(doc.createComment(' Managed by wazuh-docker-security '))
roots[0].appendChild(node)
set_child(doc, node, 'location', LOG)
set_child(doc, node, 'log_format', 'json')
set_child(doc, node, 'only-future-events', 'no')
return ''.join(n.toxml() for n in doc.documentElement.childNodes).rstrip() + '\n'
def check_rule_collisions(rules_dir, target, new_rules):
wanted = set(re.findall(r'<rule\s+id="(\d+)"', new_rules))
for directory in (Path(rules_dir), Path(rules_dir).parents[1] / 'ruleset/rules'):
if not directory.exists():
continue
for path in directory.glob('*.xml'):
if path.resolve() == Path(target).resolve():
continue
existing = set(re.findall(r'<rule\b[^>]*\bid\s*=[\'"](\d+)[\'"]', path.read_text()))
if wanted & existing:
raise ValueError('rule IDs already used in ' + str(path))
def main():
mode = sys.argv[1]
if mode == 'agent':
source, output = map(Path, sys.argv[2:4])
if source.is_symlink() or not source.is_file():
raise ValueError('ossec.conf must be a regular file')
output.write_text(configure(source.read_text(), sys.argv[4] == '1', sys.argv[5] == '1'))
elif mode == 'rules':
source, target = map(Path, sys.argv[2:4])
new_rules = source.read_text()
parse_fragment(new_rules)
if target.is_symlink():
raise ValueError('rule file is a symlink')
if target.exists() and 'Managed by wazuh-docker-security' not in target.read_text():
raise ValueError('unmanaged rule file already exists')
check_rule_collisions(target.parent, target, new_rules)
else:
raise ValueError('unknown mode')
if __name__ == '__main__':
main()
CONFIGURE_PY
cat >"$TMP/scan.py" <<'SCAN_PY'
#!/usr/bin/env python3
"""Local Docker image inventory -> Trivy -> deduplicated Wazuh JSON events.
No container exec, registry image pulls, automatic patching or notifications.
"""
import datetime as dt
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import time
import urllib.request
BASE = Path('/var/lib/wazuh-docker-security')
CONFIG = Path('/etc/wazuh-docker-security/config.json')
LOG = Path('/var/log/wazuh-docker-security/events.jsonl')
SOCKET = 'unix:///var/run/docker.sock'
KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json'
KEV_URLS = (KEV_URL, 'https://raw.githubusercontent.com/cisagov/kev-data/main/known_exploited_vulnerabilities.json')
def fetch_kev(url, now):
"""Only publisher-controlled sources; reject invalid/old mirror snapshots."""
request = urllib.request.Request(url, headers={'User-Agent': 'wazuh-docker-security/1'})
with urllib.request.urlopen(request, timeout=15) as response:
raw = response.read(12 * 1024 * 1024 + 1)
if len(raw) > 12 * 1024 * 1024:
raise ValueError('KEV response too large')
document = json.loads(raw)
published = dt.datetime.fromisoformat(document['dateReleased'].replace('Z', '+00:00')).timestamp()
if not -86400 <= now - published <= 14 * 86400:
raise ValueError('KEV publication timestamp outside freshness window')
entries = document['vulnerabilities']
if document.get('count') != len(entries):
raise ValueError('incomplete KEV catalog')
ids = sorted({v['cveID'] for v in entries
if re.fullmatch(r'CVE-\d{4}-\d{4,}', v.get('cveID', ''))})
if not ids or len(ids) != len(entries):
raise ValueError('empty or invalid KEV catalog')
return {'fetched_at': now, 'published_at': published, 'ids': ids,
'catalog_version': document.get('catalogVersion'), 'source': url}
def atomic_json(path, data):
path = Path(path)
fd, tmp = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, 'w') as f:
json.dump(data, f, ensure_ascii=True, sort_keys=True)
f.write('\n')
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def load_json(path, default):
try:
return json.loads(Path(path).read_text())
except FileNotFoundError:
return default
def run(argv, timeout=60):
env = os.environ.copy()
# Explicit local engine; inherited contexts/config must not select a remote host.
env['DOCKER_HOST'] = SOCKET
for key in list(env):
if key.startswith('TRIVY_'):
del env[key]
env['TRIVY_CACHE_DIR'] = str(BASE / 'cache')
p = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout, env=env)
if p.returncode:
# Do not forward raw stderr: CLI errors may include credentials/paths.
raise RuntimeError('%s failed (exit %s)' % (Path(argv[0]).name, p.returncode))
return p.stdout
def docker_images(scope):
args = ['docker', '--host', SOCKET, 'ps', '--no-trunc', '--format', '{{.ID}}']
if scope == 'all-containers':
args.append('--all')
ids = run(args).split()
images = {}
for cid in ids:
if not re.fullmatch(r'[a-f0-9]{64}', cid):
raise ValueError('unexpected Docker container ID')
# Env, labels and mounts from docker inspect are never persisted/logged.
data = json.loads(run(['docker', '--host', SOCKET, 'inspect', '--type', 'container', cid]))[0]
image_id = data['Image']
if not re.fullmatch(r'sha256:[a-f0-9]{64}', image_id):
raise ValueError('unexpected Docker image ID')
entry = images.setdefault(image_id, {'names': [], 'refs': []})
entry['names'].append(data['Name'].lstrip('/'))
entry['refs'].append(data['Config']['Image'])
return images
def kev_catalog():
"""Return IDs and freshness; missing/stale KEV never means not exploited."""
path = BASE / 'kev.json'
cached = load_json(path, {})
now = time.time()
error = None
if now - cached.get('fetched_at', 0) >= 86400:
for url in KEV_URLS:
try:
updated = fetch_kev(url, now)
atomic_json(path, updated)
cached = updated
break
except Exception:
continue
else:
error = 'KEV refresh failed on both official sources; cached information may be incomplete'
fresh = now - cached.get('fetched_at', 0) <= 7 * 86400
if not fresh:
error = 'KEV catalog unavailable or older than seven days'
return set(cached.get('ids', [])), fresh, error
def findings(report, image_id, meta, kev, kev_fresh):
output = {}
workload = ','.join(sorted(meta['names']))
for result in report.get('Results', []):
for v in result.get('Vulnerabilities') or []:
cve = str(v.get('VulnerabilityID', ''))
severity = str(v.get('Severity', 'UNKNOWN')).upper()
known = 'yes' if cve in kev else ('no' if kev_fresh else 'unknown')
if severity not in ('HIGH', 'CRITICAL') and known != 'yes':
continue
target = ('os-packages' if result.get('Class') == 'os-pkgs'
else str(result.get('Target', ''))[:300])
package = str(v.get('PkgName', ''))[:180]
# Container names survive a compose recreate; a changed image digest
# alone does not repeat unchanged vulnerabilities.
key = hashlib.sha256(json.dumps([workload, target, cve, package]).encode()).hexdigest()
output[key] = {
'workload': workload, 'containers': workload[:600],
'image_id': image_id, 'image_name': ','.join(sorted(set(meta['refs'])))[:300],
'target': target, 'vulnerability_id': cve[:100], 'package': package,
'installed_version': str(v.get('InstalledVersion', ''))[:160],
'fixed_version': str(v.get('FixedVersion', ''))[:160],
'severity': severity, 'known_exploited': known,
'title': str(v.get('Title', ''))[:300],
'assessment': 'known_exploitation_elsewhere' if known == 'yes' else 'needs_review',
}
return output
def meaningful(finding):
return {k: finding.get(k) for k in ('installed_version', 'fixed_version', 'severity', 'known_exploited')}
def reconcile(previous, current, successful_workloads, active_workloads, inventory_ok):
"""Preserve results after failed scans; absence is not proof of remediation."""
merged = dict(previous)
events = []
for key, finding in current.items():
if key not in previous or meaningful(previous[key]) != meaningful(finding):
events.append(dict(finding, event='finding_new' if key not in previous else 'finding_changed'))
merged[key] = finding
for key, old in previous.items():
if key in current:
continue
if old['workload'] in successful_workloads:
# Image scan no longer reports the vulnerability. Not a guarantee
# about running writable layers, mounted apps or compromise.
events.append(dict(old, event='finding_cleared'))
merged.pop(key, None)
elif inventory_ok and old['workload'] not in active_workloads:
events.append(dict(old, event='finding_out_of_scope'))
merged.pop(key, None)
return merged, events
def append_events(events):
stamp = dt.datetime.now(dt.timezone.utc).isoformat()
with LOG.open('a') as f:
for event in events:
row = dict(event, source='container_image_scan', timestamp=stamp)
# Full workload identity stays in local state, not the logcollector line.
row.pop('workload', None)
encode = lambda: json.dumps(row, ensure_ascii=True)
line = encode()
while len(line.encode()) > 5000:
context = [k for k in ('containers', 'image_name', 'target', 'title', 'package',
'installed_version', 'fixed_version', 'message')
if isinstance(row.get(k), str) and len(row[k]) > 20]
if not context:
raise ValueError('event too large for logcollector')
key = max(context, key=lambda k: len(row[k]))
row[key] = row[key][:len(row[key]) // 2] + '...'
line = encode()
f.write(line + '\n')
f.flush()
os.fsync(f.fileno())
def main():
os.umask(0o077)
config = load_json(CONFIG, {})
scope = config.get('scope', 'running')
if scope not in ('running', 'all-containers'):
raise ValueError('invalid scan scope')
with (BASE / 'scan.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print('Another scan is active; skipped.')
return 0
state_path = BASE / 'state.json'
state = load_json(state_path, {'findings': {}, 'health': {}})
current, successful, active, errors = {}, set(), set(), {}
inventory_ok = True
try:
images = docker_images(scope)
except Exception:
images = {}
inventory_ok = False
errors['docker'] = 'Local Docker inventory failed; previous findings retained'
kev, fresh, kev_error = kev_catalog()
if kev_error:
errors['kev'] = kev_error
for image_id, meta in images.items():
workload = ','.join(sorted(meta['names']))
active.add(workload)
report_path = BASE / 'reports' / (image_id.split(':')[1] + '.json')
tmp = report_path.with_suffix('.pending')
try:
run(['trivy', 'image', '--image-src', 'docker', '--scanners', 'vuln',
'--format', 'json', '--output', str(tmp), '--timeout', '15m', image_id], timeout=960)
report = load_json(tmp, None)
if not isinstance(report, dict) or 'SchemaVersion' not in report:
raise ValueError('invalid Trivy report')
current.update(findings(report, image_id, meta, kev, fresh))
successful.add(workload)
os.replace(tmp, report_path)
except Exception:
errors['image:' + image_id] = 'Image scan failed; previous findings retained'
finally:
tmp.unlink(missing_ok=True)
merged, events = reconcile(state['findings'], current, successful, active, inventory_ok)
for key, message in errors.items():
if state['health'].get(key) != message:
events.append({'event': 'scan_problem', 'component': key, 'message': message})
for key in state['health']:
if key not in errors:
events.append({'event': 'scan_recovered', 'component': key})
events.append({'event': 'scan_status', 'complete': 'yes' if inventory_ok and not any(k.startswith('image:') for k in errors) else 'no',
'kev_fresh': 'yes' if fresh else 'no', 'problem_count': len(errors)})
append_events(events)
atomic_json(state_path, {'findings': merged, 'health': errors, 'last_scan': time.time()})
atomic_json(BASE / 'inventory.json', {'updated_at': time.time(), 'scope': scope,
'complete': inventory_ok and not any(k.startswith('image:') for k in errors),
'kev_fresh': fresh, 'findings': list(merged.values()), 'problems': errors})
for old in (BASE / 'reports').glob('*.json'):
if time.time() - old.stat().st_mtime > 30 * 86400:
old.unlink()
print('Scanned %s/%s images; %s relevant findings; %s changed events.' %
(len(successful), len(images), len(merged), len(events)))
return 1 if errors else 0
if __name__ == '__main__':
raise SystemExit(main())
SCAN_PY
cat >"$TMP/rules.xml" <<'RULES_XML'
<!-- Managed by wazuh-docker-security; custom range 110800-110806. -->
<group name="docker_security,trivy,">
<rule id="110800" level="0">
<decoded_as>json</decoded_as>
<field name="source">^container_image_scan$</field>
<description>Docker image vulnerability scan event.</description>
</rule>
<rule id="110801" level="4">
<if_sid>110800</if_sid>
<field name="event" type="pcre2">^finding_(new|changed)$</field>
<field name="known_exploited" negate="yes">^yes$</field>
<description>Review image finding: $(vulnerability_id) [$(severity)] in $(package), containers $(containers). Not proof of exploitability or compromise.</description>
<group>docker_security_review,</group>
</rule>
<rule id="110802" level="12">
<if_sid>110800</if_sid>
<field name="event" type="pcre2">^finding_(new|changed)$</field>
<field name="known_exploited">^yes$</field>
<description>Prioritize assessment/mitigation: $(vulnerability_id) is in CISA KEV and reported in image for $(containers). Exploitation elsewhere, not proof this host was attacked.</description>
<group>docker_security_kev,</group>
</rule>
<rule id="110803" level="7">
<if_sid>110800</if_sid>
<field name="event">^scan_problem$</field>
<description>Docker image scanner needs attention: $(component): $(message).</description>
<group>docker_security_health,</group>
</rule>
<rule id="110804" level="3">
<if_sid>110800</if_sid>
<field name="event" type="pcre2">^finding_(cleared|out_of_scope)$</field>
<description>Image finding cleared or no longer in scan scope.</description>
</rule>
<rule id="110805" level="3">
<if_sid>110800</if_sid>
<field name="event">^scan_recovered$</field>
<description>Scanner component recovered.</description>
</rule>
<rule id="110806" level="3">
<if_sid>110800</if_sid>
<field name="event">^scan_status$</field>
<description>Image scanner status: complete=$(complete), fresh KEV=$(kev_fresh), problems=$(problem_count).</description>
<group>docker_security_status,</group>
</rule>
</group>
RULES_XML
BACKUP=/var/backups/wazuh-docker-security/$(date +%Y%m%d-%H%M%S)-$$
install -d -m 700 "$BACKUP"
if [[ $MODE == manager ]]; then
RULE_FILE=/var/ossec/etc/rules/docker_security_rules.xml
python3 "$TMP/configure.py" rules "$TMP/rules.xml" "$RULE_FILE"
HAD_RULE=0
if [[ -f $RULE_FILE ]]; then HAD_RULE=1; cp -a "$RULE_FILE" "$BACKUP/rules.xml"; fi
install -o root -g wazuh -m 640 "$TMP/rules.xml" "$RULE_FILE"
if ! /var/ossec/bin/wazuh-analysisd -t; then
if [[ $HAD_RULE == 1 ]]; then cp -a "$BACKUP/rules.xml" "$RULE_FILE"; else rm -f -- "$RULE_FILE"; fi
fail 'Wazuh lehnt Regeln ab; bisherige Regeldatei wiederhergestellt.'
fi
if ! systemctl restart wazuh-manager || ! systemctl is-active --quiet wazuh-manager; then
if [[ $HAD_RULE == 1 ]]; then cp -a "$BACKUP/rules.xml" "$RULE_FILE"; else rm -f -- "$RULE_FILE"; fi
systemctl restart wazuh-manager || true
fail 'Manager-Neustart fehlgeschlagen; bisherige Regeldatei wiederhergestellt.'
fi
say 'Manager-Regeln installiert. Suche im Dashboard: rule.groups:docker_security'
say 'High/Critical: Review (Level 4); CISA-KEV: Prioritaet (Level 12); Scanner-Ausfall: Level 7.'
say 'Keine neue Discord-Integration eingerichtet. Bestehende Integrationen koennen passende Regel-Level bereits weiterleiten.'
exit 0
fi
# Validate XML structure before changing installed packages or live configuration.
python3 "$TMP/configure.py" agent "$WAZUH_CONFIG" "$TMP/ossec.conf" "$LISTENER" "$SCANS"
cp -a "$WAZUH_CONFIG" "$BACKUP/ossec.conf"
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=120 update
apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends ca-certificates curl gnupg python3 python3-venv
install -d -m 755 "$BASE" "$CONF"
download() { curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 60 --retry 2 "$1" -o "$2"; }
if [[ $LISTENER == 1 ]]; then
python3 -m venv "$BASE/listener-venv"
"$BASE/listener-venv/bin/python3" -m pip install --disable-pip-version-check --index-url https://pypi.org/simple \
'docker==7.1.0' 'requests>=2.32.4,<3' 'urllib3>=2.2.3,<3'
"$BASE/listener-venv/bin/python3" - <<'PY'
import docker
client = docker.DockerClient(base_url='unix:///var/run/docker.sock', timeout=15)
assert client.ping(), 'Docker API ping failed'
client.close()
PY
cp -a /var/ossec/wodles/docker/DockerListener "$BACKUP/DockerListener"
LISTENER_CHANGED=1
python3 - "$BASE/listener-venv/bin/python3" /var/ossec/wodles/docker/DockerListener <<'PY'
import pathlib, sys
path=pathlib.Path(sys.argv[2]); text=path.read_text()
assert text.startswith('#!') and 'python' in text.splitlines()[0]
path.write_text('#!'+sys.argv[1]+'\n'+text.split('\n',1)[1])
PY
"$BASE/listener-venv/bin/python3" -m py_compile /var/ossec/wodles/docker/DockerListener
fi
if [[ $SCANS == 1 ]]; then
apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends logrotate
installed_version=$(dpkg-query -W '-f=${Version}' trivy 2>/dev/null || true)
if [[ -n $installed_version && $(dpkg-query -W '-f=${Status}' trivy 2>/dev/null) == 'install ok installed' ]]; then
# Check package metadata BEFORE running any already installed executable.
dpkg --compare-versions "$installed_version" ge 0.70.0 || fail 'Trivy ist zu alt oder in einer gesperrten Version. Bitte separat aktualisieren; vorhandenes Binary wird nicht ausgefuehrt.'
say "Vorhandenes Trivy-Paket bleibt erhalten: $installed_version"
else
if command -v trivy >/dev/null; then fail 'Trivy ausserhalb von APT gefunden; zuerst Herkunft/Version separat pruefen.'; fi
if ! grep -Rqs 'aquasecurity.github.io/trivy-repo/deb' /etc/apt/sources.list.d; then
[[ ! -e /etc/apt/sources.list.d/wazuh-docker-security-trivy.list ]] || fail 'Abweichende Trivy-Repositorydatei vorhanden.'
download https://aquasecurity.github.io/trivy-repo/deb/public.key "$TMP/trivy.asc"
gpg --batch --yes --dearmor -o "$TMP/trivy.gpg" "$TMP/trivy.asc"
install -m 644 "$TMP/trivy.gpg" /usr/share/keyrings/wazuh-docker-security-trivy.gpg
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/wazuh-docker-security-trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main' >/etc/apt/sources.list.d/wazuh-docker-security-trivy.list
fi
apt-get -o DPkg::Lock::Timeout=120 update
candidate=$(apt-cache madison trivy | awk '{print $3}' | grep -E "^${TRIVY_RELEASE//./[.]}(-[A-Za-z0-9.+~]+)?$" | head -n 1) || true
[[ -n $candidate ]] || fail "Trivy $TRIVY_RELEASE nicht im signierten Repository verfuegbar; kein beliebiger Versions-Fallback."
apt-get -o DPkg::Lock::Timeout=120 install -y --no-install-recommends "trivy=$candidate"
fi
install -d -m 700 "$STATE" "$STATE/cache" "$STATE/reports"
install -d -o root -g wazuh -m 750 "$LOGDIR"
touch "$LOGDIR/events.jsonl"; chown root:wazuh "$LOGDIR/events.jsonl"; chmod 640 "$LOGDIR/events.jsonl"
install -o root -g root -m 750 "$TMP/scan.py" "$BASE/scan.py"
if [[ -e $CONF/config.json ]]; then cp -a "$CONF/config.json" "$BACKUP/config.json"; fi
python3 - "$CONF/config.json" "$SCOPE" <<'PY'
import json, os, pathlib, sys
p=pathlib.Path(sys.argv[1]); p.write_text(json.dumps({'scope':sys.argv[2]})+'\n'); os.chmod(p,0o600)
PY
cat >/etc/systemd/system/wazuh-docker-security.service <<'SERVICE'
# Managed by wazuh-docker-security
[Unit]
Description=Local Docker image vulnerability scan for Wazuh
After=network-online.target docker.service wazuh-agent.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/wazuh-docker-security/scan.py
WorkingDirectory=/opt/wazuh-docker-security
TimeoutStartSec=4h
Nice=10
IOSchedulingClass=idle
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/wazuh-docker-security /var/log/wazuh-docker-security
SERVICE
cat >/etc/systemd/system/wazuh-docker-security.timer <<TIMER
# Managed by wazuh-docker-security
[Unit]
Description=Periodic Docker image vulnerability scans
[Timer]
OnActiveSec=${HOURS}h
OnUnitActiveSec=${HOURS}h
RandomizedDelaySec=10min
Unit=wazuh-docker-security.service
[Install]
WantedBy=timers.target
TIMER
chmod 644 /etc/systemd/system/wazuh-docker-security.{service,timer}
cat >/etc/logrotate.d/wazuh-docker-security <<'ROTATE'
# Managed by wazuh-docker-security
/var/log/wazuh-docker-security/events.jsonl {
daily
maxsize 10M
rotate 7
missingok
notifempty
compress
delaycompress
create 0640 root wazuh
}
ROTATE
fi
restore_wazuh() {
cp -a "$BACKUP/ossec.conf" "$WAZUH_CONFIG"
if [[ $LISTENER == 1 && -f $BACKUP/DockerListener ]]; then cp -a "$BACKUP/DockerListener" /var/ossec/wodles/docker/DockerListener; fi
}
CONFIG_CHANGED=1
cp "$TMP/ossec.conf" "$WAZUH_CONFIG"
if ! /var/ossec/bin/wazuh-agentd -t || ! /var/ossec/bin/wazuh-logcollector -t || ! /var/ossec/bin/wazuh-modulesd -t; then
restore_wazuh
fail 'Wazuh-Konfiguration abgelehnt; vorherige Konfiguration/Listener wiederhergestellt.'
fi
if ! systemctl restart wazuh-agent || ! systemctl is-active --quiet wazuh-agent; then
restore_wazuh
systemctl restart wazuh-agent || true
fail 'Agent-Neustart fehlgeschlagen; vorherige Konfiguration/Listener wiederhergestellt.'
fi
CONFIG_COMMITTED=1
if [[ $SCANS == 1 ]]; then
systemctl daemon-reload
systemctl enable --now wazuh-docker-security.timer
if [[ $FIRST_SCAN == 1 ]]; then systemctl start --no-block wazuh-docker-security.service; fi
fi
say "Einrichtung abgeschlossen. Sicherungen: $BACKUP"
say 'Einmal auf dem Wazuh-Manager dasselbe Skript mit --manager-rules ausfuehren.'
say 'Scanstatus: systemctl status wazuh-docker-security.service; journalctl -u wazuh-docker-security.service'
say 'Aktuelle Funde: /var/lib/wazuh-docker-security/inventory.json'
say 'Keine Discord-Webhook-Konfiguration eingerichtet. Ein CVE-Fund beweist keinen Angriff und keine konkrete Ausnutzbarkeit.'