mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-09-01 12:19:21 +02:00
Auto State Apply fires on SOC config saves and on suricata/strelka rule updates. Files a user creates or edits by hand under /opt/so/saltstack/local/salt/ change no pillar, so nothing fired and the change waited for the next scheduled highstate, now 120 minutes by default. That gap is the 3.2 Known Issue in the docs. Watch the directories the docs tell users to edit, and route them through the push pipeline that already exists: zeek/policy -> zeek (covers intel/ and custom/) zeek/zkg -> zeek elasticsearch/files/ingest -> elasticsearch elasticsearch/roles -> elasticsearch logstash/pipelines/config/custom -> logstash Tags are pillar_push_map.yaml app names, so the existing entries already carry the right state and compound target, and no map entry changes. Rename the beacon rules_beacon -> local_files_beacon. Rules are now one of five kinds of file it watches, and the new name matches how its sibling postgres_pillar_beacon is named: source, then what it watches. Replace push_suricata.sls and push_strelka.sls with one push_files.sls bound to salt/beacon/*/local_files_beacon/*, which looks the tag up in pillar_push_map.yaml the same way push_pillar.sls does. The map's suricata and strelka targets match the compounds those two reactors hardcoded, so rule pushes are unchanged. The app comes from the event tag rather than the payload because salt's beacon loop pops the beacon's tag key off the data. Key watermarks by watched directory instead of by tag. zeek/policy and zeek/zkg both emit the tag zeek, and a shared watermark would make them overwrite each other's digest and emit on every poll. Prune .git from the fingerprint walk. zkg packages must be git clones with a clean working tree, so the watched tree carries full git metadata; walking it every 15s is wasted work and git's own index and ref mtime churn would fire a grid-wide zeek apply on its own. Placing or updating a package always touches working-tree files too, so detection is unaffected. The watch is an allowlist rather than the whole local salt tree because salt writes into that tree itself: hypervisor/hosts/ is rewritten continuously by virtual_node_manager.py and virtual_power_manager.py, libvirt/images/ holds multi-GB qcow2 files, and elasticfleet/files/so_agent-installers/, elasticsearch/files/users, ca/files/ and filebeat/files/ are all state-written. Watching any of them would either self-retrigger or make the 15s poll walk gigabytes.
146 lines
5.2 KiB
Python
146 lines
5.2 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 hand-edited directories under
|
|
# /opt/so/saltstack/local/salt/ 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>/local_files_beacon/zeek); the push_files reactor
|
|
# looks the tag up in salt/reactor/pillar_push_map.yaml and writes 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):
|
|
# zkg packages are git clones; .git churn would fire a state apply on its own.
|
|
dirs[:] = [d for d in dirs if d != '.git']
|
|
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, directory):
|
|
# Keyed by directory: zeek/policy and zeek/zkg share the tag `zeek`.
|
|
scope = hashlib.sha1(directory.encode('utf-8', 'surrogateescape')).hexdigest()[:12]
|
|
return os.path.join(WATERMARK_DIR, 'local_files_beacon_%s_%s.hash' % (tag, scope))
|
|
|
|
|
|
def _read_watermark(tag, directory):
|
|
try:
|
|
with open(_watermark_file(tag, directory), 'r') as f:
|
|
return (f.read() or '').strip() or None
|
|
except IOError:
|
|
return None
|
|
|
|
|
|
def _write_watermark(tag, directory, digest):
|
|
path = _watermark_file(tag, directory)
|
|
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('local_files_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, directory)
|
|
|
|
# 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, directory, digest)
|
|
continue
|
|
|
|
if digest != previous:
|
|
_write_watermark(tag, directory, digest)
|
|
retval.append({'tag': tag, 'path': directory})
|
|
log.info('local_files_beacon: change detected in %s, emitting %s', directory, tag)
|
|
|
|
return retval
|