From 86fb93386862b278703451eb28469d5e992be5b1 Mon Sep 17 00:00:00 2001 From: Fabio Boi Date: Sun, 4 Oct 2026 12:54:50 +0200 Subject: [PATCH] Add docker-security.sh for Docker security monitoring --- docker-security.sh | 733 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 733 insertions(+) create mode 100644 docker-security.sh diff --git a/docker-security.sh b/docker-security.sh new file mode 100644 index 0000000..f5cce26 --- /dev/null +++ b/docker-security.sh @@ -0,0 +1,733 @@ +#!/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-110805 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 '', '', text, count=1) + return minidom.parseString('' + text + '') + + +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']*\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' + + +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: + try: + request = urllib.request.Request(KEV_URL, headers={'User-Agent': 'wazuh-docker-security/1'}) + with urllib.request.urlopen(request, timeout=30) 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) + ids = sorted({v['cveID'] for v in document['vulnerabilities'] + if re.fullmatch(r'CVE-\d{4}-\d{4,}', v.get('cveID', ''))}) + if not ids: + raise ValueError('empty KEV catalog') + cached = {'fetched_at': now, 'ids': ids} + atomic_json(path, cached) + except Exception: + error = 'KEV refresh failed; 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}) + 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' + + + + json + ^container_image_scan$ + Docker image vulnerability scan event. + + + 110800 + ^finding_(new|changed)$ + ^yes$ + Review image finding: $(vulnerability_id) [$(severity)] in $(package), containers $(containers). Not proof of exploitability or compromise. + docker_security_review, + + + 110800 + ^finding_(new|changed)$ + ^yes$ + Prioritize assessment/mitigation: $(vulnerability_id) is in CISA KEV and reported in image for $(containers). Exploitation elsewhere, not proof this host was attacked. + docker_security_kev, + + + 110800 + ^scan_problem$ + Docker image scanner needs attention: $(component): $(message). + docker_security_health, + + + 110800 + ^finding_(cleared|out_of_scope)$ + Image finding cleared or no longer in scan scope. + + + 110800 + ^scan_recovered$ + Scanner component recovered. + + +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 </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.'