Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
161 changes: 161 additions & 0 deletions scripts/check_detection_config.sh
Original file line number Diff line number Diff line change
@@ -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("<wrap>" + re.sub(r"^\s*<\?xml[^>]*\?>", "", text) + "</wrap>")
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 <rule> 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}"
3 changes: 2 additions & 1 deletion scripts/require-no-skip.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
# ---------------------------------------------------------------------------
Expand Down
Loading