760 lines
35 KiB
Bash
760 lines
35 KiB
Bash
#!/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.'
|