#!/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 '', '', 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' 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' 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. 110800 ^scan_status$ Image scanner status: complete=$(complete), fresh KEV=$(kev_fresh), problems=$(problem_count). docker_security_status, 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.'