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.'