mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-07-03 07:38:16 +02:00
a330bea25e
Rename the two custom push-detection beacons for clarity: - pillar_db -> postgres_pillar_beacon - rules_db -> rules_beacon Salt resolves a beacon by its config-key name to a _beacons/ module of the same filename and tags its events salt/beacon/<minion>/<name>/<tag>, so each rename touches the module file, the beacon config key in beacons_pushstate.conf.jinja, and the reactor tag patterns in reactor_pushstate.conf together. Watermark filenames and log prefixes are updated to match; reactor run() logic is unchanged.
140 lines
4.7 KiB
Python
140 lines
4.7 KiB
Python
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
|
|
# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at
|
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
|
# Elastic License 2.0.
|
|
|
|
# Custom salt beacon that watches the suricata/strelka rule directories for changes
|
|
# and emits a beacon event per changed directory. This replaces the stock salt
|
|
# `inotify` beacon, which leaks a kernel inotify instance every time the minion
|
|
# rebuilds the beacon loader's __context__ (orphaning the old pyinotify.Notifier
|
|
# without closing it) until fs.inotify.max_user_instances is exhausted and the
|
|
# beacon dies with EMFILE. Polling holds zero inotify instances, so the leak is
|
|
# impossible, and it keeps firing during state runs (no blackout).
|
|
#
|
|
# Detection is poll-based with a per-directory fingerprint persisted to
|
|
# WATERMARK_DIR: each pass walks the directory and hashes every file's
|
|
# (relpath, st_mtime_ns, st_size), which catches content writes, additions,
|
|
# moves, and deletions. A change in the digest emits one event; an unchanged
|
|
# digest emits nothing. This makes it self-healing (a missed poll simply catches
|
|
# up on the next one).
|
|
#
|
|
# Each emitted event carries the watched directory path under the configured tag
|
|
# (e.g. salt/beacon/<minion>/rules_beacon/suricata); the push_suricata / push_strelka
|
|
# reactors write a push intent, after which the existing so-push-drainer /
|
|
# orch.push_batch pipeline takes over unchanged.
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
WATERMARK_DIR = '/opt/so/state'
|
|
|
|
# Temp/editor files that should not trigger a push. Mirrors the exclude regexes
|
|
# the inotify beacon used. Matched against the full pathname.
|
|
EXCLUDES = [
|
|
re.compile(r'\.sw[a-z]$'),
|
|
re.compile(r'~$'),
|
|
re.compile(r'/4913$'),
|
|
re.compile(r'/\.#'),
|
|
]
|
|
|
|
|
|
def __virtual__():
|
|
return True
|
|
|
|
|
|
def validate(config):
|
|
return True, 'valid'
|
|
|
|
|
|
def _paths_from_config(config):
|
|
# The beacon config arrives as a list of single-key dicts (salt beacon style).
|
|
# Merge it and return the {dir: tag} mapping under the 'paths' key.
|
|
merged = {}
|
|
if isinstance(config, list):
|
|
for item in config:
|
|
if isinstance(item, dict):
|
|
merged.update(item)
|
|
elif isinstance(config, dict):
|
|
merged = config
|
|
paths = merged.get('paths', {})
|
|
return paths if isinstance(paths, dict) else {}
|
|
|
|
|
|
def _excluded(pathname):
|
|
for pattern in EXCLUDES:
|
|
if pattern.search(pathname):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _fingerprint(directory):
|
|
# Stat-only walk; hash each file's (relpath, mtime_ns, size). Returns a hex
|
|
# digest, or the digest of an empty tree if the directory does not exist.
|
|
h = hashlib.sha1()
|
|
if os.path.isdir(directory):
|
|
entries = []
|
|
for root, _dirs, files in os.walk(directory):
|
|
for name in files:
|
|
full = os.path.join(root, name)
|
|
if _excluded(full):
|
|
continue
|
|
try:
|
|
st = os.stat(full)
|
|
except OSError:
|
|
continue
|
|
rel = os.path.relpath(full, directory)
|
|
entries.append('%s\0%d\0%d' % (rel, st.st_mtime_ns, st.st_size))
|
|
for line in sorted(entries):
|
|
h.update(line.encode('utf-8', 'surrogateescape'))
|
|
h.update(b'\n')
|
|
return h.hexdigest()
|
|
|
|
|
|
def _watermark_file(tag):
|
|
return os.path.join(WATERMARK_DIR, 'rules_beacon_%s.hash' % tag)
|
|
|
|
|
|
def _read_watermark(tag):
|
|
try:
|
|
with open(_watermark_file(tag), 'r') as f:
|
|
return (f.read() or '').strip() or None
|
|
except IOError:
|
|
return None
|
|
|
|
|
|
def _write_watermark(tag, digest):
|
|
path = _watermark_file(tag)
|
|
try:
|
|
os.makedirs(WATERMARK_DIR, exist_ok=True)
|
|
tmp = path + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
f.write(digest)
|
|
os.rename(tmp, path)
|
|
except OSError:
|
|
log.exception('rules_beacon: failed to persist watermark to %s', path)
|
|
|
|
|
|
def beacon(config):
|
|
retval = []
|
|
|
|
for directory, tag in _paths_from_config(config).items():
|
|
digest = _fingerprint(directory)
|
|
previous = _read_watermark(tag)
|
|
|
|
# First run / missing watermark: seed the digest and emit nothing so a
|
|
# fresh host does not fire a spurious fleetwide push.
|
|
if previous is None:
|
|
_write_watermark(tag, digest)
|
|
continue
|
|
|
|
if digest != previous:
|
|
_write_watermark(tag, digest)
|
|
retval.append({'tag': tag, 'path': directory})
|
|
log.info('rules_beacon: change detected in %s, emitting %s', directory, tag)
|
|
|
|
return retval
|