mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2026-09-04 23:09:21 +02:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a244640539 | ||
|
|
ca96a15091 | ||
|
|
1bac9a218e | ||
|
|
f45dcfdf73 | ||
|
|
62da505ea7 | ||
|
|
7e5b6f276f | ||
|
|
376d29e376 | ||
|
|
665772adb8 | ||
|
|
094b4d5e86 | ||
|
|
3a3667996c | ||
|
|
dfa6f0b454 | ||
|
|
fb7d162de1 | ||
|
|
52791204e4 |
@@ -3,13 +3,14 @@
|
|||||||
# https://securityonion.net/license; you may not use this file except in compliance with the
|
# https://securityonion.net/license; you may not use this file except in compliance with the
|
||||||
# Elastic License 2.0.
|
# Elastic License 2.0.
|
||||||
|
|
||||||
# Custom salt beacon that watches the suricata/strelka rule directories for changes
|
# Custom salt beacon that watches hand-edited directories under
|
||||||
# and emits a beacon event per changed directory. This replaces the stock salt
|
# /opt/so/saltstack/local/salt/ for changes and emits a beacon event per changed
|
||||||
# `inotify` beacon, which leaks a kernel inotify instance every time the minion
|
# directory. This replaces the stock salt `inotify` beacon, which leaks a kernel
|
||||||
# rebuilds the beacon loader's __context__ (orphaning the old pyinotify.Notifier
|
# inotify instance every time the minion rebuilds the beacon loader's __context__
|
||||||
# without closing it) until fs.inotify.max_user_instances is exhausted and the
|
# (orphaning the old pyinotify.Notifier without closing it) until
|
||||||
# beacon dies with EMFILE. Polling holds zero inotify instances, so the leak is
|
# fs.inotify.max_user_instances is exhausted and the beacon dies with EMFILE.
|
||||||
# impossible, and it keeps firing during state runs (no blackout).
|
# 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
|
# Detection is poll-based with a per-directory fingerprint persisted to
|
||||||
# WATERMARK_DIR: each pass walks the directory and hashes every file's
|
# WATERMARK_DIR: each pass walks the directory and hashes every file's
|
||||||
@@ -19,9 +20,10 @@
|
|||||||
# up on the next one).
|
# up on the next one).
|
||||||
#
|
#
|
||||||
# Each emitted event carries the watched directory path under the configured tag
|
# 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
|
# (e.g. salt/beacon/<minion>/local_files_beacon/zeek); the push_files reactor
|
||||||
# reactors write a push intent, after which the existing so-push-drainer /
|
# looks the tag up in salt/reactor/pillar_push_map.yaml and writes a push intent,
|
||||||
# orch.push_batch pipeline takes over unchanged.
|
# after which the existing so-push-drainer / orch.push_batch pipeline takes over
|
||||||
|
# unchanged.
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
@@ -77,7 +79,9 @@ def _fingerprint(directory):
|
|||||||
h = hashlib.sha1()
|
h = hashlib.sha1()
|
||||||
if os.path.isdir(directory):
|
if os.path.isdir(directory):
|
||||||
entries = []
|
entries = []
|
||||||
for root, _dirs, files in os.walk(directory):
|
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:
|
for name in files:
|
||||||
full = os.path.join(root, name)
|
full = os.path.join(root, name)
|
||||||
if _excluded(full):
|
if _excluded(full):
|
||||||
@@ -94,20 +98,22 @@ def _fingerprint(directory):
|
|||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _watermark_file(tag):
|
def _watermark_file(tag, directory):
|
||||||
return os.path.join(WATERMARK_DIR, 'rules_beacon_%s.hash' % tag)
|
# 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):
|
def _read_watermark(tag, directory):
|
||||||
try:
|
try:
|
||||||
with open(_watermark_file(tag), 'r') as f:
|
with open(_watermark_file(tag, directory), 'r') as f:
|
||||||
return (f.read() or '').strip() or None
|
return (f.read() or '').strip() or None
|
||||||
except IOError:
|
except IOError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _write_watermark(tag, digest):
|
def _write_watermark(tag, directory, digest):
|
||||||
path = _watermark_file(tag)
|
path = _watermark_file(tag, directory)
|
||||||
try:
|
try:
|
||||||
os.makedirs(WATERMARK_DIR, exist_ok=True)
|
os.makedirs(WATERMARK_DIR, exist_ok=True)
|
||||||
tmp = path + '.tmp'
|
tmp = path + '.tmp'
|
||||||
@@ -115,7 +121,7 @@ def _write_watermark(tag, digest):
|
|||||||
f.write(digest)
|
f.write(digest)
|
||||||
os.rename(tmp, path)
|
os.rename(tmp, path)
|
||||||
except OSError:
|
except OSError:
|
||||||
log.exception('rules_beacon: failed to persist watermark to %s', path)
|
log.exception('local_files_beacon: failed to persist watermark to %s', path)
|
||||||
|
|
||||||
|
|
||||||
def beacon(config):
|
def beacon(config):
|
||||||
@@ -123,17 +129,17 @@ def beacon(config):
|
|||||||
|
|
||||||
for directory, tag in _paths_from_config(config).items():
|
for directory, tag in _paths_from_config(config).items():
|
||||||
digest = _fingerprint(directory)
|
digest = _fingerprint(directory)
|
||||||
previous = _read_watermark(tag)
|
previous = _read_watermark(tag, directory)
|
||||||
|
|
||||||
# First run / missing watermark: seed the digest and emit nothing so a
|
# First run / missing watermark: seed the digest and emit nothing so a
|
||||||
# fresh host does not fire a spurious fleetwide push.
|
# fresh host does not fire a spurious fleetwide push.
|
||||||
if previous is None:
|
if previous is None:
|
||||||
_write_watermark(tag, digest)
|
_write_watermark(tag, directory, digest)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if digest != previous:
|
if digest != previous:
|
||||||
_write_watermark(tag, digest)
|
_write_watermark(tag, directory, digest)
|
||||||
retval.append({'tag': tag, 'path': directory})
|
retval.append({'tag': tag, 'path': directory})
|
||||||
log.info('rules_beacon: change detected in %s, emitting %s', directory, tag)
|
log.info('local_files_beacon: change detected in %s, emitting %s', directory, tag)
|
||||||
|
|
||||||
return retval
|
return retval
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import local_files_beacon
|
||||||
|
|
||||||
|
|
||||||
|
class TestRulesBeacon(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Isolate all on-disk state (watermarks and the dirs we fingerprint) in a
|
||||||
|
# throwaway tree, and point WATERMARK_DIR at it so the real read/write
|
||||||
|
# helpers run against actual files.
|
||||||
|
self.tmpdir = tempfile.mkdtemp()
|
||||||
|
self.state = os.path.join(self.tmpdir, 'state')
|
||||||
|
patcher = patch.object(local_files_beacon, 'WATERMARK_DIR', self.state)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _make_dir(self, name, files=None):
|
||||||
|
path = os.path.join(self.tmpdir, name)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
for fname, content in (files or {}).items():
|
||||||
|
with open(os.path.join(path, fname), 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
# -- trivial contract -------------------------------------------------
|
||||||
|
|
||||||
|
def test_virtual_returns_true(self):
|
||||||
|
self.assertTrue(local_files_beacon.__virtual__())
|
||||||
|
|
||||||
|
def test_validate_returns_valid(self):
|
||||||
|
self.assertEqual(local_files_beacon.validate({}), (True, 'valid'))
|
||||||
|
|
||||||
|
# -- _paths_from_config -----------------------------------------------
|
||||||
|
|
||||||
|
def test_paths_from_config_list_of_dicts(self):
|
||||||
|
config = [{'interval': 10}, {'paths': {'/a': 'suricata', '/b': 'strelka'}}]
|
||||||
|
self.assertEqual(
|
||||||
|
local_files_beacon._paths_from_config(config),
|
||||||
|
{'/a': 'suricata', '/b': 'strelka'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_paths_from_config_plain_dict(self):
|
||||||
|
self.assertEqual(
|
||||||
|
local_files_beacon._paths_from_config({'paths': {'/a': 'suricata'}}),
|
||||||
|
{'/a': 'suricata'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_paths_from_config_skips_non_dict_items(self):
|
||||||
|
self.assertEqual(local_files_beacon._paths_from_config(['bogus', 42]), {})
|
||||||
|
|
||||||
|
def test_paths_from_config_paths_not_a_dict(self):
|
||||||
|
self.assertEqual(local_files_beacon._paths_from_config({'paths': 'nope'}), {})
|
||||||
|
|
||||||
|
def test_paths_from_config_unexpected_type(self):
|
||||||
|
self.assertEqual(local_files_beacon._paths_from_config('nonsense'), {})
|
||||||
|
|
||||||
|
# -- _excluded --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_excluded_matches_temp_and_editor_files(self):
|
||||||
|
for pathname in ('/rules/foo.swp', '/rules/foo~', '/rules/4913', '/rules/.#foo'):
|
||||||
|
self.assertTrue(local_files_beacon._excluded(pathname), pathname)
|
||||||
|
|
||||||
|
def test_excluded_allows_real_rule_files(self):
|
||||||
|
self.assertFalse(local_files_beacon._excluded('/rules/suricata.rules'))
|
||||||
|
|
||||||
|
# -- _fingerprint -----------------------------------------------------
|
||||||
|
|
||||||
|
def test_fingerprint_missing_dir_is_empty_tree_digest(self):
|
||||||
|
missing = os.path.join(self.tmpdir, 'does-not-exist')
|
||||||
|
self.assertEqual(local_files_beacon._fingerprint(missing), hashlib.sha1().hexdigest())
|
||||||
|
|
||||||
|
def test_fingerprint_changes_when_content_changes(self):
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
before = local_files_beacon._fingerprint(d)
|
||||||
|
with open(os.path.join(d, 'a.rules'), 'w') as f:
|
||||||
|
f.write('alert tcp any any -> any any') # different size
|
||||||
|
self.assertNotEqual(local_files_beacon._fingerprint(d), before)
|
||||||
|
|
||||||
|
def test_fingerprint_ignores_excluded_files(self):
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
before = local_files_beacon._fingerprint(d)
|
||||||
|
with open(os.path.join(d, 'a.rules.swp'), 'w') as f:
|
||||||
|
f.write('editor swap')
|
||||||
|
self.assertEqual(local_files_beacon._fingerprint(d), before)
|
||||||
|
|
||||||
|
def test_fingerprint_skips_unstatable_entries(self):
|
||||||
|
# A dangling symlink appears in os.walk's file list but os.stat raises
|
||||||
|
# OSError, exercising the except-continue path.
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
good = local_files_beacon._fingerprint(d)
|
||||||
|
os.symlink(os.path.join(d, 'missing-target'), os.path.join(d, 'broken.link'))
|
||||||
|
self.assertEqual(local_files_beacon._fingerprint(d), good)
|
||||||
|
|
||||||
|
def test_fingerprint_prunes_git_metadata(self):
|
||||||
|
# zkg packages are git clones, so the watched tree carries .git.
|
||||||
|
d = self._make_dir('zkg', {'pkg.zeek': 'print 1;'})
|
||||||
|
before = local_files_beacon._fingerprint(d)
|
||||||
|
git_dir = os.path.join(d, 'pkg', '.git', 'refs', 'heads')
|
||||||
|
os.makedirs(git_dir)
|
||||||
|
with open(os.path.join(git_dir, 'main'), 'w') as f:
|
||||||
|
f.write('0' * 40)
|
||||||
|
self.assertEqual(local_files_beacon._fingerprint(d), before)
|
||||||
|
|
||||||
|
def test_fingerprint_still_sees_worktree_next_to_git(self):
|
||||||
|
d = self._make_dir('zkg', {'pkg.zeek': 'print 1;'})
|
||||||
|
os.makedirs(os.path.join(d, 'pkg', '.git'))
|
||||||
|
before = local_files_beacon._fingerprint(d)
|
||||||
|
with open(os.path.join(d, 'pkg', 'scripts.zeek'), 'w') as f:
|
||||||
|
f.write('print 2;')
|
||||||
|
self.assertNotEqual(local_files_beacon._fingerprint(d), before)
|
||||||
|
|
||||||
|
# -- _read_watermark / _write_watermark -------------------------------
|
||||||
|
|
||||||
|
def test_watermark_round_trip(self):
|
||||||
|
local_files_beacon._write_watermark('suricata', '/rules/suricata', 'deadbeef')
|
||||||
|
self.assertEqual(
|
||||||
|
local_files_beacon._read_watermark('suricata', '/rules/suricata'), 'deadbeef')
|
||||||
|
|
||||||
|
def test_read_watermark_missing_returns_none(self):
|
||||||
|
self.assertIsNone(local_files_beacon._read_watermark('suricata', '/rules/suricata'))
|
||||||
|
|
||||||
|
def test_read_watermark_empty_file_returns_none(self):
|
||||||
|
os.makedirs(self.state, exist_ok=True)
|
||||||
|
with open(local_files_beacon._watermark_file('suricata', '/rules/suricata'), 'w') as f:
|
||||||
|
f.write('')
|
||||||
|
self.assertIsNone(local_files_beacon._read_watermark('suricata', '/rules/suricata'))
|
||||||
|
|
||||||
|
def test_write_watermark_swallows_oserror(self):
|
||||||
|
with patch.object(local_files_beacon.os, 'makedirs', side_effect=OSError):
|
||||||
|
local_files_beacon._write_watermark('suricata', '/rules/suricata', 'deadbeef')
|
||||||
|
self.assertIsNone(local_files_beacon._read_watermark('suricata', '/rules/suricata'))
|
||||||
|
|
||||||
|
def test_watermark_file_differs_per_directory_within_one_tag(self):
|
||||||
|
# zeek/policy and zeek/zkg share the tag 'zeek'.
|
||||||
|
self.assertNotEqual(
|
||||||
|
local_files_beacon._watermark_file('zeek', '/local/zeek/policy'),
|
||||||
|
local_files_beacon._watermark_file('zeek', '/local/zeek/zkg'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_watermarks_are_independent_within_one_tag(self):
|
||||||
|
local_files_beacon._write_watermark('zeek', '/local/zeek/policy', 'policyhash')
|
||||||
|
local_files_beacon._write_watermark('zeek', '/local/zeek/zkg', 'zkghash')
|
||||||
|
self.assertEqual(
|
||||||
|
local_files_beacon._read_watermark('zeek', '/local/zeek/policy'), 'policyhash')
|
||||||
|
self.assertEqual(
|
||||||
|
local_files_beacon._read_watermark('zeek', '/local/zeek/zkg'), 'zkghash')
|
||||||
|
|
||||||
|
# -- beacon -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _config(self, mapping):
|
||||||
|
return [{'paths': mapping}]
|
||||||
|
|
||||||
|
def test_beacon_seeds_first_run_and_emits_nothing(self):
|
||||||
|
with patch.object(local_files_beacon, '_fingerprint', return_value='hash1'), \
|
||||||
|
patch.object(local_files_beacon, '_read_watermark', return_value=None), \
|
||||||
|
patch.object(local_files_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = local_files_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
mock_write.assert_called_once_with('suricata', '/rules/suricata', 'hash1')
|
||||||
|
|
||||||
|
def test_beacon_emits_on_change(self):
|
||||||
|
with patch.object(local_files_beacon, '_fingerprint', return_value='newhash'), \
|
||||||
|
patch.object(local_files_beacon, '_read_watermark', return_value='oldhash'), \
|
||||||
|
patch.object(local_files_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = local_files_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
||||||
|
self.assertEqual(result, [{'tag': 'suricata', 'path': '/rules/suricata'}])
|
||||||
|
mock_write.assert_called_once_with('suricata', '/rules/suricata', 'newhash')
|
||||||
|
|
||||||
|
def test_beacon_no_change_emits_nothing(self):
|
||||||
|
with patch.object(local_files_beacon, '_fingerprint', return_value='samehash'), \
|
||||||
|
patch.object(local_files_beacon, '_read_watermark', return_value='samehash'), \
|
||||||
|
patch.object(local_files_beacon, '_write_watermark') as mock_write:
|
||||||
|
result = local_files_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
mock_write.assert_not_called()
|
||||||
|
|
||||||
|
def test_beacon_end_to_end_with_real_files(self):
|
||||||
|
# Exercise the full stack (real fingerprint + real watermark files) across
|
||||||
|
# two poll passes: first seeds silently, second fires after a write.
|
||||||
|
d = self._make_dir('rules', {'a.rules': 'alert'})
|
||||||
|
config = self._config({d: 'suricata'})
|
||||||
|
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), []) # seed pass
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), []) # unchanged pass
|
||||||
|
|
||||||
|
with open(os.path.join(d, 'b.rules'), 'w') as f:
|
||||||
|
f.write('alert tcp any any -> any any')
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), [{'tag': 'suricata', 'path': d}])
|
||||||
|
|
||||||
|
def test_beacon_two_dirs_one_tag_do_not_flap(self):
|
||||||
|
# Tag-keyed watermarks would clobber each other and emit on every pass.
|
||||||
|
policy = self._make_dir('zeek/policy', {'intel.dat': '#fields\tindicator'})
|
||||||
|
zkg = self._make_dir('zeek/zkg', {'README': 'place packages here'})
|
||||||
|
config = self._config({policy: 'zeek', zkg: 'zeek'})
|
||||||
|
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), []) # seed pass
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), []) # idle
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), []) # still idle
|
||||||
|
|
||||||
|
with open(os.path.join(policy, 'intel.dat'), 'a') as f:
|
||||||
|
f.write('\nevil.com\tIntel::DOMAIN\tsource\n')
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), [{'tag': 'zeek', 'path': policy}])
|
||||||
|
self.assertEqual(local_files_beacon.beacon(config), []) # quiet again
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import rules_beacon
|
|
||||||
|
|
||||||
|
|
||||||
class TestRulesBeacon(unittest.TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
# Isolate all on-disk state (watermarks and the dirs we fingerprint) in a
|
|
||||||
# throwaway tree, and point WATERMARK_DIR at it so the real read/write
|
|
||||||
# helpers run against actual files.
|
|
||||||
self.tmpdir = tempfile.mkdtemp()
|
|
||||||
self.state = os.path.join(self.tmpdir, 'state')
|
|
||||||
patcher = patch.object(rules_beacon, 'WATERMARK_DIR', self.state)
|
|
||||||
patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
|
||||||
|
|
||||||
def _make_dir(self, name, files=None):
|
|
||||||
path = os.path.join(self.tmpdir, name)
|
|
||||||
os.makedirs(path, exist_ok=True)
|
|
||||||
for fname, content in (files or {}).items():
|
|
||||||
with open(os.path.join(path, fname), 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
return path
|
|
||||||
|
|
||||||
# -- trivial contract -------------------------------------------------
|
|
||||||
|
|
||||||
def test_virtual_returns_true(self):
|
|
||||||
self.assertTrue(rules_beacon.__virtual__())
|
|
||||||
|
|
||||||
def test_validate_returns_valid(self):
|
|
||||||
self.assertEqual(rules_beacon.validate({}), (True, 'valid'))
|
|
||||||
|
|
||||||
# -- _paths_from_config -----------------------------------------------
|
|
||||||
|
|
||||||
def test_paths_from_config_list_of_dicts(self):
|
|
||||||
config = [{'interval': 10}, {'paths': {'/a': 'suricata', '/b': 'strelka'}}]
|
|
||||||
self.assertEqual(
|
|
||||||
rules_beacon._paths_from_config(config),
|
|
||||||
{'/a': 'suricata', '/b': 'strelka'},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_paths_from_config_plain_dict(self):
|
|
||||||
self.assertEqual(
|
|
||||||
rules_beacon._paths_from_config({'paths': {'/a': 'suricata'}}),
|
|
||||||
{'/a': 'suricata'},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_paths_from_config_skips_non_dict_items(self):
|
|
||||||
self.assertEqual(rules_beacon._paths_from_config(['bogus', 42]), {})
|
|
||||||
|
|
||||||
def test_paths_from_config_paths_not_a_dict(self):
|
|
||||||
self.assertEqual(rules_beacon._paths_from_config({'paths': 'nope'}), {})
|
|
||||||
|
|
||||||
def test_paths_from_config_unexpected_type(self):
|
|
||||||
self.assertEqual(rules_beacon._paths_from_config('nonsense'), {})
|
|
||||||
|
|
||||||
# -- _excluded --------------------------------------------------------
|
|
||||||
|
|
||||||
def test_excluded_matches_temp_and_editor_files(self):
|
|
||||||
for pathname in ('/rules/foo.swp', '/rules/foo~', '/rules/4913', '/rules/.#foo'):
|
|
||||||
self.assertTrue(rules_beacon._excluded(pathname), pathname)
|
|
||||||
|
|
||||||
def test_excluded_allows_real_rule_files(self):
|
|
||||||
self.assertFalse(rules_beacon._excluded('/rules/suricata.rules'))
|
|
||||||
|
|
||||||
# -- _fingerprint -----------------------------------------------------
|
|
||||||
|
|
||||||
def test_fingerprint_missing_dir_is_empty_tree_digest(self):
|
|
||||||
missing = os.path.join(self.tmpdir, 'does-not-exist')
|
|
||||||
self.assertEqual(rules_beacon._fingerprint(missing), hashlib.sha1().hexdigest())
|
|
||||||
|
|
||||||
def test_fingerprint_changes_when_content_changes(self):
|
|
||||||
d = self._make_dir('rules', {'a.rules': 'alert'})
|
|
||||||
before = rules_beacon._fingerprint(d)
|
|
||||||
with open(os.path.join(d, 'a.rules'), 'w') as f:
|
|
||||||
f.write('alert tcp any any -> any any') # different size
|
|
||||||
self.assertNotEqual(rules_beacon._fingerprint(d), before)
|
|
||||||
|
|
||||||
def test_fingerprint_ignores_excluded_files(self):
|
|
||||||
d = self._make_dir('rules', {'a.rules': 'alert'})
|
|
||||||
before = rules_beacon._fingerprint(d)
|
|
||||||
with open(os.path.join(d, 'a.rules.swp'), 'w') as f:
|
|
||||||
f.write('editor swap')
|
|
||||||
self.assertEqual(rules_beacon._fingerprint(d), before)
|
|
||||||
|
|
||||||
def test_fingerprint_skips_unstatable_entries(self):
|
|
||||||
# A dangling symlink appears in os.walk's file list but os.stat raises
|
|
||||||
# OSError, exercising the except-continue path.
|
|
||||||
d = self._make_dir('rules', {'a.rules': 'alert'})
|
|
||||||
good = rules_beacon._fingerprint(d)
|
|
||||||
os.symlink(os.path.join(d, 'missing-target'), os.path.join(d, 'broken.link'))
|
|
||||||
self.assertEqual(rules_beacon._fingerprint(d), good)
|
|
||||||
|
|
||||||
# -- _read_watermark / _write_watermark -------------------------------
|
|
||||||
|
|
||||||
def test_watermark_round_trip(self):
|
|
||||||
rules_beacon._write_watermark('suricata', 'deadbeef')
|
|
||||||
self.assertEqual(rules_beacon._read_watermark('suricata'), 'deadbeef')
|
|
||||||
|
|
||||||
def test_read_watermark_missing_returns_none(self):
|
|
||||||
self.assertIsNone(rules_beacon._read_watermark('suricata'))
|
|
||||||
|
|
||||||
def test_read_watermark_empty_file_returns_none(self):
|
|
||||||
os.makedirs(self.state, exist_ok=True)
|
|
||||||
with open(rules_beacon._watermark_file('suricata'), 'w') as f:
|
|
||||||
f.write('')
|
|
||||||
self.assertIsNone(rules_beacon._read_watermark('suricata'))
|
|
||||||
|
|
||||||
def test_write_watermark_swallows_oserror(self):
|
|
||||||
with patch.object(rules_beacon.os, 'makedirs', side_effect=OSError):
|
|
||||||
rules_beacon._write_watermark('suricata', 'deadbeef')
|
|
||||||
self.assertIsNone(rules_beacon._read_watermark('suricata'))
|
|
||||||
|
|
||||||
# -- beacon -----------------------------------------------------------
|
|
||||||
|
|
||||||
def _config(self, mapping):
|
|
||||||
return [{'paths': mapping}]
|
|
||||||
|
|
||||||
def test_beacon_seeds_first_run_and_emits_nothing(self):
|
|
||||||
with patch.object(rules_beacon, '_fingerprint', return_value='hash1'), \
|
|
||||||
patch.object(rules_beacon, '_read_watermark', return_value=None), \
|
|
||||||
patch.object(rules_beacon, '_write_watermark') as mock_write:
|
|
||||||
result = rules_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
|
||||||
self.assertEqual(result, [])
|
|
||||||
mock_write.assert_called_once_with('suricata', 'hash1')
|
|
||||||
|
|
||||||
def test_beacon_emits_on_change(self):
|
|
||||||
with patch.object(rules_beacon, '_fingerprint', return_value='newhash'), \
|
|
||||||
patch.object(rules_beacon, '_read_watermark', return_value='oldhash'), \
|
|
||||||
patch.object(rules_beacon, '_write_watermark') as mock_write:
|
|
||||||
result = rules_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
|
||||||
self.assertEqual(result, [{'tag': 'suricata', 'path': '/rules/suricata'}])
|
|
||||||
mock_write.assert_called_once_with('suricata', 'newhash')
|
|
||||||
|
|
||||||
def test_beacon_no_change_emits_nothing(self):
|
|
||||||
with patch.object(rules_beacon, '_fingerprint', return_value='samehash'), \
|
|
||||||
patch.object(rules_beacon, '_read_watermark', return_value='samehash'), \
|
|
||||||
patch.object(rules_beacon, '_write_watermark') as mock_write:
|
|
||||||
result = rules_beacon.beacon(self._config({'/rules/suricata': 'suricata'}))
|
|
||||||
self.assertEqual(result, [])
|
|
||||||
mock_write.assert_not_called()
|
|
||||||
|
|
||||||
def test_beacon_end_to_end_with_real_files(self):
|
|
||||||
# Exercise the full stack (real fingerprint + real watermark files) across
|
|
||||||
# two poll passes: first seeds silently, second fires after a write.
|
|
||||||
d = self._make_dir('rules', {'a.rules': 'alert'})
|
|
||||||
config = self._config({d: 'suricata'})
|
|
||||||
|
|
||||||
self.assertEqual(rules_beacon.beacon(config), []) # seed pass
|
|
||||||
self.assertEqual(rules_beacon.beacon(config), []) # unchanged pass
|
|
||||||
|
|
||||||
with open(os.path.join(d, 'b.rules'), 'w') as f:
|
|
||||||
f.write('alert tcp any any -> any any')
|
|
||||||
self.assertEqual(rules_beacon.beacon(config), [{'tag': 'suricata', 'path': d}])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -220,6 +220,26 @@ logrotate:
|
|||||||
- extension .log
|
- extension .log
|
||||||
- dateext
|
- dateext
|
||||||
- dateyesterday
|
- dateyesterday
|
||||||
|
/opt/so/log/salt/virtual_node_manager:
|
||||||
|
- daily
|
||||||
|
- rotate 14
|
||||||
|
- missingok
|
||||||
|
- copytruncate
|
||||||
|
- compress
|
||||||
|
- create
|
||||||
|
- extension .log
|
||||||
|
- dateext
|
||||||
|
- dateyesterday
|
||||||
|
/opt/so/log/salt/so-salt-cloud:
|
||||||
|
- daily
|
||||||
|
- rotate 14
|
||||||
|
- missingok
|
||||||
|
- copytruncate
|
||||||
|
- compress
|
||||||
|
- create
|
||||||
|
- extension .log
|
||||||
|
- dateext
|
||||||
|
- dateyesterday
|
||||||
/opt/so/log/salt/so-soup-grid-highstate:
|
/opt/so/log/salt/so-soup-grid-highstate:
|
||||||
- daily
|
- daily
|
||||||
- rotate 14
|
- rotate 14
|
||||||
|
|||||||
@@ -140,6 +140,20 @@ logrotate:
|
|||||||
multiline: True
|
multiline: True
|
||||||
global: True
|
global: True
|
||||||
forcedType: "[]string"
|
forcedType: "[]string"
|
||||||
|
"/opt/so/log/salt/virtual_node_manager":
|
||||||
|
description: List of logrotate options for this file.
|
||||||
|
title: /opt/so/log/salt/virtual_node_manager
|
||||||
|
advanced: True
|
||||||
|
multiline: True
|
||||||
|
global: True
|
||||||
|
forcedType: "[]string"
|
||||||
|
"/opt/so/log/salt/so-salt-cloud":
|
||||||
|
description: List of logrotate options for this file.
|
||||||
|
title: /opt/so/log/salt/so-salt-cloud
|
||||||
|
advanced: True
|
||||||
|
multiline: True
|
||||||
|
global: True
|
||||||
|
forcedType: "[]string"
|
||||||
"/opt/so/log/salt/so-soup-grid-highstate":
|
"/opt/so/log/salt/so-soup-grid-highstate":
|
||||||
description: List of logrotate options for this file.
|
description: List of logrotate options for this file.
|
||||||
title: /opt/so/log/salt/so-soup-grid-highstate
|
title: /opt/so/log/salt/so-soup-grid-highstate
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ beacons:
|
|||||||
postgres_pillar_beacon:
|
postgres_pillar_beacon:
|
||||||
- interval: {{ AUTOAPPLY.drain_interval }}
|
- interval: {{ AUTOAPPLY.drain_interval }}
|
||||||
- disable_during_state_run: False
|
- disable_during_state_run: False
|
||||||
rules_beacon:
|
local_files_beacon:
|
||||||
- interval: {{ AUTOAPPLY.drain_interval }}
|
- interval: {{ AUTOAPPLY.drain_interval }}
|
||||||
- disable_during_state_run: False
|
- disable_during_state_run: False
|
||||||
|
# Tags are app names in salt/reactor/pillar_push_map.yaml.
|
||||||
|
# Allowlist on purpose: salt writes elsewhere under local/salt/ and would self-retrigger.
|
||||||
- paths:
|
- paths:
|
||||||
/opt/so/saltstack/local/salt/suricata/rules: suricata
|
/opt/so/saltstack/local/salt/suricata/rules: suricata
|
||||||
/opt/so/saltstack/local/salt/strelka/rules/compiled: strelka
|
/opt/so/saltstack/local/salt/strelka/rules/compiled: strelka
|
||||||
|
/opt/so/saltstack/local/salt/zeek/policy: zeek
|
||||||
|
/opt/so/saltstack/local/salt/zeek/zkg: zeek
|
||||||
|
/opt/so/saltstack/local/salt/elasticsearch/files/ingest: elasticsearch
|
||||||
|
/opt/so/saltstack/local/salt/elasticsearch/roles: elasticsearch
|
||||||
|
/opt/so/saltstack/local/salt/logstash/pipelines/config/custom: logstash
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ is older than debounce_seconds, this script:
|
|||||||
with the deduped actions list passed as pillar kwargs
|
with the deduped actions list passed as pillar kwargs
|
||||||
* deletes the contributed intent files on successful dispatch
|
* deletes the contributed intent files on successful dispatch
|
||||||
|
|
||||||
Reactor sls files (push_suricata, push_strelka, push_pillar) write intents
|
Reactor sls files (push_files, push_pillar) write intents
|
||||||
but never dispatch directly
|
but never dispatch directly
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -344,16 +344,6 @@ check_cluster_health() {
|
|||||||
|
|
||||||
check_fleet_server() {
|
check_fleet_server() {
|
||||||
echo "Checking that Elastic Fleet Server is responding."
|
echo "Checking that Elastic Fleet Server is responding."
|
||||||
# Before checking fleet health, check for and fix known issue with elastic-agent container and fs.protected_symlinks
|
|
||||||
local protected_symlinks=$(sysctl -b fs.protected_symlinks)
|
|
||||||
if [[ "$protected_symlinks" == "1" ]]; then
|
|
||||||
# disable fs.protected_symlinks and restart elasticfleet
|
|
||||||
sysctl -w fs.protected_symlinks=0
|
|
||||||
docker stop so-elastic-fleet; docker rm -f so-elastic-fleet
|
|
||||||
printf "\nUpdated sysctl fs.protected_symlinks. Restarting fleet before running health check and continuing with soup.\n"
|
|
||||||
salt-call state.apply elasticfleet queue=True
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Modeled on the wait_for_so-elastic-fleet state check in elasticfleet/enabled.sls,
|
# Modeled on the wait_for_so-elastic-fleet state check in elasticfleet/enabled.sls,
|
||||||
# which waits for HTTP 200 from the Fleet Server status API.
|
# which waits for HTTP 200 from the Fleet Server status API.
|
||||||
if curl -sk --fail --retry 3 --retry-delay 10 --max-time 30 "https://localhost:8220/api/status" > /dev/null 2>&1; then
|
if curl -sk --fail --retry 3 --retry-delay 10 --max-time 30 "https://localhost:8220/api/status" > /dev/null 2>&1; then
|
||||||
@@ -1046,20 +1036,8 @@ post_to_3.2.0() {
|
|||||||
}
|
}
|
||||||
### 3.2.0 End ###
|
### 3.2.0 End ###
|
||||||
|
|
||||||
### 3.3.0 Scripts ###
|
### 3.2.0 Scripts ###
|
||||||
|
|
||||||
# Sets fs.protected_symlinks=0
|
|
||||||
#
|
|
||||||
# Elastic Agent docker image chowns its directory to the running UID
|
|
||||||
# but does not chown the elastic-agent launcher symlink.
|
|
||||||
# Preventing non-root users from following that launcher symlink.
|
|
||||||
disable_sysctl_fs_protected_symlink() {
|
|
||||||
salt -C 'I@stig:enabled' state.single sysctl.present name=fs.protected_symlinks value=0 config=/etc/sysctl.conf || true
|
|
||||||
}
|
|
||||||
|
|
||||||
up_to_3.3.0() {
|
up_to_3.3.0() {
|
||||||
disable_sysctl_fs_protected_symlink
|
|
||||||
|
|
||||||
INSTALLEDVERSION=3.3.0
|
INSTALLEDVERSION=3.3.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{% if 'api' in salt['pillar.get']('features', []) %}
|
{% if 'api' in salt['pillar.get']('features', []) %}
|
||||||
location ~* (^/oauth2/token.*|^.well-known/jwks.json|^.well-known/openid-configuration) {
|
location ~* (^/oauth2/token.*|^/\.well-known/jwks.json|^/\.well-known/openid-configuration) {
|
||||||
limit_req zone=auth_throttle burst={{ NGINXMERGED.config.throttle_login_burst }} nodelay;
|
limit_req zone=auth_throttle burst={{ NGINXMERGED.config.throttle_login_burst }} nodelay;
|
||||||
limit_req_status 429;
|
limit_req_status 429;
|
||||||
proxy_pass http://{{ GLOBALS.manager }}:4444;
|
proxy_pass http://{{ GLOBALS.manager }}:4444;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# Read by push_pillar.sls (SOC config saves) and push_files.sls (local/salt file edits);
|
||||||
|
# both key on the app name. An app missing here waits for the next scheduled highstate.
|
||||||
|
#
|
||||||
# One pillar directory can map to multiple (state, tgt) actions.
|
# One pillar directory can map to multiple (state, tgt) actions.
|
||||||
# tgt is a raw salt compound expression. tgt_type is always "compound".
|
# tgt is a raw salt compound expression. tgt_type is always "compound".
|
||||||
# Per-action `batch` / `batch_wait` override the orch defaults (25% / 15s).
|
# Per-action `batch` / `batch_wait` override the orch defaults (25% / 15s).
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!py
|
||||||
|
|
||||||
|
# Reactor invoked by local_files_beacon when a watched directory under
|
||||||
|
# /opt/so/saltstack/local/salt/ changes. The beacon tag is an app name in
|
||||||
|
# pillar_push_map.yaml, so file changes and pillar changes route through the same
|
||||||
|
# table -- see salt/reactor/push_pillar.sls.
|
||||||
|
#
|
||||||
|
# The app comes from the event tag, not the payload: salt's beacon loop pops the
|
||||||
|
# beacon's 'tag' key off the data and appends it to the event tag instead (see
|
||||||
|
# salt/beacons/__init__.py). The reactor renderer sets both `tag` and `data` as
|
||||||
|
# module globals.
|
||||||
|
#
|
||||||
|
# Reactors never dispatch directly. The so-push-drainer schedule picks up ready
|
||||||
|
# intents, dedupes across pending files, and dispatches orch.push_batch.
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from salt.client import Caller
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PENDING_DIR = '/opt/so/state/push_pending'
|
||||||
|
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
|
||||||
|
MAX_PATHS = 20
|
||||||
|
|
||||||
|
# The pillar_push_map.yaml is shipped via salt:// but the reactor runs on the
|
||||||
|
# master, which mounts the default saltstack tree at this path.
|
||||||
|
PUSH_MAP_PATH = '/opt/so/saltstack/default/salt/reactor/pillar_push_map.yaml'
|
||||||
|
|
||||||
|
_PUSH_MAP_CACHE = {'mtime': 0, 'data': None}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_push_map():
|
||||||
|
try:
|
||||||
|
st = os.stat(PUSH_MAP_PATH)
|
||||||
|
except OSError:
|
||||||
|
LOG.warning('push_files: %s not found', PUSH_MAP_PATH)
|
||||||
|
return {}
|
||||||
|
if _PUSH_MAP_CACHE['mtime'] != st.st_mtime:
|
||||||
|
try:
|
||||||
|
with open(PUSH_MAP_PATH, 'r') as f:
|
||||||
|
_PUSH_MAP_CACHE['data'] = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_files: failed to load %s', PUSH_MAP_PATH)
|
||||||
|
_PUSH_MAP_CACHE['data'] = {}
|
||||||
|
_PUSH_MAP_CACHE['mtime'] = st.st_mtime
|
||||||
|
return _PUSH_MAP_CACHE['data'] or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _push_enabled():
|
||||||
|
try:
|
||||||
|
caller = Caller()
|
||||||
|
return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True))
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_files: pillar.get salt:auto_apply:enabled failed, assuming enabled')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _write_intent(key, actions, path):
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
os.makedirs(PENDING_DIR, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
LOG.exception('push_files: cannot create %s', PENDING_DIR)
|
||||||
|
return
|
||||||
|
|
||||||
|
intent_path = os.path.join(PENDING_DIR, '{}.json'.format(key))
|
||||||
|
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
intent = {}
|
||||||
|
if os.path.exists(intent_path):
|
||||||
|
try:
|
||||||
|
with open(intent_path, 'r') as f:
|
||||||
|
intent = json.load(f)
|
||||||
|
except (IOError, ValueError):
|
||||||
|
intent = {}
|
||||||
|
|
||||||
|
intent.setdefault('first_touch', now)
|
||||||
|
intent['last_touch'] = now
|
||||||
|
intent['actions'] = actions
|
||||||
|
paths = intent.get('paths', [])
|
||||||
|
if path and path not in paths:
|
||||||
|
paths.append(path)
|
||||||
|
paths = paths[-MAX_PATHS:]
|
||||||
|
intent['paths'] = paths
|
||||||
|
|
||||||
|
tmp_path = intent_path + '.tmp'
|
||||||
|
with open(tmp_path, 'w') as f:
|
||||||
|
json.dump(intent, f)
|
||||||
|
os.rename(tmp_path, intent_path)
|
||||||
|
except Exception:
|
||||||
|
LOG.exception('push_files: failed to write intent %s', intent_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||||
|
finally:
|
||||||
|
os.close(lock_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
if not _push_enabled():
|
||||||
|
LOG.info('push_files: push disabled, skipping')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
event = data.get('data', data) # noqa: F821 -- data provided by reactor
|
||||||
|
path = event.get('path', '')
|
||||||
|
app = tag.rsplit('/', 1)[-1].strip() # noqa: F821 -- tag provided by reactor
|
||||||
|
|
||||||
|
if not app:
|
||||||
|
LOG.debug('push_files: ignoring event with no app segment: tag=%s', tag) # noqa: F821
|
||||||
|
return {}
|
||||||
|
|
||||||
|
entry = _load_push_map().get(app)
|
||||||
|
if not entry:
|
||||||
|
LOG.warning(
|
||||||
|
'push_files: app "%s" is not in pillar_push_map.yaml; change will be '
|
||||||
|
'picked up at the next scheduled highstate (path=%s)',
|
||||||
|
app, path,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
_write_intent('files_{}'.format(app), list(entry), path)
|
||||||
|
LOG.info('push_files: intent updated for %s (path=%s)', app, path)
|
||||||
|
return {}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
#!py
|
|
||||||
|
|
||||||
# Reactor invoked by the rules_beacon poll beacon (salt/_beacons/rules_beacon.py) on rule
|
|
||||||
# file changes under /opt/so/saltstack/local/salt/strelka/rules/compiled/.
|
|
||||||
#
|
|
||||||
# Writes (or updates) a push intent at /opt/so/state/push_pending/rules_strelka.json
|
|
||||||
# and returns {}. The so-push-drainer schedule picks up ready intents, dedupes
|
|
||||||
# across pending files, and dispatches orch.push_batch. Reactors never dispatch
|
|
||||||
# directly
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
from salt.client import Caller
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PENDING_DIR = '/opt/so/state/push_pending'
|
|
||||||
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
|
|
||||||
MAX_PATHS = 20
|
|
||||||
|
|
||||||
# Mirrors GLOBALS.sensor_roles in salt/vars/globals.map.jinja. Sensor-side
|
|
||||||
# strelka runs on exactly these four roles; so-import gets strelka.manager
|
|
||||||
# instead, which is not fired on pillar changes.
|
|
||||||
SENSOR_ROLES = ['so-eval', 'so-heavynode', 'so-sensor', 'so-standalone']
|
|
||||||
|
|
||||||
|
|
||||||
def _sensor_compound():
|
|
||||||
return ' or '.join('G@role:{}'.format(r) for r in SENSOR_ROLES)
|
|
||||||
|
|
||||||
|
|
||||||
def _push_enabled():
|
|
||||||
try:
|
|
||||||
caller = Caller()
|
|
||||||
return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True))
|
|
||||||
except Exception:
|
|
||||||
LOG.exception('push_strelka: pillar.get salt:auto_apply:enabled failed, assuming enabled')
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _write_intent(key, actions, path):
|
|
||||||
now = time.time()
|
|
||||||
try:
|
|
||||||
os.makedirs(PENDING_DIR, exist_ok=True)
|
|
||||||
except OSError:
|
|
||||||
LOG.exception('push_strelka: cannot create %s', PENDING_DIR)
|
|
||||||
return
|
|
||||||
|
|
||||||
intent_path = os.path.join(PENDING_DIR, '{}.json'.format(key))
|
|
||||||
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
||||||
|
|
||||||
intent = {}
|
|
||||||
if os.path.exists(intent_path):
|
|
||||||
try:
|
|
||||||
with open(intent_path, 'r') as f:
|
|
||||||
intent = json.load(f)
|
|
||||||
except (IOError, ValueError):
|
|
||||||
intent = {}
|
|
||||||
|
|
||||||
intent.setdefault('first_touch', now)
|
|
||||||
intent['last_touch'] = now
|
|
||||||
intent['actions'] = actions
|
|
||||||
paths = intent.get('paths', [])
|
|
||||||
if path and path not in paths:
|
|
||||||
paths.append(path)
|
|
||||||
paths = paths[-MAX_PATHS:]
|
|
||||||
intent['paths'] = paths
|
|
||||||
|
|
||||||
tmp_path = intent_path + '.tmp'
|
|
||||||
with open(tmp_path, 'w') as f:
|
|
||||||
json.dump(intent, f)
|
|
||||||
os.rename(tmp_path, intent_path)
|
|
||||||
except Exception:
|
|
||||||
LOG.exception('push_strelka: failed to write intent %s', intent_path)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
||||||
finally:
|
|
||||||
os.close(lock_fd)
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
|
||||||
if not _push_enabled():
|
|
||||||
LOG.info('push_strelka: push disabled, skipping')
|
|
||||||
return {}
|
|
||||||
|
|
||||||
path = data.get('path', '') # noqa: F821 -- data provided by reactor
|
|
||||||
actions = [{'state': 'strelka', 'tgt': _sensor_compound()}]
|
|
||||||
_write_intent('rules_strelka', actions, path)
|
|
||||||
LOG.info('push_strelka: intent updated for path=%s', path)
|
|
||||||
return {}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#!py
|
|
||||||
|
|
||||||
# Reactor invoked by the rules_beacon poll beacon (salt/_beacons/rules_beacon.py) on rule
|
|
||||||
# file changes under /opt/so/saltstack/local/salt/suricata/rules/.
|
|
||||||
#
|
|
||||||
# Writes (or updates) a push intent at /opt/so/state/push_pending/rules_suricata.json
|
|
||||||
# and returns {}. The so-push-drainer schedule picks up ready intents, dedupes
|
|
||||||
# across pending files, and dispatches orch.push_batch. Reactors never dispatch
|
|
||||||
# directly
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
from salt.client import Caller
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PENDING_DIR = '/opt/so/state/push_pending'
|
|
||||||
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
|
|
||||||
MAX_PATHS = 20
|
|
||||||
|
|
||||||
# Mirrors GLOBALS.sensor_roles in salt/vars/globals.map.jinja. Suricata also
|
|
||||||
# runs on so-import per salt/top.sls, so that role is appended below.
|
|
||||||
SENSOR_ROLES = ['so-eval', 'so-heavynode', 'so-sensor', 'so-standalone']
|
|
||||||
|
|
||||||
|
|
||||||
def _sensor_compound_plus_import():
|
|
||||||
return ' or '.join('G@role:{}'.format(r) for r in SENSOR_ROLES) + ' or G@role:so-import'
|
|
||||||
|
|
||||||
|
|
||||||
def _push_enabled():
|
|
||||||
try:
|
|
||||||
caller = Caller()
|
|
||||||
return bool(caller.cmd('pillar.get', 'salt:auto_apply:enabled', True))
|
|
||||||
except Exception:
|
|
||||||
LOG.exception('push_suricata: pillar.get salt:auto_apply:enabled failed, assuming enabled')
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _write_intent(key, actions, path):
|
|
||||||
now = time.time()
|
|
||||||
try:
|
|
||||||
os.makedirs(PENDING_DIR, exist_ok=True)
|
|
||||||
except OSError:
|
|
||||||
LOG.exception('push_suricata: cannot create %s', PENDING_DIR)
|
|
||||||
return
|
|
||||||
|
|
||||||
intent_path = os.path.join(PENDING_DIR, '{}.json'.format(key))
|
|
||||||
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
||||||
|
|
||||||
intent = {}
|
|
||||||
if os.path.exists(intent_path):
|
|
||||||
try:
|
|
||||||
with open(intent_path, 'r') as f:
|
|
||||||
intent = json.load(f)
|
|
||||||
except (IOError, ValueError):
|
|
||||||
intent = {}
|
|
||||||
|
|
||||||
intent.setdefault('first_touch', now)
|
|
||||||
intent['last_touch'] = now
|
|
||||||
intent['actions'] = actions
|
|
||||||
paths = intent.get('paths', [])
|
|
||||||
if path and path not in paths:
|
|
||||||
paths.append(path)
|
|
||||||
paths = paths[-MAX_PATHS:]
|
|
||||||
intent['paths'] = paths
|
|
||||||
|
|
||||||
tmp_path = intent_path + '.tmp'
|
|
||||||
with open(tmp_path, 'w') as f:
|
|
||||||
json.dump(intent, f)
|
|
||||||
os.rename(tmp_path, intent_path)
|
|
||||||
except Exception:
|
|
||||||
LOG.exception('push_suricata: failed to write intent %s', intent_path)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
||||||
finally:
|
|
||||||
os.close(lock_fd)
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
|
||||||
if not _push_enabled():
|
|
||||||
LOG.info('push_suricata: push disabled, skipping')
|
|
||||||
return {}
|
|
||||||
|
|
||||||
path = data.get('path', '') # noqa: F821 -- data provided by reactor
|
|
||||||
actions = [{'state': 'suricata', 'tgt': _sensor_compound_plus_import()}]
|
|
||||||
_write_intent('rules_suricata', actions, path)
|
|
||||||
LOG.info('push_suricata: intent updated for path=%s', path)
|
|
||||||
return {}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
reactor:
|
reactor:
|
||||||
- 'salt/beacon/*/rules_beacon/suricata':
|
- 'salt/beacon/*/local_files_beacon/*':
|
||||||
- salt://reactor/push_suricata.sls
|
- salt://reactor/push_files.sls
|
||||||
- 'salt/beacon/*/rules_beacon/strelka':
|
|
||||||
- salt://reactor/push_strelka.sls
|
|
||||||
- 'salt/beacon/*/postgres_pillar_beacon/audit_settings':
|
- 'salt/beacon/*/postgres_pillar_beacon/audit_settings':
|
||||||
- salt://reactor/push_pillar.sls
|
- salt://reactor/push_pillar.sls
|
||||||
|
|||||||
@@ -1549,6 +1549,8 @@ soc:
|
|||||||
memoryModel: gemma@SOAI
|
memoryModel: gemma@SOAI
|
||||||
embedModel: amazon.titan-embed-text-v2@SOAI
|
embedModel: amazon.titan-embed-text-v2@SOAI
|
||||||
reconcileModel: gemma@SOAI
|
reconcileModel: gemma@SOAI
|
||||||
|
memoryPersona: ""
|
||||||
|
reconcilePersona: ""
|
||||||
onionconfig:
|
onionconfig:
|
||||||
saltstackDir: /opt/so/saltstack
|
saltstackDir: /opt/so/saltstack
|
||||||
bypassEnabled: false
|
bypassEnabled: false
|
||||||
|
|||||||
@@ -884,6 +884,16 @@ soc:
|
|||||||
reconcileModel:
|
reconcileModel:
|
||||||
description: The model to use when reconciling memories that contain nearly the same content.
|
description: The model to use when reconciling memories that contain nearly the same content.
|
||||||
global: True
|
global: True
|
||||||
|
memoryPersona:
|
||||||
|
description: Text appended to the built-in prompt of the memory extraction agent, managed from the Agent Studio. Use it to steer what is worth remembering.
|
||||||
|
global: True
|
||||||
|
readonlyUi: True
|
||||||
|
multiline: True
|
||||||
|
reconcilePersona:
|
||||||
|
description: Text appended to the built-in prompt of the memory reconciliation agent, managed from the Agent Studio. Use it to steer how new memories are merged with existing ones.
|
||||||
|
global: True
|
||||||
|
readonlyUi: True
|
||||||
|
multiline: True
|
||||||
client:
|
client:
|
||||||
assistant:
|
assistant:
|
||||||
enabled:
|
enabled:
|
||||||
|
|||||||
@@ -65,15 +65,6 @@ run_remediate:
|
|||||||
- success_retcodes:
|
- success_retcodes:
|
||||||
- 2
|
- 2
|
||||||
|
|
||||||
# Elastic Agent docker image chowns its directory to the running UID but does not
|
|
||||||
# chown the elastic-agent launcher symlink. fs.protected_symlinks=1 then prevents
|
|
||||||
# non-root users from following that launcher symlink.
|
|
||||||
{# OSCAP rule id: xccdf_org.ssgproject.content_rule_sysctl_fs_protected_symlinks #}
|
|
||||||
fs.protected_symlinks:
|
|
||||||
sysctl.present:
|
|
||||||
- value: 0
|
|
||||||
- config: /etc/sysctl.conf
|
|
||||||
|
|
||||||
{# OSCAP rule id: xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_burstaction #}
|
{# OSCAP rule id: xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_burstaction #}
|
||||||
disable_ctrl_alt_del_action:
|
disable_ctrl_alt_del_action:
|
||||||
file.replace:
|
file.replace:
|
||||||
|
|||||||
@@ -1601,7 +1601,7 @@ DISA STIG for Oracle Linux 9 V1R3.</xccdf-1.2:description>
|
|||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sudoers_validate_passwd" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sudoers_validate_passwd" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_crypto_fips_enabled" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_crypto_fips_enabled" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_hardlinks" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_hardlinks" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_symlinks" selected="false"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_symlinks" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_core_pattern" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_core_pattern" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_dmesg_restrict" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_dmesg_restrict" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_exec_shield" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_exec_shield" selected="true"/>
|
||||||
@@ -2202,7 +2202,7 @@ standard DISA STIG for Oracle Linux 9 profile.</xccdf-1.2:description>
|
|||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sudoers_validate_passwd" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sudoers_validate_passwd" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_crypto_fips_enabled" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_crypto_fips_enabled" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_hardlinks" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_hardlinks" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_symlinks" selected="false"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_fs_protected_symlinks" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_core_pattern" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_core_pattern" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_dmesg_restrict" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_dmesg_restrict" selected="true"/>
|
||||||
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_exec_shield" selected="true"/>
|
<xccdf-1.2:select idref="xccdf_org.ssgproject.content_rule_sysctl_kernel_exec_shield" selected="true"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user