Compare commits

...
Author SHA1 Message Date
Mike Reeves 387781c629 Trim verbose comments in postgres provisioning changes 2026-07-20 15:19:02 -04:00
Mike Reeves 011749ad09 Self-heal PostgreSQL SOC database bootstrap
init-db.sh only runs on a fresh PGDATA (docker-entrypoint-initdb.d), so a
cluster left partially initialized -- e.g. the container restarted mid first-init
by a watch trigger -- never recovered: the so_postgres role existed but its
schema grants and the so_telegraf database were missing, breaking SOC with
"permission denied for schema public".

- init-db.sh: make the role upsert race-safe. Try CREATE ROLE and fall back to
  ALTER on duplicate_object/unique_violation instead of IF NOT EXISTS, so a
  concurrent creator no longer aborts the script (set -e + ON_ERROR_STOP=1)
  before the grants and so_telegraf creation run. The whole script is now
  idempotent and safe to re-run.
- postgres/enabled.sls: move postgres_wait_ready here (was telegraf-gated in
  telegraf_users.sls) and add postgres_bootstrap_soc_db, which re-runs init-db.sh
  every highstate so a partially-initialized cluster self-heals.
- telegraf_users.sls: drop its now-duplicate postgres_wait_ready definition; it
  resolves from postgres.enabled via the existing include.
- soup: remove bootstrap_so_soc_database and its post_to_3.2.0 call. The
  highstates soup runs before postupgrade now reconcile the SOC DB, making the
  one-shot bootstrap redundant.
2026-07-20 15:04:58 -04:00
Mike Reeves ad78e84ccd Prevent PostgreSQL from leaking plaintext passwords to postgres.log
PostgreSQL's log_min_error_statement defaults to 'error', so whenever a
CREATE/ALTER ROLE ... PASSWORD statement errored, the full statement text --
including the plaintext password -- was written to /opt/so/log/postgres/postgres.log.
The role-provisioning paths (init-db.sh for so_postgres, so-telegraf-postgres
for per-minion telegraf roles) both dispatch such DDL, the latter on every
state.apply.

- init-db.sh / so-telegraf-postgres: SET log_min_error_statement = panic before
  the password-bearing DDL so an error no longer emits the STATEMENT line. The
  ERROR message itself (no password) still logs, preserving debuggability.
- logrotate: add a postgres stanza (daily, keep 14, copytruncate, compress) so
  postgres.log is rotated like every other service and leaked content can't
  persist indefinitely. copytruncate is required because the container holds the
  log open via redirected stderr.
- soup: scrub any already-logged PASSWORD lines from postgres.log during
  post_to_3.2.0, rewriting in place to preserve the inode postgres is writing to.
2026-07-20 12:41:14 -04:00
Josh PattersonandGitHub c4295b4e0a Merge pull request #16071 from Security-Onion-Solutions/saltthangs
auto state apply
2026-07-20 08:56:08 -04:00
Josh BrowerandGitHub c2075ddafb Merge pull request #16081 from Security-Onion-Solutions/agentic-tweaks
Update baseline agents
2026-07-17 21:54:20 +02:00
Josh Patterson 4886034fef add date and time to output 2026-07-17 15:25:55 -04:00
Josh Brower 48a7d66964 Update baseline agents 2026-07-17 15:20:20 -04:00
Josh Patterson 6876b25280 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-17 11:54:15 -04:00
Jason ErtelandGitHub 30f3bddb8b Merge pull request #16078 from Security-Onion-Solutions/jertel/wip
ignore file already closed
2026-07-17 10:58:55 -04:00
Jason Ertel 811b799b0b ignore file already closed 2026-07-17 10:57:30 -04:00
Josh Patterson aaea6dbd58 so-salt-minion-wait: report ready immediately when already ready
Running the wait on a healthy, steady-state minion always reported readiness
after a 3s floor:

  salt-minion (pid 4114640) ready after 3s

That floor came entirely from the unconditional sleep "$INITIAL_SLEEP" (3s)
before the poll loop. The sleep is vestigial: it predates restart_pending and
never even covered the restart race (see 89e6a746c -- "INITIAL_SLEEP=3 expired
inside that window"). Salt restarts the unit with --no-block and the restart
job is enqueued before service.restart returns, so an in-flight restart is
visible to restart_pending on the first loop iteration; the sleep protects
nothing now.

Drop the INITIAL_SLEEP constant and the pre-loop sleep and start elapsed at 0.
The loop already sleeps at the bottom of each iteration, so the first readiness
check now runs immediately: an already-ready minion returns "ready after 0s",
while the restart path (guarded by restart_pending) and the mid-startup log
gate are unchanged.
2026-07-17 09:18:03 -04:00
Josh Patterson 8095b82841 soup use so-salt-minion-wait to ensure salt-minion is ready 2026-07-16 16:58:21 -04:00
Josh Patterson 141116f550 Make so-salt-minion-wait work without requiring a restart
The wait required both a socket gate and a log gate to pass. The log gate
greps the minion log for salt's one-time startup line "Minion is ready to
receive requests!", which scrolls out of the log tail on a minion that has
not restarted recently. On such a minion the log gate could never pass, so
the script ran to its full 120s timeout and exited 1 even though the minion
was healthy and connected. This also false-timed-out when salt_minion_service
reported a non-restart change (e.g. an enable toggle).

The log gate's only remaining purpose is closing the ~2.8s post-connect window
where the master sockets are up but _post_master_init() is still loading. Gate
it on the current pid's uptime: enforce the ready line only within
READY_LINE_WINDOW (90s) of (re)start, and let the already restart-independent
socket gate be the steady-state authority past that. The fresh-restart path is
unchanged, and if uptime can't be read the strict behavior is kept.
2026-07-16 16:52:06 -04:00
Josh Patterson f6d3cbe08d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-16 15:29:51 -04:00
Josh Patterson 9e7e6edae0 Add unit tests for _beacons and wire into CI
Add 100%-coverage unit tests for the three custom salt beacons
(postgres_pillar_beacon, rules_beacon, zeek) and add salt/_beacons to
the python-test workflow's paths trigger and matrix.

To pass the workflow's flake8 lint over the whole directory:
- zeek.py: reindent to 4 spaces, drop trailing blank line, noqa the
  Salt-injected __salt__ references (F821); no logic change.
- postgres_pillar_beacon.py: noqa C901 on beacon() (complexity 13 > 12).
2026-07-16 15:28:22 -04:00
Doug BurksandGitHub 6f61e7c901 Merge pull request #16075 from Security-Onion-Solutions/dougburks-patch-1
Fix typos in CPU affinity descriptions
2026-07-16 15:15:38 -04:00
Doug BurksandGitHub cc2bfc26e2 Fix typos in CPU affinity descriptions 2026-07-16 15:13:51 -04:00
Jorge ReyesandGitHub 073e32520b Merge pull request #16074 from Security-Onion-Solutions/reyesj2-patch-5
include so-yaml.py in get_soup_script_hashes() so we ensure its at la…
2026-07-16 13:00:16 -05:00
reyesj2 5867b50720 include so-yaml.py in get_soup_script_hashes() so we ensure its at latest version before using it later on in soup 2026-07-16 12:28:23 -05:00
Josh Patterson 8a16ead33d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-16 08:26:21 -04:00
Josh Patterson f9b154ccef remove comments 2026-07-15 16:32:29 -04:00
Mike ReevesandGitHub 3503d0c33d Merge pull request #16054 from Security-Onion-Solutions/kernel
Install UEK8 in so-kernel-upgrade when no UEK kernel is present
2026-07-15 16:29:51 -04:00
Josh Patterson 517538a9a7 remove comments 2026-07-15 16:28:35 -04:00
Josh Patterson 23c74f1727 remove installation of pyinotify 2026-07-15 15:24:33 -04:00
Josh Patterson b76f9d022e Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-15 15:14:20 -04:00
Mike ReevesandGitHub 02318f065c Merge pull request #16069 from Security-Onion-Solutions/mreeves/soup-resumable-upgrade
soup: make failed upgrades and hotfixes resumable
2026-07-15 15:13:57 -04:00
Josh Patterson f958212bea Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-15 15:07:09 -04:00
Josh Patterson 376607d292 so-status: show container status while system is starting
Containers now start on boot via restart_policy unless-stopped, so a
highstate is no longer required to bring them up. Gather and display the
container table even when no highstate has completed since reboot, while
still warning the user. The exit code / JSON status_code stays 2 in that
state so SOC's Grid continues to show the restarting message unchanged.
2026-07-15 14:52:27 -04:00
Mike Reeves 186bf86e99 soup: require green Elasticsearch cluster before upgrading
Change the pre-flight cluster-health gate to wait_for_status=green instead of
yellow, so soup only proceeds when the cluster is fully green.
2026-07-15 12:14:18 -04:00
Mike Reeves bd70dd53fb soup: add cluster-health and Fleet Server pre-flight checks
Before making any changes, verify the grid is in a good state:
 - check_cluster_health: waits for Elasticsearch to reach at least 'yellow'
   (blocks only on red/unreachable, since yellow is normal), modeled on the
   wait in so-elasticsearch-roles-load.
 - check_fleet_server: confirms the Fleet Server status API returns HTTP 200,
   modeled on the wait_for_so-elastic-fleet state in elasticfleet/enabled.sls.

Both run alongside the existing check_pillar_items (manager pillar render) and
verify_es_version_compatibility, before soup modifies anything, so a failure
exits cleanly with an actionable message and no partial changes. Valid on all
manager roles soup runs on (eval/standalone/manager/managerhype/managersearch/
import), which all run Elasticsearch and the Fleet Server.
2026-07-15 12:00:11 -04:00
Mike Reeves be7d8a2aa7 soup: make partial-upgrade state clear and avoid re-running completed upgrades
After a partial upgrade, /etc/soversion already reads the target version, so
soup's startup line "Found that Security Onion X is currently installed" made
it look finished even as soup resumed. When a resume marker is present and
differs from the installed version, print an explicit NOTE that the grid is only
partially upgraded and this run will resume and complete it.

Also clear any stale resume marker in the already-latest path so a successfully
completed upgrade is never mistaken for a partial one and re-run on a later
invocation (the marker is normally removed at the end of postupgrade_changes;
this is a belt-and-suspenders guard).
2026-07-15 11:35:04 -04:00
Josh Patterson 5178d5fd0e ensure restart_policy is below image 2026-07-15 11:04:51 -04:00
Josh Patterson fee62ab976 change restart_policy to unless-stopped 2026-07-15 10:49:27 -04:00
Mike Reeves 618712469e soup: clearly report incomplete upgrades on trap exit
When soup fails via the EXIT trap after it has begun modifying the system, print
a prominent UPGRADE INCOMPLETE banner instructing the user to run soup again to
resume and complete the update. Gated on a new SOUP_UPGRADE_STARTED flag set at
the start of the hotfix and upgrade branches, so pre-flight gate failures (ES
compatibility, disk, network) that abort before any changes are made do not show
it.
2026-07-15 09:42:59 -04:00
Mike Reeves 8b488f9226 soup: make failed upgrades and hotfixes resumable
A failed highstate mid-upgrade left /etc/soversion already advanced to the
target version (the highstate stamps it from the pillar via the soversionfile
state), so a re-run of soup saw INSTALLEDVERSION == NEWVERSION and reported
"already running the latest version", stranding the box with post-upgrade
steps never run.

Introduce /etc/sopostversion, a soup-owned marker (no salt state manages it)
that records post-upgrade walk progress. It is seeded from the pre-upgrade
version before the highstate, advanced after each post_to_* step, and removed
on successful completion. upgrade_check treats a leftover marker as "upgrade
not finished" and resumes the remaining post steps instead of bailing.

Also fix the hotfix path: /etc/sohotfix was written before the hotfix
highstate, so a failed hotfix highstate looked already-applied on re-run.
Since no salt state manages /etc/sohotfix, defer its write (update_version)
until after the highstate succeeds so it is an honest completion marker.
2026-07-14 17:30:58 -04:00
Jason ErtelandGitHub 1657480d31 Merge pull request #16064 from Security-Onion-Solutions/jertel/wip
prevent login redirect to any API url
2026-07-14 13:55:44 -04:00
Jason Ertel 63d4061500 prevent login redirect to any API url 2026-07-14 13:52:32 -04:00
Josh Patterson 405dc52587 move up restart policy for so-kratos 2026-07-14 13:34:19 -04:00
Josh Patterson e42f7cd6fc add restart policy to so-postgres 2026-07-14 13:33:57 -04:00
Jorge ReyesandGitHub 8167ae3282 Merge pull request #16062 from Security-Onion-Solutions/reyesj2/es937
ES 9.3.7
2026-07-13 14:35:35 -05:00
reyesj2 2cd889782d soup es check for 9.3.7 2026-07-13 13:16:12 -05:00
reyesj2 87a5639643 pipeline updates 2026-07-13 13:05:25 -05:00
Josh Patterson ed533efb7b so-salt-minion-check: tag and clarify log lines per check
With two independent checks now writing to the same log, messages like
"system uptime only N seconds does not meet 1800 second requirement" were
ambiguous about which check they came from. Prefix every line with a
[minion-restart-check] or [boot-highstate-check] tag and reword the uptime,
threshold, and healthy messages to say what was evaluated and why it was
skipped.

Restructure the boot-highstate check from a nested if into an if/elif chain so
each outcome (restart already queued, uptime too low, healthy, already running,
forcing) logs its own reason instead of silently doing nothing.
2026-07-10 15:53:49 -04:00
Josh Patterson 5af6c56996 so-salt-minion-check: force highstate if none has completed since boot
Add a second, independent trigger to the every-5-minute health check: if the
host has been up >= 15 minutes (HIGHSTATE_UPTIME_REQ) and no highstate has
completed since this boot (lasthighstate mtime older than boot time), run
salt-call state.highstate. This recovers a host whose boot highstate
(so-boot-highstate.service) failed or was skipped, even while the minion is
otherwise healthy and touching state-apply-test.

The new path deliberately does not enable highstate, so a soup-disabled
highstate is respected and never forced mid-upgrade. A saltutil.running guard
plus queue=True prevents stacking across successive cron runs, and a RESTARTED
flag suppresses the new block when the existing minion-restart path already
queued a highstate.
2026-07-10 14:54:20 -04:00
reyesj2 99e9fc1c3b ES 9.3.7 2026-07-10 13:50:55 -05:00
Jorge ReyesandGitHub e5de499bcc Merge pull request #16059 from Security-Onion-Solutions/reyesj2-patch-4 2026-07-10 13:02:18 -05:00
reyesj2 7d17784e96 status messages for so-elastic-fleet-setup 2026-07-10 12:50:21 -05:00
Jason ErtelandGitHub f0bbbf37d8 Merge pull request #16058 from Security-Onion-Solutions/jertel/wip
toggle pg metrics
2026-07-10 10:48:07 -04:00
Jason Ertel 6fc0fd954c clarify output annotation 2026-07-10 10:35:37 -04:00
Jason Ertel 566f90a0c0 toggle pg metrics 2026-07-10 10:32:25 -04:00
Josh Patterson 89e6a746c8 so-salt-minion-wait: wait for the restart job before reading MainPID
Live testing on a standalone node found the previous commit still reported
ready on the OUTGOING daemon. Reproduced by running the production sequence:

  systemctl restart --no-block salt-minion   # what service.restart issues
  /usr/sbin/so-salt-minion-wait              # what cmd.run then runs

  so-salt-minion-wait: gating on pid-tagged ready line ... plus master sockets
  salt-minion (pid 2750297) ready after 3s   # 2750297 is the OLD child

salt restarts this unit with --no-block -- _no_block_default() in
salt/modules/systemd_service.py returns True when the unit is the salt-minion
service -- so service.restart returns as soon as the job is enqueued. Measured
on the host, systemd does not swap MainPID until ~7.3s later. Throughout that
window the old daemon is still running, still holds its master sockets, and
its own ready line is already in the log, so every gate passes on the instance
that is about to be torn down. INITIAL_SLEEP=3 expired inside that window.

Wait for systemd's job queue for the unit to drain before resolving MainPID.
That is deterministic rather than a timing guess: the job exists from the
moment --no-block returns until the new instance signals READY, and MainPID is
new by the time it clears. Measured transition:

  t=0.0s   job pending, child=OLD, sockets up, ready line present
  t=7.9s   job drained, child=NEW, sockets up, ready line ABSENT
  t=10.7s  ready line for NEW child appears   <- script returns here

The same run also confirms empirically why the log line is required in
addition to the sockets: for 2.8s the new child has both master connections
while _post_master_init() is still loading modules and compiling pillar, so a
socket-only gate would return that much too early.

Correct the comment claim from the previous commit. The --no-block restart is
real; it lives in salt's systemd_service module, not in this repo, which is
why searching the repo for it turned up nothing.
2026-07-10 09:35:21 -04:00
Mike Reeves 52885e28c5 Name the reposync-side kernel repo securityonionkernelsync
The reposync section in repodownload.conf and the client repo assigned in
repo/client/oracle.sls both used the bare name securityonionkernel, colliding
across the two roles. Rename the reposync-side section (and its --repoid, the
so-repo-sync guard, and the so-kernel-upgrade presence check) to
securityonionkernelsync, mirroring the existing securityonion/securityonionsync
split for the main repo. The client repo stays securityonionkernel. Also give
the section its own name=Security Onion Kernel Repo repo.
2026-07-09 17:02:47 -04:00
Josh Patterson fbeac25ee9 so-salt-minion-check: highstate after minion restart
When the minion is deemed hung and restarted, wait for it to become
ready via so-salt-minion-wait, then kick off salt-call state.highstate
(queued, backgrounded) so the box re-applies its states and recovers on
its own rather than waiting for the next scheduled highstate.
2026-07-09 16:57:19 -04:00
Josh Patterson 8a3f5d0f81 Gate so-salt-minion-wait on real minion readiness
The previous gate did not detect whether the restarted minion was back:

  systemctl is-active --quiet salt-minion \
    && salt-call --local --timeout=5 --out=quiet test.ping

Both halves are near-vacuous. `--local` sets file_client=local, so test.ping
runs in a throwaway minion that never contacts the master and never inspects
the running daemon; it only proves python and the module loader work. And the
shipped unit is Type=notify with notify_systemd() called before the daemon
imports salt.cli.daemons, so is-active goes true at process launch, not at
connection. The script could return ready while the minion was still
authenticating, which is the race it exists to prevent.

Gate instead on the condition salt itself uses to log "Minion is ready to
receive requests!", requiring both signals of the current daemon instance:

  1. the pid-tagged ready line in the minion log. tune_in() emits it only
     after sync_connect_master() returns, i.e. the pub channel authenticated,
     the req channel connected, and _post_master_init() finished loading
     modules and compiling pillar.
  2. that same pid holding an ESTABLISHED req connection to a master on 4506
     plus a second (publish) connection to the same master IP. The publish
     port is absent from minion config -- the minion learns it from the
     master's auth reply -- so it is derived from the connection.

Resolve the daemon pid from systemd (MainPID -> pgrep -P), never from
/var/run/salt-minion.pid. salt_minion() runs the minion in a multiprocessing
child; that child writes the pidfile, owns the sockets and logs the ready
line, while MainPID is the parent. During a restart the pidfile still names
the old child, whose own ready line is already in the log, so keying off it
reports ready instantly. Children of the current MainPID exclude the old
instance structurally, with no timing assumptions.

Degrade deterministically rather than spinning to the timeout: if
log_level_logfile does not emit INFO records the ready line can never appear,
so detect that up front from the merged config and fall back to the socket
check. log_level_logfile defaults to None (inherit log_level), so resolve the
inheritance before deciding. If ss is unavailable, fall back to the log gate.
If neither signal is usable, fail immediately with a clear message.

Requiring master connectivity adds no new dependency: every path that applies
salt.minion or a highstate does so without --local, so file_client=remote
already required a reachable master to fetch salt:// files. No salt-call
master round-trip is added; the daemon's own successful auth already proves
the key is accepted.

Also fix the comment above wait_for_salt_minion_ready, which attributed the
script to common_sbin/common/tools/sbin (it is deployed by salt_sbin from
salt/tools/sbin) and asserted a --no-block restart that appears nowhere in
the repo. No state logic changed.
2026-07-09 16:55:30 -04:00
Jorge ReyesandGitHub 4e856f02da Merge pull request #16055 from Security-Onion-Solutions/reyesj2-patch-3
exit so-setup early for issues with so-elastic-fleet-setup
2026-07-09 14:53:52 -05:00
reyesj2 f6a2758321 add so-elastic-agent-install script to minions 2026-07-09 14:44:42 -05:00
reyesj2 0b078c4804 give the elasticfleet state a few chances to complete successfully before exiting 1 causing so-setup to fail 2026-07-09 14:23:45 -05:00
reyesj2 2959dc9564 keep logs for all 3 attempts 2026-07-09 14:18:08 -05:00
reyesj2 8b0759866e verify installers are generated 2026-07-09 14:15:22 -05:00
reyesj2 6fa0d327cb cause so-setup to fail if there are issues setting up fleet 2026-07-09 14:14:58 -05:00
Mike Reeves 9a71f64a35 Branch so-kernel-upgrade on the running kernel flavor
Only the RHCK->UEK flavor cross needs grubby --set-default; a UEK7->UEK8
update stays in the kernel-uek lineage and auto-promotes on its own. Detect
the running kernel and act accordingly:

- UEK8: already on target, no-op.
- UEK7: populate the repo and install UEK8, then verify it auto-promoted
  (warn with the manual grubby command if it did not) -- no grubby change.
- RHCK: install UEK8 and set the boot default explicitly, as before.

Also make an already-installed UEK8 skip the repo entirely so a disabled or
empty kernel repo can't block flipping the default, and correct the header
comment that claimed every transition needs grubby.
2026-07-09 15:10:13 -04:00
Mike Reeves 40c02b3149 Make so-kernel-upgrade populate the kernel repo and fail loudly
Three stages of the UEK8 path fail silently, and the script only handled
the last one:

1. Populate. so-repo-sync runs before the highstate deploys the
   [securityonionkernel] section into repodownload.conf, so the first
   kernel-aware soup skips the kernel sync. kernelrepo_init_empty then
   seeds valid-but-empty repodata, leaving an enabled repo with zero
   packages. dnf resolves it happily and installs nothing, no error.

2. Install. `dnf install kernel-uek` on a UEK7 node sees kernel-uek 5.15
   already installed, prints "Nothing to do" and exits 0 -- so the script
   sailed past the install and died later with a misleading grubby error.

3. Boot. Already handled: grubby only auto-promotes within the running
   kernel's flavor lineage, so 5.x -> 6.x UEK never promotes on its own.

Add ensure_kernel_repo(), which verifies the repo is enabled (necessary
because skip_if_unavailable=1 hides a broken repo) and that it can serve a
6.x kernel-uek. When it cannot, a manager runs so-repo-sync to populate
/nsm/kernelrepo and re-checks; a minion cannot fix it and exits non-zero
pointing the admin at the manager. Airgap managers bail, since their repo
comes from the ISO rather than a sync.

Install the explicit UEK8 NEVRA instead of the bare package name so the
"Nothing to do" exit-0 case cannot mask a no-op, and pin the repoquery to
securityonionkernel so a UEK7 kernel-uek in the main repo is never picked.

Still idempotent and still never reboots.
2026-07-09 14:21:08 -04:00
Mike Reeves 5fd5df54b4 Install UEK8 in so-kernel-upgrade when no UEK kernel is present
The script assumed the UEK8 kernel was already installed and only switched
the boot default to it. On a node running the EL9 stock kernel (RHCK 5.14)
there is no kernel-uek* package at all, so `dnf update` has nothing to
upgrade and UEK8 never lands -- the script just logged "nothing to do" and
exited 0.

When no 6.x UEK boot entry exists, install the kernel-uek metapackage (it
pulls kernel-uek-core plus the module subpackages, including
kernel-uek-modules-extra-netfilter) and then proceed with the grubby
switch. Fail loudly if securityonionkernel is not an enabled repo, since
that assignment is gated on the NIC-pin marker and the salt version match
and a silent no-op there is hard to diagnose.

Also point DEFAULTKERNEL at kernel-uek-core so later kernel updates stay on
the UEK line rather than falling back to RHCK.

Still idempotent and still never reboots.
2026-07-09 13:47:50 -04:00
reyesj2 3394e9aab7 increase agent health timeout and add logging for elastic agent status output 2026-07-09 11:29:43 -05:00
Jorge ReyesandGitHub 3766f74102 Merge pull request #16052 from Security-Onion-Solutions/reyesj2-patch-2
elastic fleet server persistence
2026-07-08 16:40:45 -05:00
reyesj2 c04a30785f make elastic agent state persistent to prevent re-enrollment during soup / reboots 2026-07-08 16:15:02 -05:00
Jason ErtelandGitHub ca4d22a5fe Merge pull request #16050 from Security-Onion-Solutions/jertel/wip
restart soc after re-initing db
2026-07-08 16:07:13 -04:00
Jorge ReyesandGitHub ea199aee55 Merge pull request #16049 from Security-Onion-Solutions/reyesj2-patch-1
add missing so-logs-soc annotation
2026-07-08 14:09:17 -05:00
reyesj2 5a57bbe4de add missing so-logs-soc annotation 2026-07-08 14:03:31 -05:00
Jason Ertel 1f44e98681 restart soc after re-initing db 2026-07-08 14:34:27 -04:00
Jorge ReyesandGitHub 9a313d1966 Merge pull request #16045 from Security-Onion-Solutions/reyesj2/eahealth
verify elastic-agent reports healthy status before completing install…
2026-07-08 11:45:54 -05:00
reyesj2 85d7f6bebc independently download so-elastic-agent installer 2026-07-08 08:19:15 -05:00
reyesj2 2a4a7307f7 uninstall agent after downloading new one and getting gridtoken 2026-07-08 07:51:13 -05:00
reyesj2 f8de176f4b uninstall agent on final failed attempt 2026-07-08 07:47:47 -05:00
Jason ErtelandGitHub dffe0d3780 Merge pull request #16046 from Security-Onion-Solutions/jertel/wip
recollate databases
2026-07-08 07:05:37 -04:00
Jason Ertel d131d167de provide explanation text 2026-07-08 07:00:38 -04:00
Jason Ertel 8a8f2c4a33 change order to recollate first 2026-07-07 22:22:00 -04:00
Jason Ertel 7f6014096b recollate db 2026-07-07 22:01:27 -04:00
reyesj2 70af3cec53 avoid using 'failure' until all loops are done so so-verify doesn't flag it 2026-07-07 16:48:10 -05:00
reyesj2 57b7d59387 verify elastic-agent reports healthy status before completing installation 2026-07-07 16:42:47 -05:00
Jason Ertel ef83450107 recollate 2026-07-07 16:54:06 -04:00
coreyogburnandGitHub 032d792331 Merge pull request #16030 from Security-Onion-Solutions/feature/agentic
Feature/agentic
2026-07-07 14:37:52 -06:00
Josh Patterson 66a1141b84 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-07 09:02:15 -04:00
Josh BrowerandGitHub 0cac761edc Merge pull request #16041 from Security-Onion-Solutions/playbook-reponames
Add repo names
2026-07-07 13:53:16 +02:00
Josh Brower db91ce981d Add repo names 2026-07-07 07:49:11 -04:00
Mike ReevesandGitHub bd8e5a63db Merge pull request #16039 from Security-Onion-Solutions/TOoSmOotH-patch-1
Add cluster_health option to telegraf.conf
2026-07-06 17:29:04 -04:00
Mike ReevesandGitHub 18212cad0d Add cluster_health option to telegraf.conf
Enable cluster health monitoring in Telegraf configuration.
2026-07-06 17:27:37 -04:00
Josh BrowerandGitHub 9975d36b4f Merge pull request #16019 from Security-Onion-Solutions/feature/sigma-playbooks
support sigma playbooks
2026-07-06 13:17:54 +02:00
Jorge ReyesandGitHub 8e9e221196 Merge pull request #16035 from Security-Onion-Solutions/reyesj2/huntquery
update default hunt query
2026-07-02 14:50:59 -05:00
Josh Brower 1fe7726aff Changes from feedback 2026-07-02 14:58:48 -04:00
Corey Ogburn 83cf1f0793 New Client Params for Tool Retries 2026-07-02 10:11:52 -06:00
Josh Patterson 3310e19ee4 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-02 10:27:54 -04:00
Jason ErtelandGitHub 07d6b2cfdd Merge pull request #16033 from Security-Onion-Solutions/jertel/wip
avoid setup failure reason ambiguity
2026-07-02 09:20:48 -04:00
Jason ErtelandGitHub 89afea876a Merge branch '3/dev' into jertel/wip 2026-07-02 09:04:57 -04:00
Jason Ertel 1243a25bd3 avoid setup failure reason ambiguity 2026-07-02 08:59:52 -04:00
Corey Ogburn 8675296393 More Agentic Fields
The big agentic switch, a specific maxDelegationDepth, and the agentMapping dict
2026-07-01 15:04:42 -06:00
Matthew WrightandCorey Ogburn 23f04e2866 maxSubSessionTokens and maxDelegationDepth config settings 2026-07-01 15:02:21 -06:00
Josh PattersonandGitHub 76f6947f36 Merge pull request #16029 from Security-Onion-Solutions/surirulereload
only reload suricata rules if all-rulesets.rules exists
2026-07-01 16:54:02 -04:00
Jorge ReyesandGitHub 92a55386c6 Merge pull request #16028 from Security-Onion-Solutions/reyesj2-patch-6
duplicate repo name in so-repo-sync
2026-07-01 15:50:54 -05:00
reyesj2 e7352eb841 duplicate repo name in so-repo-sync 2026-07-01 15:17:55 -05:00
Josh Patterson 795aa898a3 suricata: only reload rules once the ruleset file exists
On a fresh install the surirulesync file.recurse creates .gitkeep before
SOC has generated all-rulesets.rules. That change satisfied the
surirulereload onchanges requisite, so the reload ran with no ruleset
present, failed to stat the file, and reported the state (and install)
as failed.

Add an onlyif guard so the reload only runs when all-rulesets.rules
exists. A .gitkeep-only sync now leaves the state a clean success
(onlyif condition false); once SOC writes the ruleset, the reload fires
normally.
2026-07-01 15:12:54 -04:00
Josh Patterson 69d77382f1 suricata: timestamp each line of reload log output
Route the reload/verify output (ours plus so-common's retry/fail lines)
through a synchronous timestamping pipeline so every line in reload.log
is prefixed with a date/time, and preserve the real exit code via
PIPESTATUS.
2026-07-01 15:12:53 -04:00
Jorge ReyesandGitHub dc9b4f3ce5 Merge pull request #16027 from Security-Onion-Solutions/reyesj2-patch-6
increase wait_for_so-kibana timeout to 10m
2026-07-01 13:48:10 -05:00
reyesj2 87b9276c79 increase wait_for_so-kibana timeout to 10m 2026-07-01 13:19:47 -05:00
Jorge ReyesandGitHub 99118f9bed Merge pull request #16023 from Security-Onion-Solutions/reyesj2/uekairgap
update airgap soup to sync uek repo from iso and retain latest packag…
2026-07-01 13:14:55 -05:00
reyesj2 24b75b4a2b typo 2026-07-01 12:50:23 -05:00
Jorge ReyesandGitHub 395bd627f1 Merge pull request #16024 from Security-Onion-Solutions/reyesj2/fixsearch
remove outdated eval script and associated salt utility state
2026-07-01 11:59:00 -05:00
reyesj2 868b217549 update default hunt query 2026-07-01 11:37:46 -05:00
reyesj2 c33db9d00f remove outdated eval script and associated salt utility state 2026-07-01 11:12:39 -05:00
reyesj2 e88eb65a44 keep old packages for rollback ability 2026-07-01 10:29:05 -05:00
reyesj2 dc8c80633b update airgap soup to sync uek repo from iso and retain latest packages only 2026-07-01 10:23:04 -05:00
Josh Patterson f441d98e71 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-07-01 10:34:56 -04:00
Josh PattersonandGitHub 895aa18486 Merge pull request #16021 from Security-Onion-Solutions/surirulereload
suricata: verify reloaded ruleset is newer than the rules file
2026-07-01 10:33:14 -04:00
Josh Brower 2a6cc58306 Simplify mappings 2026-07-01 09:07:02 -04:00
Josh Patterson ee36f5f84c suricata: verify reloaded ruleset is newer than the rules file
Treating an in-progress reload as instant success could report success
while Suricata was still running a stale ruleset (the in-flight reload
may have started before the new all-rulesets.rules was written).

Make success conditional on Suricata actually having loaded the current
ruleset: capture the rules-file mtime up front, trigger a blocking
reload-rules, then query ruleset-reload-time and only succeed when
last_reload >= mtime. An in-progress reload now retries (waits for it to
clear so our own fresh reload runs) instead of short-circuiting, and a
ruleset that never catches up within the retry window fails via fail().

Also drop the redundant ruleset-reload-nonblocking call (the verified
blocking reload is authoritative and the async call was what left a
reload running) and log human-readable timestamps.
2026-07-01 09:00:36 -04:00
Josh Brower 9217670bab support sigma playbooks 2026-06-30 16:21:01 -04:00
Jorge ReyesandGitHub a3f586cf88 Merge pull request #16018 from Security-Onion-Solutions/reyesj2/kf 2026-06-30 14:46:22 -05:00
reyesj2 670d2b2757 casing 2026-06-30 12:57:56 -05:00
reyesj2 3b8459c6ec soup upgrade kafka cluster metadata v4 2026-06-30 12:43:42 -05:00
Josh Patterson 52574e21c6 suricata: treat in-progress rule reload as success
so-suricata-reload-rules failed the surirulereload state when a rule
reload was already running: suricatasc returns
{"message":"Reload already in progress","return":"NOK"}, which never
matched the expected output, so retry looped all 60 attempts (~3 min)
and called fail.

Wrap the suricatasc calls so an in-progress reload is treated as
success (the in-flight reload picks up the new rules) while genuine
container-not-ready conditions still retry and ultimately fail.
2026-06-30 09:40:23 -04:00
Josh Patterson a330bea25e Rename push-detection beacons to clearer names
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.
2026-06-29 14:29:07 -04:00
Josh Patterson 33c24cd136 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-26 15:42:56 -04:00
Josh Patterson 12f4447875 Replace inotify rule-watch beacon with poll-based rules_db beacon
Salt's stock inotify beacon leaks one kernel inotify instance every time
the minion rebuilds the beacon loader's __context__ (the orphaned
pyinotify.Notifier is never stopped), accumulating against
fs.inotify.max_user_instances=128 until inotify_init() fails with EMFILE
and rule-change push detection silently stops. This is independent of
disable_during_state_run.

Add a custom poll-based beacon (salt/_beacons/rules_db.py) modeled on
pillar_db.py: it fingerprints the suricata/strelka rule dirs each interval
(relpath + mtime_ns + size, temp files excluded) against a per-dir
watermark, emitting an event only on change. It holds zero inotify
instances, so the leak is impossible, and it keeps firing during state
runs. Swap the inotify beacon config and reactor tag mappings accordingly;
the push_suricata/push_strelka reactors are unchanged (they read only
data['path']).
2026-06-26 15:40:32 -04:00
Jorge ReyesandGitHub 576c7bfedd Merge pull request #16013 from Security-Onion-Solutions/reyesj2/so-start
update so-stop | so-start | so-restart scripts
2026-06-26 13:47:09 -05:00
reyesj2 b3b7ecdded update so-stop | so-start | so-restart scripts 2026-06-26 13:19:18 -05:00
Mike ReevesandGitHub 0af020b6c3 Merge pull request #16012 from Security-Onion-Solutions/kernel
Serve /kernelrepo through nginx so minions can reach the kernel repo
2026-06-26 12:04:33 -04:00
Mike Reeves 339a5af4a3 Serve /kernelrepo through nginx so minions can reach the kernel repo
The /nsm/kernelrepo bind mount exposed the files, but without a matching
location block external requests to /kernelrepo/ fell through to the SOC
app and returned HTML, so minions hit 'repomd.xml parser error'. Add a
/kernelrepo/ location mirroring /repo/.
2026-06-26 12:02:49 -04:00
Josh Patterson da94788255 Move highstate_interval_hours to salt.schedule and split schedule.sls
highstate_interval_hours describes the per-minion highstate schedule, not the
active-push pipeline, so relocate it from salt.auto_apply to a new salt.schedule
settings subtree. Repoint so-salt-minion-check at the new pillar path (it had
been left on the stale global:push path) so its restart grace period tracks the
schedule again.

- Add salt.schedule.highstate_interval_hours to defaults.yaml/soc_salt.yaml and a
  side-effect-free salt/salt/schedule.map.jinja (SCHEDULEMERGED), matching the
  *MERGED map convention. Consumers read SCHEDULEMERGED.highstate_interval_hours.
- Split salt/schedule.sls into salt/salt/highstate_schedule.sls (every minion) and
  salt/salt/push_drain_schedule.sls (managers); update top.sls to apply the
  highstate schedule via '*' and the drainer schedule via the configured-manager
  block. Remove the now-empty schedule.sls aggregator.
- pillar_push_map.yaml and so-push-drainer: comment/doc updates only.
2026-06-26 10:51:57 -04:00
Mike ReevesandGitHub 7952c274c4 Merge pull request #16011 from Security-Onion-Solutions/kernel
Switch boot default to UEK8 kernel and gate kernel repo on salt version
2026-06-26 09:27:01 -04:00
Mike Reeves 67a9abadf2 Gate so_kernel_repo on running salt matching the shipped version
During soup the grid is mid-salt-upgrade. Only assign the UEK8 kernel
repo once the node's grains.saltversion matches salt.minion.version from
minion.defaults.yaml, so the kernel repo and the update it enables don't
activate until the node is fully on the target salt.
2026-06-26 09:21:11 -04:00
Mike Reeves 94f31e1356 Add so-kernel-upgrade to switch the boot default to the UEK8 kernel
Installing kernel-uek-core adds a UEK8 (6.x) boot entry but doesn't make
it the default, because grubby only auto-promotes within the running
kernel's flavor lineage and we cross from a 5.x kernel to the new UEK8
flavor. so-kernel-upgrade finds the newest installed 6.x UEK kernel and
grubby --set-default's it (idempotent, verifies the change, no reboot).
2026-06-26 09:21:11 -04:00
Josh BrowerandGitHub 435e2b4182 Merge pull request #16009 from Security-Onion-Solutions/zeek-communityid
Set transport for ssl.established:false logs
2026-06-25 21:04:27 +02:00
Josh Brower d0edfd2131 set transport for ssl.established:false logs 2026-06-25 14:18:43 -04:00
Mike ReevesandGitHub 13ebde61bd Merge pull request #16000 from Security-Onion-Solutions/kernel
Add UEK8 kernel repo support across install and grid
2026-06-25 14:15:58 -04:00
Josh Patterson fa2ae1b87f Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-25 11:45:03 -04:00
Josh Patterson 5bf9751adf do not disable during state run 2026-06-25 11:44:38 -04:00
Josh Patterson 3effdbc91e do not disable during state run 2026-06-25 11:36:52 -04:00
Jason ErtelandGitHub 30312b93a6 Merge pull request #16008 from Security-Onion-Solutions/jertel/wip
support multiple capinfos versions
2026-06-25 10:19:56 -04:00
Jason Ertel a9c03e39bb support multiple capinfos versions 2026-06-25 09:32:08 -04:00
Josh Patterson 8836529496 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-25 08:13:32 -04:00
Josh Patterson b09c3776b7 Point pillar_db beacon at securityonion database
The SOC postgres database was renamed so_soc -> securityonion (see
POSTGRES_DB in salt/postgres/enabled.sls and the SOC postgres config in
salt/soc/defaults.yaml). The pillar_db beacon still hardcoded so_soc, so
every poll failed with 'database "so_soc" does not exist' (rc=2),
silently disabling active-push detection of audit_settings changes.

Update DATABASE to 'securityonion' and refresh the now-stale so_soc
references in the beacon and push_pillar reactor comments.
2026-06-24 16:51:32 -04:00
Josh Patterson dfdb1fbaeb Move global.push config to salt.auto_apply
The active-push tunables (enabled, highstate_interval_hours, debounce_seconds,
drain_interval, batch, batch_wait) described how Salt auto-applies changes, not
general grid config, so relocate them from the global namespace to a new
salt.auto_apply settings module.

- Add salt/salt/{defaults.yaml,auto_apply.map.jinja,soc_salt.yaml,adv_salt.yaml}.
  auto_apply.map.jinja is a dedicated, side-effect-free merge map (the existing
  salt/salt/map.jinja dereferences pillar.host.mainint at import time).
- Remove the push blocks from salt/global/{defaults,soc_global}.yaml.
- Register salt.soc_salt/salt.adv_salt in pillar/top.sls; seed the local pillar
  stubs for fresh installs (make_some_dirs) and upgrades (ensure_salt_local_pillar
  in soup, wired into up_to_3.2.0).
- Repoint all consumers: GLOBALMERGED.push.* -> AUTOAPPLY.* (schedule, salt
  master, manager beacons, beacons_pushstate, orch.push_batch) and
  pillar.get('global:push...') -> 'salt:auto_apply...' (push reactors,
  so-push-drainer).
- Add a salt: fleetwide-highstate entry to pillar_push_map.yaml so edits keep
  applying immediately, matching the prior global-namespace behavior.
2026-06-24 15:17:48 -04:00
Dan MarrandGitHub 4d34470b84 Merge pull request #16005 from triggerman86/triggerman-fix-root_check-so-soup
Fix premature fail_setup function call in so-setup
2026-06-24 13:41:53 -04:00
Mike Reeves b0b022c3ad Seed an empty /nsm/kernelrepo so the manager repo is always valid
so-repo-sync only populates /nsm/kernelrepo after the highstate, so on a
manager the file:///nsm/kernelrepo repo could be assigned before any
repodata exists, failing every dnf op. Run createrepo on the dir when
repodata/repomd.xml is missing, leaving a synced repo untouched.
2026-06-24 13:23:25 -04:00
Mike Reeves 27c1c35e62 Mark kernel repo skip_if_unavailable so an empty repo can't brick dnf
When the kernel repo is assigned but /nsm/kernelrepo isn't populated
yet, its missing repomd.xml makes every dnf/pkg operation fail (e.g.
pkg.held for salt during highstate). The kernel repo is supplementary,
so set skip_if_unavailable=1 in both the salt-managed client repo and
the four install-time bootstrap repo files; dnf ignores it until it is
populated instead of aborting. The main repo stays strict.
2026-06-24 13:20:10 -04:00
Mike Reeves f45631af3a Guard kernel reposync on its config section existing
During soup, so-repo-sync runs before the highstate deploys the new
repodownload.conf. On the first upgrade to a kernel-aware version the
on-disk config lacks the [securityonionkernel] section, so dnf aborts
with "Unknown repo: 'securityonionkernel'" (set -e kills soup). Guard
the kernel reposync on the section being present; the next sync after
the highstate deploys it picks it up.
2026-06-24 12:15:10 -04:00
Josh Patterson 61aa963a2d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-24 08:10:27 -04:00
Jorge ReyesandGitHub 81c8d54589 Merge pull request #16006 from Security-Onion-Solutions/reyesj2-patch-5
remove heayvnode FleetServer_* directory creation, and skip empty dir…
2026-06-23 15:53:34 -05:00
reyesj2 4f3b57f495 remove duplicate package-upgrade attempts, upgrade only when reported latest version differs from installed version 2026-06-23 15:52:10 -05:00
reyesj2 84228a819b remove heayvnode FleetServer_* directory creation, and skip empty directories during FleetServer policy management 2026-06-23 15:30:49 -05:00
Dan Marr 81ebea0451 Fix non-root exit checks at start of so-setup 2026-06-23 16:07:30 -04:00
Mike Reeves 8e2753aeb8 Fix duplicate securityonionkernel repo definition
The install bootstrap appended the [securityonionkernel] section to the
shared /etc/yum.repos.d/securityonion.repo, but the salt state so_kernel_repo
(name securityonionkernel) manages its own canonical file
/etc/yum.repos.d/securityonionkernel.repo. At highstate both files defined the
same repo id, so dnf failed with "repository securityonionkernel is listed
more than 1 time".

Write the bootstrap kernel repo to /etc/yum.repos.d/securityonionkernel.repo
in all four securityonion_repo() branches so the id lives in exactly one file
and salt edits it in place. Mirrors how the main repo's runtime id matches its
file name.
2026-06-23 13:53:14 -04:00
Mike Reeves 698a746d6d Add UEK8 kernel repo support across install and grid
Mirror the kernel repo to full parity with the main package repo so the
grid can pull the Oracle UEK8 kernel:

- setup/so-functions: securityonion_repo() emits a [securityonionkernel]
  section in every branch (mirrorlist on non-airgap, https://$MSRV/kernelrepo
  for airgap/minion, file:///nsm/kernelrepo/ for manager); repo_sync_local()
  and create_repo() sync and build /nsm/kernelrepo.
- manager/init.sls: create /nsm/kernelrepo and deploy mirror-kernel.txt.
- nginx/enabled.sls: serve /nsm/kernelrepo at https://<repo_host>/kernelrepo.
- repo/client/oracle.sls: add so_kernel_repo, gated by
  onlyif test -e /opt/so/state/nic_names_pinned so the kernel repo is only
  assigned once NICs are pinned by MAC.
- update_packages(): run so-nic-pin before the dnf update that pulls the
  kernel, freezing interface names and dropping the pin marker so the kernel
  isn't downgraded then re-upgraded on the first highstate.
2026-06-23 13:19:56 -04:00
Josh Patterson d71e80cf66 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-23 10:32:32 -04:00
Josh PattersonandGitHub a9f9d8bd0d Merge pull request #15985 from Security-Onion-Solutions/soupmod2
allow manager two full highstates during soup, improve elastic script runtime
2026-06-22 17:02:02 -04:00
Jason ErtelandGitHub 953fdee3af Merge pull request #15984 from Security-Onion-Solutions/jertel/wip
Upgrade registry
2026-06-22 16:56:18 -04:00
Jason Ertel e2e3e690ca reset version 2026-06-22 16:52:29 -04:00
Josh PattersonandGitHub 323491f58e Merge pull request #15983 from Security-Onion-Solutions/reyesj2-jpp
wip
2026-06-22 16:52:10 -04:00
reyesj2 96fcc0ec38 wip 2026-06-22 14:25:46 -05:00
Jason Ertel bcc60a4ae0 kilo version 2026-06-22 13:07:49 -04:00
Jason Ertel b77103aa9f upgrade registry 2026-06-22 13:01:02 -04:00
Jorge ReyesandGitHub 63a2e20698 Merge pull request #15982 from Security-Onion-Solutions/reyesj2/wip
don't create stack trace when set -e is disabled
2026-06-18 15:25:41 -05:00
reyesj2 22d5c96bd5 don't create stack trace when set -e is disabled 2026-06-18 14:56:29 -05:00
Mike ReevesandGitHub 28fdd1eb6f Merge pull request #15970 from Security-Onion-Solutions/udev
Pin NIC names by MAC via udev (run-once) from the common state
2026-06-18 14:28:09 -04:00
Josh Patterson d0bea2ebcb Restore grouped per-integration logging and retry 409s in fleet integration loader
elastic_fleet_load_integrations_dir now buffers each concurrent job's
output (header + API response) to a per-job file and prints them in
submission order after wait, restoring the readable serial-style output
while keeping concurrent writes.

Add --retry-all-errors to the integration create/update curl calls so
transient 409 conflicts from concurrent writes to the same agent policy
are retried (curl --retry alone does not retry 409).
2026-06-18 11:19:36 -04:00
Josh Patterson 62c01a9756 Merge remote-tracking branch 'origin/3/dev' into soupmod2 2026-06-18 09:53:44 -04:00
Jorge ReyesandGitHub b143e1e577 Merge pull request #15979 from Security-Onion-Solutions/reyesj2/wip
add context to soup errors
2026-06-17 16:47:49 -05:00
reyesj2 16149df71f formatting 2026-06-16 18:21:28 -05:00
reyesj2 6a18f35020 add context to soup errors and optional soup debug log with xtrace output 2026-06-16 18:21:28 -05:00
Jason ErtelandGitHub aa58225e8f Merge pull request #15974 from Security-Onion-Solutions/jertel/wip
es|ql defaults
2026-06-16 14:27:54 -04:00
Josh Patterson 8e33d0e1e9 Merge remote-tracking branch 'origin/3/dev' into soupmod2 2026-06-16 12:54:18 -04:00
Jorge ReyesandGitHub acf48db915 Merge pull request #15978 from Security-Onion-Solutions/reyesj2-patch-1
remove pillar merge
2026-06-16 11:17:56 -05:00
reyesj2 3daed551df use --fail flag without set -x, since elasticsearch can return a 404 on the template lookup 2026-06-16 11:17:04 -05:00
reyesj2 4456bde1c8 check if template exists without --fail flag 2026-06-16 10:45:53 -05:00
Jorge ReyesandGitHub 4a6c675223 skip kibana backport if the template doesn't exist 2026-06-16 10:33:11 -05:00
reyesj2 a769d4c680 another unneeded default 2026-06-16 09:32:37 -05:00
reyesj2 f68e3e47a1 remove pillar merge 2026-06-16 09:19:10 -05:00
Jorge ReyesandGitHub b81257bf45 Merge pull request #15973 from Security-Onion-Solutions/reyesj2/dlm-support
Data stream lifecycle management support
2026-06-15 14:47:51 -05:00
reyesj2 1a423a2434 update message 2026-06-15 14:17:34 -05:00
reyesj2 95cae4c734 remove so-elasticsearch-indices-delete cron when using DLM 2026-06-15 13:32:45 -05:00
reyesj2 596471e140 using new annotation config 2026-06-15 13:31:53 -05:00
reyesj2 d10f21399c remove comments 2026-06-15 13:31:23 -05:00
Jason Ertel ae1ddf3817 es|ql defaults 2026-06-15 12:33:08 -04:00
Josh BrowerandGitHub ea73216f4e Merge pull request #15971 from Security-Onion-Solutions/delta
userid vs names
2026-06-15 15:28:03 +02:00
Josh Patterson 1ee555957a Speed up so-elastic-fleet-integration-upgrade
Fetch each agent policy once and extract integration name/package/version/id
locally via a single jq pass instead of re-fetching the identical policy JSON
1+3N times. Memoize epm/packages latest-version lookups so each package is
queried once instead of per (policy, integration). Dispatch the per-integration
dry-run+upgrade as throttled background jobs (MAX_FLEET_JOBS) with
flock-serialized output and a FAIL_FILE marker, mirroring
elastic_fleet_load_integrations_dir.

Behavior preserved: same elastic-defend-endpoints/fleet_server skips, same
AUTO_UPGRADE_INTEGRATIONS default-package gating (moved into jq, using $defaults
to avoid the jq $def keyword collision), and exit 1 on any failure so salt
retries.
2026-06-12 15:23:43 -04:00
Josh Patterson 43f72c1f9f Parallelize so-elasticsearch-templates-load template PUTs
Load component and index templates as throttled background jobs (max 10
concurrent) instead of sequential curl PUTs, matching the bounded-concurrency
+ flock-serialized-output pattern used by the fleet/ILM load scripts. Keeps a
wait barrier between the component phase and the index phase so index
templates never load before their referenced component templates. Failures are
tracked via per-job marker files since counter increments can't escape
background subshells.
2026-06-12 15:11:34 -04:00
Josh Brower 9031c1fd22 userid vs names 2026-06-12 11:18:59 -04:00
Josh Patterson ae6a705ce1 Speed up so-elastic-fleet-integration-policy-load
Fetch each agent policy once per group instead of refetching the full
policy (plus a fresh Kibana session cookie) for every integration file,
and dispatch the create/update writes as throttled background jobs.

Adds elastic_fleet_load_integrations_dir and elastic_fleet_throttle to
so-elastic-fleet-common, reusing the bounded-concurrency pattern from
so-elasticsearch-ilm-policy-load. Replaces the four serial loops in the
loader with one call per agent policy.
2026-06-12 09:38:41 -04:00
Mike Reeves 80c39d612c Pin NIC names by MAC via udev (run-once) from the common state
Add so-nic-pin, which writes by-MAC persistent-net udev rules pinning each
physical NIC to its current name so a kernel upgrade can't renumber the
interfaces Security Onion binds by name (host:mainint, sensor:mainint, bond0).

Gated by the drop file /opt/so/state/nic_names_pinned: run-once on highstate,
and an admin can pre-create the marker to opt out. Wired into common/init.sls
as pin_nic_names, guarded by a matching unless.
2026-06-11 18:40:43 -04:00
reyesj2 c505160480 set default DLM retention 90d 2026-06-11 15:13:28 -05:00
reyesj2 d9f6cde4e1 remove global setting from data_retention annotation 2026-06-11 15:11:29 -05:00
Josh Patterson b1273573ed Fix jq $def keyword collision in optional-integrations-load
The agent-policy enumeration passed --argjson def, creating a jq
variable $def. 'def' is a reserved keyword in jq and the deployed jq
version rejects it, so the program failed to compile and
in_use_integrations was left empty (silently disabling the in-use
upgrade guard). Rename the arg to $defaults.
2026-06-11 15:50:53 -04:00
Josh Patterson 6c42c419e2 Serialize ILM policy-load output with flock to stop interleaving
A single printf per block was not actually one write() call, so
concurrent jobs still occasionally interleaved their label and response
lines. Hold an flock around just the printf (curl still runs in
parallel) so each policy's block prints intact, keeping live
completion-order streaming.
2026-06-11 15:42:41 -04:00
Jorge ReyesandGitHub f03f0155f4 Merge pull request #15966 from Security-Onion-Solutions/reyesj2-patch-8
update so-elastic-fleet-package-upgrade script
2026-06-11 14:36:03 -05:00
Josh Patterson f23652397c Speed up so-elastic-fleet-optional-integrations-load decision logic
Replace the per-package decision loop (which forked ~10 processes per
package and rebuilt a growing JSON file on every add -> O(n^2)) with two
jq passes: one prints the status messages, one builds the bulk install
list. A vnum/needs() jq definition reproduces the previous
version_conversion/compare_versions and excluded/subscription/installed/
upgrade/in-use logic exactly. Also fetch each agent policy once and
extract non-default package names locally instead of re-fetching the
policy per integration (1+K -> 1 GET per policy). Install behavior is
unchanged.
2026-06-11 13:57:56 -04:00
Josh Patterson 07d3b148b5 fix output 2026-06-11 13:37:26 -04:00
Josh Patterson 780d9faf0d Parallelize so-elasticsearch-ilm-policy-load PUTs
Run the ~300 ILM policy PUTs concurrently (bounded to 10 in flight via a
throttle gate) instead of one serial curl per policy. Adds a put_policy
helper and waits for all background jobs before exiting. Preserves policy
parity; only the scheduling changes. Drops the dead empty sid cookie arg
(falls back to basic auth from curl.config as before).
2026-06-11 12:08:32 -04:00
Josh Patterson d2fe51d5fe Merge remote-tracking branch 'origin/3/dev' into soupmod2 2026-06-11 09:26:14 -04:00
Jason ErtelandGitHub 0cc94980af Merge pull request #15967 from Security-Onion-Solutions/jertel/wip
Jertel/wip
2026-06-11 08:22:14 -04:00
Jason Ertel b8bf684077 ver 2026-06-11 08:18:38 -04:00
Jason Ertel f083db67e4 disable telemetry for automated tests 2026-06-11 08:17:39 -04:00
reyesj2 4741cc92bd fleet manager start kibana if it isn't already running and wait for healthly status 2026-06-10 17:52:08 -05:00
reyesj2 46655860e9 http 2026-06-10 17:27:23 -05:00
reyesj2 289ddda5e8 kibana health check for fleet scripts 2026-06-10 17:06:22 -05:00
Josh Patterson 83aaa76f98 allow full highstate on manager when locked 2026-06-10 16:34:10 -04:00
reyesj2 f905afbc6f logging 2026-06-10 15:01:22 -05:00
reyesj2 bd5e77afc5 increase delay in so-elastic-fleet-package-upgrade attempts 2026-06-10 14:59:29 -05:00
reyesj2 944e773759 save exit until all packages have been attempted 2026-06-10 14:58:49 -05:00
Josh Patterson 33a116357d Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-06-10 08:56:17 -04:00
reyesj2 cf456dc58c reuse existing index templates 2026-06-09 23:21:43 -05:00
reyesj2 9aa9ea3255 Iniitial DLM support 2026-06-09 23:19:26 -05:00
Josh PattersonandGitHub 3ba96da3b7 Merge pull request #15965 from Security-Onion-Solutions/nostartupstates
remove startup states from salt config
2026-06-09 16:26:47 -04:00
Jorge ReyesandGitHub f0712bd780 Merge pull request #15964 from Security-Onion-Solutions/reyesj2-patch-8
use pipe exit status for update_docker_containers
2026-06-09 13:49:24 -05:00
Josh Patterson 448668a72e Merge remote-tracking branch 'origin/3/dev' into nostartupstates 2026-06-09 14:02:00 -04:00
Josh Patterson f088a27159 so-boot-mine-update: warm master pillar cache before highstate
A complete mine is not enough: elasticsearch:nodes, redis:nodes,
logstash:nodes (tgt_type=pillar) and hypervisor:nodes (tgt_type=compound)
resolve their target against the master's per-minion data cache
(grains+pillar in data.p), which is populated only when a minion's pillar
is recompiled -- separately from the mine. After a reboot a node can be in
the mine (so node_data/glob sees it) yet absent from that cache, so it
fails the elasticsearch:enabled:true pillar match and is dropped from
elasticsearch:nodes -> so-elasticsearch ExtraHosts -> container recreate.

After the mine-completeness wait, run salt '*' saltutil.refresh_pillar
wait=True to synchronously cache every up node's pillar (the same lever
deploy_newnode.sls uses), then verify with salt-run cache.pillar and retry
stragglers, bounded by MINE_UPDATE_MAX_WAIT. Also log elasticsearch:nodes
alongside node_data for inspection.
2026-06-09 13:52:19 -04:00
reyesj2 9f5a9616a5 use pipe exit status for update_docker_containers 2026-06-09 12:51:58 -05:00
Josh Patterson 27c7702325 so-boot-mine-update: wait for a complete mine before highstate
Mine-backed pillars (node_data, elasticsearch:nodes, redis:nodes,
logstash:nodes, hypervisor:nodes) include a node only if it returned an
IP from the mine, and the configs they build are rebuilt fresh every
highstate. After a manager reboot with a flushed mine, the first boot
highstate could run before an up node re-reported network.ip_addrs,
dropping it from e.g. so-elasticsearch ExtraHosts and forcing a
container recreate.

After the initial broad mine.update, poll until every currently-up
minion actually has network.ip_addrs in the mine, re-pushing mine.update
to stragglers, before releasing the boot highstate. Shares the existing
MINE_UPDATE_MAX_WAIT backstop so a slow/down node never blocks boot, and
still logs the rendered node_data for inspection.
2026-06-09 10:10:32 -04:00
Josh Patterson 8c306eb37d so-boot-mine-update: log the rendered node_data content
Dump the actual rendered node_data pillar (pretty-printed JSON) to the
journal instead of just a rendered/empty verdict, so the boot-time render
attempt is fully inspectable. Empty renders print false/null and still
emit the WARNING.
2026-06-09 09:49:19 -04:00
Josh Patterson e536ffa363 so-boot-mine-update: render node_data after mine.update before highstate
After the boot-time mine.update, have the manager actually render the
node_data pillar and log whether it came back populated. node_data: False
makes salt/top.sls apply the bootstrap recovery branch instead of the
manager's real config, so surfacing this in the journal makes the
condition visible before so-boot-highstate runs. Best-effort and
non-blocking: always exits 0 so highstate proceeds regardless.
2026-06-09 09:35:24 -04:00
Jason Ertel eb82f9ea9d kilo version 2026-06-08 16:53:35 -04:00
Jorge ReyesandGitHub d7aa7ab228 Merge pull request #15961 from Security-Onion-Solutions/reyesj2/fleet-autoconfigure
respect elasticfleet enable_auto_configuration setting for so-elastic…
2026-06-08 15:09:58 -05:00
Jorge ReyesandGitHub fe0b68d24c Merge pull request #15958 from Security-Onion-Solutions/reyesj2-patch-template
fix elasticsearch template generation issue
2026-06-08 15:07:49 -05:00
reyesj2 6ad345730b respect elasticfleet enable_auto_configuration setting for so-elastic-fleet-urls-update 2026-06-08 15:02:57 -05:00
Josh Patterson 9580976ba2 Add manager boot-time grid mine.update oneshot before highstate
so-boot-mine-update.service is a manager-only Type=oneshot unit that runs
once per boot after salt-master/salt-minion start and before
so-boot-highstate.service. It pushes mine.update to all reachable minions
so mine-backed pillars (node IPs, ES/Redis/Logstash discovery) are fresh
before the boot highstate renders them.

The helper waits for the responsive minion set to settle (plateau) rather
than for every accepted key to report up, so an intentionally powered-off
minion doesn't block the update; MAX_WAIT remains as a backstop.
2026-06-08 11:05:13 -04:00
reyesj2 ac907ba45f fix elasticsearch template generation issue 2026-06-05 16:42:08 -05:00
Josh PattersonandGitHub f957954abf Merge pull request #15956 from Security-Onion-Solutions/nostartupstates
higstate on host start, not salt-minion start
2026-06-04 16:51:10 -04:00
Josh Patterson cb3631da81 Move setup-complete marker from /opt/so/conf to /opt/so/state
The setup-complete marker is a runtime-state file, not config, so move it
to /opt/so/state/setup-complete. Updates both writers (mark_setup_complete
in setup/so-functions and the upgrade-path state in minion/init.sls) and the
three readers (so-boot-highstate.service ConditionPathExists, boot_highstate.sls
enable gate, and the so-user_sync cron gate).
2026-06-04 15:07:27 -04:00
Josh Patterson f5d63f585e Merge remote-tracking branch 'origin/3/dev' into nostartupstates 2026-06-04 09:19:01 -04:00
Josh Patterson 13f8be40b5 so-boot-highstate: wait for docker before running highstate
Add docker.service to After= and Wants= so the boot-time highstate
starts after docker is up. Uses Wants (soft) so highstate still runs
if docker fails to start.
2026-06-04 08:46:35 -04:00
Jason ErtelandGitHub 9ee90a5bc0 Merge pull request #15955 from Security-Onion-Solutions/jertel/wip
config updates
2026-06-03 17:26:51 -04:00
Jason Ertel ca85c5d900 fix version 2026-06-03 17:26:08 -04:00
Josh Patterson 2d653b6f1b does not need to be jinja template 2026-06-03 15:46:58 -04:00
Josh Patterson 34fee25b0c Merge remote-tracking branch 'origin/3/dev' into nostartupstates 2026-06-03 15:44:41 -04:00
Jason Ertel 1d3d98f759 kilo 2026-06-03 12:24:41 -04:00
Jason Ertel a767c79641 restore soup db init 2026-06-03 10:39:37 -04:00
Jason Ertel 61e72c89e4 postgres updates 2026-06-03 09:49:53 -04:00
Jason Ertel d9fb7313f9 merge 2026-06-03 09:30:05 -04:00
Jason Ertel 7ca2313255 move to securityonion db 2026-06-03 09:05:23 -04:00
Jorge ReyesandGitHub 534f0e639d Merge pull request #15954 from Security-Onion-Solutions/reyesj2-patch-4
run elastic agent regen installer script in post_to_3.2.0
2026-06-02 15:25:55 -05:00
reyesj2 559465b407 run elastic agent gen installers script in post_to_3.2.0 2026-06-02 15:18:00 -05:00
reyesj2 f9c2579261 remove logstash pipeline rename from hotfix moving to up_to_3.2.0 2026-06-02 15:18:00 -05:00
Jorge ReyesandGitHub 33699a914b Merge pull request #15952 from Security-Onion-Solutions/reyesj2-patch-3
use so-config-backup script in soup
2026-06-02 15:02:27 -05:00
Jorge ReyesandGitHub 0c2d8f8973 Merge pull request #15951 from Security-Onion-Solutions/reyesj2-patch-2
check if there is a version or hotfix to upgrade to before verifiying elasticsearch compatibility
2026-06-02 15:02:10 -05:00
Josh Patterson 8c17ae0f66 move so-salt-minion-wait 2026-06-01 14:48:54 -04:00
reyesj2 f2996fb888 use so-config-backup script in soup 2026-06-01 11:52:35 -05:00
reyesj2 3c533cccbc and after free space check 2026-06-01 11:28:59 -05:00
reyesj2 79da9f9f2c check if there is a version or hotfix to upgrade to before verifiying elasticsearch compatibility 2026-06-01 11:26:52 -05:00
Mike ReevesandGitHub 99a027589b Merge pull request #15949 from Security-Onion-Solutions/jertel/wip
fix version
2026-05-30 09:50:14 -04:00
Jason Ertel 68a82a425b fix version 2026-05-30 08:12:50 -04:00
Josh Patterson f54939b444 Replace inotify pillar watch with postgres audit_settings beacon
The active-push feature detected pillar/settings changes via an inotify
beacon on the manager watching /opt/so/saltstack/local/pillar. Replace
that pillar watch with a custom salt beacon (pillar_db) that polls the
SOC so_soc.audit_settings table on a monotonic id watermark, so changes
made through SOC drive immediate pushes from the database instead of the
files. The suricata/strelka rule inotify watches (and pyinotify) are kept
unchanged, since rule-file edits are not recorded in audit_settings.

- salt/_beacons/pillar_db.py: new beacon. Polls audit_settings via
  `docker exec so-postgres psql` (unix-socket trust auth), tracks the last
  processed id in /opt/so/state/pillar_db_watch.id, seeds to MAX(id) on
  first run (no history replay), and emits one event per new row.
- salt/reactor/push_pillar.sls: consume setting_id/node_id from the beacon
  event instead of a file path. App = first dotted segment of setting_id,
  looked up in pillar_push_map.yaml. Empty node_id -> grid-wide actions as
  is; populated node_id -> the app's state(s) retargeted to that one node.
- salt/manager/files/beacons_pushstate.conf.jinja: drop the pillar inotify
  block, add the pillar_db beacon (interval = push.drain_interval); keep
  the suricata/strelka inotify watches.
- salt/salt/files/reactor_pushstate.conf: map salt/beacon/*/pillar_db/
  audit_settings to push_pillar.sls; remove the pillar inotify reactor
  lines; keep suricata/strelka.

The intent -> so-push-drainer -> orch.push_batch pipeline is unchanged.
Verified end-to-end on a standalone: a grid-wide telegraf.output change
re-applied telegraf fleetwide (container replaced), and a per-host
ntp.config.servers change applied ntp to only that node.
2026-05-29 14:55:13 -04:00
Jason ErtelandGitHub d86a3c5cc9 Merge pull request #15947 from Security-Onion-Solutions/jertel/wip
refactored soc config
2026-05-29 14:07:06 -04:00
Jason Ertel 86edc5aaba version 2026-05-28 22:57:59 -04:00
Josh PattersonandGitHub d48a22e37e Merge pull request #15944 from Security-Onion-Solutions/jertel/wip
Jertel/wip
2026-05-28 14:01:42 -04:00
Josh Patterson 9a70a06b3b Merge remote-tracking branch 'origin/3/dev' into jertel/wip 2026-05-28 13:55:12 -04:00
Mike ReevesandGitHub 526d739b3b Merge pull request #15940 from Security-Onion-Solutions/TOoSmOotH-patch-4
Remove outdated HOTFIX version number
2026-05-28 10:25:28 -04:00
Mike ReevesandGitHub 68d783e760 Remove outdated HOTFIX version number 2026-05-28 10:24:47 -04:00
Mike ReevesandGitHub 1e9b6b0975 Merge pull request #15939 from Security-Onion-Solutions/3/main
main to dev for hotfix
2026-05-28 10:24:21 -04:00
Mike ReevesandGitHub 2131e7d450 Merge pull request #15937 from Security-Onion-Solutions/hotfix/3.1.0
Hotfix/3.1.0
2026-05-28 10:20:53 -04:00
Mike ReevesandGitHub 2a2d853ac4 Merge pull request #15936 from Security-Onion-Solutions/hotfix310
3.1.0 hotfix
2026-05-28 09:53:00 -04:00
Mike Reeves 5abd6de4b5 3.1.0 hotfix 2026-05-28 09:34:17 -04:00
Josh Patterson bb8ae91d91 fix so-soc postgres bootstrap 2026-05-27 16:39:52 -04:00
Josh Patterson 93ffce98d7 add onionconfig and postgres modules to soc config 2026-05-27 15:07:25 -04:00
Jorge ReyesandGitHub 5599cce22c Merge pull request #15934 from Security-Onion-Solutions/reyesj2-patch-1
keep logstash lumberjack pipeline name update unified
2026-05-27 13:37:41 -05:00
reyesj2 b2a82fec29 fix_logstash_0013_lumberjack_pipeline_name
Before removing from apply_hotfix function first verify that older installs < 3.1.0 are still upgradable when referencing 'so/0013_input_lumberjack_fleet.conf' via pillar. Failure to do so will prevent logstash from starting
2026-05-27 13:24:23 -05:00
reyesj2 613eca52fc update hotfix date 2026-05-27 13:24:10 -05:00
Josh Patterson 79987f3659 bootstrap so-soc db in postgres during soup 2026-05-27 13:55:30 -04:00
reyesj2 bf609a112e LF 2026-05-27 12:21:44 -05:00
reyesj2 0b4a4de609 always run logstash pipeline rename 2026-05-27 12:21:22 -05:00
Jorge ReyesandGitHub ad376d2a43 Merge pull request #15930 from Security-Onion-Solutions/reyesj2-patch-1
check for stale logstash pipeline name in local pillar
2026-05-27 10:16:39 -05:00
reyesj2 0834998cca usuable for next soup 2026-05-27 09:52:29 -05:00
reyesj2 473f93f0ee check for stale logstash pipeline name in pillars 2026-05-27 09:33:15 -05:00
Josh Patterson 16055c4d88 Merge remote-tracking branch 'origin/3/dev' into jertel/wip 2026-05-27 09:18:33 -04:00
Josh Patterson 6393d08e86 merge 2026-05-27 08:59:28 -04:00
Jorge ReyesandGitHub 7cc2e045fb Merge pull request #15925 from Security-Onion-Solutions/reyesj2/soup-heavynode
use multiple or combined input
2026-05-26 08:34:33 -05:00
Mike ReevesandGitHub 6955ee73bf Merge pull request #15924 from Security-Onion-Solutions/TOoSmOotH-patch-3
Add version number to HOTFIX file
2026-05-26 09:28:41 -04:00
Mike ReevesandGitHub c0272ddb81 Add version number to HOTFIX file 2026-05-26 09:24:10 -04:00
reyesj2 d72219c586 use multiple or combined input 2026-05-22 20:04:21 -05:00
Mike ReevesandGitHub ffd34d4e0e Merge pull request #15919 from Security-Onion-Solutions/TOoSmOotH-patch-2
Add 3.2.0 option to discussion template
2026-05-21 15:58:28 -04:00
Mike ReevesandGitHub aa78978740 Add 3.2.0 option to discussion template 2026-05-21 15:57:57 -04:00
Mike ReevesandGitHub 75d4f5e496 Merge pull request #15918 from Security-Onion-Solutions/TOoSmOotH-patch-1
Bump version from 3.1.0 to 3.2.0
2026-05-21 15:49:08 -04:00
Mike ReevesandGitHub 89a28d2cfe Bump version from 3.1.0 to 3.2.0 2026-05-21 15:45:58 -04:00
Mike ReevesandGitHub c1d187599b Merge pull request #15912 from Security-Onion-Solutions/3/dev
3.1.0
2026-05-21 15:41:50 -04:00
Mike ReevesandGitHub d87313db27 Merge pull request #15911 from Security-Onion-Solutions/3.1.0
3.1.0
2026-05-21 13:50:23 -04:00
Mike Reeves 141a61f5b5 3.1.0 2026-05-21 13:47:03 -04:00
Jorge ReyesandGitHub 901cbf03e4 Merge pull request #15907 from Security-Onion-Solutions/reyesj2/es-verify-compat
Verify compatibility for all ES nodes in the cluster
2026-05-20 14:16:41 -05:00
reyesj2 b485be4602 separate salt-key command from main es version compatiblity loop 2026-05-20 14:12:58 -05:00
reyesj2 7d13007aa9 block soup if all ES nodes are not online and reporting their ES version for compatibility check 2026-05-20 10:03:37 -05:00
reyesj2 d7a1b67095 use pipefail on heavynode versino command to pass through error 2026-05-20 09:16:57 -05:00
reyesj2 6c8997b28a verify all heavynodes and all searchnodes are at compatible ES version before attempting an elasticsearch upgrade 2026-05-19 22:27:31 -05:00
Jorge ReyesandGitHub 58f1d08ebe Merge pull request #15902 from Security-Onion-Solutions/reyesj2/ea-fleet-sync
sync elastic agent packages to fleet nodes
2026-05-19 11:08:48 -05:00
reyesj2 d0aa33a255 sync elastic agent packages to fleet nodes 2026-05-19 10:50:17 -05:00
Josh Patterson 730c828bec Merge remote-tracking branch 'origin/jertel/wip' into saltthangs 2026-05-19 10:23:45 -04:00
Jorge ReyesandGitHub 74b50f6009 Merge pull request #15899 from Security-Onion-Solutions/revert-15895-reyesj2/agentinstall
Revert "use -verify flag during grid agent install to ensure agent health"
2026-05-16 10:01:58 -05:00
Jorge ReyesandGitHub e89c820b65 Revert "use -verify flag during grid agent install to ensure agent health" 2026-05-16 09:59:14 -05:00
Jorge ReyesandGitHub 9ac05a6ad1 Merge pull request #15895 from Security-Onion-Solutions/reyesj2/agentinstall
use -verify flag during grid agent install to ensure agent health
2026-05-15 12:58:09 -05:00
Jason ErtelandGitHub 24ee3318bc Merge pull request #15898 from Security-Onion-Solutions/jertel/logcheck
exclude fps
2026-05-15 11:38:20 -04:00
Jason Ertel ce566ba174 exclude fps 2026-05-15 11:36:46 -04:00
Mike ReevesandGitHub 2635a60a8c Merge pull request #15896 from Security-Onion-Solutions/quickfixes2
Make so-postgres-backup fail-safe against silent corruption
2026-05-15 09:32:15 -04:00
Mike Reeves 244a73b7a2 Make so-postgres-backup fail-safe against silent corruption
The dump pipeline returned gzip's exit status, so a pg_dumpall that
died mid-stream still produced a valid .gz holding a truncated dump,
written straight to the final filename. The idempotency check then
blocked retries for the day and the corrupt file counted toward
retention, evicting a good backup each day until none remained.

- set -o pipefail so a failed pg_dumpall fails the pipeline
- dump to a .tmp file and atomically rename only after success, so
  the final filename appears only for a complete backup
- gzip -t integrity check before publishing
- trap-based cleanup of the temp file; sweep stale temps at startup
- run retention only after a successful backup, with a glob
  restricted to finished backups
- log timestamped OK/ERROR outcomes to /opt/so/log/postgres/backup.log
2026-05-15 08:48:54 -04:00
Jason Ertel e45ad45d73 Merge branch '3/dev' into jertel/wip 2026-05-14 18:33:40 -04:00
Mike ReevesandGitHub 1189621ec5 Merge pull request #15893 from Security-Onion-Solutions/quickfixes2 2026-05-14 18:21:30 -04:00
reyesj2 d2524a593f use -verify flag during grid agent install to ensure agent health 2026-05-14 17:12:02 -05:00
Josh BrowerandGitHub f2ab2354fd Merge pull request #15894 from Security-Onion-Solutions/3/nginx-fix
Tweak for nginx upgrade
2026-05-14 23:20:57 +02:00
Mike Reeves 64731c73ba Fix psql :var substitution in telegraf role and retention SQL
psql does not substitute :var references inside dollar-quoted strings,
so the DO blocks in the user and retention subcommands were receiving
literal colons and failing (silently for user, via hide_output: True).
Rewrite the conditional CREATE/ALTER ROLE with SELECT format(...) \\gexec
and guard the retention UPDATE with \\gset + \\if.
2026-05-14 17:17:49 -04:00
Josh Brower 024fece607 Tweak for nginx upgrade 2026-05-14 17:08:57 -04:00
Mike Reeves 249b126312 Quote telegraf role env vars to survive YAML-special chars in passwords 2026-05-14 17:08:51 -04:00
Mike Reeves 8e38bff0c3 Rename telegraf_postgres.sh to so-telegraf-postgres 2026-05-14 16:55:53 -04:00
Mike Reeves b9f2d56932 Consolidate telegraf postgres SQL into multi-mode script
Replace inline psql heredocs in telegraf_users.sls with subcommand
dispatcher telegraf_postgres.sh: create_db, group_role, user, retention.
2026-05-14 16:37:08 -04:00
Mike Reeves 03fa01a705 Move telegraf_role.sh to postgres tools/sbin 2026-05-14 16:18:01 -04:00
Mike Reeves 450eacca41 Move telegraf role provisioning to external script with env vars 2026-05-14 16:15:54 -04:00
Mike Reeves b7a13899f7 Suppress output logging for postgres telegraf role provisioning 2026-05-14 15:56:04 -04:00
Mike Reeves 6f273d7d97 Rename init-users.sh to init-db.sh and update all references 2026-05-14 15:53:00 -04:00
Josh Patterson fabecb8288 remove highstate from startup_states. highstate on system start 2026-05-14 13:57:40 -04:00
Jason Ertel 907f699721 state rename 2026-05-14 11:03:08 -04:00
Jason Ertel e7a7047f71 Merge branch '3/dev' into jertel/wip 2026-05-14 11:01:36 -04:00
Josh Patterson b4e5171415 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-14 08:03:45 -04:00
Josh BrowerandGitHub b328820c01 Merge pull request #15792 from Security-Onion-Solutions/3/strelkalnk
Fix module name
2026-05-14 13:06:26 +02:00
Jason Ertel 936295f1c4 Merge branch '3/dev' into jertel/wip 2026-05-13 17:28:25 -04:00
Jason Ertel 61ca60a94c prep for soc db config 2026-05-13 17:28:07 -04:00
Jorge ReyesandGitHub 638aca97c8 Merge pull request #15877 from Security-Onion-Solutions/reyesj2-patch-1
update redis index template
2026-05-13 13:44:04 -05:00
Jorge ReyesandGitHub 74a5c895e8 Merge pull request #15889 from Security-Onion-Solutions/reyesj2/zeek-ja4d
add zeek.ja4d ingest pipeline
2026-05-13 13:43:56 -05:00
Josh Patterson 84decc1db6 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-13 14:09:15 -04:00
reyesj2 d56bf01823 add zeek.ja4d ingest pipeline 2026-05-13 12:32:54 -05:00
Mike ReevesandGitHub d29267d9c2 Merge pull request #15888 from Security-Onion-Solutions/TOoSmOotH-patch-1
Change Telegraf output from BOTH to INFLUXDB
2026-05-13 12:47:55 -04:00
Mike ReevesandGitHub 72327285b2 Change Telegraf output from BOTH to INFLUXDB 2026-05-13 11:58:21 -04:00
Josh PattersonandGitHub cc7a237457 Merge pull request #15887 from Security-Onion-Solutions/m0duspwnens-patch-1
remove stig from hypervisor and managerhype
2026-05-13 10:57:58 -04:00
Josh PattersonandGitHub b068ad2b35 remove stig from hypervisor and managerhype 2026-05-13 10:53:11 -04:00
Jorge ReyesandGitHub b103f412b5 Merge pull request #15884 from Security-Onion-Solutions/reyesj2/strelkalnk
rename strelka ScanLNK - ScanLnk
2026-05-13 09:46:52 -05:00
reyesj2 ef79c63858 Merge branch '3/dev' of github.com:Security-Onion-Solutions/securityonion into reyesj2/strelkalnk 2026-05-12 15:20:09 -05:00
reyesj2 01fb1aa156 check pillars for ScanLNK and rename to ScanLnk 2026-05-12 15:19:44 -05:00
Doug BurksandGitHub f19bdd7aae Merge pull request #15883 from Security-Onion-Solutions/reyesj2/transformhealth
use temp files to prevent jq arg too long
2026-05-12 15:36:12 -04:00
reyesj2 f637dc62d1 use temp files to prevent jq arg too long 2026-05-12 13:29:32 -05:00
Jorge ReyesandGitHub 081f6fa1fb Merge pull request #15878 from Security-Onion-Solutions/reyesj2/es-ingest-lag
add ingest latency metrics
2026-05-12 10:21:04 -05:00
Josh BrowerandGitHub d6d90d84cd Merge pull request #15880 from Security-Onion-Solutions/feature/import-overrides
Initial commit
2026-05-12 17:00:44 +02:00
Josh Brower 125610ed42 Additional test coverage 2026-05-12 10:11:22 -04:00
Josh Brower 306b0af4d0 Initial commit 2026-05-12 09:55:06 -04:00
reyesj2 492ae80da7 add ingest latency metrics 2026-05-11 16:51:38 -05:00
Jorge ReyesandGitHub 4a2177c827 update redis index template
missing redis integration component templates
2026-05-11 16:15:56 -05:00
Josh BrowerandGitHub 006ac31109 Merge pull request #15579 from marcopedrinazzi/3/dev
New Sigma rules pipeline mapping for M365 and Fortigate
2026-05-11 21:03:53 +02:00
Josh Patterson 7d4d6a0756 prune images if so-docker-prune exists 2026-05-08 10:13:15 -04:00
Josh Patterson 66c0a662fc convert wait to script 2026-05-08 09:26:42 -04:00
Josh BrowerandGitHub 49a643fff4 Merge pull request #15875 from Security-Onion-Solutions/3/sigma-fp-os
proc_creation per OS type
2026-05-08 15:13:14 +02:00
Josh Brower e1d830da76 proc_creation per OS type 2026-05-08 09:11:24 -04:00
Josh Patterson 778cc055ea wait for salt-minion service to be ready before finishing state run 2026-05-07 17:01:20 -04:00
Josh BrowerandGitHub e847c46129 Merge pull request #15872 from Security-Onion-Solutions/3/soc-logs
cleanup status code
2026-05-07 19:01:24 +02:00
Josh Brower 499f7102bd cleanup status code 2026-05-07 11:27:49 -04:00
Josh Patterson 932deab751 update the push map 2026-05-07 10:51:53 -04:00
Josh Patterson 1281f0ee37 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-06 09:46:12 -04:00
Josh PattersonandGitHub 4bc19f91ce Merge pull request #15867 from Security-Onion-Solutions/fixhype
sanitize minion ids for hypervisor reactors / orchestration
2026-05-06 09:46:01 -04:00
Josh Patterson f774334b6c Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-05-06 08:16:41 -04:00
Mike ReevesandGitHub 4990d0ddea Merge pull request #15866 from Security-Onion-Solutions/management-bond1
Management bond1
2026-05-05 17:17:58 -04:00
Mike Reeves 3e49322220 Allow preconfigured management bond in requirements 2026-05-05 15:35:12 -04:00
Mike Reeves ecb92d43fc Limit management bond setup to ISO installs 2026-05-05 15:30:09 -04:00
Mike Reeves 3b714db0bf Show management bond option consistently 2026-05-05 15:22:40 -04:00
Mike Reeves f17da4e68b Add management bond setup option 2026-05-05 15:13:24 -04:00
Jorge ReyesandGitHub 04cfc22e3f Merge pull request #15864 from Security-Onion-Solutions/reyesj2/patch-2
update grok type conversion to convert processor
2026-05-05 13:58:39 -05:00
reyesj2 dceed421ae update grok type conversion to convert processor 2026-05-05 13:41:00 -05:00
Josh Patterson 652ac5d61f fix regex 2026-05-05 14:26:04 -04:00
Josh Patterson f888a2ba6b Merge remote-tracking branch 'origin/3/dev' into fixhype 2026-05-05 10:28:49 -04:00
Mike ReevesandGitHub 8a1ee02335 Merge pull request #15846 from Security-Onion-Solutions/feature/ensure-pyyaml
Ensure python3-pyyaml is installed before continuing setup
2026-05-05 10:24:25 -04:00
Josh Patterson 192f6cfe13 Merge remote-tracking branch 'origin/3/dev' into fixhype 2026-05-05 08:18:26 -04:00
Mike ReevesandGitHub 5bca81d833 Merge pull request #15858 from Security-Onion-Solutions/security-fix
Fix unsafe PyYAML load in filecheck
2026-05-04 16:16:40 -04:00
Josh Patterson 1c6574c694 ensure minion ids 2026-05-04 14:03:14 -04:00
Mike Reeves b701664e04 Fix unsafe PyYAML load in filecheck 2026-05-04 12:09:35 -04:00
Jorge ReyesandGitHub bc64f1431d Merge pull request #15857 from Security-Onion-Solutions/reyesj2/package-registry-health
fleet package registry health check
2026-05-04 11:05:23 -05:00
reyesj2 2203037ce7 fleet package registry health check 2026-05-04 10:52:37 -05:00
Jorge ReyesandGitHub 77a4ad877e Merge pull request #15851 from Security-Onion-Solutions/reyesj2/integration-transforms 2026-05-01 14:11:12 -05:00
reyesj2 702b3585cc excluding additional integration transform job failures 2026-05-01 12:57:59 -05:00
reyesj2 86966d2778 reauthorize unhealthy transform jobs using kibana 9.3.3 auth flow 2026-05-01 12:44:08 -05:00
Josh Patterson 7fcace34c4 add sensoroni to push map 2026-04-30 16:09:08 -04:00
Josh Patterson 9541024eb7 fix broken things 2026-04-30 15:35:24 -04:00
Jorge ReyesandGitHub ce3ad3a895 Merge pull request #15844 from Security-Onion-Solutions/reyesj2/elastic-agent-warning
update default elastic agent logging level to warning
2026-04-30 09:46:28 -05:00
Mike Reeves 3a4b7b50de ensure python3-pyyaml is installed before continuing setup 2026-04-30 10:15:09 -04:00
Josh Patterson 0d166ef732 remove trailing slashes 2026-04-30 09:53:00 -04:00
Josh Patterson f7d2994f8b filter temp files 2026-04-30 09:16:22 -04:00
reyesj2 39d0947102 update default elastic agent logging level to warning 2026-04-29 17:38:40 -05:00
Josh Patterson 8f0757606d include salt..minion 2026-04-29 16:42:19 -04:00
Josh Patterson 0a8f2e01a0 install pyinotify 2026-04-29 16:41:56 -04:00
Josh Patterson 4546d7bc52 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-29 14:28:19 -04:00
Jorge ReyesandGitHub 0085d9a353 Merge pull request #15842 from Security-Onion-Solutions/reyesj2-patch-1
so-elastic-fleet-outputs-update now checks for cert drift. Remove run…
2026-04-29 12:37:04 -05:00
Jorge ReyesandGitHub 2f01ce3b23 so-elastic-fleet-outputs-update now checks for cert drift. Remove running --cert arg on cert change to prevent highstate from running outputs-update 2x 2026-04-29 12:33:28 -05:00
Mike ReevesandGitHub 71b19c1b5f Merge pull request #15840 from Security-Onion-Solutions/fix/import-postgres-firewall
Open postgres in DOCKER-USER firewall everywhere influxdb is open
2026-04-29 09:20:03 -04:00
Mike Reeves 82e55ae87f Open postgres on every hostgroup that opens influxdb
The static defaults only listed postgres on each role's self-hostgroup,
leaving sensor/searchnode/heavynode/receiver/fleet/idh/desktop/hypervisor
hostgroups unable to reach the manager's so-postgres in distributed
grids. A dynamic block in firewall/map.jinja added postgres to those
hostgroups only when telegraf.output was switched to POSTGRES/BOTH,
which left postgres unreachable by default.

Mirror influxdb statically across manager/managerhype/managersearch/
standalone for every hostgroup that already lists influxdb, and drop
the now-redundant telegraf-gated dynamic block from firewall/map.jinja.
2026-04-29 09:09:50 -04:00
Mike Reeves 3e02001544 Open postgres port for import role in DOCKER-USER firewall
When so-postgres was wired in (868cd1187), the import role's firewall
defaults were missed while every other manager-class role (manager,
managerhype, managersearch, standalone, eval) had postgres added to
their DOCKER-USER manager-hostgroup portgroups. As a result, on a
fresh import install the so-postgres container starts but tcp/5432 is
dropped at DOCKER-USER, so soc/kratos/telegraf can't reach it.

Add postgres alongside the existing influxdb entry so import nodes
match the other roles.
2026-04-29 08:48:45 -04:00
Josh Patterson 17849d8758 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-28 15:49:22 -04:00
Mike ReevesandGitHub 82f70bb53a Merge pull request #15839 from Security-Onion-Solutions/fix/drop-postgres-soc-module-injection
drop postgres module from soc defaults injection
2026-04-28 15:48:49 -04:00
Mike Reeves 2dcded6cca drop postgres module from soc defaults injection
The soc binary on 3/dev does not register a postgres module, so injecting
postgres into soc.config.server.modules makes soc abort at launch with
'Module does not exist: postgres'. The soc-side module is staged on
feature/postgres but is not landing this release. Drop the injection
until the module ships; salt/postgres state and pillars are unchanged.
2026-04-28 15:46:56 -04:00
Josh Patterson d3d30a587c Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-28 15:30:31 -04:00
Mike ReevesandGitHub 8ca59e6f0c Merge pull request #15838 from Security-Onion-Solutions/fix/docker-refresh-multiarch-pull
Fix/docker refresh multiarch pull
2026-04-28 15:14:27 -04:00
Mike Reeves 82dac82d15 drop platform/digest pull resolution
The digest-pull logic was added to make `docker push` work for multi-arch
upstream tags. Now that the push step is `docker buildx imagetools create`
pinned to the gpg-verified RepoDigest, the registry-to-registry copy
handles single- and multi-arch sources without help. Reverts the pull
back to the original line and removes the unused PLATFORM_OS/_ARCH
detection.
2026-04-28 14:54:25 -04:00
Mike Reeves 288a823edf push images via buildx imagetools create
Replaces `docker push` with a registry-to-registry copy. On Docker 29.x
with the containerd image store, `docker push` of a freshly-pulled image
hits a path that wraps single-platform manifests in a synthetic index
and then can't push the layers it claims to reference, producing
`NotFound: content digest ...` even when the image is fully present.

Keep the local `docker tag` so so-image-pull's `docker images | grep :5000`
existence check continues to work.
2026-04-28 14:49:02 -04:00
Jorge ReyesandGitHub f9e3d30a71 Merge pull request #15837 from Security-Onion-Solutions/reyesj2/elastic-fleet-cert-check
check current fleet policy cert against cert on disk
2026-04-28 13:47:55 -05:00
reyesj2andCopilot 9cec79b299 check current fleet policy cert against cert on disk
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:34:39 -05:00
Mike Reeves c86399327b fix so-docker-refresh push for multi-arch source images
docker pull of a multi-arch tag on Docker 29.x leaves the local tag
pointing at the image index rather than the platform-specific manifest.
The subsequent docker push then tries to push every sub-manifest the
index references and fails on layers we never fetched.

Resolve the local-platform manifest digest from the upstream index via
docker buildx imagetools inspect, pull by that digest, and re-tag locally
to the canonical tag. The signing flow and the existing tag/push to the
embedded registry are unchanged.
2026-04-28 14:27:59 -04:00
Josh Patterson 034711d148 Merge remote-tracking branch 'origin/3/dev' into saltthangs 2026-04-28 10:47:29 -04:00
Josh BrowerandGitHub affede7f0a Rename 'ScanLNK' to 'ScanLnk' in YAML config 2026-04-20 10:01:10 -04:00
Josh BrowerandGitHub 97366c0496 Rename 'ScanLNK' to 'ScanLnk' in defaults.yaml 2026-04-20 10:00:29 -04:00
Mike Reeves a0cf0489d6 reduce highstate frequency with active push for rules and pillars
- schedule highstate every 2 hours (was 15 minutes); interval lives in
  global:push:highstate_interval_hours so the SOC admin UI can tune it and
  so-salt-minion-check derives its threshold as (interval + 1) * 3600
- add inotify beacon on the manager + master reactor + orch.push_batch that
  writes per-app intent files, with a so-push-drainer schedule on the manager
  that debounces, dedupes, and dispatches a single orchestration
- pillar_push_map.yaml allowlists the apps whose pillar changes trigger an
  immediate targeted state.apply (targets verified against salt/top.sls);
  edits under pillar/minions/ trigger a state.highstate on that one minion
- host-batch every push orchestration (batch: 25%, batch_wait: 15) so rule
  changes don't thundering-herd large fleets
- new global:push:enabled kill-switch tears down the beacon, reactor config,
  and drainer schedule on the next highstate for operators who want to keep
  highstate-only behavior
- set restart_policy: unless-stopped on 23 container states so docker
  recovers crashes without waiting for the next highstate; leave registry
  (always), strelka/backend (on-failure), kratos, and hydra alone with
  inline comments explaining why
2026-04-10 15:43:16 -04:00
Marco Pedrinazzi d7e971a0fc m365 and fortigate mappings sigma 2026-03-11 14:49:40 +01:00
Jason Ertel 613d31c8a6 merge 2026-03-05 11:52:09 -05:00
182 changed files with 7602 additions and 1116 deletions
+1
View File
@@ -11,6 +11,7 @@ body:
-
- 3.0.0
- 3.1.0
- 3.2.0
- Other (please provide detail below)
validations:
required: true
+2 -1
View File
@@ -5,6 +5,7 @@ on:
paths:
- "salt/sensoroni/files/analyzers/**"
- "salt/manager/tools/sbin/**"
- "salt/_beacons/**"
jobs:
build:
@@ -14,7 +15,7 @@ jobs:
fail-fast: false
matrix:
python-version: ["3.14"]
python-code-path: ["salt/sensoroni/files/analyzers", "salt/manager/tools/sbin"]
python-code-path: ["salt/sensoroni/files/analyzers", "salt/manager/tools/sbin", "salt/_beacons"]
steps:
- uses: actions/checkout@v3
+11 -11
View File
@@ -1,17 +1,17 @@
### 3.0.0-20260331 ISO image released on 2026/03/31
### 3.1.0-20260528 ISO image released on 2026/05/28
### Download and Verify
3.0.0-20260331 ISO image:
https://download.securityonion.net/file/securityonion/securityonion-3.0.0-20260331.iso
3.1.0-20260528 ISO image:
https://download.securityonion.net/file/securityonion/securityonion-3.1.0-20260528.iso
MD5: ECD318A1662A6FDE0EF213F5A9BD4B07
SHA1: E55BE314440CCF3392DC0B06BC5E270B43176D9C
SHA256: 7FC47405E335CBE5C2B6C51FE7AC60248F35CBE504907B8B5A33822B23F8F4D5
MD5: 9D6FF58DEEE24089D722C73169765B3E
SHA1: 2B8B816B6CEC3B7F96B3C5E040EBF502DD2C412F
SHA256: 62FAB57E247C843D6A04F0796D8162C732B65D82FC3E4A59D087135B9FD32912
Signature for ISO image:
https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.0.0-20260331.iso.sig
https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.1.0-20260528.iso.sig
Signing key:
https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/3/main/KEYS
@@ -25,22 +25,22 @@ wget https://raw.githubusercontent.com/Security-Onion-Solutions/securityonion/3/
Download the signature file for the ISO:
```
wget https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.0.0-20260331.iso.sig
wget https://github.com/Security-Onion-Solutions/securityonion/raw/3/main/sigs/securityonion-3.1.0-20260528.iso.sig
```
Download the ISO image:
```
wget https://download.securityonion.net/file/securityonion/securityonion-3.0.0-20260331.iso
wget https://download.securityonion.net/file/securityonion/securityonion-3.1.0-20260528.iso
```
Verify the downloaded ISO image using the signature file:
```
gpg --verify securityonion-3.0.0-20260331.iso.sig securityonion-3.0.0-20260331.iso
gpg --verify securityonion-3.1.0-20260528.iso.sig securityonion-3.1.0-20260528.iso
```
The output should show "Good signature" and the Primary key fingerprint should match what's shown below:
```
gpg: Signature made Mon 30 Mar 2026 06:22:14 PM EDT using RSA key ID FE507013
gpg: Signature made Wed 27 May 2026 03:03:59 PM EDT using RSA key ID FE507013
gpg: Good signature from "Security Onion Solutions, LLC <info@securityonionsolutions.com>"
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -1 +1 @@
3.1.0
3.2.0
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# This script adds sensors/nodes/etc to the nodes tab
default_salt_dir=/opt/so/saltstack/default
local_salt_dir=/opt/so/saltstack/local
TYPE=$1
NAME=$2
IPADDRESS=$3
CPUS=$4
GUID=$5
MANINT=$6
ROOTFS=$7
NSM=$8
MONINT=$9
#NODETYPE=$10
#HOTNAME=$11
echo "Seeing if this host is already in here. If so delete it"
if grep -q $NAME "$local_salt_dir/pillar/data/$TYPE.sls"; then
echo "Node Already Present - Let's re-add it"
awk -v blah=" $NAME:" 'BEGIN{ print_flag=1 }
{
if( $0 ~ blah )
{
print_flag=0;
next
}
if( $0 ~ /^ [a-zA-Z0-9]+:$/ )
{
print_flag=1;
}
if ( print_flag == 1 )
print $0
} ' $local_salt_dir/pillar/data/$TYPE.sls > $local_salt_dir/pillar/data/tmp.$TYPE.sls
mv $local_salt_dir/pillar/data/tmp.$TYPE.sls $local_salt_dir/pillar/data/$TYPE.sls
echo "Deleted $NAME from the tab. Now adding it in again with updated info"
fi
echo " $NAME:" >> $local_salt_dir/pillar/data/$TYPE.sls
echo " ip: $IPADDRESS" >> $local_salt_dir/pillar/data/$TYPE.sls
echo " manint: $MANINT" >> $local_salt_dir/pillar/data/$TYPE.sls
echo " totalcpus: $CPUS" >> $local_salt_dir/pillar/data/$TYPE.sls
echo " guid: $GUID" >> $local_salt_dir/pillar/data/$TYPE.sls
echo " rootfs: $ROOTFS" >> $local_salt_dir/pillar/data/$TYPE.sls
echo " nsmfs: $NSM" >> $local_salt_dir/pillar/data/$TYPE.sls
if [ $TYPE == 'sensorstab' ]; then
echo " monint: bond0" >> $local_salt_dir/pillar/data/$TYPE.sls
fi
if [ $TYPE == 'evaltab' ] || [ $TYPE == 'standalonetab' ]; then
echo " monint: bond0" >> $local_salt_dir/pillar/data/$TYPE.sls
if [ ! $10 ]; then
salt-call state.apply utility queue=True
fi
fi
if [ $TYPE == 'nodestab' ]; then
salt-call state.apply elasticsearch queue=True
# echo " nodetype: $NODETYPE" >> $local_salt_dir/pillar/data/$TYPE.sls
# echo " hotname: $HOTNAME" >> $local_salt_dir/pillar/data/$TYPE.sls
fi
+2
View File
@@ -3,6 +3,8 @@ base:
- ca
- global.soc_global
- global.adv_global
- salt.soc_salt
- salt.adv_salt
- docker.soc_docker
- docker.adv_docker
- influxdb.token
+142
View File
@@ -0,0 +1,142 @@
# 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 SOC audit_settings table in postgres for
# new settings changes and emits a beacon event per new row. This replaces the
# inotify watch on /opt/so/saltstack/local/pillar -- instead of monitoring pillar
# files on disk, we monitor the securityonion.audit_settings table that SOC writes to.
#
# Detection is poll-based with a monotonic `id` watermark persisted to
# WATERMARK_FILE: each pass selects rows with id greater than the last id seen,
# which makes it self-healing (a missed poll simply catches up on the next one).
#
# Each emitted event carries setting_id and node_id; the push_pillar reactor maps
# setting_id -> app via pillar_push_map.yaml and writes a push intent, after which
# the existing so-push-drainer / orch.push_batch pipeline takes over unchanged.
import logging
import os
import subprocess
log = logging.getLogger(__name__)
WATERMARK_FILE = '/opt/so/state/postgres_pillar_beacon_watch.id'
CONTAINER = 'so-postgres'
DATABASE = 'securityonion'
# Unaligned, tuples-only psql output with a field separator that cannot appear in
# an id/setting_id/node_id, so we can split each row reliably.
FIELD_SEP = '\x1f'
def __virtual__():
return True
def validate(config):
return True, 'valid'
def _read_watermark():
# Returns the last processed id, or None if the watermark has not been seeded.
try:
with open(WATERMARK_FILE, 'r') as f:
return int((f.read() or '').strip())
except (IOError, ValueError):
return None
def _write_watermark(value):
try:
os.makedirs(os.path.dirname(WATERMARK_FILE), exist_ok=True)
tmp = WATERMARK_FILE + '.tmp'
with open(tmp, 'w') as f:
f.write(str(int(value)))
os.rename(tmp, WATERMARK_FILE)
except OSError:
log.exception('postgres_pillar_beacon: failed to persist watermark to %s', WATERMARK_FILE)
def _query(sql):
# Run a query against securityonion inside the so-postgres container over the unix
# socket (trust auth, no password). Returns stdout on success, or None on any
# failure so the caller can no-op and retry on the next interval.
cmd = [
'docker', 'exec', CONTAINER,
'psql', '-U', 'postgres', '-d', DATABASE,
'-tA', '-F', FIELD_SEP, '-c', sql,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
log.warning('postgres_pillar_beacon: psql timed out')
return None
except Exception:
log.exception('postgres_pillar_beacon: failed to exec psql')
return None
if result.returncode != 0:
log.warning('postgres_pillar_beacon: psql failed (rc=%s): %s',
result.returncode, (result.stderr or '').strip())
return None
return result.stdout
def beacon(config): # noqa: C901
retval = []
watermark = _read_watermark()
# First run / missing watermark: seed to the current MAX(id) and emit nothing
# so we never replay the entire settings history into a fleetwide push.
if watermark is None:
seed = _query('SELECT COALESCE(MAX(id), 0) FROM audit_settings;')
if seed is None:
return retval # postgres not ready yet; retry next interval
try:
_write_watermark(int((seed or '0').strip() or 0))
except ValueError:
log.warning('postgres_pillar_beacon: could not parse MAX(id) seed: %r', seed)
return retval
rows = _query(
"SELECT id, setting_id, COALESCE(node_id, '') FROM audit_settings "
"WHERE id > %d ORDER BY id;" % watermark
)
if rows is None:
return retval
max_id = watermark
for line in rows.splitlines():
# Do NOT str.strip() the whole line: Python treats the \x1f field
# separator (and \x1c-\x1e) as whitespace, so stripping would eat an
# empty trailing node_id field and make the row look malformed.
if not line.strip():
continue
parts = line.split(FIELD_SEP)
if len(parts) < 3:
log.warning('postgres_pillar_beacon: skipping malformed row: %r', line)
continue
try:
row_id = int(parts[0])
except ValueError:
log.warning('postgres_pillar_beacon: skipping row with non-int id: %r', line)
continue
setting_id = parts[1]
node_id = parts[2]
retval.append({
'tag': 'audit_settings',
'id': row_id,
'setting_id': setting_id,
'node_id': node_id,
})
if row_id > max_id:
max_id = row_id
if max_id > watermark:
_write_watermark(max_id)
log.info('postgres_pillar_beacon: emitted %d change(s), watermark %d -> %d',
len(retval), watermark, max_id)
return retval
@@ -0,0 +1,165 @@
# 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 os
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import postgres_pillar_beacon
class TestPostgresPillarBeacon(unittest.TestCase):
def setUp(self):
# Point WATERMARK_FILE at a throwaway dir so the real read/write helpers
# (and their os.makedirs/os.rename) run against actual files, then clean
# it all up in tearDown.
self.tmpdir = tempfile.mkdtemp()
self.watermark = os.path.join(self.tmpdir, 'state', 'watch.id')
patcher = patch.object(postgres_pillar_beacon, 'WATERMARK_FILE', self.watermark)
patcher.start()
self.addCleanup(patcher.stop)
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
# -- trivial contract -------------------------------------------------
def test_virtual_returns_true(self):
self.assertTrue(postgres_pillar_beacon.__virtual__())
def test_validate_returns_valid(self):
self.assertEqual(postgres_pillar_beacon.validate({}), (True, 'valid'))
# -- _read_watermark --------------------------------------------------
def test_read_watermark_valid(self):
postgres_pillar_beacon._write_watermark(42)
self.assertEqual(postgres_pillar_beacon._read_watermark(), 42)
def test_read_watermark_missing_file_returns_none(self):
# tmp watermark file was never created
self.assertIsNone(postgres_pillar_beacon._read_watermark())
def test_read_watermark_garbage_returns_none(self):
os.makedirs(os.path.dirname(self.watermark), exist_ok=True)
with open(self.watermark, 'w') as f:
f.write('nope')
self.assertIsNone(postgres_pillar_beacon._read_watermark())
# -- _write_watermark -------------------------------------------------
def test_write_watermark_round_trip(self):
postgres_pillar_beacon._write_watermark(7)
with open(self.watermark) as f:
self.assertEqual(f.read(), '7')
def test_write_watermark_swallows_oserror(self):
with patch.object(postgres_pillar_beacon.os, 'makedirs', side_effect=OSError):
# Must not raise; failure is logged and the beacon retries next pass.
postgres_pillar_beacon._write_watermark(5)
self.assertFalse(os.path.exists(self.watermark))
# -- _query -----------------------------------------------------------
def test_query_success_returns_stdout_and_builds_argv(self):
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout='rows', stderr='')
with patch.object(postgres_pillar_beacon.subprocess, 'run', return_value=completed) as mock_run:
result = postgres_pillar_beacon._query('SELECT 1;')
self.assertEqual(result, 'rows')
argv = mock_run.call_args[0][0]
self.assertEqual(argv[:5], ['docker', 'exec', 'so-postgres', 'psql', '-U'])
self.assertIn('SELECT 1;', argv)
self.assertFalse(mock_run.call_args[1].get('shell', False))
def test_query_timeout_returns_none(self):
with patch.object(postgres_pillar_beacon.subprocess, 'run',
side_effect=subprocess.TimeoutExpired(cmd='psql', timeout=30)):
self.assertIsNone(postgres_pillar_beacon._query('SELECT 1;'))
def test_query_generic_exception_returns_none(self):
with patch.object(postgres_pillar_beacon.subprocess, 'run', side_effect=Exception('boom')):
self.assertIsNone(postgres_pillar_beacon._query('SELECT 1;'))
def test_query_nonzero_returncode_returns_none(self):
completed = subprocess.CompletedProcess(args=[], returncode=1, stdout='', stderr='bad')
with patch.object(postgres_pillar_beacon.subprocess, 'run', return_value=completed):
self.assertIsNone(postgres_pillar_beacon._query('SELECT 1;'))
# -- beacon: first run / seeding --------------------------------------
def test_beacon_seeds_when_postgres_not_ready(self):
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=None), \
patch.object(postgres_pillar_beacon, '_query', return_value=None), \
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
mock_write.assert_not_called()
def test_beacon_seeds_to_max_id_and_emits_nothing(self):
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=None), \
patch.object(postgres_pillar_beacon, '_query', return_value='7\n'), \
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
mock_write.assert_called_once_with(7)
def test_beacon_seed_unparseable_is_swallowed(self):
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=None), \
patch.object(postgres_pillar_beacon, '_query', return_value='abc'), \
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
mock_write.assert_not_called()
# -- beacon: steady state ---------------------------------------------
def test_beacon_query_failure_returns_empty(self):
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
patch.object(postgres_pillar_beacon, '_query', return_value=None), \
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
mock_write.assert_not_called()
def test_beacon_emits_events_and_advances_watermark(self):
sep = postgres_pillar_beacon.FIELD_SEP
rows = '11%s5%snode1\n12%s6%s\n' % (sep, sep, sep, sep)
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
patch.object(postgres_pillar_beacon, '_query', return_value=rows), \
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
result = postgres_pillar_beacon.beacon({})
self.assertEqual(result, [
{'tag': 'audit_settings', 'id': 11, 'setting_id': '5', 'node_id': 'node1'},
{'tag': 'audit_settings', 'id': 12, 'setting_id': '6', 'node_id': ''},
])
mock_write.assert_called_once_with(12)
def test_beacon_skips_malformed_blank_and_noninteger_rows(self):
sep = postgres_pillar_beacon.FIELD_SEP
rows = (
'\n' # blank line -> skipped
'13%s7\n' # too few fields -> skipped
'abc%s8%snodeX\n' # non-integer id -> skipped
'14%s9%snodeY\n' # the one good row
) % (sep, sep, sep, sep, sep)
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
patch.object(postgres_pillar_beacon, '_query', return_value=rows), \
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
result = postgres_pillar_beacon.beacon({})
self.assertEqual(result, [
{'tag': 'audit_settings', 'id': 14, 'setting_id': '9', 'node_id': 'nodeY'},
])
mock_write.assert_called_once_with(14)
def test_beacon_no_new_rows_does_not_advance_watermark(self):
with patch.object(postgres_pillar_beacon, '_read_watermark', return_value=10), \
patch.object(postgres_pillar_beacon, '_query', return_value=''), \
patch.object(postgres_pillar_beacon, '_write_watermark') as mock_write:
self.assertEqual(postgres_pillar_beacon.beacon({}), [])
mock_write.assert_not_called()
if __name__ == '__main__':
unittest.main()
+139
View File
@@ -0,0 +1,139 @@
# 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
+172
View File
@@ -0,0 +1,172 @@
# 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()
+3 -4
View File
@@ -4,7 +4,7 @@ import logging
def status():
cmd = "runuser -l zeek -c '/opt/zeek/bin/zeekctl status'"
retval = __salt__['docker.run']('so-zeek', cmd)
retval = __salt__['docker.run']('so-zeek', cmd) # noqa: F821
logging.info('zeekctl_module: zeekctl.status retval: %s' % retval)
return retval
@@ -14,7 +14,7 @@ def beacon(config):
retval = []
is_enabled = __salt__['healthcheck.is_enabled']()
is_enabled = __salt__['healthcheck.is_enabled']() # noqa: F821
logging.info('zeek_beacon: healthcheck_is_enabled: %s' % is_enabled)
if is_enabled:
@@ -25,9 +25,8 @@ def beacon(config):
else:
zeek_restart = False
__salt__['telegraf.send']('healthcheck zeek_restart=%s' % str(zeek_restart))
__salt__['telegraf.send']('healthcheck zeek_restart=%s' % str(zeek_restart)) # noqa: F821
retval.append({'zeek_restart': zeek_restart})
logging.info('zeek_beacon: retval: %s' % str(retval))
return retval
+59
View File
@@ -0,0 +1,59 @@
# 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 unittest
from unittest.mock import MagicMock
import zeek
ZEEKCTL_CMD = "runuser -l zeek -c '/opt/zeek/bin/zeekctl status'"
class TestZeekBeacon(unittest.TestCase):
def setUp(self):
# zeek.py relies on the __salt__ dunder that Salt injects at load time.
# Nothing defines it under test, so we attach a dict of mock loader
# functions to the module and remove it again afterwards.
self.salt = {
'docker.run': MagicMock(return_value='Zeek is running'),
'healthcheck.is_enabled': MagicMock(return_value=True),
'telegraf.send': MagicMock(),
}
zeek.__salt__ = self.salt
self.addCleanup(lambda: delattr(zeek, '__salt__'))
# -- status -----------------------------------------------------------
def test_status_runs_zeekctl_and_returns_output(self):
self.salt['docker.run'].return_value = 'Zeek is running'
result = zeek.status()
self.assertEqual(result, 'Zeek is running')
self.salt['docker.run'].assert_called_once_with('so-zeek', ZEEKCTL_CMD)
# -- beacon -----------------------------------------------------------
def test_beacon_disabled_returns_empty_and_skips_telegraf(self):
self.salt['healthcheck.is_enabled'].return_value = False
self.assertEqual(zeek.beacon({}), [])
self.salt['telegraf.send'].assert_not_called()
def test_beacon_running_reports_no_restart(self):
self.salt['docker.run'].return_value = 'Zeek is running'
self.assertEqual(zeek.beacon({}), [{'zeek_restart': False}])
self.salt['telegraf.send'].assert_called_once_with('healthcheck zeek_restart=False')
def test_beacon_unhealthy_status_triggers_restart(self):
# Each of these status tokens should flag a restart (the or-chain in beacon).
for status_text in ('Zeek is stopped', 'Zeek crashed', 'Zeek error state', 'Zeek error:'):
with self.subTest(status=status_text):
self.salt['docker.run'].return_value = status_text
self.salt['telegraf.send'].reset_mock()
self.assertEqual(zeek.beacon({}), [{'zeek_restart': True}])
self.salt['telegraf.send'].assert_called_once_with('healthcheck zeek_restart=True')
if __name__ == '__main__':
unittest.main()
+1 -2
View File
@@ -37,8 +37,7 @@
'elasticfleet',
'elasticfleet.manager',
'elasticsearch.cluster',
'elastic-fleet-package-registry',
'utility'
'elastic-fleet-package-registry'
] %}
{% set sensor_states = [
@@ -25,9 +25,11 @@ if [ ! -f $BACKUPFILE ]; then
# Create empty backup file
tar -cf $BACKUPFILE -T /dev/null
# Loop through all paths defined in global.sls, and append them to backup file
# Loop through all paths defined in global.sls, and append them to backup file if they exist
{%- for LOCATION in BACKUPLOCATIONS %}
if [[ -d {{ LOCATION }} || -f {{ LOCATION }} ]]; then
tar -rf $BACKUPFILE "${EXCLUSIONS[@]}" {{ LOCATION }}
fi
{%- endfor %}
fi
+11
View File
@@ -130,6 +130,17 @@ common_sbin:
- so-pcap-import
{% endif %}
# Pin physical NIC names by MAC (run-once) so a kernel upgrade can't renumber the
# interfaces SO binds by name. The marker keeps it a one-time setup; an admin can
# pre-create the marker to opt out.
pin_nic_names:
cmd.run:
- name: /usr/sbin/so-nic-pin
- unless: 'test -e /opt/so/state/nic_names_pinned'
- require:
- file: common_sbin
- file: statedir
common_sbin_jinja:
file.recurse:
- name: /usr/sbin
+20 -37
View File
@@ -142,6 +142,11 @@ check_elastic_license() {
fi
}
check_elasticsearch_responsive() {
retry 3 15 "so-elasticsearch-query / --output /dev/null --fail" ||
fail "Elasticsearch is not responding. Please review Elasticsearch logs /opt/so/log/elasticsearch/securityonion.log for more details. Additionally, consider running so-elasticsearch-troubleshoot."
}
check_salt_master_status() {
local count=0
local attempts="${1:- 10}"
@@ -286,6 +291,20 @@ download_and_verify() {
fi
}
# check if container with name is running and optionally stop it
docker_check_running() {
# show running containers, only names
if docker ps --format '{{.Names}}' | grep -q "^so-${1}$"; then
if [[ "$2" == "--stop" ]]; then
docker stop "so-${1}"
fi
return 0
else
return 1
fi
}
elastic_license() {
read -r -d '' message <<- EOM
@@ -583,42 +602,6 @@ run_check_net_err() {
fi
}
wait_for_salt_minion() {
local minion="$1"
local max_wait="${2:-30}"
local interval="${3:-2}"
local logfile="${4:-'/dev/stdout'}"
local elapsed=0
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Waiting for salt-minion '$minion' to be ready..."
while [ $elapsed -lt $max_wait ]; do
# Check if service is running
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Check if salt-minion service is running"
if ! systemctl is-active --quiet salt-minion; then
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion service not running (elapsed: ${elapsed}s)"
sleep $interval
elapsed=$((elapsed + interval))
continue
fi
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion service is running"
# Check if minion responds to ping
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Check if $minion responds to ping"
if salt "$minion" test.ping --timeout=3 --out=json 2>> "$logfile" | grep -q "true"; then
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - salt-minion '$minion' is connected and ready!"
return 0
fi
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - Waiting... (${elapsed}s / ${max_wait}s)"
sleep $interval
elapsed=$((elapsed + interval))
done
echo "$(date '+%a %d %b %Y %H:%M:%S.%6N') - ERROR: salt-minion '$minion' not ready after $max_wait seconds"
return 1
}
salt_minion_count() {
local MINIONDIR="/opt/so/saltstack/local/pillar/minions"
MINIONCOUNT=$(ls -la $MINIONDIR/*.sls | grep -v adv_ | wc -l)
@@ -683,7 +666,7 @@ systemctl_func() {
echo ""
echo "${echo_action^}ing $service_name service at $(date +"%T.%6N")"
systemctl $action $service_name && echo "Successfully ${echo_action}ed $service_name." || echo "Failed to $action $service_name."
systemctl $action $service_name && echo "Successfully ${echo_action}ed $service_name at $(date +"%T.%6N")." || echo "Failed to $action $service_name at $(date +"%T.%6N")."
echo ""
}
+15 -2
View File
@@ -192,8 +192,21 @@ update_docker_containers() {
echo "Unable to tag $image" >> "$LOG_FILE" 2>&1
exit 1
}
docker push $HOSTNAME:5000/$IMAGEREPO/$image >> "$LOG_FILE" 2>&1 || {
echo "Unable to push $image" >> "$LOG_FILE" 2>&1
# Push to the embedded registry via a registry-to-registry copy. Avoids
# `docker push`, which on Docker 29.x with the containerd image store
# represents freshly-pulled images as an index whose layer content
# isn't reachable through the push path. The local `docker tag` above
# is preserved so so-image-pull's `:5000` existence check still works.
# Pin to the digest already gpg-verified above so we copy exactly the
# bytes we approved.
local VERIFIED_REF
VERIFIED_REF=$(echo "$DOCKERINSPECT" | jq -r ".[0].RepoDigests[] | select(. | contains(\"$CONTAINER_REGISTRY\"))" | head -n 1)
if [ -z "$VERIFIED_REF" ] || [ "$VERIFIED_REF" = "null" ]; then
echo "Unable to determine verified digest for $image" >> "$LOG_FILE" 2>&1
exit 1
fi
docker buildx imagetools create --tag $HOSTNAME:5000/$IMAGEREPO/$image "$VERIFIED_REF" >> "$LOG_FILE" 2>&1 || {
echo "Unable to copy $image to embedded registry" >> "$LOG_FILE" 2>&1
exit 1
}
fi
+243
View File
@@ -0,0 +1,243 @@
#!/bin/bash
#
# 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.
#
# so-kernel-upgrade — install the UEK8 (6.x) kernel and make it the boot default.
#
# Security Onion is moving off the EL9 stock kernel (RHCK, 5.14) and UEK7 (5.15) onto UEK8
# (6.x). Three things have to happen, and the tool has to drive each one:
#
# 1. Populate. The manager mirrors the UEK8 packages into /nsm/kernelrepo via so-repo-sync,
# and serves them to the grid over https://<manager>/kernelrepo. Until that sync runs the
# repo is valid but EMPTY -- dnf resolves it happily and installs nothing, with no error.
# 2. Install. A node on RHCK has no kernel-uek* package at all, so there is nothing for
# 'dnf update' to upgrade. A node on UEK7 does have kernel-uek installed, so
# 'dnf install kernel-uek' reports "Nothing to do" and exits 0 without installing 6.x.
# Both cases need an explicit install of the UEK8 NEVRA.
# 3. Boot it. Whether a newly installed UEK8 kernel becomes the boot default depends on the
# RUNNING kernel's flavor. kernel-install/grubby (with UPDATEDEFAULT=yes) only auto-promote
# within the running kernel's flavor lineage:
# - From UEK7 (5.x, kernel-uek) the install stays in the kernel-uek lineage and IS
# auto-promoted, so no grubby change is needed -- just make sure the repo is populated
# and install UEK8.
# - From the stock EL9 kernel (RHCK, 5.14, no UEK) it is a flavor CROSS that is NOT
# auto-promoted, so the box keeps booting RHCK until grubby is told otherwise.
# This tool inspects the running kernel and only runs 'grubby --set-default' for RHCK.
#
# Every one of those failure modes is silent by default. This tool handles each case and fails
# loudly when it cannot, rather than reporting success while changing nothing.
#
# Manager vs minion: only the manager owns /nsm/kernelrepo, so only the manager can populate
# it. If the repo is empty here, a manager runs so-repo-sync itself; a minion has no way to
# fix it and exits non-zero telling the admin to sync the manager first.
#
# Idempotent: an already-installed, already-default UEK8 kernel is left alone. It only sets
# the boot default; it does NOT reboot -- the admin reboots the node on their own schedule.
. /usr/sbin/so-common
# Client-side repo id (what dnf enables on this node, from repo/client/oracle.sls) vs the
# reposync-side section in repodownload.conf that the manager mirrors from (mirrors the
# securityonion/securityonionsync split for the main repo).
KERNEL_REPO="securityonionkernel"
KERNEL_REPO_SYNC="securityonionkernelsync"
KERNEL_PKG="kernel-uek"
KERNEL_REPO_DIR="/nsm/kernelrepo"
REPOSYNC_CONF="/opt/so/conf/reposync/repodownload.conf"
GLOBAL_PILLAR="/opt/so/saltstack/local/pillar/global/soc_global.sls"
log() { echo "[so-kernel-upgrade] $*"; }
die() { echo "[so-kernel-upgrade] ERROR: $*" >&2; exit 1; }
command -v grubby >/dev/null 2>&1 || die "grubby not found"
command -v dnf >/dev/null 2>&1 || die "dnf not found"
ARCH="$(rpm -E '%{_arch}')"
is_airgap() {
[ -f "$GLOBAL_PILLAR" ] && grep -q 'airgap: *[Tt]rue' "$GLOBAL_PILLAR"
}
# Newest installed UEK8 (6.x) kernel known to the bootloader. UEK8 vmlinuz paths look like
# /boot/vmlinuz-6.12.0-204.92.4.2.el9uek.x86_64; UEK7 (5.15) and RHCK (5.14) won't match.
find_uek8() {
grubby --info=ALL 2>/dev/null \
| sed -n 's/^kernel="\(.*\)"$/\1/p' \
| grep -E '/vmlinuz-6\.[0-9]+.*uek' \
| sort -V | tail -1
}
# Classify the RUNNING kernel (uname -r) -- this, not what's installed, is what decides whether
# a UEK8 install auto-promotes to the boot default:
# uek8 6.x UEK already on the target line; nothing to do
# uek7 5.x UEK a UEK8 install stays in the kernel-uek lineage and auto-promotes (no grubby)
# rhck 5.14 EL9 crossing into the UEK flavor does NOT auto-promote (needs grubby --set-default)
running_flavor() {
case "$(uname -r)" in
6.*uek*) echo uek8 ;;
*uek*) echo uek7 ;;
*) echo rhck ;;
esac
}
# Newest UEK8 kernel-uek NEVRA offered by the kernel repo, empty if the repo has none.
# Restricted to the kernel repo so a UEK7 kernel-uek in the main repo can't be picked up,
# and filtered to 6.x so we never "succeed" by reinstalling the 5.15 we already have.
uek8_available() {
dnf -q repoquery --disablerepo='*' --enablerepo="$KERNEL_REPO" \
--arch="$ARCH" --latest-limit=1 \
--qf '%{name}-%{evr}.%{arch}\n' "$KERNEL_PKG" 2>/dev/null \
| grep -E "^${KERNEL_PKG}-6\." | tail -1
}
kernelrepo_rpm_count() {
find "$KERNEL_REPO_DIR" -maxdepth 1 -name '*.rpm' 2>/dev/null | wc -l
}
# The kernel repo starts life as valid-but-empty (kernelrepo_init_empty in
# salt/manager/init.sls) and is filled by so-repo-sync. During a soup, so-repo-sync runs
# BEFORE the highstate deploys the [securityonionkernelsync] section into repodownload.conf, so
# the first kernel-aware soup leaves the repo empty until the next nightly sync.
sync_kernel_repo() {
if is_airgap; then
log "airgap install: $KERNEL_REPO_DIR is populated from the airgap ISO, not by so-repo-sync."
return 1
fi
if ! grep -q "^\[${KERNEL_REPO_SYNC}\]" "$REPOSYNC_CONF" 2>/dev/null; then
log "$REPOSYNC_CONF has no [${KERNEL_REPO_SYNC}] section -- run a highstate to deploy it."
return 1
fi
log "populating $KERNEL_REPO_DIR with so-repo-sync (mirrors upstream; can take several minutes)"
su socore -c '/usr/sbin/so-repo-sync' || { log "so-repo-sync failed"; return 1; }
dnf -q clean expire-cache >/dev/null 2>&1
return 0
}
# Make the kernel repo actually able to serve a UEK8 package, or fail trying.
ensure_kernel_repo() {
# The repo is assigned by the repo.client highstate, and only once NICs are pinned by MAC
# (/opt/so/state/nic_names_pinned) so the kernel swap can't renumber interfaces SO binds
# by name. skip_if_unavailable=1 means a broken repo is silently ignored, so check first.
if ! dnf -q repolist --enabled 2>/dev/null | awk '{print $1}' | grep -qx "$KERNEL_REPO"; then
log "repo '$KERNEL_REPO' is not enabled on this node."
log "Run a highstate first; the repo is skipped until /opt/so/state/nic_names_pinned"
log "exists (run so-nic-pin) and this node's salt matches the version this release ships."
die "kernel repo unavailable"
fi
[ -n "$(uek8_available)" ] && return 0
log "repo '$KERNEL_REPO' is enabled but offers no UEK8 $KERNEL_PKG package"
if ! is_manager_node; then
log "This is a minion; it consumes the kernel repo from the manager and cannot populate it."
log "On the manager, run: su socore -c /usr/sbin/so-repo-sync"
log "then re-run this script here."
die "manager's kernel repo is empty"
fi
log "this is a manager and $KERNEL_REPO_DIR holds $(kernelrepo_rpm_count) rpm(s)"
sync_kernel_repo || die "could not populate $KERNEL_REPO_DIR"
[ -n "$(uek8_available)" ] \
|| die "so-repo-sync completed but $KERNEL_REPO still offers no UEK8 $KERNEL_PKG"
}
reboot_notice() {
[ "$(uname -r)" = "$(basename "$1" | sed 's/^vmlinuz-//')" ] \
|| log "REBOOT REQUIRED to start using the UEK8 kernel (currently running $(uname -r))."
}
# Keep future kernel updates on the UEK line rather than falling back to RHCK. Oracle ships
# /etc/sysconfig/kernel; only rewrite it when it's actually pointing somewhere else.
set_default_kernel_conf() {
if [ -f /etc/sysconfig/kernel ] && ! grep -q '^DEFAULTKERNEL=kernel-uek-core$' /etc/sysconfig/kernel; then
log "setting DEFAULTKERNEL=kernel-uek-core in /etc/sysconfig/kernel"
sed -i 's/^DEFAULTKERNEL=.*/DEFAULTKERNEL=kernel-uek-core/' /etc/sysconfig/kernel
fi
}
# Make sure a UEK8 kernel is installed, leaving its boot entry in INSTALLED_UEK8. If one is
# already present we leave the repo alone -- it may be disabled or empty and we don't need it
# just to flip the boot default. Otherwise install the explicit NEVRA, not the bare package
# name: on a UEK7 node 'dnf install kernel-uek' sees 5.15 already present, prints "Nothing to
# do" and exits 0 without installing 6.x.
ensure_uek8_installed() {
INSTALLED_UEK8="$(find_uek8)"
if [ -n "$INSTALLED_UEK8" ]; then
log "UEK8 kernel already installed: $INSTALLED_UEK8"
return 0
fi
ensure_kernel_repo
local nevra; nevra="$(uek8_available)"
log "installing $nevra from $KERNEL_REPO"
dnf -y install "$nevra" || die "failed to install $nevra"
INSTALLED_UEK8="$(find_uek8)"
[ -n "$INSTALLED_UEK8" ] || die "$nevra installed but no 6.x UEK boot entry appeared -- check 'grubby --info=ALL'"
log "installed UEK8 kernel: $INSTALLED_UEK8"
}
case "$(running_flavor)" in
uek8)
# Already on the 6.x UEK line. A plain 'dnf update' keeps this node current within the
# lineage and auto-promotes newer builds, so there is nothing for this tool to do.
log "already running a UEK8 kernel ($(uname -r)); nothing to do."
exit 0
;;
uek7)
# On a 5.x UEK kernel. Installing UEK8 stays inside the kernel-uek lineage, so dnf/grubby
# (UPDATEDEFAULT=yes) auto-promote it and we do NOT touch grubby. A node still on UEK7
# usually means the kernel repo was empty when it last updated, so populate it and install.
log "running UEK7 kernel ($(uname -r)); the kernel repo was likely not yet populated when"
log "this node last updated. Populating it and installing UEK8 -- the update stays on the"
log "kernel-uek line, so it becomes the boot default automatically (no grubby change needed)."
set_default_kernel_conf
ensure_uek8_installed
now="$(grubby --default-kernel 2>/dev/null)"
if [ "$now" = "$INSTALLED_UEK8" ]; then
log "boot default auto-promoted to UEK8 kernel: $INSTALLED_UEK8"
else
log "WARNING: expected the UEK8 kernel to auto-promote but the default is still"
log "'${now:-unknown}'. Run 'grubby --set-default=$INSTALLED_UEK8' to force it."
fi
reboot_notice "$INSTALLED_UEK8"
;;
rhck)
# On the stock EL9 kernel (5.14, no UEK installed). Crossing from RHCK into the UEK flavor
# does NOT auto-promote -- kernel-install/grubby only auto-promote within the running
# kernel's flavor lineage -- so after installing we must set the boot default explicitly.
log "running stock EL9 (RHCK) kernel ($(uname -r)); installing UEK8 and setting it as the"
log "boot default explicitly (a RHCK->UEK flavor change does not auto-promote)."
set_default_kernel_conf
ensure_uek8_installed
target="$INSTALLED_UEK8"
current="$(grubby --default-kernel 2>/dev/null)"
if [ "$current" = "$target" ]; then
log "UEK8 kernel is already the boot default: $target"
reboot_notice "$target"
exit 0
fi
log "current default kernel: ${current:-unknown}"
log "switching boot default to UEK8 kernel: $target"
grubby --set-default="$target" || die "grubby --set-default failed for $target"
# Verify the change actually took before claiming success.
now="$(grubby --default-kernel 2>/dev/null)"
[ "$now" = "$target" ] || die "default kernel is still '${now:-unknown}' after set-default"
log "boot default is now $target"
reboot_notice "$target"
;;
esac
+4 -1
View File
@@ -132,6 +132,7 @@ if [[ $EXCLUDE_STARTUP_ERRORS == 'Y' ]]; then
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|HTTP 404: Not Found" # Salt loops until Kratos returns 200, during startup Kratos may not be ready
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Cancelling deferred write event maybeFenceReplicas because the event queue is now closed" # Kafka controller log during shutdown/restart
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Redis may have been restarted" # Redis likely restarted by salt
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|file already closed" # Go logging race condition during container restart
fi
if [[ $EXCLUDE_FALSE_POSITIVE_ERRORS == 'Y' ]]; then
@@ -165,6 +166,8 @@ if [[ $EXCLUDE_FALSE_POSITIVE_ERRORS == 'Y' ]]; then
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|upgrading component template" # false positive (elasticsearch index or template names contain 'error')
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|upgrading composable template" # false positive (elasticsearch composable template names contain 'error')
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|Error while parsing document for index \[.ds-logs-kratos-so-.*object mapping for \[file\]" # false positive (mapping error occuring BEFORE kratos index has rolled over in 2.4.210)
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|No such container" # false positive (telegraf trying to run stats on an old container)
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|passwords do not match" # false positive (automated hydra test)
fi
if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then
@@ -227,7 +230,7 @@ if [[ $EXCLUDE_KNOWN_ERRORS == 'Y' ]]; then
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|from NIC checksum offloading" # zeek reporter.log
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|marked for removal" # docker container getting recycled
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|tcp 127.0.0.1:6791: bind: address already in use" # so-elastic-fleet agent restarting. Seen starting w/ 8.18.8 https://github.com/elastic/kibana/issues/201459
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|TransformTask\] \[logs-(tychon|aws_billing|microsoft_defender_endpoint|armis|o365_metrics|microsoft_sentinel|snyk).*user so_kibana lacks the required permissions \[(logs|metrics)-\1" # Known issue with integrations starting transform jobs that are explicitly not allowed to start as a system user. (installed as so_elastic / so_kibana)
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|TransformTask\] \[logs-(tychon|aws_billing|microsoft_defender_endpoint|armis|o365_metrics|microsoft_sentinel|snyk|cyera|island_browser).*user so_kibana lacks the required permissions \[(logs|metrics)-\1" # Known issue with integrations starting transform jobs that are explicitly not allowed to start as a system user. This error should not be seen on fresh ES 9.3.3 installs or after SO 3.1.0 with soups addition of check_transform_health_and_reauthorize()
EXCLUDED_ERRORS="$EXCLUDED_ERRORS|manifest unknown" # appears in so-dockerregistry log for so-tcpreplay following docker upgrade to 29.2.1-1
fi
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
#
# so-nic-pin — pin physical NIC names by permanent MAC via classic by-MAC udev
# rules, so a kernel upgrade can't renumber them.
#
# Security Onion binds its management and monitor interfaces BY NAME in pillar
# (host:mainint, sensor:mainint, and bond0 is built on a specific physical NIC).
# A kernel upgrade can change the kernel/systemd-udevd predictable-naming output
# and renumber those NICs (e.g. enp1s0 -> enp2s0), which breaks the grid: the
# pillar references a name that no longer exists and bond/bridge bring-up fails.
#
# This writes /etc/udev/rules.d/70-persistent-net.rules pinning each PHYSICAL NIC
# to its CURRENT name by its PERMANENT MAC, freezing the names across future kernel
# changes. It only writes the rules file; it does NOT live-trigger a rename (the
# rules apply on the next boot/kernel, and a live rename would be disruptive).
#
# Run-once: gated by the drop file /opt/so/state/nic_names_pinned. If the marker is
# present the script does nothing, so an admin can pre-create it to opt out. Invoked
# from the common state on every highstate; the marker keeps it a one-time setup.
NET_RULES_FILE="/etc/udev/rules.d/70-persistent-net.rules"
MARKER="/opt/so/state/nic_names_pinned"
log() { echo -e "[so-nic-pin] $*"; }
# Echo "<name> <permanent-mac>" for every PHYSICAL NIC. A physical NIC is backed by a
# real device (has device/driver), which excludes bond0/sobridge/docker0/veth*/lo whose
# MACs are dynamic and must never be pinned. The PERMANENT MAC is used (ethtool -P, with
# fallbacks), not the current one: an enslaved bond member's current MAC is rewritten to
# the bond's, so matching on it would be wrong/ambiguous.
physical_nics() {
local path n mac
for path in /sys/class/net/*; do
n="${path##*/}"
[ "$n" = "lo" ] && continue
[ -e "${path}/device/driver" ] || continue # real device only
mac="$(ethtool -P "$n" 2>/dev/null | awk '/Permanent address/{print $NF}')"
case "$mac" in ""|00:00:00:00:00:00) mac="$(cat "${path}/bonding_slave/perm_hwaddr" 2>/dev/null)" ;; esac
case "$mac" in ""|00:00:00:00:00:00) mac="$(cat "${path}/address" 2>/dev/null)" ;; esac
case "$mac" in ""|00:00:00:00:00:00) continue ;; esac
echo "$n $mac"
done
}
# Turn "<name> <mac>" lines on stdin into classic by-MAC persistent-net udev rules.
render_net_rules() {
echo "# Generated by so-nic-pin: pin NIC names by MAC so kernel upgrades can't renumber them."
echo "# Security Onion binds its management/monitor interfaces by name; do not hand-edit."
local n mac
while read -r n mac; do
[ -n "$n" ] || continue
printf 'SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*", ATTR{address}=="%s", NAME="%s"\n' \
"$mac" "$n"
done
}
[ "$(id -u)" -eq 0 ] || exit 0 # salt runs us as root; bail quietly otherwise
[ -e "${MARKER}" ] && exit 0 # run-once guard (mirrors the state's unless)
nics="$(physical_nics)"
if [ -z "${nics}" ]; then
log "no physical NICs detected — nothing to pin (will retry on next highstate)"
exit 0 # do NOT drop the marker; let it retry later
fi
log "pinning physical NICs by permanent MAC:"
echo "${nics}" | sed 's/^/ /'
[ -f "${NET_RULES_FILE}" ] && cp -f "${NET_RULES_FILE}" "${NET_RULES_FILE}.bak"
echo "${nics}" | render_net_rules > "${NET_RULES_FILE}" || {
log "ERROR: failed to write ${NET_RULES_FILE}"
exit 1
}
mkdir -p "$(dirname "${MARKER}")" && touch "${MARKER}"
log "wrote ${NET_RULES_FILE} ($(grep -c '^SUBSYSTEM' "${NET_RULES_FILE}") NIC(s) pinned); dropped ${MARKER}"
+32 -15
View File
@@ -5,27 +5,44 @@
# https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0.
# Usage: so-restart kibana | playbook
. /usr/sbin/so-common
if [ $# -ge 1 ]; then
usage() {
echo "Usage: $0 <component> [args]"
echo ""
echo "Supported args:"
echo " --force | -f Force stop all Salt jobs before starting component."
echo ""
echo "Examples:"
echo " $0 kibana Restart Kibana"
echo " $0 kibana --force Force stop all Salt jobs before restarting Kibana"
exit 1
}
echo $banner
printf "Restarting $1...\n\nThis could take a while if another Salt job is running. \nRun this command with --force to stop all Salt jobs before proceeding.\n"
echo $banner
if [[ $# -lt 1 ]]; then
usage
fi
if [ "$2" = "--force" ]; then
#shellcheck disable=SC2154
echo "$banner"
printf "Restarting %s...\n\nThis could take a while if another Salt job is running. \nRun this command with --force to stop all Salt jobs before proceeding.\n" "$1"
echo "$banner"
if [[ "$2" = "--force" ]] || [[ "$2" = "-f" ]]; then
printf "\nForce-stopping all Salt jobs before proceeding\n\n"
salt-call saltutil.kill_all_jobs
fi
case $1 in
"elastic-fleet") docker stop so-elastic-fleet && docker rm so-elastic-fleet && salt-call state.apply elasticfleet queue=True;;
*) docker stop so-$1 ; docker rm so-$1 ; salt-call state.apply $1 queue=True;;
"elastic-fleet"|"elasticfleet")
docker_check_running "elastic-fleet" "--stop"
docker rm "so-elastic-fleet" 2> /dev/null
# Removing the elastic fleet state directory, so that the next startup re-enrolls with a fresh policy
rm -rf /opt/so/conf/elastic-fleet/state
salt-call state.apply elasticfleet queue=True
;;
*)
docker_check_running "$1" "--stop"
docker rm "so-${1}" 2> /dev/null
salt-call state.apply "$1" queue=True
;;
esac
else
echo -e "\nPlease provide an argument by running like so-restart $component, or by using the component-specific script.\nEx. so-restart logstash, or so-logstash-restart\n"
fi
+42 -15
View File
@@ -5,27 +5,54 @@
# https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0.
# Usage: so-start all | kibana | playbook
# shellcheck disable=SC1091
. /usr/sbin/so-common
if [ $# -ge 1 ]; then
echo $banner
printf "Starting $1...\n\nThis could take a while if another Salt job is running. \nRun this command with --force to stop all Salt jobs before proceeding.\n"
echo $banner
usage() {
echo "Usage: $0 <component> [args]"
echo ""
echo "Supported args:"
echo " --force | -f Force stop all Salt jobs before starting component."
echo ""
echo "Examples:"
echo " $0 kibana Start Kibana"
echo " $0 kibana --force Force stop all Salt jobs before starting Kibana"
exit 1
}
if [ "$2" = "--force" ]; then
if [[ $# -lt 1 ]]; then
usage
fi
#shellcheck disable=SC2154
echo "$banner"
printf "Starting %s...\n\nThis could take a while if another Salt job is running. \nRun this command with --force to stop all Salt jobs before proceeding.\n" "$1"
echo "$banner"
if [[ "$2" = "--force" ]] || [[ "$2" == "-f" ]]; then
printf "\nForce-stopping all Salt jobs before proceeding\n\n"
salt-call saltutil.kill_all_jobs
fi
case $1 in
"all") salt-call state.highstate queue=True;;
"elastic-fleet") if docker ps | grep -q so-$1; then printf "\n$1 is already running!\n\n"; else docker rm so-$1 >/dev/null 2>&1 ; salt-call state.apply elasticfleet queue=True; fi ;;
*) if docker ps | grep -E -q '^so-$1$'; then printf "\n$1 is already running\n\n"; else docker rm so-$1 >/dev/null 2>&1 ; salt-call state.apply $1 queue=True; fi ;;
esac
case "$1" in
"all")
salt-call state.highstate queue=True
;;
"elastic-fleet"|"elasticfleet")
if docker_check_running "elastic-fleet"; then
printf "\nso-%s is already running!\n\n" "elastic-fleet"
/usr/sbin/so-status
else
echo -e "\nPlease provide an argument by running like so-start $component, or by using the component-specific script.\nEx. so-start logstash, or so-logstash-start\n"
docker rm "so-elastic-fleet" 2> /dev/null
salt-call state.apply elasticfleet queue=True
fi
;;
*)
if docker_check_running "$1"; then
printf "\nso-%s is already running\n\n" "$1"
/usr/sbin/so-status
else
docker rm "so-${1}" 2> /dev/null
salt-call state.apply "$1" queue=True
fi
;;
esac
+10 -5
View File
@@ -74,13 +74,13 @@ def output(options, console, code, data):
summary = { "status_code": code, "containers": data }
print(json.dumps(summary))
elif "-q" not in options:
if code == 2:
console.print(" [bold yellow]:hourglass: [bold white]System appears to be starting. No highstate has completed since the system was restarted.")
elif code == 99:
if code == 99:
console.print(" [bold red]:exclamation: [bold white]Installation does not appear to be complete. A highstate has not fully completed.")
elif code == 100:
console.print(" [bold red]:exclamation: [bold white]Installation encountered errors.")
else:
if code == 2:
console.print(" [bold yellow]:hourglass: [bold white]System appears to be starting. No highstate has completed since the system was restarted. Container status is shown below.")
table = Table(title = "Security Onion Status", show_edge = False, safe_box = True, box = box.MINIMAL)
table.add_column("Container", justify="right", style="white", no_wrap=True)
table.add_column("Status", justify="left", style="green", no_wrap=True)
@@ -154,8 +154,14 @@ def check_status(options, console):
code = check_installation_status(options, console)
if code == 0:
code = check_system_status(options, console)
# Containers now start on boot without a highstate, so gather/display their
# status even when the system is still "starting" (code 2). Keep the starting
# code as the exit/status_code so SOC keeps showing the "restarting" message
# on the Grid until a highstate completes.
if code == 0 or code == 2:
container_code, container_list = check_container_status(options, console)
if code == 0:
code, container_list = check_container_status(options, console)
code = container_code
output(options, console, code, container_list)
return code
@@ -180,4 +186,3 @@ def main():
if __name__ == "__main__":
main()
+27 -13
View File
@@ -5,21 +5,35 @@
# https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0.
# Usage: so-stop kibana | playbook | thehive
# shellcheck disable=SC1091
. /usr/sbin/so-common
if [ $# -ge 1 ]; then
echo $banner
printf "Stopping $1...\n"
echo $banner
usage() {
echo "Usage: $0 <component>"
echo ""
echo "Examples:"
echo " $0 kibana Stop Kibana"
exit 1
}
case $1 in
*) docker stop so-$1 ; docker rm so-$1 ;;
esac
else
echo -e "\nPlease provide an argument by running like so-stop $component, or by using the component-specific script.\nEx. so-stop logstash, or so-logstash-stop\n"
if [[ $# -lt 1 ]]; then
usage
fi
#shellcheck disable=SC2154
echo "$banner"
printf "Stopping %s...\n" "$1"
echo "$banner"
case $1 in
"elasticfleet"|"elastic-fleet")
docker_check_running "elastic-fleet" "--stop"
docker rm "so-elastic-fleet" 2> /dev/null
# Removing the elastic fleet state directory, so that the next startup re-enrolls with a fresh policy
rm -rf /opt/so/conf/elastic-fleet/state
;;
*)
docker_check_running "$1" "--stop"
docker rm "so-${1}" 2> /dev/null
;;
esac
+2 -1
View File
@@ -63,7 +63,8 @@ function status {
function pcapinfo() {
PCAP=$1
ARGS=$2
docker run --rm -v "$PCAP:/input.pcap" --entrypoint capinfos {{ MANAGER }}:5000/{{ IMAGEREPO }}/so-pcaptools:{{ VERSION }} /input.pcap -ae $ARGS
docker run --rm -v "$PCAP:/input.pcap" --entrypoint capinfos {{ MANAGER }}:5000/{{ IMAGEREPO }}/so-pcaptools:{{ VERSION }} /input.pcap -ae $ARGS |\
sed 's/First packet/Earliest packet/g' | sed 's/Last packet/Latest packet/g'
}
function pcapfix() {
@@ -1,5 +1,3 @@
{% import_yaml 'salt/minion.defaults.yaml' as SALT_MINION_DEFAULTS -%}
#!/bin/bash
#
# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one
@@ -7,7 +5,7 @@
# https://securityonion.net/license; you may not use this file except in compliance with the
# Elastic License 2.0.
{% from 'salt/schedule.map.jinja' import SCHEDULEMERGED %}
# this script checks the time the file /opt/so/log/salt/state-apply-test was last modified and restarts the salt-minion service if it is outside a threshold date/time
# the file is modified via file.touch using a scheduled job healthcheck.salt-minion.state-apply-test that runs a state.apply.
@@ -20,12 +18,14 @@
QUIET=false
UPTIME_REQ=1800 #in seconds, how long the box has to be up before considering restarting salt-minion due to /opt/so/log/salt/state-apply-test not being touched
HIGHSTATE_UPTIME_REQ=900 #in seconds; if the box has been up this long and no highstate has completed since boot, force one
CURRENT_TIME=$(date +%s)
SYSTEM_START_TIME=$(date -d "$(</proc/uptime awk '{print $1}') seconds ago" +%s)
LAST_HIGHSTATE_END=$([ -e "/opt/so/log/salt/lasthighstate" ] && date -r /opt/so/log/salt/lasthighstate +%s || echo 0)
LAST_HEALTHCHECK_STATE_APPLY=$([ -e "/opt/so/log/salt/state-apply-test" ] && date -r /opt/so/log/salt/state-apply-test +%s || echo 0)
# SETTING THRESHOLD TO ANYTHING UNDER 600 seconds may cause a lot of salt-minion restarts since the job to touch the file occurs every 5-8 minutes by default
THRESHOLD={{SALT_MINION_DEFAULTS.salt.minion.check_threshold}} #within how many seconds the file /opt/so/log/salt/state-apply-test must have been touched/modified before the salt minion is restarted
# THRESHOLD is derived from the salt schedule highstate interval + 1 hour, so the minion-check grace period tracks the schedule automatically.
THRESHOLD=$(( ({{ SCHEDULEMERGED.highstate_interval_hours }} + 1) * 3600 )) #within how many seconds the file /opt/so/log/salt/state-apply-test must have been touched/modified before the salt minion is restarted
THRESHOLD_DATE=$((LAST_HEALTHCHECK_STATE_APPLY+THRESHOLD))
logCmd() {
@@ -77,24 +77,50 @@ done
log "running so-salt-minion-check"
RESTARTED=false
# Check 1 (minion-restart-check): if the minion has stopped applying states (the
# state-apply-test healthcheck file has gone stale), restart the salt-minion service.
if [ $CURRENT_TIME -ge $((SYSTEM_START_TIME+$UPTIME_REQ)) ]; then
if [ $THRESHOLD_DATE -le $CURRENT_TIME ]; then
log "salt-minion is unable to apply states" E
log "/opt/so/log/salt/healthcheck-state-apply not touched by required date: `date -d @$THRESHOLD_DATE`, last touched: `date -d @$LAST_HEALTHCHECK_STATE_APPLY`" I
log "last highstate completed at `date -d @$LAST_HIGHSTATE_END`" I
log "checking if any jobs are running" I
log "[minion-restart-check] salt-minion is unable to apply states; restarting salt-minion" E
log "[minion-restart-check] state-apply-test not touched by required date `date -d @$THRESHOLD_DATE`, last touched `date -d @$LAST_HEALTHCHECK_STATE_APPLY`" I
log "[minion-restart-check] last highstate completed at `date -d @$LAST_HIGHSTATE_END`" I
log "[minion-restart-check] checking if any jobs are running" I
logCmd "salt-call --local saltutil.running" I
log "ensure salt.minion-state-apply-test is enabled" I
log "[minion-restart-check] ensure salt.minion-state-apply-test is enabled" I
logCmd "salt-call state.enable salt.minion-state-apply-test" I
log "ensure highstate is enabled" I
log "[minion-restart-check] ensure highstate is enabled" I
logCmd "salt-call state.enable highstate" I
log "killing all salt-minion processes" I
log "[minion-restart-check] killing all salt-minion processes" I
logCmd "pkill -9 -ef /usr/bin/salt-minion" I
log "starting salt-minion service" I
log "[minion-restart-check] starting salt-minion service" I
logCmd "systemctl start salt-minion" I
log "[minion-restart-check] waiting for salt-minion to become ready, then applying highstate in the background (queued)" I
nohup bash -c '/usr/sbin/so-salt-minion-wait; salt-call state.highstate queue=True' >> "/opt/so/log/salt/so-salt-minion-check" 2>&1 &
RESTARTED=true
else
log "/opt/so/log/salt/healthcheck-state-apply last touched: `date -d @$LAST_HEALTHCHECK_STATE_APPLY` must be touched by `date -d @$THRESHOLD_DATE` to avoid salt-minion restart" I
log "[minion-restart-check] healthy: state-apply-test last touched `date -d @$LAST_HEALTHCHECK_STATE_APPLY`, must go stale past `date -d @$THRESHOLD_DATE` to trigger a salt-minion restart" I
fi
else
log "system uptime only $((CURRENT_TIME-SYSTEM_START_TIME)) seconds does not meet $UPTIME_REQ second requirement." I
log "[minion-restart-check] skipped: system uptime $((CURRENT_TIME-SYSTEM_START_TIME))s is below the ${UPTIME_REQ}s minimum required before a salt-minion restart" I
fi
# Check 2 (boot-highstate-check): if the host has been up long enough but no highstate
# has completed since this boot, force one. This recovers a host whose boot highstate
# (so-boot-highstate.service) failed or was skipped, even while the minion is otherwise
# healthy (touching state-apply-test). We deliberately do NOT enable highstate here: if
# soup has disabled it during an upgrade, Salt will refuse the highstate and we avoid
# forcing one mid-upgrade.
if $RESTARTED; then
log "[boot-highstate-check] skipped: minion-restart-check already queued a highstate this run" I
elif [ $CURRENT_TIME -lt $((SYSTEM_START_TIME+HIGHSTATE_UPTIME_REQ)) ]; then
log "[boot-highstate-check] skipped: system uptime $((CURRENT_TIME-SYSTEM_START_TIME))s is below the ${HIGHSTATE_UPTIME_REQ}s minimum required before forcing a highstate" I
elif [ $LAST_HIGHSTATE_END -ge $SYSTEM_START_TIME ]; then
log "[boot-highstate-check] healthy: a highstate completed at `date -d @$LAST_HIGHSTATE_END`, after this boot at `date -d @$SYSTEM_START_TIME`" I
elif salt-call --local saltutil.running 2>/dev/null | grep -q 'state.highstate'; then
log "[boot-highstate-check] no highstate has completed since boot, but one is already running; skipping" I
else
log "[boot-highstate-check] no highstate has completed since boot after $((CURRENT_TIME-SYSTEM_START_TIME))s uptime; applying highstate" E
nohup bash -c 'salt-call state.highstate -l info queue=True' >> "/opt/so/log/salt/so-salt-minion-check" 2>&1 &
fi
+2 -1
View File
@@ -9,7 +9,8 @@
prune_images:
cmd.run:
- name: so-docker-prune
- order: last
- onlyif: command -v /usr/sbin/so-docker-prune >/dev/null 2>&1
- order: 9000
{% else %}
+1
View File
@@ -19,6 +19,7 @@ wait_for_elasticsearch:
so-elastalert:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastalert:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: elastalert
- name: so-elastalert
- user: so-elastalert
@@ -15,6 +15,7 @@ include:
so-elastic-fleet-package-registry:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-fleet-package-registry:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-elastic-fleet-package-registry
- hostname: Fleet-package-reg-{{ GLOBALS.hostname }}
- detach: True
@@ -51,6 +52,16 @@ so-elastic-fleet-package-registry:
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
{% endfor %}
{% endif %}
wait_for_so-elastic-fleet-package-registry:
http.wait_for_successful_query:
- name: "http://localhost:8080/health"
- status: 200
- wait_for: 300
- request_interval: 15
- require:
- docker_container: so-elastic-fleet-package-registry
delete_so-elastic-fleet-package-registry_so-status.disabled:
file.uncomment:
- name: /opt/so/conf/so-status/so-status.conf
+1
View File
@@ -16,6 +16,7 @@ include:
so-elastic-agent:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-elastic-agent
- hostname: {{ GLOBALS.hostname }}
- detach: True
+1 -1
View File
@@ -173,7 +173,7 @@ eaoptionalintegrationsdir:
{% for minion in node_data %}
{% set role = node_data[minion]["role"] %}
{% if role in [ "eval","fleet","heavynode","import","manager", "managerhype", "managersearch","standalone" ] %}
{% if role in [ "eval","fleet","import","manager", "managerhype", "managersearch","standalone" ] %}
{% set optional_integrations = ELASTICFLEETMERGED.optional_integrations %}
{% set integration_keys = optional_integrations.keys() %}
fleet_server_integrations_{{ minion }}:
@@ -9,7 +9,6 @@
{% set CORE_ESFLEET_PACKAGES = ELASTICFLEETDEFAULTS.get('elasticfleet', {}).get('packages', {}) %}
{% set ADDON_CONTENT_INTEGRATION_DEFAULTS = {} %}
{% set DEBUG_STUFF = {} %}
{% for pkg in ADDON_CONTENT_PACKAGE_COMPONENTS %}
{% if pkg.name in CORE_ESFLEET_PACKAGES %}
-1
View File
@@ -1,6 +1,5 @@
elasticfleet:
enabled: False
patch_version: 9.3.3+build202604082258 # Elastic Agent specific patch release.
enable_manager_output: True
config:
server:
+24
View File
@@ -11,6 +11,10 @@
{# This value is generated during node install and stored in minion pillar #}
{% set SERVICETOKEN = salt['pillar.get']('elasticfleet:config:server:es_token','') %}
{# Prevent Elastic Agent from re-enrolling with a new agent.id everytime the container starts up.
- if a fresh enrollment is needed use 'so-stop elasticfleet'
#}
{% set ENROLLED = salt['file.file_exists']('/opt/so/conf/elastic-fleet/state/fleet.enc') %}
include:
- ca
@@ -26,7 +30,9 @@ include:
wait_for_elasticsearch_elasticfleet:
cmd.run:
- name: so-elasticsearch-wait
{% endif %}
{% if GLOBALS.role == "so-fleet" %}
# Sync Elastic Agent artifacts to Fleet Node
elasticagent_syncartifacts:
file.recurse:
@@ -40,6 +46,7 @@ elasticagent_syncartifacts:
so-elastic-fleet:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-elastic-fleet
- hostname: FleetServer-{{ GLOBALS.hostname }}
- detach: True
@@ -63,6 +70,7 @@ so-elastic-fleet:
- /etc/pki/elasticfleet-server.crt:/etc/pki/elasticfleet-server.crt:ro
- /etc/pki/elasticfleet-server.key:/etc/pki/elasticfleet-server.key:ro
- /etc/pki/tls/certs/intca.crt:/etc/pki/tls/certs/intca.crt:ro
- /opt/so/conf/elastic-fleet/state:/usr/share/elastic-agent/state
- /opt/so/log/elasticfleet:/usr/share/elastic-agent/logs
{% if DOCKERMERGED.containers['so-elastic-fleet'].custom_bind_mounts %}
{% for BIND in DOCKERMERGED.containers['so-elastic-fleet'].custom_bind_mounts %}
@@ -70,6 +78,7 @@ so-elastic-fleet:
{% endfor %}
{% endif %}
- environment:
{% if not ENROLLED %}
- FLEET_SERVER_ENABLE=true
- FLEET_URL=https://{{ GLOBALS.hostname }}:8220
- FLEET_SERVER_ELASTICSEARCH_HOST=https://{{ GLOBALS.manager }}:9200
@@ -79,6 +88,9 @@ so-elastic-fleet:
- FLEET_SERVER_CERT_KEY=/etc/pki/elasticfleet-server.key
- FLEET_CA=/etc/pki/tls/certs/intca.crt
- FLEET_SERVER_ELASTICSEARCH_CA=/etc/pki/tls/certs/intca.crt
{% endif %}
- STATE_PATH=/usr/share/elastic-agent/state
- CONFIG_PATH=/usr/share/elastic-agent/state
- LOGS_PATH=logs
{% if DOCKERMERGED.containers['so-elastic-fleet'].extra_env %}
{% for XTRAENV in DOCKERMERGED.containers['so-elastic-fleet'].extra_env %}
@@ -97,8 +109,20 @@ so-elastic-fleet:
- x509: etc_elasticfleet_crt
- require:
- file: trusttheca
- file: eastatedir
- x509: etc_elasticfleet_key
- x509: etc_elasticfleet_crt
wait_for_so-elastic-fleet:
http.wait_for_successful_query:
- name: "https://localhost:8220/api/status"
- ssl: True
- verify_ssl: False
- status: 200
- wait_for: 300
- request_interval: 15
- require:
- docker_container: so-elastic-fleet
{% endif %}
delete_so-elastic-fleet_so-status.disabled:
@@ -5,7 +5,7 @@
"package": {
"name": "endpoint",
"title": "Elastic Defend",
"version": "9.3.0",
"version": "9.3.1",
"requires_root": true
},
"enabled": true,
@@ -29,7 +29,7 @@
"\\.gz$"
],
"include_files": [],
"processors": "- dissect:\n tokenizer: \"/nsm/import/%{import.id}/evtx/%{import.file}\"\n field: \"log.file.path\"\n target_prefix: \"\"\n- decode_json_fields:\n fields: [\"message\"]\n target: \"\"\n- drop_fields:\n fields: [\"host\"]\n ignore_missing: true\n- add_fields:\n target: data_stream\n fields:\n type: logs\n dataset: system.security\n- add_fields:\n target: event\n fields:\n dataset: system.security\n module: system\n imported: true\n- add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.security-2.15.0\n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-Sysmon/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.sysmon_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.sysmon_operational\n module: windows\n imported: true\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.sysmon_operational-3.8.0\n- if:\n equals:\n winlog.channel: 'Application'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.application\n - add_fields:\n target: event\n fields:\n dataset: system.application\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.application-2.15.0\n- if:\n equals:\n winlog.channel: 'System'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.system\n - add_fields:\n target: event\n fields:\n dataset: system.system\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.system-2.15.0\n \n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-PowerShell/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.powershell_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.powershell_operational\n module: windows\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.powershell_operational-3.8.0\n- add_fields:\n target: data_stream\n fields:\n dataset: import",
"processors": "- dissect:\n tokenizer: \"/nsm/import/%{import.id}/evtx/%{import.file}\"\n field: \"log.file.path\"\n target_prefix: \"\"\n- decode_json_fields:\n fields: [\"message\"]\n target: \"\"\n- drop_fields:\n fields: [\"host\"]\n ignore_missing: true\n- add_fields:\n target: data_stream\n fields:\n type: logs\n dataset: system.security\n- add_fields:\n target: event\n fields:\n dataset: system.security\n module: system\n imported: true\n- add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.security-2.20.0\n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-Sysmon/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.sysmon_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.sysmon_operational\n module: windows\n imported: true\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.sysmon_operational-3.8.3\n- if:\n equals:\n winlog.channel: 'Application'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.application\n - add_fields:\n target: event\n fields:\n dataset: system.application\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.application-2.20.0\n- if:\n equals:\n winlog.channel: 'System'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: system.system\n - add_fields:\n target: event\n fields:\n dataset: system.system\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-system.system-2.20.0\n \n- if:\n equals:\n winlog.channel: 'Microsoft-Windows-PowerShell/Operational'\n then: \n - add_fields:\n target: data_stream\n fields:\n dataset: windows.powershell_operational\n - add_fields:\n target: event\n fields:\n dataset: windows.powershell_operational\n module: windows\n - add_fields:\n target: \"@metadata\"\n fields:\n pipeline: logs-windows.powershell_operational-3.8.3\n- add_fields:\n target: data_stream\n fields:\n dataset: import",
"tags": [
"import"
],
@@ -9,7 +9,6 @@
{% set CORE_ESFLEET_PACKAGES = ELASTICFLEETDEFAULTS.get('elasticfleet', {}).get('packages', {}) %}
{% set ADDON_INPUT_INTEGRATION_DEFAULTS = {} %}
{% set DEBUG_STUFF = {} %}
{% for pkg in ADDON_INPUT_PACKAGE_COMPONENTS %}
{% if pkg.name in CORE_ESFLEET_PACKAGES %}
@@ -116,7 +115,6 @@
{% do ADDON_INPUT_INTEGRATION_DEFAULTS.update({integration_key: integration_defaults}) %}
{% do DEBUG_STUFF.update({integration_key: "Generating defaults for "+ pkg.name })%}
{% endfor %}
{% endif %}
{% endif %}
+12 -5
View File
@@ -10,6 +10,15 @@
{% set AGENT_STATUS = salt['service.available']('elastic-agent') %}
{% set AGENT_EXISTS = salt['file.file_exists']('/opt/Elastic/Agent/elastic-agent') %}
so-elastic-agent-install:
file.managed:
- name: /usr/sbin/so-elastic-agent-install
- source: salt://elasticfleet/tools/sbin/so-elastic-agent-install
- user: 947
- group: 939
- mode: 755
- show_changes: False
{% if not AGENT_STATUS or not AGENT_EXISTS %}
pull_agent_installer:
@@ -21,11 +30,9 @@ pull_agent_installer:
run_installer:
cmd.run:
- name: ./so-elastic-agent_linux_amd64 -token={{ GRIDNODETOKEN }} -force
- cwd: /opt/so
- retry:
attempts: 3
interval: 20
- name: /usr/sbin/so-elastic-agent-install "{{ GRIDNODETOKEN }}"
- require:
- file: pull_agent_installer
cleanup_agent_installer:
file.absent:
+25 -15
View File
@@ -9,26 +9,19 @@
include:
- elasticfleet.config
- kibana.enabled
# If enabled, automatically update Fleet Logstash Outputs
{% if ELASTICFLEETMERGED.config.server.enable_auto_configuration and grains.role not in ['so-import', 'so-eval'] %}
{% if ELASTICFLEETMERGED.config.server.enable_auto_configuration %}
{% if grains.role not in ['so-import', 'so-eval']%}
so-elastic-fleet-auto-configure-logstash-outputs:
cmd.run:
- name: /usr/sbin/so-elastic-fleet-outputs-update
- retry:
attempts: 4
interval: 30
{# Separate from above in order to catch elasticfleet-logstash.crt changes and force update to fleet output policy #}
so-elastic-fleet-auto-configure-logstash-outputs-force:
cmd.run:
- name: /usr/sbin/so-elastic-fleet-outputs-update --certs
- retry:
attempts: 4
interval: 30
- onchanges:
- x509: etc_elasticfleet_logstash_crt
- x509: elasticfleet_kafka_crt
- require:
- http: wait_for_so-kibana
{% endif %}
# If enabled, automatically update Fleet Server URLs & ES Connection
@@ -38,6 +31,9 @@ so-elastic-fleet-auto-configure-server-urls:
- retry:
attempts: 4
interval: 30
- require:
- http: wait_for_so-kibana
{% endif %}
# Automatically update Fleet Server Elasticsearch URLs & Agent Artifact URLs
so-elastic-fleet-auto-configure-elasticsearch-urls:
@@ -46,6 +42,8 @@ so-elastic-fleet-auto-configure-elasticsearch-urls:
- retry:
attempts: 4
interval: 30
- require:
- http: wait_for_so-kibana
so-elastic-fleet-auto-configure-artifact-urls:
cmd.run:
@@ -53,6 +51,8 @@ so-elastic-fleet-auto-configure-artifact-urls:
- retry:
attempts: 4
interval: 30
- require:
- http: wait_for_so-kibana
so-elastic-fleet-package-statefile:
file.managed:
@@ -64,9 +64,9 @@ so-elastic-fleet-package-upgrade:
- name: /usr/sbin/so-elastic-fleet-package-upgrade
- retry:
attempts: 3
interval: 10
- onchanges:
- file: /opt/so/state/elastic_fleet_packages.txt
interval: 30
- require:
- http: wait_for_so-kibana
so-elastic-fleet-integrations:
cmd.run:
@@ -74,6 +74,8 @@ so-elastic-fleet-integrations:
- retry:
attempts: 3
interval: 10
- require:
- http: wait_for_so-kibana
so-elastic-agent-grid-upgrade:
cmd.run:
@@ -81,6 +83,8 @@ so-elastic-agent-grid-upgrade:
- retry:
attempts: 12
interval: 5
- require:
- http: wait_for_so-kibana
so-elastic-fleet-integration-upgrade:
cmd.run:
@@ -88,16 +92,22 @@ so-elastic-fleet-integration-upgrade:
- retry:
attempts: 3
interval: 10
- require:
- http: wait_for_so-kibana
{# Optional integrations script doesn't need the retries like so-elastic-fleet-integration-upgrade which loads the default integrations #}
so-elastic-fleet-addon-integrations:
cmd.run:
- name: /usr/sbin/so-elastic-fleet-optional-integrations-load
- require:
- http: wait_for_so-kibana
{% if ELASTICFLEETMERGED.config.defend_filters.enable_auto_configuration %}
so-elastic-defend-manage-filters-file-watch:
cmd.run:
- name: python3 /sbin/so-elastic-defend-manage-filters.py -c /opt/so/conf/elasticsearch/curl.config -d /opt/so/conf/elastic-fleet/defend-exclusions/disabled-filters.yaml -i /nsm/securityonion-resources/event_filters/ -i /opt/so/conf/elastic-fleet/defend-exclusions/rulesets/custom-filters/ &>> /opt/so/log/elasticfleet/elastic-defend-manage-filters.log
- require:
- http: wait_for_so-kibana
- onchanges:
- file: elasticdefendcustom
- file: elasticdefenddisabled
@@ -0,0 +1,100 @@
#!/bin/bash
# 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.
. /usr/sbin/so-elastic-fleet-common
# passed in as arg from elasticfleet/install_agent_grid.sls, else pulled from pillar later
GRIDNODETOKEN="$1"
LOGFILE="/opt/so/SO-Elastic-Agent_Installer_Health.log"
check_agent_health() {
timeout=300
interval=10
start=$SECONDS
while (( SECONDS - start < timeout )); do
agent_status=$(elastic-agent status 2>&1)
echo -e "\n$(date)\n$agent_status\n" >> "$LOGFILE"
if echo "$agent_status" | grep -A1 'elastic-agent$' | grep -q 'status: (HEALTHY)'; then
return 0
fi
echo "The Elastic Agent is not yet healthy. Waiting for ${interval} seconds before checking again..."
sleep "$interval"
done
echo "The Elastic Agent did not become healthy within ${timeout} seconds"
return 1
}
uninstall_agent() {
if command -v elastic-agent >/dev/null 2>&1; then
elastic-agent uninstall -f
fi
}
if [[ -z "$GRIDNODETOKEN" ]]; then
noderole=$(so-yaml.py get -r /etc/salt/grains role)
if [[ "$noderole" == "so-heavynode" ]]; then
GRIDNODETOKEN=$(salt-call pillar.get global:fleet_grid_enrollment_token_heavy --out=newline_values_only)
else
GRIDNODETOKEN=$(salt-call pillar.get global:fleet_grid_enrollment_token_general --out=newline_values_only)
fi
fi
if [[ -z "$GRIDNODETOKEN" ]]; then
echo "Unable to determine Elastic Fleet enrollment token. Exiting."
exit 1
fi
if [[ ! -x /opt/so/so-elastic-agent_linux_amd64 ]]; then
echo "Downloading so-elastic-agent installer... This could take a while if another Salt job is running."
# When running outside of elasticfleet/install_agent_grid.sls we need to download the installer independently.
# PYTHONWARNINGS="ignore" to avoid messages like the following when running salt-call:
# '/opt/saltstack/salt/lib/python3.10/site-packages/salt/transport/base.py:129: TransportWarning: Unclosed transport! <salt.transport.zeromq.RequestClient object at 0x7fc5f0ee7a30>
# File "/bin/salt-call", line 12, in <module>
# sys.exit(salt_call())'
PYTHONWARNINGS="ignore" salt-call state.single file.managed name=/opt/so/so-elastic-agent_linux_amd64 source=salt://elasticfleet/files/so_agent-installers/so-elastic-agent_linux_amd64 mode=755 makedirs=True queue=True
fi
if [[ -x /opt/so/so-elastic-agent_linux_amd64 ]]; then
attempts=0
cd /opt/so/ || exit 1
truncate -s 0 "$LOGFILE"
uninstall_agent
while [[ $attempts -lt 3 ]]; do
if ./so-elastic-agent_linux_amd64 -token="$GRIDNODETOKEN" -force && echo "Verifying Elastic Agent health..." && check_agent_health; then
rm -f /opt/so/so-elastic-agent_linux_amd64
elastic-agent status
exit 0
fi
attempts=$((attempts + 1))
if [[ $attempts -lt 3 ]]; then
echo "Unable to verify Elastic Agent health... Retrying in 20 seconds..."
sleep 20
fi
done
uninstall_agent
rm -f /opt/so/so-elastic-agent_linux_amd64
echo "The so-elastic-agent installer failed after 3 attempts. Exiting."
exit 1
else
echo "Unable to locate so-elastic-agent installer. Exiting."
exit 1
fi
@@ -30,6 +30,94 @@ fleet_api() {
curl -sK /opt/so/conf/elasticsearch/curl.config -L "localhost:5601/api/fleet/${QUERYPATH}" "$@" --retry 3 --retry-delay 10 --fail 2>/dev/null
}
# Max number of concurrent Fleet write jobs (create/update). Override via env if needed.
MAX_FLEET_JOBS=${MAX_FLEET_JOBS:-10}
# Block until fewer than MAX_FLEET_JOBS background jobs are running.
elastic_fleet_throttle() {
while (( $(jobs -rp | wc -l) >= MAX_FLEET_JOBS )); do
wait -n || true
done
}
# Load every integration JSON in a directory into a single agent policy.
# The agent policy is fetched ONCE (not per file), and the create/update writes
# are dispatched as throttled background jobs.
# $1 AGENT_POLICY - the agent policy id/name to load integrations into
# $2 DIR - directory of integration *.json files
# $3 LABEL - human-readable label for log output
# $4 SKIP_CREATE_NAME - (optional) integration name to skip when creating (still updated if present)
# Returns 1 if the policy cannot be fetched or if any integration failed to create/update.
elastic_fleet_load_integrations_dir() {
local AGENT_POLICY=$1
local DIR=$2
local LABEL=$3
local SKIP_CREATE_NAME=$4
local POLICY_JSON FAIL_FILE OUT_DIR INTEGRATION NAME ID i
FAIL_FILE=$(mktemp)
# Each job buffers its full output (header + API response) into its own file so the
# parent can print them grouped and in submission order after concurrent writes finish.
OUT_DIR=$(mktemp -d)
i=0
# Fetch the agent policy a single time; we look up integration ids locally below.
if ! POLICY_JSON=$(fleet_api "agent_policies/$AGENT_POLICY"); then
echo "Error: Failed to retrieve agent policy '$AGENT_POLICY'."
rm -f "$FAIL_FILE"
rm -rf "$OUT_DIR"
return 1
fi
if ! jq -e '.item.package_policies' <<<"$POLICY_JSON" >/dev/null 2>&1; then
echo "Error: Invalid agent policy response for '$AGENT_POLICY'."
rm -f "$FAIL_FILE"
rm -rf "$OUT_DIR"
return 1
fi
for INTEGRATION in "$DIR"/*.json; do
[ -e "$INTEGRATION" ] || continue
NAME=$(jq -r .name "$INTEGRATION")
ID=$(jq -r --arg n "$NAME" '.item.package_policies[]? | select(.name==$n) | .id' <<<"$POLICY_JSON")
elastic_fleet_throttle
{
local RESP
if [ -n "$ID" ]; then
printf "\n\n%s - Updating integration %s\n" "$LABEL" "$NAME"
if ! RESP=$(elastic_fleet_integration_update "$ID" "@$INTEGRATION"); then
flock 9; echo "update ${INTEGRATION##*/}" >&9
fi
printf '%s\n' "$RESP"
elif [ -n "$SKIP_CREATE_NAME" ] && [ "$NAME" == "$SKIP_CREATE_NAME" ]; then
printf "\n\n%s - Skipping creation of %s\n" "$LABEL" "$NAME"
else
printf "\n\n%s - Creating integration %s\n" "$LABEL" "$NAME"
if ! RESP=$(elastic_fleet_integration_create "@$INTEGRATION"); then
flock 9; echo "create ${INTEGRATION##*/}" >&9
fi
printf '%s\n' "$RESP"
fi
} >"$OUT_DIR/$(printf '%03d' "$i")" 9>>"$FAIL_FILE" &
i=$((i+1))
done
wait || true
# Emit per-integration output grouped and in submission order (glob sorts numerically).
cat "$OUT_DIR"/* 2>/dev/null
rm -rf "$OUT_DIR"
local rc=0
if [ -s "$FAIL_FILE" ]; then
printf "\n%s: failed integrations:\n" "$LABEL"
cat "$FAIL_FILE"
rc=1
fi
rm -f "$FAIL_FILE"
return $rc
}
elastic_fleet_integration_check() {
AGENT_POLICY=$1
@@ -46,7 +134,9 @@ elastic_fleet_integration_create() {
JSON_STRING=$1
if ! fleet_api "package_policies" -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -XPOST -d "$JSON_STRING"; then
# --retry-all-errors so transient 409 conflicts (concurrent writes to the same agent
# policy) are retried; curl --retry alone does not retry 409.
if ! fleet_api "package_policies" --retry-all-errors -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -XPOST -d "$JSON_STRING"; then
return 1
fi
}
@@ -77,7 +167,9 @@ elastic_fleet_integration_update() {
JSON_STRING=$2
if ! fleet_api "package_policies/$UPDATE_ID" -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -XPUT -d "$JSON_STRING"; then
# --retry-all-errors so transient 409 conflicts (concurrent writes to the same agent
# policy) are retried; curl --retry alone does not retry 409.
if ! fleet_api "package_policies/$UPDATE_ID" --retry-all-errors -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -XPUT -d "$JSON_STRING"; then
return 1
fi
}
@@ -240,7 +332,7 @@ elastic_fleet_policy_create() {
--arg DESC "$DESC" \
--arg TIMEOUT $TIMEOUT \
--arg FLEETSERVER "$FLEETSERVER" \
'{"name": $NAME,"id":$NAME,"description":$DESC,"namespace":"default","monitoring_enabled":["logs"],"inactivity_timeout":$TIMEOUT,"has_fleet_server":$FLEETSERVER}'
'{"name": $NAME,"id":$NAME,"description":$DESC,"namespace":"default","monitoring_enabled":["logs"],"inactivity_timeout":$TIMEOUT,"has_fleet_server":$FLEETSERVER,"advanced_settings":{"agent_logging_level": "warning"}}'
)
# Create Fleet Policy
if ! fleet_api "agent_policies" -XPOST -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d "$JSON_STRING"; then
@@ -9,108 +9,45 @@
RETURN_CODE=0
if [ ! -f /opt/so/state/eaintegrations.txt ]; then
# First, check for any package upgrades
/usr/sbin/so-elastic-fleet-package-upgrade
# Second, update Fleet Server policies
# update Fleet Server policies
/usr/sbin/so-elastic-fleet-integration-policy-elastic-fleet-server
# Third, configure Elastic Defend Integration seperately
# configure Elastic Defend Integration separately
/usr/sbin/so-elastic-fleet-integration-policy-elastic-defend
# Each group fetches its agent policy once and dispatches create/update writes concurrently.
# Initial Endpoints
for INTEGRATION in /opt/so/conf/elastic-fleet/integrations/endpoints-initial/*.json; do
printf "\n\nInitial Endpoints Policy - Loading $INTEGRATION\n"
elastic_fleet_integration_check "endpoints-initial" "$INTEGRATION"
if [ -n "$INTEGRATION_ID" ]; then
printf "\n\nIntegration $NAME exists - Updating integration\n"
if ! elastic_fleet_integration_update "$INTEGRATION_ID" "@$INTEGRATION"; then
echo -e "\nFailed to update integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
else
printf "\n\nIntegration does not exist - Creating integration\n"
if ! elastic_fleet_integration_create "@$INTEGRATION"; then
echo -e "\nFailed to create integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
fi
done
elastic_fleet_load_integrations_dir "endpoints-initial" \
/opt/so/conf/elastic-fleet/integrations/endpoints-initial "Initial Endpoints Policy" || RETURN_CODE=1
# Grid Nodes - General
for INTEGRATION in /opt/so/conf/elastic-fleet/integrations/grid-nodes_general/*.json; do
printf "\n\nGrid Nodes Policy_General - Loading $INTEGRATION\n"
elastic_fleet_integration_check "so-grid-nodes_general" "$INTEGRATION"
if [ -n "$INTEGRATION_ID" ]; then
printf "\n\nIntegration $NAME exists - Updating integration\n"
if ! elastic_fleet_integration_update "$INTEGRATION_ID" "@$INTEGRATION"; then
echo -e "\nFailed to update integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
else
printf "\n\nIntegration does not exist - Creating integration\n"
if ! elastic_fleet_integration_create "@$INTEGRATION"; then
echo -e "\nFailed to create integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
fi
done
elastic_fleet_load_integrations_dir "so-grid-nodes_general" \
/opt/so/conf/elastic-fleet/integrations/grid-nodes_general "Grid Nodes Policy_General" || RETURN_CODE=1
# Grid Nodes - Heavy
for INTEGRATION in /opt/so/conf/elastic-fleet/integrations/grid-nodes_heavy/*.json; do
printf "\n\nGrid Nodes Policy_Heavy - Loading $INTEGRATION\n"
elastic_fleet_integration_check "so-grid-nodes_heavy" "$INTEGRATION"
if [ -n "$INTEGRATION_ID" ]; then
printf "\n\nIntegration $NAME exists - Updating integration\n"
if ! elastic_fleet_integration_update "$INTEGRATION_ID" "@$INTEGRATION"; then
echo -e "\nFailed to update integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
else
printf "\n\nIntegration does not exist - Creating integration\n"
if ! elastic_fleet_integration_create "@$INTEGRATION"; then
echo -e "\nFailed to create integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
fi
done
elastic_fleet_load_integrations_dir "so-grid-nodes_heavy" \
/opt/so/conf/elastic-fleet/integrations/grid-nodes_heavy "Grid Nodes Policy_Heavy" || RETURN_CODE=1
# Fleet Server - Optional integrations
for INTEGRATION in /opt/so/conf/elastic-fleet/integrations-optional/FleetServer*/*.json; do
if ! [ "$INTEGRATION" == "/opt/so/conf/elastic-fleet/integrations-optional/FleetServer*/*.json" ]; then
FLEET_POLICY=`echo "$INTEGRATION"| cut -d'/' -f7`
printf "\n\nFleet Server Policy - Loading $INTEGRATION\n"
elastic_fleet_integration_check "$FLEET_POLICY" "$INTEGRATION"
if [ -n "$INTEGRATION_ID" ]; then
printf "\n\nIntegration $NAME exists - Updating integration\n"
if ! elastic_fleet_integration_update "$INTEGRATION_ID" "@$INTEGRATION"; then
echo -e "\nFailed to update integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
else
printf "\n\nIntegration does not exist - Creating integration\n"
if [ "$NAME" != "elasticsearch-logs" ]; then
if ! elastic_fleet_integration_create "@$INTEGRATION"; then
echo -e "\nFailed to create integration for ${INTEGRATION##*/}"
RETURN_CODE=1
continue
fi
fi
fi
fi
# Fleet Server - Optional integrations (adds integration configuration to a given FleetServer_ policy)
for FLEET_DIR in /opt/so/conf/elastic-fleet/integrations-optional/FleetServer*/; do
[ -d "$FLEET_DIR" ] || continue
INTEGRATIONS=("${FLEET_DIR%/}"/*.json)
[ -e "${INTEGRATIONS[0]}" ] || continue
FLEET_POLICY=$(basename "$FLEET_DIR")
elastic_fleet_load_integrations_dir "$FLEET_POLICY" \
"${FLEET_DIR%/}" "Fleet Server Policy" "elasticsearch-logs" || RETURN_CODE=1
done
# Only create the state file if all policies were created/updated successfully
if [[ "$RETURN_CODE" != "1" ]]; then
if [[ $RETURN_CODE -eq 0 ]]; then
touch /opt/so/state/eaintegrations.txt
else
exit 1
fi
else
exit $RETURN_CODE
echo "Fleet integration policies already loaded."
exit 0
fi
@@ -30,7 +30,7 @@ done
if [[ -z $FLEETHOST ]] || [[ -z $ENROLLMENTOKEN ]]; then
printf "\nFleet Host URL, Enrollment Token or Elastic Version empty - exiting..."
printf "\nFleet Host: $FLEETHOST, Enrollment Token: $ENROLLMENTOKEN\n"
exit
exit 1
fi
OSARCH=( "linux-x86_64" "windows-x86_64" "darwin-x86_64" "darwin-aarch64" )
@@ -62,31 +62,54 @@ do
done
GOTARGETOS=( "linux" "windows" "darwin" "darwin/arm64" )
GOARCH="amd64"
printf "\n### Generating OS packages using the cleaned up tarballs"
for GOOS in "${GOTARGETOS[@]}"
do
for GOOS in "${GOTARGETOS[@]}"; do
GOARCH="amd64"
if [[ $GOOS == 'darwin/arm64' ]]; then GOOS="darwin" && GOARCH="arm64"; fi
printf "\n\n### Generating $GOOS/$GOARCH Installer...\n"
docker run -e CGO_ENABLED=0 -e GOOS=$GOOS -e GOARCH=$GOARCH \
--mount type=bind,source=/etc/pki/tls/certs/,target=/workspace/files/cert/ \
--mount type=bind,source=/nsm/elastic-agent-workspace/,target=/workspace/files/elastic-agent/ \
--mount type=bind,source=/opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/,target=/output/ \
--mount type=bind,source=/opt/so/saltstack/local/salt/elasticfleet/files/,target=/output/ \
{{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent-builder:{{ GLOBALS.so_version }} go build -ldflags "-X main.fleetHostURLsList=$FLEETHOST -X main.enrollmentToken=$ENROLLMENTOKEN" -o /output/so-elastic-agent_${GOOS}_${GOARCH}
printf "\n### $GOOS/$GOARCH Installer Generated...\n"
done
printf "\n\n### Generating MSI...\n"
cp /opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/so-elastic-agent_windows_amd64 /opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/so-elastic-agent_windows_amd64.exe
cp /opt/so/saltstack/local/salt/elasticfleet/files/so-elastic-agent_windows_amd64 /opt/so/saltstack/local/salt/elasticfleet/files/so-elastic-agent_windows_amd64.exe
docker run \
--mount type=bind,source=/opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/,target=/output/ -w /output \
--mount type=bind,source=/opt/so/saltstack/local/salt/elasticfleet/files/,target=/output/ -w /output \
{{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elastic-agent-builder:{{ GLOBALS.so_version }} wixl -o so-elastic-agent_windows_amd64_msi --arch x64 /workspace/so-elastic-agent.wxs
printf "\n### MSI Generated...\n"
# Verify installers were created
for GOOS in "${GOTARGETOS[@]}"; do
GOARCH="amd64"
if [[ $GOOS == 'darwin/arm64' ]]; then GOOS="darwin"; GOARCH="arm64"; fi
if [[ ! -f /opt/so/saltstack/local/salt/elasticfleet/files/so-elastic-agent_${GOOS}_${GOARCH} ]]; then
printf "\n### ERROR: Installer for %s/%s was not generated. Exiting...\n" "$GOOS" "$GOARCH"
exit 1
fi
# After verifying new installer was generated, move it to so_agent-installers directory
mv /opt/so/saltstack/local/salt/elasticfleet/files/so-elastic-agent_${GOOS}_${GOARCH} /opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/
done
# Verify MSI installer
if [[ ! -f /opt/so/saltstack/local/salt/elasticfleet/files/so-elastic-agent_windows_amd64_msi ]]; then
printf "\n### ERROR: Installer MSI was not generated. Exiting...\n"
exit 1
else
# After verifying new installer MSI was generated, move it to so_agent-installers directory
mv /opt/so/saltstack/local/salt/elasticfleet/files/so-elastic-agent_windows_amd64_msi /opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/
fi
printf "\n### Cleaning up temp files \n"
rm -rf /nsm/elastic-agent-workspace
rm -rf /opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/so-elastic-agent_windows_amd64.exe
rm -rf /opt/so/saltstack/local/salt/elasticfleet/files/so-elastic-agent_windows_amd64.exe
printf "\n### Copying so_agent-installers to /nsm/elastic-fleet/ for nginx.\n"
\cp -vr /opt/so/saltstack/local/salt/elasticfleet/files/so_agent-installers/ /nsm/elastic-fleet/
chmod 644 /nsm/elastic-fleet/so_agent-installers/*
# if we got here all installers have been generated successfully
exit 0
@@ -23,73 +23,90 @@ if [ $? -ne 0 ]; then
fi
default_packages=({% for pkg in SUPPORTED_PACKAGES %}"{{ pkg }}"{% if not loop.last %} {% endif %}{% endfor %})
# JSON array of the default packages, used by the jq filter below.
default_packages_json=$(printf '%s\n' "${default_packages[@]}" | jq -R . | jq -s '.')
# Output lock (serializes concurrent job output) and failure file (one marker line per
# failed integration). Mirrors the pattern used by elastic_fleet_load_integrations_dir.
OUTPUT_LOCK=$(mktemp)
FAIL_FILE=$(mktemp)
trap 'rm -f "$OUTPUT_LOCK" "$FAIL_FILE"' EXIT
# Cache of package name -> latest available version, so the same package is only looked up
# once instead of once per (policy, integration).
declare -A LATEST_VERSION_CACHE
ERROR=false
for AGENT_POLICY in $agent_policies; do
if ! integrations=$(elastic_fleet_integration_policy_names "$AGENT_POLICY"); then
# Fetch the agent policy a single time; package name/version and integration id are all
# extracted locally below instead of re-fetching the same policy per integration.
if ! POLICY_JSON=$(fleet_api "agent_policies/$AGENT_POLICY"); then
# this script upgrades default integration packages, exit 1 and let salt handle retrying
exit 1
fi
for INTEGRATION in $integrations; do
if ! [[ "$INTEGRATION" == "elastic-defend-endpoints" ]] && ! [[ "$INTEGRATION" == "fleet_server-"* ]]; then
# Get package name so we know what package to look for when checking the current and latest available version
if ! PACKAGE_NAME=$(elastic_fleet_integration_policy_package_name "$AGENT_POLICY" "$INTEGRATION"); then
exit 1
fi
{%- if not AUTO_UPGRADE_INTEGRATIONS %}
if [[ " ${default_packages[@]} " =~ " $PACKAGE_NAME " ]]; then
{%- endif %}
# Get currently installed version of package
attempt=0
max_attempts=3
while [ $attempt -lt $max_attempts ]; do
if PACKAGE_VERSION=$(elastic_fleet_integration_policy_package_version "$AGENT_POLICY" "$INTEGRATION") && AVAILABLE_VERSION=$(elastic_fleet_package_latest_version_check "$PACKAGE_NAME"); then
break
fi
attempt=$((attempt + 1))
done
if [ $attempt -eq $max_attempts ]; then
echo "Error: Failed getting $PACKAGE_VERSION or $AVAILABLE_VERSION"
exit 1
fi
# Get integration ID
if ! INTEGRATION_ID=$(elastic_fleet_integration_id "$AGENT_POLICY" "$INTEGRATION"); then
# One jq pass emits name/package.name/package.version/id for every eligible integration.
# The endpoint/fleet_server skips and the default-package gate are applied here in jq.
# $defaults (not $def, a jq reserved keyword) holds the default package list.
while IFS=$'\t' read -r INTEGRATION PACKAGE_NAME PACKAGE_VERSION INTEGRATION_ID; do
[ -n "$INTEGRATION" ] || continue
# Look up the latest available version once per package, then memoize it.
if [[ -z "${LATEST_VERSION_CACHE[$PACKAGE_NAME]+set}" ]]; then
if ! AVAILABLE_VERSION=$(elastic_fleet_package_latest_version_check "$PACKAGE_NAME"); then
echo "Error: Failed getting latest version for $PACKAGE_NAME"
exit 1
fi
LATEST_VERSION_CACHE[$PACKAGE_NAME]=$AVAILABLE_VERSION
fi
AVAILABLE_VERSION=${LATEST_VERSION_CACHE[$PACKAGE_NAME]}
if [[ "$PACKAGE_VERSION" != "$AVAILABLE_VERSION" ]]; then
# Dry run of the upgrade
echo ""
echo "Current $PACKAGE_NAME package version ($PACKAGE_VERSION) is not the same as the latest available package ($AVAILABLE_VERSION)..."
echo "Upgrading $INTEGRATION..."
echo "Starting dry run..."
# Dry run, then (if clean) the actual upgrade, dispatched as a throttled background
# job. Each job builds its full log into one block, then flushes it under a single
# shared lock (OUTPUT_LOCK) so concurrent jobs never interleave on stdout; a failed
# job also appends a marker line to FAIL_FILE while holding that same lock.
elastic_fleet_throttle
{
block=$'\n'"Current $PACKAGE_NAME package version ($PACKAGE_VERSION) is not the same as the latest available package ($AVAILABLE_VERSION)..."$'\n'
block+="Upgrading $INTEGRATION..."$'\n'"Starting dry run..."$'\n'
fail=""
if ! DRYRUN_OUTPUT=$(elastic_fleet_integration_policy_dryrun_upgrade "$INTEGRATION_ID"); then
exit 1
fi
DRYRUN_ERRORS=$(echo "$DRYRUN_OUTPUT" | jq .[].hasErrors)
# If no errors with dry run, proceed with actual upgrade
if [[ "$DRYRUN_ERRORS" == "false" ]]; then
echo "No errors detected. Proceeding with upgrade..."
block+="Error: Failed to complete dry run for '$INTEGRATION_ID'."$'\n'
fail="dryrun $INTEGRATION"
elif [[ "$(jq .[].hasErrors <<<"$DRYRUN_OUTPUT")" == "false" ]]; then
block+="No errors detected. Proceeding with upgrade..."$'\n'
if ! elastic_fleet_integration_policy_upgrade "$INTEGRATION_ID"; then
echo "Error: Upgrade failed for $PACKAGE_NAME with integration ID '$INTEGRATION_ID'."
ERROR=true
continue
block+="Error: Upgrade failed for $PACKAGE_NAME with integration ID '$INTEGRATION_ID'."$'\n'
fail="upgrade $INTEGRATION"
fi
else
echo "Errors detected during dry run for $PACKAGE_NAME policy upgrade..."
ERROR=true
continue
block+="Errors detected during dry run for $PACKAGE_NAME policy upgrade..."$'\n'
fail="dryrun-errors $INTEGRATION"
fi
{
flock 9
printf '%s' "$block"
[ -n "$fail" ] && printf '%s\n' "$fail" >>"$FAIL_FILE"
} 9>>"$OUTPUT_LOCK"
} &
fi
done < <(jq -r --argjson defaults "$default_packages_json" '
.item.package_policies[]
| select(.name != "elastic-defend-endpoints")
| select(.name | startswith("fleet_server-") | not)
{%- if not AUTO_UPGRADE_INTEGRATIONS %}
fi
| select(.package.name | IN($defaults[]))
{%- endif %}
fi
| [.name, .package.name, .package.version, .id] | @tsv
' <<<"$POLICY_JSON")
done
done
if [[ "$ERROR" == "true" ]]; then
# Barrier: wait for every dispatched dry-run/upgrade job to finish.
wait
if [ -s "$FAIL_FILE" ]; then
printf '\nFailed integration upgrades:\n'
cat "$FAIL_FILE"
exit 1
fi
echo
@@ -16,7 +16,6 @@
STATE_FILE_SUCCESS=/opt/so/state/estemplates.txt
INSTALLED_PACKAGE_LIST=/tmp/esfleet_installed_packages.json
BULK_INSTALL_PACKAGE_LIST=/tmp/esfleet_bulk_install.json
BULK_INSTALL_PACKAGE_TMP=/tmp/esfleet_bulk_install_tmp.json
BULK_INSTALL_OUTPUT=/opt/so/state/esfleet_bulk_install_results.json
INTEGRATION_PACKAGE_COMPONENTS=/opt/so/state/esfleet_package_components.json
INPUT_PACKAGE_COMPONENTS=/opt/so/state/esfleet_input_package_components.json
@@ -29,29 +28,6 @@ PENDING_UPDATE=false
# Requiring some level of manual Elastic Stack configuration before installation
EXCLUDED_INTEGRATIONS=('apm')
version_conversion(){
version=$1
echo "$version" | awk -F '.' '{ printf("%d%03d%03d\n", $1, $2, $3); }'
}
compare_versions() {
version1=$1
version2=$2
# Convert versions to numbers
num1=$(version_conversion "$version1")
num2=$(version_conversion "$version2")
# Compare using bc
if (( $(echo "$num1 < $num2" | bc -l) )); then
echo "less"
elif (( $(echo "$num1 > $num2" | bc -l) )); then
echo "greater"
else
echo "equal"
fi
}
IFS=$'\n'
agent_policies=$(elastic_fleet_agent_policy_ids)
if [ $? -ne 0 ]; then
@@ -63,23 +39,23 @@ default_packages=({% for pkg in SUPPORTED_PACKAGES %}"{{ pkg }}"{% if not loop.l
in_use_integrations=()
# Fetch each agent policy once; its package_policies[] already contain both the integration name
# and the .package.name, so extract all non-default package names locally in a single jq instead
# of re-fetching the same policy per integration.
default_packages_json=$(printf '%s\n' "${default_packages[@]}" | jq -R . | jq -s '.')
for AGENT_POLICY in $agent_policies; do
if ! integrations=$(elastic_fleet_integration_policy_names "$AGENT_POLICY"); then
if ! policy_json=$(fleet_api "agent_policies/$AGENT_POLICY"); then
# skip the agent policy if we can't get required info, let salt retry. Integrations loaded by this script are non-default integrations.
echo "Skipping $AGENT_POLICY.. "
continue
fi
for INTEGRATION in $integrations; do
if ! PACKAGE_NAME=$(elastic_fleet_integration_policy_package_name "$AGENT_POLICY" "$INTEGRATION"); then
echo "Not adding $INTEGRATION, couldn't get package name"
continue
fi
# non-default integrations that are in-use in any policy
if ! [[ " ${default_packages[@]} " =~ " $PACKAGE_NAME " ]]; then
in_use_integrations+=("$PACKAGE_NAME")
fi
done
while IFS= read -r PACKAGE_NAME; do
[ -n "$PACKAGE_NAME" ] && in_use_integrations+=("$PACKAGE_NAME")
done < <(jq -r --argjson defaults "$default_packages_json" \
'.item.package_policies[].package.name | select(. as $n | ($defaults | index($n)) | not)' \
<<<"$policy_json")
done
if [[ -f $STATE_FILE_SUCCESS ]]; then
@@ -90,72 +66,55 @@ if [[ -f $STATE_FILE_SUCCESS ]]; then
rm -f $INSTALLED_PACKAGE_LIST
echo $latest_package_list | jq '{packages: [.items[] | {name: .name, latest_version: .version, installed_version: .installationInfo.version, subscription: .conditions.elastic.subscription }]}' >> $INSTALLED_PACKAGE_LIST
while read -r package; do
# get package details
package_name=$(echo "$package" | jq -r '.name')
latest_version=$(echo "$package" | jq -r '.latest_version')
installed_version=$(echo "$package" | jq -r '.installed_version')
subscription=$(echo "$package" | jq -r '.subscription')
bulk_package=$(echo "$package" | jq '{name: .name, version: .latest_version}' )
# Build the bulk install list and the per-package status messages with two jq passes
# instead of a per-package bash loop. The old loop forked ~10 processes per package
# (5 jq + awk/bc for the version compare) and re-parsed/rewrote a growing JSON file on
# every add (O(n^2)). Selection and messages below are identical to that logic.
SUB={% if SUB %}true{% else %}false{% endif %}
AUTOUP={% if AUTO_UPGRADE_INTEGRATIONS %}true{% else %}false{% endif %}
EXCLUDED_JSON=$(printf '%s\n' "${EXCLUDED_INTEGRATIONS[@]}" | jq -R 'select(length>0)' | jq -s '.')
INUSE_JSON=$(printf '%s\n' "${in_use_integrations[@]}" | jq -R 'select(length>0)' | jq -s 'unique')
if [[ ! "${EXCLUDED_INTEGRATIONS[@]}" =~ "$package_name" ]]; then
{% if not SUB %}
if [[ "$subscription" != "basic" && "$subscription" != "null" && -n "$subscription" ]]; then
# pass over integrations that require non-basic elastic license
echo "$package_name integration requires an Elastic license of $subscription or greater... skipping"
continue
else
if [[ "$installed_version" == "null" || -z "$installed_version" ]]; then
echo "$package_name is not installed... Adding to next update."
jq --argjson package "$bulk_package" '.packages += [$package]' $BULK_INSTALL_PACKAGE_LIST > $BULK_INSTALL_PACKAGE_TMP && mv $BULK_INSTALL_PACKAGE_TMP $BULK_INSTALL_PACKAGE_LIST
# vnum replicates the previous version_conversion (%d%03d%03d of the first three dotted
# fields); needs() replicates the excluded/subscription/installed/upgrade/in-use logic.
JQ_DECISION='
def vnum:
[ (split(".")|.[0:3][] | gsub("[^0-9].*";"") | (if .=="" then "0" else . end) | tonumber) ]
| (.[0]//0)*1000000 + (.[1]//0)*1000 + (.[2]//0);
def needs($sub;$autoup;$excluded;$inuse):
.name as $n
| ($n | IN($excluded[]) | not)
and ( $sub or (.subscription==null or .subscription=="basic" or .subscription=="") )
and ( (.installed_version==null or .installed_version=="")
or ( ((.latest_version|vnum) > (.installed_version|vnum))
and ( $autoup or ($n | IN($inuse[]) | not) ) ) );'
PENDING_UPDATE=true
else
results=$(compare_versions "$latest_version" "$installed_version")
if [ $results == "greater" ]; then
{#- When auto_upgrade_integrations is false, skip upgrading in_use_integrations #}
{%- if not AUTO_UPGRADE_INTEGRATIONS %}
if ! [[ " ${in_use_integrations[@]} " =~ " $package_name " ]]; then
{%- endif %}
echo "$package_name is at version $installed_version latest version is $latest_version... Adding to next update."
jq --argjson package "$bulk_package" '.packages += [$package]' $BULK_INSTALL_PACKAGE_LIST > $BULK_INSTALL_PACKAGE_TMP && mv $BULK_INSTALL_PACKAGE_TMP $BULK_INSTALL_PACKAGE_LIST
JQ_ARGS=(--argjson sub "$SUB" --argjson autoup "$AUTOUP" --argjson excluded "$EXCLUDED_JSON" --argjson inuse "$INUSE_JSON")
# (a) Per-package status messages (parity with the previous echo output).
jq -r "${JQ_ARGS[@]}" "$JQ_DECISION"'
.packages[]
| .name as $n
| if ($n|IN($excluded[])) then "Skipping \($n)..."
elif (($sub|not) and (.subscription!=null and .subscription!="basic" and .subscription!="")) then
"\($n) integration requires an Elastic license of \(.subscription) or greater... skipping"
elif (.installed_version==null or .installed_version=="") then
"\($n) is not installed... Adding to next update."
elif ((.latest_version|vnum) > (.installed_version|vnum)) then
(if ($autoup or ($n|IN($inuse[])|not))
then "\($n) is at version \(.installed_version) latest version is \(.latest_version)... Adding to next update."
else "skipping available upgrade for in use integration - \($n)." end)
else empty end
' "$INSTALLED_PACKAGE_LIST"
# (b) The bulk install list, built in a single pass.
jq "${JQ_ARGS[@]}" "$JQ_DECISION"'
{packages: [ .packages[] | select(needs($sub;$autoup;$excluded;$inuse)) | {name, version: .latest_version} ]}
' "$INSTALLED_PACKAGE_LIST" > "$BULK_INSTALL_PACKAGE_LIST"
if jq -e '.packages | length > 0' "$BULK_INSTALL_PACKAGE_LIST" >/dev/null; then
PENDING_UPDATE=true
{%- if not AUTO_UPGRADE_INTEGRATIONS %}
else
echo "skipping available upgrade for in use integration - $package_name."
fi
{%- endif %}
fi
fi
fi
{% else %}
if [[ "$installed_version" == "null" || -z "$installed_version" ]]; then
echo "$package_name is not installed... Adding to next update."
jq --argjson package "$bulk_package" '.packages += [$package]' $BULK_INSTALL_PACKAGE_LIST > $BULK_INSTALL_PACKAGE_TMP && mv $BULK_INSTALL_PACKAGE_TMP $BULK_INSTALL_PACKAGE_LIST
PENDING_UPDATE=true
else
results=$(compare_versions "$latest_version" "$installed_version")
if [ $results == "greater" ]; then
{#- When auto_upgrade_integrations is false, skip upgrading in_use_integrations #}
{%- if not AUTO_UPGRADE_INTEGRATIONS %}
if ! [[ " ${in_use_integrations[@]} " =~ " $package_name " ]]; then
{%- endif %}
echo "$package_name is at version $installed_version latest version is $latest_version... Adding to next update."
jq --argjson package "$bulk_package" '.packages += [$package]' $BULK_INSTALL_PACKAGE_LIST > $BULK_INSTALL_PACKAGE_TMP && mv $BULK_INSTALL_PACKAGE_TMP $BULK_INSTALL_PACKAGE_LIST
PENDING_UPDATE=true
{%- if not AUTO_UPGRADE_INTEGRATIONS %}
else
echo "skipping available upgrade for in use integration - $package_name."
fi
{%- endif %}
fi
fi
{% endif %}
else
echo "Skipping $package_name..."
fi
done <<< "$(jq -c '.packages[]' "$INSTALLED_PACKAGE_LIST")"
if [ "$PENDING_UPDATE" = true ]; then
# Run chunked install of packages
@@ -235,6 +235,16 @@ function update_kafka_outputs() {
{% endif %}
# Compare the current Elastic Fleet certificate against what is on disk
POLICY_CERT_SHA=$(jq -r '.item.ssl.certificate' <<< $RAW_JSON | openssl x509 -noout -sha256 -fingerprint)
DISK_CERT_SHA=$(openssl x509 -in /etc/pki/elasticfleet-logstash.crt -noout -sha256 -fingerprint)
if [[ "$POLICY_CERT_SHA" != "$DISK_CERT_SHA" ]]; then
printf "Certificate on disk doesn't match certificate in policy - forcing update\n"
UPDATE_CERTS=true
FORCE_UPDATE=true
fi
# Sort & hash the new list of Logstash Outputs
NEW_LIST_JSON=$(jq --compact-output --null-input '$ARGS.positional' --args -- "${NEW_LIST[@]}")
NEW_HASH=$(sha256sum <<< "$NEW_LIST_JSON" | awk '{print $1}')
@@ -8,18 +8,35 @@
. /usr/sbin/so-elastic-fleet-common
PKG_LOAD_FAILURES=0
PKG_LOAD_FAILURES_NAMES=()
{%- for PACKAGE in SUPPORTED_PACKAGES %}
echo "Upgrading {{ PACKAGE }} package..."
if VERSION=$(elastic_fleet_package_latest_version_check "{{ PACKAGE }}"); then
if ! elastic_fleet_package_install "{{ PACKAGE }}" "$VERSION"; then
# exit 1 on failure to upgrade a default package, allow salt to handle retries
echo -e "\nERROR: Failed to upgrade $PACKAGE to version: $VERSION"
exit 1
if INSTALLED_VERSION=$(elastic_fleet_package_version_check "{{ PACKAGE }}") && LATEST_VERSION=$(elastic_fleet_package_latest_version_check "{{ PACKAGE }}"); then
if [ "$INSTALLED_VERSION" == "$LATEST_VERSION" ]; then
echo "{{ PACKAGE }} integration version $INSTALLED_VERSION is already at the reported latest version $LATEST_VERSION, skipping upgrade."
else
echo "Upgrading {{ PACKAGE }} package to version $LATEST_VERSION..."
if ! elastic_fleet_package_install "{{ PACKAGE }}" "$LATEST_VERSION"; then
PKG_LOAD_FAILURES=$((PKG_LOAD_FAILURES + 1))
PKG_LOAD_FAILURES_NAMES+=("{{ PACKAGE }}")
fi
fi
else
echo -e "\nERROR: Failed to get version information for integration $PACKAGE"
echo "ERROR: Failed to get version information for integration {{ PACKAGE }}"
PKG_LOAD_FAILURES=$((PKG_LOAD_FAILURES + 1))
PKG_LOAD_FAILURES_NAMES+=("{{ PACKAGE }}")
fi
echo
{%- endfor %}
echo
/usr/sbin/so-elasticsearch-templates-load
if [ $PKG_LOAD_FAILURES -gt 0 ]; then
echo "ERROR: Failed to upgrade $PKG_LOAD_FAILURES package(s):"
for PKG in "${PKG_LOAD_FAILURES_NAMES[@]}"; do
echo " - $PKG"
done
# exit 1 on failure to upgrade a default package, allow salt to handle retries
exit 1
else
echo "Successfully upgraded all packages."
fi
@@ -181,6 +181,9 @@ if ! elastic_fleet_policy_create "so-grid-nodes_heavy" "SO Grid Nodes - Heavy No
exit 1
fi
# Check for package upgrades
so-elastic-fleet-package-upgrade
# Load Integrations for default policies
so-elastic-fleet-integration-policy-load
@@ -241,11 +244,37 @@ printf '%s\n'\
"" >> "$global_pillar_file"
# Call Elastic-Fleet Salt State
printf "\nApplying elasticfleet state"
salt-call state.apply elasticfleet queue=True
printf "\nApplying elasticfleet state\n"
for state_attempt in {1..3}; do
if salt-call state.apply elasticfleet queue=True; then
break
elif [[ $state_attempt -lt 3 ]]; then
printf "\nElasticfleet state did not complete successfully... Attempt (%s/3). Retrying...\n" "$state_attempt"
sleep 10
else
printf "\nFailure(s) in elasticfleet state... Exiting...\n"
exit 1
fi
done
printf "\nRunning so-elastic-agent-gen-installers\n"
# Generate installers & install Elastic Agent on the node
so-elastic-agent-gen-installers
printf "\nApplying elasticfleet.install_agent_grid state"
salt-call state.apply elasticfleet.install_agent_grid queue=True
exit 0
for agent_gen_attempt in {1..3}; do
if so-elastic-agent-gen-installers; then
break
elif [[ $agent_gen_attempt -lt 3 ]]; then
printf "\nUnable to generate Elastic Agent installers... Attempt (%s/3). Retrying...\n" "$agent_gen_attempt"
sleep 10
else
printf "\nFailed to generate Elastic Agent installers after 3 attempts. Exiting...\n"
exit 1
fi
done
printf "\nApplying elasticfleet.install_agent_grid state\n"
if ! salt-call state.apply elasticfleet.install_agent_grid queue=True; then
printf "\nFailure(s) in elasticfleet.install_agent_grid state... Exiting...\n"
exit 1
fi
printf "\nElastic Fleet setup completed successfully\n"
+32 -2
View File
@@ -9,9 +9,12 @@
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
{% from 'elasticsearch/template.map.jinja' import ES_INDEX_SETTINGS, SO_MANAGED_INDICES %}
{% if GLOBALS.role != 'so-heavynode' %}
{% from 'elasticsearch/template.map.jinja' import ALL_ADDON_SETTINGS %}
{% from 'elasticsearch/template.map.jinja' import ALL_ADDON_SETTINGS, ADDON_INDICES %}
{% endif %}
include:
- elasticsearch.enabled
escomponenttemplates:
file.recurse:
- name: /opt/so/conf/elasticsearch/templates/component
@@ -35,6 +38,20 @@ so_index_template_dir:
{%- endfor %}
{%- endif %}
{% if GLOBALS.role != "so-heavynode" %}
# Clean up legacy and non-SO managed templates from the elasticsearch/templates/addon-index/ directory
addon_index_template_dir:
file.directory:
- name: /opt/so/conf/elasticsearch/templates/addon-index
- clean: True
{%- if ADDON_INDICES %}
- require:
{%- for index in ADDON_INDICES %}
- file: addon_index_template_{{index}}
{%- endfor %}
{%- endif %}
{% endif %}
# Auto-generate index templates for SO managed indices (directly defined in elasticsearch/defaults.yaml)
# These index templates are for the core SO datasets and are always required
{% for index, settings in ES_INDEX_SETTINGS.items() %}
@@ -116,6 +133,18 @@ so-elasticsearch-templates:
- docker_container: so-elasticsearch
- file: elasticsearch_sbin_jinja
so-elasticsearch-dlm-apply:
cmd.run:
- name: /usr/sbin/so-elasticsearch-dlm-apply
- cwd: /opt/so
- require:
- docker_container: so-elasticsearch
- file: elasticsearch_sbin_jinja
- cmd: so-elasticsearch-templates
- retry:
attempts: 3
interval: 10
so-elasticsearch-pipelines:
cmd.run:
- name: /usr/sbin/so-elasticsearch-pipelines {{ GLOBALS.hostname }}
@@ -136,7 +165,8 @@ so-elasticsearch-roles-load:
{% set ap = "absent" %}
{% endif %}
{% if grains.role in ['so-eval', 'so-standalone', 'so-heavynode'] %}
{% if ELASTICSEARCHMERGED.index_clean %}
{# Remove so-elasticsearch-indices-delete script when using DLM #}
{% if ELASTICSEARCHMERGED.index_clean and ELASTICSEARCHMERGED.data_retention_method == "ILM" %}
{% set ap = "present" %}
{% else %}
{% set ap = "absent" %}
+135 -4
View File
@@ -1,7 +1,8 @@
elasticsearch:
enabled: false
version: 9.3.3
version: 9.3.7
index_clean: true
data_retention_method: DLM
vm:
max_map_count: 1048576
config:
@@ -63,6 +64,8 @@ elasticsearch:
verification_mode: none
index_settings:
global_overrides:
data_stream_lifecycle:
data_retention: 90d
index_template:
template:
settings:
@@ -143,6 +146,8 @@ elasticsearch:
order: desc
so-common:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -304,6 +309,8 @@ elasticsearch:
number_of_shards: 1
so-assistant-chat:
index_sorting: false
data_stream_lifecycle:
data_retention: ""
index_template:
composed_of:
- assistant-chat-mappings
@@ -344,6 +351,8 @@ elasticsearch:
min_age: 0ms
so-assistant-session:
index_sorting: false
data_stream_lifecycle:
data_retention: ""
index_template:
composed_of:
- assistant-session-mappings
@@ -497,6 +506,8 @@ elasticsearch:
min_age: 30d
so-idh:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -605,6 +616,8 @@ elasticsearch:
min_age: 30d
so-import:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -787,6 +800,8 @@ elasticsearch:
min_age: 0ms
so-kismet:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- kismet-mappings
@@ -836,6 +851,8 @@ elasticsearch:
min_age: 30d
so-kratos:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -904,6 +921,8 @@ elasticsearch:
min_age: 30d
so-hydra:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -1049,6 +1068,8 @@ elasticsearch:
min_age: 0ms
so-logs:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- so-data-streams-mappings
@@ -1129,6 +1150,8 @@ elasticsearch:
min_age: 30d
so-logs-detections_x_alerts:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- so-data-streams-mappings
@@ -1192,6 +1215,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1307,6 +1332,8 @@ elasticsearch:
min_age: 30d
so-elastic-agent-monitor:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1369,6 +1396,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_apm_server:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.apm_server@package
@@ -1433,6 +1462,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_auditbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.auditbeat@package
@@ -1497,6 +1528,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_cloudbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.cloudbeat@package
@@ -1561,6 +1594,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_endpoint_security:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1620,6 +1655,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_filebeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1679,6 +1716,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_fleet_server:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1735,6 +1774,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_heartbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.heartbeat@package
@@ -1799,6 +1840,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_metricbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1858,6 +1901,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_osquerybeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -1917,6 +1962,8 @@ elasticsearch:
min_age: 30d
so-logs-elastic_agent_x_packetbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elastic_agent.packetbeat@package
@@ -1981,6 +2028,8 @@ elasticsearch:
min_age: 30d
so-logs-elasticsearch_x_server:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-elasticsearch.server@package
@@ -2045,10 +2094,13 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_actions:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.actions@package
- .logs-endpoint.actions@custom
- endpoint@custom
- event-mappings
- so-fleet_integrations.ip_mappings-1
- so-fleet_globals-1
@@ -2058,8 +2110,9 @@ elasticsearch:
hidden: false
ignore_missing_component_templates:
- .logs-endpoint.actions@custom
- endpoint@custom
index_patterns:
- logs-endpoint.actions-*
- .logs-endpoint.actions-*
priority: 501
template:
settings:
@@ -2104,10 +2157,13 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_action_x_responses:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.action.responses@package
- .logs-endpoint.action.responses@custom
- endpoint@custom
- event-mappings
- so-fleet_integrations.ip_mappings-1
- so-fleet_globals-1
@@ -2117,14 +2173,15 @@ elasticsearch:
hidden: false
ignore_missing_component_templates:
- .logs-endpoint.action.responses@custom
- endpoint@custom
index_patterns:
- logs-endpoint.action.responses-*
- .logs-endpoint.action.responses-*
priority: 501
template:
settings:
index:
lifecycle:
name: so-logs-endpoint.actions-logs
name: so-logs-endpoint.action.responses-logs
mapping:
total_fields:
limit: 5000
@@ -2163,6 +2220,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_alerts:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.alerts@package
@@ -2222,6 +2281,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_diagnostic_x_collection:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.diagnostic.collection@package
@@ -2297,6 +2358,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_api:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.api@package
@@ -2356,6 +2419,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_file:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.file@package
@@ -2415,6 +2480,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_library:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.library@package
@@ -2474,6 +2541,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_network:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.network@package
@@ -2533,6 +2602,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_process:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.process@package
@@ -2592,6 +2663,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_registry:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.registry@package
@@ -2651,6 +2724,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_events_x_security:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-endpoint.events.security@package
@@ -2710,6 +2785,8 @@ elasticsearch:
min_age: 30d
so-logs-endpoint_x_heartbeat:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- .logs-endpoint.heartbeat@package
@@ -2769,6 +2846,8 @@ elasticsearch:
min_age: 30d
so-logs-http_endpoint_x_generic:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-http_endpoint.generic@package
@@ -2817,6 +2896,8 @@ elasticsearch:
min_age: 30d
so-logs-httpjson_x_generic:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-httpjson.generic@package
@@ -2882,6 +2963,8 @@ elasticsearch:
number_of_replicas: 0
so-logs-osquery-manager_x_action_x_responses:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
_meta:
managed: true
@@ -2953,6 +3036,8 @@ elasticsearch:
number_of_replicas: 0
so-logs-osquery-manager_x_result:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
_meta:
managed: true
@@ -3005,6 +3090,8 @@ elasticsearch:
min_age: 30d
so-logs-soc:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -3113,6 +3200,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_application:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3162,6 +3251,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_auth:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3211,6 +3302,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_security:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3260,6 +3353,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_syslog:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3309,6 +3404,8 @@ elasticsearch:
min_age: 30d
so-logs-system_x_system:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- event-mappings
@@ -3358,6 +3455,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_forwarded:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.forwarded@package
@@ -3405,6 +3504,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_powershell:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.powershell@package
@@ -3452,6 +3553,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_powershell_operational:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.powershell_operational@package
@@ -3499,6 +3602,8 @@ elasticsearch:
min_age: 30d
so-logs-windows_x_sysmon_operational:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-windows.sysmon_operational@package
@@ -3546,6 +3651,8 @@ elasticsearch:
min_age: 30d
so-logs-winlog_x_winlog:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- logs-winlog.winlog@package
@@ -3594,6 +3701,8 @@ elasticsearch:
min_age: 30d
so-logstash:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -3709,6 +3818,8 @@ elasticsearch:
min_age: 30d
so-metrics-endpoint_x_metadata:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics-endpoint.metadata@package
@@ -3756,6 +3867,8 @@ elasticsearch:
min_age: 30d
so-metrics-endpoint_x_metrics:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics-endpoint.metrics@package
@@ -3803,6 +3916,8 @@ elasticsearch:
min_age: 30d
so-metrics-endpoint_x_policy:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics-endpoint.policy@package
@@ -3850,6 +3965,8 @@ elasticsearch:
min_age: 30d
so-metrics-fleet_server_x_agent_status:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics@tsdb-settings
@@ -3874,6 +3991,8 @@ elasticsearch:
number_of_replicas: 0
so-metrics-fleet_server_x_agent_versions:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- metrics@tsdb-settings
@@ -3898,6 +4017,8 @@ elasticsearch:
number_of_replicas: 0
so-redis:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4013,6 +4134,8 @@ elasticsearch:
min_age: 30d
so-strelka:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4130,6 +4253,8 @@ elasticsearch:
min_age: 30d
so-suricata:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4246,6 +4371,8 @@ elasticsearch:
min_age: 30d
so-suricata_x_alerts:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4362,6 +4489,8 @@ elasticsearch:
min_age: 30d
so-syslog:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
@@ -4478,6 +4607,8 @@ elasticsearch:
min_age: 30d
so-zeek:
index_sorting: false
data_stream_lifecycle:
data_retention: 90d
index_template:
composed_of:
- agent-mappings
+1
View File
@@ -24,6 +24,7 @@ include:
so-elasticsearch:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-elasticsearch:{{ ELASTICSEARCHMERGED.version }}
- restart_policy: unless-stopped
- hostname: elasticsearch
- name: so-elasticsearch
- user: elasticsearch
+2 -1
View File
@@ -63,7 +63,8 @@
{ "set": { "if": "ctx.event?.dataset != null && !ctx.event.dataset.contains('.')", "field": "event.dataset", "value": "{{event.module}}.{{event.dataset}}" } },
{ "split": { "if": "ctx.event?.dataset != null && ctx.event.dataset.contains('.')", "field": "event.dataset", "separator": "\\.", "target_field": "dataset_tag_temp" } },
{ "append": { "if": "ctx.dataset_tag_temp != null", "field": "tags", "value": "{{dataset_tag_temp.1}}" } },
{ "grok": { "if": "ctx.http?.response?.status_code != null", "field": "http.response.status_code", "patterns": ["%{NUMBER:http.response.status_code:long} %{GREEDYDATA}"]} },
{ "grok": { "if": "ctx.http?.response?.status_code instanceof String", "field": "http.response.status_code", "patterns": ["%{NUMBER:http.response.status_code:long}(?:\\s+%{GREEDYDATA})?"], "ignore_failure": true } },
{ "convert": { "if": "ctx.http?.response?.status_code != null && !(ctx.http.response.status_code instanceof Number)", "field": "http.response.status_code", "type": "long", "ignore_failure": true } },
{ "set": { "if": "ctx?.metadata?.kafka != null" , "field": "kafka.id", "value": "{{metadata.kafka.partition}}{{metadata.kafka.offset}}{{metadata.kafka.timestamp}}", "ignore_failure": true } },
{ "remove": { "field": [ "message2", "type", "fields", "category", "module", "dataset", "dataset_tag_temp", "event.dataset_temp" ], "ignore_missing": true, "ignore_failure": true } },
{ "pipeline": { "name": "global@custom", "ignore_missing_pipeline": true, "description": "[Fleet] Global pipeline for all data streams" } }
+74 -1
View File
@@ -177,12 +177,84 @@
"description": "Extract IPs from Elastic Agent events (host.ip) and adds them to related.ip"
}
},
{
"script": {
"description": "Snapshot event.ingested into _tmp.event_ingested_pre_fleet before .fleet_final_pipeline-1 overwrites it with ES ingest time",
"lang": "painless",
"if": "ctx.event?.ingested != null && ctx.event?.created == null",
"ignore_failure": true,
"source": "ctx.putIfAbsent('_tmp', [:]); ctx._tmp.event_ingested_pre_fleet = ctx.event.ingested;"
}
},
{
"pipeline": {
"name": ".fleet_final_pipeline-1",
"ignore_missing_pipeline": true
}
},
{
"script": {
"description": "Calculate time from Elastic Agent to Logstash.",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_agent != null",
"ignore_failure": true,
"source": "ZonedDateTime start = ctx._tmp.event_ingested_pre_fleet != null ? ZonedDateTime.parse(ctx._tmp.event_ingested_pre_fleet) : ZonedDateTime.parse(ctx['@timestamp']); ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_elasticagent_to_logstash = ChronoUnit.SECONDS.between(start, ZonedDateTime.parse(ctx._tmp.logstash_from_agent));"
}
},
{
"script": {
"description": "Calculate time from Logstash to Redis",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_agent != null && ctx._tmp?.logstash_to_redis != null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_logstash_to_redis = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_agent), ZonedDateTime.parse(ctx._tmp.logstash_to_redis));"
}
},
{
"script": {
"description": "Calculate time message spends in redis queue (logstash delay in pulling event).",
"lang": "painless",
"if": "ctx._tmp?.logstash_to_redis != null && ctx._tmp?.logstash_from_redis != null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_redis_to_logstash = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_to_redis), ZonedDateTime.parse(ctx._tmp.logstash_from_redis));"
}
},
{
"script": {
"description": "Calculate time from Logstash to Elasticsearch (after read from Redis).",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_redis != null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_logstash_to_elasticsearch = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_redis), metadata().now);"
}
},
{
"script": {
"description": "Calculate time from Elastic Agent to Kafka.",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_kafka != null && ctx._tmp?.logstash_from_agent == null",
"ignore_failure": true,
"source": "ZonedDateTime start = ctx._tmp.event_ingested_pre_fleet != null ? ZonedDateTime.parse(ctx._tmp.event_ingested_pre_fleet) : ZonedDateTime.parse(ctx['@timestamp']); ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_elasticagent_to_kafka = ChronoUnit.SECONDS.between(start, ZonedDateTime.parse(ctx._tmp.logstash_from_kafka));"
}
},
{
"script": {
"description": "Calculate time message spends in Kafka queue (logstash delay in pulling event).",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_kafka != null && ctx.metadata?.kafka?.timestamp != null && ctx._tmp?.logstash_from_agent == null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_kafka_queue = ChronoUnit.SECONDS.between(ZonedDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(ctx.metadata.kafka.timestamp.toString())), ZoneId.of('UTC')), ZonedDateTime.parse(ctx._tmp.logstash_from_kafka));"
}
},
{
"script": {
"description": "Calculate time from Logstash to Elasticsearch (after read from Kafka).",
"lang": "painless",
"if": "ctx._tmp?.logstash_from_kafka != null && ctx._tmp?.logstash_from_agent == null",
"ignore_failure": true,
"source": "ctx.event.putIfAbsent('ingestion', [:]); ctx.event.ingestion.latency_kafka_to_elasticsearch = ChronoUnit.SECONDS.between(ZonedDateTime.parse(ctx._tmp.logstash_from_kafka), metadata().now);"
}
},
{
"remove": {
"field": "event.agent_id_status",
@@ -202,7 +274,8 @@
"event.dataset_temp",
"dataset_tag_temp",
"module_temp",
"datastream_dataset_temp"
"datastream_dataset_temp",
"_tmp"
],
"ignore_missing": true,
"ignore_failure": true
@@ -118,70 +118,70 @@
{
"pipeline": {
"tag": "pipeline_e16851a7",
"name": "logs-pfsense.log-1.25.2-firewall",
"name": "logs-pfsense.log-1.25.4-firewall",
"if": "ctx.event.provider == 'filterlog'"
}
},
{
"pipeline": {
"tag": "pipeline_828590b5",
"name": "logs-pfsense.log-1.25.2-openvpn",
"name": "logs-pfsense.log-1.25.4-openvpn",
"if": "ctx.event.provider == 'openvpn'"
}
},
{
"pipeline": {
"tag": "pipeline_9d37039c",
"name": "logs-pfsense.log-1.25.2-ipsec",
"name": "logs-pfsense.log-1.25.4-ipsec",
"if": "ctx.event.provider == 'charon'"
}
},
{
"pipeline": {
"tag": "pipeline_ad56bbca",
"name": "logs-pfsense.log-1.25.2-dhcp",
"name": "logs-pfsense.log-1.25.4-dhcp",
"if": "[\"dhcpd\", \"dhclient\", \"dhcp6c\", \"dnsmasq-dhcp\"].contains(ctx.event.provider)"
}
},
{
"pipeline": {
"tag": "pipeline_dd85553d",
"name": "logs-pfsense.log-1.25.2-unbound",
"name": "logs-pfsense.log-1.25.4-unbound",
"if": "ctx.event.provider == 'unbound'"
}
},
{
"pipeline": {
"tag": "pipeline_720ed255",
"name": "logs-pfsense.log-1.25.2-haproxy",
"name": "logs-pfsense.log-1.25.4-haproxy",
"if": "ctx.event.provider == 'haproxy'"
}
},
{
"pipeline": {
"tag": "pipeline_456beba5",
"name": "logs-pfsense.log-1.25.2-php-fpm",
"name": "logs-pfsense.log-1.25.4-php-fpm",
"if": "ctx.event.provider == 'php-fpm'"
}
},
{
"pipeline": {
"tag": "pipeline_a0d89375",
"name": "logs-pfsense.log-1.25.2-squid",
"name": "logs-pfsense.log-1.25.4-squid",
"if": "ctx.event.provider == 'squid'"
}
},
{
"pipeline": {
"tag": "pipeline_c2f1ed55",
"name": "logs-pfsense.log-1.25.2-snort",
"name": "logs-pfsense.log-1.25.4-snort",
"if": "ctx.event.provider == 'snort'"
}
},
{
"pipeline": {
"tag":"pipeline_33db1c9e",
"name": "logs-pfsense.log-1.25.2-suricata",
"name": "logs-pfsense.log-1.25.4-suricata",
"if": "ctx.event.provider == 'suricata'"
}
},
+71
View File
@@ -0,0 +1,71 @@
{
"description": "zeek.ja4d",
"processors": [
{
"set": {
"field": "event.dataset",
"value": "ja4d"
}
},
{
"remove": {
"field": [
"host"
],
"ignore_failure": true
}
},
{
"json": {
"field": "message",
"target_field": "message2",
"ignore_failure": true
}
},
{
"rename": {
"field": "message2.ja4d",
"target_field": "hash.ja4d",
"ignore_missing": true,
"if": "ctx?.message2?.ja4d != null && ctx.message2.ja4d.length() > 0"
}
},
{
"rename": {
"field": "message2.client_mac",
"target_field": "host.mac",
"ignore_missing": true,
"if": "ctx?.message2?.client_mac != null && ctx.message2.client_mac.length() > 0"
}
},
{
"rename": {
"field": "message2.hostname",
"target_field": "host.hostname",
"ignore_missing": true,
"if": "ctx?.message2?.hostname != null && ctx.message2.hostname.length() > 0"
}
},
{
"rename": {
"field": "message2.requested_ip",
"target_field": "dhcp.requested_address",
"ignore_missing": true,
"if": "ctx?.message2?.requested_ip != null && ctx.message2.requested_ip.length() > 0"
}
},
{
"rename": {
"field": "message2.vendor_class_id",
"target_field": "zeek.ja4d.vendor_class_id",
"ignore_missing": true,
"if": "ctx?.message2?.vendor_class_id != null && ctx.message2.vendor_class_id.length() > 0"
}
},
{
"pipeline": {
"name": "zeek.common"
}
}
]
}
+1
View File
@@ -5,6 +5,7 @@
{ "remove": { "field": ["host"], "ignore_failure": true } },
{ "json": { "field": "message", "target_field": "message2", "ignore_failure": true } },
{ "rename": { "field": "message2.version", "target_field": "ssl.version", "ignore_missing": true } },
{ "set": { "description": "Set transport for the community_id processor", "if": "ctx.ssl?.version == null || !ctx.ssl.version.startsWith('DTLS')", "field": "network.transport", "value": "tcp", "ignore_failure": true } },
{ "rename": { "field": "message2.cipher", "target_field": "ssl.cipher", "ignore_missing": true } },
{ "rename": { "field": "message2.curve", "target_field": "ssl.curve", "ignore_missing": true } },
{ "rename": { "field": "message2.server_name", "target_field": "ssl.server_name", "ignore_missing": true } },
+395 -61
View File
@@ -4,6 +4,13 @@ elasticsearch:
forcedType: bool
advanced: True
helpLink: elasticsearch
data_retention_method:
description: Method for data retention. Options are ILM or DLM. For single node deployments and most distributed grid users, DLM will be the recommended option for simplified management. Those with more complex use cases may prefer ILM. The latter allows for more granular control, but requires more management overhead.
options:
- ILM
- DLM
forcedType: string
global: True
version:
description: "This specifies the version of the following containers: so-elastic-fleet-package-registry, so-elastic-agent, so-elastic-fleet, so-kibana, so-logstash and so-elasticsearch. Modifying this value in the Elasticsearch defaults.yaml will result in catastrophic grid failure."
readonly: True
@@ -13,7 +20,7 @@ elasticsearch:
description: Specify the memory heap size in (m)egabytes for Elasticsearch.
helpLink: elasticsearch
index_clean:
description: Determines if indices should be considered for deletion by available disk space in the cluster. Otherwise, indices will only be deleted by the age defined in the ILM settings. This setting only applies to EVAL, STANDALONE, and HEAVY NODE installations. Other installations can only use ILM settings.
description: Determines if indices should be considered for deletion by available disk space in the cluster. Otherwise, data is retained by the configured lifecycle settings. This setting only applies to EVAL, STANDALONE, and HEAVY NODE installations. Other installations use lifecycle settings only.
forcedType: bool
helpLink: elasticsearch
vm:
@@ -139,6 +146,23 @@ elasticsearch:
custom010: *pipelines
index_settings:
global_overrides:
data_stream_lifecycle:
data_retention:
description: |
The retention period for all data streams. Retention does not define the period that the data will be removed, but the minimum time period they will be kept.
Use a number followed by a time unit, such as 7d. Leave blank for indefinite retention where supported.
Configured retention period also affects the frequency of rolling over data streams.
- If retention is less than or equal to 1 day, max_age will be 1 hour
- If retention is less than or equal to 14 days, max_age will be 1 day
- If retention is less than or equal to 90 days, max_age will be 7 days
- If retention is greater than 90 days, max_age will be 30 days
forcedType: string
allowedNodeTypes:
- heavynode
regex: ^$|^[0-9]{1,5}(?:d|h|m|s)$
regexFailureMessage: Must be blank or a number followed by d, h, m, or s, such as 7d.
index_template:
template:
settings:
@@ -311,13 +335,30 @@ elasticsearch:
forcedType: string
global: True
helpLink: elasticsearch
so-logs: &indexSettings
so-logs: &dataStreamSettings
index_sorting:
description: Sorts the index by event time, at the cost of additional processing resource consumption.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
data_stream_lifecycle:
data_retention:
description: |
The retention period for this data stream. Retention does not define the period that the data will be removed, but the minimum time period it will be kept.
Use a number followed by a time unit, such as 7d. Leave blank for indefinite retention where supported.
Configured retention period also affects the frequency of rolling over this data stream.
- If retention is less than or equal to 1 day, max_age will be 1 hour
- If retention is less than or equal to 14 days, max_age will be 1 day
- If retention is less than or equal to 90 days, max_age will be 7 days
- If retention is greater than 90 days, max_age will be 30 days
forcedType: string
allowedNodeTypes:
- heavynode
regex: ^$|^[0-9]{1,5}(?:d|h|m|s)$
regexFailureMessage: Must be blank or a number followed by d, h, m, or s, such as 7d.
index_template:
index_patterns:
description: Patterns for matching multiple indices or tables.
@@ -335,6 +376,14 @@ elasticsearch:
global: True
advanced: True
helpLink: elasticsearch
auto_expand_replicas:
description: Automatically expand the number of replicas based on the number of data nodes in the cluster. This can help ensure high availability as the cluster scales up or down.
forcedType: string
regex: "^(0-[1-9]|1-[2-9]|2-[3-9]|3-[4-9]|4-[5-9]|5-[6-9]|6-[7-9]|7-[89]|8-9|[0-9]-all|false)$"
regexFailureMessage: Must be in the format of "x-y" where x is minimum number of replicas and y is maximum number of replicas, or "0-all" to specify a minimum of 0 and no maximum, or "false" to disable automatic replica expansion.
global: True
advanced: True
helpLink: elasticsearch
mapping:
total_fields:
limit:
@@ -596,65 +645,350 @@ elasticsearch:
global: True
advanced: True
helpLink: elasticsearch
so-logs-system_x_auth: *indexSettings
so-logs-system_x_syslog: *indexSettings
so-logs-system_x_system: *indexSettings
so-logs-system_x_application: *indexSettings
so-logs-system_x_security: *indexSettings
so-logs-windows_x_forwarded: *indexSettings
so-logs-windows_x_powershell: *indexSettings
so-logs-windows_x_powershell_operational: *indexSettings
so-logs-windows_x_sysmon_operational: *indexSettings
so-logs-winlog_x_winlog: *indexSettings
so-logs-detections_x_alerts: *indexSettings
so-logs-http_endpoint_x_generic: *indexSettings
so-logs-httpjson_x_generic: *indexSettings
so-logs-osquery-manager-actions: *indexSettings
so-logs-osquery-manager-action_x_responses: *indexSettings
so-logs-osquery-manager_x_action_x_responses: *indexSettings
so-logs-osquery-manager_x_result: *indexSettings
so-logs-elastic_agent_x_apm_server: *indexSettings
so-logs-elastic_agent_x_auditbeat: *indexSettings
so-logs-elastic_agent_x_cloudbeat: *indexSettings
so-logs-elastic_agent_x_endpoint_security: *indexSettings
so-logs-endpoint_x_alerts: *indexSettings
so-logs-endpoint_x_events_x_api: *indexSettings
so-logs-endpoint_x_events_x_file: *indexSettings
so-logs-endpoint_x_events_x_library: *indexSettings
so-logs-endpoint_x_events_x_network: *indexSettings
so-logs-endpoint_x_events_x_process: *indexSettings
so-logs-endpoint_x_events_x_registry: *indexSettings
so-logs-endpoint_x_events_x_security: *indexSettings
so-logs-elastic_agent_x_filebeat: *indexSettings
so-logs-elastic_agent_x_fleet_server: *indexSettings
so-logs-elastic_agent_x_heartbeat: *indexSettings
so-logs-elastic_agent: *indexSettings
so-logs-elastic_agent_x_metricbeat: *indexSettings
so-logs-elastic_agent_x_osquerybeat: *indexSettings
so-logs-elastic_agent_x_packetbeat: *indexSettings
so-logs-elasticsearch_x_server: *indexSettings
so-metrics-endpoint_x_metadata: *indexSettings
so-metrics-endpoint_x_metrics: *indexSettings
so-metrics-endpoint_x_policy: *indexSettings
so-metrics-nginx_x_stubstatus: *indexSettings
so-metrics-vsphere_x_datastore: *indexSettings
so-metrics-vsphere_x_host: *indexSettings
so-metrics-vsphere_x_virtualmachine: *indexSettings
so-case: *indexSettings
so-common: *indexSettings
so-endgame: *indexSettings
so-idh: *indexSettings
so-suricata: *indexSettings
so-suricata_x_alerts: *indexSettings
so-import: *indexSettings
so-kratos: *indexSettings
so-hydra: *indexSettings
so-kismet: *indexSettings
so-logstash: *indexSettings
so-redis: *indexSettings
so-strelka: *indexSettings
so-syslog: *indexSettings
so-zeek: *indexSettings
so-logs-soc: *dataStreamSettings
so-logs-system_x_auth: *dataStreamSettings
so-logs-system_x_syslog: *dataStreamSettings
so-logs-system_x_system: *dataStreamSettings
so-logs-system_x_application: *dataStreamSettings
so-logs-system_x_security: *dataStreamSettings
so-logs-windows_x_forwarded: *dataStreamSettings
so-logs-windows_x_powershell: *dataStreamSettings
so-logs-windows_x_powershell_operational: *dataStreamSettings
so-logs-windows_x_sysmon_operational: *dataStreamSettings
so-logs-winlog_x_winlog: *dataStreamSettings
so-logs-detections_x_alerts: *dataStreamSettings
so-logs-http_endpoint_x_generic: *dataStreamSettings
so-logs-httpjson_x_generic: *dataStreamSettings
so-logs-osquery-manager-actions: *dataStreamSettings
so-logs-osquery-manager-action_x_responses: *dataStreamSettings
so-logs-osquery-manager_x_action_x_responses: *dataStreamSettings
so-logs-osquery-manager_x_result: *dataStreamSettings
so-logs-elastic_agent_x_apm_server: *dataStreamSettings
so-logs-elastic_agent_x_auditbeat: *dataStreamSettings
so-logs-elastic_agent_x_cloudbeat: *dataStreamSettings
so-logs-elastic_agent_x_endpoint_security: *dataStreamSettings
so-logs-endpoint_x_alerts: *dataStreamSettings
so-logs-endpoint_x_events_x_api: *dataStreamSettings
so-logs-endpoint_x_events_x_file: *dataStreamSettings
so-logs-endpoint_x_events_x_library: *dataStreamSettings
so-logs-endpoint_x_events_x_network: *dataStreamSettings
so-logs-endpoint_x_events_x_process: *dataStreamSettings
so-logs-endpoint_x_events_x_registry: *dataStreamSettings
so-logs-endpoint_x_events_x_security: *dataStreamSettings
so-logs-elastic_agent_x_filebeat: *dataStreamSettings
so-logs-elastic_agent_x_fleet_server: *dataStreamSettings
so-logs-elastic_agent_x_heartbeat: *dataStreamSettings
so-logs-elastic_agent: *dataStreamSettings
so-logs-elastic_agent_x_metricbeat: *dataStreamSettings
so-logs-elastic_agent_x_osquerybeat: *dataStreamSettings
so-logs-elastic_agent_x_packetbeat: *dataStreamSettings
so-logs-elasticsearch_x_server: *dataStreamSettings
so-metrics-endpoint_x_metadata: *dataStreamSettings
so-metrics-endpoint_x_metrics: *dataStreamSettings
so-metrics-endpoint_x_policy: *dataStreamSettings
so-metrics-nginx_x_stubstatus: *dataStreamSettings
so-metrics-vsphere_x_datastore: *dataStreamSettings
so-metrics-vsphere_x_host: *dataStreamSettings
so-metrics-vsphere_x_virtualmachine: *dataStreamSettings
so-common: *dataStreamSettings
so-endgame: *dataStreamSettings
so-idh: *dataStreamSettings
so-suricata: *dataStreamSettings
so-suricata_x_alerts: *dataStreamSettings
so-import: *dataStreamSettings
so-kratos: *dataStreamSettings
so-hydra: *dataStreamSettings
so-kismet: *dataStreamSettings
so-logstash: *dataStreamSettings
so-redis: *dataStreamSettings
so-strelka: *dataStreamSettings
so-syslog: *dataStreamSettings
so-zeek: *dataStreamSettings
# Managed SOC integration annotations are inserted below this line. Referencing '*dataStreamSettings'
so-case: &indexSettings
index_sorting:
description: Sorts the index by event time, at the cost of additional processing resource consumption.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
index_template:
index_patterns:
description: Patterns for matching multiple indices or tables.
forcedType: "[]string"
multiline: True
global: True
advanced: True
helpLink: elasticsearch
template:
settings:
index:
number_of_replicas:
description: Number of replicas required for this index. Multiple replicas protects against data loss, but also increases storage costs.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
auto_expand_replicas:
description: Automatically expand the number of replicas based on the number of data nodes in the cluster. This can help ensure high availability as the cluster scales up or down.
forcedType: string
regex: "^(0-[1-9]|1-[2-9]|2-[3-9]|3-[4-9]|4-[5-9]|5-[6-9]|6-[7-9]|7-[89]|8-9|[0-9]-all|false)$"
regexFailureMessage: Must be in the format of "x-y" where x is minimum number of replicas and y is maximum number of replicas, or "0-all" to specify a minimum of 0 and no maximum, or "false" to disable automatic replica expansion.
global: True
advanced: True
helpLink: elasticsearch
mapping:
total_fields:
limit:
description: Max number of fields that can exist on a single index. Larger values will consume more resources.
global: True
advanced: True
helpLink: elasticsearch
refresh_interval:
description: Seconds between index refreshes. Shorter intervals can cause query performance to suffer since this is a synchronous and resource-intensive operation.
global: True
advanced: True
helpLink: elasticsearch
number_of_shards:
description: Number of shards required for this index. Using multiple shards increases fault tolerance, but also increases storage and network costs.
global: True
advanced: True
helpLink: elasticsearch
sort:
field:
description: The field to sort by. Must set index_sorting to True.
global: True
advanced: True
helpLink: elasticsearch
order:
description: The order to sort by. Must set index_sorting to True.
global: True
advanced: True
helpLink: elasticsearch
mappings:
_meta:
package:
name:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed_by:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed:
description: Meta settings for the mapping.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
composed_of:
description: The index template is composed of these component templates.
forcedType: "[]string"
global: True
advanced: True
helpLink: elasticsearch
priority:
description: The priority of the index template.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
policy:
phases:
hot:
min_age:
description: Minimum age of index. This determines when the index should be moved to the hot tier.
global: True
advanced: True
helpLink: elasticsearch
actions:
set_priority:
priority:
description: Priority of index. This is used for recovery after a node restart. Indices with higher priorities are recovered before indices with lower priorities.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
rollover:
max_age:
description: Maximum age of index. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
max_primary_shard_size:
description: Maximum primary shard size. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
shrink:
method:
description: Shrink the index to a new index with fewer primary shards. Shrink operation is by count or size.
options:
- COUNT
- SIZE
global: True
advanced: True
forcedType: string
number_of_shards:
title: shard count
description: Desired shard count. Note that this value is only used when the shrink method selected is 'COUNT'.
global: True
forcedType: int
advanced: True
max_primary_shard_size:
title: max shard size
description: Desired shard size in gb/tb/pb eg. 100gb. Note that this value is only used when the shrink method selected is 'SIZE'.
regex: ^[0-9]+(?:gb|tb|pb)$
global: True
forcedType: string
advanced: True
allow_write_after_shrink:
description: Allow writes after shrink.
global: True
forcedType: bool
default: False
advanced: True
forcemerge:
max_num_segments:
description: Reduce the number of segments in each index shard and clean up deleted documents.
global: True
forcedType: int
advanced: True
index_codec:
title: compression
description: Use higher compression for stored fields at the cost of slower performance.
forcedType: bool
global: True
default: False
advanced: True
warm:
min_age:
description: Minimum age of index. ex. 30d - This determines when the index should be moved to the warm tier. Nodes in the warm tier generally dont need to be as fast as those in the hot tier. Its important to note that this is calculated relative to the rollover date (NOT the original creation date of the index). For example, if you have an index that is set to rollover after 30 days and warm min_age set to 30 then there will be 30 days from index creation to rollover and then an additional 30 days before moving to warm tier.
regex: ^[0-9]{1,5}d$
forcedType: string
global: True
advanced: True
helpLink: elasticsearch
actions:
set_priority:
priority:
description: Priority of index. This is used for recovery after a node restart. Indices with higher priorities are recovered before indices with lower priorities.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
rollover:
max_age:
description: Maximum age of index. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
max_primary_shard_size:
description: Maximum primary shard size. Once an index reaches this limit, it will be rolled over into a new index.
global: True
advanced: True
helpLink: elasticsearch
shrink:
method:
description: Shrink the index to a new index with fewer primary shards. Shrink operation is by count or size.
options:
- COUNT
- SIZE
global: True
advanced: True
number_of_shards:
title: shard count
description: Desired shard count. Note that this value is only used when the shrink method selected is 'COUNT'.
global: True
forcedType: int
advanced: True
max_primary_shard_size:
title: max shard size
description: Desired shard size in gb/tb/pb eg. 100gb. Note that this value is only used when the shrink method selected is 'SIZE'.
regex: ^[0-9]+(?:gb|tb|pb)$
global: True
forcedType: string
advanced: True
allow_write_after_shrink:
description: Allow writes after shrink.
global: True
forcedType: bool
default: False
advanced: True
forcemerge:
max_num_segments:
description: Reduce the number of segments in each index shard and clean up deleted documents.
global: True
forcedType: int
advanced: True
index_codec:
title: compression
description: Use higher compression for stored fields at the cost of slower performance.
forcedType: bool
global: True
default: False
advanced: True
allocate:
number_of_replicas:
description: Set the number of replicas. Remains the same as the previous phase by default.
forcedType: int
global: True
advanced: True
cold:
min_age:
description: Minimum age of index. ex. 60d - This determines when the index should be moved to the cold tier. While still searchable, this tier is typically optimized for lower storage costs rather than search speed. Its important to note that this is calculated relative to the rollover date (NOT the original creation date of the index). For example, if you have an index that is set to rollover after 30 days and cold min_age set to 60 then there will be 30 days from index creation to rollover and then an additional 60 days before moving to cold tier.
regex: ^[0-9]{1,5}d$
forcedType: string
global: True
advanced: True
helpLink: elasticsearch
actions:
set_priority:
priority:
description: Used for index recovery after a node restart. Indices with higher priorities are recovered before indices with lower priorities.
forcedType: int
global: True
advanced: True
helpLink: elasticsearch
allocate:
number_of_replicas:
description: Set the number of replicas. Remains the same as the previous phase by default.
forcedType: int
global: True
advanced: True
delete:
min_age:
description: Minimum age of index. ex. 90d - This determines when the index should be deleted. Its important to note that this is calculated relative to the rollover date (NOT the original creation date of the index). For example, if you have an index that is set to rollover after 30 days and delete min_age set to 90 then there will be 30 days from index creation to rollover and then an additional 90 days before deletion.
regex: ^[0-9]{1,5}d$
forcedType: string
global: True
advanced: True
helpLink: elasticsearch
_meta:
package:
name:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed_by:
description: Meta settings for the mapping.
global: True
advanced: True
helpLink: elasticsearch
managed:
description: Meta settings for the mapping.
forcedType: bool
global: True
advanced: True
helpLink: elasticsearch
sos-backup: *indexSettings
so-detection: *indexSettings
so-assistant-chat: *indexSettings
so-assistant-session: *indexSettings
so-metrics-fleet_server_x_agent_status: &fleetMetricsSettings
index_sorting:
description: Sorts the index by event time, at the cost of additional processing resource consumption.
+37 -3
View File
@@ -4,7 +4,11 @@
Elastic License 2.0. #}
{% import_yaml 'elasticsearch/defaults.yaml' as ELASTICSEARCHDEFAULTS %}
{# ELASTICSEARCHMERGED only used here to collect data_retention_method. This file intentionally works with ELASTICSEARCHDEFAULTS #}
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
{% set DEFAULT_GLOBAL_OVERRIDES = ELASTICSEARCHDEFAULTS.elasticsearch.index_settings.pop('global_overrides') %}
{% set DATA_RETENTION_METHOD = ELASTICSEARCHMERGED.data_retention_method %}
{% set PILLAR_GLOBAL_OVERRIDES = {} %}
{% set ES_INDEX_PILLAR = salt['pillar.get']('elasticsearch:index_settings', {}) %}
@@ -61,15 +65,25 @@
{% if ALL_ADDON_SETTINGS_ORIG.keys() | length > 0 %}
{% for index in ALL_ADDON_SETTINGS_ORIG.keys() %}
{% do ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES.update({index: salt['defaults.merge'](ALL_ADDON_SETTINGS_ORIG[index], PILLAR_GLOBAL_OVERRIDES, in_place=False)}) %}
{# Explicitly excluding addon indices from ES_INDEX_SETTINGS_ORIG
When manager.soc_managed_annotations runs, new entries are added to the salt/elasticsearch/defaults.yaml file to support 'revert to default' functionality.
Subsequent map renders will then incorrectly include 'integration X' in 'ES_INDEX_SETTINGS_ORIG' due to being in the defaults.yaml file. #}
{% if index in ES_INDEX_SETTINGS_ORIG.keys() %}
{% do ES_INDEX_SETTINGS_ORIG.pop(index) %}
{% endif %}
{% endfor %}
{% endif %}
{% set ES_INDEX_SETTINGS = {} %}
{% macro create_final_index_template(DEFINED_SETTINGS, GLOBAL_OVERRIDES, FINAL_INDEX_SETTINGS) %}
{% macro create_final_index_template(DEFINED_SETTINGS, GLOBAL_OVERRIDES, FINAL_INDEX_SETTINGS, EXCLUDE_INDICES=[]) %}
{% do GLOBAL_OVERRIDES.update(salt['defaults.merge'](GLOBAL_OVERRIDES, ES_INDEX_PILLAR, in_place=False)) %}
{% for index, settings in GLOBAL_OVERRIDES.items() %}
{% if index in EXCLUDE_INDICES %}
{% continue %}
{% endif %}
{# prevent this action from being performed on custom defined indices. #}
{# the custom defined index is not present in either of the dictionaries and fails to reder. #}
{% if index in DEFINED_SETTINGS and index in GLOBAL_OVERRIDES %}
@@ -95,6 +109,17 @@
{% if not settings.get('index_sorting', False) | to_bool and settings.index_template.template.settings.index.sort is defined %}
{% do settings.index_template.template.settings.index.pop('sort') %}
{% endif %}
{% if DATA_RETENTION_METHOD == 'DLM' and settings.index_template.data_stream is defined and settings.data_stream_lifecycle is defined %}
{% if settings.data_stream_lifecycle.data_retention is defined and settings.data_stream_lifecycle.data_retention %}
{% do settings.index_template.template.update({'lifecycle': {'data_retention': settings.data_stream_lifecycle.data_retention}}) %}
{% else %}
{% do settings.index_template.template.update({'lifecycle': {}}) %}
{% endif %}
{% if settings.index_template.template.settings.index.lifecycle is not defined %}
{% do settings.index_template.template.settings.index.update({'lifecycle': {}}) %}
{% endif %}
{% do settings.index_template.template.settings.index.lifecycle.update({'prefer_ilm': false}) %}
{% endif %}
{% endif %}
{# advanced ilm actions #}
@@ -150,10 +175,19 @@
{% endfor %}
{% endmacro %}
{{ create_final_index_template(ES_INDEX_SETTINGS_ORIG, ES_INDEX_SETTINGS_GLOBAL_OVERRIDES, ES_INDEX_SETTINGS) }}
{{ create_final_index_template(ALL_ADDON_SETTINGS_ORIG, ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES, ALL_ADDON_SETTINGS) }}
{# Exclude addon integrations from final ES_INDEX_SETTINGS #}
{{ create_final_index_template(ES_INDEX_SETTINGS_ORIG, ES_INDEX_SETTINGS_GLOBAL_OVERRIDES, ES_INDEX_SETTINGS, ALL_ADDON_SETTINGS_ORIG.keys() | list ) }}
{# Exclude SO managed indices, otherwise ALL_ADDON_SETTINGS will include pillar values
of core integrations without merging defaults, resulting in an overlapping, but bad index template being generated. #}
{{ create_final_index_template(ALL_ADDON_SETTINGS_ORIG, ALL_ADDON_SETTINGS_GLOBAL_OVERRIDES, ALL_ADDON_SETTINGS, ES_INDEX_SETTINGS_ORIG.keys() | list ) }}
{% set SO_MANAGED_INDICES = [] %}
{% for index, settings in ES_INDEX_SETTINGS.items() %}
{% do SO_MANAGED_INDICES.append(index) %}
{% endfor %}
{% set ADDON_INDICES = [] %}
{% for index, settings in ALL_ADDON_SETTINGS.items() %}
{% do ADDON_INDICES.append(index) %}
{% endfor %}
@@ -11,10 +11,8 @@ ADDON_STATEFILE_SUCCESS=/opt/so/state/addon_estemplates.txt
ELASTICSEARCH_TEMPLATES_DIR="/opt/so/conf/elasticsearch/templates"
SO_TEMPLATES_DIR="${ELASTICSEARCH_TEMPLATES_DIR}/index"
ADDON_TEMPLATES_DIR="${ELASTICSEARCH_TEMPLATES_DIR}/addon-index"
SO_LOAD_FAILURES=0
ADDON_LOAD_FAILURES=0
SO_LOAD_FAILURES_NAMES=()
ADDON_LOAD_FAILURES_NAMES=()
FAILED_NAMES=()
FAILED_COUNT=0
IS_HEAVYNODE="false"
FORCE="false"
VERBOSE="false"
@@ -46,20 +44,86 @@ while [[ $# -gt 0 ]]; do
shift
done
# Max number of concurrent template PUT jobs. Override via env if needed.
MAX_TEMPLATE_JOBS=${MAX_TEMPLATE_JOBS:-10}
# Block until fewer than MAX_TEMPLATE_JOBS background jobs are running.
template_throttle() {
while (( $(jobs -rp | wc -l) >= MAX_TEMPLATE_JOBS )); do
wait -n
done
}
# Per-job failure markers and an output lock for serializing parallel job output.
# Each failed load drops one file (named after the template) into FAIL_DIR; the
# output of each job is flushed as a single block under flock so concurrent jobs
# never interleave their (chatty) retry output.
FAIL_DIR=$(mktemp -d)
OUTPUT_LOCK="${FAIL_DIR}/.output.lock"
: > "$OUTPUT_LOCK"
trap 'rm -rf "$FAIL_DIR"' EXIT
# Record a failure: $1 = the template name/path to report later. Slashes are
# encoded so the path becomes a safe single filename.
record_failure() {
local marker="${1//\//__}"
: > "${FAIL_DIR}/fail.${marker}"
}
# Populate FAILED_NAMES and FAILED_COUNT from the current phase's markers.
# Must run in the current shell (not a command substitution) so the array sticks.
collect_failures() {
FAILED_NAMES=()
FAILED_COUNT=0
local f name
shopt -s nullglob
for f in "${FAIL_DIR}"/fail.*; do
name="${f##*/fail.}"
name="${name//__//}"
FAILED_NAMES+=("$name")
FAILED_COUNT=$((FAILED_COUNT + 1))
done
shopt -u nullglob
}
# Clear markers and names between phases so SO and addon counts stay independent.
reset_failures() {
shopt -s nullglob
rm -f "${FAIL_DIR}"/fail.*
shopt -u nullglob
FAILED_NAMES=()
FAILED_COUNT=0
}
# Print a block of text atomically (under the shared output lock) so the output
# of concurrent background jobs is not interleaved.
locked_echo() {
{ flock 9; printf '%s\n' "$1"; } 9>>"$OUTPUT_LOCK"
}
# Loads one template file via PUT. Intended to be dispatched as a background job.
# $1 uri - e.g. _component_template/foo or _index_template/foo
# $2 file - path to the template JSON
# $3 report_name - name/path to record if this load fails
load_template() {
local uri="$1"
local file="$2"
local report_name="$3"
local out rc=0 block
echo "Loading template file $file"
if ! output=$(retry 3 3 "so-elasticsearch-query $uri -d@$file -XPUT" "{\"acknowledged\":true}"); then
echo "$output"
return 1
# Capture everything (including retry's diagnostic chatter) into one block so
# concurrent jobs never interleave; the whole block is flushed under one flock.
block="Loading template file $file"$'\n'
if ! out=$(retry 3 3 "so-elasticsearch-query $uri -d@$file -XPUT" "{\"acknowledged\":true}" 2>&1); then
block+="$out"$'\n'
rc=1
elif [[ "$VERBOSE" == "true" ]]; then
echo "$output"
block+="$out"$'\n'
fi
{ flock 9; printf '%s' "$block"; } 9>>"$OUTPUT_LOCK"
(( rc != 0 )) && record_failure "$report_name"
}
check_required_component_template_exists() {
@@ -110,6 +174,9 @@ load_component_templates() {
return
fi
# Dispatch loads as throttled background jobs. The barrier (wait) happens in
# the caller after all component groups have been dispatched, since index
# templates must not load until every component template is in place.
for component in "$pattern"/*.json; do
tmpl_name=$(basename "${component%.json}")
@@ -118,21 +185,11 @@ load_component_templates() {
tmpl_name="${tmpl_name%-mappings}-mappings"
fi
if ! load_template "_component_template/${tmpl_name}" "$component"; then
SO_LOAD_FAILURES=$((SO_LOAD_FAILURES + 1))
SO_LOAD_FAILURES_NAMES+=("$component")
fi
template_throttle
load_template "_component_template/${tmpl_name}" "$component" "$component" &
done
}
check_elasticsearch_responsive() {
# Cannot load templates if Elasticsearch is not responding.
# NOTE: Slightly faster exit w/ failure than previous "retry 240 1" if there is a problem with Elasticsearch the
# script should exit sooner rather than hang at the 'so-elasticsearch-templates' salt state.
retry 3 15 "so-elasticsearch-query / --output /dev/null --fail" ||
fail "Elasticsearch is not responding. Please review Elasticsearch logs /opt/so/log/elasticsearch/securityonion.log for more details. Additionally, consider running so-elasticsearch-troubleshoot."
}
index_templates_exist() {
local templates_dir="$1"
@@ -180,6 +237,9 @@ if [[ "$FORCE" == "true" || ! -f "$SO_STATEFILE_SUCCESS" ]] && index_templates_e
load_component_templates "Elastic Agent" "elastic-agent"
load_component_templates "Security Onion" "so"
# Barrier: every component template PUT must complete before we snapshot the
# component template list and start loading index templates that depend on them.
wait
component_templates=$(so-elasticsearch-component-templates-list)
echo -e "Loading Security Onion index templates...\n"
for so_idx_tmpl in "${SO_TEMPLATES_DIR}"/*.json; do
@@ -189,7 +249,7 @@ if [[ "$FORCE" == "true" || ! -f "$SO_STATEFILE_SUCCESS" ]] && index_templates_e
# TODO: Better way to load only heavynode specific templates
if ! check_heavynode_compatiable_index_template "$tmpl_name"; then
if [[ "$VERBOSE" == "true" ]]; then
echo "Skipping over $so_idx_tmpl, template is not a heavynode specific index template."
locked_echo "Skipping over $so_idx_tmpl, template is not a heavynode specific index template."
fi
continue
@@ -197,32 +257,34 @@ if [[ "$FORCE" == "true" || ! -f "$SO_STATEFILE_SUCCESS" ]] && index_templates_e
fi
if check_required_component_template_exists "$so_idx_tmpl"; then
if ! load_template "_index_template/$tmpl_name" "$so_idx_tmpl"; then
SO_LOAD_FAILURES=$((SO_LOAD_FAILURES + 1))
SO_LOAD_FAILURES_NAMES+=("$so_idx_tmpl")
fi
template_throttle
load_template "_index_template/$tmpl_name" "$so_idx_tmpl" "$so_idx_tmpl" &
else
echo "Skipping over $so_idx_tmpl due to missing required component template(s)."
SO_LOAD_FAILURES=$((SO_LOAD_FAILURES + 1))
SO_LOAD_FAILURES_NAMES+=("$so_idx_tmpl")
locked_echo "Skipping over $so_idx_tmpl due to missing required component template(s)."
record_failure "$so_idx_tmpl"
continue
fi
done
if [[ $SO_LOAD_FAILURES -eq 0 ]]; then
# Barrier: all SO index template PUTs must finish before tallying failures.
wait
collect_failures
if [[ $FAILED_COUNT -eq 0 ]]; then
echo "All Security Onion core templates loaded successfully."
touch "$SO_STATEFILE_SUCCESS"
else
echo "Encountered $SO_LOAD_FAILURES failure(s) loading templates:"
for failed_template in "${SO_LOAD_FAILURES_NAMES[@]}"; do
echo "Encountered $FAILED_COUNT failure(s) loading templates:"
for failed_template in "${FAILED_NAMES[@]}"; do
echo " - $failed_template"
done
if [[ "$SHOULD_EXIT_ON_FAILURE" == "true" ]]; then
fail "Failed to load all Security Onion core templates successfully."
fi
fi
reset_failures
elif ! index_templates_exist "$SO_TEMPLATES_DIR"; then
echo "No Security Onion core index templates found in ${SO_TEMPLATES_DIR}, skipping."
elif [[ -f "$SO_STATEFILE_SUCCESS" ]]; then
@@ -241,26 +303,27 @@ if should_load_addon_templates; then
tmpl_name=$(basename "${addon_idx_tmpl%-template.json}")
if check_required_component_template_exists "$addon_idx_tmpl"; then
if ! load_template "_index_template/${tmpl_name}" "$addon_idx_tmpl"; then
ADDON_LOAD_FAILURES=$((ADDON_LOAD_FAILURES + 1))
ADDON_LOAD_FAILURES_NAMES+=("$addon_idx_tmpl")
fi
template_throttle
load_template "_index_template/${tmpl_name}" "$addon_idx_tmpl" "$addon_idx_tmpl" &
else
echo "Skipping over $addon_idx_tmpl due to missing required component template(s)."
ADDON_LOAD_FAILURES=$((ADDON_LOAD_FAILURES + 1))
ADDON_LOAD_FAILURES_NAMES+=("$addon_idx_tmpl")
locked_echo "Skipping over $addon_idx_tmpl due to missing required component template(s)."
record_failure "$addon_idx_tmpl"
continue
fi
done
if [[ $ADDON_LOAD_FAILURES -eq 0 ]]; then
# Barrier: all addon index template PUTs must finish before tallying failures.
wait
collect_failures
if [[ $FAILED_COUNT -eq 0 ]]; then
echo "All addon integration templates loaded successfully."
touch "$ADDON_STATEFILE_SUCCESS"
else
echo "Encountered $ADDON_LOAD_FAILURES failure(s) loading addon integration templates:"
for failed_template in "${ADDON_LOAD_FAILURES_NAMES[@]}"; do
echo "Encountered $FAILED_COUNT failure(s) loading addon integration templates:"
for failed_template in "${FAILED_NAMES[@]}"; do
echo " - $failed_template"
done
if [[ "$SHOULD_EXIT_ON_FAILURE" == "true" ]]; then
@@ -0,0 +1,175 @@
#!/bin/bash
# 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.
. /usr/sbin/so-common
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
{%- set DATA_RETENTION_METHOD = ELASTICSEARCHMERGED.data_retention_method %}
ELASTICSEARCH_TEMPLATES_DIR="${ELASTICSEARCH_TEMPLATES_DIR:-/opt/so/conf/elasticsearch/templates}"
TEMPLATE_DIRS=(
"${ELASTICSEARCH_TEMPLATES_DIR}/index"
"${ELASTICSEARCH_TEMPLATES_DIR}/addon-index"
)
DATA_RETENTION_METHOD=$(cat <<'EOF'
{{ DATA_RETENTION_METHOD }}
EOF
)
DLM_FAILURES=0
DLM_FAILURE_NAMES=()
if [[ "$DATA_RETENTION_METHOD" != "DLM" && "$DATA_RETENTION_METHOD" != "ILM" ]]; then
echo "Unsupported data retention method $DATA_RETENTION_METHOD. Expected DLM or ILM."
exit 1
fi
validate_template_file() {
local template_file="$1"
if ! jq -e 'type == "object" and (.data_stream == null or (.data_stream | type == "object")) and (.template.lifecycle == null or (.template.lifecycle | type == "object")) and (.template.lifecycle.data_retention == null or (.template.lifecycle.data_retention | type == "string"))' >/dev/null 2>&1 "$template_file"; then
echo "Invalid index template JSON: $template_file"
return 1
fi
}
is_data_stream_template() {
jq -e '.data_stream | type == "object"' >/dev/null 2>&1 "$1"
}
has_data_stream_lifecycle() {
jq -e '.template.lifecycle | type == "object"' >/dev/null 2>&1 "$1"
}
get_data_retention() {
jq -r '.template.lifecycle.data_retention // ""' "$1"
}
find_template_file() {
local template="$1"
local template_dir
local template_file
for template_dir in "${TEMPLATE_DIRS[@]}"; do
template_file="${template_dir}/${template}-template.json"
if [[ -f "$template_file" ]]; then
echo "$template_file"
return 0
fi
done
return 1
}
set_data_stream_lifecycle() {
local data_stream="$1"
local data_retention="$2"
local body
local output
if [[ -n "$data_retention" ]]; then
if jq -e --arg data_stream "$data_stream" --arg data_retention "$data_retention" '.data_streams[]? | select(.name == $data_stream and .lifecycle.enabled == true and .lifecycle.data_retention == $data_retention)' >/dev/null 2>&1 <<< "$data_streams"; then
echo "DLM lifecycle already set for $data_stream with data_retention $data_retention, skipping."
return 0
fi
elif jq -e --arg data_stream "$data_stream" '.data_streams[]? | select(.name == $data_stream and .lifecycle.enabled == true and (.lifecycle.data_retention == null))' >/dev/null 2>&1 <<< "$data_streams"; then
echo "DLM lifecycle already set for $data_stream with indefinite retention, skipping."
return 0
fi
if [[ -n "$data_retention" ]]; then
body=$(jq -cn --arg data_retention "$data_retention" '{data_retention: $data_retention}')
else
# Setting indefinite retention
body='{}'
fi
if ! output=$(so-elasticsearch-query "_data_stream/${data_stream}/_lifecycle" -XPUT -d "$body" --retry 3 --retry-delay 5 --fail); then
echo "Failed to set data stream lifecycle for $data_stream."
return 1
fi
if [[ -n "$data_retention" ]]; then
echo "Set DLM lifecycle for $data_stream with data_retention $data_retention."
else
echo "Set DLM lifecycle for $data_stream with indefinite retention."
fi
}
disable_data_stream_lifecycle() {
local data_stream="$1"
local body='{"enabled":false}'
local output
if ! jq -e --arg data_stream "$data_stream" '.data_streams[]? | select(.name == $data_stream and .lifecycle != null and .lifecycle.enabled != false)' >/dev/null 2>&1 <<< "$data_streams"; then
# No action needed
return 0
fi
if ! output=$(so-elasticsearch-query "_data_stream/${data_stream}/_lifecycle" -XPUT -d "$body" --retry 3 --retry-delay 5 --fail); then
echo "Failed to disable data stream lifecycle for $data_stream."
return 1
fi
echo "Disabled DLM lifecycle for $data_stream."
}
process_data_stream() {
local data_stream="$1"
local data_retention="$2"
if [[ "$DATA_RETENTION_METHOD" == "DLM" ]]; then
set_data_stream_lifecycle "$data_stream" "$data_retention"
else
disable_data_stream_lifecycle "$data_stream"
fi
}
check_elasticsearch_responsive
if ! data_streams=$(so-elasticsearch-query "_data_stream?format=json" --retry 3 --retry-delay 5 --fail); then
echo "Failed to retrieve data streams."
exit 1
fi
while read -r data_stream_config; do
data_stream=$(jq -r '.name' <<< "$data_stream_config")
template=$(jq -r '.template' <<< "$data_stream_config")
if ! template_file=$(find_template_file "$template"); then
echo "Skipping $data_stream: index template file not found for $template."
continue
fi
validate_template_file "$template_file" || exit 1
if ! is_data_stream_template "$template_file"; then
echo "Skipping $data_stream: $template_file is not a data stream template."
continue
fi
if [[ "$DATA_RETENTION_METHOD" == "DLM" ]] && ! has_data_stream_lifecycle "$template_file"; then
echo "Skipping $data_stream: $template_file does not define data stream lifecycle."
continue
fi
data_retention=$(get_data_retention "$template_file")
if ! process_data_stream "$data_stream" "$data_retention"; then
DLM_FAILURES=$((DLM_FAILURES + 1))
DLM_FAILURE_NAMES+=("$data_stream")
fi
done < <(jq -c '.data_streams[]' <<< "$data_streams")
if [[ $DLM_FAILURES -eq 0 ]]; then
echo "Data stream lifecycle updates completed successfully."
else
echo "Encountered $DLM_FAILURES failure(s) updating data stream lifecycle:"
for failed_data_stream in "${DLM_FAILURE_NAMES[@]}"; do
echo " - $failed_data_stream"
done
exit 1
fi
@@ -6,6 +6,48 @@
. /usr/sbin/so-common
MAX_JOBS=${MAX_ILM_JOBS:-10}
# Lock used to serialize block writes so concurrent jobs never interleave their output.
ILM_OUTPUT_LOCK=$(mktemp)
ILM_FAIL_FILE=$(mktemp)
trap 'rm -f "$ILM_OUTPUT_LOCK" "$ILM_FAIL_FILE"' EXIT
# Policies are loaded concurrently (up to MAX_JOBS at a time) for speed. Each policy's block is
# printed the moment its curl returns, so output appears in COMPLETION ORDER, not the order
# policies are defined in configuration.
echo "Loading ILM policies concurrently; output below appears in completion order, not configuration order."
echo
put_policy() {
local desc="$1" policyname="$2" data="$3" result rc=0
if ! result=$(curl -K /opt/so/conf/elasticsearch/curl.config -s -k -L --fail \
-X PUT "https://localhost:9200/_ilm/policy/${policyname}" \
-H 'Content-Type: application/json' -d"${data}" 2>&1); then
rc=1
elif ! jq -e '.acknowledged == true' <<<"$result" >/dev/null 2>&1; then
rc=1
fi
# curl above ran in parallel; serialize just this block write so concurrent jobs never interleave.
{
flock 200
printf 'Setting up %s policy...\n%s\n\n' "${desc}" "${result}"
if (( rc != 0 )); then
printf '%s\n' "${policyname}" >>"$ILM_FAIL_FILE"
fi
} 200>>"${ILM_OUTPUT_LOCK}"
return "$rc"
}
# Block until fewer than MAX_JOBS background curls are running.
throttle() {
while (( $(jobs -rp | wc -l) >= MAX_JOBS )); do
wait -n || true
done
}
{%- from 'elasticsearch/template.map.jinja' import ES_INDEX_SETTINGS %}
{%- if GLOBALS.role != "so-heavynode" %}
{%- from 'elasticsearch/template.map.jinja' import ALL_ADDON_SETTINGS %}
@@ -14,35 +56,36 @@
{%- for index, settings in ES_INDEX_SETTINGS.items() %}
{%- if settings.policy is defined %}
{%- if index == 'so-logs-detections.alerts' %}
echo
echo "Setting up so-logs-detections.alerts-so policy..."
curl -K /opt/so/conf/elasticsearch/curl.config -b "sid=$SESSIONCOOKIE" -s -k -L -X PUT "https://localhost:9200/_ilm/policy/{{ index }}-so" -H 'Content-Type: application/json' -d'{ "policy": {{ settings.policy | tojson(true) }} }'
echo
throttle
put_policy "so-logs-detections.alerts-so" "{{ index }}-so" '{ "policy": {{ settings.policy | tojson(true) }} }' &
{%- elif index == 'so-logs-soc' %}
echo
echo "Setting up so-soc-logs policy..."
curl -K /opt/so/conf/elasticsearch/curl.config -b "sid=$SESSIONCOOKIE" -s -k -L -X PUT "https://localhost:9200/_ilm/policy/so-soc-logs" -H 'Content-Type: application/json' -d'{ "policy": {{ settings.policy | tojson(true) }} }'
echo
echo
echo "Setting up {{ index }}-logs policy..."
curl -K /opt/so/conf/elasticsearch/curl.config -b "sid=$SESSIONCOOKIE" -s -k -L -X PUT "https://localhost:9200/_ilm/policy/{{ index }}-logs" -H 'Content-Type: application/json' -d'{ "policy": {{ settings.policy | tojson(true) }} }'
echo
throttle
put_policy "so-soc-logs" "so-soc-logs" '{ "policy": {{ settings.policy | tojson(true) }} }' &
throttle
put_policy "{{ index }}-logs" "{{ index }}-logs" '{ "policy": {{ settings.policy | tojson(true) }} }' &
{%- else %}
echo
echo "Setting up {{ index }}-logs policy..."
curl -K /opt/so/conf/elasticsearch/curl.config -b "sid=$SESSIONCOOKIE" -s -k -L -X PUT "https://localhost:9200/_ilm/policy/{{ index }}-logs" -H 'Content-Type: application/json' -d'{ "policy": {{ settings.policy | tojson(true) }} }'
echo
throttle
put_policy "{{ index }}-logs" "{{ index }}-logs" '{ "policy": {{ settings.policy | tojson(true) }} }' &
{%- endif %}
{%- endif %}
{%- endfor %}
echo
{%- if GLOBALS.role != "so-heavynode" %}
{%- for index, settings in ALL_ADDON_SETTINGS.items() %}
{%- if settings.policy is defined %}
echo
echo "Setting up {{ index }}-logs policy..."
curl -K /opt/so/conf/elasticsearch/curl.config -b "sid=$SESSIONCOOKIE" -s -k -L -X PUT "https://localhost:9200/_ilm/policy/{{ index }}-logs" -H 'Content-Type: application/json' -d'{ "policy": {{ settings.policy | tojson(true) }} }'
echo
throttle
put_policy "{{ index }}-logs" "{{ index }}-logs" '{ "policy": {{ settings.policy | tojson(true) }} }' &
{%- endif %}
{%- endfor %}
{%- endif %}
wait || true
if [[ -s "$ILM_FAIL_FILE" ]]; then
echo "ERROR: Failed to load ILM policy(s):"
while read -r POLICY; do
echo " - $POLICY"
done < "$ILM_FAIL_FILE"
exit 1
else
echo "Successfully loaded all ILM policies."
fi
+33
View File
@@ -398,6 +398,7 @@ firewall:
- elasticsearch_rest
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -410,6 +411,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -427,6 +429,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- sensoroni
searchnode:
portgroups:
@@ -437,6 +440,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -450,6 +454,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -459,6 +464,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -492,6 +498,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- elastic_agent_control
@@ -502,6 +509,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -610,6 +618,7 @@ firewall:
- elasticsearch_rest
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -622,6 +631,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -639,6 +649,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- sensoroni
searchnode:
portgroups:
@@ -649,6 +660,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -662,6 +674,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -671,6 +684,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -702,6 +716,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- elastic_agent_control
@@ -712,6 +727,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -820,6 +836,7 @@ firewall:
- elasticsearch_rest
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -832,6 +849,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -849,6 +867,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- sensoroni
searchnode:
portgroups:
@@ -858,6 +877,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -870,6 +890,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -879,6 +900,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -912,6 +934,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- elastic_agent_control
@@ -922,6 +945,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -1040,6 +1064,7 @@ firewall:
- elasticsearch_rest
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -1052,6 +1077,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -1063,6 +1089,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- beats_5044
@@ -1074,6 +1101,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- redis
@@ -1083,6 +1111,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- redis
@@ -1093,6 +1122,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -1129,6 +1159,7 @@ firewall:
portgroups:
- docker_registry
- influxdb
- postgres
- sensoroni
- yum
- elastic_agent_control
@@ -1139,6 +1170,7 @@ firewall:
- yum
- docker_registry
- influxdb
- postgres
- elastic_agent_control
- elastic_agent_data
- elastic_agent_update
@@ -1482,6 +1514,7 @@ firewall:
- kibana
- redis
- influxdb
- postgres
- elasticsearch_rest
- elasticsearch_node
- elastic_agent_control
-13
View File
@@ -1,6 +1,5 @@
{% from 'vars/globals.map.jinja' import GLOBALS %}
{% from 'docker/docker.map.jinja' import DOCKERMERGED %}
{% from 'telegraf/map.jinja' import TELEGRAFMERGED %}
{% import_yaml 'firewall/defaults.yaml' as FIREWALL_DEFAULT %}
{# add our ip to self #}
@@ -56,16 +55,4 @@
{% endif %}
{# Open Postgres (5432) to minion hostgroups when Telegraf is configured to write to Postgres #}
{% set TG_OUT = TELEGRAFMERGED.output | upper %}
{% if TG_OUT in ['POSTGRES', 'BOTH'] %}
{% if role.startswith('manager') or role == 'standalone' or role == 'eval' %}
{% for r in ['sensor', 'searchnode', 'heavynode', 'receiver', 'fleet', 'idh', 'desktop', 'import'] %}
{% if FIREWALL_DEFAULT.firewall.role[role].chain["DOCKER-USER"].hostgroups[r] is defined %}
{% do FIREWALL_DEFAULT.firewall.role[role].chain["DOCKER-USER"].hostgroups[r].portgroups.append('postgres') %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% set FIREWALL_MERGED = salt['pillar.get']('firewall', FIREWALL_DEFAULT.firewall, merge=True) %}
+1 -1
View File
@@ -22,6 +22,7 @@ include:
so-hydra:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-hydra:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: hydra
- name: so-hydra
- networks:
@@ -58,7 +59,6 @@ so-hydra:
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
{% endfor %}
{% endif %}
- restart_policy: unless-stopped
- watch:
- file: hydraconfig
- require:
+1
View File
@@ -15,6 +15,7 @@ include:
so-idh:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-idh:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- name: so-idh
- detach: True
- network_mode: host
+1
View File
@@ -18,6 +18,7 @@ include:
so-influxdb:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-influxdb:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: influxdb
- networks:
- sobridge:
+2 -1
View File
@@ -27,12 +27,13 @@ include:
so-kafka:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kafka:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: so-kafka
- name: so-kafka
- networks:
- sobridge:
- ipv4_address: {{ DOCKERMERGED.containers['so-kafka'].ip }}
- user: kafka
- user: "960"
- environment:
KAFKA_HEAP_OPTS: -Xmx2G -Xms1G
KAFKA_OPTS: "-javaagent:/opt/jolokia/agents/jolokia-agent-jvm-javaagent.jar=port=8778,host={{ DOCKERMERGED.containers['so-kafka'].ip }},policyLocation=file:/opt/jolokia/jolokia.xml {%- if KAFKA_EXTERNAL_ACCESS %} -Djava.security.auth.login.config=/opt/kafka/config/kafka_server_jaas.conf {% endif -%}"
+1 -1
View File
@@ -22,7 +22,7 @@ kibana:
- default
- file
migrations:
discardCorruptObjects: "9.3.3"
discardCorruptObjects: "9.3.7"
telemetry:
enabled: False
xpack:
+16 -1
View File
@@ -6,6 +6,7 @@
{% from 'allowed_states.map.jinja' import allowed_states %}
{% if sls.split('.')[0] in allowed_states %}
{% from 'docker/docker.map.jinja' import DOCKERMERGED %}
{% from 'elasticsearch/config.map.jinja' import ELASTICSEARCHMERGED %}
{% from 'vars/globals.map.jinja' import GLOBALS %}
include:
@@ -16,8 +17,9 @@ include:
so-kibana:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kibana:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: kibana
- user: kibana
- user: "932:0"
- networks:
- sobridge:
- ipv4_address: {{ DOCKERMERGED.containers['so-kibana'].ip }}
@@ -60,6 +62,19 @@ so-kibana:
- watch:
- file: kibanaconfig
wait_for_so-kibana:
http.wait_for_successful_query:
- name: "http://localhost:5601/api/status"
- username: 'so_elastic'
- password: '{{ ELASTICSEARCHMERGED.auth.users.so_elastic_user.pass }}'
- ssl: True
- verify_ssl: False
- status: 200
- wait_for: 600
- request_interval: 15
- require:
- docker_container: so-kibana
delete_so-kibana_so-status.disabled:
file.uncomment:
- name: /opt/so/conf/so-status/so-status.conf
+1 -1
View File
@@ -15,6 +15,7 @@ include:
so-kratos:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-kratos:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: kratos
- name: so-kratos
- networks:
@@ -51,7 +52,6 @@ so-kratos:
- {{ ULIMIT.name }}={{ ULIMIT.soft }}:{{ ULIMIT.hard }}
{% endfor %}
{% endif %}
- restart_policy: unless-stopped
- watch:
- file: kratosschema
- file: kratosconfig
+1 -1
View File
@@ -103,7 +103,7 @@ kratos:
config:
session:
lifespan:
description: Defines the length of a login session.
description: Defines the length of a login session before it will timeout, and require a new login.
global: True
helpLink: kratos
whoami:
+67
View File
@@ -0,0 +1,67 @@
# This state is designed to run on a development manager running in a libvirt VM. It will map the default pillar and salt directories
# from /opt/so/saltstack/default to your local development machine as the source path.
# The VM requires a filesystem to be added. Only the source path should be changed to your development codebase
# Driver: virtio-9p
# Source path: ~/project/securityonion
# Target path: saltDev
# If you want a directory to be RW, then kvm must have group privileges.
# ll /home/user/projects/securityonion/salt/hypervisor
# total 48
# drwxrwxr-x 3 user kvm 4096 Feb 13 11:18 ./
# drwxrwxr-x 64 user user 4096 Feb 13 10:32 ../
# -rw-rw-r-- 1 user kvm 2238 Feb 12 15:06 defaults.yaml
# -rw-rw-r-- 1 user kvm 1467 Feb 12 15:06 init.sls
# -rw-rw-r-- 1 user kvm 70 Feb 13 09:37 soc_hypervisor.yaml
# drwxrwxr-x 3 user kvm 4096 Feb 12 15:06 tools/
# Ensure required kernel modules are configured for loading
/etc/modules-load.d/virtio-9p.conf:
file.managed:
- contents: |
9pnet_virtio
9pnet
9p
- mode: 644
- user: root
- group: root
# Load the kernel modules immediately (in the correct order)
load_9p_modules:
cmd.run:
- names:
- modprobe 9pnet_virtio
- modprobe 9pnet
- modprobe 9p
- unless: lsmod | grep -E '9pnet_virtio|9pnet|9p'
# Ensure mount point exists
/opt/so/saltstack/default:
file.directory:
- user: root
- group: root
- mode: 755
- makedirs: True
# Configure fstab entry using mount.fstab_present
# Configure fstab entry using mount.fstab_present
saltdev_fstab:
mount.fstab_present:
- name: saltDev
- fs_file: /opt/so/saltstack/default
- fs_vfstype: 9p
- fs_mntops: _netdev,trans=virtio,version=9p2000.L
- fs_freq: 0
- fs_passno: 0
# Mount the filesystem if not already mounted
mount_saltdev:
mount.mounted:
- name: /opt/so/saltstack/default
- device: saltDev
- fstype: 9p
- opts: _netdev,trans=virtio,version=9p2000.L
- require:
- file: /opt/so/saltstack/default
- mount: saltdev_fstab
- cmd: load_9p_modules
+10
View File
@@ -150,6 +150,16 @@ logrotate:
- extension .log
- dateext
- dateyesterday
/opt/so/log/postgres/*_x_log:
- daily
- rotate 14
- missingok
- copytruncate
- compress
- create
- extension .log
- dateext
- dateyesterday
/opt/so/log/telegraf/*_x_log:
- daily
- rotate 14
+7
View File
@@ -91,6 +91,13 @@ logrotate:
multiline: True
global: True
forcedType: "[]string"
"/opt/so/log/postgres/*_x_log":
description: List of logrotate options for this file.
title: /opt/so/log/postgres/*.log
advanced: True
multiline: True
global: True
forcedType: "[]string"
"/opt/so/log/telegraf/*_x_log":
description: List of logrotate options for this file.
title: /opt/so/log/telegraf/*.log
+3 -2
View File
@@ -26,12 +26,12 @@ logstash:
manager:
- so/0011_input_endgame.conf
- so/0012_input_elastic_agent.conf.jinja
- so/0013_input_lumberjack_fleet.conf
- so/0013_input_lumberjack_fleet.conf.jinja
- so/9999_output_redis.conf.jinja
receiver:
- so/0011_input_endgame.conf
- so/0012_input_elastic_agent.conf.jinja
- so/0013_input_lumberjack_fleet.conf
- so/0013_input_lumberjack_fleet.conf.jinja
- so/9999_output_redis.conf.jinja
search:
- so/0900_input_redis.conf.jinja
@@ -69,4 +69,5 @@ logstash:
pipeline_x_batch_x_size: 125
pipeline_x_ecs_compatibility: disabled
dmz_nodes: []
latency_metrics: False
+2 -1
View File
@@ -28,12 +28,13 @@ include:
so-logstash:
docker_container.running:
- image: {{ GLOBALS.registry_host }}:5000/{{ GLOBALS.image_repo }}/so-logstash:{{ GLOBALS.so_version }}
- restart_policy: unless-stopped
- hostname: so-logstash
- name: so-logstash
- networks:
- sobridge:
- ipv4_address: {{ DOCKERMERGED.containers['so-logstash'].ip }}
- user: logstash
- user: "931:0"
- extra_hosts:
{% for node in LOGSTASH_NODES %}
{% for hostname, ip in node.items() %}
@@ -1,3 +1,4 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
input {
elastic_agent {
port => 5055
@@ -11,6 +12,11 @@ input {
}
}
filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_agent]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] {
mutate {
rename => {"@metadata" => "metadata"}
@@ -1,23 +0,0 @@
input {
elastic_agent {
port => 5056
tags => [ "elastic-agent", "fleet-lumberjack-input" ]
ssl_enabled => true
ssl_certificate => "/usr/share/logstash/elasticfleet-lumberjack.crt"
ssl_key => "/usr/share/logstash/elasticfleet-lumberjack.key"
ecs_compatibility => v8
id => "fleet-lumberjack-in"
codec => "json"
}
}
filter {
if ![metadata] {
mutate {
rename => {"@metadata" => "metadata"}
}
}
}
@@ -0,0 +1,26 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
input {
elastic_agent {
port => 5056
tags => [ "elastic-agent", "fleet-lumberjack-input" ]
ssl_enabled => true
ssl_certificate => "/usr/share/logstash/elasticfleet-lumberjack.crt"
ssl_key => "/usr/share/logstash/elasticfleet-lumberjack.key"
ecs_compatibility => v8
id => "fleet-lumberjack-in"
codec => "json"
}
}
filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_fleet]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] {
mutate {
rename => {"@metadata" => "metadata"}
}
}
}
@@ -1,3 +1,4 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{%- set kafka_password = salt['pillar.get']('kafka:config:password') %}
{%- set kafka_trustpass = salt['pillar.get']('kafka:config:trustpass') %}
{%- set kafka_brokers = salt['pillar.get']('kafka:nodes', {}) %}
@@ -30,6 +31,11 @@ input {
}
}
filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_kafka]', Time.now().utc.iso8601(3));"
}
{% endif %}
if ![metadata] {
mutate {
rename => { "@metadata" => "metadata" }
@@ -1,4 +1,4 @@
{%- from 'logstash/map.jinja' import LOGSTASH_REDIS_NODES with context %}
{%- from 'logstash/map.jinja' import LOGSTASH_REDIS_NODES, LOGSTASH_MERGED %}
{%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %}
{%- for index in range(LOGSTASH_REDIS_NODES|length) %}
@@ -18,3 +18,10 @@ input {
}
{% endfor %}
{% endfor -%}
filter {
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
ruby {
code => "event.set('[_tmp][logstash_from_redis]', Time.now().utc.iso8601(3));"
}
{% endif %}
}
@@ -1,3 +1,11 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
filter {
ruby {
code => "event.set('[_tmp][logstash_to_elasticsearch]', Time.now().utc.iso8601(3));"
}
}
{% endif %}
output {
if "elastic-agent" in [tags] and "so-ip-mappings" in [tags] {
elasticsearch {
@@ -13,7 +13,14 @@ filter {
add_tag => "fleet-lumberjack-{{ GLOBALS.hostname }}"
}
}
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
filter {
ruby {
code => "event.set('[_tmp][fleet_to_logstash]', Time.now().utc.iso8601(3));"
}
}
{% endif %}
output {
lumberjack {
codec => json
@@ -1,10 +1,17 @@
{%- from 'logstash/map.jinja' import LOGSTASH_MERGED %}
{%- if grains.role in ['so-heavynode', 'so-receiver'] %}
{%- set HOST = GLOBALS.hostname %}
{%- else %}
{%- set HOST = GLOBALS.manager %}
{%- endif %}
{%- set REDIS_PASS = salt['pillar.get']('redis:config:requirepass') %}
{% if LOGSTASH_MERGED.get('latency_metrics', False) %}
filter {
ruby {
code => "event.set('[_tmp][logstash_to_redis]', Time.now().utc.iso8601(3));"
}
}
{% endif %}
output {
redis {
host => '{{ HOST }}'
+5
View File
@@ -86,3 +86,8 @@ logstash:
multiline: True
advanced: True
forcedType: "[]string"
latency_metrics:
description: Enable latency metrics within events processed by logstash. Useful for pinpointing log ingest delay.
forcedType: bool
global: False
advanced: True
+21
View File
@@ -0,0 +1,21 @@
{% from 'vars/globals.map.jinja' import GLOBALS %}
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
include:
- salt.minion
{% if GLOBALS.is_manager and AUTOAPPLY.enabled %}
salt_beacons_pushstate:
file.managed:
- name: /etc/salt/minion.d/beacons_pushstate.conf
- source: salt://manager/files/beacons_pushstate.conf.jinja
- template: jinja
- watch_in:
- service: salt_minion_service
{% else %}
salt_beacons_pushstate:
file.absent:
- name: /etc/salt/minion.d/beacons_pushstate.conf
- watch_in:
- service: salt_minion_service
{% endif %}
@@ -0,0 +1,11 @@
{% from 'salt/auto_apply.map.jinja' import AUTOAPPLY %}
beacons:
postgres_pillar_beacon:
- interval: {{ AUTOAPPLY.drain_interval }}
- disable_during_state_run: False
rules_beacon:
- interval: {{ AUTOAPPLY.drain_interval }}
- disable_during_state_run: False
- paths:
/opt/so/saltstack/local/salt/suricata/rules: suricata
/opt/so/saltstack/local/salt/strelka/rules/compiled: strelka
+2
View File
@@ -0,0 +1,2 @@
https://repo.securityonion.net/file/so-repo/prod/3/oracle/9-uek8
https://repo-alt.securityonion.net/prod/3/oracle/9-uek8
+5
View File
@@ -11,3 +11,8 @@ name=Security Onion Repo repo
mirrorlist=file:///opt/so/conf/reposync/mirror.txt
enabled=1
gpgcheck=1
[securityonionkernelsync]
name=Security Onion Kernel Repo repo
mirrorlist=file:///opt/so/conf/reposync/mirror-kernel.txt
enabled=1
gpgcheck=1
+31
View File
@@ -15,6 +15,7 @@ include:
- manager.elasticsearch
- manager.kibana
- manager.managed_soc_annotations
- manager.beacons
repo_log_dir:
file.directory:
@@ -86,6 +87,28 @@ repo_dir:
- group
- show_changes: False
kernelrepo_dir:
file.directory:
- name: /nsm/kernelrepo
- user: socore
- group: socore
- recurse:
- user
- group
- show_changes: False
# Ensure /nsm/kernelrepo is always a valid (if empty) repo before it is ever assigned to
# a client. Without repodata/repomd.xml an enabled file:///nsm/kernelrepo repo makes every
# dnf operation fail; so-repo-sync only populates it after the highstate, so seed an empty
# repo here. Only runs when repodata is missing, so it won't clobber a synced repo.
kernelrepo_init_empty:
cmd.run:
- name: createrepo /nsm/kernelrepo
- unless: 'test -e /nsm/kernelrepo/repodata/repomd.xml'
- require:
- file: kernelrepo_dir
- pkg: install_createrepo
manager_sbin:
file.recurse:
- name: /usr/sbin
@@ -122,6 +145,13 @@ so-repo-mirrorlist:
- user: socore
- group: socore
so-repo-kernel-mirrorlist:
file.managed:
- name: /opt/so/conf/reposync/mirror-kernel.txt
- source: salt://manager/files/mirror-kernel.txt
- user: socore
- group: socore
so-repo-sync:
{% if MANAGERMERGED.reposync.enabled %}
cron.present:
@@ -231,6 +261,7 @@ surifiltersrules:
- user: 939
- group: 939
{% else %}
{{sls}}_state_not_allowed:
+24 -29
View File
@@ -16,40 +16,35 @@
{% endif %}
{% endfor %}
{% endfor %}
{% set es_soc_annotations = '/opt/so/saltstack/default/salt/elasticsearch/soc_elasticsearch.yaml' %}
{{ es_soc_annotations }}:
file.serialize:
- dataset:
{% set data = salt['file.read'](es_soc_annotations) | load_yaml %}
{% set es = data.get('elasticsearch', {}) %}
{% set index_settings = es.get('index_settings', {}) %}
{% set input = index_settings.get('so-logs', {}) %}
{% set soc_annotation_lines = [] %}
{% set defaults_lines = [] %}
{% for k in matched_integration_names %}
{% do index_settings.update({k: input}) %}
{% do soc_annotation_lines.append(' ' ~ k ~ ': *dataStreamSettings') %}
{% do defaults_lines.append(' ' ~ k ~ ':') %}
{% set defaults_yaml = salt['slsutil.serialize']('yaml', ADDON_INTEGRATION_DEFAULTS[k], default_flow_style=False).strip() %}
{% for line in defaults_yaml.splitlines() %}
{% do defaults_lines.append(' ' ~ line) %}
{% endfor %}
{% for k in addon_integration_keys %}
{% if k not in matched_integration_names and k in index_settings %}
{% do index_settings.pop(k) %}
{% endif %}
{% endfor %}
{{ data }}
{% set es_soc_annotations = '/opt/so/saltstack/default/salt/elasticsearch/soc_elasticsearch.yaml' %}
manage_soc_annotations:
file.blockreplace:
- name: {{ es_soc_annotations }}
- marker_start: ' # START managed SOC integration annotations'
- marker_end: ' # END managed SOC integration annotations'
- content: {{ soc_annotation_lines | join('\n') | tojson }}
- insert_after_match: '^ # Managed SOC integration annotations are inserted below this line\.'
- append_if_not_found: False
- show_changes: True
{# Managed elasticsearch/defaults.yaml file for enabling 'Revert to default' via SOC UI for newly added config items #}
{% set es_defaults = '/opt/so/saltstack/default/salt/elasticsearch/defaults.yaml' %}
{{ es_defaults }}:
file.serialize:
- dataset:
{% set data = salt['file.read'](es_defaults) | load_yaml %}
{% set es = data.get('elasticsearch', {}) %}
{% set index_settings = es.get('index_settings', {}) %}
{% for k in matched_integration_names %}
{% set input = ADDON_INTEGRATION_DEFAULTS[k] %}
{% do index_settings.update({k: input})%}
{% endfor %}
{% for k in addon_integration_keys %}
{% if k not in matched_integration_names and k in index_settings %}
{% do index_settings.pop(k) %}
{% endif %}
{% endfor %}
{{ data }}
file.blockreplace:
- marker_start: ' # START managed SOC integration defaults'
- marker_end: ' # END managed SOC integration defaults'
- content: {{ defaults_lines | join('\n') | tojson }}
- insert_after_match: '^ index_settings:$'
- append_if_not_found: False
- show_changes: True
{% endif %}
+5 -3
View File
@@ -31,11 +31,13 @@ sync_es_users:
- http: wait_for_kratos
- file: so-user.lock # require so-user.lock file to be missing
# we dont want this added too early in setup, so we add the onlyif to verify 'startup_states: highstate'
# is in the minion config. That line is added before the final highstate during setup
# we dont want this added too early in setup, so the onlyif gates on the
# /opt/so/state/setup-complete marker. The marker is written by
# mark_setup_complete in setup/so-functions just before the final setup
# highstate (and by an upgrade-path state for systems set up under the old gate).
so-user_sync:
cron.present:
- user: root
- name: 'PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin /usr/sbin/so-user sync &>> /opt/so/log/soc/sync.log'
- identifier: so-user_sync
- onlyif: "grep -x 'startup_states: highstate' /etc/salt/minion"
- onlyif: "test -e /opt/so/state/setup-complete"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
#
# 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.
# Runs once per boot on managers (via so-boot-mine-update.service), before
# so-boot-highstate.service. Waits for the responsive minion set to settle, pushes
# mine.update, waits until every up minion has actually reported to the mine, then
# warms the master's per-minion pillar cache so the mine-backed node pillars (node
# IPs, ES/Redis/Logstash/hypervisor discovery -- some glob- and some pillar/grain-
# targeted) are complete before the boot highstate renders them. Otherwise a node
# that is up but not yet fully reported gets dropped from those pillars and torn
# out of the configs they build (e.g. so-elasticsearch ExtraHosts -> container recreate).
MAX_WAIT=${MINE_UPDATE_MAX_WAIT:-180} # hard backstop only
INTERVAL=10
STABLE_CHECKS=3 # up-count must hold steady this many polls
elapsed=0
prev=-1
stable=0
up=0
# Wait for the *reachable* minion set to settle rather than for every accepted
# key to report up: an operator may accept a minion's key and then intentionally
# power off that host, so requiring up >= accepted would never be satisfied and
# we'd always burn the full MAX_WAIT. Once the responsive count stops growing we
# stop waiting and run mine.update against whoever is up.
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
up=$(/usr/bin/salt-run manage.up --out=json 2>/dev/null \
| python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null)
up=${up:-0}
if [ "$up" -gt 0 ] && [ "$up" -eq "$prev" ]; then
stable=$((stable + 1))
[ "$stable" -ge "$STABLE_CHECKS" ] && break
else
stable=0
fi
prev=$up
sleep "$INTERVAL"
elapsed=$((elapsed + INTERVAL))
done
echo "so-boot-mine-update: ${up} minions up (settled after ${elapsed}s); running mine.update"
/usr/bin/salt '*' mine.update --out=txt
# A node that is up but has not yet re-reported network.ip_addrs to the mine is
# silently dropped from mine-backed pillars (elasticsearch:nodes, node_data, ...)
# when highstate recompiles them -- which e.g. removes it from so-elasticsearch
# ExtraHosts and forces a container recreate. After the broad mine.update above,
# wait until every up minion actually has network.ip_addrs in the mine, re-pushing
# mine.update to stragglers, before releasing the boot highstate. Bounded by the
# same MAX_WAIT backstop so a slow/down node never blocks boot indefinitely.
missing=""
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
up_json=$(/usr/bin/salt-run manage.up --out=json 2>/dev/null)
mine_json=$(/usr/bin/salt-run mine.get '*' network.ip_addrs tgt_type=glob --out=json 2>/dev/null)
missing=$(printf '%s' "$up_json" | python3 -c '
import sys, json
up = set(json.load(sys.stdin) or [])
mine = {k for k, v in (json.loads(sys.argv[1]) or {}).items() if v}
print("\n".join(sorted(up - mine)))
' "$mine_json" 2>/dev/null)
if [ -z "$missing" ]; then
echo "so-boot-mine-update: mine complete for all up minions after ${elapsed}s"
break
fi
echo "so-boot-mine-update: mine missing up minion(s): $(echo $missing); re-running mine.update"
for m in $missing; do /usr/bin/salt "$m" mine.update --out=txt; done
sleep "$INTERVAL"
elapsed=$((elapsed + INTERVAL))
done
[ -n "$missing" ] && echo "so-boot-mine-update: WARNING ${MAX_WAIT}s backstop hit; up minion(s) still absent from mine: $(echo $missing); highstate may drop them from configs"
# The pillar/compound-targeted node pillars (elasticsearch:nodes, redis:nodes,
# logstash:nodes, hypervisor:nodes) resolve their target against the master's
# per-minion data cache (grains+pillar in .../minions/<id>/data.p), populated only
# when a minion's pillar is (re)compiled -- separately from the mine. A freshly
# booted node can be in the mine (glob/node_data sees it) yet absent from that
# cache, so it is dropped from those pillars and from the configs they build (e.g.
# so-elasticsearch ExtraHosts). Force a synchronous pillar refresh so the master
# caches every up node's pillar; refresh_pillar wait=True returns only once the
# pillar is recompiled (and thus cached for matching). Retry stragglers <= MAX_WAIT.
echo "so-boot-mine-update: warming master pillar cache for pillar/grain-targeted node pillars"
/usr/bin/salt '*' saltutil.refresh_pillar wait=True --out=txt
missing=""
while [ "$elapsed" -lt "$MAX_WAIT" ]; do
up_json=$(/usr/bin/salt-run manage.up --out=json 2>/dev/null)
cached_json=$(/usr/bin/salt-run cache.pillar tgt='*' --out=json 2>/dev/null)
missing=$(printf '%s' "$up_json" | python3 -c '
import sys, json
up = set(json.load(sys.stdin) or [])
cached = {k for k, v in (json.loads(sys.argv[1]) or {}).items() if v}
print("\n".join(sorted(up - cached)))
' "$cached_json" 2>/dev/null)
if [ -z "$missing" ]; then
echo "so-boot-mine-update: pillar cache warm for all up minions after ${elapsed}s"
break
fi
echo "so-boot-mine-update: pillar not yet cached for: $(echo $missing); refreshing"
for m in $missing; do /usr/bin/salt "$m" saltutil.refresh_pillar wait=True --out=txt; done
sleep "$INTERVAL"
elapsed=$((elapsed + INTERVAL))
done
[ -n "$missing" ] && echo "so-boot-mine-update: WARNING ${MAX_WAIT}s backstop hit; pillar not cached for: $(echo $missing); pillar-targeted pillars may drop them"
# Log what the mine-backed pillars render so the boot-time state is inspectable.
/usr/bin/salt-call saltutil.refresh_pillar >/dev/null 2>&1
sleep 2
for key in node_data elasticsearch:nodes; do
rendered=$(/usr/bin/salt-call --out=json pillar.get "$key" 2>/dev/null \
| python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin).get("local"), indent=2, sort_keys=True))' 2>/dev/null)
echo "so-boot-mine-update: ${key} rendered as:"
echo "${rendered:-null}"
done
exit 0
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
# 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.
# Imports detection overrides (e.g. from so-detections-backup) into the so-detection
# index. Reads <publicId>.<ext> files (NDJSON, one override per line) from a source
# directory, looks up the matching detection by publicId+engine, validates each
# override against the same rules SOC enforces, dedupes against existing overrides
# (operational fields only), and appends new ones.
import argparse
import ipaddress
import json
import os
import re
import sys
from datetime import datetime
import requests
from requests.auth import HTTPBasicAuth
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
DEFAULT_INDEX = "so-detection"
AUTH_FILE = "/opt/so/conf/elasticsearch/curl.config"
ES_URL = "https://localhost:9200"
# Engines we know how to handle and the file extension the backup script writes.
ENGINES = {
"suricata": "txt",
}
# Standard Suricata variables that ship with Security Onion. Anything else
# referenced in an override is "custom" and the user needs to make sure it
# exists in SOC Config before the override will function.
BUILTIN_SURICATA_VARS = {
"$HOME_NET", "$EXTERNAL_NET",
"$HTTP_SERVERS", "$DNS_SERVERS", "$SQL_SERVERS", "$SMTP_SERVERS",
"$TELNET_SERVERS", "$AIM_SERVERS", "$DC_SERVERS", "$MODBUS_SERVER",
"$MODBUS_CLIENT", "$ENIP_CLIENT", "$ENIP_SERVER",
"$HTTP_PORTS", "$SHELLCODE_PORTS", "$ORACLE_PORTS", "$SSH_PORTS",
"$FTP_PORTS", "$FILE_DATA_PORTS",
}
VAR_PATTERN = re.compile(r"\$[A-Z_][A-Z0-9_]*")
# Canonical valid values, per securityonion-soc/model/detection.go.
SURICATA_OVERRIDE_TYPES = {"suppress", "threshold", "modify"}
SUPPRESS_TRACKS = {"by_src", "by_dst", "by_either"}
THRESHOLD_TRACKS = {"by_src", "by_dst", "by_both"}
THRESHOLD_TYPES = {"limit", "threshold", "both"}
STALE_WARNING = """\
WARNING: so-detections-backup does not remove backup files when overrides are
deleted via the Security Onion web UI. As a result, files in the source
directory may represent overrides that were intentionally deleted and should
NOT be re-imported.
Before continuing, verify that the source directory reflects the overrides you
actually want imported. Remove any files corresponding to overrides you previously deleted.
"""
def make_session(auth_file):
with open(auth_file, "r") as f:
for line in f:
if line.startswith("user ="):
creds = line.split("=", 1)[1].strip().replace('"', "")
user, _, password = creds.partition(":")
session = requests.Session()
session.auth = HTTPBasicAuth(user, password)
session.headers.update({"Content-Type": "application/json"})
session.verify = False
return session
raise RuntimeError(f"Could not find 'user =' line in {auth_file}")
def find_detection(session, index, public_id, engine):
query = {
"query": {"bool": {"must": [
{"term": {"so_detection.publicId": public_id}},
{"term": {"so_detection.engine": engine}},
]}},
"size": 2,
}
r = session.get(f"{ES_URL}/{index}/_search", json=query)
r.raise_for_status()
hits = r.json().get("hits", {}).get("hits", [])
if not hits:
return None, None, None
if len(hits) > 1:
# Shouldn't happen — publicId is unique per engine — but flag it.
print(f" WARN: {len(hits)} detections matched publicId={public_id} engine={engine}; using first")
hit = hits[0]
existing = hit["_source"].get("so_detection", {}).get("overrides") or []
return hit["_id"], hit["_index"], existing
def update_overrides(session, doc_index, doc_id, overrides):
body = {"doc": {"so_detection": {"overrides": overrides}}}
r = session.post(f"{ES_URL}/{doc_index}/_update/{doc_id}", json=body)
r.raise_for_status()
return r.json()
def dedupe_key(override):
"""Operational fields only, per Override.Equal() in detection.go.
Excludes timestamps and isEnabled so re-imports don't appear unique."""
t = override.get("type")
if t == "suppress":
return (t, override.get("track"), override.get("ip"))
if t == "threshold":
return (t, override.get("thresholdType"), override.get("track"),
override.get("count"), override.get("seconds"))
if t == "modify":
return (t, override.get("regex"), override.get("value"))
def _validate_suricata_ip(ip):
if not ip:
return "ip cannot be empty"
if ip.startswith("$"):
return None
if ip.startswith("[") and ip.endswith("]"):
for part in ip[1:-1].split(","):
err = _validate_single_ip(part.strip())
if err:
return f"invalid IP in list: {err}"
return None
return _validate_single_ip(ip)
def _validate_single_ip(ip):
try:
if "/" in ip:
ipaddress.ip_network(ip, strict=False)
else:
ipaddress.ip_address(ip)
except ValueError:
return f"invalid IP/CIDR {ip!r}"
return None
def validate_override(override, engine):
"""Mirror Override.Validate() from securityonion-soc/model/detection.go.
Returns None on success, an error string otherwise."""
t = override.get("type")
if not t:
return "override type is required"
if t not in SURICATA_OVERRIDE_TYPES:
return f"invalid type {t!r}: must be one of {sorted(SURICATA_OVERRIDE_TYPES)}"
has = {k: override.get(k) is not None for k in
("regex", "value", "thresholdType", "track", "ip", "count", "seconds", "customFilter")}
if t == "suppress":
if not has["ip"] or not has["track"]:
return "suppress requires 'ip' and 'track'"
if any(has[k] for k in ("regex", "value", "thresholdType", "count", "seconds", "customFilter")):
return "suppress has unnecessary fields"
if override["track"] not in SUPPRESS_TRACKS:
return f"invalid track {override['track']!r}: must be one of {sorted(SUPPRESS_TRACKS)}"
return _validate_suricata_ip(override["ip"])
if t == "threshold":
if not all(has[k] for k in ("thresholdType", "track", "count", "seconds")):
return "threshold requires 'thresholdType', 'track', 'count', 'seconds'"
if any(has[k] for k in ("regex", "value", "customFilter")):
return "threshold has unnecessary fields"
if override["thresholdType"] not in THRESHOLD_TYPES:
return f"invalid thresholdType {override['thresholdType']!r}: must be one of {sorted(THRESHOLD_TYPES)}"
if override["track"] not in THRESHOLD_TRACKS:
return f"invalid track {override['track']!r}: must be one of {sorted(THRESHOLD_TRACKS)}"
if not isinstance(override["count"], int) or override["count"] <= 0:
return f"count must be a positive integer, got {override['count']!r}"
if not isinstance(override["seconds"], int) or override["seconds"] <= 0:
return f"seconds must be a positive integer, got {override['seconds']!r}"
return None
if t == "modify":
if not has["regex"] or not has["value"]:
return "modify requires 'regex' and 'value'"
if any(has[k] for k in ("thresholdType", "track", "count", "seconds", "customFilter")):
return "modify has unnecessary fields"
try:
re.compile(override["regex"])
except re.error as e:
return f"invalid regex: {e}"
return None
def parse_overrides_file(path):
"""Parse a file written by so-detections-backup.py: NDJSON, one override
per line. Returns a list of (override_dict, line_number)."""
overrides = []
with open(path, "r") as f:
for i, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
overrides.append((json.loads(line), i))
return overrides
def describe(override):
"""Human-readable summary of the operational fields for a given override type."""
t = override.get("type")
if t == "suppress":
return f"type=suppress track={override.get('track')} ip={override.get('ip')}"
if t == "threshold":
return (f"type=threshold track={override.get('track')} "
f"thresholdType={override.get('thresholdType')} "
f"count={override.get('count')} seconds={override.get('seconds')}")
if t == "modify":
return f"type=modify regex={override.get('regex')!r}"
def collect_custom_vars(override):
found = set()
for value in override.values():
if isinstance(value, str):
for match in VAR_PATTERN.findall(value):
if match not in BUILTIN_SURICATA_VARS:
found.add(match)
return found
def parse_args():
p = argparse.ArgumentParser(
description="Import detection overrides into the so-detection index.",
)
p.add_argument("--source", "-s", required=True,
help="Source directory containing <publicId>.<ext> override files.")
p.add_argument("--engine", "-e", default="suricata", choices=list(ENGINES.keys()),
help="Detection engine (default: suricata).")
p.add_argument("--dry-run", "-n", action="store_true",
help="Print what would happen without writing to Elasticsearch.")
p.add_argument("--no-import-note", action="store_true",
help="Do not prepend '[Imported YYYY-MM-DD] ' to the override note.")
p.add_argument("--index", "-i", default=DEFAULT_INDEX,
help=f"Elasticsearch index to update (default: {DEFAULT_INDEX}).")
return p.parse_args()
def confirm_proceed(args):
"""Show the stale-backup warning. Dry-run prints it and continues. Real
runs require the user typing 'yes' at the prompt."""
print(STALE_WARNING)
if args.dry_run:
print("(dry-run: no acknowledgement required)\n")
return True
answer = input("Type 'yes' to acknowledge and continue: ").strip().lower()
print()
return answer == "yes"
def main():
args = parse_args()
if not os.path.isdir(args.source):
print(f"ERROR: source directory not found: {args.source}", file=sys.stderr)
sys.exit(1)
extension = ENGINES[args.engine]
files = sorted(f for f in os.listdir(args.source) if f.endswith(f".{extension}"))
if not files:
print(f"No *.{extension} files found in {args.source}")
sys.exit(0)
if not confirm_proceed(args):
print("Aborted.")
sys.exit(1)
session = make_session(AUTH_FILE)
today = datetime.now().strftime("%Y-%m-%d")
note_prefix = "" if args.no_import_note else f"[Imported {today}] "
counts = {"added": 0, "skipped_dedupe": 0, "skipped_not_found": 0, "invalid": 0, "error": 0}
custom_vars = set()
mode = "DRY-RUN" if args.dry_run else "IMPORT"
print(f"[{mode}] engine={args.engine} source={args.source} index={args.index}\n")
for filename in files:
public_id = os.path.splitext(filename)[0]
path = os.path.join(args.source, filename)
print(f"{public_id}:")
try:
new_overrides = parse_overrides_file(path)
except (json.JSONDecodeError, OSError) as e:
print(f" ERROR: could not parse {filename}: {e}")
counts["error"] += 1
continue
if not new_overrides:
print(" SKIP: empty file")
continue
try:
doc_id, doc_index, existing = find_detection(session, args.index, public_id, args.engine)
except requests.HTTPError as e:
print(f" ERROR: search failed: {e}")
counts["error"] += 1
continue
if doc_id is None:
print(f" WARN: no detection found for publicId={public_id} engine={args.engine}; skipping")
counts["skipped_not_found"] += len(new_overrides)
continue
existing_keys = {dedupe_key(o) for o in existing}
merged = list(existing)
added_this_file = 0
for override, line_no in new_overrides:
err = validate_override(override, args.engine)
if err:
print(f" INVALID (line {line_no}): {err}")
counts["invalid"] += 1
continue
custom_vars.update(collect_custom_vars(override))
key = dedupe_key(override)
if key in existing_keys:
print(f" SKIP (line {line_no}): duplicate of existing override [{describe(override)}]")
counts["skipped_dedupe"] += 1
continue
if note_prefix:
override = dict(override)
override["note"] = note_prefix + (override.get("note") or "")
merged.append(override)
existing_keys.add(key)
added_this_file += 1
print(f" ADD (line {line_no}): {describe(override)}")
if added_this_file == 0:
continue
if args.dry_run:
print(f" DRY-RUN: would update {doc_index}/{doc_id} "
f"({len(existing)} existing → {len(merged)} total)")
counts["added"] += added_this_file
continue
try:
update_overrides(session, doc_index, doc_id, merged)
print(f" UPDATED {doc_index}/{doc_id} ({len(existing)} → {len(merged)})")
counts["added"] += added_this_file
except requests.HTTPError as e:
print(f" ERROR: update failed: {e}")
counts["error"] += 1
print()
print("=" * 60)
print(f"Summary ({mode}):")
print(f" Overrides added: {counts['added']}")
print(f" Skipped (already present): {counts['skipped_dedupe']}")
print(f" Skipped (no detection): {counts['skipped_not_found']}")
print(f" Invalid (failed checks): {counts['invalid']}")
print(f" Errors: {counts['error']}")
if custom_vars:
print()
print("WARNING: detected custom Suricata variables in imported overrides:")
for v in sorted(custom_vars):
print(f" {v}")
print("If any of these are not already defined in SOC Config (Suricata variables),")
print("you must add them manually before the rules will function correctly.")
sys.exit(0 if counts["error"] == 0 and counts["invalid"] == 0 else 1)
if __name__ == "__main__":
main()
@@ -0,0 +1,588 @@
# 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 importlib.util
import json
import os
import shutil
import sys
import tempfile
import unittest
from importlib.machinery import SourceFileLoader
from io import StringIO
from unittest.mock import MagicMock, patch
import requests
# The script has no .py extension; spec_from_file_location can't auto-detect a
# loader, so we hand it a SourceFileLoader explicitly. (load_module() is
# deprecated in 3.14 and slated for removal in 3.15.)
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "so-detections-overrides-import")
_loader = SourceFileLoader("so_overrides_import", SCRIPT)
_spec = importlib.util.spec_from_loader("so_overrides_import", _loader)
soi = importlib.util.module_from_spec(_spec)
_loader.exec_module(soi)
class TestValidateSuppress(unittest.TestCase):
def test_valid(self):
self.assertIsNone(soi.validate_override(
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}, "suricata"))
def test_valid_var(self):
self.assertIsNone(soi.validate_override(
{"type": "suppress", "track": "by_either", "ip": "$HOME_NET"}, "suricata"))
def test_valid_cidr(self):
self.assertIsNone(soi.validate_override(
{"type": "suppress", "track": "by_dst", "ip": "10.0.0.0/8"}, "suricata"))
def test_valid_bracket_list(self):
self.assertIsNone(soi.validate_override(
{"type": "suppress", "track": "by_src", "ip": "[1.2.3.4,10.0.0.0/8]"}, "suricata"))
def test_missing_ip(self):
err = soi.validate_override({"type": "suppress", "track": "by_src"}, "suricata")
self.assertIn("requires", err)
def test_missing_track(self):
err = soi.validate_override({"type": "suppress", "ip": "1.2.3.4"}, "suricata")
self.assertIn("requires", err)
def test_invalid_track(self):
err = soi.validate_override(
{"type": "suppress", "track": "by_both", "ip": "1.2.3.4"}, "suricata")
self.assertIn("invalid track", err)
def test_invalid_ip(self):
err = soi.validate_override(
{"type": "suppress", "track": "by_src", "ip": "not-an-ip"}, "suricata")
self.assertIn("invalid IP", err)
def test_unnecessary_field(self):
err = soi.validate_override(
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4", "count": 5}, "suricata")
self.assertIn("unnecessary fields", err)
class TestValidateThreshold(unittest.TestCase):
def test_valid(self):
self.assertIsNone(soi.validate_override({
"type": "threshold", "track": "by_src",
"thresholdType": "limit", "count": 10, "seconds": 60,
}, "suricata"))
def test_valid_by_both(self):
self.assertIsNone(soi.validate_override({
"type": "threshold", "track": "by_both",
"thresholdType": "both", "count": 1, "seconds": 1,
}, "suricata"))
def test_track_by_either_invalid(self):
err = soi.validate_override({
"type": "threshold", "track": "by_either",
"thresholdType": "limit", "count": 10, "seconds": 60,
}, "suricata")
self.assertIn("invalid track", err)
def test_invalid_threshold_type(self):
err = soi.validate_override({
"type": "threshold", "track": "by_src",
"thresholdType": "bogus", "count": 10, "seconds": 60,
}, "suricata")
self.assertIn("invalid thresholdType", err)
def test_zero_count(self):
err = soi.validate_override({
"type": "threshold", "track": "by_src",
"thresholdType": "limit", "count": 0, "seconds": 60,
}, "suricata")
self.assertIn("count", err)
def test_negative_seconds(self):
err = soi.validate_override({
"type": "threshold", "track": "by_src",
"thresholdType": "limit", "count": 10, "seconds": -1,
}, "suricata")
self.assertIn("seconds", err)
def test_missing_field(self):
err = soi.validate_override({
"type": "threshold", "track": "by_src",
"thresholdType": "limit", "count": 10, # missing seconds
}, "suricata")
self.assertIn("requires", err)
def test_unnecessary_field(self):
err = soi.validate_override({
"type": "threshold", "track": "by_src",
"thresholdType": "limit", "count": 10, "seconds": 60,
"regex": "foo",
}, "suricata")
self.assertIn("unnecessary fields", err)
class TestValidateModify(unittest.TestCase):
def test_valid(self):
self.assertIsNone(soi.validate_override(
{"type": "modify", "regex": r"content:\"foo\"", "value": "content:bar"}, "suricata"))
def test_invalid_regex(self):
err = soi.validate_override(
{"type": "modify", "regex": "(unbalanced", "value": "x"}, "suricata")
self.assertIn("invalid regex", err)
def test_missing_value(self):
err = soi.validate_override({"type": "modify", "regex": "x"}, "suricata")
self.assertIn("requires", err)
def test_unnecessary_field(self):
err = soi.validate_override(
{"type": "modify", "regex": "x", "value": "y", "track": "by_src"}, "suricata")
self.assertIn("unnecessary fields", err)
class TestValidateMisc(unittest.TestCase):
def test_unknown_type(self):
err = soi.validate_override({"type": "suppresss", "track": "by_src", "ip": "1.2.3.4"}, "suricata")
self.assertIn("invalid type", err)
def test_missing_type(self):
err = soi.validate_override({"track": "by_src"}, "suricata")
self.assertIn("type is required", err)
class TestValidateIP(unittest.TestCase):
def test_plain_ipv4(self):
self.assertIsNone(soi._validate_suricata_ip("1.2.3.4"))
def test_plain_ipv6(self):
self.assertIsNone(soi._validate_suricata_ip("::1"))
def test_cidr(self):
self.assertIsNone(soi._validate_suricata_ip("10.0.0.0/8"))
def test_var(self):
self.assertIsNone(soi._validate_suricata_ip("$CONCOURSEWORKERS"))
def test_bracket_list(self):
self.assertIsNone(soi._validate_suricata_ip("[1.2.3.4, 10.0.0.0/8]"))
def test_bracket_list_bad_member(self):
err = soi._validate_suricata_ip("[1.2.3.4,nope]")
self.assertIn("invalid IP in list", err)
def test_empty(self):
self.assertIn("empty", soi._validate_suricata_ip(""))
def test_invalid(self):
self.assertIn("invalid", soi._validate_suricata_ip("999.999.999.999"))
class TestDedupeKey(unittest.TestCase):
def test_suppress(self):
a = {"type": "suppress", "track": "by_src", "ip": "1.2.3.4", "count": 99}
b = {"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}
# count is irrelevant for suppress dedupe
self.assertEqual(soi.dedupe_key(a), soi.dedupe_key(b))
def test_suppress_differs_on_ip(self):
a = {"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}
b = {"type": "suppress", "track": "by_src", "ip": "5.6.7.8"}
self.assertNotEqual(soi.dedupe_key(a), soi.dedupe_key(b))
def test_threshold(self):
a = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
"count": 10, "seconds": 60, "ip": "ignored"}
b = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
"count": 10, "seconds": 60}
self.assertEqual(soi.dedupe_key(a), soi.dedupe_key(b))
def test_threshold_differs_on_count(self):
a = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
"count": 10, "seconds": 60}
b = {"type": "threshold", "track": "by_src", "thresholdType": "limit",
"count": 20, "seconds": 60}
self.assertNotEqual(soi.dedupe_key(a), soi.dedupe_key(b))
def test_modify(self):
a = {"type": "modify", "regex": "x", "value": "y"}
b = {"type": "modify", "regex": "x", "value": "y"}
self.assertEqual(soi.dedupe_key(a), soi.dedupe_key(b))
class TestDescribe(unittest.TestCase):
def test_suppress(self):
s = soi.describe({"type": "suppress", "track": "by_src", "ip": "1.2.3.4"})
self.assertIn("suppress", s)
self.assertIn("by_src", s)
self.assertIn("1.2.3.4", s)
def test_threshold_includes_count(self):
s = soi.describe({"type": "threshold", "track": "by_src",
"thresholdType": "limit", "count": 10, "seconds": 60})
self.assertIn("count=10", s)
self.assertIn("seconds=60", s)
def test_modify(self):
s = soi.describe({"type": "modify", "regex": "foo"})
self.assertIn("modify", s)
self.assertIn("foo", s)
class TestParseOverridesFile(unittest.TestCase):
def _write(self, content):
fd, path = tempfile.mkstemp(suffix=".txt")
os.close(fd)
with open(path, "w") as f:
f.write(content)
self.addCleanup(os.unlink, path)
return path
def test_single_line(self):
path = self._write('{"type":"suppress","track":"by_src","ip":"1.2.3.4"}')
result = soi.parse_overrides_file(path)
self.assertEqual(len(result), 1)
self.assertEqual(result[0][0]["type"], "suppress")
self.assertEqual(result[0][1], 1)
def test_ndjson(self):
path = self._write(
'{"type":"suppress","track":"by_src","ip":"1.2.3.4"}\n'
'{"type":"suppress","track":"by_dst","ip":"5.6.7.8"}\n'
)
result = soi.parse_overrides_file(path)
self.assertEqual(len(result), 2)
self.assertEqual(result[1][1], 2)
def test_empty(self):
path = self._write("")
self.assertEqual(soi.parse_overrides_file(path), [])
def test_blank_lines_skipped(self):
path = self._write('\n{"type":"suppress","track":"by_src","ip":"1.2.3.4"}\n\n')
result = soi.parse_overrides_file(path)
self.assertEqual(len(result), 1)
self.assertEqual(result[0][1], 2) # line number reflects original position
def test_invalid_raises(self):
path = self._write("not json")
with self.assertRaises(json.JSONDecodeError):
soi.parse_overrides_file(path)
class TestCollectCustomVars(unittest.TestCase):
def test_finds_custom(self):
v = soi.collect_custom_vars({"ip": "$CONCOURSEWORKERS"})
self.assertEqual(v, {"$CONCOURSEWORKERS"})
def test_filters_builtins(self):
v = soi.collect_custom_vars({"ip": "$HOME_NET"})
self.assertEqual(v, set())
def test_mixed(self):
v = soi.collect_custom_vars({"ip": "[$HOME_NET,$MYNET]"})
self.assertEqual(v, {"$MYNET"})
def test_non_string_fields_ignored(self):
v = soi.collect_custom_vars({"count": 10, "isEnabled": True})
self.assertEqual(v, set())
class TestMakeSession(unittest.TestCase):
def _write(self, content):
fd, path = tempfile.mkstemp()
os.close(fd)
with open(path, "w") as f:
f.write(content)
self.addCleanup(os.unlink, path)
return path
def test_valid_auth_file(self):
path = self._write('user = "admin:secret"\n')
session = soi.make_session(path)
self.assertEqual(session.auth.username, "admin")
self.assertEqual(session.auth.password, "secret")
self.assertFalse(session.verify)
def test_missing_user_line(self):
path = self._write("# no user line here\n")
with self.assertRaises(RuntimeError):
soi.make_session(path)
class TestFindDetection(unittest.TestCase):
def _session_with_response(self, payload):
session = MagicMock()
response = MagicMock()
response.json.return_value = payload
response.raise_for_status.return_value = None
session.get.return_value = response
return session
def test_found(self):
session = self._session_with_response({"hits": {"hits": [{
"_id": "abc", "_index": "so-detection",
"_source": {"so_detection": {"overrides": [{"type": "suppress"}]}},
}]}})
doc_id, idx, existing = soi.find_detection(session, "so-detection", "2049201", "suricata")
self.assertEqual(doc_id, "abc")
self.assertEqual(idx, "so-detection")
self.assertEqual(len(existing), 1)
def test_not_found(self):
session = self._session_with_response({"hits": {"hits": []}})
doc_id, idx, existing = soi.find_detection(session, "so-detection", "x", "suricata")
self.assertIsNone(doc_id)
self.assertIsNone(idx)
self.assertIsNone(existing)
def test_no_overrides_field(self):
session = self._session_with_response({"hits": {"hits": [{
"_id": "abc", "_index": "so-detection",
"_source": {"so_detection": {}},
}]}})
_, _, existing = soi.find_detection(session, "so-detection", "x", "suricata")
self.assertEqual(existing, [])
def test_multiple_hits_warns(self):
session = self._session_with_response({"hits": {"hits": [
{"_id": "a", "_index": "i", "_source": {"so_detection": {"overrides": []}}},
{"_id": "b", "_index": "i", "_source": {"so_detection": {"overrides": []}}},
]}})
with patch("sys.stdout", new=StringIO()) as out:
doc_id, _, _ = soi.find_detection(session, "i", "x", "suricata")
self.assertEqual(doc_id, "a")
self.assertIn("WARN", out.getvalue())
class TestUpdateOverrides(unittest.TestCase):
def test_posts_to_update_endpoint(self):
session = MagicMock()
response = MagicMock()
response.raise_for_status.return_value = None
response.json.return_value = {"result": "updated"}
session.post.return_value = response
result = soi.update_overrides(session, "so-detection", "abc", [{"type": "suppress"}])
self.assertEqual(result, {"result": "updated"})
url = session.post.call_args[0][0]
self.assertIn("/_update/abc", url)
body = session.post.call_args[1]["json"]
self.assertEqual(body["doc"]["so_detection"]["overrides"], [{"type": "suppress"}])
class TestConfirmProceed(unittest.TestCase):
def test_dry_run_skips_prompt(self):
args = MagicMock(dry_run=True)
with patch("sys.stdout", new=StringIO()):
self.assertTrue(soi.confirm_proceed(args))
def test_yes_input(self):
args = MagicMock(dry_run=False)
with patch("sys.stdout", new=StringIO()):
with patch("builtins.input", return_value="yes"):
self.assertTrue(soi.confirm_proceed(args))
def test_yes_input_case_insensitive(self):
args = MagicMock(dry_run=False)
with patch("sys.stdout", new=StringIO()):
with patch("builtins.input", return_value="YES"):
self.assertTrue(soi.confirm_proceed(args))
def test_no_input_aborts(self):
args = MagicMock(dry_run=False)
with patch("sys.stdout", new=StringIO()):
with patch("builtins.input", return_value="no"):
self.assertFalse(soi.confirm_proceed(args))
def test_empty_input_aborts(self):
args = MagicMock(dry_run=False)
with patch("sys.stdout", new=StringIO()):
with patch("builtins.input", return_value=""):
self.assertFalse(soi.confirm_proceed(args))
class TestParseArgs(unittest.TestCase):
def test_defaults(self):
with patch.object(sys, "argv", ["cmd", "--source", "/some/path"]):
args = soi.parse_args()
self.assertEqual(args.source, "/some/path")
self.assertEqual(args.engine, "suricata")
self.assertFalse(args.dry_run)
self.assertFalse(args.no_import_note)
self.assertEqual(args.index, soi.DEFAULT_INDEX)
def test_all_options(self):
argv = ["cmd", "-s", "/x", "-e", "suricata", "-n",
"--no-import-note", "-i", "alt-index"]
with patch.object(sys, "argv", argv):
args = soi.parse_args()
self.assertEqual(args.source, "/x")
self.assertTrue(args.dry_run)
self.assertTrue(args.no_import_note)
self.assertEqual(args.index, "alt-index")
class TestMain(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
# Stub make_session so tests don't need /opt/so/conf/elasticsearch/curl.config.
p = patch.object(soi, "make_session", return_value=MagicMock())
p.start()
self.addCleanup(p.stop)
def _write_file(self, public_id, overrides, ext="txt"):
"""Write an NDJSON override file. Entries may be dicts or raw strings (for malformed input)."""
path = os.path.join(self.tmpdir, f"{public_id}.{ext}")
with open(path, "w") as f:
for o in overrides:
f.write(o if isinstance(o, str) else json.dumps(o))
f.write("\n")
return path
def _run_main(self, *extra_argv, input_response="yes"):
"""Run main() with stdout/stderr captured and input mocked. Returns (stdout, stderr, exit_code)."""
argv = ["cmd", "--source", self.tmpdir, *extra_argv]
out, err = StringIO(), StringIO()
with patch.object(sys, "argv", argv), \
patch("sys.stdout", new=out), \
patch("sys.stderr", new=err), \
patch("builtins.input", return_value=input_response):
with self.assertRaises(SystemExit) as cm:
soi.main()
return out.getvalue(), err.getvalue(), cm.exception.code
def test_source_dir_missing(self):
argv = ["cmd", "--source", "/no/such/path/here"]
err = StringIO()
with patch.object(sys, "argv", argv), patch("sys.stderr", new=err):
with self.assertRaises(SystemExit) as cm:
soi.main()
self.assertEqual(cm.exception.code, 1)
self.assertIn("source directory not found", err.getvalue())
def test_no_files_found(self):
out, _, code = self._run_main()
self.assertEqual(code, 0)
self.assertIn("No *.txt files found", out)
def test_user_aborts(self):
self._write_file("1001", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
out, _, code = self._run_main(input_response="no")
self.assertEqual(code, 1)
self.assertIn("Aborted", out)
def test_parse_error_increments_error(self):
# Malformed JSON line — parse_overrides_file raises JSONDecodeError.
self._write_file("1002", ["not json"])
out, _, code = self._run_main("--dry-run")
self.assertEqual(code, 1) # invalid+error → non-zero
self.assertIn("could not parse", out)
self.assertIn("Errors: 1", out)
def test_empty_file_skipped(self):
# Blank lines only — parse_overrides_file returns []; main reports "empty file" and continues.
path = os.path.join(self.tmpdir, "1003.txt")
with open(path, "w") as f:
f.write("\n\n")
out, _, code = self._run_main("--dry-run")
self.assertEqual(code, 0)
self.assertIn("empty file", out)
@patch.object(soi, "find_detection")
def test_search_http_error(self, mock_find):
mock_find.side_effect = requests.HTTPError("boom")
self._write_file("1004", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
out, _, code = self._run_main("--dry-run")
self.assertEqual(code, 1)
self.assertIn("search failed", out)
@patch.object(soi, "find_detection")
def test_no_detection_found(self, mock_find):
mock_find.return_value = (None, None, None)
self._write_file("1005", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
out, _, code = self._run_main("--dry-run")
self.assertEqual(code, 0)
self.assertIn("no detection found", out)
self.assertIn("Skipped (no detection): 1", out)
@patch.object(soi, "find_detection")
def test_all_duplicates_no_update(self, mock_find):
existing = [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}]
mock_find.return_value = ("doc1", "so-detection", existing)
self._write_file("1006", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
out, _, code = self._run_main("--dry-run")
self.assertEqual(code, 0)
self.assertIn("SKIP", out)
self.assertNotIn("DRY-RUN: would update", out) # added_this_file == 0 branch
@patch.object(soi, "update_overrides")
@patch.object(soi, "find_detection")
def test_happy_path_full(self, mock_find, mock_update):
# Exercises: ADD, dedupe SKIP, INVALID, note prefix, UPDATE, custom-vars warning, exit=1 (invalid present)
existing = [{"type": "suppress", "track": "by_src", "ip": "9.9.9.9"}]
mock_find.return_value = ("doc1", "so-detection", existing)
mock_update.return_value = {"result": "updated"}
self._write_file("1007", [
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}, # ADD
{"type": "suppress", "track": "by_src", "ip": "9.9.9.9"}, # SKIP (dupe of existing)
{"type": "suppress", "track": "bogus", "ip": "1.2.3.4"}, # INVALID
{"type": "suppress", "track": "by_src", "ip": "$CONCOURSEWORKERS"}, # ADD + custom var
])
out, _, code = self._run_main()
self.assertEqual(code, 1) # one invalid -> non-zero
mock_update.assert_called_once()
merged = mock_update.call_args[0][3]
self.assertEqual(len(merged), 3) # 1 existing + 2 new
new_notes = [o.get("note", "") for o in merged if o.get("ip") in ("1.2.3.4", "$CONCOURSEWORKERS")]
self.assertTrue(all(n.startswith("[Imported ") for n in new_notes))
self.assertIn("ADD", out)
self.assertIn("SKIP", out)
self.assertIn("INVALID", out)
self.assertIn("UPDATED", out)
self.assertIn("$CONCOURSEWORKERS", out)
@patch.object(soi, "update_overrides")
@patch.object(soi, "find_detection")
def test_no_import_note_preserves_note(self, mock_find, mock_update):
mock_find.return_value = ("doc1", "so-detection", [])
mock_update.return_value = {"result": "updated"}
self._write_file("1008", [
{"type": "suppress", "track": "by_src", "ip": "1.2.3.4", "note": "original"},
])
_, _, code = self._run_main("--no-import-note")
self.assertEqual(code, 0)
merged = mock_update.call_args[0][3]
self.assertEqual(merged[0]["note"], "original") # no prefix applied
@patch.object(soi, "find_detection")
def test_dry_run_skips_update(self, mock_find):
mock_find.return_value = ("doc1", "so-detection", [])
self._write_file("1009", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
with patch.object(soi, "update_overrides") as mock_update:
out, _, code = self._run_main("--dry-run")
self.assertEqual(code, 0)
mock_update.assert_not_called()
self.assertIn("DRY-RUN: would update", out)
@patch.object(soi, "update_overrides")
@patch.object(soi, "find_detection")
def test_update_http_error(self, mock_find, mock_update):
mock_find.return_value = ("doc1", "so-detection", [])
mock_update.side_effect = requests.HTTPError("nope")
self._write_file("1010", [{"type": "suppress", "track": "by_src", "ip": "1.2.3.4"}])
out, _, code = self._run_main()
self.assertEqual(code, 1)
self.assertIn("update failed", out)
if __name__ == "__main__":
unittest.main()
+231
View File
@@ -0,0 +1,231 @@
#!/opt/saltstack/salt/bin/python3
# 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.
"""
so-push-drainer
===============
Scheduled drainer for the active-push feature. Runs on the manager every
drain_interval seconds (default 15) via a salt schedule in salt/salt/push_drain_schedule.sls.
For each intent file under /opt/so/state/push_pending/*.json whose last_touch
is older than debounce_seconds, this script:
* concatenates the actions lists from every ready intent
* dedupes by (state or __highstate__, tgt, tgt_type)
* dispatches a single `salt-run state.orchestrate orch.push_batch --async`
with the deduped actions list passed as pillar kwargs
* deletes the contributed intent files on successful dispatch
Reactor sls files (push_suricata, push_strelka, push_pillar) write intents
but never dispatch directly
"""
import fcntl
import glob
import json
import logging
import logging.handlers
import os
import subprocess
import sys
import time
import salt.client
PENDING_DIR = '/opt/so/state/push_pending'
LOCK_FILE = os.path.join(PENDING_DIR, '.lock')
LOG_FILE = '/opt/so/log/salt/so-push-drainer.log'
HIGHSTATE_SENTINEL = '__highstate__'
def _make_logger():
logger = logging.getLogger('so-push-drainer')
logger.setLevel(logging.INFO)
if not logger.handlers:
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
handler = logging.handlers.RotatingFileHandler(
LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=3,
)
handler.setFormatter(logging.Formatter(
'%(asctime)s | %(levelname)s | %(message)s',
))
logger.addHandler(handler)
return logger
def _load_push_cfg():
"""Read the salt:auto_apply pillar subtree via salt-call. Returns a dict."""
caller = salt.client.Caller()
cfg = caller.cmd('pillar.get', 'salt:auto_apply', {})
return cfg if isinstance(cfg, dict) else {}
def _read_intent(path, log):
try:
with open(path, 'r') as f:
return json.load(f)
except (IOError, ValueError) as exc:
log.warning('cannot read intent %s: %s', path, exc)
return None
except Exception:
log.exception('unexpected error reading %s', path)
return None
def _dedupe_actions(actions):
seen = set()
deduped = []
for action in actions:
if not isinstance(action, dict):
continue
state_key = HIGHSTATE_SENTINEL if action.get('highstate') else action.get('state')
tgt = action.get('tgt')
tgt_type = action.get('tgt_type', 'compound')
if not state_key or not tgt:
continue
key = (state_key, tgt, tgt_type)
if key in seen:
continue
seen.add(key)
deduped.append(action)
return deduped
def _dispatch(actions, log):
pillar_arg = json.dumps({'actions': actions})
cmd = [
'salt-run',
'state.orchestrate',
'orch.push_batch',
'pillar={}'.format(pillar_arg),
'--async',
]
log.info('dispatching: %s', ' '.join(cmd[:3]) + ' pillar=<{} actions>'.format(len(actions)))
try:
result = subprocess.run(
cmd, check=True, capture_output=True, text=True, timeout=60,
)
except subprocess.CalledProcessError as exc:
log.error('dispatch failed (rc=%s): stdout=%s stderr=%s',
exc.returncode, exc.stdout, exc.stderr)
return False
except subprocess.TimeoutExpired:
log.error('dispatch timed out after 60s')
return False
except Exception:
log.exception('dispatch raised')
return False
log.info('dispatch accepted: %s', (result.stdout or '').strip())
return True
def main():
log = _make_logger()
if not os.path.isdir(PENDING_DIR):
# Nothing to do; reactors create the dir on first use.
return 0
try:
push = _load_push_cfg()
except Exception:
log.exception('failed to read salt:auto_apply pillar; aborting drain pass')
return 1
if not push.get('enabled', True):
log.debug('push disabled; exiting')
return 0
debounce_seconds = int(push.get('debounce_seconds', 30))
os.makedirs(PENDING_DIR, exist_ok=True)
lock_fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
intent_files = [
p for p in sorted(glob.glob(os.path.join(PENDING_DIR, '*.json')))
if os.path.basename(p) != '.lock'
]
if not intent_files:
return 0
now = time.time()
ready = []
skipped = 0
broken = []
for path in intent_files:
intent = _read_intent(path, log)
if not isinstance(intent, dict):
broken.append(path)
continue
last_touch = intent.get('last_touch', 0)
if now - last_touch < debounce_seconds:
skipped += 1
continue
ready.append((path, intent))
for path in broken:
try:
os.unlink(path)
except OSError:
pass
if not ready:
if skipped:
log.debug('no ready intents (%d still in debounce window)', skipped)
return 0
combined_actions = []
oldest_first_touch = now
all_paths = []
for path, intent in ready:
combined_actions.extend(intent.get('actions', []) or [])
first = intent.get('first_touch', now)
if first < oldest_first_touch:
oldest_first_touch = first
all_paths.extend(intent.get('paths', []) or [])
deduped = _dedupe_actions(combined_actions)
if not deduped:
log.warning('%d intent(s) had no usable actions; clearing', len(ready))
for path, _ in ready:
try:
os.unlink(path)
except OSError:
pass
return 0
debounce_duration = now - oldest_first_touch
log.info(
'draining %d intent(s): %d action(s) after dedupe (raw=%d), '
'debounce_duration=%.1fs, paths=%s',
len(ready), len(deduped), len(combined_actions),
debounce_duration, all_paths[:20],
)
if not _dispatch(deduped, log):
log.warning('dispatch failed; leaving intent files in place for retry')
return 1
for path, _ in ready:
try:
os.unlink(path)
except OSError:
log.exception('failed to remove drained intent %s', path)
return 0
finally:
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
finally:
os.close(lock_fd)
if __name__ == '__main__':
sys.exit(main())
+11
View File
@@ -10,5 +10,16 @@ NOROOT=1
set -e
curl --retry 5 --retry-delay 60 -A "reposync/$(sync_options)" https://sigs.securityonion.net/checkup --output /tmp/checkup
dnf reposync --norepopath -g --delete -m -c /opt/so/conf/reposync/repodownload.conf --repoid=securityonionsync --download-metadata -p /nsm/repo/
createrepo /nsm/repo
# The kernel repo section is deployed to repodownload.conf by the manager highstate, which
# runs AFTER this script during soup. On the first upgrade to a kernel-aware version the
# on-disk config still predates the section, so guard on its presence to avoid dnf's
# "Unknown repo: 'securityonionkernelsync'" aborting the sync (set -e). The next sync after the
# highstate deploys the section will pick it up.
if grep -q '^\[securityonionkernelsync\]' /opt/so/conf/reposync/repodownload.conf; then
dnf reposync --norepopath -g --delete -m -c /opt/so/conf/reposync/repodownload.conf --repoid=securityonionkernelsync --download-metadata -p /nsm/kernelrepo/
createrepo /nsm/kernelrepo
fi
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More