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"
# ---------------------------------------------------------------------------