diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad3a798f..2573191b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -500,6 +500,18 @@ jobs: - name: Validate glance.yml run: ./scripts/require-no-skip.sh ./scripts/check_glance_config.sh + # The detection and sensor configs that break only on odin or fenrir + # otherwise: Wazuh's rules and ossec.conf through wazuh-analysisd -t, + # Zeek's local.zeek and ja4 through zeek --parse-only, each in its pinned + # image, and the XML and JSON parse (#1426). + - name: Validate the detection and sensor configs + run: ./scripts/require-no-skip.sh ./scripts/check_detection_config.sh + + # The vendored ja4 and Sysmon config match the upstream commit VENDORED + # names. Both re-fetch upstream, so this needs the network (#1426). + - name: Vendored detection content matches upstream + run: ./scripts/vendor-ja4.sh --check && ./scripts/vendor-sysmon-config.sh --check + # The device list is spread across snmp.yaml, generator.yaml, # render-config.sh's REQUIRED array and the example secrets file. Drift # between them is invisible until a poll goes out with an empty community, diff --git a/scripts/README.md b/scripts/README.md index 3c482e14..aa72c35b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -46,6 +46,7 @@ and only its fixtures run in CI, through `self-tests.sh`. | `check_compose_health.py` | `check-compose-health` | Every `depends_on: service_healthy` can actually be satisfied | | `check_caddyfile.sh` | — | Every stack's Caddyfile, validated by the pinned Caddy | | `check_glance_config.sh` | — | `stacks/sensitive/glance/glance.yml`, validated by the pinned Glance | +| `check_detection_config.sh` | — | Wazuh's rules, `ossec.conf` and `agent.conf` parse, and the rule ids are unique and in range; the indexer JSON parses; and in their pinned images, `wazuh-analysisd -t` and `zeek --parse-only` accept the configs odin and fenrir run | | `check_image_pins.py` | `check-image-pins` | Every image the repository runs comes from a `compose.yaml`, and an image that mounts another stack's config runs that stack's exact pin | | `check_shared_blocks.py` | — | Every copy of a `# shared-block:` (code copied because a collector installs as one file) matches the others, so a fix cannot land in one copy only | | `check_sops_rules.py`, `check-sops-encrypted.sh` | — | `.sops.yaml` matches its files, and every committed SOPS file is ciphertext, now and (`--history`) in every past revision | diff --git a/scripts/check_detection_config.sh b/scripts/check_detection_config.sh new file mode 100755 index 00000000..3fae4514 --- /dev/null +++ b/scripts/check_detection_config.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# Check the detection and sensor configuration the SOC and the sensor run. +# +# Nothing validated these (#1426). A malformed Wazuh rule, an ossec.conf the +# manager refuses or a Zeek script that does not parse merged green and broke +# only on odin or fenrir, which are exactly the configs a detection exercise +# depends on. +# +# Static, everywhere (python3, no network): +# - stacks/soc/wazuh/manager/rules/local_rules.xml, ossec.conf and +# shared/default/agent.conf parse as XML (Wazuh's files may hold several +# top-level elements, so each is read inside a wrapper); +# - every rule id in local_rules.xml is unique and inside the range the +# file's header gives itself, 100100-100199; +# - the indexer's two JSON files parse. +# +# In the pinned images, when docker is reachable (SKIP otherwise): +# - `wazuh-analysisd -t` in compose.yaml's wazuh.manager, with the three +# files mounted where the manager reads them; +# - `zeek --parse-only` in compose.yaml's zeek, with local.zeek and the +# vendored ja4 mounted as stacks/sensor/compose.yaml mounts them. +# +# Not covered: Velociraptor's init.vql (no offline parser) and the analyst's +# scan0-egress.nft (nft -c needs CAP_NET_ADMIN). +# +# Usage: scripts/check_detection_config.sh [--skips-file PATH] +# --skips-file append each SKIP's reason, as the other docker-backed checks +# do, so validate.sh counts it and require-no-skip.sh fails it +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${REPO_ROOT}" || exit 1 + +SKIPS_FILE="" +while (($#)); do + case "$1" in + --skips-file) SKIPS_FILE="${2:?--skips-file needs a path}"; shift 2 ;; + *) printf 'usage: %s [--skips-file PATH]\n' "$0" >&2; exit 2 ;; + esac +done + +FAILED=0 +pass() { printf '\033[0;32m PASS\033[0m %s\n' "$*"; } +fail() { printf '\033[0;31m FAIL\033[0m %s\n' "$*"; FAILED=1; } +skip() { + printf '\033[0;33m SKIP\033[0m %s\n' "$*" + [[ -n "${SKIPS_FILE}" ]] && printf '%s\n' "$*" >> "${SKIPS_FILE}" + return 0 +} + +WAZUH=stacks/soc/wazuh +RULES="${WAZUH}/manager/rules/local_rules.xml" +OSSEC="${WAZUH}/manager/ossec.conf" +AGENT="${WAZUH}/manager/shared/default/agent.conf" +ZEEK_LOCAL=stacks/sensor/zeek/local.zeek +ZEEK_JA4=stacks/sensor/zeek/ja4 + +# --- static ----------------------------------------------------------------- +if out="$(python3 -I - "${RULES}" "${OSSEC}" "${AGENT}" "${WAZUH}"/indexer/*.json 2>&1 <<'PY' +import json, re, sys, xml.etree.ElementTree as ET +problems = [] +rules, ossec, agent, *jsons = sys.argv[1:] +trees = {} +for f in (rules, ossec, agent): + text = open(f, encoding="utf-8").read() + # Wazuh files may have several top-level elements and a leading comment. + try: + trees[f] = ET.fromstring("" + re.sub(r"^\s*<\?xml[^>]*\?>", "", text) + "") + print(f"ok {f} parses as XML") + except ET.ParseError as e: + problems.append(f"{f}: not well-formed XML: {e}") +if rules in trees: + ids = [r.get("id") for r in trees[rules].iter("rule")] + seen, dupes = set(), set() + for i in ids: + (dupes if i in seen else seen).add(i) + if dupes: + problems.append(f"{rules}: duplicate rule id(s) {sorted(dupes)}") + bad = [i for i in ids if not (i and i.isdigit() and 100100 <= int(i) <= 100199)] + if bad: + problems.append(f"{rules}: rule id(s) outside 100100-100199 or not numeric: {bad}") + if not ids: + problems.append(f"{rules}: no at all") + if not dupes and not bad and ids: + print(f"ok {rules}: {len(ids)} rule ids, unique, all in 100100-100199") +for f in jsons: + try: + json.load(open(f, encoding="utf-8")) + print(f"ok {f} parses as JSON") + except ValueError as e: + problems.append(f"{f}: not valid JSON: {e}") +for p in problems: + print("FAIL " + p) +sys.exit(1 if problems else 0) +PY +)"; then + pass "Wazuh XML and indexer JSON: $(grep -c '^ok' <<<"${out}") checks" +else + printf '%s\n' "${out}" | grep -v '^ok' | sed 's/^/ /' + fail "Wazuh XML or indexer JSON" +fi + +# --- in the pinned images ------------------------------------------------- +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + skip "no docker daemon — wazuh-analysisd -t and zeek --parse-only not run" + exit "${FAILED}" +fi + +# The image ships /var/ossec/etc's defaults outside /var/ossec and copies them +# in at first start (its "permanent data"), and analysisd reads +# etc/internal_options.conf relative to /var/ossec. Run cold, with our three +# files bind-mounted into an otherwise empty etc/, it stopped at "Could not +# open file 'etc/internal_options.conf'" before reading a line of ours. So +# the files are mounted aside, the image's own defaults are restored into the +# container's writable layer, ours are laid over them, and the test runs from +# /var/ossec, which is what the manager's init does before it starts. +# shellcheck disable=SC2016 # expanded by the container's sh, not here +wazuh_test=' +set -e +if [ ! -f /var/ossec/etc/internal_options.conf ]; then + src="$(find / -xdev -path /proc -prune -o -name internal_options.conf -print 2>/dev/null \ + | grep -v "^/var/ossec/etc/" | head -1)" + [ -n "$src" ] || { echo "no internal_options.conf anywhere in the image"; exit 3; } + cp -a "$(dirname "$src")/." /var/ossec/etc/ +fi +mkdir -p /var/ossec/etc/rules /var/ossec/etc/shared/default +cp /hl/ossec.conf /var/ossec/etc/ossec.conf +cp /hl/local_rules.xml /var/ossec/etc/rules/local_rules.xml +cp /hl/agent.conf /var/ossec/etc/shared/default/agent.conf +cd /var/ossec +exec bin/wazuh-analysisd -t +' +# The compose service is wazuh.manager. An image that cannot be resolved is a +# failure of this check, not an empty reference handed to docker: the first +# CI run asked for wazuh-manager and docker said "invalid reference format". +if ! manager="$(COMPOSE_FILE=stacks/soc/compose.yaml ./scripts/image-for.sh wazuh.manager)" || [[ -z "${manager}" ]]; then + fail "could not resolve wazuh.manager's image from stacks/soc/compose.yaml" +elif out="$(docker run --rm --network none --entrypoint /bin/sh \ + -v "${REPO_ROOT}/${OSSEC}:/hl/ossec.conf:ro" \ + -v "${REPO_ROOT}/${RULES}:/hl/local_rules.xml:ro" \ + -v "${REPO_ROOT}/${AGENT}:/hl/agent.conf:ro" \ + "${manager}" -c "${wazuh_test}" 2>&1)"; then + pass "wazuh-analysisd -t accepts ossec.conf and the rules (${manager%%@*})" +else + printf '%s\n' "${out}" | tail -20 | sed 's/^/ /' + fail "wazuh-analysisd -t rejects the configuration (${manager%%@*})" +fi + +zeek="$(COMPOSE_FILE=stacks/sensor/compose.yaml ./scripts/image-for.sh zeek)" +if out="$(docker run --rm --network none --entrypoint zeek -w /tmp \ + -v "${REPO_ROOT}/${ZEEK_LOCAL}:/zeek/site/local.zeek:ro" \ + -v "${REPO_ROOT}/${ZEEK_JA4}:/zeek/site/ja4:ro" \ + "${zeek}" --parse-only local /zeek/site/local.zeek 2>&1)"; then + pass "zeek --parse-only accepts local.zeek and ja4 (${zeek%%@*})" +else + printf '%s\n' "${out}" | tail -20 | sed 's/^/ /' + fail "zeek --parse-only rejects local.zeek or ja4 (${zeek%%@*})" +fi + +exit "${FAILED}" diff --git a/scripts/require-no-skip.sh b/scripts/require-no-skip.sh index 31a23161..34253035 100755 --- a/scripts/require-no-skip.sh +++ b/scripts/require-no-skip.sh @@ -7,7 +7,8 @@ # # The docker-backed checks (check_loki_rules.sh, test_loki_rules.py, # check_syslog_senders.sh, check_socket_proxy.sh, check_gosu_skip.sh, -# check_caddyfile.sh, check_glance_config.sh, check_dashboard_roundtrip.sh) +# check_caddyfile.sh, check_glance_config.sh, check_dashboard_roundtrip.sh, +# check_detection_config.sh) # print SKIP and exit 0 when they cannot reach docker or their binary. That is # right for a local run, where validate.sh counts the skips through # --skips-file. CI called them bare, so a runner that lost its daemon, or an diff --git a/scripts/validate.sh b/scripts/validate.sh index 7bfaf762..a3f3171f 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -517,6 +517,15 @@ GLANCE_SKIPS="$(mktemp)" SKIPPED=$((SKIPPED + $(wc -l < "${GLANCE_SKIPS}"))) rm -f "${GLANCE_SKIPS}" +# The detection and sensor configs: Wazuh's rules and ossec.conf, the +# indexer's JSON, Zeek's local.zeek and ja4. XML/JSON everywhere; the +# manager's and Zeek's own parsers in their pinned images when docker is up +# (#1426). +DETECTION_SKIPS="$(mktemp)" +./scripts/check_detection_config.sh --skips-file "${DETECTION_SKIPS}" || FAILED=1 +SKIPPED=$((SKIPPED + $(wc -l < "${DETECTION_SKIPS}"))) +rm -f "${DETECTION_SKIPS}" + # --------------------------------------------------------------------------- head_ "Grafana dashboards" # ---------------------------------------------------------------------------